commit 51c16a1efcb036cc18e9cdb6d592e691b3da5816 Author: gedoor Date: Mon Jan 3 23:59:44 2022 +0800 fetch太慢去除历史提交记录 diff --git a/.github/ISSUE_TEMPLATE/01-bugReport.md b/.github/ISSUE_TEMPLATE/01-bugReport.md new file mode 100644 index 000000000..27219b16f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bugReport.md @@ -0,0 +1,52 @@ +--- +name: "[BUG] 问题提交模板" +about: 请从符号">"后面开始填写内容,填写内容可以参考 https://github.com/gedoor/legado/issues/505                     +title: "[BUG] " +labels: 'BUG' +assignees: '' +--- + + +### 机型(如Redmi K30 Pro) +> + + +### 安卓版本(如Android 7.1.1) +> + + +### 阅读Legdao版本(我的-关于-版本,如3.20.112220) +> + +### 网络环境(移动,联通,电信,移动宽带,联通宽带,电信宽带,等等..) +> + + +### 问题描述(简要描述发生的问题) +> + + +### 使用书源(填写URL或者JSON) +> + + +```json + + + + + + +``` + +### 复现步骤(详细描述导致问题产生的操作步骤,如果能稳定复现) +> + + + + +### 日志提交(问题截图或者日志) +> + + + diff --git a/.github/ISSUE_TEMPLATE/02-featureRequest.md b/.github/ISSUE_TEMPLATE/02-featureRequest.md new file mode 100644 index 000000000..9a09a80b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-featureRequest.md @@ -0,0 +1,19 @@ +--- +name: "[FeatureRequest] 功能请求模板" +about: 提交你希望能够在阅读中增加的功能 +title: "[Feature Request] " +labels: '需求' +assignees: '' +--- + +### 功能描述(请清晰的、详细的描述你想要的功能) +> + +### 期望实现方式(阅读应该如何实现该功能) +> + +### 附加信息(其他的与功能相关的附加信息) +> + +### 效果演示(可以手绘一些草图,或者提供可借鉴的图片) +> diff --git a/.github/scripts/lzy_web.py b/.github/scripts/lzy_web.py new file mode 100644 index 000000000..f39d4b172 --- /dev/null +++ b/.github/scripts/lzy_web.py @@ -0,0 +1,98 @@ +import requests, os, datetime, sys + +# Cookie 中 phpdisk_info 的值 +cookie_phpdisk_info = os.environ.get('phpdisk_info') +# Cookie 中 ylogin 的值 +cookie_ylogin = os.environ.get('ylogin') + +# 请求头 +headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36 Edg/89.0.774.45', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Referer': 'https://pc.woozooo.com/account.php?action=login' +} + +# 小饼干 +cookie = { + 'ylogin': cookie_ylogin, + 'phpdisk_info': cookie_phpdisk_info +} + + +# 日志打印 +def log(msg): + utc_time = datetime.datetime.utcnow() + china_time = utc_time + datetime.timedelta(hours=8) + print(f"[{china_time.strftime('%Y.%m.%d %H:%M:%S')}] {msg}") + + +# 检查是否已登录 +def login_by_cookie(): + url_account = "https://pc.woozooo.com/account.php" + if cookie['phpdisk_info'] is None: + log('ERROR: 请指定 Cookie 中 phpdisk_info 的值!') + return False + if cookie['ylogin'] is None: + log('ERROR: 请指定 Cookie 中 ylogin 的值!') + return False + res = requests.get(url_account, headers=headers, cookies=cookie, verify=True) + if '网盘用户登录' in res.text: + log('ERROR: 登录失败,请更新Cookie') + return False + else: + log('登录成功') + return True + + +# 上传文件 +def upload_file(file_dir, folder_id): + file_name = os.path.basename(file_dir) + url_upload = "https://up.woozooo.com/fileup.php" + headers['Referer'] = f'https://up.woozooo.com/mydisk.php?item=files&action=index&u={cookie_ylogin}' + post_data = { + "task": "1", + "folder_id": folder_id, + "id": "WU_FILE_0", + "name": file_name, + } + files = {'upload_file': (file_name, open(file_dir, "rb"), 'application/octet-stream')} + res = requests.post(url_upload, data=post_data, files=files, headers=headers, cookies=cookie, timeout=120).json() + log(f"{file_dir} -> {res['info']}") + return res['zt'] == 1 + + +# 上传文件夹内的文件 +def upload_folder(folder_dir, folder_id): + file_list = sorted(os.listdir(folder_dir), reverse=True) + for file in file_list: + path = os.path.join(folder_dir, file) + if os.path.isfile(path): + upload_file(path, folder_id) + else: + upload_folder(path, folder_id) + + +# 上传 +def upload(dir, folder_id): + if dir is None: + log('ERROR: 请指定上传的文件路径') + return + if folder_id is None: + log('ERROR: 请指定蓝奏云的文件夹id') + return + if os.path.isfile(dir): + upload_file(dir, str(folder_id)) + else: + upload_folder(dir, str(folder_id)) + + +if __name__ == '__main__': + argv = sys.argv[1:] + if len(argv) != 2: + log('ERROR: 参数错误,请以这种格式重新尝试\npython lzy_web.py 需上传的路径 蓝奏云文件夹id') + # 需上传的路径 + upload_path = argv[0] + # 蓝奏云文件夹id + lzy_folder_id = argv[1] + if login_by_cookie(): + upload(upload_path, lzy_folder_id) diff --git a/.github/scripts/tg_bot.py b/.github/scripts/tg_bot.py new file mode 100644 index 000000000..88a3a8bb5 --- /dev/null +++ b/.github/scripts/tg_bot.py @@ -0,0 +1,47 @@ +import os, sys, telebot + +# 上传文件 +def upload_file(tb, chat_id, file_dir): + doc = open(file_dir, 'rb') + tb.send_document(chat_id, doc) + +# 上传文件夹内的文件 +def upload_folder(tb, chat_id, folder_dir): + file_list = sorted(os.listdir(folder_dir)) + for file in file_list: + path = os.path.join(folder_dir, file) + if os.path.isfile(path): + upload_file(tb, chat_id, path) + else: + upload_folder(tb, chat_id, path) + +# 上传 +def upload(tb, chat_id, dir): + if tb is None: + log('ERROR: 输入正确的token') + return + if chat_id is None: + log('ERROR: 输入正确的chat_id') + return + if dir is None: + log('ERROR: 请指定上传的文件路径') + return + if os.path.isfile(dir): + upload_file(tb, chat_id, dir) + else: + upload_folder(tb, chat_id, dir) + +if __name__ == '__main__': + argv = sys.argv[1:] + if len(argv) != 3: + log('ERROR: 参数错误,请以这种格式重新尝试\npython tg_bot.py $token $chat_id 待上传的路径') + # Token + TOKEN = argv[0] + # chat_id + chat_id = argv[1] + # 待上传文件的路径 + upload_path = argv[2] + #创建连接 + tb = telebot.TeleBot(TOKEN) + #开始上传 + upload(tb, chat_id, upload_path) diff --git a/.github/workflows/autoupdatefork.yml b/.github/workflows/autoupdatefork.yml new file mode 100644 index 000000000..9fa5d6425 --- /dev/null +++ b/.github/workflows/autoupdatefork.yml @@ -0,0 +1,32 @@ +#更新fork +name: update fork + +on: + schedule: + - cron: '0 16 * * *' #设置定时任务 + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.event.repository.owner.id == github.event.sender.id && github.actor != 'gedoor' }} + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Install git + run: | + sudo apt-get update + sudo apt-get -y install git + - name: Set env + run: | + git config --global user.email "github-actions@github.com" + git config --global user.name "github-actions" + - name: Update fork + run: | + git remote add upstream https://github.com/gedoor/legado.git + git remote -v + git fetch upstream + git checkout master + git merge upstream/master + git push origin master diff --git a/.github/workflows/legado.jks b/.github/workflows/legado.jks new file mode 100644 index 000000000..89ea725fd Binary files /dev/null and b/.github/workflows/legado.jks differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..04be02dfb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Build and Release + +on: + push: + branches: + - master + paths: + - 'CHANGELOG.md' +# schedule: +# - cron: '0 4 * * *' + +jobs: + build: + runs-on: ubuntu-latest + env: + # 登录蓝奏云后在控制台运行document.cookie + ylogin: ${{ secrets.LANZOU_ID }} + phpdisk_info: ${{ secrets.LANZOU_PSD }} + # 蓝奏云里的文件夹ID(阅读3测试版:2670621) + LANZOU_FOLDER_ID: 'b0f7pt4ja' + # 是否上传到artifact + UPLOAD_ARTIFACT: 'true' + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-legado-${{ hashFiles('**/updateLog.md') }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-legado-${{ hashFiles('**/updateLog.md') }}- + - name: Release Apk Sign + run: | + echo "给apk增加签名" + cp $GITHUB_WORKSPACE/.github/workflows/legado.jks $GITHUB_WORKSPACE/app/legado.jks + sed '$a\RELEASE_STORE_FILE=./legado.jks' $GITHUB_WORKSPACE/gradle.properties -i + sed '$a\RELEASE_KEY_ALIAS=legado' $GITHUB_WORKSPACE/gradle.properties -i + sed '$a\RELEASE_STORE_PASSWORD=gedoor_legado' $GITHUB_WORKSPACE/gradle.properties -i + sed '$a\RELEASE_KEY_PASSWORD=gedoor_legado' $GITHUB_WORKSPACE/gradle.properties -i + - name: Unify Version Name + run: | + echo "统一版本号" + VERSION=$(date -d "8 hour" -u +3.%y.%m%d%H) + echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV + sed "/def version/c def version = \"$VERSION\"" $GITHUB_WORKSPACE/app/build.gradle -i + - name: Build With Gradle + run: | + echo "开始进行release构建" + chmod +x gradlew + ./gradlew assembleRelease --build-cache --parallel + - name: Organize the Files + run: | + mkdir -p ${{ github.workspace }}/apk/ + cp -rf ${{ github.workspace }}/app/build/outputs/apk/*/*/*.apk ${{ github.workspace }}/apk/ + - name: Upload App To Artifact + if: ${{ env.UPLOAD_ARTIFACT != 'false' }} + uses: actions/upload-artifact@v2 + with: + name: legado apk + path: ${{ github.workspace }}/apk/*.apk + - name: Upload App To Lanzou + if: ${{ env.ylogin }} + run: | + path="$GITHUB_WORKSPACE/apk/" + python3 $GITHUB_WORKSPACE/.github/scripts/lzy_web.py "$path" "$LANZOU_FOLDER_ID" + echo "[$(date -u -d '+8 hour' '+%Y.%m.%d %H:%M:%S')] 分享链接: https://kunfei.lanzoux.com/b0f7pt4ja" + - name: Release + uses: softprops/action-gh-release@59c3b4891632ff9a897f99a91d7bc557467a3a22 + with: + name: legado_app_${{ env.RELEASE_VERSION }} + tag_name: ${{ env.RELEASE_VERSION }} + body_path: ${{ github.workspace }}/CHANGELOG.md + draft: false + prerelease: false + files: ${{ github.workspace }}/apk/*.apk + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Push Assets To "release" Branch + run: | + cd $GITHUB_WORKSPACE/apk || exit 1 + git init + git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b release + git add *.apk + git commit -m "${{ env.RELEASE_VERSION }}" + git remote add origin "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}" + git push -f -u origin release + - name: Purge Jsdelivr Cache + run: | + result=$(curl -s https://purge.jsdelivr.net/gh/${{ github.repository }}@release/) + if echo $result |grep -q 'success.*true'; then + echo "jsdelivr缓存更新成功" + else + echo $result + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..3f6441b98 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,161 @@ +name: Test Build + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set-ver.outputs.version }} + versionL: ${{ steps.set-ver.outputs.versionL }} + lanzou: ${{ steps.check.outputs.lanzou }} + telegram: ${{ steps.check.outputs.telegram }} + steps: + - id: set-ver + run: | + echo "::set-output name=version::$(date -d "8 hour" -u +3.%y.%m%d%H)" + echo "::set-output name=versionL::$(date -d "8 hour" -u +3.%y.%m%d%H%M)" + - id: check + run: | + if [ ${{ secrets.LANZOU_ID }} ]; then + echo "::set-output name=lanzou::yes" + fi + if [ ${{ secrets.BOT_TOKEN }} ]; then + echo "::set-output name=telegram::yes" + fi + + build: + needs: prepare + strategy: + matrix: + product: [app, google] + type: [release, releaseA] + fail-fast: false + runs-on: ubuntu-latest + env: + product: ${{ matrix.product }} + type: ${{ matrix.type }} + VERSION: ${{ needs.prepare.outputs.version }} + VERSIONL: ${{ needs.prepare.outputs.versionL }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-legado-${{ hashFiles('**/updateLog.md') }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-legado-${{ hashFiles('**/updateLog.md') }}- + - name: Clear 18PlusList.txt + run: | + echo "清空18PlusList.txt" + echo "">$GITHUB_WORKSPACE/app/src/main/assets/18PlusList.txt + - name: Release Apk Sign + run: | + echo "给apk增加签名" + cp $GITHUB_WORKSPACE/.github/workflows/legado.jks $GITHUB_WORKSPACE/app/legado.jks + sed '$a\RELEASE_STORE_FILE=./legado.jks' $GITHUB_WORKSPACE/gradle.properties -i + sed '$a\RELEASE_KEY_ALIAS=legado' $GITHUB_WORKSPACE/gradle.properties -i + sed '$a\RELEASE_STORE_PASSWORD=gedoor_legado' $GITHUB_WORKSPACE/gradle.properties -i + sed '$a\RELEASE_KEY_PASSWORD=gedoor_legado' $GITHUB_WORKSPACE/gradle.properties -i + - name: Build With Gradle + run: | + if [ ${{ env.type }} == 'release' ]; then + typeName="原包名" + else + typeName="共存" + sed "s/'.release'/'.releaseA'/" $GITHUB_WORKSPACE/app/build.gradle -i + sed 's/.release/.releaseA/' $GITHUB_WORKSPACE/app/google-services.json -i + fi + echo "统一版本号" + sed "/def version/c def version = \"${{ env.VERSION }}\"" $GITHUB_WORKSPACE/app/build.gradle -i + echo "开始${{ env.product }}$typeName构建" + chmod +x gradlew + ./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all + echo "修改文件名" + mkdir -p ${{ github.workspace }}/apk/ + for file in `ls ${{ github.workspace }}/app/build/outputs/apk/*/*/*.apk`; do + mv "$file" ${{ github.workspace }}/apk/legado_${{ env.product }}_${{ env.VERSIONL }}_$typeName.apk + done + - name: Upload App To Artifact + uses: actions/upload-artifact@v2 + with: + name: legado.${{ env.product }}.${{ env.type }} + path: ${{ github.workspace }}/apk/*.apk + + lanzou: + needs: [prepare, build] + if: ${{ needs.prepare.outputs.lanzou }} + runs-on: ubuntu-latest + env: + # 登录蓝奏云后在控制台运行document.cookie + ylogin: ${{ secrets.LANZOU_ID }} + phpdisk_info: ${{ secrets.LANZOU_PSD }} + # 蓝奏云里的文件夹ID(阅读3测试版:2670621) + LANZOU_FOLDER_ID: '2670621' + steps: + - uses: actions/checkout@v2 + - uses: actions/download-artifact@v2 + with: + path: apk/ + - working-directory: apk/ + run: mv */*.apk . ;rm -rf */ + - name: Upload To Lanzou + continue-on-error: true + run: | + path="$GITHUB_WORKSPACE/apk/" + python3 $GITHUB_WORKSPACE/.github/scripts/lzy_web.py "$path" "$LANZOU_FOLDER_ID" + echo "[$(date -u -d '+8 hour' '+%Y.%m.%d %H:%M:%S')] 分享链接: https://kunfei.lanzoux.com/b0f810h4b" + + test_Branch: + needs: [prepare, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/download-artifact@v2 + with: + path: apk/ + - working-directory: apk/ + run: mv */*.apk . ;rm -rf */ + - name: Push To "test" Branch + run: | + cd $GITHUB_WORKSPACE/apk + git init + git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b test + git add *.apk + git commit -m "${{ needs.prepare.outputs.versionL }}" + git remote add origin "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}" + git push -f -u origin test + + telegram: + needs: [prepare, build] + if: ${{ needs.prepare.outputs.telegram }} + runs-on: ubuntu-latest + env: + CHANNEL_ID: ${{ secrets.CHANNEL_ID }} + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + steps: + - uses: actions/checkout@v2 + - uses: actions/download-artifact@v2 + with: + path: apk/ + - working-directory: apk/ + run: | + for file in `ls */*.apk`; do + mv "$file" "$(echo "$file"|sed -e 's#.*\/##g' -e "s/_/ /g" -e 's/legado/阅读/')" + done + rm -rf */ + - name: Post to channel + run: | + pip install pyTelegramBotAPI + path="$GITHUB_WORKSPACE/apk/" + python3 $GITHUB_WORKSPACE/.github/scripts/tg_bot.py "$BOT_TOKEN" "$CHANNEL_ID" "$path" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0b089a7d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +.DS_Store +/build +/captures +.externalNativeBuild +/release +/tmp +node_modules/ +/app/app +/app/google +/app/gradle.properties +package-lock.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..3a9e1aca5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,14 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Custom ignored +/caches/ +/dictionaries/ +/modules/ +/libraries/ +/*.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..d37ddf6e9 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,125 @@ + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..273ec41c2 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6cae673ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +**2021/08/09** + +1. 修复选择文字不能选择单个文字的bug +2. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 000000000..4b0e204cf --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +[![icon_android](https://github.com/gedoor/gedoor.github.io/blob/master/images/icon_android.png)](https://play.google.com/store/apps/details?id=io.legado.play.release) + + # + + +
+legado + +Legado / 开源阅读 +
+gedoor.github.io / legado.top +
+Legado is a free and open source novel reader for Android. +
+ +[![](https://img.shields.io/badge/-Contents:-696969.svg)](#contents) [![](https://img.shields.io/badge/-Function-F5F5F5.svg)](#Function-主要功能-) [![](https://img.shields.io/badge/-Download-F5F5F5.svg)](#Download-下载-) [![](https://img.shields.io/badge/-Community-F5F5F5.svg)](#Community-交流社区-) [![](https://img.shields.io/badge/-API-F5F5F5.svg)](#API-) [![](https://img.shields.io/badge/-Other-F5F5F5.svg)](#Other-其他-) [![](https://img.shields.io/badge/-Grateful-F5F5F5.svg)](#Grateful-感谢-) [![](https://img.shields.io/badge/-Interface-F5F5F5.svg)](#Interface-界面-) + +>新用户? +> +>软件不提供内容,需要您自己手动添加,例如导入书源等。 +>看看 [官方帮助文档](https://www.yuque.com/legado/wiki),也许里面就有你要的答案。 + +# Function-主要功能 [![](https://img.shields.io/badge/-Function-F5F5F5.svg)](#Function-主要功能-) +
English +1. Online reading from a variety of sources.
+2. Local reading of downloaded content.
+3. A configurable reader with multiple viewers, reading directions and other settings.
+4. Categories to organize your library.
+5. Light and dark themes.
+6. Schedule updating your library for new chapters.
+7. read offline or to your desired cloud service +
+ +
中文 +1.自定义书源,自己设置规则,抓取网页数据,规则简单易懂,软件内有规则说明。
+2.列表书架,网格书架自由切换。
+3.书源规则支持搜索及发现,所有找书看书功能全部自定义,找书更方便。
+4.订阅内容,可以订阅想看的任何内容,看你想看
+5.支持替换净化,去除广告替换内容很方便。
+6.支持本地TXT、EPUB阅读,手动浏览,智能扫描。
+7.支持高度自定义阅读界面,切换字体、颜色、背景、行距、段距、加粗、简繁转换等。
+8.支持多种翻页模式,覆盖、仿真、滑动、滚动等。
+9.软件开源,持续优化,无广告。 +
+ + + # + + +# Download-下载 [![](https://img.shields.io/badge/-Download-F5F5F5.svg)](#Download-下载-) +#### Android-安卓 +* [Releases](https://github.com/gedoor/legado/releases/latest) +* [Google play - $1.99](https://play.google.com/store/apps/details?id=io.legado.play.release) +* [Coolapk](https://www.coolapk.com/apk/io.legado.app.release) +* [Jsdelivr](https://cdn.jsdelivr.net/gh/gedoor/legado@release/) +* [\#Beta](https://kunfei.lanzoui.com/b0f810h4b) + + +#### IOS-苹果 +* 准备中(No release) - [Github](https://github.com/gedoor/YueDuFlutter) + + + # + + +# Community-交流社区 [![](https://img.shields.io/badge/-Community-F5F5F5.svg)](#Community-交流社区-) + +#### Telegram +[![Telegram-group](https://img.shields.io/badge/Telegram-%E7%BE%A4%E7%BB%84-blue)](https://t.me/yueduguanfang) [![Telegram-channel](https://img.shields.io/badge/Telegram-%E9%A2%91%E9%81%93-blue)](https://t.me/legado_channels) + +#### Discord +[![Discord](https://img.shields.io/discord/560731361414086666?color=%235865f2&label=Discord)](https://discord.gg/VtUfRyzRXn) + +#### Other +https://www.yuque.com/legado/wiki/community + + + # + + +# API [![](https://img.shields.io/badge/-API-F5F5F5.svg)](#API-) +* 阅读3.0 提供了2种方式的API:`Web方式`和`Content Provider方式`。您可以在[这里](api.md)根据需要自行调用。 +* 可通过url唤起阅读进行一键导入,url格式: legado://import/{path}?src={url} +* path类型: bookSource,rssSource,replaceRule,textTocRule,httpTTS,theme,readConfig +* path类型解释: 书源,订阅源,替换规则,本地txt小说目录规则,在线朗读引擎,主题,阅读排版 + + + # + + +# Other-其他 [![](https://img.shields.io/badge/-Other-F5F5F5.svg)](#Other-其他-) +##### 免责声明 +https://gedoor.github.io/about.html +##### 阅读3.0 +* [书源规则](https://alanskycn.gitee.io/teachme) +* [更新日志](/app/src/main/assets/updateLog.md) +* [帮助文档](/app/src/main/assets/help/appHelp.md) +* [web端](https://github.com/celetor/web-yuedu3) + + + # + + +# Grateful-感谢 [![](https://img.shields.io/badge/-Grateful-F5F5F5.svg)](#Grateful-感谢-) +> * org.jsoup:jsoup +> * cn.wanghaomiao:JsoupXpath +> * com.jayway.jsonpath:json-path +> * com.github.gedoor:rhino-android +> * com.squareup.okhttp3:okhttp +> * com.github.bumptech.glide:glide +> * org.nanohttpd:nanohttpd +> * org.nanohttpd:nanohttpd-websocket +> * cn.bingoogolapple:bga-qrcode-zxing +> * com.jaredrummler:colorpicker +> * org.apache.commons:commons-text +> * io.noties.markwon:core +> * io.noties.markwon:image-glide +> * com.hankcs:hanlp +> * com.positiondev.epublib:epublib-core + + # + + +# Interface-界面 [![](https://img.shields.io/badge/-Interface-F5F5F5.svg)](#Interface-界面-) + + + + + # + diff --git a/api.md b/api.md new file mode 100644 index 000000000..4096516be --- /dev/null +++ b/api.md @@ -0,0 +1,194 @@ +# 阅读API +## 对于Web的配置 +您需要先在设置中启用"Web 服务"。 +## 使用 +### Web +以下说明假设您的操作在本机进行,且开放端口为1234。 +如果您要从远程计算机访问[阅读](),请将`127.0.0.1`替换成手机IP。 +#### 插入单个书源 +``` +URL = http://127.0.0.1:1234/saveSource +Method = POST +``` + +请求BODY内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookSource.kt) + +#### 插入多个书源or订阅源 + +``` +URL = http://127.0.0.1:1234/saveBookSources +URL = http://127.0.0.1:1234/saveRssSources +Method = POST +``` + +请求BODY内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 + +#### 获取书源 + +``` +URL = http://127.0.0.1:1234/getBookSource?url=xxx +URL = http://127.0.0.1:1234/getRssSource?url=xxx +Method = GET +``` + +#### 获取所有书源or订阅源 + +``` +URL = http://127.0.0.1:1234/getBookSources +URL = http://127.0.0.1:1234/getRssSources +Method = GET +``` + +#### 删除多个书源or订阅源 + +``` +URL = http://127.0.0.1:1234/deleteBookSources +URL = http://127.0.0.1:1234/deleteRssSources +Method = POST +``` + +请求BODY内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 + +#### 插入书籍 +``` +URL = http://127.0.0.1:1234/saveBook +Method = POST +``` + +请求BODY内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/Book.kt)。 + +#### 获取所有书籍 +``` +URL = http://127.0.0.1:1234/getBookshelf +Method = GET +``` + +获取APP内的所有书籍。 + +#### 获取书籍章节列表 +``` +URL = http://127.0.0.1:1234/getChapterList?url=xxx +Method = GET +``` + +获取指定图书的章节列表。 + +#### 获取书籍内容 +``` +URL = http://127.0.0.1:1234/getBookContent?url=xxx&index=1 +Method = GET +``` +获取指定图书的第`index`章节的文本内容。 + +#### 获取封面 +``` +URL = http://127.0.0.1:1234/cover?path=xxxxx +Method = GET +``` + + +### Content Provider +* 需声明`io.legado.READ_WRITE`权限 +* `providerHost`为`包名.readerProvider`, 如`io.legado.app.release.readerProvider`,不同包的地址不同,防止冲突安装失败 +* 以下出现的`providerHost`请自行替换 + +#### 插入单个书源or订阅源 + +``` +URL = content://providerHost/bookSource/insert +URL = content://providerHost/rssSource/insert +Method = insert +``` + +创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookSource.kt) + +#### 插入多个书源or订阅源 + +``` +URL = content://providerHost/bookSources/insert +URL = content://providerHost/rssSources/insert +Method = insert +``` + +创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 + +#### 获取书源or订阅源 + +``` +URL = content://providerHost/bookSource/query?url=xxx +URL = content://providerHost/rssSource/query?url=xxx +Method = query +``` + +获取指定URL对应的书源信息。 +用`Cursor.getString(0)`取出返回结果。 + +#### 获取所有书源or订阅源 + +``` +URL = content://providerHost/bookSources/query +URL = content://providerHost/rssSources/query +Method = query +``` + +获取APP内的所有书源。 +用`Cursor.getString(0)`取出返回结果。 + +#### 删除多个书源or订阅源 + +``` +URL = content://providerHost/bookSources/delete +URL = content://providerHost/rssSources/delete +Method = delete +``` + +创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/BookSource.kt),**为数组格式**。 + +#### 插入书籍 +``` +URL = content://providerHost/book/insert +Method = insert +``` + +创建`Key="json"`的`ContentValues`,内容为`JSON`字符串, +格式参考[这个文件](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/data/entities/Book.kt)。 + +#### 获取所有书籍 +``` +URL = content://providerHost/books/query +Method = query +``` + +获取APP内的所有书籍。 +用`Cursor.getString(0)`取出返回结果。 + +#### 获取书籍章节列表 +``` +URL = content://providerHost/book/chapter/query?url=xxx +Method = query +``` + +获取指定图书的章节列表。 +用`Cursor.getString(0)`取出返回结果。 + +#### 获取书籍内容 + +``` +URL = content://providerHost/book/content/query?url=xxx&index=1 +Method = query +``` +获取指定图书的第`index`章节的文本内容。 +用`Cursor.getString(0)`取出返回结果。 + +#### 获取封面 +``` +URL = content://providerHost/book/cover/query?path=xxxx +Method = query +``` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..e431c26b5 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/so \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..3417d1362 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,227 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' +apply plugin: 'kotlin-kapt' +apply plugin: 'de.timfreiheit.resourceplaceholders' +apply from: 'download.gradle' + +static def releaseTime() { + return new Date().format("yy.MMddHH", TimeZone.getTimeZone("GMT+8")) +} + +def name = "legado" +def version = "3." + releaseTime() +def gitCommits = Integer.parseInt('git rev-list --count HEAD'.execute([], project.rootDir).text.trim()) + +android { + compileSdkVersion 32 + buildToolsVersion '32.0.0' + kotlinOptions { + jvmTarget = "11" + } + signingConfigs { + if (project.hasProperty("RELEASE_STORE_FILE")) { + myConfig { + storeFile file(RELEASE_STORE_FILE) + storePassword RELEASE_STORE_PASSWORD + keyAlias RELEASE_KEY_ALIAS + keyPassword RELEASE_KEY_PASSWORD + v1SigningEnabled true + v2SigningEnabled true + } + } + } + defaultConfig { + applicationId "io.legado.app" + minSdkVersion 21 + targetSdkVersion 32 + versionCode gitCommits + versionName version + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + project.ext.set("archivesBaseName", name + "_" + version) + multiDexEnabled true + + javaCompileOptions { + annotationProcessorOptions { + arguments += [ + "room.incremental" : "true", + "room.expandProjection": "true", + "room.schemaLocation" : "$projectDir/schemas".toString() + ] + } + } + } + buildFeatures { + viewBinding true + } + buildTypes { + release { + buildConfigField "String", "Cronet_Version", "\"$CronetVersion\"" + if (project.hasProperty("RELEASE_STORE_FILE")) { + signingConfig signingConfigs.myConfig + } + applicationIdSuffix '.release' + + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + buildConfigField "String", "Cronet_Version", "\"$CronetVersion\"" + if (project.hasProperty("RELEASE_STORE_FILE")) { + signingConfig signingConfigs.myConfig + } + applicationIdSuffix '.debug' + versionNameSuffix 'debug' + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + android.applicationVariants.all { variant -> + variant.outputs.all { + def flavor = variant.productFlavors[0].name + outputFileName = "${name}_${flavor}_${defaultConfig.versionName}.apk" + } + } + } + flavorDimensions "mode" + productFlavors { + app { + dimension "mode" + manifestPlaceholders = [APP_CHANNEL_VALUE: "app"] + } + google { + dimension "mode" + applicationId "io.legado.play" + manifestPlaceholders = [APP_CHANNEL_VALUE: "google"] + } + } + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 11 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + sourceSets { + // Adds exported schema location as test app assets. + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } + tasks.withType(JavaCompile) { + //options.compilerArgs << "-Xlint:unchecked" + } +} + +resourcePlaceholders { + files = ['xml/shortcuts.xml'] +} + +kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } +} + +dependencies { + coreLibraryDesugaring('com.android.tools:desugar_jdk_libs:1.1.5') + testImplementation('junit:junit:4.13.2') + androidTestImplementation('androidx.test:runner:1.4.0') + androidTestImplementation('androidx.test.espresso:espresso-core:3.4.0') + implementation('androidx.multidex:multidex:2.0.1') + //kotlin + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") + //Kotlin反射 + implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlin_version") + //协程 + def coroutines_version = '1.6.0' + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version") + + //androidX + implementation('androidx.core:core-ktx:1.7.0') + implementation('androidx.appcompat:appcompat:1.4.0') + implementation('androidx.activity:activity-ktx:1.4.0') + implementation('androidx.fragment:fragment-ktx:1.4.0') + implementation('androidx.preference:preference-ktx:1.1.1') + implementation('androidx.constraintlayout:constraintlayout:2.1.2') + implementation('androidx.swiperefreshlayout:swiperefreshlayout:1.1.0') + implementation('androidx.viewpager2:viewpager2:1.0.0') + implementation('com.google.android.material:material:1.4.0') + implementation('com.google.android.flexbox:flexbox:3.0.0') + implementation('com.google.code.gson:gson:2.8.9') + implementation('androidx.webkit:webkit:1.4.0') + + implementation('com.jakewharton.timber:timber:5.0.1') + + //media + implementation("androidx.media:media:1.4.3") + def exoplayer_version = '2.16.1' + implementation("com.google.android.exoplayer:exoplayer-core:$exoplayer_version") + implementation("com.google.android.exoplayer:extension-okhttp:$exoplayer_version") + + //Splitties + def splitties_version = '3.0.0' + implementation("com.louiscad.splitties:splitties-appctx:$splitties_version") + implementation("com.louiscad.splitties:splitties-systemservices:$splitties_version") + implementation("com.louiscad.splitties:splitties-views:$splitties_version") + + //lifecycle + def lifecycle_version = '2.4.0' + implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycle_version") + + //room + def room_version = '2.4.0' + implementation("androidx.room:room-runtime:$room_version") + implementation("androidx.room:room-ktx:$room_version") + kapt("androidx.room:room-compiler:$room_version") + testImplementation("androidx.room:room-testing:$room_version") + + //liveEventBus + implementation('io.github.jeremyliao:live-event-bus-x:1.8.0') + + //规则相关 + implementation('org.jsoup:jsoup:1.14.3') + implementation('com.jayway.jsonpath:json-path:2.6.0') + implementation('cn.wanghaomiao:JsoupXpath:2.5.1') + implementation(project(path: ':epublib')) + + //JS rhino + implementation('com.github.gedoor:rhino-android:1.6') + + //网络 + implementation('com.squareup.okhttp3:okhttp:4.9.3') + implementation(fileTree(dir: 'cronetlib', include: ['*.jar', '*.aar'])) + + //Glide + implementation('com.github.bumptech.glide:glide:4.12.0') + kapt('com.github.bumptech.glide:compiler:4.12.0') + + //webServer + def nanoHttpdVersion = "2.3.1" + implementation("org.nanohttpd:nanohttpd:$nanoHttpdVersion") + implementation("org.nanohttpd:nanohttpd-websocket:$nanoHttpdVersion") + + //二维码 + implementation('com.github.jenly1314:zxing-lite:2.1.1') + + //颜色选择 + implementation('com.jaredrummler:colorpicker:1.1.0') + + //apache + implementation('org.apache.commons:commons-text:1.9') + + //MarkDown + def markwonVersion = "4.6.2" + implementation("io.noties.markwon:core:$markwonVersion") + implementation("io.noties.markwon:image-glide:$markwonVersion") + implementation("io.noties.markwon:ext-tables:$markwonVersion") + implementation("io.noties.markwon:html:$markwonVersion") + + //转换繁体 + implementation('com.github.liuyueyi.quick-chinese-transfer:quick-transfer-core:0.2.3') + + //代码编辑com.github.AmrDeveloper:CodeView已集成到应用内 + //epubLib集成到应用内 + + // LeakCanary + //debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' +} diff --git a/app/cronetlib/cronet_api.jar b/app/cronetlib/cronet_api.jar new file mode 100644 index 000000000..6343c5224 Binary files /dev/null and b/app/cronetlib/cronet_api.jar differ diff --git a/app/cronetlib/cronet_impl_common_java.jar b/app/cronetlib/cronet_impl_common_java.jar new file mode 100644 index 000000000..4cdf36014 Binary files /dev/null and b/app/cronetlib/cronet_impl_common_java.jar differ diff --git a/app/cronetlib/cronet_impl_native_java.jar b/app/cronetlib/cronet_impl_native_java.jar new file mode 100644 index 000000000..aecbf6d77 Binary files /dev/null and b/app/cronetlib/cronet_impl_native_java.jar differ diff --git a/app/cronetlib/cronet_impl_platform_java.jar b/app/cronetlib/cronet_impl_platform_java.jar new file mode 100644 index 000000000..6adb23853 Binary files /dev/null and b/app/cronetlib/cronet_impl_platform_java.jar differ diff --git a/app/download.gradle b/app/download.gradle new file mode 100644 index 000000000..ec479c2c2 --- /dev/null +++ b/app/download.gradle @@ -0,0 +1,110 @@ +import java.security.MessageDigest + +apply plugin: 'de.undercouch.download' + +def BASE_PATH = "https://storage.googleapis.com/chromium-cronet/android/" + CronetVersion + "/Release/cronet/" +def assetsDir = projectDir.toString() + "/src/main/assets" +def libPath = projectDir.toString() + "/cronetlib" +def soPath = projectDir.toString() + "/so" + +/** + * 从文件生成MD5 + * @param file + * @return + */ +static def generateMD5(final file) { + MessageDigest digest = MessageDigest.getInstance("MD5") + file.withInputStream() { is -> + byte[] buffer = new byte[1024] + int numRead = 0 + while ((numRead = is.read(buffer)) > 0) { + digest.update(buffer, 0, numRead) + } + } + return String.format("%032x", new BigInteger(1, digest.digest())).toLowerCase() +} + +/** + * 下载Cronet相关的jar + */ +task downloadJar(type: Download) { + src([ + BASE_PATH + "cronet_api.jar", + BASE_PATH + "cronet_impl_common_java.jar", + BASE_PATH + "cronet_impl_native_java.jar", + BASE_PATH + "cronet_impl_platform_java.jar", + ]) + dest libPath + overwrite true + onlyIfModified true +} +/** + * 下载Cronet的arm64-v8a so + */ +task downloadARM64(type: Download) { + src BASE_PATH + "libs/arm64-v8a/libcronet." + CronetVersion + ".so" + dest soPath + "/arm64-v8a.so" + overwrite true + onlyIfModified true +} +/** + * 下载Cronet的armeabi-v7a so + */ +task downloadARMv7(type: Download) { + src BASE_PATH + "libs/armeabi-v7a/libcronet." + CronetVersion + ".so" + dest soPath + "/armeabi-v7a.so" + overwrite true + onlyIfModified true +} +/** + * 下载Cronet的x86_64 so + */ +task downloadX86_64(type: Download) { + src BASE_PATH + "libs/x86_64/libcronet." + CronetVersion + ".so" + dest soPath + "/x86_64.so" + overwrite true + onlyIfModified true +} +/** + * 下载Cronet的x86 so + */ +task downloadX86(type: Download) { + src BASE_PATH + "libs/x86/libcronet." + CronetVersion + ".so" + dest soPath + "/x86.so" + overwrite true + onlyIfModified true +} + +/** + * 更新Cronet版本时执行这个task + * 先更改gradle.properties 里面的版本号,然后再执行 + * gradlew app:downloadCronet + */ +task downloadCronet() { + dependsOn downloadJar, downloadARM64, downloadARMv7, downloadX86_64, downloadX86 + + doLast { + StringBuilder sb = new StringBuilder("{") + def files = new File(soPath).listFiles() + for (File file : files) { + println file.name.replace(".so", "") + sb.append("\"").append(file.name.replace(".so", "")).append("\":\"").append(generateMD5(file)).append("\",") + } + sb.append("\"version\":\"").append(CronetVersion).append("\"}") + + println sb.toString() + + println assetsDir + def f1 = new File(assetsDir + "/cronet.json") + if (!f1.exists()) { + f1.parentFile.mkdirs() + f1.createNewFile() + } + f1.text = sb.toString() + + } + + +} + + diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..fcbc11f0d --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,151 @@ +{ + "project_info": { + "project_number": "453392274790", + "firebase_url": "https://legado-fca69.firebaseio.com", + "project_id": "legado-fca69", + "storage_bucket": "legado-fca69.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:453392274790:android:c4eac14b1410eec5f624a7", + "android_client_info": { + "package_name": "io.legado.app.debug" + } + }, + "oauth_client": [ + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:453392274790:android:c1481c1c3d3f51eff624a7", + "android_client_info": { + "package_name": "io.legado.app.release" + } + }, + "oauth_client": [ + { + "client_id": "453392274790-trrgennt5njr1lhil1sgtf0ogcgd38fo.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.legado.app.release", + "certificate_hash": "fd67dba87b7b761631266f19ddde249054aac5c1" + } + }, + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:453392274790:android:b891abd2331577dff624a7", + "android_client_info": { + "package_name": "io.legado.play.release" + } + }, + "oauth_client": [ + { + "client_id": "453392274790-f8sjn6ohs72rg1dvp0pdvk42nkq54p0k.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.legado.play.release", + "certificate_hash": "00819ace9891386e535967cbafd6a88f3797bd5b" + } + }, + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:453392274790:android:b891abd2331577dff624a7", + "android_client_info": { + "package_name": "io.legado.play.debug" + } + }, + "oauth_client": [ + { + "client_id": "453392274790-f8sjn6ohs72rg1dvp0pdvk42nkq54p0k.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.legado.play.debug", + "certificate_hash": "00819ace9891386e535967cbafd6a88f3797bd5b" + } + }, + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyD90mfNLhA7cAzzI9SonpSz5mrF5BnmyJA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "453392274790-hnbpatpce9hbjiggj76hgo7queu86atq.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..a2949e6bf --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,231 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +# 混合时不使用大小写混合,混合后的类名为小写 +-dontusemixedcaseclassnames + +# 指定不去忽略非公共库的类 +-dontskipnonpubliclibraryclasses + +# 这句话能够使我们的项目混淆后产生映射文件 +# 包含有类名->混淆后类名的映射关系 +-verbose + +# 指定不去忽略非公共库的类成员 +-dontskipnonpubliclibraryclassmembers + +# 不做预校验,preverify是proguard的四个步骤之一,Android不需要preverify,去掉这一步能够加快混淆速度。 +-dontpreverify + +# 保留Annotation不混淆 +-keepattributes *Annotation*,InnerClasses + +# 避免混淆泛型 +-keepattributes Signature + +# 抛出异常时保留代码行号 +-keepattributes SourceFile,LineNumberTable + +# 指定混淆是采用的算法,后面的参数是一个过滤器 +# 这个过滤器是谷歌推荐的算法,一般不做更改 +-optimizations !code/simplification/cast,!field/*,!class/merging/* + + +############################################# +# +# Android开发中一些需要保留的公共部分 +# +############################################# +# 屏蔽错误Unresolved class name +#noinspection ShrinkerUnresolvedReference + +# 保留我们使用的四大组件,自定义的Application等等这些类不被混淆 +# 因为这些子类都有可能被外部调用 +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Application +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.app.backup.BackupAgentHelper +-keep public class * extends android.preference.Preference +-keep public class * extends android.view.View + +# 保留androidx下的所有类及其内部类 +-keep class androidx.** {*;} + +# 保留继承的 +-keep public class * extends androidx.** + +# 保留R下面的资源 +-keep class **.R$* {*;} + +# 保留本地native方法不被混淆 +-keepclasseswithmembernames class * { + native ; +} + +# 保留在Activity中的方法参数是view的方法, +# 这样以来我们在layout中写的onClick就不会被影响 +-keepclassmembers class * extends android.app.Activity{ + public void *(android.view.View); +} + +# 保留枚举类不被混淆 +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# 保留我们自定义控件(继承自View)不被混淆 +-keep public class * extends android.view.View{ + *** get*(); + void set*(***); + public (android.content.Context); + public (android.content.Context, android.util.AttributeSet); + public (android.content.Context, android.util.AttributeSet, int); +} + +# 保留Parcelable序列化类不被混淆 +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +# 保留Serializable序列化的类不被混淆 +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + !private ; + !private ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# 对于带有回调函数的onXXEvent、**On*Listener的,不能被混淆 +-keepclassmembers class * { + void *(**On*Event); + void *(**On*Listener); +} + +# webView处理,项目中没有使用到webView忽略即可 +-keepclassmembers class * extends android.webkit.WebViewClient { + public void *(android.webkit.WebView, java.lang.String); + public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap); + public boolean *(android.webkit.WebView, java.lang.String); +} + +# 移除Log类打印各个等级日志的代码,打正式包的时候可以做为禁log使用,这里可以作为禁止log打印的功能使用 +# 记得proguard-android.txt中一定不要加-dontoptimize才起作用 +# 另外的一种实现方案是通过BuildConfig.DEBUG的变量来控制 +-assumenosideeffects class android.util.Log { + public static int v(...); + public static int i(...); + public static int w(...); + public static int d(...); + public static int e(...); +} + +# 保持js引擎调用的java类 +-keep class **.analyzeRule.**{*;} +# 保持web类 +-keep class **.web.**{*;} +#数据类 +-keep class **.data.**{*;} + +-dontwarn rx.** +-dontwarn okio.** +-dontwarn javax.annotation.** +-dontwarn org.apache.log4j.lf5.viewer.** +-dontnote org.apache.log4j.lf5.viewer.** +-dontwarn freemarker.** +-dontnote org.python.core.** +-dontwarn com.hwangjr.rxbus.** +-dontwarn okhttp3.** +-dontwarn org.conscrypt.** +-dontwarn com.jeremyliao.liveeventbus.** + +-keep class com.google.gson.** { *; } +-keep class com.ke.gson.** { *; } +-keep class com.jeremyliao.liveeventbus.** { *; } +-keep class okhttp3.**{*;} +-keep class okio.**{*;} +-keep class com.hwangjr.rxbus.**{*;} +-keep class org.conscrypt.**{*;} +-keep class android.support.**{*;} +-keep class me.grantland.widget.**{*;} +-keep class de.hdodenhof.circleimageview.**{*;} +-keep class tyrant.explosionfield.**{*;} +-keep class tyrantgit.explosionfield.**{*;} +-keep class freemarker.**{*;} +-keep class com.gyf.barlibrary.** {*;} +##JSOUP +-keep class org.jsoup.**{*;} +-keep class **.xpath.**{*;} + +-keep class org.slf4j.**{*;} +-dontwarn org.slf4j.** + +-keep class org.codehaus.**{*;} +-dontwarn org.codehaus.** +-keep class com.jayway.**{*;} +-dontwarn com.jayway.** +-keep class com.fasterxml.**{*;} + +-keep class javax.swing.**{*;} +-dontwarn javax.swing.** +-keep class java.awt.**{*;} +-dontwarn java.awt.** +-keep class sun.misc.**{*;} +-dontwarn sun.misc.** +-keep class sun.reflect.**{*;} +-dontwarn sun.reflect.** + +## Rhino +-keep class javax.script.** { *; } +-keep class com.sun.script.javascript.** { *; } +-keep class org.mozilla.javascript.** { *; } + +###EPUB +-dontwarn nl.siegmann.epublib.** +-dontwarn org.xmlpull.** +-keep class nl.siegmann.epublib.**{*;} +-keep class javax.xml.**{*;} +-keep class org.xmlpull.**{*;} + +-keep class org.simpleframework.**{*;} +-dontwarn org.simpleframework.xml.** + +-keepclassmembers class * { + public (org.json.JSONObject); +} +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + + +# Keep all of Cronet API as it's used by the Cronet module. +-keep public class org.chromium.net.* { + !private *; + *; +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/1.json b/app/schemas/io.legado.app.data.AppDatabase/1.json new file mode 100644 index 000000000..1ab906cf0 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/1.json @@ -0,0 +1,1022 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "d9ed367fc7241a61e9f770d416c4f887", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleCategories` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleCategories", + "columnName": "ruleCategories", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `categories` TEXT, `read` INTEGER NOT NULL, `star` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "star", + "columnName": "star", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd9ed367fc7241a61e9f770d416c4f887')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/10.json b/app/schemas/io.legado.app.data.AppDatabase/10.json new file mode 100644 index 000000000..ddbecfb58 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/10.json @@ -0,0 +1,1176 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "a9744f575dad6d4774cccc433921973b", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "name", + "author" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a9744f575dad6d4774cccc433921973b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/11.json b/app/schemas/io.legado.app.data.AppDatabase/11.json new file mode 100644 index 000000000..660476fd8 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/11.json @@ -0,0 +1,1182 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "d3019908fa3212a7ac8eb87ac2f33369", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "name", + "author" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd3019908fa3212a7ac8eb87ac2f33369')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/12.json b/app/schemas/io.legado.app.data.AppDatabase/12.json new file mode 100644 index 000000000..1552fd7cb --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/12.json @@ -0,0 +1,1188 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "fa238e7524c215177f66110c847d327d", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "name", + "author" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fa238e7524c215177f66110c847d327d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/13.json b/app/schemas/io.legado.app.data.AppDatabase/13.json new file mode 100644 index 000000000..3226a6f05 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/13.json @@ -0,0 +1,1194 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "da04f8cb7f257482f105b1274a7a351b", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "name", + "author" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da04f8cb7f257482f105b1274a7a351b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/14.json b/app/schemas/io.legado.app.data.AppDatabase/14.json new file mode 100644 index 000000000..7f0f72ab8 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/14.json @@ -0,0 +1,1194 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "139ff0cc002ac7be67a60912cd26bac7", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '139ff0cc002ac7be67a60912cd26bac7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/15.json b/app/schemas/io.legado.app.data.AppDatabase/15.json new file mode 100644 index 000000000..cb401ac94 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/15.json @@ -0,0 +1,1200 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "07a0976a08cbae60a16550af5663cde5", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '07a0976a08cbae60a16550af5663cde5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/16.json b/app/schemas/io.legado.app.data.AppDatabase/16.json new file mode 100644 index 000000000..80bb3925b --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/16.json @@ -0,0 +1,1226 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "ce9320370930dec28d85e2a77fad95e2", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))", + "fields": [ + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce9320370930dec28d85e2a77fad95e2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/17.json b/app/schemas/io.legado.app.data.AppDatabase/17.json new file mode 100644 index 000000000..f061e584e --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/17.json @@ -0,0 +1,1226 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "ce9320370930dec28d85e2a77fad95e2", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))", + "fields": [ + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce9320370930dec28d85e2a77fad95e2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/18.json b/app/schemas/io.legado.app.data.AppDatabase/18.json new file mode 100644 index 000000000..2ba39a806 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/18.json @@ -0,0 +1,1258 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "ec3badfc86fb8187260ab26fb78a2d3f", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))", + "fields": [ + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ec3badfc86fb8187260ab26fb78a2d3f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/19.json b/app/schemas/io.legado.app.data.AppDatabase/19.json new file mode 100644 index 000000000..bb5a0bb0f --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/19.json @@ -0,0 +1,1265 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "c58916ed4a4aece6092e21acf99845a1", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c58916ed4a4aece6092e21acf99845a1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/2.json b/app/schemas/io.legado.app.data.AppDatabase/2.json new file mode 100644 index 000000000..7fa0d4da9 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/2.json @@ -0,0 +1,1028 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "8164f697c57c68c7b82ec8e427214a88", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `star` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "star", + "columnName": "star", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8164f697c57c68c7b82ec8e427214a88')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/20.json b/app/schemas/io.legado.app.data.AppDatabase/20.json new file mode 100644 index 000000000..31058f1df --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/20.json @@ -0,0 +1,1271 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "2c443ea987b87d8daf2a6161a98d2d5c", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2c443ea987b87d8daf2a6161a98d2d5c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/21.json b/app/schemas/io.legado.app.data.AppDatabase/21.json new file mode 100644 index 000000000..80bc0b319 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/21.json @@ -0,0 +1,1283 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "eac4e5b7fdad840e82c1f607a3a8a46a", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, `config` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eac4e5b7fdad840e82c1f607a3a8a46a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/22.json b/app/schemas/io.legado.app.data.AppDatabase/22.json new file mode 100644 index 000000000..02a64213a --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/22.json @@ -0,0 +1,1277 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "9cf4f754700355578a8b8bf045c0e8e1", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9cf4f754700355578a8b8bf045c0e8e1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/23.json b/app/schemas/io.legado.app.data.AppDatabase/23.json new file mode 100644 index 000000000..48934cae4 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/23.json @@ -0,0 +1,1283 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "874aa30f88921306741b488dbad38536", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '874aa30f88921306741b488dbad38536')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/24.json b/app/schemas/io.legado.app.data.AppDatabase/24.json new file mode 100644 index 000000000..879ffdd5f --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/24.json @@ -0,0 +1,1324 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "55416d5a8a8530659ae3e7f948c0058b", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '55416d5a8a8530659ae3e7f948c0058b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/25.json b/app/schemas/io.legado.app.data.AppDatabase/25.json new file mode 100644 index 000000000..c5e40e258 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/25.json @@ -0,0 +1,1368 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "469ee9861faf7f562d7c60bc15a4a58b", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "sourceSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '469ee9861faf7f562d7c60bc15a4a58b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/26.json b/app/schemas/io.legado.app.data.AppDatabase/26.json new file mode 100644 index 000000000..573b83e23 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/26.json @@ -0,0 +1,1392 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "e20aa63032efb23c9e9c269afd64e7d7", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e20aa63032efb23c9e9c269afd64e7d7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/27.json b/app/schemas/io.legado.app.data.AppDatabase/27.json new file mode 100644 index 000000000..98e0181e7 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/27.json @@ -0,0 +1,1392 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "e20aa63032efb23c9e9c269afd64e7d7", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e20aa63032efb23c9e9c269afd64e7d7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/28.json b/app/schemas/io.legado.app.data.AppDatabase/28.json new file mode 100644 index 000000000..180fb4fa4 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/28.json @@ -0,0 +1,1404 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "f77119a40a8930665af834d03c8c5d25", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f77119a40a8930665af834d03c8c5d25')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/29.json b/app/schemas/io.legado.app.data.AppDatabase/29.json new file mode 100644 index 000000000..f31bbc341 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/29.json @@ -0,0 +1,1410 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "85f1e7146f650af82aac6f9137eff815", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`androidId`, `bookName`))", + "fields": [ + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "androidId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '85f1e7146f650af82aac6f9137eff815')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/3.json b/app/schemas/io.legado.app.data.AppDatabase/3.json new file mode 100644 index 000000000..0880d84a9 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/3.json @@ -0,0 +1,1036 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "a3ccd8882307290a450c49e09a4435f6", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `star` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "star", + "columnName": "star", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a3ccd8882307290a450c49e09a4435f6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/30.json b/app/schemas/io.legado.app.data.AppDatabase/30.json new file mode 100644 index 000000000..e5bf2b031 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/30.json @@ -0,0 +1,1485 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "d9c8ef97ef4ffe0c1dbd57ca74bc4de4", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "epubChapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `href` TEXT NOT NULL, `parentHref` TEXT, PRIMARY KEY(`bookUrl`, `href`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentHref", + "columnName": "parentHref", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl", + "href" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_epubChapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_epubChapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_epubChapters_bookUrl_href", + "unique": true, + "columnNames": [ + "bookUrl", + "href" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_epubChapters_bookUrl_href` ON `${TABLE_NAME}` (`bookUrl`, `href`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd9c8ef97ef4ffe0c1dbd57ca74bc4de4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/31.json b/app/schemas/io.legado.app.data.AppDatabase/31.json new file mode 100644 index 000000000..fe8a10f4d --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/31.json @@ -0,0 +1,1422 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "d1c390e708a1e89c7d016cdd2e0b2e88", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd1c390e708a1e89c7d016cdd2e0b2e88')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/32.json b/app/schemas/io.legado.app.data.AppDatabase/32.json new file mode 100644 index 000000000..03fd2016f --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/32.json @@ -0,0 +1,1422 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "d1c390e708a1e89c7d016cdd2e0b2e88", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd1c390e708a1e89c7d016cdd2e0b2e88')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/33.json b/app/schemas/io.legado.app.data.AppDatabase/33.json new file mode 100644 index 000000000..f4deedbd2 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/33.json @@ -0,0 +1,1417 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "6dad1518f359667b4d740fc6a1f44a21", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6dad1518f359667b4d740fc6a1f44a21')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/34.json b/app/schemas/io.legado.app.data.AppDatabase/34.json new file mode 100644 index 000000000..3fe40c410 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/34.json @@ -0,0 +1,1423 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "2e519f1f67ca16091cbc3891c1b71c66", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2e519f1f67ca16091cbc3891c1b71c66')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/35.json b/app/schemas/io.legado.app.data.AppDatabase/35.json new file mode 100644 index 000000000..9e3eeb6fa --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/35.json @@ -0,0 +1,1441 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "25948a8defe4d091514bb725b4db7683", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '25948a8defe4d091514bb725b4db7683')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/36.json b/app/schemas/io.legado.app.data.AppDatabase/36.json new file mode 100644 index 000000000..d99c1b8fb --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/36.json @@ -0,0 +1,1441 @@ +{ + "formatVersion": 1, + "database": { + "version": 36, + "identityHash": "25948a8defe4d091514bb725b4db7683", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '25948a8defe4d091514bb725b4db7683')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/37.json b/app/schemas/io.legado.app.data.AppDatabase/37.json new file mode 100644 index 000000000..c5c21ec88 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/37.json @@ -0,0 +1,1459 @@ +{ + "formatVersion": 1, + "database": { + "version": 37, + "identityHash": "11ebd6a72eb3f9ccd6ca46bc5535bca5", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11ebd6a72eb3f9ccd6ca46bc5535bca5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/38.json b/app/schemas/io.legado.app.data.AppDatabase/38.json new file mode 100644 index 000000000..ab7415219 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/38.json @@ -0,0 +1,1465 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "5211699415b40f58b06d4136d14173d1", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `concurrentRate` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5211699415b40f58b06d4136d14173d1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/39.json b/app/schemas/io.legado.app.data.AppDatabase/39.json new file mode 100644 index 000000000..290db5cb0 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/39.json @@ -0,0 +1,1471 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "8cfa1fb6bb9a65c04bfe563680126a4f", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8cfa1fb6bb9a65c04bfe563680126a4f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/4.json b/app/schemas/io.legado.app.data.AppDatabase/4.json new file mode 100644 index 000000000..aadc551fb --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/4.json @@ -0,0 +1,1093 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "3b81b2e900be34b8ceb48aaacc6b1004", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b81b2e900be34b8ceb48aaacc6b1004')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/40.json b/app/schemas/io.legado.app.data.AppDatabase/40.json new file mode 100644 index 000000000..0bbd935aa --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/40.json @@ -0,0 +1,1483 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "09617e0520cd8ec1671d812a866b45a4", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVip", + "columnName": "isVip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPay", + "columnName": "isPay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '09617e0520cd8ec1671d812a866b45a4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/41.json b/app/schemas/io.legado.app.data.AppDatabase/41.json new file mode 100644 index 000000000..c04ce26a1 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/41.json @@ -0,0 +1,1513 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "6fbd1d1b3918bcc6db113ad108e6b924", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVip", + "columnName": "isVip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPay", + "columnName": "isPay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `concurrentRate` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fbd1d1b3918bcc6db113ad108e6b924')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/42.json b/app/schemas/io.legado.app.data.AppDatabase/42.json new file mode 100644 index 000000000..b14284f09 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/42.json @@ -0,0 +1,1519 @@ +{ + "formatVersion": 1, + "database": { + "version": 42, + "identityHash": "5bef05ac6abeaa4b82c3ff6e9e6bd7b3", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": true, + "columnNames": [ + "name", + "author" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `show` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `bookSourceComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVip", + "columnName": "isVip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPay", + "columnName": "isPay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `variable` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "deviceId", + "bookName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `loginCheckJs` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bef05ac6abeaa4b82c3ff6e9e6bd7b3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/5.json b/app/schemas/io.legado.app.data.AppDatabase/5.json new file mode 100644 index 000000000..840c85b66 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/5.json @@ -0,0 +1,1093 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "a355f8e02ebe0b13464573b1420a7b90", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a355f8e02ebe0b13464573b1420a7b90')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/6.json b/app/schemas/io.legado.app.data.AppDatabase/6.json new file mode 100644 index 000000000..2e61f6107 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/6.json @@ -0,0 +1,1131 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "af70ea583587e17c968d29f41bb3c0d6", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'af70ea583587e17c968d29f41bb3c0d6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/7.json b/app/schemas/io.legado.app.data.AppDatabase/7.json new file mode 100644 index 000000000..3a1fb4165 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/7.json @@ -0,0 +1,1131 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "af70ea583587e17c968d29f41bb3c0d6", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'af70ea583587e17c968d29f41bb3c0d6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/8.json b/app/schemas/io.legado.app.data.AppDatabase/8.json new file mode 100644 index 000000000..dcd9838d7 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/8.json @@ -0,0 +1,1157 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "0c09d0b5970a01069c4381648f793da7", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0c09d0b5970a01069c4381648f793da7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.legado.app.data.AppDatabase/9.json b/app/schemas/io.legado.app.data.AppDatabase/9.json new file mode 100644 index 000000000..a298e14ab --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/9.json @@ -0,0 +1,1164 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "8da976febbd44e9e028b951b42583f9a", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`name`, `author`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useReplaceRule", + "columnName": "useReplaceRule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "name", + "author" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_books_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceUrl` TEXT NOT NULL, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `enabledExplore` INTEGER NOT NULL, `header` TEXT, `loginUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "bookSourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `start` INTEGER, `end` INTEGER, `variable` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "url", + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `group` TEXT, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `scope` TEXT, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "bookUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "word" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `enabled` INTEGER NOT NULL, `sortUrl` TEXT, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `header` TEXT, `enableJs` INTEGER NOT NULL, `loadWithBaseUrl` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sourceUrl" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `pageIndex` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageIndex", + "columnName": "pageIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "time" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_bookmarks_time", + "unique": true, + "columnNames": [ + "time" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `${TABLE_NAME}` (`time`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `read` INTEGER NOT NULL, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `read` INTEGER NOT NULL, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "record" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "origin", + "link" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `rule` TEXT NOT NULL, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8da976febbd44e9e028b951b42583f9a')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/io/legado/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/legado/app/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..4059bc269 --- /dev/null +++ b/app/src/androidTest/java/io/legado/app/ExampleInstrumentedTest.kt @@ -0,0 +1,26 @@ +package io.legado.app + +import android.net.Uri +import android.util.Log +import androidx.test.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun testContentProvider() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getTargetContext() + Log.d("test", + appContext.contentResolver.query(Uri.parse("content://io.legado.app.api.ReaderProvider/sources/query"),null,null,null,null) + !!.getString(0) + ) + } +} diff --git a/app/src/androidTest/java/io/legado/app/MigrationTest.kt b/app/src/androidTest/java/io/legado/app/MigrationTest.kt new file mode 100644 index 000000000..64b7c5f17 --- /dev/null +++ b/app/src/androidTest/java/io/legado/app/MigrationTest.kt @@ -0,0 +1,50 @@ +package io.legado.app + +import androidx.room.Room +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import io.legado.app.data.AppDatabase +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class MigrationTest { + private val TEST_DB = "migration-test" + + private val ALL_MIGRATIONS = arrayOf( + + ) + + @Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + @Throws(IOException::class) + fun migrateAll() { + // Create earliest version of the database. + helper.createDatabase(TEST_DB, 30).apply { + close() + } + + // Open latest version of the database. Room will validate the schema + // once all migrations execute. + Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + TEST_DB + ).addMigrations(*ALL_MIGRATIONS) + .build().apply { + openHelper.writableDatabase + close() + } + } +} \ No newline at end of file diff --git a/app/src/debug/res/values-zh/strings.xml b/app/src/debug/res/values-zh/strings.xml new file mode 100644 index 000000000..e4464c88d --- /dev/null +++ b/app/src/debug/res/values-zh/strings.xml @@ -0,0 +1,4 @@ + + 阅读·D + 阅读·D·搜索 + \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 000000000..ffc41e3cd --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + legado·D + legado·D·search + \ No newline at end of file diff --git a/app/src/google/res/values-zh-rCN/strings.xml b/app/src/google/res/values-zh-rCN/strings.xml new file mode 100644 index 000000000..ed2d735c4 --- /dev/null +++ b/app/src/google/res/values-zh-rCN/strings.xml @@ -0,0 +1,6 @@ + + + + 阅读Pro + + \ No newline at end of file diff --git a/app/src/google/res/values-zh-rHK/strings.xml b/app/src/google/res/values-zh-rHK/strings.xml new file mode 100644 index 000000000..daa6a610a --- /dev/null +++ b/app/src/google/res/values-zh-rHK/strings.xml @@ -0,0 +1,6 @@ + + + + 閱讀Pro + + \ No newline at end of file diff --git a/app/src/google/res/values-zh-rTW/strings.xml b/app/src/google/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..35770fc68 --- /dev/null +++ b/app/src/google/res/values-zh-rTW/strings.xml @@ -0,0 +1,6 @@ + + + + 閱讀Pro + + diff --git a/app/src/google/res/values/strings.xml b/app/src/google/res/values/strings.xml new file mode 100644 index 000000000..773c6e15d --- /dev/null +++ b/app/src/google/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + legadoPro + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f1624fe82 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,465 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/18PlusList.txt b/app/src/main/assets/18PlusList.txt new file mode 100644 index 000000000..3451937dc --- /dev/null +++ b/app/src/main/assets/18PlusList.txt @@ -0,0 +1,284 @@ +OGN5dS5jb20= +c2cwMC54eXo= +aXRyYWZmaWNuZXQuY29t +eGlhb3FpYW5nNTIw +MTIzeGlhb3FpYW5n +eGlhb3FpYW5neHM= +eGlhb3FpYW5nNTIw +MzM1eHM= +eGN4czk= +eGN4czUyMA== +c2h1YmFvYW4= +c2h1YmFvd2FuZzEyMw== +c2h1YmFvYW4= +aGFpdGFuZzEyMw== +eXV6aGFpd3VsYQ== +cG8xOA== +Ymwtbm92ZWw= +NXRucw== +c2hhb3NodWdl +amluamlzaHV3dQ== +NDJ3Zw== +eWlxdXNodQ== +c2h1YmFvd2FuZzEyMw== +M2hlYmFv +MzNoZWJhbw== +bHVvcWl1enc= +bXlzaHVnZQ== +c3NzeHN3 +eWl0ZQ== +Y3Vpd2VpanV1 +Y3Vpd2VpanV4cw== +Y3Vpd2VpanV4 +eGlhb3FpYW5nd3g= +YXN6dw== +YXN6dzY= +c2FuaGFveHM= +ODdzaHV3dQ== +NDh3eA== +bG9uZ3Rlbmcy +NnF3eA== +bG9uZ3Rlbmd4cw== +aGF4ZHU= +M3R3eA== +aGF4d3g1 +NjZsZXdlbg== +eGJhbnpodQ== +aGR5cA== +ZHliejk= +ZGl5aWJhbnpodTk= +ZGl5aWJhbnpodQ== +ZGl5aWJhbnpodTc= +YnoyMjI= +d29kZWFwaTAwMQ== +dGFuZ3poZWthbg== +YmF4aWFueHM= +eGlhb3NodW9zaGVuemhhbg== +ZGFtb2tl +emh3ZW5wZw== +eXV6aGFpZ2U= +d21wOA== +OXhpYW53ZW4= +bmFucmVudmlw +cmV5b28= +eWZ4aWFvc2h1b2U= +c2Fuaml1enc= +N3Fpbmc3 +cWR4aWFvc2h1bw== +Y2hpbmVzZXpq +MzlzaHViYW8= +a3l4czU= +NTZtcw== +bml1c2hh +bWt4czY= +MjIyMjJ4cw== +OTVkdXNodQ== +YmFuemh1MjI= +d3JsdHh0 +dHVkb3V0eHQ= +cm5neHM= +OTl3ZW5rdQ== +bGFvc2lqaXhz +ZnVzaHV6aGFpMQ== +cG8xOA== +czUyMTc= +c2FuaGFveHM= +NTJrc2h1 +NDhyeA== +ZWNub3ZlbA== +bGllaHVvenc= +eGlhb3FpYW5nd3g= +NTJrc2h1 +NDh3eA== +NTJrc2h1 +MDB1aQ== +MDFieg== +c2h1YmFvMQ== +ZG54aWFvc2h1b2E= +am5zaHViYQ== +MThzaHV3dQ== +bGV4cw== +MzM1eHM= +dXB1 +ZnVndW9kdQ== +ODB0eHQ= +YWFyZWFk +eWlkdWR1MQ== +YmFuemh1d2FuZw== +cWloYW9xaWhhbw== +OHhpYW54cw== +amluamlzaHV3dQ== +d21wOA== +ZXl1c2h1d3U= +NTB4c2Y= +aGF4d3g1 +cG93YW5qdWFu +d2luMTBjaXR5 +eWV5ZXhzdw== +bXlzaHVnZQ== +eGlhbmd0eHN3 +Y3Vpd2VpanV4 +MzY2eHN3 +aHVheXVld2Vua3U= +eW91ZGlhbmxlbg== +c291Nzg= +bGFucm91Mg== +cXFib29r +eW91d3V4cw== +cnVpbGlzYWxl +MzY1bXd3 +ZnV3ZW5o +bGVzYmw= +YXd1Ym9vaw== +bGl5dXhpYW5nMjAyMA== +OTJwb3Bv +ZnVzaHV0dWFu +ODhkYW5tZWk= +ZG14cw== +eXVsaW56aGFueWU= +M2hlYmFv +eGd1YWd1YXhz +ZGl5aWJhbnpodTY= +aXJlYWR4cw== +c2h1YmFvOTY= +ZGl5aWJhbnpodTU1NQ== +c2Fuaml1enc= +N3Fpbmc3 +NjZsZXdlbg== +a3l4czU= +MjIyMjJ4cw== +c2hhb3NodWdl +amlsaW41NQ== +bWt4czY= +amluc2h1bG91 +eGlhbndhbmdz +eWlkdWR1 +cWR0eHQ= +MTZib29rMQ== +am1zaHV3dQ== +MzY2eHN3 +ZHliejk= +c2hvdWRhOA== +ZnlxMTg= +eWlzaHVn +eXV6aGFpd3VsYQ== +MTFiYW56aHU= +MTIzeGlhb3FpYW5n +ZGl5aWJhbnpodTk= +ZGl5aWJhbnpodQ== +MzY2eHN3 +ODdzaHV3dQ== +NnF3eA== +emhlbmh1bnhpYW9zaHVv +bG9uZ3Rlbmc1Mg== +eGlueGluZ3hpYW5nemhpZmE= +ZHliejk= +ZHVvemhla2Fu +MTIzeGlhb3FpYW5n +MzM1eHM= +am1zaHV3dQ== +c2hhb3NodWdl +bGF3ZW54cw== +cnVzaHV3dQ== +MzY2eHN3 +NTB4c2Y= +bGV3ZW41NQ== +aGFpdGFuZzEyMw== +aGViYW81MjA= +bHVvcWl1enc= +c3NzeHN3 +c2h1c2h1d3V4cw== +cm5neHM= +cWR4aWFvc2h1bw== +dHl1ZQ== +Y2hlNDM= +bG9uZ3Rlbmcy +amZ5eHNo +aGV0dTI= +bGFvc2lqaXhz +bG9uZ3Rlbmd4cw== +bGllaHVvenc= +c2h1YmFvYW4= +eHNodW9zaHVv +NTIxZGFubWVp +YmFuemh1MjI= +cWtzaHU= +eWZ4aWFvc2h1b2U= +a3lnc28= +c2h1bG91YmE= +NXRucw== +N3Fpbmc3 +bWlhb2R1NQ== +eXVzaHV3ZW4= +YWFyZWFk +cXRzaHU= +MTdzaHV3dQ== +c2h1YmFvMnM= +YnowMDE= +ZGFtb2dl +MTMxdGI= +aXhpYW9z +bXlzaHVnZQ== +OXhpYW53ZW4= +ZHVvemhla2Fu +MTIwdw== +c2h1c2h1d3U1MjA= +c2h1YmFvMnM= +YWd4c3c= +OTR4c3c= +cG8xOA== +eWFvY2hpeHM= +eGlhb3FpYW5neHM= +Ym9va2Js +c2Fuaml1eHM= +d29kZXNodWJhbw== +em9uZ2NhaXhpYW9zaHVvMg== +OWI4OTEzOTRkZjVi +MThub3ZlbA== +YWFib29r +YjF0eHQ= +eXVjYWl6dw== +Yzl0eHQ= +ZGl5aWJhbnpodTU1NQ== +MzBtYw== +eGlueXVzaHV3dQ== +c2h1YmFvd2FuZzEyMw== +YWd4cw== +YmlxdWdlbmw= +c2hpcWlzaHV3dQ== +c2lsdWtl +ZGl5aWJhbnpodTg= +ZGl5aWJhbnpodTk= +aGV0dW54cw== +OTl3ZW5rdQ== +aGFpdGFuZ3NodXd1 +OTd5ZA== +eXV6aGFpd3UxMQ== +Y3Vpd2VpanV4cw== +Y2JpcXU= +NTIxZGFubWVp +c2h1YmFvMzM= +c2FuaGFvMQ== +dGlhbm1lbmd3ZW5rdQ== +eXVzaHV3dTUyMA== +c2h1YmFvMjIy +c2h1YmFvd2FuZzEyMw== +eXVib29r +Y2JpcXU= +MWxld2Vu +MTV4c3c= +eG5jd3h3 +c2h1YmFvd2FuZzEyMw== +c2FuaGFveHM= +eXV3YW5nc2hl +YmlxdXRz +bGFtZWl4cw== +eGJhbnpodQ== +cWR4aWFvc2h1bw== +bWh0bGE= +OTl3ZW5rdQ== +eGlhb3FpYW5nNTIw +dGlhbm1lbmd3ZW5rdQ== +YWlmdXNodQ== +bWlhb2R1NQ== +bWlmZW5neHM= diff --git a/app/src/main/assets/bg/午后沙滩.jpg b/app/src/main/assets/bg/午后沙滩.jpg new file mode 100644 index 000000000..afe088dd9 Binary files /dev/null and b/app/src/main/assets/bg/午后沙滩.jpg differ diff --git a/app/src/main/assets/bg/宁静夜色.jpg b/app/src/main/assets/bg/宁静夜色.jpg new file mode 100644 index 000000000..501a46748 Binary files /dev/null and b/app/src/main/assets/bg/宁静夜色.jpg differ diff --git a/app/src/main/assets/bg/山水墨影.jpg b/app/src/main/assets/bg/山水墨影.jpg new file mode 100644 index 000000000..588374c68 Binary files /dev/null and b/app/src/main/assets/bg/山水墨影.jpg differ diff --git a/app/src/main/assets/bg/山水画.jpg b/app/src/main/assets/bg/山水画.jpg new file mode 100644 index 000000000..4c85a5b5d Binary files /dev/null and b/app/src/main/assets/bg/山水画.jpg differ diff --git a/app/src/main/assets/bg/护眼漫绿.jpg b/app/src/main/assets/bg/护眼漫绿.jpg new file mode 100644 index 000000000..daf109e8d Binary files /dev/null and b/app/src/main/assets/bg/护眼漫绿.jpg differ diff --git a/app/src/main/assets/bg/新羊皮纸.jpg b/app/src/main/assets/bg/新羊皮纸.jpg new file mode 100644 index 000000000..eec310669 Binary files /dev/null and b/app/src/main/assets/bg/新羊皮纸.jpg differ diff --git a/app/src/main/assets/bg/明媚倾城.jpg b/app/src/main/assets/bg/明媚倾城.jpg new file mode 100644 index 000000000..a68967cf2 Binary files /dev/null and b/app/src/main/assets/bg/明媚倾城.jpg differ diff --git a/app/src/main/assets/bg/深宫魅影.jpg b/app/src/main/assets/bg/深宫魅影.jpg new file mode 100644 index 000000000..27896f0a6 Binary files /dev/null and b/app/src/main/assets/bg/深宫魅影.jpg differ diff --git a/app/src/main/assets/bg/清新时光.jpg b/app/src/main/assets/bg/清新时光.jpg new file mode 100644 index 000000000..4bdd5aba6 Binary files /dev/null and b/app/src/main/assets/bg/清新时光.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸1.jpg b/app/src/main/assets/bg/羊皮纸1.jpg new file mode 100644 index 000000000..40f397735 Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸1.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸2.jpg b/app/src/main/assets/bg/羊皮纸2.jpg new file mode 100644 index 000000000..3e46c8e0f Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸2.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸3.jpg b/app/src/main/assets/bg/羊皮纸3.jpg new file mode 100644 index 000000000..31b99a870 Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸3.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸4.jpg b/app/src/main/assets/bg/羊皮纸4.jpg new file mode 100644 index 000000000..cee31be58 Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸4.jpg differ diff --git a/app/src/main/assets/bg/边彩画布.jpg b/app/src/main/assets/bg/边彩画布.jpg new file mode 100644 index 000000000..6638b9f21 Binary files /dev/null and b/app/src/main/assets/bg/边彩画布.jpg differ diff --git a/app/src/main/assets/cronet.json b/app/src/main/assets/cronet.json new file mode 100644 index 000000000..bb31c0cd2 --- /dev/null +++ b/app/src/main/assets/cronet.json @@ -0,0 +1 @@ +{"arm64-v8a":"690c212d9bbad4b09b9e1ba450b273bb","armeabi-v7a":"4dbb88e5229abef7d84138218772f872","x86":"3f2421e040147da48abb07cfc6c7c87e","x86_64":"730a71ef4f03a27d1c8c8a77e7d09ff5","version":"96.0.4664.104"} \ No newline at end of file diff --git a/app/src/main/assets/defaultData/bookSources.json b/app/src/main/assets/defaultData/bookSources.json new file mode 100644 index 000000000..386a1e8be --- /dev/null +++ b/app/src/main/assets/defaultData/bookSources.json @@ -0,0 +1,48 @@ +[ + { + "bookSourceComment": "", + "bookSourceGroup": "听书", + "bookSourceName": "消消乐听书", + "bookSourceType": 1, + "bookSourceUrl": "https://www.kaixin7days.com", + "customOrder": 0, + "enabled": true, + "enabledExplore": true, + "exploreUrl": "@js:var header = JSON.parsesource.getLoginHeader()\nvar json = ''\nvar j = null\nif (header != null) {\n json = java.connect('https://www.kaixin7days.com/book-service/bookMgt/getBookCategroy,{\"method\":\"POST\",\"body\":{}}', header).body()\n j = JSON.parse(json)\n}\nif (j == null || j.statusCode != 200) {\n json = java.connect('https://www.kaixin7days.com/visitorLogin,{\"method\":\"POST\", \"body\":{} }').body()\n j = JSON.parse(json)\n var accessToken = {\n Authorization: 'Bearer ' + j.content.accessToken\n }\n header = JSON.stringify(accessToken)\n source.putLoginHeader(header)\n json = java.connect('https://www.kaixin7days.com/book-service/bookMgt/getBookCategroy,{\"method\":\"POST\",\"body\":{} }', header).body()\n j = JSON.parse(json)\n}\nvar fls = j.content\nvar fx = []\nfor (var i = 0; i < fls.length; i++) {\n fx.push({\n title: fls[i].categoryName,\n url: '/book-service/bookMgt/getAllBookByCategroyId,{\"method\":\"POST\",\"body\":{\"categoryIds\": \"' + fls[i].associationCategoryIDs + '\",\"pageNum\": {{page}},\"pageSize\": 100}}'\n })\n}\nJSON.stringify(fx)", + "searchUrl": "https://www.kaixin7days.com/book-service/bookMgt/findBookName,{\"method\":\"POST\",\"body\":{\"title\": \"searchKey\",\"pageNum\": {{searchPage}},\"pageSize\": 100}}", + "lastUpdateTime": 1630656684531, + "loginCheckJs": "var strRes = result\nvar c = JSON.parse(result.body())\nif (c.statusCode == 301) {\n var loginInfo = source.getLoginInfo()\n var dl = null\n if (loginInfo) {\n dl = java.connect('https://www.kaixin7days.com/login,{\"method\":\"POST\",\"body\":' + loginInfo + '}').body()\n } else {\n dl = java.connect('https://www.kaixin7days.com/visitorLogin,{\"method\":\"POST\",\"body\":{}}').body()\n }\n c = JSON.parse(dl)\n var accessToken = {\n Authorization: \"Bearer \" + c.content.accessToken\n }\n var header = JSON.stringify(accessToken)\n source.putLoginHeader(header)\n strRes = java.connect(url, header)\n}\nstrRes", + "loginUi": "[{\"name\": \"telephone\",\"type\": \"text\"},{\"name\": \"password\",\"type\": \"password\"},{\"name\": \"注册\",\"type\": \"button\",\"action\": \"http://www.yooike.com/xiaoshuo/#/register?title=%E6%B3%A8%E5%86%8C\"}]", + "loginUrl": "var loginInfo = source.getLoginInfo()\nvar json = java.connect('https://www.kaixin7days.com/login,{\"method\":\"POST\",\"body\":' + loginInfo + '}').body()\nvar loginRes = JSON.parse(json)\nvar header = null\nif (loginRes.statusCode == 200) {\n var accessToken = {\n Authorization: \"Bearer \" + loginRes.content.accessToken\n }\n header = JSON.stringify(accessToken)\n source.putLoginHeader(header)\n}\nheader", + "respondTime": 180000, + "ruleBookInfo": {}, + "ruleContent": { + "content": "", + "payAction": "var header = JSON.parse(source.getLoginHeader()); var bookId = book.getVariableMap().get('bookId');var chapterId = java.get('chapterId');\n'http://www.shuidi.online/?name=' + book.getName() + '&type=2&cover=' + book.getCoverUrl() + '&chapterId=' + chapterId + '&chapter=203&allNumber=' + book.getTotalChapterNum() + '&bookId=' + bookId + '&chapterIds=' + chapterId + '&number=' + chapter.getIndex() + '&accessToken=' + header.Authorization.substring(7) + '#/pay'" + }, + "ruleExplore": { + "author": "$.author", + "bookList": "$.content.content", + "bookUrl": "$.id@js:java.put('bookId', result);'https://www.kaixin7days.com/book-service/bookMgt/getAllChapterByBookId,{ \"method\": \"POST\",\"body\": {\"bookId\": \"'+result+'\",\"pageNum\": \"1\",\"pageSize\": \"10000\"} }'", + "coverUrl": "$.cover@js:var cover = JSON.parse(result);'https://www.shuidi.online/fileMgt/getPicture?filePath='+cover.storeFilePath", + "intro": "$.desc", + "lastChapter": "$.newestChapter", + "name": "$.title" + }, + "ruleSearch": { + "author": "$.author", + "bookList": "$.content.content", + "bookUrl": "$.id@js:java.put('bookId', result);'https://www.kaixin7days.com/book-service/bookMgt/getAllChapterByBookId,{ \"method\": \"POST\",\"body\": {\"bookId\": \"'+result+'\",\"pageNum\": \"1\",\"pageSize\": \"10000\"} }'", + "coverUrl": "$.cover@js:var cover = JSON.parse(result);'https://www.shuidi.online/fileMgt/getPicture?filePath='+cover.storeFilePath", + "intro": "$.desc", + "lastChapter": "$.newestChapter", + "name": "$.title" + }, + "ruleToc": { + "chapterList": "$.content.content", + "chapterName": "$.chapterTitle", + "chapterUrl": "$.id@js:java.put('chapterId', result);'https://www.shuidi.online/fileMgt/getAudioByChapterId?bookId=' + java.get('bookId') + '&chapterId=' + result + \"&pageNum=1&pageSize=50&keyId={{var header = JSON.parse(source.getLoginHeader());var keyId = '1632746188011002';var ks = java.md5Encode(keyId + java.get('chapterId') + header.Authorization);keyId + '&keySecret=' + ks}\" + '}'" + }, + "weight": 0 + } +] \ No newline at end of file diff --git a/app/src/main/assets/defaultData/directLinkUpload.json b/app/src/main/assets/defaultData/directLinkUpload.json new file mode 100644 index 000000000..299e5e589 --- /dev/null +++ b/app/src/main/assets/defaultData/directLinkUpload.json @@ -0,0 +1,5 @@ +{ + "UploadUrl": "http://sy.miaogongzi.cc/shuyuan,{\"method\":\"POST\",\"body\": {\"file\": \"fileRequest\"},\"type\": \"multipart/form-data\"}", + "DownloadUrlRule": "$.data@js:if (result == '') \n '' \n else \n 'https://shuyuan.miaogongzi.cc/shuyuan/' + result", + "summary": "有效期2天" +} \ No newline at end of file diff --git a/app/src/main/assets/defaultData/httpTTS.json b/app/src/main/assets/defaultData/httpTTS.json new file mode 100644 index 000000000..00e369861 --- /dev/null +++ b/app/src/main/assets/defaultData/httpTTS.json @@ -0,0 +1,30 @@ +[ + { + "id": -100, + "name": "1.百度", + "url": "http://tts.baidu.com/text2audio,{\n \"method\": \"POST\",\n \"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=4127&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=11&vol=5&aue=6&pit=3&_res_tag_=audio\"\n}", + "contentType": "audio/wav" + }, + { + "id": -29, + "name": "2.阿里云语音", + "url": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts,{\"method\": \"POST\",\"body\": {\"appkey\":\"{{source.getLoginInfoMap().get('AppKey')}}\",\"text\":\"{{speakText}}\",\"format\":\"mp3\",\"volume\":100,\"speech_rate\":{{String((speakSpeed) * 20 - 400)}} }}", + "contentType": "audio/mpeg", + "loginUrl": "var loginInfo = source.getLoginInfoMap();\nvar accessKeyId = loginInfo.get('AccessKeyId');\nvar accessKeySecret = loginInfo.get('AccessKeySecret');\nvar timestamp = java.timeFormatUTC(new Date().getTime(), \"yyyy-MM-dd'T'HH:mm:ss'Z'\", 0);\nvar aly = new JavaImporter(Packages.javax.crypto.Mac, Packages.javax.crypto.spec.SecretKeySpec, Packages.javax.xml.bind.DatatypeConverter, Packages.java.net.URLEncoder, Packages.java.lang.String, Packages.android.util.Base64);\nwith (aly) {\n function percentEncode(value) {\n return URLEncoder.encode(value, \"UTF-8\").replace(\"+\", \"%20\")\n .replace(\"*\", \"%2A\").replace(\"%7E\", \"~\")\n }\n\n function sign(stringToSign, accessKeySecret) {\n var mac = Mac.getInstance('HmacSHA1');\n mac.init(new SecretKeySpec(String(accessKeySecret + '&').getBytes(\"UTF-8\"), \"HmacSHA1\"));\n var signData = mac.doFinal(String(stringToSign).getBytes(\"UTF-8\"));\n var signBase64 = Base64.encodeToString(signData, Base64.NO_WRAP);\n var signUrlEncode = percentEncode(signBase64);\n return signUrlEncode;\n }\n}\nvar query = 'AccessKeyId=' + accessKeyId + '&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=' + java.randomUUID() + '&SignatureVersion=1.0&Timestamp=' + percentEncode(timestamp) + '&Version=2019-02-28';\nvar signStr = sign('GET&' + percentEncode('/') + '&' + percentEncode(query), accessKeySecret);\nvar queryStringWithSign = \"Signature=\" + signStr + \"&\" + query;\nvar body = java.ajax('http://nls-meta.cn-shanghai.aliyuncs.com/?' + queryStringWithSign)\nvar res = JSON.parse(body)\nif (res.Message) {\n throw new Error(res.Message)\n}\nvar header = { \"X-NLS-Token\": res.Token.Id };\nsource.putLoginHeader(JSON.stringify(header))", + "loginUi": [ + { + "name": "AppKey", + "type": "text" + }, + { + "name": "AccessKeyId", + "type": "text" + }, + { + "name": "AccessKeySecret", + "type": "text" + } + ], + "loginCheckJs": "var response = result;\nif (response.headers().get(\"Content-Type\") != \"audio/mpeg\") {\n var body = JSON.parse(response.body().string())\n if (body.status == 40000001) {\n source.login()\n java.getHeaderMap().putAll(source.getHeaderMap(true))\n response = java.getResponse()\n } else {\n throw body.message\n }\n}\nresponse" + } +] diff --git a/app/src/main/assets/defaultData/readConfig.json b/app/src/main/assets/defaultData/readConfig.json new file mode 100644 index 000000000..cd62a05be --- /dev/null +++ b/app/src/main/assets/defaultData/readConfig.json @@ -0,0 +1,107 @@ +[ + { + "bgStr": "#ffc0edc6", + "bgStrEInk": "#FFFFFF", + "bgStrNight": "#000000", + "bgType": 0, + "bgTypeEInk": 0, + "bgTypeNight": 0, + "darkStatusIcon": true, + "darkStatusIconEInk": true, + "darkStatusIconNight": false, + "footerMode": 0, + "footerPaddingBottom": 10, + "footerPaddingLeft": 13, + "footerPaddingRight": 17, + "footerPaddingTop": 0, + "headerMode": 0, + "headerPaddingBottom": 0, + "headerPaddingLeft": 19, + "headerPaddingRight": 16, + "headerPaddingTop": 10, + "letterSpacing": 0, + "lineSpacingExtra": 10, + "name": "微信读书", + "paddingBottom": 4, + "paddingLeft": 22, + "paddingRight": 22, + "paddingTop": 5, + "pageAnim": 3, + "pageAnimEInk": 3, + "paragraphIndent": "  ", + "paragraphSpacing": 6, + "showFooterLine": true, + "showHeaderLine": true, + "textBold": 0, + "textColor": "#ff0b0b0b", + "textColorEInk": "#000000", + "textColorNight": "#ADADAD", + "textSize": 24, + "tipColor": -10461088, + "tipFooterLeft": 7, + "tipFooterMiddle": 0, + "tipFooterRight": 6, + "tipHeaderLeft": 1, + "tipHeaderMiddle": 0, + "tipHeaderRight": 2, + "titleBottomSpacing": 0, + "titleMode": 0, + "titleSize": 4, + "titleTopSpacing": 0 + }, + { + "name": "预设1", + "bgStr": "#FFFFFF", + "bgStrNight": "#000000", + "textColor": "#000000", + "textColorNight": "#FFFFFF", + "bgType": 0, + "bgTypeNight": 0, + "darkStatusIcon": true, + "darkStatusIconNight": false + }, + { + "name": "预设2", + "bgStr": "#DDC090", + "bgStrNight": "#3C3F43", + "textColor": "#3E3422", + "textColorNight": "#DCDFE1", + "bgType": 0, + "bgTypeNight": 0, + "darkStatusIcon": true, + "darkStatusIconNight": false + }, + { + "name": "预设3", + "bgStr": "#C2D8AA", + "bgStrNight": "#3C3F43", + "textColor": "#596C44", + "textColorNight": "#88C16F", + "bgType": 0, + "bgTypeNight": 0, + "darkStatusIcon": false, + "darkStatusIconNight": false + }, + { + "name": "预设4", + "bgStr": "#DBB8E2", + "bgStrNight": "#3C3F43", + "textColor": "#68516C", + "textColorNight": "#F6AEAE", + "bgType": 0, + "bgTypeNight": 0, + "darkStatusIcon": false, + "darkStatusIconNight": false + }, + { + "name": "预设5", + "bgStr": "#ABCEE0", + "bgStrNight": "#3C3F43", + "textColor": "#3D4C54", + "textColorNight": "#90BFF5", + "bgType": 0, + "bgTypeNight": 0, + "darkStatusIcon": false, + "darkStatusIconNight": false + } +] \ No newline at end of file diff --git a/app/src/main/assets/defaultData/rssSources.json b/app/src/main/assets/defaultData/rssSources.json new file mode 100644 index 000000000..af3dbc47f --- /dev/null +++ b/app/src/main/assets/defaultData/rssSources.json @@ -0,0 +1,42 @@ +[ + { + "customOrder": 1, + "enableJs": true, + "enabled": true, + "singleUrl": true, + "sourceGroup": "legado", + "sourceIcon": "https://cdn.jsdelivr.net/gh/gedoor/legado@master/app/src/main/res/mipmap-hdpi/ic_launcher.png", + "sourceName": "使用说明", + "sourceUrl": "https://www.yuque.com/legado" + }, + { + "customOrder": 2, + "enableJs": true, + "enabled": true, + "singleUrl": true, + "sourceGroup": "legado", + "sourceIcon": "http://mmbiz.qpic.cn/mmbiz_png/hpfMV8hEuL2eS6vnCxvTzoOiaCAibV6exBzJWq9xMic9xDg3YXAick87tsfafic0icRwkQ5ibV0bJ84JtSuxhPuEDVquA/0?wx_fmt=png", + "sourceName": "小说拾遗", + "sourceUrl": "snssdk1128://user/profile/562564899806367" + }, + { + "customOrder": 3, + "enableJs": true, + "enabled": true, + "singleUrl": true, + "sourceGroup": "legado", + "sourceIcon": "https://Cloud.miaogongzi.site/images/icon.png", + "sourceName": "Meow云", + "sourceUrl": "https://pan.miaogongzi.net" + }, + { + "customOrder": 4, + "enableJs": true, + "enabled": true, + "singleUrl": true, + "sourceGroup": "legado", + "sourceIcon": "https://cdn.jsdelivr.net/gh/gedoor/legado@master/app/src/main/res/mipmap-hdpi/ic_launcher.png", + "sourceName": "烏雲净化", + "sourceUrl": "https://www.lanzoux.com/b0bw8jwoh" + } +] \ No newline at end of file diff --git a/app/src/main/assets/defaultData/themeConfig.json b/app/src/main/assets/defaultData/themeConfig.json new file mode 100644 index 000000000..a8826cf98 --- /dev/null +++ b/app/src/main/assets/defaultData/themeConfig.json @@ -0,0 +1,26 @@ +[ + { + "themeName": "典雅蓝", + "isNightTheme": false, + "primaryColor": "#03A9F4", + "accentColor": "#AD1457", + "backgroundColor": "#F5F5F5", + "bottomBackground": "#EEEEEE" + }, + { + "themeName": "黑白", + "isNightTheme": true, + "primaryColor": "#303030", + "accentColor": "#E0E0E0", + "backgroundColor": "#424242", + "bottomBackground": "#424242" + }, + { + "themeName": "A屏黑", + "isNightTheme": true, + "primaryColor": "#000000", + "accentColor": "#FFFFFF", + "backgroundColor": "#000000", + "bottomBackground": "#000000" + } +] \ No newline at end of file diff --git a/app/src/main/assets/defaultData/txtTocRule.json b/app/src/main/assets/defaultData/txtTocRule.json new file mode 100644 index 000000000..ad6f7d591 --- /dev/null +++ b/app/src/main/assets/defaultData/txtTocRule.json @@ -0,0 +1,128 @@ +[ + { + "id": -1, + "enable": true, + "name": "目录(去空白)", + "rule": "(?<=[ \\s])(?:序章|序言|卷首语|扉页|楔子|正文(?!完|结)|终章|后记|尾声|番外|第?\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$", + "serialNumber": 0 + }, + { + "id": -2, + "enable": true, + "name": "目录", + "rule": "^[  \\t]{0,4}(?:序章|序言|卷首语|扉页|楔子|正文(?!完|结)|终章|后记|尾声|番外|第?\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$", + "serialNumber": 1 + }, + { + "id": -3, + "enable": false, + "name": "目录(匹配简介)", + "rule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|序言|卷首语|扉页|楔子|正文(?!完|结)|终章|后记|尾声|番外|第?\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$", + "serialNumber": 2 + }, + { + "id": -4, + "enable": false, + "name": "目录(古典、轻小说备用)", + "rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第?\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$", + "serialNumber": 3 + }, + { + "id": -5, + "enable": false, + "name": "数字(纯数字标题)", + "rule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$", + "serialNumber": 4 + }, + { + "id": -6, + "enable": true, + "name": "数字 分隔符 标题名称", + "rule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-].{1,30}$", + "serialNumber": 5 + }, + { + "id": -7, + "enable": true, + "name": "大写数字 分隔符 标题名称", + "rule": "^[  \\t]{0,4}(?:序章|序言|卷首语|扉页|楔子|正文(?!完|结)|终章|后记|尾声|番外|[〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[ 、_—\\-].{1,30}$", + "serialNumber": 6 + }, + { + "id": -8, + "enable": true, + "name": "正文 标题/序号", + "rule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$", + "serialNumber": 7 + }, + { + "id": -9, + "enable": true, + "name": "Chapter/Section/Part/Episode 序号 标题", + "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO]\\.|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$", + "serialNumber": 8 + }, + { + "id": -10, + "enable": false, + "name": "Chapter(去简介)", + "rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$", + "serialNumber": 9 + }, + { + "id": -11, + "enable": true, + "name": "特殊符号 序号 标题", + "rule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$", + "serialNumber": 10 + }, + { + "id": -12, + "enable": false, + "name": "特殊符号 标题(成对)", + "rule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$", + "serialNumber": 11 + }, + { + "id": -13, + "enable":true, + "name": "特殊符号 标题(单个)", + "rule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$", + "serialNumber": 12 + }, + { + "id": -14, + "enable": true, + "name": "章/卷 序号 标题", + "rule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|序言|卷首语|扉页|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$", + "serialNumber": 13 + }, + { + "id": -15, + "enable":false, + "name": "顶格标题", + "rule": "^\\S.{1,20}$", + "serialNumber": 14 + }, + { + "id": -16, + "enable":false, + "name": "双标题(前向)", + "rule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)", + "serialNumber": 15 + }, + { + "id": -17, + "enable":false, + "name": "双标题(后向)", + "rule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$", + "serialNumber": 16 + }, + { + "id":-18, + "enable": true, + "name": "标题 特殊符号 序号", + "rule": "^.{1,20}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \t]{0,4}$", + "serialNumber": 17 + } +] \ No newline at end of file diff --git a/app/src/main/assets/disclaimer.md b/app/src/main/assets/disclaimer.md new file mode 100644 index 000000000..5511655f5 --- /dev/null +++ b/app/src/main/assets/disclaimer.md @@ -0,0 +1,16 @@ +# 免责声明(Disclaimer) + +* 阅读是一款解析指定规则并获取内容的工具,为广大网络文学爱好者提供一种方便、快捷舒适的试读体验。 +* 当您搜索一本书的时,阅读会您所使用的规则将该书的书名以关键词的形式提交到各个第三方网络文学网站。 +各第三方网站返回的内容与阅读无关,阅读对其概不负责,亦不承担任何法律责任。 +任何通过使用阅读而链接到的第三方网页均系他人制作或提供,您可能从第三方网页上获得其他服务,阅读对其合法性概不负责,亦不承担任何法律责任。 +第三方搜索引擎结果根据您提交的书名自动搜索获得并提供试读,不代表阅读赞成或被搜索链接到的第三方网页上的内容或立场。 +您应该对使用搜索引擎的结果自行承担风险。 +* 阅读不做任何形式的保证:不保证第三方搜索引擎的搜索结果满足您的要求,不保证搜索服务不中断,不保证搜索结果的安全性、正确性、及时性、合法性。 +因网络状况、通讯线路、第三方网站等任何原因而导致您不能正常使用阅读,阅读不承担任何法律责任。 +阅读尊重并保护所有使用阅读用户的个人隐私权,您注册的用户名、电子邮件地址等个人资料,非经您亲自许可或根据相关法律、法规的强制性规定,阅读不会主动地泄露给第三方。 +* 阅读致力于最大程度地减少网络文学阅读者在自行搜寻过程中的无意义的时间浪费,通过专业搜索展示不同网站中网络文学的最新章节。 +阅读在为广大小说爱好者提供方便、快捷舒适的试读体验的同时,也使优秀网络文学得以迅速、更广泛的传播,从而达到了在一定程度促进网络文学充分繁荣发展之目的。 +阅读鼓励广大小说爱好者通过阅读发现优秀网络小说及其提供商,并建议阅读正版图书。 +任何单位或个人认为通过阅读搜索链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权,应该及时向阅读提出书面权力通知,并提供身份证明、权属证明及详细侵权情况证明。 +阅读在收到上述法律文件后,将会依法尽快断开相关链接内容。 \ No newline at end of file diff --git a/app/src/main/assets/epub/chapter.html b/app/src/main/assets/epub/chapter.html new file mode 100644 index 000000000..39b505fad --- /dev/null +++ b/app/src/main/assets/epub/chapter.html @@ -0,0 +1,18 @@ + + + + + Chapter + + + + + +
+
+

{title}

+{content} + + diff --git a/app/src/main/assets/epub/cover.html b/app/src/main/assets/epub/cover.html new file mode 100644 index 000000000..cb6df6353 --- /dev/null +++ b/app/src/main/assets/epub/cover.html @@ -0,0 +1,21 @@ + + + + + Cover + + + +
+

{name}

+
{author} / 著
+ + \ No newline at end of file diff --git a/app/src/main/assets/epub/fonts.css b/app/src/main/assets/epub/fonts.css new file mode 100644 index 000000000..3457a6511 --- /dev/null +++ b/app/src/main/assets/epub/fonts.css @@ -0,0 +1,267 @@ +@charset "utf-8"; +/*---常用---*/ + +@font-face { + font-family: "zw"; + src: + local("宋体"),local("明体"),local("明朝"), + local("Songti"),local("Songti SC"),local("Songti TC"), /*iOS6+iBooks3*/ + local("Song S"),local("Song T"),local("STBShusong"),local("TBMincho"),local("HYMyeongJo"), /*Kindle Paperwihite*/ + local("DK-SONGTI"), + url(../Fonts/zw.ttf), + url(res:///opt/sony/ebook/FONT/zw.ttf), + url(res:///Data/FONT/zw.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/zw.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/zw.ttf), + url(res:///ebook/fonts/zw.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/zw.ttf), + url(res:///../../media/mmcblk0p1/fonts/zw.ttf), + url(file:///mnt/us/DK_System/system/fonts/zw.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/zw.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/zw.ttf), + url(res:///system/fonts/zw.ttf), + url(res:///system/media/sdcard/fonts/zw.ttf), + url(res:///media/fonts/zw.ttf), + url(res:///sdcard/fonts/zw.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/zw.ttf), + url(res:///media/flash/fonts/zw.ttf), + url(res:///media/sd/fonts/zw.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/zw.ttf), + url(res:///../fonts/zw.ttf), + url(../../../../../zw.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/zw.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/zw.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/zw.ttf); + /*ADE1,8, 2.0 Windows Path*/; +} + +@font-face { + font-family: "fs"; + src: + local("amasis30"),local("仿宋"),local("仿宋_GB2312"), + local("Yuanti"),local("Yuanti SC"),local("Yuanti TC"), /*iOS6+iBooks3*/ + local("DK-FANGSONG"), + url(../Fonts/fs.ttf), + url(res:///opt/sony/ebook/FONT/fs.ttf), + url(res:///Data/FONT/fs.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/fs.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/fs.ttf), + url(res:///ebook/fonts/fs.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/fs.ttf), + url(res:///../../media/mmcblk0p1/fonts/fs.ttf), + url(file:///mnt/us/DK_System/system/fonts/fs.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/fs.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/fs.ttf), + url(res:///system/fonts/fs.ttf), + url(res:///system/media/sdcard/fonts/fs.ttf), + url(res:///media/fonts/fs.ttf), + url(res:///sdcard/fonts/fs.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/fs.ttf), + url(res:///media/flash/fonts/fs.ttf), + url(res:///media/sd/fonts/fs.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/fs.ttf), + url(res:///../fonts/fs.ttf), + url(../../../../../fs.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/fs.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/fs.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/fs.ttf); + /*ADE1,8, 2.0 Windows Path*/; +} + +@font-face { + font-family: "kt"; + src: + local("Caecilia"),local("楷体"),local("楷体_GB2312"), + local("Kaiti"),local("Kaiti SC"),local("Kaiti TC"), /*iOS6+iBooks3*/ + local("MKai PRC"),local("MKaiGB18030C-Medium"),local("MKaiGB18030C-Bold"), /*Kindle Paperwihite*/ + local("DK-KAITI"), + url(../Fonts/kt.ttf), + url(res:///opt/sony/ebook/FONT/kt.ttf), + url(res:///Data/FONT/kt.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/kt.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/kt.ttf), + url(res:///ebook/fonts/kt.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/kt.ttf), + url(res:///../../media/mmcblk0p1/fonts/kt.ttf), + url(file:///mnt/us/DK_System/system/fonts/kt.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/kt.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/kt.ttf), + url(res:///system/fonts/kt.ttf), + url(res:///system/media/sdcard/fonts/kt.ttf), + url(res:///media/fonts/kt.ttf), + url(res:///sdcard/fonts/kt.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/kt.ttf), + url(res:///media/flash/fonts/kt.ttf), + url(res:///media/sd/fonts/kt.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/kt.ttf), + url(res:///../fonts/kt.ttf), + url(../../../../../kt.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/kt.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/kt.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/kt.ttf); + /*ADE1,8, 2.0 Windows Path*/; +} + +@font-face { + font-family: "ht"; + src: + local("黑体"),local("微软雅黑"), + local("Heiti"),local("Heiti SC"),local("Heiti TC"), /*iOS6+iBooks3*/ + local("MYing Hei S"),local("MYing Hei T"),local("TBGothic"), /*Kindle Paperwihite*/ + local("DK-HEITI"), + url(../Fonts/ht.ttf), + url(res:///opt/sony/ebook/FONT/ht.ttf), + url(res:///Data/FONT/ht.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/ht.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/ht.ttf), + url(res:///ebook/fonts/ht.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/ht.ttf), + url(res:///../../media/mmcblk0p1/fonts/ht.ttf), + url(file:///mnt/us/DK_System/system/fonts/ht.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/ht.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/ht.ttf), + url(res:///system/fonts/ht.ttf), + url(res:///system/media/sdcard/fonts/ht.ttf), + url(res:///media/fonts/ht.ttf), + url(res:///sdcard/fonts/ht.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/ht.ttf), + url(res:///media/flash/fonts/ht.ttf), + url(res:///media/sd/fonts/ht.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/ht.ttf), + url(res:///../fonts/ht.ttf), + url(../../../../../ht.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/ht.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/ht.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/ht.ttf); + /*ADE1,8, 2.0 Windows Path*/; +} +@font-face { + font-family:"h1"; + src: + local("方正兰亭特黑长_GBK"),local("方正兰亭特黑长简体"),local("方正兰亭特黑长繁体"), + local("LantingTeheichang"), + local("Yuanti"),local("Yuanti SC"),local("Yuanti TC"), + local("DK-HEITI"), + url(../Fonts/h1.ttf), + url(res:///opt/sony/ebook/FONT/h1.ttf), + url(res:///Data/FONT/h1.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/h1.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/h1.ttf), + url(res:///ebook/fonts/h1.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/h1.ttf), + url(res:///../../media/mmcblk0p1/fonts/h1.ttf), + url(file:///mnt/us/DK_System/system/fonts/h1.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/h1.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/h1.ttf), + url(res:///system/fonts/h1.ttf), + url(res:///system/media/sdcard/fonts/h1.ttf), + url(res:///media/fonts/h1.ttf), + url(res:///sdcard/fonts/h1.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/h1.ttf), + url(res:///media/flash/fonts/h1.ttf), + url(res:///media/sd/fonts/h1.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/h1.ttf), + url(res:///../fonts/h1.ttf), + url(../../../../../h1.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/h1.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/h1.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/h1.ttf); /*ADE1,8, 2.0 Windows Path*/ +} +@font-face { + font-family:"h2"; + src: + local("方正大标宋_GBK"),local("方正大标宋简体"),local("方正大标宋繁体"), + local("Dabiaosong"), + local("Heiti"),local("Heiti SC"),local("Heiti TC"), + local("DK-XIAOBIAOSONG"), + url(../Fonts/h2.ttf), + url(res:///opt/sony/ebook/FONT/h2.ttf), + url(res:///Data/FONT/h2.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/h2.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/h2.ttf), + url(res:///ebook/fonts/h2.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/h2.ttf), + url(res:///../../media/mmcblk0p1/fonts/h2.ttf), + url(file:///mnt/us/DK_System/system/fonts/h2.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/h2.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/h2.ttf), + url(res:///system/fonts/h2.ttf), + url(res:///system/media/sdcard/fonts/h2.ttf), + url(res:///media/fonts/h2.ttf), + url(res:///sdcard/fonts/h2.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/h2.ttf), + url(res:///media/flash/fonts/h2.ttf), + url(res:///media/sd/fonts/h2.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/h2.ttf), + url(res:///../fonts/h2.ttf), + url(../../../../../h2.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/h2.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/h2.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/h2.ttf); /*ADE1,8, 2.0 Windows Path*/ +} + +@font-face { + font-family:"h3"; + src: + local("方正华隶_GBK"),local("方正行黑简体"),local("方正行黑繁体"), + local("Yuanti"),local("Yuanti SC"),local("Yuanti TC"), + local("DK-FANGSONG"), + url(../Fonts/h3.ttf), + url(res:///opt/sony/ebook/FONT/h3.ttf), + url(res:///Data/FONT/h3.ttf), + url(res:///opt/sony/ebook/FONT/tt0011m_.ttf), + url(res:///ebook/fonts/../../mnt/sdcard/fonts/h3.ttf), + url(res:///ebook/fonts/../../mnt/extsd/fonts/h3.ttf), + url(res:///ebook/fonts/h3.ttf), + url(res:///ebook/fonts/DroidSansFallback.ttf), + url(res:///fonts/ttf/h3.ttf), + url(res:///../../media/mmcblk0p1/fonts/h3.ttf), + url(file:///mnt/us/DK_System/system/fonts/h3.ttf), /*Duokan Old Path*/ + url(file:///mnt/us/DK_System/xKindle/res/userfonts/h3.ttf), /*Duokan 2012 Path*/ + url(res:///abook/fonts/h3.ttf), + url(res:///system/fonts/h3.ttf), + url(res:///system/media/sdcard/fonts/h3.ttf), + url(res:///media/fonts/h3.ttf), + url(res:///sdcard/fonts/h3.ttf), + url(res:///system/fonts/DroidSansFallback.ttf), + url(res:///mnt/MOVIFAT/font/h3.ttf), + url(res:///media/flash/fonts/h3.ttf), + url(res:///media/sd/fonts/h3.ttf), + url(res:///opt/onyx/arm/lib/fonts/AdobeHeitiStd-Regular.otf), + url(res:///../../fonts/h3.ttf), + url(res:///../fonts/h3.ttf), + url(../../../../../h3.ttf), /*EpubReaderI*/ + url(res:///mnt/sdcard/fonts/h3.ttf), /*Nook for Android: fonts in TF Card*/ + url(res:///fonts/h3.ttf), /*ADE1,8, 2.0 Program Path*/ + url(res:///../../../../Windows/fonts/h3.ttf); /*ADE1,8, 2.0 Windows Path*/ +} + +@font-face { + font-family:"luohua"; + src:local("汉仪落花体"), + url("../Fonts/hylh.ttf"); +} \ No newline at end of file diff --git a/app/src/main/assets/epub/intro.html b/app/src/main/assets/epub/intro.html new file mode 100644 index 000000000..5c64026f8 --- /dev/null +++ b/app/src/main/assets/epub/intro.html @@ -0,0 +1,11 @@ + + + + + Intro + + + + +

内容简介

{intro} + diff --git a/app/src/main/assets/epub/logo.png b/app/src/main/assets/epub/logo.png new file mode 100644 index 000000000..104d1777c Binary files /dev/null and b/app/src/main/assets/epub/logo.png differ diff --git a/app/src/main/assets/epub/main.css b/app/src/main/assets/epub/main.css new file mode 100644 index 000000000..fcb287442 --- /dev/null +++ b/app/src/main/assets/epub/main.css @@ -0,0 +1,551 @@ +@charset "utf-8"; +@import url("../Styles/fonts.css"); +body { + padding: 0%; + margin-top: 0%; + margin-bottom: 0%; + margin-left: 0.5%; + margin-right: 0.5%; + line-height: 130%; + text-align: justify; + font-family: "DK-SONGTI","st","宋体","zw",sans-serif; +} + +p { + text-align: justify; + text-indent: 2em; + line-height: 130%; + margin-right: 0.5%; + margin-left: 0.5%; + font-family: "DK-SONGTI","st","宋体","zw",sans-serif; +} +p.kaiti { + font-family: "DK-KAITI","kt","楷体","zw",serif; +} + +p.fangsong { + font-family: "DK-FANGSONG","fs","仿宋","zw",serif; +} + +span.xinli { + font-family: "DK-KAITI","kt","楷体","zw",serif; + color: #4e753f; +} +/** 英文斜体字 **/ +span.english{ + font-style: italic; +} +div { + margin: 0px; + padding: 0px; + line-height: 120%; + text-align: justify; + font-family: "zw"; +} +div.foot { + text-indent: 2em; + margin: 30% 5% 0 5%; + padding: 8px 0; +} +p.foot { + font-family: "DK-KAITI","kt","楷体","zw",serif; +} + +/*扉页*/ +.booksubtitle { + padding: 10px 0 0px 0; + text-indent: 0em; + font-size: 75%; + font-family: "ht"; +} + +.booktitle { + padding: 9% 0 0 0; + font-size: 1.3em; + font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; + font-weight: normal; + text-indent: 0em; + color: #000; + text-align: center; + line-height: 1.6; +} + +.booktitle0 { + font-size: 1.2em; + font-family: "fs"; + text-indent: 0em; + text-align: center; + line-height: 1.8; +} + +.booktitle1 { + padding: 0 0 0 0; + font-size: 0.85em; + font-family: "fs"; + text-indent: 0em; + text-align: center; + line-height: 1.6; +} + +.bookauthor { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; + padding: 5% 5px 0px 5px; + text-indent: 0em; + text-align: center; + color: #000; + font-size: 90%; + line-height: 1.3; +} + +.booktranslator { + padding: 1% 5px 0px 5px; + text-indent: 0em; + text-align: center; + font-size: 85%; + line-height: 1.3; +} + +.bookpub { + font-family: "DK-KAITI","kt","楷体","楷体_gb2312"; + padding: 30% 5px 5px 5px; + text-indent: 0em; + color: #000; + text-align: center; + font-size: 80%; +} + +/*标题页*/ +body.head { + background-repeat:no-repeat no-repeat; + background-size:160px 229px; + background-position:bottom right; + background-attachment:fixed; +} + +body.xhead { + background-color: #FDF5E6; +} + +h1.head { + font-family: "DK-HEITI",黑体,sans-serif; + font-size: 1.2em; + font-weight: bold; + color: #311a02; + text-indent: 0em; + font-weight: normal; + duokan-text-indent: 0em; + padding: auto; + text-align: center; + margin-top: -8em; +} + +div.head { + border: solid 2px #ffffff; + padding: 2px; + margin: 2em auto 0.7em auto; + text-align: center; + width: 1em; +} + +h1.head b { + font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; + font-weight: bold; + font-size: 1.2em; + text-align: center; + text-indent: 0em; + duokan-text-indent: 0em; + color: #311a02; + margin: 0.5em auto; + line-height: 140%; +} + +div.back { + text-align: center; + text-indent: 0em; + duokan-text-indent: 0em; + margin: 4em auto; +} + +img.back { + width: 70%; +} +img.back2 { + width: 40%; + margin: 2em 0 0 0; +} +/*正文*/ +/**楷体引文**/ +.titou { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; +} +.yinwen { + font-family: "DK-KAITI","kt","楷体","zw",serif; + margin-left: 2em; + text-indent: 0em; +} +.nicename { + font-family: "DK-HEITI",黑体,sans-serif; + font-weight: bold; + font-size: 0.9em; +} +body.head3 { + background-color: #a7bdcc; + color: #354f66; +} + +body.head4 { + background-color: #bfd19b; + color: #4e753f; +} + +h2.head { + font-family: "小标宋"; + text-align: left; + font-weight: bold; + font-size: 1.1em; + margin: -3em 2em 2em 0; + color: #3f83e8; + line-height: 140%; +} + +h2.head span { + font-family: "仿宋"; + font-size: 0.7em; + background-color: #3f83e8; + border-radius: 9px; + padding: 4px; + color: #fff; +} + + +div.logo { + margin: -2em 0% 0 0; + text-align: right; +} + +img.logo { + width: 40%; +} +.imgl { + /*图片居右*/ + margin: -8.8em 1em 4em 0em; + width: 80%; + text-align: right; +} + +h1.head { + line-height:130%; + font-size:1.4em; + text-align: center; + color: #BA2213; + font-weight: bold; + margin-top: 2em; + margin-bottom: 1em; + font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; + +} +h3 { + font-family: "DK-HEITI",黑体,sans-serif; + font-size: 1.1em; + margin: 1em 0; + border-left: 1.2em solid #00a1e9; + line-height: 120%; + padding-left: 3px; + color: #00a1e9; +} +h4 { + font-family: "DK-HEITI",黑体,sans-serif; + font-size: 1.1em; + text-align: center; + margin: 1em 0; + line-height: 120%; + color: #000; +} +h1.post { + font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; + text-align: center; + font-size: 1.3em; + color: #026fca; + margin: 3em auto 2em auto; +} +.banquan { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; + text-align: left; + color: #000; + font-size:1.1em; + margin-bottom:1em; + text-indent: 1em; + duokan-text-indent: 1em; +} +p.post { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; +} +p.zy { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; + margin: 1em 0 0 1em; + padding: 5px 0px 5px 10px; + text-indent: 0em; + border-left: 5px solid #a9b5c1; +} +.sign { + font-family: "DK-KAITI","kt","楷体","zw",serif; + margin: 1em 2px 0 auto; + text-align: right; + font-size: 0.8em; + text-indent: 0em; + duokan-text-indent: 0em; +} + +.mark { + font-family: "DK-HEITI",黑体,sans-serif; + font-size: 0.9em; + color: #fff; + text-indent: 0em; + duokan-text-indent: 0em; + background-color: maroon; + text-align: center; + padding: 0px; + margin: 2em 30%; +} + +/*出版社*/ +.chubanshe img{ + width:106px; + height:28px; +} +.chubanshe { + margin-top:20px; +} +.cr { + font-size:0.9em; +} + +/*多看画廊*/ +div.duokan-image-single { + text-align: center; + margin: 0.5em auto; /*插图盒子上下外边距为0.5em,左右设置auto是为了水平居中这个盒子*/ +} +img.picture-80 { + margin: 0; /*清除img元素的外边距*/ + width: 80%; /*预览窗口的宽度*/ + box-shadow: 3px 3px 10px #bfbfbf; /*给图片添加阴影效果*/ +} +p.duokan-image-maintitle { + margin: 1em 0 0; /*图片说明的段间距*/ + font-family: "楷体"; /*图片说明使用的字体*/ + font-size: 0.9em; /*字体大小*/ + text-indent: 0; /*首行缩进为零,当你使用单标签p来指定首行缩进为2em时,记得在需要居中的文本中清除缩进,因为样式是叠加的*/ + text-align: center; /*图片说明水平居中*/ + color: #a52a2a; /*字体颜色*/ + line-height: 1.25em; /*行高,防止有很长的图片说明*/ +} + + +/*制作说明页*/ +body.description { + background-image: url(../Images/001.png); + background-position: bottom center; + background-repeat: no-repeat; + background-size: cover; + padding: 25% 10% 0; + font-size: 0.9em; +} + +div.description-body { + width: 55%; + padding: 2em 1.3em; + border-radius: 0.5em; + font-size: 0.9em; + border-style: solid; + border-color: #393939; + border-width: 0.3em; + border-radius: 5em; + background-color: #5a5a5a; + box-shadow: 2px 2px 3px #828281; +} + +h1.description-title { + text-align: center; + font-family: "黑体"; + font-size: 1.2em; + margin: 0 0 1em 0; + color: #FF9; + text-shadow: 1px 1px 0 black; +} + +p.description-text { + color: #f9ddd2; + font-family: "准圆"; + margin: 0; + text-align: justify; + text-indent: 0; + duokan-text-indent: 0; +} + +hr.description-hr { + margin: 0.5em -1em; + border-style: dotted; + border-color: #9C9; + border-width: 0.05em 0 0 0; +} + +p.tips { + text-align: justify; + text-indent: 0; + duokan-text-indent: 0; + font-family: "楷体"; + font-size: 0.7em; + color: #FFC; + margin: 0; +} + +/*版本说明页*/ +.ver { + font-family: "DK-CODE","DK-XIHEITI",细黑体,"xihei",sans-serif; + font-weight: bold; + font-size: 100%; + color: #000; + margin: 1em 0 1em 0; + text-align: center; +} + +.vertitle { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; + font-size: 100%; + text-indent: 0em; + text-align: left; + duokan-text-indent: 0em; +} + +.vertxt { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; + line-height: 100%; + font-size: 85%; + text-indent: 0em; + text-align: left; + duokan-text-indent: 0em; +} +.verchar { + font-family: "DK-KAITI","kt","楷体","楷体_gb2312"; + text-align: left; + text-indent: 1em; + duokan-text-indent: 1em; + margin-bottom: 1em; + margin-top: 1em; +} +.vernote { + font-family: "DK-FANGSONG",仿宋,"fs","fangsong",sans-serif; + font-size: 75%; + color: #686d70; + text-indent: 0em; + text-align: left; + duokan-text-indent: 0em; + padding-bottom: 15px; +} + +.line { + border: dotted #A2906A; + border-width: 1px 0 0 0; +} + +.entry { + margin-left: 18px; + font-size: 83%; + color: #8fe0a3; + text-indent: 0em; + duokan-text-indent: 0em; +} +/*版权信息*/ +.vol { + text-indent: 0em; + text-align: center; + padding: 0.8em; + margin: 0 auto 3px auto; + color: #000; + font-family: "方正小标宋_GBK","DK-XIAOBIAOSONG"; + font-size: 130%; + text-shadow: none; +} + +.cp { + font-family: "DK-CODE","DK-XIHEITI",细黑体,"xihei",sans-serif; + color: #412938; + font-size: 70%; + text-align: left; + text-indent: 0em; + duokan-text-indent: 0em; +} + +.xchar { + font-family: "DK-KAITI","kt","楷体","楷体_gb2312"; + text-indent: 0em; + duokan-text-indent: 0em; +} +/*多看弹注*/ +sup img { + line-height: 100%; + width: auto; + height: 1.0em; + margin: 0em; + padding: 0em; + vertical-align: text-top; +} + +ol { + margin-bottom:0; + padding:0 auto; + list-style-type: decimal; +} +.hr { + width:50%; + margin:2em 0 0 0.5em; + padding:0; + height:2px; + background-color: #F3221D; +} + +.duokan-footnote-content{ + padding:0 auto; + text-align: left; +} + +.duokan-footnote-item { + font-family:"DK-XIHEITI",细黑体,"xihei",sans-serif; + text-align: left; + font-size: 80%; + line-height: 100%; + clear: both; + color:#000; + list-style-type:decimal; +} + +li.duokan-footnote-item a { + font-family:"DK-HEITI"; + text-align: left; +} +a{ + text-decoration: none; + color: #222; +} + +a:hover {background: #81caf9} +a:active {background: yellow} +.duokan-image-maintitle { + font-family:"DK-HEITI",黑体,"hei",sans-serif; + text-align: center; + text-indent: 0em; + duokan-text-indent: 0em; + font-size:90%; + color: #1F4150; + margin-top: 1em; +} + +.duokan-image-subtitle { + font-family:"DK-XIHEITI",细黑体,"xihei",sans-serif; + text-align: center; + text-indent: 0em; + duokan-text-indent: 0em; + font-size:70%; + color: #3A3348; + margin-top: 1em; +} \ No newline at end of file diff --git a/app/src/main/assets/font/number.ttf b/app/src/main/assets/font/number.ttf new file mode 100644 index 000000000..f2804dbe9 Binary files /dev/null and b/app/src/main/assets/font/number.ttf differ diff --git a/app/src/main/assets/help/ExtensionContentType.md b/app/src/main/assets/help/ExtensionContentType.md new file mode 100644 index 000000000..84b0babc1 --- /dev/null +++ b/app/src/main/assets/help/ExtensionContentType.md @@ -0,0 +1,156 @@ +```java +public enum MimeTypeEnum { + + AAC("acc", "AAC音频", "audio/aac"), + + ABW("abw", "AbiWord文件", "application/x-abiword"), + + ARC("arc", "存档文件", "application/x-freearc"), + + AVI("avi", "音频视频交错格式", "video/x-msvideo"), + + AZW("azw", "亚马逊Kindle电子书格式", "application/vnd.amazon.ebook"), + + BIN("bin", "任何类型的二进制数据", "application/octet-stream"), + + BMP("bmp", "Windows OS / 2位图图形", "image/bmp"), + + BZ("bz", "BZip存档", "application/x-bzip"), + + BZ2("bz2", "BZip2存档", "application/x-bzip2"), + + CSH("csh", "C-Shell脚本", "application/x-csh"), + + CSS("css", "级联样式表(CSS)", "text/css"), + + CSV("csv", "逗号分隔值(CSV)", "text/csv"), + + DOC("doc", "微软Word文件", "application/msword"), + + DOCX("docx", "Microsoft Word(OpenXML)", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + + EOT("eot", "MS Embedded OpenType字体", "application/vnd.ms-fontobject"), + + EPUB("epub", "电子出版物(EPUB)", "application/epub+zip"), + + GZ("gz", "GZip压缩档案", "application/gzip"), + + GIF("gif", "图形交换格式(GIF)", "image/gif"), + + HTM("htm", "超文本标记语言(HTML)", "text/html"), + + HTML("html", "超文本标记语言(HTML)", "text/html"), + + ICO("ico", "图标格式", "image/vnd.microsoft.icon"), + + ICS("ics", "iCalendar格式", "text/calendar"), + + JAR("jar", "Java存档", "application/java-archive"), + + JPEG("jpeg", "JPEG图像", "image/jpeg"), + + JPG("jpg", "JPEG图像", "image/jpeg"), + + JS("js", "JavaScript", "text/javascript"), + + JSON("json", "JSON格式", "application/json"), + + JSONLD("jsonld", "JSON-LD格式", "application/ld+json"), + + MID("mid", "乐器数字接口(MIDI)", "audio/midi"), + + MIDI("midi", "乐器数字接口(MIDI)", "audio/midi"), + + MJS("mjs", "JavaScript模块", "text/javascript"), + + MP3("mp3", "MP3音频", "audio/mpeg"), + + MPEG("mpeg", "MPEG视频", "video/mpeg"), + + MPKG("mpkg", "苹果安装程序包", "application/vnd.apple.installer+xml"), + + ODP("odp", "OpenDocument演示文稿文档", "application/vnd.oasis.opendocument.presentation"), + + ODS("ods", "OpenDocument电子表格文档", "application/vnd.oasis.opendocument.spreadsheet"), + + ODT("odt", "OpenDocument文字文件", "application/vnd.oasis.opendocument.text"), + + OGA("oga", "OGG音讯", "audio/ogg"), + + OGV("ogv", "OGG视频", "video/ogg"), + + OGX("ogx", "OGG", "application/ogg"), + + OPUS("opus", "OPUS音频", "audio/opus"), + + OTF("otf", "otf字体", "font/otf"), + + PNG("png", "便携式网络图形", "image/png"), + + PDF("pdf", "Adobe 可移植文档格式(PDF)", "application/pdf"), + + PHP("php", "php", "application/x-httpd-php"), + + PPT("ppt", "Microsoft PowerPoint", "application/vnd.ms-powerpoint"), + + PPTX("pptx", "Microsoft PowerPoint(OpenXML)", "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + + RAR("rar", "RAR档案", "application/vnd.rar"), + + RTF("rtf", "富文本格式", "application/rtf"), + + SH("sh", "Bourne Shell脚本", "application/x-sh"), + + SVG("svg", "可缩放矢量图形(SVG)", "image/svg+xml"), + + SWF("swf", "小型Web格式(SWF)或Adobe Flash文档", "application/x-shockwave-flash"), + + TAR("tar", "磁带存档(TAR)", "application/x-tar"), + + TIF("tif", "标记图像文件格式(TIFF)", "image/tiff"), + + TIFF("tiff", "标记图像文件格式(TIFF)", "image/tiff"), + + TS("ts", "MPEG传输流", "video/mp2t"), + + TTF("ttf", "ttf字体", "font/ttf"), + + TXT("txt", "文本(通常为ASCII或ISO 8859- n", "text/plain"), + + VSD("vsd", "微软Visio", "application/vnd.visio"), + + WAV("wav", "波形音频格式", "audio/wav"), + + WEBA("weba", "WEBM音频", "audio/webm"), + + WEBM("webm", "WEBM视频", "video/webm"), + + WEBP("webp", "WEBP图像", "image/webp"), + + WOFF("woff", "Web开放字体格式(WOFF)", "font/woff"), + + WOFF2("woff2", "Web开放字体格式(WOFF)", "font/woff2"), + + XHTML("xhtml", "XHTML", "application/xhtml+xml"), + + XLS("xls", "微软Excel", "application/vnd.ms-excel"), + + XLSX("xlsx", "微软Excel(OpenXML)", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + + XML("xml", "XML", "application/xml"), + + XUL("xul", "XUL", "application/vnd.mozilla.xul+xml"), + + ZIP("zip", "ZIP", "application/zip"), + + MIME_3GP("3gp", "3GPP audio/video container", "video/3gpp"), + + MIME_3GP_WITHOUT_VIDEO("3gp", "3GPP audio/video container doesn't contain video", "audio/3gpp2"), + + MIME_3G2("3g2", "3GPP2 audio/video container", "video/3gpp2"), + + MIME_3G2_WITHOUT_VIDEO("3g2", "3GPP2 audio/video container doesn't contain video", "audio/3gpp2"), + + MIME_7Z("7z", "7-zip存档", "application/x-7z-compressed") +} +``` \ No newline at end of file diff --git a/app/src/main/assets/help/SourceMBookHelp.md b/app/src/main/assets/help/SourceMBookHelp.md new file mode 100644 index 000000000..e14590232 --- /dev/null +++ b/app/src/main/assets/help/SourceMBookHelp.md @@ -0,0 +1,25 @@ +# 书源管理界面帮助 + +* 书源右上角标志 + * 绿点表示书源有发现,且启用了发现 + * 红点表示书源有发现,但是未启用 + * 没有标志表示此书源没有发现 +* 右上角有分组菜单,可以按分组筛选书源 +* 右上角更多菜单里包含 + * 新建书源 + * 本地导入 + * 网络导入 + * 二维码导入 + * 分享选中源 +* 选择源的更多操作在右下角的菜单里面,操作都是针对选择的书源 + * 启用所选 + * 禁用所选 + * 添加分组 + * 移除分组 + * 启用发现 + * 禁用发现 + * 置顶所选 + * 置底所选 + * 导出所选 + * 校验所选 +* 校验失败的书源分组会加上"失效",选择"失效"分组即可批量操作 \ No newline at end of file diff --git a/app/src/main/assets/help/SourceMRssHelp.md b/app/src/main/assets/help/SourceMRssHelp.md new file mode 100644 index 000000000..5f29a5128 --- /dev/null +++ b/app/src/main/assets/help/SourceMRssHelp.md @@ -0,0 +1,21 @@ +# 订阅源管理界面帮助 + +* 订阅源可以通过规则订阅一些网络内容 +* 书源右上角标志 + * 绿点表示书源有发现,且启用了发现 + * 红点表示书源有发现,但是未启用 + * 没有标志表示此书源没有发现 +* 右上角有分组菜单,可以按分组筛选书源 +* 右上角更多菜单里包含 + * 新建订阅源 + * 本地导入 + * 网络导入 + * 二维码导入 + * 分享选中源 +* 选择源的更多操作在右下角的菜单里面,操作都是针对选择的书源 + * 启用所选 + * 禁用所选 + * 置顶所选 + * 置底所选 + * 导出所选 +* 校验失败的书源分组会加上"失效",选择"失效"分组即可批量操作 \ No newline at end of file diff --git a/app/src/main/assets/help/appHelp.md b/app/src/main/assets/help/appHelp.md new file mode 100644 index 000000000..63c170809 --- /dev/null +++ b/app/src/main/assets/help/appHelp.md @@ -0,0 +1,190 @@ +# 帮助文档 + +## **新人必读** + +【温馨提醒】 *本帮助可以在我的-右上角帮助按钮再次打开,更新前一定要做好备份,以免数据丢失!* + +1. 为什么第一次安装好之后什么东西都没有? +* 阅读只是一个转码工具,不提供内容,第一次安装app,需要自己手动导入书源,可以从公众号 **[开源阅读]**、QQ群、酷安评论里获取由书友制作分享的书源。 + +2. 正文出现缺字漏字、内容缺失、排版错乱等情况,如何处理? +* 有可能是净化规则出现问题,先关闭替换净化并刷新,再观察是否正常。如果正常说明净化规则存在误杀,如果关闭后仍然出现相关问题,请点击源链接查看原文与正文是否相同,如果不同,再进行反馈。 + +3. 漫画源看书显示乱码,如何解决? +* 异次元和阅读是两个不同的软件,**两个软件的源并不通用**,请导入阅读的支持的漫画源! + +## 书源相关 + +1. 如何导入本地书源文件? +* 下载群文件里的书源文件(书源格式后缀有txt、json,其中json文件某些情况下无法导入,需要修改后缀为txt格式才可导入); +* 打开阅读软件; +* 我的 - 点击“书源管理”; +* 点击右上角选择“本地导入”; +* 左下角选择书源文件所在的路径; +* 点击书源文件导入; +* 导入后返回书源管理界面; +* 新版qq下载路径:Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/ + +![QQ导入书源](https://cdn.jsdelivr.net/gh/gedoor/gedoor.github.io@master/images/importSource.jpg) + +2. 如何新建大佬发的单独书源? +* 复制书源代码; +* 打开阅读软件; +* 我的 - 点击“书源管理”; +* 右上角选择“新建书源”; +* 进入新建书源后点击右上角“粘贴源”; +* 粘贴书源完成后点击上方保存; +* 本次新建单独书源操作完成。 +* 注:如果书源有错误或者复制不全会显示格式错误,请重新复制。 + +3. 为什么导入2.0书源后无法阅读? +* 部分2.0书源并不适用于3.0版本的阅读,建议导入后进行筛选。 + +4. 阅读2.0数据如何导入阅读3.0? +* 先对阅读2.0的数据进行备份,然后进入阅读3.0,点击“我的”,选择“备份与恢复”,再点击“导入旧版本数据”。 + +5. 如何给朋友分享我的书源? +* 打开阅读软件; +* 点击备份; +* 打开手机自带的文件管理; +* 手机自带内存根目录找到YueDu3.0文件夹; +* 找到myBookSource.json长按选择分享; +* 选择微信分享或者QQ分享; +* 选择你要分享的好友点击发送; +* 好友接收后在手机自带内存根目录找到myBookSource.json文件(最新版QQ 安卓10及以下版本在Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/,安卓11版本用户由于系统限制无法访问data目录,微信在Tencent/MicroMsg/Download); +* 复制该文件到手机自带内存根目录找到YueDu3.0文件夹(如已有该文件请先删除该文件或者备份到其他地方再复制到文件夹); +* 打开阅读软件点击恢复。 +* 注:备份路径如已修改过请在修改后的路径下查找书源文件。 + +6. 效验书源显示失效就说明书源不能用了吗? +* 效验书源只是测试书源,可以做个参考,失效了不代表书源不能用了。 + +7. 发现和正版书源能不能使用? +* 发现和正版书源只能用来找书,看排行榜,不能用来看书,如需看书请切换书源。 + +8. 为什么书源这么多,发现里却只有一点点? +* 书源想要在发现界面里显示需要在书源里添加发现规则,并不是所有书源都有发现规则。 + +## 本地书籍相关 +1. 目前阅读支持哪些格式的本地书籍? +* 目前支持TXT、EPUB格式 + +2. 如何导入本地书籍? +* 在书架页面点击右上角,选择“添加本地”,授予相关权限后即可导入本地书籍。也可在文件管理器中使用 **阅读** 打开相关书籍。 + +3. 导入TXT文件提示“LoadTocError”或“List is empty”是怎么回事? +* 请先去应用详情中确认是否授予了阅读“读写手机存储”的权限。 +* 自动识别目录失败,可能是相关目录规则未开启,请点击右上角的换源按钮手动更换目录规则。 +* 如果尝试所有规则均无法识别,请在github上提交issue并附件相关txt文件,也可以发送邮件至i@qnmlgb.trade(标题:legado本地文件章节无法识别,内容对其具体情况进行简要说明,附件上传相关txt文件)。 + +4. 如何下载书籍到本地? +* 把在线书籍加入到书架后,在书架页面点击右上角,选择“离线缓存”即可。 + +5. 如何自定义导出的txt/epub文件名称? +* 点击 **离线缓存** - **导出文件名**. +* 使用方法: + - 导出文件名支持js语法 + - 可用变量: name - 书名 author-作者 + - 示例: + > {name+"作者:"+author} + - 导出文件名: + > Legado是最好的在线阅读软件 作者: kunfei. + +**注意:** name、author等变量与字符串的拼接都需要在js环境中进行,即必须使用{ } 将变量与字符串包裹起来. + +6. 为什么我打开本地的TXT文件,显示内容却是乱码? +* 部分编码在阅读上会识别错误,建议先用文本编辑器转换为常用的UTF-8格式。 + +7. 阅读对部分把正文(如所有含引号的句子)识别成标题,如何解决? +* 点击右上角更换目录规则即可。 + +## 书籍界面相关 + +1. 如何刷新书架? +* 在书架界面下拉即可刷新。 + +2. 书架界面书籍右上角的红色或者灰色背景小数字代表什么? +* 红色代表书籍有更新,灰色代表无更新,数字代表未读章节。 + +3. 如何查看书籍详情? +* 长按书籍。 + +4. 如何对书架上的书进行删除、切换书架的操作? +* 书籍详情页操作即可。 + +5. 如何禁止或允许某本书更新? +* 书籍详情页,点击右上角 - “允许更新”。 + +6. 如何更换小说封面、名字、作者或简介? +* 书籍详情页,点击右上角修改按钮。 + +7. 怎么使用自定义字体? +* 阅读界面 - 字体-点击右上角选择字体文件路径。 + +8. 目前支持哪些格式的字体文件? +* 目前支持ttf、otf格式。 + +9. 书籍经常“正在加载中”怎么办? +* 在线书籍出现这个问题通常是由于源质量不好或不兼容引起的,可以换其它源多试试;本地书籍出现这个问题大概率是目录规则问题,手动切换规则可以解决。 + +10. 书籍内容只有标题,正文内容是路径怎么办? +* 通常是缓存路径引起的,更换缓存路径即可。 + +11. 看书时如遇到“目录为空”、“加载失败”和长串英文等情况怎么办? +* 在线书籍一般是书源问题,切换或更新书源即可。本地书籍请尝试手动更换目录规则。 + +12. 为什么每一章的最后一页,阅读的文字和横线背景总是对不齐? +* 请在 设置-文字底部对齐 选项中关闭底部对齐,再调整排版。 + +13. 漫画源或图片章节只能看到第一页,如何解决? +* 请先查看原网页是否正常,若正常,请在书籍阅读界面点击右上角的 **⁝** 按钮,在弹出的菜单中,选择 **翻页动画(本书)**,将翻页动画更改为 **滚动**。 + +14. 阅读图片章节、漫画或epub插图时,图片被缩放到一页中,以至无法看清,如何处理? +* 临时处理方案:长按图片可以进行双指缩放。图片章节请先参考Q13中的方案将翻页动画更改为**滚动** +* 3.0旧版可以点击书籍界面的章节标题进入 **编辑书源** 界面,在 正文-图片样式 中填入 *full*,保存更改,刷新当前章节即可。 +* 3.0新版可以直接在书籍阅读界面点击右上角的 **⁝** 按钮,选择 图片样式- *full*. + + +## 替换净化相关 +1. 替换净化是什么? +* 替换净化可以去除书籍内容里的广告、错别字、屏蔽词等。 + +2. 如何自己填写净化替换规则? +* 第一行:替换规则名称 - 根据自己需求对替换净化规则进行命名; +* 第二行:分组 - 净化规则的分组组别; +* 第三行:替换规则 - 填写需要被替换的内容; +* 第四行:替换为 - 填写想替换成的内容(如不填则默认表示删除第三行里填写的内容); +* 第五行:替换范围,选填书名或者源名 - 填写此替换净化规则需要对哪本书籍或者哪个书源生效(如不填则对所有书籍和书源生效)。 +* 注:如常规去除方法去除不掉,则需要勾选“使用正则表达式”,同时第三行里的替换规则也需要按照正则表达式来填写(正则表达式填写方法可自行百度学习)。 + + +## 备份相关 + +1. 云备份在哪? +* 我的 - 备份与恢复 - WebDav设置。 + +2. 如何操作进行云备份? +* 侧栏设置,WebDav设置; +* 正确填写WebDAV 服务器地址、WebDAV 账号、WebDAV 密码;(要获得这三项的信息,需要注册一个坚果云账号,如果直接在手机上注册,坚果云会让你下载app,过程比较麻烦,为了一步到位,最好是在电脑上打开这个注册链接:https://www.jianguoyun.com/d/signup ;注册后,进入坚果云;点击右上角账户名处选择 “账户信息”,然后选择“安全选项”;在“安全选项” 中找到“第三方应用管理”,并选择“添加应用”,输入名称如“阅读”后,会生成密码,选择完成;其中 https://dav.jianguoyun.com/dav/ 就是填入“WebDAV 服务器地址”的内容,“使用情况”后面的邮箱地址就是你的“WebDAV 账号”,点击显示密码后得到的密码就是你的“WebDAV 密码”。) +* 无需操作,APP默认每天自动云备份一次。 + +3. 关于云备份的相关说明 +* 在正确设置好云备份的情况下,APP默认每天自动云备份一次,当日多次手动云备份会对当日的旧云备份文件进行覆盖,并不会覆盖之前及之后不同日期的备份文件,每天所自动云备份的文件会按照日期进行命名。 + +4. 本地备份和云备份都能备份哪些东西? +* 书架、看书进度、搜索记录、书源、替换、APP设置等都会备份,基本涵盖所有内容。 + +5. 出现某些未知bug怎么办? +* 清除软件数据试试看,不行再进行反馈。 + + +## 其他 +1. 如何听书? +* 可以使用手机自带的朗读引擎,也可使用第三方如谷歌、小米等朗读引擎。 +* 【具体操作:安装-系统设置-其他高级设置-辅助功能-TTS输出-选择安装的朗读引擎(不同品牌手机的操作方法及步骤也不同,视情况而定)。】 + +2. 如何设置屏幕方向、屏幕显示时长、显示/隐藏状态栏、显示/隐藏导航栏、音量键翻页、长按选择文本、点击总是翻下一页、自定义翻页案件? +* 阅读界面,设置(可上划,下面还有其他设置)。 + +3. 搜索的时候感觉手机卡顿,如何解决? +* 我的 - 其他设置 - “更新和搜索线程数”调低。 diff --git a/app/src/main/assets/help/debugHelp.md b/app/src/main/assets/help/debugHelp.md new file mode 100644 index 000000000..dcfd3677e --- /dev/null +++ b/app/src/main/assets/help/debugHelp.md @@ -0,0 +1,22 @@ +# 书源调试 + +* 调试搜索>>输入关键字,如: +``` +系统 +``` +* 调试发现>>输入发现URL,如: +``` +月票榜::https://www.qidian.com/rank/yuepiao?page={{page}} +``` +* 调试详情页>>输入详情页URL,如: +``` +https://m.qidian.com/book/1015609210 +``` +* 调试目录页>>输入目录页URL,如: +``` +++https://www.zhaishuyuan.com/read/30394 +``` +* 调试正文页>>输入正文页URL,如: +``` +--https://www.zhaishuyuan.com/chapter/30394/20940996 +``` diff --git a/app/src/main/assets/help/httpTTSHelp.md b/app/src/main/assets/help/httpTTSHelp.md new file mode 100644 index 000000000..0f261888a --- /dev/null +++ b/app/src/main/assets/help/httpTTSHelp.md @@ -0,0 +1,15 @@ +# 在线朗读规则说明 + +* 在线朗读规则为url规则,同书源url +* js参数 +``` +speakText //朗读文本 +speakSpeed //朗读速度,0-45 +``` +* 例: +``` +http://tts.baidu.com/text2audio,{ + "method": "POST", + "body": "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{String((speakSpeed + 5) / 10 + 4)}}&per=5003&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=1&vol=5&pit=5&_res_tag_=audio" +} +``` \ No newline at end of file diff --git a/app/src/main/assets/help/jsExtensions.md b/app/src/main/assets/help/jsExtensions.md new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/assets/help/readMenuHelp.md b/app/src/main/assets/help/readMenuHelp.md new file mode 100644 index 000000000..85a976046 --- /dev/null +++ b/app/src/main/assets/help/readMenuHelp.md @@ -0,0 +1,58 @@ +# 阅读界面帮助文档 + +## 阅读界面主菜单 +* 顶部操作 + * 章节名称:点击可编辑书源 + * 章节url:点击可打开浏览器浏览 + * 菜单:**不同类型的书籍显示的菜单不同**。详情请查看菜单文字,长按菜单图标可显示文字 +* 中间左侧-亮度调节 + * 亮度调节的顶端有跟随系统亮度的开关,打开后亮度跟随系统,关闭后才可以调节亮度条 +* 底部操作 + * 4个圆形按钮依次为 全文搜索✧自动翻页✧替换净化✧切换夜间模式 + * 上一章✧下一章中间的进度条为页数进度,要快速跳转章节点击目录按钮进入目录快速跳转 + * 目录->目录和书签界面 + * 朗读->单击开始朗读,长按进入朗读设置界面 + * 界面->所有排版设置都在里面 + * 设置->其它一些设置,找不到的设置去这里看看,可滚动 + +## 朗读设置界面 +* 后台->进入后台朗读,可以做一些其它事 +* 设置->朗读引擎设置,可以切换本地TTS和在线朗读,在线朗读可自定义 + +## 排版设置界面 +* 白天模式和夜间模式背景不同布局相同 +* 共用布局->启用共用布局时所有背景使用同一布局,关闭共用布局则每个背景单独布局 +* 长按背景可进入文字颜色和背景设置界面 + +## 其它设置界面 +* 屏幕方向 +* 屏幕超时 +* 隐藏状态栏 +* 扩展到刘海 +* 隐藏导航栏 +* 文字两端对齐 +* 文字底部对齐 +* 音量键翻页 +* 点击翻页 +* 朗读时音量键翻页 +* 自动换源->书源被删除时自动切换到其它书源 +* 长按选择文本 +* 显示亮度调节控件 +* 点击区域设置 +* 自定义翻页按键 + +## Txt目录正则说明 + +### 菜单区 + +- 新增目录规则,当Legado自带的规则不能够满足需求时,用户可根据自己的情况自定义目录规则 +- 导入默认规则 在旧版本中,Legado自带的规则不会随着软件的更新而更新。用户需要使用最新规则或对自带规则修改后需要重置时,可点击 导入默认规则。**注意:导入默认规则不会重置用户自定义的规则,但如果您对自带的规则进行了修改,则修改的规则会被重置为默认规则。** +- 导入在线规则 为了方便异步调试以及用户导入他人的目录规则,Legado增加目录规则的网络导入功能。点击 网络导入 的输入框,可以通过内置的链接导入在线规则。(在线规则优先比内置的规则更加激进,但适配了更多类型的标题格式,用户需根据自己的情况选择是否导入) + +### 操作区 +![Functions][example] + - 按钮① 当前书籍规则 如果Legado的自动识别的目录情况不太理想,用户可以手动点击各个规则前面的按钮,临时对本书启用该规则,该按钮仅**针对当前书籍生效**。 + - 按钮组② 左边的开关被点亮时,表示该规则针对**所有TXT书籍生效**。开启后会对所有的TXT格式的书籍启用当前规则扫描符合条件的标题格式;中间的按钮表示编辑当前规则,当识别出的目录与你所期望的不一致时,可以修改当前规则以适应你所导入的书籍;右边的按钮表示删除当前规则,当用户不需要当前规则时可直接删除。(默认的规则删除后可点击 导入默认规则 按钮恢复) + - 按钮组③ 在当前界面进行操作后,需要点击确认按钮使得选择生效。 + +[example]:data:image/jpg;base64,/9j/4RZnRXhpZgAATU0AKgAAAAgADAEAAAMAAAABBDgAAAEBAAMAAAABCWAAAAECAAMAAAADAAAAngEGAAMAAAABAAIAAAESAAMAAAABAAEAAAEVAAMAAAABAAMAAAEaAAUAAAABAAAApAEbAAUAAAABAAAArAEoAAMAAAABAAIAAAExAAIAAAAiAAAAtAEyAAIAAAAUAAAA1odpAAQAAAABAAAA7AAAASQACAAIAAgACvyAAAAnEAAK/IAAACcQQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpADIwMjA6MTE6MjQgMTY6NDQ6MjIAAAAABJAAAAcAAAAEMDIyMaABAAMAAAAB//8AAKACAAQAAAABAAABLKADAAQAAAABAAACWAAAAAAAAAAGAQMAAwAAAAEABgAAARoABQAAAAEAAAFyARsABQAAAAEAAAF6ASgAAwAAAAEAAgAAAgEABAAAAAEAAAGCAgIABAAAAAEAABTdAAAAAAAAAEgAAAABAAAASAAAAAH/2P/tAAxBZG9iZV9DTQAB/+4ADkFkb2JlAGSAAAAAAf/bAIQADAgICAkIDAkJDBELCgsRFQ8MDA8VGBMTFRMTGBEMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAENCwsNDg0QDg4QFA4ODhQUDg4ODhQRDAwMDAwREQwMDAwMDBEMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM/8AAEQgAoABQAwEiAAIRAQMRAf/dAAQABf/EAT8AAAEFAQEBAQEBAAAAAAAAAAMAAQIEBQYHCAkKCwEAAQUBAQEBAQEAAAAAAAAAAQACAwQFBgcICQoLEAABBAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHxY3M1FqKygyZEk1RkRcKjdDYX0lXiZfKzhMPTdePzRieUpIW0lcTU5PSltcXV5fVWZnaGlqa2xtbm9jdHV2d3h5ent8fX5/cRAAICAQIEBAMEBQYHBwYFNQEAAhEDITESBEFRYXEiEwUygZEUobFCI8FS0fAzJGLhcoKSQ1MVY3M08SUGFqKygwcmNcLSRJNUoxdkRVU2dGXi8rOEw9N14/NGlKSFtJXE1OT0pbXF1eX1VmZ2hpamtsbW5vYnN0dXZ3eHl6e3x//aAAwDAQACEQMRAD8AfHw7cisvqc0vD21Np929znhzqwz2+l7vSs+lb/3xFb0u91T7fUrAYz1IDt0j0vtf0mbm/wA0mdhZWO91TsjHpsYSHs+10tcHDcxzXt9b6bdz2Jw3MDS0Z2PtIII+2UwQQWO/w37rluy5rFZrmMP1nD+LzkPh/M0L5LmSe8cWXhl/zWTukZDHUB9jGfaZ2F27SNn0trHe39J9Nns9iiemX/ZnZQsrNbN8gF0n0ztdt9qkHdQa4OHUKQ5o2tP22mQPAfplEsy3NLTm45a4HcPtlEEHV/8Ahvzvz0BzOPS+Yw+PrguPw/mNa5LmdtP1WX/vWnqlKvY3Q+o5e/7IKcn049T0r6X7Znbv22+3dtR/+avX/wDuL/4LV/6UUw5jAdsuP/Hi1TyfNRNSwZYnsccwf+i5UqdFNmRcymuN1hDRJgamO60v+avX/wDuL/4LV/6USH1W+sAIIxYIMgi2uQR/1xA8xhrTLj/xoqHK57F4MhHX0S/71y3Nc1xa7QjnUH8WqRYWGskg72h+naSRB/zVpf8ANXr/AP3FH/blX/pROfqz9YBDn40hg0m2sw0e6B+k+ikeYw1/O4/8aKRyue/5nJvp6JP/0Oqqu62y9rcJpON9qy93tJbP2nI2y4Mcz32+zI9W2r0sf9LR+lV+/K+szbLxRiVWVi2tuOS4CaiX+vZZN/5rfSd9Fik/p+HXcaxn34773usbQ28Ml1j3WP8ATrI3e+170T9jO/7m5v8A29/5gmjiAArYVu2sxw5JGXEI2Sfk9XqPF6kV+V9Y2m70MStways0EkEusJ/WGbfXr9rWO/RbvT/mrP8ASVpOPXMg203VVV172uqfOhDDZZts/SWWena+nG3/AKP+Zyrf9Eifsj/u9mf9vf8AmCX7I/7vZn/b3/mCVy7fixiGIEEZNjfyFp9Fwbsbq+T6p2FmMwtra8vBF+TnZP6R7wHPsq/m2v8A+uf4Rbiq4mBXi2WWi226y1rGOfc7edtZe5jW6N/OusVpKAoV5q5nJ7mQyBvSIuq2j6v+e431kyPrHRi2v6LS2wtrB3BofYHbv0hrqe73vZX/ADTPSt3oPR8n6w3fV1t3Um2V55uaA4Vt9Y45trbZc/G2bPWbSb/8B9Bnqekt2yyuqt1tr2111gufY8hrWgfSc5zvotaoNy8V+P8Aa231uxiNwvD2msidu71Z9P6Scg5P1XB7cd/5yvX/AHeJBiPzndN3vB+2bbNgtaASQX/Z/VY30G+5vpb/AOj/APF0IPSbOpv6dc7qYIul+zc0Mds2B3uaza3+dNjG+yv2f6T+es0K3stY19Tg9jxLXtILSD3a5vtcomyuzHfZU5r2FrwHNIcJAc13ub+64JMT/9H0PIwX2vt22NbVkbBc0s3P9nt/RWbhs9v7zH+lZ+mq96qZP1aw8l97n33t+02susDCwasNjmt3bHO2/pf66uX5rqnWltQfVj7Te8vDXDcN36KrafV2sO76dX+jp9SxVL/rL0rHsurtc8Ox7a6Hw0GX2F7GBnu93uqckp1TqZSWVkfWXpmO6wWiwNr9OX7WwfVLNu39Ju/Rtursu9v6KtSq+sOBbbZUxtpfTYyl4DQfdZvj6D3/AEW1u9v85/omWWJKdNAyc7BxNv2vJqx98lvqvayY52+o5viiU2suqZaydrxInnw7bmrA+smPZkZrK6sd+RYcSwM9IgOYTbT+m2ufX6uz6bK/9Iz07fTostTZkgWGblsccmQRmSI0SSKG395h9YMb6t9bqey3q1FFpYGNcLqnNBY71GF1bnfvfTa17N6j0nH6H0voo6ZX1nFss9duSbnWVlvqMsrva1tHrfzX6Bjf53/hFtVX/s/posyWOa1rjFctc5jHvd6FRdu2OdVWWMdssf8AyPU+mj5OWKcYXtb6m91TK2aNJNz2U17i76Hut96XqrcfZ/6EvlmjwnFU5YwduOHTr/NObTm9BZgOwrep4trbBaLX+tWJ9YvfZ7X2Wf6X891n8tQ6Q/ouDi2YeL1DGyLb3OeBW+sOc41tpaxldb3ud7alp157X4LswsIbWLC5ggn9EXss2bvT+l6X6Pf6f9hZlvU2dU6bXe2l1OzOxqy15YdfVotDmuqc/wDMsYkeIC7H2f2rIDFOQiIzHEavjifw9t//0vSn42PZa26ypr7WRseQCRB3N/zXe5insZJO1snUmBJKpZTcs3v9MW7iGfZXMdFTT/hftTJ93u/nPUa/9B/RP1hVrx9Z/Wv+znH9M3V/Z98CKZf6+/2uc5+z0v8AydaSnVNdZkljTPPtH9ycNYDIa0EAAEAcDj8qycwfWl7b2YTsat7mVjHfaAQ14c12Q9+3duY9j7K6m+n+j9Fn+l/R2Mk9aN2I7HbU2rUZjHETMs2urPu/RbfV/wCESU3xAEAQBoAFUyun/aMhmSzIuxrmMdVupLNWOc2yHC+q/wDOr/NVK7/nQWA1egHGh0zE+sWv9Pc125v6N3pb9lnpKeI36yfaa35bqPs7q6/UrYBLbB6X2k7vpbbP1j0tn/BoEA7roTlA3Hfbvv4SS/svImf2llk+foEfccVNb0iy6s13dQyba3fSY8Y7mnv7mPxC1Wsv7UaCMT25EiC6NsT7pLw5n0VHOGYcKMfccjdVu9Eta4t31/avROR+jb+h9Xbu/wCrQ4R4/aV/vz7Q/wDC8f8A3rXr6TbUwV1dQyq62/RYwY7Wj4MbibVAdFZXWB9qvdVS4XCiKWVl9Z9Wve3Hx6XfzjG/nqzjjPPTS2yWZhbaGGwtc4GX/ZvUczfU/az0v+/oPS29UbgX/tRznXkvLN5rLgzY3l2M1lf876qXCPH7Sr359OEeUMYP28L/AP/T9JsyceuxtVjw17o0M6bjsr3ujbX6r/ZV6n86/wDm1M2Vjl7RBDTLhyfot/rIF2H6jrALCyrIgZFYAO7aNntefdV6lY9K3+R/Nejb+lVR31a6S+3Iuexzn5VzMi33R763Gxv0Wtds93vZu+gkp0jZWOXtEan3Didvj+8n3skje2QYI3DQ/urKyfqv0jJ3+ox82hrbCHD3BhqNQdLdv6P7OxTP1c6SbTd6bhYbWXbg4iHVt9JrWx7Wsc3fu2/6R6SnRZZW+dj2vjna4GP834qSo9N6Nh9Nn7Nv1Lz7yD/Oel6n0Ws/7j1K8kpi97GDc9wY3iXEAT8XJrrqcet1t9jaq2xue8hrRJ2t1P7znbWqGTjMyaXUWk+k4gkN0Jg7gN3uSyMVl2OMcOdUGmtzHMglpqcy2r+cD2u91XuSUzZfTZSL2WNdSQXCwEbYH0ju/kodeVi5WNZbi3V5FYD2l9Tg9sgGW7mF3uTVYVVeG7Dc51tbxYHufG53ql77Zgf8K5Cw+nVdPw7qanvs9Tc9z7ILidjahwP3KmJKf//U9DyM2yp9pa1hrxgw2tc6LHep9H0G/R/kU7v6Tkfq/wCi/nFUs+smOy3JqGLkWHFuZQ4sDHbnWONbXVtFnqbdzfz2rUdVW97bHMa59c7HloLmzzsd9JilJ8UlOVmfWHGw33NfRc/7O9tb3NAj3B79zTu+j+icms+sVFdVlr8XI21Xeho0Eklr7W2c+2t3p/8AgtS15PiluPikpx2fWbEfaK/RtAOT9l3nZtB949VxDvbX7P8AjH71c6Z1FnUsUZLKrKBuLTXcAHAgNd+aXfvbf+MVzc7xTSTykpxvrF9Y2dDpc847r3BjXNM7GS53pNDnw76P+EUOlfWX9o9CHVfs4qeLm4zq3v21lzrK8f1WX7Hfof03+i+n+hW25rXsdW8B7HgtcxwkEHQtc0/SUW0U11ChlbGUtG0VBoDAP3fTjZtSZDLH7fDwfrL/AJzi/R7cDXoz/W6c7OFc7W2n02O3Sai9hDHuFf0/S/wjGf8ACIPSuqHqnT78g1CksL69rX+oD+jbbua4spd9G3/R/wDgfvWg0BoDWgNDRAA0AA8IUXta2h7WgNaGO0Gg1B8EmN//1e/yqMp97nMa9zjs+z3Ns2tpj+c9SncPU93v9rLftH9Gt9Jijk43XLH2mjLZUw2tdSyBpWAWWUPs9F387/P+p/g/5n/hFatzMaqz03uIcNu4hri1u4wz1bGjZVu/lp7M3CqLhZk1MLHBrw6xrdrnasY/c72vfHtSU0aMX6w+ow5GcwsGS572ta0zjx7KP5ir9Jv9v0voe/1N7ErMb6xm1jq8yoMFxc5hHNMscyufSd72s3s/9Wfo7tmfgVBxtyqawx21++xrYdqdjtzvp+x/tSfn4FZeLMqlhqE2h1jRtE+nNnu9n6T9H/XSU0a8T6xNZQHZ9dhZY43ktAL65r2NBbX9Juy783/Df8Glg431jrsxnZuXVaxhf9qYIl4cGirY8Y9X808Pfs21/wDGrV0Oo1B4ISSU431kwvrBlY1g6Pkik+mAKw703ucHbrNlu32vsr9jH+pX6aD0fA6/R9XG43UbH2Z3rNeGi6LG0C2t9mMcxrvdZ6Tb/wDC/wCE9H1VrZ3U+ndPrdbnZDKGsbvO4+7bO321tl7/AHfR2qGP1jpuVgDqOPf6uKXbA9rXl28uFLavR2+v6rrHsb6fp70mYzyezw8A4OL5+H9L93jVi05remejY8syi2wNeXbywuL/AEP0j/V3ekx1f0/X/wCMvQek43UMfp11fULDba4vczc82FrCxvt9R0v/AJ31X/T/AO2/5mu7XlY9mOckPDaQHFz3ywN2SLfVFux1XpbXep6n82h0ZuJm4ttuJaLWNDmOIkEODd21zXhr2+1zXt/4P9J9BJhf/9b0a7Crte8myxrLY9appAY/b7ffLXP9zP0dnpvr9StCs6RgWPte5tm66xtth9V/02Eurc33ezY538239Eo5WfZTe9jXVN9PZtoeD6l2/wD0BDh/xTPZb+m/nPTrQ7es20Pu39PyX1Uuc0PpYXuO07QTXtr9trd11fpW3s9L+e9G2z0klJLuhdJufZY+gh9zxbY5j3sl43+/2OH+lsSs6H0i31N+M0m2sVPdLt20EPbtfO5r2vYx/qfT3qFfWXWOhvT8uHt31PLBsI2h7dz93s3v/RpmdatLXA9OyfVYCSA32OIn21XPFbrPa3d/Nf8AF70lOk0Na0NaIa0AADsBoE6zX9YvLHHH6bl2WAw1r2itrj+d7/0m3b/KYp09TtutbUMDJql7q3PtaGtDmbN7twLt1Wx1jqrv5u70vTYkpbq3Qum9XqfXmMdue0M9Wtxa4bTvY5v5jnVu+jvYodP+r+B07pg6biusbX6gvNpINhta5lzbXHZ6ftdTV7fS9PYgfWTruX0fFttx8I5G2sPFp3em0l2z9J6bT7Kfp2/pK0Lo/wBY8rP+ro6rfVXjWes2kvduFJaba6HZTZdv9Gv1Xf4X6dP86kzmOb2OIyPs8QFcX6X912KMOijE+yNl1MOadx1IeXOs1Zs2/wA476H0EPFwMfp+HbRj7trg55LjJLizZ4N/NY381Ni5tl/TTmNYLHhthYxsgP8ATL2M2x6231vT/N9f/rqD0jqV/UunXZF1TKnNL2N9NxLXDYLNw3tbt2+p6X/W/wDraTA//9f06DzHHfwSkqhlYVtt7ntYxxfs9LIc4h9G36XpMA/677P57+ZyP0KhdgdWL7rMbqPpOe5xY19fqM2k7mB1b3ba3VM/QM9DZvr/AEl2++z9GlOlJSk+KzGYHWpm3qctcw7621NHv2hvsuaGvZVuH5jPU+n/ANZgel9XNrXnqTtrMl9xYNwBqe5jm43OzbU1rtm+u1JTrSUtVVwsXJxwRdkOyPaAC6eQ6x+73fyHsZ/rUrSSlapCdAO2gAQMum++g1Uv9F5IItBIgA9vTLX+5Qz8e+/CNFZD7N1RO9xrDwx9dlrHvqa9zPWYx7foJKbRmdeVG2fTsnnY7n4FVsbFyGdNOLY8MuLbGh9bnEM3l5q9Owiqz9C17Nn0EDpWDlYHT7qcq31XuL3gh9lgaCxrdrX3/pPptfZ/bSU//9D05JJJJSkkkklKWc+/qV+fkY2LbTRXjNqP6Sl1rnGwOd9JuRj7du391aKyzdbh9Vy7XYt91d7aTW+lm8HYHteD7vbymy6dr1ryZsAv3KETIQ9AkIy9XuY/0Z+n5ONN6PXP+5mN/wCwr/8A3tWfm5/XsawNrfRewW10WWDHLQLLfT9KprTml253rV+/+aUxl5oM7c4/pfUj0D9Cd3o63FNmDp+be2/I6bnGxogFrHMkTu2v9Oxu7hMlVaE35ybGIT4v1mOJj4Y8P/oDbpHWb6a7683G2Wsa9s4jwYcN7f8Atb5qF13VMa+irIuouryvVrIZQ6tzdtVt24Pdk3/6P9xBdmZfqWOprzaqnNDaqRj+2uAG+yLWqWRkW5uViObi5FTcc3WWvtrDGgGi2rkv/wBI9qOmlE3Y6y7rRGZMuOEBDhyfoYYn5JcHqjH99//Z/+0eiFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAHHAIAAAIAAAA4QklNBCUAAAAAABDo8VzzL8EYoaJ7Z63FZNW6OEJJTQQ6AAAAAADXAAAAEAAAAAEAAAAAAAtwcmludE91dHB1dAAAAAUAAAAAUHN0U2Jvb2wBAAAAAEludGVlbnVtAAAAAEludGUAAAAAQ2xybQAAAA9wcmludFNpeHRlZW5CaXRib29sAAAAAAtwcmludGVyTmFtZVRFWFQAAAABAAAAAAAPcHJpbnRQcm9vZlNldHVwT2JqYwAAAAVoIWg3i75/bgAAAAAACnByb29mU2V0dXAAAAABAAAAAEJsdG5lbnVtAAAADGJ1aWx0aW5Qcm9vZgAAAAlwcm9vZkNNWUsAOEJJTQQ7AAAAAAItAAAAEAAAAAEAAAAAABJwcmludE91dHB1dE9wdGlvbnMAAAAXAAAAAENwdG5ib29sAAAAAABDbGJyYm9vbAAAAAAAUmdzTWJvb2wAAAAAAENybkNib29sAAAAAABDbnRDYm9vbAAAAAAATGJsc2Jvb2wAAAAAAE5ndHZib29sAAAAAABFbWxEYm9vbAAAAAAASW50cmJvb2wAAAAAAEJja2dPYmpjAAAAAQAAAAAAAFJHQkMAAAADAAAAAFJkICBkb3ViQG/gAAAAAAAAAAAAR3JuIGRvdWJAb+AAAAAAAAAAAABCbCAgZG91YkBv4AAAAAAAAAAAAEJyZFRVbnRGI1JsdAAAAAAAAAAAAAAAAEJsZCBVbnRGI1JsdAAAAAAAAAAAAAAAAFJzbHRVbnRGI1B4bEBSAAAAAAAAAAAACnZlY3RvckRhdGFib29sAQAAAABQZ1BzZW51bQAAAABQZ1BzAAAAAFBnUEMAAAAATGVmdFVudEYjUmx0AAAAAAAAAAAAAAAAVG9wIFVudEYjUmx0AAAAAAAAAAAAAAAAU2NsIFVudEYjUHJjQFkAAAAAAAAAAAAQY3JvcFdoZW5QcmludGluZ2Jvb2wAAAAADmNyb3BSZWN0Qm90dG9tbG9uZwAAAAAAAAAMY3JvcFJlY3RMZWZ0bG9uZwAAAAAAAAANY3JvcFJlY3RSaWdodGxvbmcAAAAAAAAAC2Nyb3BSZWN0VG9wbG9uZwAAAAAAOEJJTQPtAAAAAAAQAEgAAAABAAEASAAAAAEAAThCSU0EJgAAAAAADgAAAAAAAAAAAAA/gAAAOEJJTQQNAAAAAAAEAAAAHjhCSU0EGQAAAAAABAAAAB44QklNA/MAAAAAAAkAAAAAAAAAAAEAOEJJTScQAAAAAAAKAAEAAAAAAAAAAThCSU0D9QAAAAAASAAvZmYAAQBsZmYABgAAAAAAAQAvZmYAAQChmZoABgAAAAAAAQAyAAAAAQBaAAAABgAAAAAAAQA1AAAAAQAtAAAABgAAAAAAAThCSU0D+AAAAAAAcAAA/////////////////////////////wPoAAAAAP////////////////////////////8D6AAAAAD/////////////////////////////A+gAAAAA/////////////////////////////wPoAAA4QklNBAAAAAAAAAIAADhCSU0EAgAAAAAADgAAAAAAAAAAAAAAAAAAOEJJTQQwAAAAAAAHAQEBAQEBAQA4QklNBC0AAAAAAAYAAQAAAAI4QklNBAgAAAAAABAAAAABAAACQAAAAkAAAAAAOEJJTQQeAAAAAAAEAAAAADhCSU0EGgAAAAADlwAAAAYAAAAAAAAAAAAAAlgAAAEsAAAAMQBTAGMAcgBlAGUAbgBzAGgAbwB0AF8AMgAwADIAMAAtADEAMQAtADIANAAtADEANgAtADMANAAtADQANQAtADUAMQA0AF8AaQBvAC4AbABlAGcAYQBkAG8ALgBhAHAAcAAuAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEsAAACWAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAABAAAAABAAAAAAAAbnVsbAAAAAIAAAAGYm91bmRzT2JqYwAAAAEAAAAAAABSY3QxAAAABAAAAABUb3AgbG9uZwAAAAAAAAAATGVmdGxvbmcAAAAAAAAAAEJ0b21sb25nAAACWAAAAABSZ2h0bG9uZwAAASwAAAAGc2xpY2VzVmxMcwAAAAFPYmpjAAAAAQAAAAAABXNsaWNlAAAAEgAAAAdzbGljZUlEbG9uZwAAAAAAAAAHZ3JvdXBJRGxvbmcAAAAAAAAABm9yaWdpbmVudW0AAAAMRVNsaWNlT3JpZ2luAAAADWF1dG9HZW5lcmF0ZWQAAAAAVHlwZWVudW0AAAAKRVNsaWNlVHlwZQAAAABJbWcgAAAABmJvdW5kc09iamMAAAABAAAAAAAAUmN0MQAAAAQAAAAAVG9wIGxvbmcAAAAAAAAAAExlZnRsb25nAAAAAAAAAABCdG9tbG9uZwAAAlgAAAAAUmdodGxvbmcAAAEsAAAAA3VybFRFWFQAAAABAAAAAAAAbnVsbFRFWFQAAAABAAAAAAAATXNnZVRFWFQAAAABAAAAAAAGYWx0VGFnVEVYVAAAAAEAAAAAAA5jZWxsVGV4dElzSFRNTGJvb2wBAAAACGNlbGxUZXh0VEVYVAAAAAEAAAAAAAlob3J6QWxpZ25lbnVtAAAAD0VTbGljZUhvcnpBbGlnbgAAAAdkZWZhdWx0AAAACXZlcnRBbGlnbmVudW0AAAAPRVNsaWNlVmVydEFsaWduAAAAB2RlZmF1bHQAAAALYmdDb2xvclR5cGVlbnVtAAAAEUVTbGljZUJHQ29sb3JUeXBlAAAAAE5vbmUAAAAJdG9wT3V0c2V0bG9uZwAAAAAAAAAKbGVmdE91dHNldGxvbmcAAAAAAAAADGJvdHRvbU91dHNldGxvbmcAAAAAAAAAC3JpZ2h0T3V0c2V0bG9uZwAAAAAAOEJJTQQoAAAAAAAMAAAAAj/wAAAAAAAAOEJJTQQRAAAAAAABAQA4QklNBBQAAAAAAAQAAAAKOEJJTQQMAAAAABT5AAAAAQAAAFAAAACgAAAA8AAAlgAAABTdABgAAf/Y/+0ADEFkb2JlX0NNAAH/7gAOQWRvYmUAZIAAAAAB/9sAhAAMCAgICQgMCQkMEQsKCxEVDwwMDxUYExMVExMYEQwMDAwMDBEMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAQ0LCw0ODRAODhAUDg4OFBQODg4OFBEMDAwMDBERDAwMDAwMEQwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCACgAFADASIAAhEBAxEB/90ABAAF/8QBPwAAAQUBAQEBAQEAAAAAAAAAAwABAgQFBgcICQoLAQABBQEBAQEBAQAAAAAAAAABAAIDBAUGBwgJCgsQAAEEAQMCBAIFBwYIBQMMMwEAAhEDBCESMQVBUWETInGBMgYUkaGxQiMkFVLBYjM0coLRQwclklPw4fFjczUWorKDJkSTVGRFwqN0NhfSVeJl8rOEw9N14/NGJ5SkhbSVxNTk9KW1xdXl9VZmdoaWprbG1ub2N0dXZ3eHl6e3x9fn9xEAAgIBAgQEAwQFBgcHBgU1AQACEQMhMRIEQVFhcSITBTKBkRShsUIjwVLR8DMkYuFygpJDUxVjczTxJQYWorKDByY1wtJEk1SjF2RFVTZ0ZeLys4TD03Xj80aUpIW0lcTU5PSltcXV5fVWZnaGlqa2xtbm9ic3R1dnd4eXp7fH/9oADAMBAAIRAxEAPwB8fDtyKy+pzS8PbU2n3b3OeHOrDPb6Xu9Kz6Vv/fEVvS73VPt9SsBjPUgO3SPS+1/SZub/ADSZ2FlY73VOyMemxhIez7XS1wcNzHNe31vpt3PYnDcwNLRnY+0ggj7ZTBBBY7/DfuuW7LmsVmuYw/WcP4vOQ+H8zQvkuZJ7xxZeGX/NZO6RkMdQH2MZ9pnYXbtI2fS2sd7f0n02ez2KJ6Zf9mdlCys1s3yAXSfTO1232qQd1Brg4dQpDmja0/baZA8B+mUSzLc0tObjlrgdw+2UQQdX/wCG/O/PQHM49L5jD4+uC4/D+Y1rkuZ20/VZf+9aeqUq9jdD6jl7/sgpyfTj1PSvpftmdu/bb7d21H/5q9f/AO4v/gtX/pRTDmMB2y4/8eLVPJ81E1LBliexxzB/6LlSp0U2ZFzKa43WENEmBqY7rS/5q9f/AO4v/gtX/pRIfVb6wAgjFggyCLa5BH/XEDzGGtMuP/GiocrnsXgyEdfRL/vXLc1zXFrtCOdQfxapFhYaySDvaH6dpJEH/NWl/wA1ev8A/cUf9uVf+lE5+rP1gEOfjSGDSbazDR7oH6T6KR5jDX87j/xopHK57/mcm+nok//Q6qq7rbL2twmk432rL3e0ls/acjbLgxzPfb7Mj1bavSx/0tH6VX78r6zNsvFGJVZWLa245LgJqJf69lk3/mt9J30WKT+n4ddxrGffjvve6xtDbwyXWPdY/wBOsjd77XvRP2M7/ubm/wDb3/mCaOIACthW7azHDkkZcQjZJ+T1eo8XqRX5X1jabvQxK3BrKzQSQS6wn9YZt9ev2tY79Fu9P+as/wBJWk49cyDbTdVVXXva6p86EMNlm2z9JZZ6dr6cbf8Ao/5nKt/0SJ+yP+72Z/29/wCYJfsj/u9mf9vf+YJXLt+LGIYgQRk2N/IWn0XBuxur5PqnYWYzC2try8EX5Odk/pHvAc+yr+ba/wD65/hFuKriYFeLZZaLbbrLWsY59zt521l7mNbo3866xWkoChXmrmcnuZDIG9Ii6raPq/57jfWTI+sdGLa/otLbC2sHcGh9gdu/SGup7ve9lf8ANM9K3eg9HyfrDd9XW3dSbZXnm5oDhW31jjm2ttlz8bZs9ZtJv/wH0Gep6S3bLK6q3W2vbXXWC59jyGtaB9JznO+i1qg3LxX4/wBrbfW7GI3C8PaayJ27vVn0/pJyDk/VcHtx3/nK9f8Ad4kGI/Od03e8H7Zts2C1oBJBf9n9VjfQb7m+lv8A6P8A8XQg9Js6m/p1zupgi6X7NzQx2zYHe5rNrf502Mb7K/Z/pP56zQrey1jX1OD2PEte0gtIPdrm+1yibK7Md9lTmvYWvAc0hwkBzXe5v7rgkxP/0fQ8jBfa+3bY1tWRsFzSzc/2e39FZuGz2/vMf6Vn6ar3qpk/VrDyX3uffe37Tay6wMLBqw2Oa3dsc7b+l/rq5fmuqdaW1B9WPtN7y8NcNw3foqtp9Xaw7vp1f6On1LFUv+svSsey6u1zw7HtrofDQZfYXsYGe73e6pySnVOplJZWR9ZemY7rBaLA2v05ftbB9Us27f0m79G26uy72/oq1Kr6w4FttlTG2l9NjKXgNB91m+PoPf8ARbW72/zn+iZZYkp00DJzsHE2/a8mrH3yW+q9rJjnb6jm+KJTay6plrJ2vEiefDtuasD6yY9mRmsrqx35FhxLAz0iA5hNtP6ba59fq7Ppsr/0jPTt9Oiy1NmSBYZuWxxyZBGZIjRJIobf3mH1gxvq31up7LerUUWlgY1wuqc0FjvUYXVud+99NrXs3qPScfofS+ijplfWcWyz125JudZWW+oyyu9rW0et/NfoGN/nf+EW1Vf+z+mizJY5rWuMVy1zmMe93oVF27Y51VZYx2yx/wDI9T6aPk5Ypxhe1vqb3VMrZo0k3PZTXuLvoe633peqtx9n/oS+WaPCcVTljB244dOv805tOb0FmA7Ct6ni2tsFotf61Yn1i99ntfZZ/pfz3Wfy1DpD+i4OLZh4vUMbItvc54Fb6w5zjW2lrGV1ve53tqWnXntfguzCwhtYsLmCCf0ReyzZu9P6Xpfo9/p/2FmW9TZ1Tptd7aXU7M7GrLXlh19Wi0Oa6pz/AMyxiR4gLsfZ/asgMU5CIjMcRq+OJ/D23//S9KfjY9lrbrKmvtZGx5AJEHc3/Nd7mKexkk7WydSYEkqllNyze/0xbuIZ9lcx0VNP+F+1Mn3e7+c9Rr/0H9E/WFWvH1n9a/7Ocf0zdX9n3wIpl/r7/a5zn7PS/wDJ1pKdU11mSWNM8+0f3Jw1gMhrQQAAQBwOPyrJzB9aXtvZhOxq3uZWMd9oBDXhzXZD37d25j2Psrqb6f6P0Wf6X9HYyT1o3YjsdtTatRmMcRMyza6s+79Ft9X/AIRJTfEAQBAGgAVTK6f9oyGZLMi7GuYx1W6ks1Y5zbIcL6r/AM6v81Urv+dBYDV6AcaHTMT6xa/09zXbm/o3elv2Wekp4jfrJ9prfluo+zurr9StgEtsHpfaTu+lts/WPS2f8GgQDuuhOUDcd9u+/hJL+y8iZ/aWWT5+gR9xxU1vSLLqzXd1DJtrd9Jjxjuae/uY/ELVay/tRoIxPbkSILo2xPukvDmfRUc4Zhwox9xyN1W70S1ri3fX9q9E5H6Nv6H1du7/AKtDhHj9pX+/PtD/AMLx/wDetevpNtTBXV1DKrrb9FjBjtaPgxuJtUB0VldYH2q91VLhcKIpZWX1n1a97cfHpd/OMb+erOOM89NLbJZmFtoYbC1zgZf9m9RzN9T9rPS/7+g9Lb1RuBf+1HOdeS8s3msuDNjeXYzWV/zvqpcI8ftKvfn04R5Qxg/bwv8A/9P0mzJx67G1WPDXujQzpuOyve6Ntfqv9lXqfzr/AObUzZWOXtEENMuHJ+i3+sgXYfqOsAsLKsiBkVgA7to2e1591XqVj0rf5H816Nv6VVHfVrpL7ci57HOflXMyLfdHvrcbG/Ra12z3e9m76CSnSNlY5e0RqfcOJ2+P7yfeySN7ZBgjcND+6srJ+q/SMnf6jHzaGtsIcPcGGo1B0t2/o/s7FM/VzpJtN3puFhtZduDiIdW30mtbHtaxzd+7b/pHpKdFllb52Pa+OdrgY/zfipKj03o2H02fs2/UvPvIP856XqfRaz/uPUrySmL3sYNz3BjeJcQBPxcmuupx63W32NqrbG57yGtEna3U/vOdtaoZOMzJpdRaT6TiCQ3QmDuA3e5LIxWXY4xw51Qaa3McyCWmpzLav5wPa73Ve5JTNl9NlIvZY11JBcLARtgfSO7+Sh15WLlY1luLdXkVgPaX1OD2yAZbuYXe5NVhVV4bsNznW1vFge58bneqXvtmB/wrkLD6dV0/Dupqe+z1Nz3PsguJ2NqHA/cqYkp//9T0PIzbKn2lrWGvGDDa1zosd6n0fQb9H+RTu/pOR+r/AKL+cVSz6yY7LcmoYuRYcW5lDiwMdudY41tdW0Wept3N/PatR1Vb3tscxrn1zseWgubPOx30mKUnxSU5WZ9YcbDfc19Fz/s721vc0CPcHv3NO76P6Jyaz6xUV1WWvxcjbVd6GjQSSWvtbZz7a3en/wCC1LXk+KW4+KSnHZ9ZsR9or9G0A5P2Xedm0H3j1XEO9tfs/wCMfvVzpnUWdSxRksqsoG4tNdwAcCA135pd+9t/4xXNzvFNJPKSnG+sX1jZ0OlzzjuvcGNc0zsZLnek0OfDvo/4RQ6V9Zf2j0IdV+zip4ubjOre/bWXOsrx/VZfsd+h/Tf6L6f6Fbbmtex1bwHseC1zHCQQdC1zT9JRbRTXUKGVsZS0bRUGgMA/d9ONm1JkMsft8PB+sv8AnOL9HtwNejP9bpzs4VztbafTY7dJqL2EMe4V/T9L/CMZ/wAIg9K6oeqdPvyDUKSwvr2tf6gP6Ntu5riyl30bf9H/AOB+9aDQGgNaA0NEADQADwhRe1raHtaA1oY7QaDUHwSY3//V7/Koyn3ucxr3OOz7Pc2za2mP5z1Kdw9T3e/2st+0f0a30mKOTjdcsfaaMtlTDa11LIGlYBZZQ+z0Xfzv8/6n+D/mf+EVq3MxqrPTe4hw27iGuLW7jDPVsaNlW7+WnszcKouFmTUwscGvDrGt2udqxj9zva98e1JTRoxfrD6jDkZzCwZLnva1rTOPHso/mKv0m/2/S+h7/U3sSsxvrGbWOrzKgwXFzmEc0yxzK59J3vazez/1Z+ju2Z+BUHG3KprDHbX77Gth2p2O3O+n7H+1J+fgVl4syqWGoTaHWNG0T6c2e72fpP0f9dJTRrxPrE1lAdn12FljjeS0AvrmvY0Ftf0m7Lvzf8N/waWDjfWOuzGdm5dVrGF/2pgiXhwaKtjxj1fzTw9+zbX/AMatXQ6jUHghJJTjfWTC+sGVjWDo+SKT6YArDvTe5wdus2W7fa+yv2Mf6lfpoPR8Dr9H1cbjdRsfZnes14aLosbQLa32YxzGu91npNv/AML/AIT0fVWtndT6d0+t1udkMoaxu87j7ts7fbW2Xv8Ad9HaoY/WOm5WAOo49/q4pdsD2teXby4Utq9Hb6/qusexvp+nvSZjPJ7PDwDg4vn4f0v3eNWLTmt6Z6NjyzKLbA15dvLC4v8AQ/SP9Xd6THV/T9f/AIy9B6TjdQx+nXV9QsNtri9zNzzYWsLG+31HS/8AnfVf9P8A7b/ma7teVj2Y5yQ8NpAcXPfLA3ZIt9UW7HVeltd6nqfzaHRm4mbi224lotY0OY4iQQ4N3bXNeGvb7XNe3/g/0n0EmF//1vRrsKu17ybLGstj1qmkBj9vt98tc/3M/R2em+v1K0KzpGBY+17m2brrG22H1X/TYS6tzfd7Njnfzbf0SjlZ9lN72NdU309m2h4PqXb/APQEOH/FM9lv6b+c9OtDt6zbQ+7f0/JfVS5zQ+lhe47TtBNe2v22t3XV+lbez0v570bbPSSUku6F0m59lj6CH3PFtjmPeyXjf7/Y4f6WxKzofSLfU34zSbaxU90u3bQQ9u187mva9jH+p9PeoV9ZdY6G9Py4e3fU8sGwjaHt3P3eze/9GmZ1q0tcD07J9VgJIDfY4ifbVc8Vus9rd381/wAXvSU6TQ1rQ1ohrQAAOwGgTrNf1i8sccfpuXZYDDWvaK2uP53v/Sbdv8pinT1O261tQwMmqXurc+1oa0OZs3u3Au3VbHWOqu/m7vS9NiSlurdC6b1ep9eYx257Qz1a3FrhtO9jm/mOdW76O9ih0/6v4HTumDpuK6xtfqC82kg2G1rmXNtcdnp+11NXt9L09iB9ZOu5fR8W23Hwjkbaw8Wnd6bSXbP0nptPsp+nb+krQuj/AFjys/6ujqt9VeNZ6zaS924UlptrodlNl2/0a/Vd/hfp0/zqTOY5vY4jI+zxAVxfpf3XYow6KMT7I2XUw5p3HUh5c6zVmzb/ADjvofQQ8XAx+n4dtGPu2uDnkuMkuLNng381jfzU2Lm2X9NOY1gseG2FjGyA/wBMvYzbHrbfW9P831/+uoPSOpX9S6ddkXVMqc0vY303EtcNgs3De1u3b6npf9b/AOtpMD//1/ToPMcd/BKSqGVhW23ue1jHF+z0shziH0bfpekwD/rvs/nv5nI/QqF2B1Yvusxuo+k57nFjX1+ozaTuYHVvdtrdUz9Az0Nm+v8ASXb77P0aU6UlKT4rMZgdambepy1zDvrbU0e/aG+y5oa9lW4fmM9T6f8A1mB6X1c2teepO2syX3Fg3AGp7mObjc7NtTWu2b67UlOtJS1VXCxcnHBF2Q7I9oALp5DrH7vd/Iexn+tStJKVqkJ0A7aABAy6b76DVS/0Xkgi0EiAD29Mtf7lDPx778I0VkPs3VE73GsPDH12Wse+pr3M9ZjHt+gkptGZ15UbZ9OyedjufgVWxsXIZ004tjwy4tsaH1ucQzeXmr07CKrP0LXs2fQQOlYOVgdPupyrfVe4veCH2WBoLGt2tff+k+m19n9tJT//0PTkkkklKSSSSUpZz7+pX5+RjYttNFeM2o/pKXWucbA530m5GPt27f3VorLN1uH1XLtdi33V3tpNb6Wbwdge14Pu9vKbLp2vWvJmwC/coRMhD0CQjL1e5j/Rn6fk403o9c/7mY3/ALCv/wDe1Z+bn9exrA2t9F7BbXRZYMctAst9P0qmtOaXbnetX7/5pTGXmgztzj+l9SPQP0J3ejrcU2YOn5t7b8jpucbGiAWscyRO7a/07G7uEyVVoTfnJsYhPi/WY4mPhjw/+gNukdZvprvrzcbZaxr2ziPBhw3t/wC1vmoXXdUxr6Ksi6i6vK9WshlDq3N21W3bg92Tf/o/3EF2Zl+pY6mvNqqc0NqpGP7a4Ab7ItapZGRbm5WI5uLkVNxzdZa+2sMaAaLauS//AEj2o6aUTdjrLutEZky44QEOHJ+hhifklweqMf33/9kAOEJJTQQhAAAAAABdAAAAAQEAAAAPAEEAZABvAGIAZQAgAFAAaABvAHQAbwBzAGgAbwBwAAAAFwBBAGQAbwBiAGUAIABQAGgAbwB0AG8AcwBoAG8AcAAgAEMAQwAgADIAMAAxADkAAAABADhCSU0EBgAAAAAABwABAAAAAQEA/+EOxWh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8APD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIiB4bWxuczpwaG90b3Nob3A9Imh0dHA6Ly9ucy5hZG9iZS5jb20vcGhvdG9zaG9wLzEuMC8iIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjM2YTcwM2M3LWNiOWMtN2Q0Zi05MDZmLWZkYmEwNjEzYTc5ZSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpiMzIyZjg5NS1mNmJjLTZkNDAtYTY2MS1hYzNlZTY0Nzg5OWMiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0iODU2NzdDMTcyNUEwMDVDMUJCODcwRDIxQTdGMTk2MTciIGRjOmZvcm1hdD0iaW1hZ2UvanBlZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IiIHhtcDpDcmVhdGVEYXRlPSIyMDIwLTExLTI0VDE2OjM4OjAyKzA4OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMC0xMS0yNFQxNjo0NDoyMiswODowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMC0xMS0yNFQxNjo0NDoyMiswODowMCI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjZkNWJiOTY2LTU5MTYtZTU0Mi1hMGM5LWM3Zjg1YzcxZTY2NCIgc3RFdnQ6d2hlbj0iMjAyMC0xMS0yNFQxNjo0NDoyMiswODowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMzIyZjg5NS1mNmJjLTZkNDAtYTY2MS1hYzNlZTY0Nzg5OWMiIHN0RXZ0OndoZW49IjIwMjAtMTEtMjRUMTY6NDQ6MjIrMDg6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSLikaAiIHBob3Rvc2hvcDpMYXllclRleHQ9IuKRoCIvPiA8cmRmOmxpIHBob3Rvc2hvcDpMYXllck5hbWU9IuKRoSIgcGhvdG9zaG9wOkxheWVyVGV4dD0i4pGhIi8+IDxyZGY6bGkgcGhvdG9zaG9wOkxheWVyTmFtZT0i4pGiIiBwaG90b3Nob3A6TGF5ZXJUZXh0PSLikaIiLz4gPC9yZGY6QmFnPiA8L3Bob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+/+4ADkFkb2JlAGSAAAAAAf/bAIQADAgICAkIDAkJDBELCgsRFQ8MDA8VGBMTFRMTGBEMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAENCwsNDg0QDg4QFA4ODhQUDg4ODhQRDAwMDAwREQwMDAwMDBEMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM/8AAEQgCWAEsAwEiAAIRAQMRAf/dAAQAE//EAT8AAAEFAQEBAQEBAAAAAAAAAAMAAQIEBQYHCAkKCwEAAQUBAQEBAQEAAAAAAAAAAQACAwQFBgcICQoLEAABBAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHxY3M1FqKygyZEk1RkRcKjdDYX0lXiZfKzhMPTdePzRieUpIW0lcTU5PSltcXV5fVWZnaGlqa2xtbm9jdHV2d3h5ent8fX5/cRAAICAQIEBAMEBQYHBwYFNQEAAhEDITESBEFRYXEiEwUygZEUobFCI8FS0fAzJGLhcoKSQ1MVY3M08SUGFqKygwcmNcLSRJNUoxdkRVU2dGXi8rOEw9N14/NGlKSFtJXE1OT0pbXF1eX1VmZ2hpamtsbW5vYnN0dXZ3eHl6e3x//aAAwDAQACEQMRAD8ADJ8UpPimSXTvGryfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFKT4pkklLyfFF3H7Lz+f/wB9QUX/ALTf2/8AvqZLeH97/uJL4fLk/u/93B//0AK5gM6S5tn7RtuqII9P0Wh0j87duCppHhdNIWKsx8Y7vHQlwyB4RLwl8ru9T6T0XBtfiMvybM0sBpZtbtc5/wDNtLg1ZDcLMdkHFbQ85LfpU7TvECdWrT+tgJ60QASTVUAByTHZa2X9pPS7KWOY7rzMdn23aD6px5cfTDvzr9uz1v8A1Wqkc0oY8ZJ4zlA+f9A/ven/ACTfny8MmXLER9sYSa4B/ODX0er/ACriU9BzLulvz212mwPa2uoMkPYRJuaf3WovSum4t/TLcy3FvzLWXioVY5IIaWh+8gNcj9Pt6jd9WclmK+6yyq+trG1ucXNr2jc1oafbUm6VZTX9XbnXWZFLPtjQHYn85Owact/RpTyZOGYMtRkjEcN8XCf7qseLFxYyI6SwymeOuHiHX1JcTpGDkZVVD+k59DLHQ617iGt0+k72LFx66P2j6Nrd9XqPZsdYKpjcG7r3fQW/0nIwXdTxm15XU3vL/ay6PTJh3877vorO6O+5vVrmMxnZVVj3i5jK22OA3O2vHrA1s2u/eQhOY9yyTUAQJGcf3v8AOJyY4H2aAF5CCYxhLpH9GCc9P6WAT9mq0/8ANjX/AORVHruFj4Oc2jGBax1NdhaXb4L53RZ+c1dGzGazHza78jBdex7K6LBRWRWXktYMtjW7Get9D+Qsj6zX5IfXh3NcTSAH3vpbUHubIb9ncwbvQa0/R3JuDLI5QASRrdyn2jLi9a7mcMY4CSADoRUcenqlHgPto20fVsmkOq6iwZBAre70w10nZuYfzmtcl1HG+rmHfkYgGY7IplrXTXsL49vg/YpZX9C+r39r/wA+sU/rD1Qfb87E+xY07iz7R6Z9Xhvv9Td/OJ8eMziAZkHjv1/5vJ7fEsmIRxyJjAEe3R4P87i9zhR9L6bhX4FNtuO/Iyb7rK2tbb6TQ2tnrE/Rd+amz+k4YY/IxS+qpuFTmCtx3km1+zY5/wDJarf1bsbZTTSw+/FfkXWl3tY1tlX2enda72fpLHIObm0VUX4lxNOQzp+PiGt4IJtrfusY395u33Ns+g9Azye8QDLQjTf0yn+7/cXCGI8uDKMdQaNAeqOP9/8Av8TR6/i0YnWMnGx2enTWW7GSTEsY4/Sn85yz1q/Wn/l/L+LP/PbFlKzgJOLGSbJhH/otLmQBnygCgJzAA/vKSSSUjEpJJJJSkkkklKSSSSUpJJJJSkkkklKV3pOPRk5FrLhuazHusaJI9zG7mH2qrVYarWWtDXFhDg143NMfvsP0mrd6P1e+7Jua6jFbtxr3gsoY0y1m7aSPzP32KHPKYhLhHTfi4aZ+Vhjlkjxnr8vDxCTz4MgJK7kdVuyaDS+nGY10e6qlrHCNfa9qpKSJkR6hw/XiYpiIPplxDvXCpJJJOWtzpo6Xuu/aO+Nn6H05+l57f/OFTWr0B3UGuyvsVFd5NUWeoYgfm7f3t3+j/PWUFHE+uYvbh63/AM39BlmP1WM1vxa8PD1/f/TUi/8Aab+3/wB9QkTYPS3yZ3Rtn2/R52/vIy3h/e/7iS2Hy5P7v/dwf//RAkkkuneNSOych1ovfa91zY22lxLht+hDz7vanbk5LLzkMusbeSSbg4h5J+l+knd7kJJDhHYdvonilvZ3vfr3S05OTQXGi59Rfo8scWyP5W0+5WMTrHUMHFdjYlnote8WOe0e+QNu3cfbs/sqkkhKEZaSiD5jsmOScTcZGNWND+9u6lP1l65Va2w5TrNhnY+C0+T9u13/AElQblZLHWursdV68+rsJbuBO7adv5qEkgMcBdRiL3odkyzZZVxTkaurPduWdUyX4A6eG1V4/t3+mwNc8t+i65/56H+0M4101Ove6vHeLKWuO4NcPolu79391V0kRjgP0RvxbfpHqo5ch/SO3Dv+iOjq/wDOnr+v62df5Ff/AKTSP1o68WlpyyQRB9lfB/62spJM+74f81D/ABYr/vXMf57J/jybBz8k4A6eCG4wcXua0QXk/wCld/hNn5iPV1vqDLGWucy91dYpZ6zA+GA7xz+fu/PVBJOOOB0MRqSdusvmWDNkFETOgA36R+VLlZN2XkWZOQ7fbaZe6I8uEJJJOAAAA0AWEkkkmydSSpJJJFSkkkklKSSSSUpJJJJSkkkklKSSSSUpTrttqcXVPLHOaWktMS12j2/2lBJAi91AkGxopJJJFSkkkklJKr7qdxpsdWXja7aSJB/NMIaSSFKs1XZSL/2m/t/99QlP1Gen6X5+7dEaRt/eTZbw/vf9xJfD5cn93/u4P//S6P8A5iu/7nD/ALb/APM0v+Yrv+5w/wC2/wDzNC+vWMzL6j0PFsJFeRc+p5boQHuxmO2zu93uRv8AxuOh/wCnyv8APr/9IKU/E+c4pRiRLhrX0R3HF+4vj8A+FjBhy5sksRziUowjDJlr25yxfN78P3Vv+Yrv+5w/7b/8zS/5iu/7nD/tv/zNP/43HQ/9Plf59f8A6QS/8bjof+nyv8+v/wBIJf6R57w+2H/qtH+hPgn+fn/4Tk/+CFv+Yrv+5w/7b/8AM0v+Yrv+5w/7b/8AM0//AI3HQ/8AT5X+fX/6QS/8bjof+nyv8+v/ANIJf6R57w+2H/qtX+hPgn+fn/4Tk/8Aghb/AJiu/wC5w/7b/wDM0v8AmK7/ALnD/tv/AMzT/wDjcdD/ANPlf59f/pBL/wAbjof+nyv8+v8A9IJf6R57w+2H/qtX+hPgn+fn/wCE5P8A4IW/5iu/7nD/ALb/APM0v+Yrv+5w/wC2/wDzNP8A+Nx0P/T5X+fX/wCkEv8AxuOh/wCnyv8APr/9IJf6R57w+2H/AKrV/oT4J/n5/wDhOT/4IW/5iu/7nD/tv/zNL/mK7/ucP+2//M0//jcdD/0+V/n1/wDpBL/xuOh/6fK/z6//AEgl/pHnvD7Yf+q1f6E+Cf5+f/hOT/4IW/5iu/7nD/tv/wAzS/5iu/7nD/tv/wAzT/8AjcdD/wBPlf59f/pBL/xuOh/6fK/z6/8A0gl/pHnvD7Yf+q1f6E+Cf5+f/hOT/wCCFv8AmK7/ALnD/tv/AMzTH6jwQDntBPE18/8Agiy8v6mYD+r19K6dbcXMZ62bda5rm1sOjGMayuvddZ/XVXN6F9W6ut4nR8W7IvstsDMmzeyGT+Y3bT7rP3v3E0/FecG9DXh/Q3/8LZh/xb+FyIEMs5XA5T+qyDgxx/Sn+v8ATxO//wAxXf8Ac4f9t/8AmaX/ADFd/wBzh/23/wCZrE630PA+rHVulZVVtr8d1wstFkOc0Uvqe4t9Ntf5r/3V6K1zXNDmmWuEgjggp8PiXNSMgZcJjXSEt/8AAavNfAuRxQw5Md5ceYSMZEZMX83LgkP5ybyv/MV3/c4f9t/+Zpf8xXf9zh/23/5murSUn3/mf3/+bD/vWp/ozlP83/zp/wDfPKf8xXf9zh/23/5ml/zFd/3OH/bf/ma6tJL7/wAz+/8A82H/AHqv9Gcp/m/+dP8A755T/mK7/ucP+2//ADNL/mK7/ucP+2//ADNdWkl9/wCZ/f8A+bD/AL1X+jOU/wA3/wA6f/fPKf8AMV3/AHOH/bf/AJml/wAxXf8Ac4f9t/8Ama6tJL7/AMz+/wD82H/eq/0Zyn+b/wCdP/vnlP8AmK7/ALnD/tv/AMzS/wCYrv8AucP+2/8AzNdWkl9/5n9//mw/71X+jOU/zf8Azp/988p/zFd/3OH/AG3/AOZpf8xXf9zh/wBt/wDma6tJL7/zP7//ADYf96r/AEZyn+b/AOdP/vnlP+Yrv+5w/wC2/wDzNL/mK7/ucP8Atv8A8zXVpJff+Z/f/wCbD/vVf6M5T/N/86f/AHzyn/MV3/c4f9t/+Zpf8xXf9zh/23/5murSS+/8z+//AM2H/eq/0Zyn+b/50/8AvnlP+Yrv+5w/7b/8zS/5iu/7nD/tv/zNdWkl9/5n9/8A5sP+9V/ozlP83/zp/wDfPKf8xXf9zh/23/5mg/8ANX9e/Zn2sbvT+0b9nb+b2+nv/wCmuxWdtb/zk3QN32ON3eN3CB57mDXr21+WH/epHw3lBdY9xR9U/wC9+8//0+k+t/8Ay59Xf/DR/wDPmKtbqvW3YmRV0/BxzndUvbvZjg7WsYNPtGTb/gqv+rWT9b/+XPq5/wCGj/58xUK6zLrH1tyMSf2nW9jGFur20Ctuw1/nfzfrPaosf85l84/9B1Z4xPleSvWseTQ6RufNSxjj/qep0Tl/XKnda/H6fmtr1sxcax7bgPBj7fY5/wDWWn0vqeL1XDbmYpdsJLHseNr2Pb/OU2s/NsYvJegX5lfWsN/T3OOU+5ohpJL2k/pW2fv17N3qb16T0gNZ9Z+vMo/mD9ne8DgXuY71f7bmbfUUxDFzfKjGCPTxCPGDEcGnFHGYyhcv3/RJ18jLxMUNOVfXQHaNNr2smP3fULdyjj5+DkvLMbJpveBJbVY15A8drHOXN/XBjn9Z6QGtc8+hl6MxRmnnG/7Tv+h/x39hVuh1PZ9a8Pex7JxMqN+A3B/OxvouZ/SP6n+DQc96qzq3SqrHVW5uPXYw7XsdcwOaR+a5rnbmozMnHfR9pZax+PBd6zXAs2t+k71Adm1q4LrHVX4edZ9tFdWU7KbTZjNuc1wa/wB32+qo4Vr7MFtbPVst/wCM/wAIrFAyq/qJm12Fow7cgNqyGbwbMfJyWHKu/Ssp3UOqyHsqvaxnr1fpfTSU9vTYy+pl1LhZVa0Pre3UOa4bmPb/ACXNUce+jJpF+PY26l0htjDLTtJY73D917dqxPrZb9lrryLMS23Cw2l7raMx2GWuJFbMcU0OrsybLPYzHZ/pP0dSx/qlg5uBZiYtmBcMrH/SXh3USRVVkPtfXbd01z3Vu9j/AKO3+erf/hUlPaX3047BZkPbUxzmsa55gFzyK6mSfzrHu2MRII7Lg87puHh9Ryr+sdJstw7r3ek6usZLrLciwDHttyrbaW1/pHbMfDpxtlH+FyLEWnAy+nfVv6wXuxjhGzEcK7GzS55qZYPUfhB97cXI9/uvx8r0sj/R46Sns2X0WXW49djX3UbTdWDLmbxur9Rv5u9o9qZ+RRXdXjvsa264ONVZMOeGQbdg/O9Pd7lxGX0OqvpuRaz6r3VP9BzzcM5s7gw7bHbcrc/YtXKc49C+rWVvLsluR041vP0nG1raMn3H3fpca271ElNropaOv9ca/S82UOPnX6cVf9+WZ1npuF0/r/QRiVCs3ZN1lruXOcTS6XOPu/OWt1jpuc3MZ1jpEHNqb6duO4w2+qd3pl35lrf8G9ZXUOo4+dndPy8rHzcO/pr3vOOcZ1m8u2e1lrHbf8F9NQz0HCRrxWD4cfE6eAmWQZYEmEsRx5IR+aOSGCWCHFj/AHJS+STU/wAZvHTf+v8A/ohdZ0NtrejYDbf5wY9QdPM7G8rjvrFlX9Y6v0ZmVg2YmFZfsrF8NssD30Nu31A/ovbsXfAACBoBwEMWuXJIf1R/zU86TDkOSwyGo92ZNiX+Vnp6f+cpJJJTuWpJJJJSkklX6i61uBkOpn1BW7bHPnH9lJIFkDuaR3dY6ZTYa33gvbo4NBdB8Jasnrf136Z0yoCgOy8p+rKYLAB/pLXuH0FjiIEcdlyXWXOd1TI3dnAN/qgDanAB0uX5HFKdSJIAsi/mejp/xl9WFu6/EofTOrGFzXR/JscX/wDSau36P1fD6xgszcMnY47Xsdo5jx9KuwfvLxldt/ixfb9o6jXr6OytxHbfLm/9QkQKZee5PDHCckI8EoVttIE8L3qwH/XCn7Tk0YnSupdQbiXPx7cjGqY6r1a9Lq2Osuqf+jd7PoLoG/SHxXPfU7+a6x/6eM7/AM+BNcdNhfWmrJ6hj9Pv6b1Dp1uXvGO/LqY2t7q2+s+vfVbdtf6bXP8Actpc31G/J6n1mjpOU63pnTX3OZWBubfnWUt+0vZVbX/RensYzc+3ey3L/mq10hMmUlKVDqXX+idKcWdRzqMWwM9UVWPAeWa+5lf03/R/MV9c/wBdrn60fVt4Alzs2ncQPzsc2t5/lUpKa+D/AIx/qxl3spssfgixpfXdlGtlZjXa59dtvo2bfzL/AE10eLl4ubjsysO5mRj2TsurIcx0Ha7a9v8AKC4GrrmbmdFabrvU9T6t5uZkghvvva5uMy58N+n/ADq7bolQo6L0+kCBXi0tj4VsSU3Vn/8ArRf+gn/floLP/wDWi/8AQT/vySn/1Ok+t/8Ay59Xf/DR/wDPmKtPqfSMx2czq/R7mY/UWM9O1loJpvrGrar9vuY9n+Duasz63/8ALn1d/wDDR/8APmKuqUWP+cy+cf8AoOjzEzDlORI/zeYEHUSH3jJoXmqqPrILHnD6R0/pmRbpZn722QD9J7Kqq2ve7+utjpHSqelYpoY911tjzbk5Nn07bXfTtf8A98arqSlac80pCqER14b9Vd5T4pNDqPRMDqWTj5OX6u/FbYyoVWvp0t2epvdjursd/NN/PUMX6vdNw8+vqGP6wvqrfU31L7Lm7bCxz/bkvt2u/RN/m1pJJMTks+q/SKbcd+Ox+PXjW/afQrdDbbvcWZGZY5rsnJsr3u9P1MhGZ0DpNeJmYVNHpY3UA77RSxzgyXjY91NRJrx3a7v0DGfpP0i0EklOdndBweoVYdeY++x3TyH0WttdW/1A30/XsdSWb79v+EQ6fq10ynOo6hW7J+148tZa/ItsJrd/OY9rb32NfQ936TZ/pfetVJJTkZn1Yws1zjk5Wc9rrBaKvtVgY17XerW6usH9H6Vg31fuIv7Bxjg5eDbkZd9OdWarfXvda5rSCx3oG3d6X0lpJJKcd/1S6I+l1JGVtcw1n9cyToRs/Ov2/RVsdIxR+zhLyzpQjGrJG0uFf2ZltzY99tVO/wBP/jFdSSUpJJJJTyv1v/5c+rv/AIaP/nzFXVLlfrf/AMufV3/w0f8Az5irqlFj/nMvnH/oN/m/9x8j/czf+7GRSSSSlaCkkkklKSSSSU5tv1f6bY8vAfXu1LWOhv8AZaQdqwPrH9RGZLftPTrHNyGiHNsMtcB29o9v9ZdikjZZoczmhISjM6d9Xyer6l/WCy0VmllYmC8vBA+TfcvQvq30GnoeB9nYd9th33WHQud/31rfzFrJJEkr8/O5s4EZkCO/DEVfmoaGVztf1c69h5GY7pXWKsbGzMm3LNN2ILnNfcd9rfW9evczf9D2LokkGs4GP9X+sv6rhdQ6t1WvNb042PopqxhRL7WHHLrLPWu9jWP+jtW+kkkpSz+s9Jd1JuNZRkHDzcG318TJDRYGuLXU2Mtpft9Wm2qx7Ht31/8AGLQSSU83d9V+rZdDsPK6hi14drDRc3EwhTaaHHdbi1XvvubRXb+f+iXRta1jWsYNrGANa3wAENCdJJSlmRV/znnT1fsXjrt3furTWdA/5xzGv2Tnv9JJT//V7nrv1cwuueh9rfaz7Pv2ek5onfs3bt7LP9Gsr/xueif6bK/z6/8A0guqSUcsOORuUQSW3h+I85hgMeLNKEI3wxGw4vUXlf8Axueif6bK/wA+v/0gl/43PRP9Nlf59f8A6QXVJIfd8X7gZP8AS/P/APiibyv/AI3PRP8ATZX+fX/6QS/8bnon+myv8+v/ANILqkkvu+L9wK/0vz//AIom8r/43PRP9Nlf59f/AKQS/wDG56J/psr/AD6//SC6pJL7vi/cCv8AS/P/APiibyv/AI3PRP8ATZX+fX/6QS/8bnon+myv8+v/ANILqkkvu+L9wK/0vz//AIom8r/43PRP9Nlf59f/AKQS/wDG56J/psr/AD6//SC6pJL7vi/cCv8AS/P/APiibyv/AI3PRP8ATZX+fX/6QS/8bnon+myv8+v/ANILqkkvu+L9wK/0vz//AIom8r/43PRP9Nlf59f/AKQS/wDG56J/psr/AD6//SC6pJL7vi/cCv8AS/P/APiibzWJ9Qej4mVTlV25Jsx7G2sDn1kEsIe3dFLfb7V0vySST4QjD5RVtbmOaz8wQc2Q5DEVHi6K+SXySSTmFXyS+SSSSlfJL5JJJKV8kvkkkkpXyS+SSSSlfJL5JJJKV8kvkkkkpXyS+SSSSlfJL5JJJKV8ln/+tF/6Cf8AfloLP/8AWi/9BP8AvySn/9b05U+o9VxOnNZ62591x20Y9Td9tjvCusf9Wrix+ltbkdc6rm2+67HsZiUT+ZUGNtOz931Xv9ySlO69mUt9XM6RlUYw1da0stLR+9ZTU71FqY+RRlUMyMexttNo3V2N1BCIDGoWP0ituL1jquBSNuMDVksYOGPuB9ZjR+buc31ElOwATwnII5C5X632sb1XpVV1tVdD6spzm5GVbh1Fzfs/pn1cX32Wt3P2Vqt0G2kfWjGpxbsd1b8TJdazFzr8xpLXY/puvZlw2nbuf6T2/wAtJT2cHwKbhcF1DJxrMu7KdTdWyzMGK9pbuc3IsIa2i3Z1enZ6jvz/AEaqdi3/AKl33u6Vbi3tf6mHk31Osc5r2n9I97aq3svy/wCj1uZVYx1z/Sf+i9R6SneSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJAz734+FffX9OthLfj4/2UkgWQB10SPvorO2y1jHeDnAH7iVQ6t9YukdJxxfk3h5dpXVUQ97z/Ja0/8ATcuXPucXPO9ztXOdqSfNcn1qx1nU7p4rIrYPAAJwi6GDkIznUpGgLlT2lP8AjM6c60Nuwr6qif5wOa8jzNY2rrMTLxs3GrysSwXUWiWWN4P/AJk391eJLuP8WWVcXZ+ESTS0MuaOwcSa3x/X2tSIZOd5DFjxHJjuPBVi+LiB9L3axc365/VbAyrMPL6jVXkUnbbXD3bXfuudWxzdy22/SHxXP/UwkU9Yj/y4zv8Az4muU2em/Wz6t9Vyhh9Pz678lwLm1AOaSG6u2eoxm7a1ay5vqXULer9Zp6b0s102YNrhf1a5jXGqwNm/B6VVaP1nOfj/ANKs/mcTG/4RdIefBJSknENY6x5DWNBLnOMAAauc5xSXKfWnpPT876z9B+147bxkty6LWvkteK6Tk47bGtI3em9tiSnZ6d9Y+h9TyPs2BmMuvLS9rAHN3NH0n0+o1jbmt/4JaREaFeZiro2Z0mu49LxK32dCyupufW14Nd1JbTWMbda70atznexd39W8WrD+r/TsapuxrMaokeL3tbZa/X9+xznpKdFZ20f845/7qf8AflorP/8AWi/9BP8AvySn/9f05ZGbi5+F1B/Vem1/aW3ta3OwpDXP2fzeRjud7PXY32uY7+cWukkpyHdeybG+nh9KzH5J0DLmCqtp/wCFuc7bs/qKx0fp1uHVbblWC7OzH+rlWN0bujayqr/gqWexivyfFJJTmdU6PkZ2fiZuPmuwn4ld1Z2VssLxd6X/AHIbZWzb6P7iHi9DzKurUdSyeouzBj020tqfTVXHrGp29r8ZlX+g+jYtdJJTzeR9SsbJzaMnIv8Atm2z1cuzLYLr7Q3eK8Wt014uLifpffXXib1rdJ6U3pVBxKb7LcRkDFot2n0GAfzFdoa22yr9z1/UsZ++rySSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUmc1rmlrhua4EOB4IKdJJTiW/VhheTRkbK+zXt3EeW4ELmPrN9Ss5jvtuG4ZJdpZU0bXafnM3H3L0JIwRB1CNlsY+czY5CQlddCPm83xerpHVrbPSrw7i/iC0tA+Lne1ekfU76vO6LgvdeQ7LySHWkcAD6Fbf6q3hVUDIYAfgpJE2yczz+TPHgIEI7kR/SUDBBXMYeJ9aej3dQqwsLEzcfLzb8yu6zJdS6L3ep6TqvQs91f9ddOkg03nGYn1j6j1vpeb1LExsKjpj7rSacg3OebanYzawz0adv09+/cujSSSUpZXXem5uTZgZ/TjWc3pl5urqvJbXax9b8a+k2sa91L/AE7P0Vnp2e9aqSSnjH/V3ql2G7p+J0jF6WLMR/ThluzX5HpYtr/VyGMxvRZ61u7d6e6xn/GemuxqqZTUylk7K2tY2eYaNoUkklKWf/60X/oJ/wB+Wgs//wBaL/0E/wC/JKf/0PTpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJSpSlJJJTz/1o671Ppd+Bj9OqqutznPrDbQfpA1Nqa0iylrdzrfz1j5v1r+tuA7Zl42DS/nY6xu7/MGZvVj69svs6j0OvHf6V77ntqs/deXYwY/+y5Qd03Op6oOkdEYMd1bBbmdWyK/UsscdTtsta7d9P8xVZ8ZnOpSABiBw+MXd5YctDleXMsOGcpwyTySyie2PLOPFLJGX9z/JZJsML60fW/PJbh4uDe4CSxtjdwH9T7ZvVv8AaP8AjC/8q8X/ADh/72Kp0/Ev6pm53S+oOYOp9OIfjdToaK3gz7fU9MMa9uv0F0f1e6lfn4LhlANzMWx2PlAcepX+e3j6bTvRhEnfJPW+sf0d/wBFZzObHjsw5TlpCPDxDhyH05RxY8kZe7Hjxz/uQcf9o/4wv/KvF/zh/wC9iX7R/wAYX/lXi/5w/wDexdWkpPaP+cn9sf8AvWp/pCH/AIj5X/Ey/wDq55T9o/4wv/KvF/zh/wC9iX7R/wAYX/lXi/5w/wDexdWkl7R/zk/tj/3qv9IQ/wDEfK/4mX/1c8p+0f8AGF/5V4v+cP8A3sS/aP8AjC/8q8X/ADh/72Lq0kvaP+cn9sf+9V/pCH/iPlf8TL/6ueU/aP8AjC/8q8X/ADh/72JftH/GF/5V4v8AnD/3sXVpJe0f85P7Y/8Aeq/0hD/xHyv+Jl/9XPKftH/GF/5V4v8AnD/3sS/aP+ML/wAq8X/OH/vYurSS9o/5yf2x/wC9V/pCH/iPlf8AEy/+rnlP2j/jC/8AKvF/zh/72JftH/GF/wCVeL/nD/3sXVpJe0f85P7Y/wDeq/0hD/xHyv8AiZf/AFc8efrH9a8TqOBi9Uw8bHrzrm1AtlxILmMt27Mi3a5vq/nroP8A1ov/AEE/78sX63/8ufV3/wANH/z5ira/9aL/ANBP+/JsASckDOWhjUv0tuJk5nJjjDk+Yjy+IHJDIZ4qn7EuHJPFH08fH/44/wD/0fTkHLzMXCoORl2topboXvMCT+aP3nIyxcalnUuu5mVlD1K+mPbjYdTtWteWiy/J2/6V27YxJSRn1p6I54a+2ylrjDbbqrK6yT/wtjdi1gQQCDIOoI1BBTWsZdW6q5osreIcx4lpB7FpWR0NpwszP6M1xdj4hZbiTqWVXAu9CT+bVY32JKdhJYP1iz+p0dS6fiYL8lrMirIsubh102WE1GkV7vtv6NlX6V30EDpfUOsft/Gwsp+ace/HvsLc6nHrl9TqAz0H4Xu9vqv9RtiSnpUlxuZ9Yn/a7HUdUezGstLaZcKxM7fRY2zpVzt+72+n61tiNjfWPOs+qeZebHP6tRYcYOewtLXX3fZsCwzVjV27arar/ZSzekp6xJc99YMnqeFZh4eHnOx214WXfdkOZXa+w4raPT9U3tc33+o59uxVOhZ3WsjMH7R6uWVVswnml1FLPUfl1utdil4r3s942V7P0iSnrElxFf1k6ofrI7Ffk5DemG92PW4/ZZD2g7tzfS9Z1Ff09m71/S2K7hdY6hfg9Xtxuo/a6Meuh2J1DLqFDGmxrjkuGynGbbsbssq9uz1P8J6aSnqklwr+p/WMCrEp6hbZk1S4NNWP6rnBvswsqr7Q3fcyj1XZv/dr0n0foFtZP1jdi9Jw3485mbm0MtquyG+jQxtkBuV1Kyr9Fj1MdYxnoUb777P0VP8ApElPQJLAwM7P6VnDo/U7X9SDnM9PqFbNz2Ou3bKuo0Vbvs9VlrLfseS39B6X6G70/S9Sy3hZGRV17qPTrrDbWa6c3F3aljLd+PfQD/o2X4/qVf8AHJKdRJJJJSkkkklPJ/XFwb1v6uucYaMokk8AepirW+sXXqej4m4D1My72YtA1LnH84t/0bFjfXrGry+pdCxbZ9O+99b40O178Zjo/wA5Az/qx1V14dmYw641jRXTcMj7PaGCdrbRZ+ic7X6TVXMpCeXhHWOv+C7EMOGXL8ic0wIiOWXt+mHH+vn+nklDH/e9bt/VbpB6di2XZTxb1HNd62W+QSCdfT9v7m7/AD031ZeL7OqZtY/QZGY70XdnNY1tXqN/kvc1YuB9U+o+q51NA6LTcw1ZBFxyL3VuLXOrrc39DXv2fTXYYeJj4WNXiYzdlNLdrG+SfjB9OnCI/i1ubnAHIRk93JmMboR4ccIerh/VyyY/0YcHBP5Eyfa7wKb46juFzmY19GVbS179rXe33Hg+4d1K0Ho0lmdHzLbS/HtcX7RuY48xwWlaaSlIb8jHrdsstrY4ctc9oOvkSit5HxXH/V3oPROpu6xk9SwKMzI/a2az1bmB79rXhtbN7vdsY36DUlPWV30WktrtZY4CSGuDjH9kqa5LKwOi4XW8XH+rXTqB1zGJfa6oenRRVa30nv6m+n3P31/0XE/nfU/SrrTE6cJKUkkuY+s9GTf13pOGzOy8XF6jXlVZFONYKw401/aKy0lj9j7P0jLElPSMupsc5tdjHuZo8NcHEf1g0+1TXmGJ03o+PiUdQ6XZnYWSej5PUqbGX1/o207G/ZbNmMz7Qx73fSs/0a9A+r7bx0PAOTdZkX2UV223WmXl9jRa+SA32tc/bX/ISU4n1v8A+XPq7/4aP/nzFW1/60X/AKCf9+WL9b/+XPq7/wCGj/58xVtf+tF/6Cf9+UWP+cy+cf8AoN/m/wDcfI/3M3/uxkf/0vTli5Lrui9Tv6h6T7umZ212Uamlz6bmjZ65rb7n0Ws+ns+gtpIGOElOTZ9auhBk05P2qw/Qooa59rj+6Kw32/21PouJltOT1DPaK8zPe1zqQZ9Ktg2UUbv32t91i0msY1xc1jWuPLgAD94TpKef+svQsvquf0++inFvqxa722NyzYGg2+j6bq24/v3fo3oHRvq1m4HXqM9+PhY9DMe6p5wzbuLrHUur3tyd3s/RP+gunSSU8hnfVTq2bn0OyLwabsluXmHFe7GprdWIY6jHb6mTfnP9j/tbr62epXv9JXh9Xc/9gZ3R7L6rC8izDy2tc219rS3IZf1Le631Mj7VWz1bqnfpWf4Or6C6FJJTg9c6P1Dqz8DI9DHs9Oi2vNw77rK2E3ehbsF2NXZ61VduP+krf+jvrUW9I6nd1ejqWZ0/p/q1uZvtrycgkNr3tZYMb0K8a6+lltnoOt+h/pF0CSSnkT9VurOftLqxjuzrck/pnEhljr/0n2b0fS9XZcz/ALUKw/o/1iyOiZOBkmmuMCvCxseq1z2PsZ/OZdlllVPo+pDGMr/SLpkklPLZP1Xz6MqzqWK9uZaM5+dVhyaQTY11Wx+RZZZS30/U9R+3F3q5f0XOs6H0voc1+hWMdvUrpMhmP6dprxmR73ZN1Xpeo7+aqW6kkpoY+HfX13Pz3R6OVRjV1wfduqN5t3N/68xRwsLJHV+odRyg0esKsbEaDJFFIc/e7919+Rfa70/3GVrRSSUpJJJJSkkkklPK/W//AJc+rv8A4aP/AJ8xV1S5r63dN6vl5XS8rpdAyLMGx9pDnNaAQaX1bvUfVua70vzEL9o/4w//ACrxf85v/vYoBLgyZLjI2Y1wxMv0XVlgHMcpygjmwQljjljOOXLDFOPFmnKPpk9S4bmkDQngqNdgfpxYPpMPIP8A5Fcx+0f8Yf8A5V4v+c3/AN7FCzM+v1kb+k4jo4O4T9/2xO94fuT/AMSTD/o2f/ijlf8A2oxfxerfYysS8/Bo5J/daFjW9M6lfa+57WB1ji4jeNPBv9lqzWZX1+rMs6TiB3724E/ecxT/AGj/AIw//KvF/wA5v/vYl7w/cn/iSV/o2f8A4o5X/wBqMX8Xe6d0/wCyBz3uDrX6GOAB+aFcXK/tH/GH/wCVeL/nN/8AexL9o/4w/wDyrxf85v8A72Je8P3J/wCJJX+jZ/8Aijlf/ajF/F6saEFcj0zqGZ0K/qmJkdH6jlG/qOTlVXYlLbKnV3u9SrbY62v3bfpqf7R/xh/+VeL/AJzf/exL9o/4w/8Aysxf85v/AL2Je8P3J/4klf6Nn/4o5X/2oxfxY419+f8AWHp1mB0jO6Xj1X5GV1KzJqbTXa6yl1DHP9O2z17vUNa6xcr+0f8AGH/5WYv+c3/3sS/aP+MP/wAq8X/Ob/72Je8P3J/4klf6Nn/4o5X/ANqMX8Xqli/WLHyxldK6ti478s9Lvssux6o9V1V1NmLY6hryxtj697X+lu96z/2j/jD/APKvF/zm/wDvYl+0f8Yf/lXi/wCc3/3sS94fuT/xJK/0bP8A8Ucr/wC1GL+Ljt6c4YB6f0zp/VX5Lum3dIodl0Mppay9/rPyci4u9npfyf8AMXfY1IoxqaAZFNba58drQz+C5n9o/wCMP/ysxf8AOb/72JftH/GH/wCVeL/nN/8AexL3h+5P/Ekr/Rs//FHK/wDtRi/ir63/APLn1d/8NH/z5ira/wDWi/8AQT/vy5q/D+uHVOq9MyOo4FVNWDe2wuqez6JfU61zg7Itc7a2n8xdL/60X/oJ/wB+TYS1yT4ZUTGhw+r5eH5WXmcIMOT5cZsJnCGUTkMsPZhxZZ5Y8WX5Y+l//9P05JJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpZ/wD60X/oJ/35aCz/AP1ov/QT/vySn//U9OSJABcSABqSdAAksO2gdc6rkY+SSemdNLa3Y4JAuvcPUcb9v06aWn+bSU6lXUenX2+jTl0228bGWNLvulWFQyOg9FyKfQswqWsH0TWwMc3+VXZXtexyD0S7Jrsy+lZdhvtwHN9K930rKLBuodZ/wjP5t6SnVSWD9ZOv5HScrBxqXY9Qy23PdblC1zR6Xp/o2MxQX7n+r+cgdF+s+TndZr6dbZiZDLaLbi/FbexzDUamjf8Aa2ta5tnrfmJKelSXO5XXeqszLasYU2UtcfTe2iyyWfveozKYx/8AmJ6PrRZZ9V8jrDmVuy6HWUihkwbRacPFbYwufZV61vp72uekp6FJUL87Nw/Qpfg5HULTWPWvxRU2sPHts9uRfS5m53vb/I/PWb9XvrJ1HqWDjW5HS8nde97X5FYqFLQ22yoPLTkG79Gxn6X9Gkp6FJcf1z665uH1C3Ew6McNxjk12OvuDXOfTjty2PFW39GzdZ/6Ef8ABrZ+rfXX9ZxrHW1MquxzW2w1WC1jjZVXkbmPaG7f5z31f4NJTrpJcrlMX61dUvwczMubhYNVFxFV2TaSz05hlLm0/pbMm1jfWp9P+e3/AEElPVpLG6N18ZfRL+rZ5pqbjG43CkuJZXVJm+mz9NRe9jfU+zWfpEmfWGtvUMyvK/RYdLcH0Xljg4OzTYz9Z/0bPUbUz/gklOyksXrPXj07IyGCylzMbEdfdSPdkseXCvGuZRuYzIxrXO9N/vq2P/wvvQ+n9b6u/quLgdVwTg/acYlhOxwfk1APzNj67rHVY1bHN9DfX+m/60kp3klx/UPrtl4XULRfVTh4W2MVmRvdfY5p/S2vrwW5Lsbf7Ps2NkNq9Sv9N6v+Cr0rvrFlU/VXI61ZTQ3Mx6y92My4XMYdwawXW0/yD6lja0lO8kuFv+vmfS7LYMnpT/sdIvDh9oiwkWH0a/5f6L/wRdNZ1PJrzOkl7WjE6ox1bh+dXkGv7XR7/wA6uyuu+nZ+/wCmkp1EkjwkkpUpJc8rCyM3qNGRZSb3H03QDA1HLeySndSVDpnUH5QdVdHqsEhw03Djj95X0lKTwfBIakLj+ldJf127quXm9S6hW+rqWVjV14+VZTW2qlwrpYymv2N2tSU9gQRyEy5TIw6vq/1LAfi9Q6jnZd7nMb0m292Schjhs3n13ivEqxHfrD8t3/Frqzz4pKUkkue+sOb1yrq/TundOyqcWnqVeQDbZQbrK7KGetvr/TVNd6jHe1rm+z0/8Ikp6GCOUl55hu6rhVM6jh/WKzLc/p9/UGU5VFtld1NQbufc23Ld9nv9RzNnortuiX5mT0fCys5zHZWRSy601tLGA2D1QxrC6z+ba7Z9L3pKbqz/AP1o/wD0E/78tBZ//rRf+gn/AH5JT//V9OWGMivo3WskZZ9PC6q9ttGS76Dbw0V2Y9zv8Hv276luKNtVV1bqrmNtreIcx4DmkebXJKRZGdhYtByMjIrqpaJLy4RHl+8qHQ23ZN+Z1m2s0tzixuLW8Q/0Khtrssb+a65zt6LT9Xeg0Wi6rApbY3Vp2zB/ktdua1aPKSnnfrLh9Rv6t0y7Cqy7G1U5LbX4VldLml5x/TbZblfotj9n0PpoHS8HqrPrJiZGVTntorxshhszbqb2hz3Y+1tf2T+ae/Y7+c+mupSSU+f5/wBWupZWZj0jBrwq8nNbktrrpZknHZXvNjsnP2041ePY/wBO39mfrPq/zXrrUq6H1Fn1b6jhOxGVdRFjMhl1ZrNeS+lzMqn7OyllLsen9D6FeNdV+g/0l388usSSU879ZKb+ou6O9uDk5eK+yy3JxK3Gh4DqXGn7Q/1cdtXpW/mPu/nFT6Z0k4f1g6bZhdGyelYwGQMlz7hbUQWfomOZVkZLK/0vu97K11ySSnh7fqtn9U6rm2Omql1uS77VkYtbd7rq24fo0V+s7Itprobupy7fSZ6n+lWn9VOn9S6fX1BllJpyHNqNdFlbKqDYyr0K3VZeJZe26u1tVf2h3osuq/0K6VJJTXwMm7Kwqsi7Hfh3vafUxrYLmPBLHN3M9tle4fo7G/zlS4un6u53Tn9Ud9jcLbLajgu6XWWltjaPbay3Nve2vF9R/pWekz1PW/4P9Gu8SSU8z0zo93Uej/Y88ZePdZbj2dTfmNrc7I9IMfbjV21O/orrK/T3u/wf6NSxMN3W8nr1mZh34eH1KmjFrGS0MsJqbe226usOftax11bqbF0iSSnl+v8ATeom3Ge7HPVsZmA/CzQPbbc6x+PsFLans9C266lttuS9/oYlPqWKv0rpXU+nddxm5VGTn2imoDPtvstx6t1bm9Xex1tntvuvZS2pjqfez+2uwSSU+fv6R1PHzskYODfRU7qb3WHHqfWx+Ft+h+hy8Vl9O938yyln/Gq7T0fLd9Ucvp9eE6jK6jmuq0q9NzKTkD08q/c61/pY+K3dX6luR+YuzSSU8Z1vpHVabesWsyepZbcvEqx6PRZS82WOGU307Q2hmyirez1LP0Pp+t/OLYuxMq3K6FhOqIqwB9ryrR9Br6qTiUY7bPzrH25D3/8AFUrbSSUs4EtO3nskx4e3c3jv5H90p0N9LXO3tLq3nlzTE/1klMyWtaXOMNGpPgufyKc3IyLL/s9gFjpaC3Xbw1boobuDnudYRqNx0H9ke1ESU53ScG3H3XXDa942tZ3A5JctFJJJS45C4vof1m6D0W3rGF1XMZh5P7VzLRVaHB2yx4fVZo13ssZ7mLs08/6wkp4vD6x0TI+tmEfq/mfbbeo23v6s7WxwpZS441XqWs3Y2JTkbPTqrd/OLs08pklKXP8A1nczD6h0TrF4IwsDIuGXaAXCtl9FmO220M3ObV6pZvs/MXQJAkcJKfMWu6bj9L+y43VMfqOYOjZHSMfFxQ99tl+RZvrdUwN/m/T2scvSMKg4+Hj47tDTVXWR5ta1n8EaY4gfAQkkpSz/AP1ov/QT/vy0Fm7nf85duw7fsc75ETu+jt+kkp//1vTkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSkkoCUBJSln/8ArRf+gn/floQFn/8ArRf+gn/fklP/1/TkkljZdmZ1TqVvTMW52Lh4gb9uyK9LHveNzcWh/wDg/Z7rbElO1tPgmWOfqtgMbuxLsnEyB9HIbc9zgfF7LXOZaj9GzsrIGRiZwaM/BeK73MENsa4bqcmtv5vqs/6aSnRSWN1v60YPRcmrHyWuc64sjaf3y5u1rRuc63azfXX/AIVNg/Wrp+X1D9nuDqbnubXj6PeLLPSblZNQtqrdjtdh7/Tu/TJKdpJYTvrZXYyu3p/Ts3Opfb6b7a6HhoYC6u3IqdH6b0ns/m/8Ijn6y4AwM/M2XVnpg/T491bqrNzmiyitrH/9yNzGVpKdZJZuZ1puE7Dpyaw3IyW+pkMDxtoY1o9W11kfpP1h9OHR/wByb7lWwfrTTmdTt6cMHMqdUamix9DwJuDn/pwW/q7WbfzvppKdtJYB+uXTR1R3TXNIdXu32bhta1rzW+5x+h6Hps9Tfv8Ap/oNinh/XDpWR0y7Pt9Sl2LXXbl4/p2F9Yu/ozPfVV6r7vzPTSU7iSxf+ctn2o1npPUfs/p7hf8AZ3z6k7XUejG76H6T1Urvrb0it3SoslnWHRQ5wc0taWuc17mbHfStayj0/wDhElO0kqFHUeoWZDKrOk5NDHO2uve+gsaP33Cu99m3+oxZtn1wqbm34gxjFL8isW2WtrYXYobZc619jfToq2v9tnqJKehSWJ0n6x2dXwcnKxMP9JQzdXjuuYbHP9+ym6uvdZiut2fovVb72P3rT6fm0dQwcfOxzNOTW21k8gOE7Xfy2fQekpsJJJJKUkkkkpSSSUJKUklHy81j29YzKbX1Prr3VktPP96SnYSVXAz25jXe3Zaz6TeRB/OarSSlJJASQFy2E/60dav6jfjdYr6dj4udfh1Y4xGXe2h3piw3WWMfus+k5JT1KS5u23rnQsjHy+sdapzOm2PNN1RxRTaXvEYwwm4xvtyb3XfTq/0PqWLpCIMJKUkksTrnXepdP6jidNwcCvKs6hXa7Hvtv9JjX0jfa21npWO2MY5j/p/pElO2kuIxOt/XPDH2zMswep4v2azKdQy+utzq6xvfk4D6KnOfRX9H9Nv/ADF1nSM23qHS8XPuo+yvyq23CgP9Ta1431/pNte5zq9v5iSm2s//ANaL/wBBP+/LQWf/AOtF/wCgn/fklP8A/9D05Y3T7GYfXuoYN52vznty8Rx0Fg2tquraf9JU5n0Fsqtn9Owuo0inMr9RrTuY4Etex379VjffW9JTZOgJdoBqSdAB5rH6LY3N6j1LqtX9FuNePjv7WCgEWXN/keo7YxOfqziWAMysrMyqB/2nuuJrI/dft2usb/WWrXXXWxtdbQytgDWMaIAA4a1oSU8b9drLG9VxvTY3NfXUba8NlduRc21m70LPQx76XYeK9z/Uv6hX+m/Q+j+kS+r78Svr9Tmbv2ZZ6ren5AbY6qzqFra/2o1uTc59z2/oHvouyP5+77Zsus9JdoNONJ5KUlJTwX1kws12Xj4eQOk/tDqVtVLRTVaMj0XOLbLa3Osd6bK62u9S+v8Amv5D1qZnSuoYX1TysN1WL6eEKbcajDbYJrxrGZVtdrsqy2y57mVO2e5dSkkp5f6wNw8zrHSsxjWZDWs9assxLsix7bDtpsZkY7PSrqqba+6uvIs2et+sf4NZWPi2dK+sNeBmZguOPZ0/7HSzHLbMgMquwmPa91z2Nrxm2Psy7P8Ag/8ABLvRoIGg8BoE8mInRJT5zk2Yh61m/tHZkYLrH0ZefW3IdW3Gh9j6b+pY979+bbe5mM3pj6X0VV1+lX/o1t/Vk9Qdg5+O9jKuul1T3VZtTgz7OGso6fY9lb/0m/Ho/S+nZ+hzfVru9NdUNBA0Hh2SkpKfPG9Gs6j184FFfSX/ALPZ9pvFFdzaxkC3ZXXmOrt9V9jNr7GYzrPT/PsqWx1/D6jZl9EvzLofS64eh09u3dd6ORdZ6Tcj1dzb6avsnpWf6e9dWlp93CSng+jdPx3dWN2Pg4978N+CzNqo3FlFrxf9p+y/pHNZbg2/ZXZf0/UqrVTJysZvVc67IFlePYLzW8srDr3et6W7Df6DGX+vXUxmR+sY/o7PVs9Rejp9zvFJTyv1Y6nj1/tLLzMmuy2ipluTbTZ6tXpMdk37m2O22epX6r6X1vZ/g6/R/nPZp/VTFuxfq7gU3tLLTWbHMPLfVc/IFZ/4ttuxazvcIdqPA6hJJSkkkklKSSSSUs+dpI1I1hOCHAOGoOoKSGantJNL9gOpY4S2fL91JSRc1mXsvy7bmGWOd7T4ge3d/aXQela/S54LO7GCAf6zvpIsDwH3BJTldExrGufkPBaxzdrJ0nWS7+qtVJJJS7fpD4rnPqhdSyvrAfY1p/bGdo5wB/nB+8uiWXl/Vb6t5uQ/Ky+mY12RaZstfWC5x/ecf3klOXfWzG+t3Tcm+5nUbuo25FOMXRGHSyl2Rsw62Ocz7Rc9n61mP/S2Vfo/0a6hZ2B9XOgdOyPtOB0/HxrwC0W11gOAP0od/KWikpSwPrCa6uv/AFbybnBlIycihz3GGzfjWsqZuP51ljPYt9CysTFzcd+NmUsycez6dVrQ9hjUSx37qSngLeldU6T9Xzf1KttDMP6vZWA5xsYYybrG+hSNrjudZU1m3/MXedOpNHTsShw2mqiphB5BaxrYVLH+qn1Zxb2ZFHS8Zl1ZDq3+mCWuH0Xt3bve395aqSlLOkf849s+77HMd43crRWb6bP+c3qbff8AY9u7y3TCSn//0fTkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSkkoShJSln/APrRf+gn/floQs3eP+cvp+6fsczGn0v3klP/0vTkkllZ+fnW537K6VsbkMaLMvKtG5lDHfzbW1/4XIt/NYkp1UlkO6X12oGzG6w+64a+nk1sNLv5J9INsr/sq10nqX7QoebKzj5eO805WOTOywfuu/Orsb763JKbqSpZ/WOn9PsZVlWFjrNu32mIcSzfv+j7HD9J+emxut9NysqzEquAvrFZNb4Y4+swZFQZW8tt3ek5u9uxJTeSWRlfWz6uYrq22dQocbLhjkVva7Y525u66Hfo6mOZsss/wav4fUMDPY5+Dk1ZTGHa91Lw8NdG7a7Z9F21JTYSVe/OxqMrGw7HH7RmF4orAJJFbfUtsdH0Kq/9I7/CWV1/nodHV8C/pZ6s2wjDYx77HOaQ5gqLm3ssq+m22p7HsfWkpuJLJ/50dGFYsL7mtLnM91FrSHMO2ytwexvvYiZf1h6Vh4NOfk2Oqx8iwVVl7HNcSZ9/pOHqekxrfUfZ/ov0iSnSSWP/AM7vq99qbi/av0j2Me32Pg7yWMZ9Df6nt3ubs/m/0i0Oo5+N03DtzcsltNAl5Gp1O32j5pKbCSzcr6xdHxL3499zxZXG4NoueNRuG2yql9b/AOy5PnfWDpeBktxsqxzbHNY/RjnANtf6FLnuA9u+32JKdFJUMTreDl5b8Klt/r1HbcH0Wsaw7fUa222xja2epX76/wB9DzvrL0Xp+e3p+XktryCz1HCCQwH+bbc5v83Zd/ga/pvSU6aSz7Ov9Gr6ceqHKa/BDix19bX2AOB2uDm0se9u38/2of8Azm6G280X5bMV4pryCMk+gQy0vFYczI9Oxlv6Pe6tzPYxJTqJJmPY9jXscHscA5rmmQQdWua4fmuTpKUkkkSAJJgDUk8QkpSSyLfrLiMeW1VPuaPzwQ0H+ruWF9YPr9ZjN+y9Op2ZTgC+22HhjTxtY36Vjv5aNFnx8rmnIRjHfv8Ate0SXk1P1z+s9Vwt+3Ot1k12Na5h8tga3/or0P6tfWCnr2Ab2t9LIqOzJpmQ10S1zD/o7PzEiKX8xyOXBHilUo94/o+brJJASYXOU9b+svUL809KwcN2Lh5VuGHZORYyxzqDsss2V0WM2Pd9D3oNV6NJYdPVPrFjZVI61hYmPgW7hbl0ZDnCkhu6t2R9oqoZ6dz/ANCz3/zi3ElKSSWR1j6yY/SsurCdiZeVlZNbrMavGq3izZ/OVtsn6df+E/0e9JTrpLkcX65dcre6zq/QMnHw2sc+22mux5pLfdFotbW26vbu3XUrpem59PUsCjPoZYynJbvrba3Y/afoPLNfbY331/yElNlZvqs/5zelPv8Ase7b5boWks//ANaL/wBBP+/JKf/T9OWP0givrPWcazS99zMho7upcxrGOb/JY9uxbCo9S6TTnuqvbY/FzcefQy6o3tB+lW4H220u/wBG9JTeWR0pzbuu9XyaTNANNBcOHW1tPqx/U3bEndM6/eDTk9WDaDo449ArtcPD1XOf6f8AYatHCwsbBxmYuKwV01iGt5Mn6TnO/Oe785ySnj/r4zFb1DFvzqR9nqZ61VxsrpDradxGKx9lN32nKt3/AKHHv/V/S9ZLoOFhVfWwYljmWX4QuzGW2embXX5LKW3Yf6Fvpb+nN3/o6P5mi/GXXP6fgPyjmWY9b8ks9L1ntDnbNf0Y3Ttb7lGnpXTKK6aqMSmuvGebcdrWACux27fbX+5Y/wBR+5JTyHVR1jBqrbh9Py+nNvuZi49dWdV6LH3PP6RtDN26x73us/SP2ep/Oro/q9j5FFd32rDtx73Fgsyci6u+3I2t2Ntudi+xr62fo/oq7d0zp2Rm059+NXbl4w20XvEuYP8Ag5+j9JWUlPKZ2Jm0/WxjendRjM6jW+zLddUy92Ni1D9BXTLq/s+JbkuZWyrb+nu9a/1LPTVfFx7R0r6z5lmQbbGuyca1tbW10XPqZWftrsdu9teZ7vs99lVn6b0/0tfqrqXdK6a9uUx+LU5ueZzAWg+qQNrfW/f2t+ipM6fgV4R6fVj114RaWHHY0Nr2u+m3YyPp/nJKeCvutqtFrabS3H3UY9r6rXX3MybrcjGc/GdWdn2nKr9Kux9db/8AC/za0LK7T0To2JXj111/andMyMoEl1VJv+zW0UNu/Tb8/wBJrcj9yj1a1178LEsc976WOdaGNsJGpFZc6j/tlz3emoVdN6fTi0YlWOxmNiua+ioD2sew+oyxv8vf79ySnlms63+1ukn1asG3IzMoXVOwywl9VD63W7vtVnr120Us9C2p/wDN+l/ovSV//GDjWZX1Yyaa2Mc0uY6yx4D/AE2tcHb6q3/Tuf8AzLf+MW0OmdPHUD1P7PX9vLPT+0kS/Zxta4/R/souTjUZVD8fJrFtNgh9btQRM6pKeHyr+oV2s9DJy66Q7Mr+z4tgqqt+xVMdZ+zq302uxKK7xfTRVvv9T0v5zYp9avxMn6w9OppcMp19WIWj3m6ykG3MruewPq9VzLKPVu2eh+jt/SLsf2fg/bz1L0GHOLPS+0ke/Zz6bXfmtTZPTOm5YeMrEpvFrmvs9RjXS5o9Ot/uH02M9jUlPJ/VvHDOsY17KgwO9c15YYWsyvZ+surt2+pZ+s/pWfbG1epV+kxfUqVOjH6qeoZNWI7G6Jj473UZ2KzOeMjJfYK8r1r8y6m3a/8AS/02mv7b/gPX9L012mP0To2LeMnGwqab2/RtYwBwkbfpJWdF6NbdZkXYOPbde7dbbZUx7nEAMBc6xrvzGpKeW6tecv6h5eLg4oxBjW2YYGJc401spcfXyjewUPvpfXv/AEdjP099n6RCymZdb/s7cYepkHNrspLrn/an1YjG3OffkP8AtVtb7A+nCf8A4JdjX0vp1WJZg1Y1deJcXGzHa0Bji/Wz2N/fUv2fg/bv2h6DPtor9EZBHvFfPptd+a1JTPEdS/EofQw10uqYaq3DaWsLRsY5h+hsb7dqKkkkpSr9Qqsuwciqr+cfWQ0Duf3f7SsJJJBog9tXhR/qPBcl1hr29TyN35zg5vwI0Xr93T8C95stx63vPLiNT8YWP136ndM6nUDSwY17Po2V8/1XNd7XtThJ0eX5/HCdyiQCKPXhfLF23+LGu31+oXa+jtrZPYvBc/8A6hBq/wAW+abQLsoCqdSxnuj+07au36T0rF6ThsxMVu1jdSeSSfpOc785yRIpl57nsU8Rx4zxmdWaIEQPV+k3W/SHxXP/AFNBNPWI/wDLjO/8+Bb6wrvqdgPyb8ijN6hhfarHX204mU+qo2P1ttFTPz7Xe56a5DW6lRbf9ZMCnrgFuFkX2M6Xg1mag6mp2T9v6lvj7RkP2enj4+30Mb+XYumWLg/VXCw8+nPfl52bfjbvs4zMl97GOe30n2Vsf/hPTdsW0kpSwOusn6y/Vtx0Drcykn/jMV7v+qqW+qnU+lYfVMdtGUHj03i2m2p5rtqsbo26i6v312e5JTwOOM2vodb8iq2oYv1XzWONjXNAtNjamMdvH85sq3bf3F33Sa/R6Tg1f6PGpZ91bAs131Rw7YZmZ/Uc7HkF+LkZLnUvjUNurY2v1Gbv8H9BbqSlLP8A/Wi/9BP+/LQWbP8A2TRt/wC0f05E/S+jt+kkp//U9OSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKSS1S1SUpJLVLVJSkktUtUlKWdtb/wA5N0Dd9kjdGsbvFaOqz/8A1ov/AEE/78kp/9X05JJZ3Ueqvx768HCo+2dRubvbTO1jGcevk2/4Ov8Ad/fSU6KSyH2fWuhvrOqw8to1dj0l9b48KrbPY9/9ZXundQx+o4rcrHkNJLXseIex7dH1Wt/NexJTZSSQ8e+jJpryMd7bqbQHV2MMtc08OaUlJElTd1no7HFr8/Fa5pILTdWCCNHA+9FpzsHIZ6lGTVdWHBhfXY1zd7tG17mu273bvoJKTpKuzqGDYKzXkVvFzg2ra4HcXB72NaP5TKrHf9bVmDBMccpKWSQ8e+jKx68rGsbdj2tDq7WGWuafzmuU3Oaxpe8hrGguc4mAANXOcUlLpIVOXi5BLaLmWkMZaQxwd+jsk026f4O3a703odnUum1WPqty6K7Ko9RjrWNc2fo+o1ztzN38pJTZSQMfOwcp5rxcmm94G4sqsa8gcbi1jne1Qb1Xpj6G5Lcul1D3NY2wPbtLnO9FjOfz7v0aSm0kma9j27mOD28S0gjTzCAeo9Pa1znZNTGtsdS4ue1oFjP5yr3lvvYkpsJIH2/A9A5P2mn7O07XXeo3YHfuGzds3aoT+s9HYAX5+K0OG5pddWAWzt3CX/R3NSU3EkK/LxcfGdl33MqxmN3uvc4BgaeHb/o+5FBBEgg/ApKUkkkkpSSpXda6ZTYa33S5ujtjS4A+G5qyOufXjpvTag3Ga7Ky36sqILGgf6S15/N/qo0WWGDLMgRgSTto9IkvOaf8ZXWG3brsbHsqnWtu5jo/k2bn/wDSau46P1jD6zgtzcQnaTtsrd9Jjx9KuxIghfn5TNhAlOPpP6QPEG8kksF31ua7IyacLpPUM9mJc/GsyMdlZrNtelzGerfW/wDRu9v0EGu7ySxcP6zjIz8fAyel53T7MveKLMljAxzq2+s+vdTbdsd6bXfTW0kpSSSzup/WHofSX+n1LNqxrdnqip7oe5mvurr+k/6P5qSnRSXLYP8AjJ+rWVe2q578FtjS6u7J9MMMfmWOqst9Cz+RcujxMzFzsZmVh2syMeyfTtrMtdB2u2u/rBJSZZu8f85dms/Y5mDH0v3lpLM3n/nT6cHb9h3T2nfH/UpKf//W9OWR0eD1jrbrP6QL6268ikVt9GP5H01rrN6h0vIfls6l021uPnsb6bxYJqurBkVZDW+72/4O1qSnSWR0wNH1g6y2qPSPoOsjj1iw+p/a2bNyY3fWu4ek3GxMNx0OSbTaG/yq6NrNzv66u9M6bT03G9CtzrXvcbL736vssd9O2xJTS+sRuxsW7qP7Uvwaaa9voUspeLLD7ams9em6x19z3MqZWxZ/1LxM9nT8ai7qV4v6X+rZvTCyjax7R7WOeyn1/ScxzLqbfW/S/wCkW5f0nCyOo09RyGutuxmxjse4mqt2v6xXj/zf2n3bfX+mlb0nBs6lV1TY5mbS0s9Wtzmb6z/gMprDsyamu99bLv5t/wDNpKeJss6Xi5+Z9jyMZuC57fsza78EhrWt/TusHUqr8ht1mU657/crr34NX1Z6nkOqtx3tymV51zdjnE1ip1V7K+mtrxrGsqspbVsZT6j/AOk2Lr8zFGVjvoFlmMXx+mxyK7Gwd3ss2u+kqVf1fw6umv6dVbkMrusNuRcLP01rnmbvXvcHbvW+hZs2fo/oJKeFPT7Mbp1TbLb7RVZZiNY71ab29TyamtwbXvD9v6Ftn2f9Ba+j9LvXRde6X00ZONVYMizJox68dj34mRnVbGlzt/6At/W7H/ztj7n2en+YtBv1M+rjG+kMX9WFbq2Yhe91LDZpbkVVOcfSynt9v2hn6RaN2D6nThgDJyKg1rWDJZZ+seyPc7IeH77Hx+ke76aSnjfqmzp2RmYucMZ2OPVcen/ZsK6gFsWUvdnXNN2D6Fvueymt/wCi/wAJb6v6NdB9cKcy3pNj67Kx0/Ha+/qWO9zq3ZFVY9T7IMpjbfs9Nm39P+i9S/8AmPUqr9RauBhY/TsKnBxQW4+O0MYCS4x+89zvpPc73OQuo9KxOpeg3M32U0PFv2fcRVY4Q6v7VUP59lT2+pXU/wDR+okp551nWbur9Gudj43TcvJof6LqnvtaaK215FvT87HdTj+z9J+gspt/Vb/+DsesvqltLepdTqBBycm+2moV7BYw78Ox1tzrSzbRdTX6LLN/5llda7Kjo2LV1J/U3WX35Lt4r9e11jKW2FptZiUu9lDbNjEx6F08vfZtdvtym5r3SJNjNvpsJj+Ybt/mklPMdCysO09TrLbW034kt+wubY4Bh9O6vGdS++9uVb9pqr/sfyFmtx8W3phsb0rG9S7Ev6gzHFnosY1rX4oqbR9nf9od01tdbv6R/TL/ALX+h9Vdz07oeD03J+0Y28FtRorrc4FjGOsdk2emxrW+625+97n70O36vYdnR6+jstvoxa9zZqeG2OY/d6tNlu0/orPU9+1JSvq8SzoGK+zGZiTSLHVUHcCC31DaPTrq/TXfzn0P5xcXg2ZOPgZ/rU1+qcrOyqas9js7d6DHX2Nf6ttX2B9LPToyL/032i7IrXo1VVdNTKamhldTQxjRwGtG1jf81ZeT9Wem5FGRQXW1fa7LH321PDbC253q5OL6u3c3EvcP0lSSnDyAbfq9ZV+h9SrOpmnGpbhhlzfTyGt2Pue2/wBf9F6VjbKfVrsrWPNX2Dp9wvdUHtutwHiysbbrftVt11uz1Ws2vuw8P9bf6FXr+n6nqrus3oHR86o05OMx9TnMdawaCw1sNFPrR7rPRrd+j/63+4qY+qHT2tc2vKzWNfWKSBkE/omgtZj7rGve6ljXfzb0lOR9bX32X42GMTKzsw1Ndg4fp1fYDexptufki1zPtbvTbs9P3/Z2f0b08j9Ii/VfFxcXrDn5eFZidWysdwr20Nx8VlNZrN9GIxlttllnq2VvuyMn9Pd/wVf6Jb2Z0PBzRhi91w/Z8+ia7XVuO5n2d3qW07LXfo/3XqOL9X+n4mczPpdkOurrfS0W32XN22Fjn+3IdZtd+ib9BJTpKv1E2twMg0z6grdtjnzj+yrCSSQaIPYvCiIEcdlyXWC49UyN3ZwDf6oA2r1a3oHTbbC/a+vdqWsdDZ8m67VgfWL6iVZTftHTnuZkNEOFh3NcPA7Rub/WTgQ6nLc7hjP1WOIVZHyvni7X/Fi+37R1Fgn0dlbj4b5cP+oWPV9SfrBZaK3V11idXl8gf2W+5eg/VzoNPQ8D7Ow77bDvusPLnf8AkW/mNRJFMnP83hOE44SE5Tr5fVw0eK3Wb9IfFc99Tv5rrH/p4zv/AD4F0A0MrnGfVrrmJkZb+k9bbiY2Zk2ZZosw2Xltlx33fpnXV7mb/oexMcZbqV2R1LrNHSsx1nTumWXOrprbLbs6ylv2qz9NV/ROn1tZ+/62UukJkysDG+r3V3dVwuo9W6u3PHTzY/HprxW4/vtYcdzrLG227mtre72LfSUpYHXGn/nR9W3jQudnU7vDfjGwf9Klb6z+sdJPUmY76ch2Hm4Vvr4mU1ofseWuqe2yl/ttqtqsex7PYkp4ynrufl9FZ62Q6zf9Ws3KyAY99zXNxmWv/l/zjV2/RavR6L0+mI9PFoZHwrYFkXfVfqmVQ7Ey+qUDDtYab2YuEyix1Ljutxq7zdd6Ndv5/wCjXRNa1jWsYNrGgNa0dgBDQkpdZ8n/AJxR2+yfjuWgs/8A9aL/ANBP+/JKf//X9OSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKSS+SXySUpJL5JfJJSkkvkl8klKWf/wCtF/6Cf9+Wh8ln/wDrRf8AoJ/35JT/AP/Q9OSSVPqPVcXpwrFofbfedtGNUN9thH7jP3W/nPckpuJLId1zPob62X0fJqxxq+xjmWuaP3n01nf/AJi08bJoyqGZONYLaLRuZY3ghJSRJIAngJ4PgkpZJJJJSkkoTwR2SUskn2nwKaDMd0lKSSSSUpJPtd4FMkpSSSSSlJJJJKUkkkkpSSSSSlJIdmRj1O222sY791zgD9yz+rfWXpHSccXZF4sc7Sumoh73Efutn2/13pLo45yIEYmRO1B1ElxlP+M3BdaG3YN1dRP8417XkDx9P2/9Uutw83FzsavLxLBdRaJY9v5P5Lm/nNRor8vL5cVHJAxvr0/5qZJJYmZ9dPqtg5VuHldQazIodstYGWP2uHLC+qt7NzfzvcgxO2ksjpv1t+rnVMtuFgZzbsl4LmVFljC4NG5+z1q62v2t/NWukpSSSZzmsY6x7gxjAXPc4gAAauc4n81qSl0lmdO+svQuqZAxcHMbde5pexm17N7R9J9LrmVtua3/AIJafCSlLP8A/Wi/9BP+/LQWf/60X/oJ/wB+SU//0fTlj9Ka2/rfVsy3W6ixmJTP5lTWNthn7vqvfucthZObiZ+J1B3VemMGR6zWszcInabNn83fQ8+312N9vv8A5xJTrAxqFj9IY3F6z1bBpG3GBqyWMH0WPuB9ZrR+bv2+ond1zNsbsxOkZbsk6AXhtVbT42Xbnez+orHSOnW4VVtmTYLs7Lf62Xa3RpdG1tdQ/wBFSz2MSU1es14l3V+l4t7bic03VB9WTdQGCqt2VJqx31tudZt9P3rK+pprtNVltXUrchlmU37Zdba/FcK7bqWN/SXuY/8ARNbWz9B9Ni2OqdEys/qGNn09Rsw3YQccetlVT2h72upuscbmlz/Uqdt2IfSeg5/SxVTX1a23Dqe97sZ9NI3Gxz7rAbmM9Vv6a1z/AGJKcfq7MrH6g+rrfW8mvFxmt6j0y5lWO1zn07mZGM0/Z/02TX6lfpY//aijI/wq6D6v09ZZ06qzrOU/Jzb2NssrcytgpJEmhnoV17ts/pN+/wDSKrZ9Vq857reuZl3UrGknGa39Xqx9S6u3EpxzublV/wDcu2227/R+mr/TMLNwq7KsnPs6gzcPs77mMbaxgG307baQxuS7d/hfSY9JTylmbkWfWfrtRe7Jwy3GoDXOOJUHNc4en+06Gvuqsott9Gr/ALm2erV/gla6Vf8AaOl9TfRivw3PxLGknNuy7WOm7Hq3Yzg51O59d1jbanf4NbGV0Bt+dfmttDbH/Z7MdhaSxl2N6/pW3sY+v7Qz1Mr1fS3V/pK0Cj6qUU9MOJ9oecmzFsw7ssCA9l1jsm79XDtrffZc2j3/AKD1UlPI14xswLGW2PopNzd1oZY01HHrqoyG0us+xUstycr2Xfzn6L/hV0GVZkf82MHpdI+y5fVcsYX6DezYwXWPzb6hZZbdW37HjXP/AJ32eoiN+pTqyWVZzX0eq670snHbcXvdo12Y/wBWn7a6j/tP67P0a0sHoFeO7p9uRe7Iu6Wy5mMQNlYF+1v8251z91FLfs9H6b+aSU879Z8twz+qVW5b201jHqrwjdZVW+qyqx+UytmP/OXu/wCE/nP5tF+p/VK7H5F1WTZmVUUTh4bLrrLrK2EMfb9hzTtp/wAHXj/p/U/0i6BvS8ug9QuxMtteb1G4W+vZVvbWxjGUVUei2yr1PTrr+nv+m9E6X0fE6bRjMrHqX4uMzEGS76bq2HfH7rN1nv8Aakp8/wAamqzEc1rW5Li61hyrm2ttLw97Hmxn7Ur/AElT/Z9D/B+xegdDc89Ixa7G3NfRU2lzshobZYa2iv7RtbZd7L49Rn6VUesfVr7e5wxH4mFXZW5l27Crusc53NzLnPr2P2n85li18PFqw8SjDpn0saplNc6nbW0Vt3f5qSkqSSSSlJJJJKUkkkkpSBn3Px8K+6v6dbCW/Hx/so6ZzWuaWuEtcIcDwQeySQQCCddXhj7iXOO5x1c46knxJXJ9asdZ1O7dxWQxg8AAvS7fqxWXk05BYw8Mc3dHlukLmfrN9Sc1rvtuE4ZLiItqA2kx+cyT9JOBDrctzWETFyqxWulPGruP8WWVbvz8MmaQGXNHYPJNb4/rtauTq6L1i2z0mYV2/j3N2gfFzvavSPqf9XndFwXm4h2XkkOuI4AH0K2/yWIyIpl+I58XsGHEJSnXCAeLr8z0DfpD4rn/AKmkinrEaf5Yzv8Az4FvgwZXM4eF9aukXZ9eDi4WXjZebfmV225D6ngXuFnpPqbRZ/N/10xw2XUc+3q/WKem9NdXjuwrnNu6rcxpeyxrd1+D0iu4fpsx9H9Lv/mcXG/feukPPgudZhfWXqHW+l53U8fExKOlvus/QXPue821Oxm17X007Pp7925dEkpS5X60dK6fm/WjoBysavIGQMuixtglrgyk5OO2xv53pvY/auqWV13publPwM7pzqxndMvN1Vd5Irsa9j8a+l9lYe+lzqrP0duyz3/4NJTxAZ0fM6Sy13S8NjrehZXU3vZWQa7qi2mv7NL3ejXuc72Lu/q5iVYf1f6bjVMDGsxqiWj95zG2Wv8A6z7HOeufs+rnVbsN/T8bpWF0ttuK7p/2v7XbkGrFtf6uRXXj+jV61jne6vfaz3rr6q2VVMqZOytrWNnmGjaElMln/wDrRf8AoJ/35aCzf0n/ADl/N9P7H57p3f5u1JT/AP/S9OSSlKUlKkpJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJJSlKSlJfFKUpSUxFdYMhgB8YUkpSlJSkkpSlJSkkpSlJSkkpSlJSkkpSlJSlmzZ/wA5Y2j0/sf0p1ndxtWlKz//AFov/QT/AL8kp//T9OSSQcvNxMGg5GZa2ilpje88k/mtH0nu/qpKTJLIb9aujFwFj7aGOMNtupsrrP8A117drVrgggOBBBEgjUEHuElKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJPB8FXzc7D6fjuyc21uPS3QveY1/db+c938lqSQCTQFk9AnSXP0/Xz6s23Cr7Q+uTAssrc1n+f+b/aW+1zXtD2ODmOALXNMgg8Oa4JUuniyY644She3EOFdJJBszMOp5rtyKq3jlj3taRP8lzkliZJCqysW5xZTfXa4CS1j2uMeMNJRUlKSSSSUpJDqyca5zmU3V2ur+m1j2uLf64aTtRElKWf/AOtF/wCgn/floLN9Nn/OX1I9/wBj27vLckp//9T05YuJSzqPXs3LyR6jOmPbjYdTtWscWi2/I2/6V+7buW0sXJN/RupX9QbU+/pudtOWKhufTawbPtHpj3WU2s/nNn0ElOzY1trHV2tFlbxDmOEtIPZzSsjobPsWbn9Ha4uxsU13YgOuyu4F3oSfza7G+xSf9auhhn6C85dp0Zj0Mc6xx/d2bfZ/bU+i4mWw5PUM9oZmZ7w99QMiqtg2UY+785zW/wA5/LSU1+oZnVszqz+jdIvZg/ZqGZGZnPrFzm+q57MfHx6HuZXvf6Nj7bbFSzeode6Tn9Gx8q13VH3vy2PrxKm1OvDa2OxvVrsf6VTqnl/qW+rXQxaXUujZd2ezqvSssYOe2v0LfUr9am6oE2V15FO6p++mx36G+qz1P5ytRo6L1F2b0/P6j1BuXkYDslx2UippbkMbU2mtrXv2Mx9n07PVstSUs36yttwRlY3T8m60XPxsjHmmt1FtR2215F2RfVjf8T6dtnrITfrhiZAx29OwsvPtysd2UyqptbS1jHnHuZeb7amstqta+v8A4R/82gZP1QssuGRXkUWvGbk5goy6PWxyMoVtLX0+rXuyMb0v1fI3fn2ez9IqeD9W+t9K6ni0dMya2txun21OzLscupc63Kdk+i2iq6r0LKW2epW1tmxJTeyPrXa/K6IemYluVh9VdaLHAMa8emyzdRtuuqdVkY11X6zvbs9Ouz099isD62YJyxUKLjhuyfsQ6iPT9E5G70fT9P1ftfo+v+r/AGr7P9n9b/txMz6suxsbpTMHK2ZHSrrLvWuZ6guOQLW5vq1sfVsdc7Issr2P/RKtjfU4Yme2zHdh/Y25JymmzDZZmDc/134rc579vo+q79Hd6P2qpns9T/CJKbbPrRQ/puf1MYl9WH0+u53r3bGttfQ6yqyqlrLH2O/SVbfU2LIzuqfWDCq6dT1LqJ6VXZiMtyeqOxW3sdmPP6TCvgehhVUtc3Y97avX/wBN+jWo/wCrTndCq6KcgOp+1/aMp5af0lRyX9RfjNG7273OZTvVvqWJ127I9XpvUasal9fp2Y2Rj+uyZP6xU9lmPb6m07fSs9SlJSDJ+sDcI42GK3dVz7MduTb9j9NjPSkV/amnJuZVtyLP6NQy62yxRd9asS1mMel42R1SzKoGWKscNaWUElnqXOyX01se6xllddG/1bLKrFRf9R6aWYX2J+PbZh4ow3DqOOMqt7Gudcy5jA+l2PdXZbd9B/pem/0v8GrY+r3UMN9OR0nOqoym4zMTK9XGaaLW1lz6bq8XGfjNxraX3XbGV/off+kSUtd1/MH1gZ0z7HdXhWYDsp98Vh7DLP0vuu3NbQ1/o2V+l6v2n/gv0iej6yVDFw6sHFzer3Pw6suzaKvVbTY39FbmPssoo+15G1+zHo99myxWcnouRd1HH6g3KAezEswstrq5FrLNtnq1bXt+z2+szf8A4Vip0fVvqXTmY7ukdQrovbh0YOWb6DbXYMZvp4+XVW26p9ORW19ns9Symz+wkpK763YdjqK+nYmT1K3Jx/tdbMdrBFYeabBc7Isp9K2uxjmem7/Cfo02Z105eL0V3SLC13WsmvY4gB7cdgdlZ8seHta9tNP2f/jLUTo/1ap6Tm05NN7rGU4ZxCx49znuudm3ZTrAdv6W2x/6PYh9F+rL+mZGBZZkjIq6ZiWY2O3ZtPqXWC3IyfpO27qq6qWt/rpKcJ/1n6tVjX9UHVsWy2vMtoq6C6pnq2NbkOxK8eqyuz7X9ofV+krf6L/5f6NdI/6w1VdVZ07JxL8Zt1px8fKsNRY+yHuZ+hruflV13Nqf6F1tGyxS6L0DD6UxzvTquynX33HK9JrbIvsff6fqe6z9G2z0vprMr+pr2dSqyvtFBrozjntsOPOXYXGwux8nO9X311+rtq/RfzbK60lMc/65ZLvq6/rXTOnZHpl9TaLbxUGua+xtVlvpev6np/4Kt3+ksrs/mf0i0czqOZZ1bpHT8ZrsY5Aty86t2xz2Y9LdnoWbTdX+ly76Gb6rP8H/ADiH/wA2f+xNn1c+0w6utjGZQZ+fW9uRXZ6Jd9H1GN31+orWF0vIr6rf1XNuZfkW41GKz02FjWNr325Ba177P6Rk2ept3exjGJKdJBy8j7Ni25EbvSaXAeJ7IyjbUy6p9VgllgLXDyKSRVi9r1eMtysq95sute5559xA/stb9Fcl17LuyOoPre9zq8f2MaSSAY979fznLvrfq3nNeRS5ltf5rnHaY/lNhcl9Z/qz1TCudnGr1KbNXmo7y0j85zQPop4IdjlMuH3AOKIsVHpq86u+/wAWvUrrKcrplji6vH220T+aHktsrH8neN64FoL3BjAXvOga0En/ADQvSvqH0G/pmJdl5bdmRlx7Dy1jfotd/LdO5KWzL8SlAcuYyrikRwDrd/N/ivVN5HxXIfVzofRepHrGT1HAx8y/9r5rPVvqbY/a14DGb3gu2M/MauvGhBXI9Mzs/oV3U8W/o3UMv1+o5OXVfiMrfU6u93qVQ999Tt+36fsTHBVlYfRsXreNjfVnpuMOt4hL7rKm+lRj1Wt9J7upPx9jrX217vsuF/Ob/wBP9BdaYnThcpj25Of9YOm24fR8zpWPRfkZXUbMhldTLXW0Ox2vd6Ntvr3us9P6bV1aSlLmPrPj35HXukYgzcvHxeoV5VWRTj2+mHGmr7TVHtdte/8ASMsXTrF+sONmfauldVxMd2W7pd9ll2NWWi11V1VmLYaPUcxj7K/U3+lv/SJKeKxendGpw6M/ppzcLJPRsnqVNteQ39E2nYz7K7Zj1/aK3vd7n2/6Nd/9X23N6FgfaLrMi9+PXZbdcdz3PsaLXy791rn+z+QuOHTH/YD0/pfTeqnJf023pFL8yuqmltd9nrWZGRdv9vpfyG/9bXe49Iox6qAZFNbawfHa0M/gkpIszdZ/zn27P0X2Kd8/nb/ox/VWms//ANaL/wBBP+/JKf/V9OS44SSSUsGtaS5rQ0nkgAFOkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJnMa8bXCQnSSU1mdOwmP3sqaHeIABVkAAQNAkkkpSSSSSlJJJJKUkkkkpSSSSSlLP8A/Wi/9BP+/LQWf/60X/oJ/wB+SU//1vTkkkzi1rS5xDWtElxMADzJSUukqtPVel32+jRmUW28bG2NJ+WqtJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSSSSlJJJJKUkkkkpSSUpJKUkkkkpSSSeD4JKWSTkEchMkpSSSSSlJJEEchJJSln/APrRf+gn/floLP8A/Wi/9BP+/JKf/9f05YdlA651bJoyiXdM6a5tf2aSG3Xub6j33x9OqlrvZWtxYf2hnRes5Jyz6eB1R7bask/QZeG+nZRc7/B+pt31vSU38jonSMmj0LcOn0/zdjAxzf5Vb2bXscq/RLcmq3L6TlWG+zAc30b3fSfRYN1Pqf8ACV/zau5HUcDFoORkZNVdLRJeXA/5sfS/sqj0Nl2RfmdYurNIzyxuNU8Q8UVDbU+xv5rrnH1ElK6l1fOZ1BvSekYrMrPNQvuffYaqKKnFzKn3WVstsfZfYxzaqKmf8IqWV9Zc/pmR05nW6KcCvJfksyHVvdeHeixj8Z+Jsay39Ysf/R3UeurPUMHq2L1Z3Wej11ZTr6GY+bg3WejvFRe/Gvx8jZa1l1frPZZXY307K1BuF1rN6p0nqWfTRjHCdlGymqw2Fjbq2VY49RzK/Uu3b/V9P2JKbD/rR0FmHRnHKLsfKc5lJrqtseXMn1WHHqqfkVvq2/pG2VexNk/Wv6u4tVN12a3Zk1evR6bX2l9fHqsZQyx+1v8AhP8AR/4RZVvROv49j7MebcezqOVlXYlGS7FfYy4V/ZLHZVTWvb6G231sbf8ApN/5/prP6Tidc6F1bDxaMSrOzaumXerV65raG2Zz7mOZlWVWert3/pfUYx//AFCSnoM/63dJwszpmOX+tX1WXV31h72hm1zqrW+lXY231bG+lt3fo/536Ctf84uiftH9mfa2/bN/o7Nrtnqxv+zfadv2X7Tt/wC0/ressrH6B1TpuJ0M44qy8jpmRfdlVB5pYRli8X/ZXObZ7MV+V+ire39LUxVMT6rdQxs1mLZQcrBZmHLGU7OtZVtNxzW7+l1j+mVP+h/gLX/pbElOtnfWvpzen593TbBl5OG0NFYa/Z61j/slFT7XNbX/AEl36Wtr/UV/IyOpYlOO2vDf1O0jbkPpdVSA5oG5+zJtq9tjt2xlayH9E6gz6n3dOAa7Prtty6WtMhzxlP6lj17tPda3ZWo/Wvp1nVcfEuxulvyMnJaGWZEsbdiUuHqWbKbraa3Zfu2U/wChu/SWfuJKTfVjrnWOq4eNdl9NextxtFmY19IrGx9jG/oRa7I/M9P+b+mqmX9ZOvY+ZmA41LcetwZTS71DewkH0PWNDLaXfaodl+12yimr7Lb+tIPS+hMxOr4s/V442A0NFNzn1vtovZuf61xoyLHXU3/6R7P0WR/xqa76vdXy8ix1Vba6xl9RcfWcGAi92OcW1jXVZG9rvTt9+xJTpdC+sNuRiZd/VrsSluBtbfZWbKwCR/PWjLZV6dWQ39JS3/rah/zj6g1x6vbiOZ9Wz7G2bXfagOf2rZjfzn2B/wDN+hs+1V1frdlX+jD9XMfIqx8+/qGFdYyqjEqFVtYNl9mHVttfRRad1m+/+j2WbN71JvS+pN6V0ht1ROWeqNz8xjDu9IXWX5NrXO/7r+s2qx6SnSzurGl/S8vFsryOnZt7ca17IfPrgtxMim5h27G5DfTs/wCN/kLUWR1jDffZ0vAxqdmO3LZk3vY0CuuvG/WWs9sbX5GR6TK/+urXOuqSlJJJJKUkkkkpR4STOBLTHPZJjw9u5vHceB/dKSl+dCsLJzOoY+RZSchx2OgHTUct7Lcc5rWlzjDRqSsDIozsjIsv+zWAWOloI1DeG/8ARSU6PS89+Tuquj1WDcHDTcOOP3lfWf0rAtx911w2veNrWdwOfctBJS41IXH9I6QOuXdVy87OzxZV1LKxq20ZVtNbaqXCumtlNTmsbtauwHIXFdF+s3QuiXdYwerZQw8o9Vy7hVYyyTXY8Pps9jHt2WM9zElNnJw8foHU8B2DmdQzM65zmt6U/Iff69bhsNln2h/p4lGK/wDT/anf8WurPPiuMwus9FyvrXhf838o5dufbfZ1d+17neiyl/2as231tdRi037PSprfs3rs0lKXPfWHL63X1jp3T+n5lWJT1KvIa6x1HrPrfQz1/Ur/AEtTXeox30f8H6f+EXQrA+s7m4mf0XrFzXfY+n5Fwy7GtL/Trvosx222NrDn+l6pZvf+Ykp5rDHUcKlnUcH6w3ZD7OnX9RZVk0PsZbTUG7nZLbcx7ar/AFHN2eku36JdmZHR8LJzntsysill1rq27Gg2D1Qxte5/821/p/S968+a7plHS/smJ1OrqWYOjZHSMfFxmWOssuyLN9b6xt/m/T2tfuXpGHQcfDx8c801MrMeLGtZ/BJSZZ//AK0X/oJ/35aCz/8A1ov/AEE/78kp/9D05RsrrtrdXaxtlbxDmOALSPNrlJJJTn0fV/oePaLqcChljdWu2TB/k7vorQ5SSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpDfS1zt4JY/u5hgn+t+8iJJKRihu4Oe51hHG8yB/Z+iiJJJKUkkkkpScOcNJTJJKXLnHQlMkkkpSQMcJJJKX3O8UySSSlLM9Vn/Of0Z9/2LdHlv2rTWf8A+tF/6Cf9+SU//9H06AlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSUqAlASSSU0f250P8A8sMX/t6v/wAml+3Oh/8Alhi/9vV/+TXI/Ur6u9H6p0q3Iz8f1rW3ura7e9vtDKnbYqexv0nuXQf8yfqx/wBwv/Bbf/SqghPNOIkBCj3Mv4OpzPLfDuXzTwzycwZYzwyMYYuH6frG9+3Oh/8Alhi/9vV/+TS/bnQ//LDF/wC3q/8Ayao/8yfqx/3C/wDBbf8A0ql/zJ+rH/cL/wAFt/8ASqdebtD7Zf8AesXD8M/f5r/wvD/6tb37c6H/AOWGL/29X/5NL9udD/8ALDF/7er/APJqj/zJ+rH/AHC/8Ft/9Kpf8yfqx/3C/wDBbf8A0qlebtD7Zf8Aeq4fhn7/ADX/AIXh/wDVre/bnQ//ACwxf+3q/wDyaX7c6H/5YYv/AG9X/wCTVD/mV9V/+4f/AILb/wClVX6j9Wfqd03Efl5eLsqZ4W2kuJ+ixjfV9z3JE5hqRD7Zf96mMPhsiIxlzRkTQAx4f/Vrr/tzof8A5YYv/b1f/k0v250P/wAsMX/t6v8A8muGzOndNq22X4mH0muwbqqsq3JtyC0mGPfTjW/omu/lI2Jg9Aqay3qWDRdgvf6Y6jhX3uqY4/Rbk02W+rQme7kuqh9sv+9bJ+H8lw8QlzBvpGGE34Rl7vBOX9XHN7P9udD/APLDF/7er/8AJpftzof/AJYYv/b1f/k1QH1K+q5AIw5B1BFtv/pVP/zJ+rH/AHC/8Ft/9Kp95u0Ptl/3rV4fhn7/ADX/AIXh/wDVre/bnQ//ACwxf+3q/wDyaX7c6H/5YYv/AG9X/wCTVH/mT9WP+4X/AILb/wClUv8AmT9WP+4X/gtv/pVK83aH2y/71XD8M/f5r/wvD/6tb37c6H/5YYv/AG9X/wCTS/bnQ/8Aywxf+3q//Jqj/wAyfqx/3C/8Ft/9Kpf8yfqx/wBwv/Bbf/SqV5u0Ptl/3quH4Z+/zX/heH/1a62NlYmWw2Yt1eRWDtL6nB4BGu3cwu93uVSW/wDOPbI3fZJ2zrG7wWN/i4/5Dv8A/DT/APz3Qtna3/nJu2jd9jjdGsbuJQ90+z7la1dLvuEf9I/c+M8Pue3x/pP/0vTkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklPK/4uP+Q7/wDw0/8A890Lqlyv+Lj/AJDv/wDDT/8Az3QuqUXL/wA1Dyb/AMX/AN38x/fUkme9lbS+xwYxurnOMAD+U4obcvEeCWX1OA5h7TH/AElK0EqSg66lu3dYxu8FzJcNQ0bnub+9sb9JMMjHLi0WsLm7ZEj/AAn81/25+YkpIsHPazN+teDhW+6rDx35oYfomwvGPU4/8V9Nq3lhdfbfg52L16lhtrxmupza2iXeg/3eq3/iH+9Mnt4AgnybHK/zhANSlCcYf7SUKj/jfIjzundO6TkZfXcii7qN2QWtFYY2w1iI/RtO39Hta1u5Z/1Jxac6nq9rmMbhZ1uwYYM7B753N/M9tn/QU/8AnB1nEz78mqmzrHSMoh2LZjwfT5Pp/omF25rv0b2XKt0zLyOkv6h1TLxxRm9VePsPTG/zjnS4hz6/pMbus973t/0iiuPGD0HFenf9Li/S4nQGPN93yQJEskxiGOQn+5KP6n2f8lLD/lJ/oO79UbLHdGbRY4vOJZZjBx7trcW1/wCbXtatpZ3QcB/TOkVUZDgbgHW5DyRG95Ntnu/k7tquOy8RoYXXVgWO9Nh3CC+N+wa/S2qaAIjEHs5vMyjLPklHWJnIgjrrulSUXW1MDS57WiwhrCSIc4/Ra395zkhZWbDUHD1GtDnM7hpJDXf2tqcwskkkklPK/wCLj/kO/wD8NP8A/PdC2v8A1ov/AEE/78sX/Fx/yHf/AOGn/wDnuhbX/rRf+gn/AH5Vf/Av+C7v/l//AOrf9y//0/TkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklKSSSSUpJJJJSkkkklPK/4uP+Q7/wDw0/8A890Lqlxn1B6l07E6PdXlZVOPYclzgy2xrCQWUjdte5vt9q6X9u9D/wDLHF/7er/8mocEojFDUbd3T+K4Msue5gjHMgz3EZJepbf2bl742+hZM8fRd4rnmXYtbLszIYy5mI7HsuY70rHem6p2P/gWiv8AnHsW0/rXQLGFlmdiPY76TXW1kH4tLlW+0/VX1/X+2YkyCK/Wr9MODTW1/oh2zfsc5Sccf3h9rR+75v8ANT/xJMnUHB6fhVOexr6arC2osD/0orffvpeP5r0ff/wVlSzsS1jbcJ7LA59luO0s9Sp7QI9P9HQyljq9tdj2+x/6NX/tX1X9KuoZ2OK6q7Kqm/aGHay3Sxrdz/3fZX/o2Kb+ofV2w4+7qVG3F2musZLAwln82+xu/wB7mJccf3h9qvu+b/NT/wASTrJc6FUf270P/wAscX/t6v8A8ml+3eh/+WOL/wBvV/8Ak0uOP7w+1X3fN/mp/wCLJq3fVTotlzr66nY1j/pnHsfUD/Yrc1iP0/6v9J6bYbsWgC93NzyX2a/8JaXub/ZU/wBu9D/8scX/ALer/wDJpft3of8A5Y4v/b1f/k0P1YN+n8GU/fZR4T7xjVUfc2bGa+yvDvfUwW2NreWVmIcQDDXbvauVvAporZuYzZjmoBkNYZfQ2yxjH13fp7a7v0r10LuudCc0td1DFLXAgj1q+Dofz1Vbk/VRjXMZl4jGPa1m1t7AA1h3s2w/2+/3vd/hEeOP7w+1i+75v81P/Ekj9cN6Tim59ZwmZJotZYA5pqY9zKNtv6Pa6n02em/Z71Ypyw/r9rv8BbU3Grf431h2VZV/21d/nssSZ1D6ttpbSc/Gsax5ta6y+tzvUJL/AFtxd/O7ne1R+2fVf7IzD+24voVkPZ+sM3B7XeoLvV9T1PW9T3usS44/vD7Vfd83+an/AIknXSVH9vdEP/eji/8Ab1f/AJNL9u9D/wDLHF/7er/8mlxx/eH2q+75v81P/Ek4f+Lj/kO//wANP/8APdC2v/Wi/wDQT/vyxf8AFx/yHf8A+Gn/APnuhbG8f85dkOn7HO6Pb9L9795V/wDwL/gux/5f/wDq3/cv/9T05JfLiSSn6jSXy4kkp+o0l8uJJKfqNJfLiSSn6jSXy4kkp+o0l8uJJKfqNJfLiSSn6jSXy4kkp+o0l8uJJKfqNJfLiSSn6H/5k/Vj/uF/4Lb/AOlUv+ZP1Y/7hf8Agtv/AKVXzwkoP6N/q/8AmOp/w1/5W/8Atw/Q/wDzJ+rH/cL/AMFt/wDSqX/Mn6sf9wv/AAW3/wBKr54SS/o3+r/5iv8Ahr/yt/8Abh+h/wDmT9WP+4X/AILb/wClUv8AmT9WP+4X/gtv/pVfPCSX9G/1f/MV/wANf+Vv/tw/Q/8AzJ+rH/cL/wAFt/8ASqX/ADJ+rH/cL/wW3/0qvnhJL+jf6v8A5iv+Gv8Ayt/9uH6H/wCZP1Y/7hf+C2/+lUv+ZP1Y/wC4X/gtv/pVfPCSX9G/1f8AzFf8Nf8Alb/7cP0P/wAyfqx/3C/8Ft/9Kpf8yfqx/wBwv/Bbf/Sq+eEkv6N/q/8AmK/4a/8AK3/24fof/mT9WP8AuF/4Lb/6VS/5k/Vj/uF/4Lb/AOlV88JJf0b/AFf/ADFf8Nf+Vv8A7cP0P/zJ+rH/AHC/8Ft/9Kpf8yfqx/3C/wDBbf8A0qvnhJL+jf6v/mK/4a/8rf8A24fpnpvS8HpdDsfAq9GpzjY5u5zvcQ1u6bXPd9FjVW3n/nNsgbfsn0twndunZs+l9D3r5vSUno4P0eD/AJlNL+lfeP8AK/eeL+v944//AErxv//Z \ No newline at end of file diff --git a/app/src/main/assets/help/regexHelp.md b/app/src/main/assets/help/regexHelp.md new file mode 100644 index 000000000..cf0779d80 --- /dev/null +++ b/app/src/main/assets/help/regexHelp.md @@ -0,0 +1,204 @@ +# 正则表达式学习 + +- [基本匹配] +- [元字符] + - [英文句号] + - [字符集] + - [否定字符集] + - [重复] + - [星号] + - [加号] + - [问号] + - [花括号] + - [字符组] + - [分支结构] + - [转义特殊字符] + - [定位符] + - [插入符号] + - [美元符号] +- [简写字符集] +- [断言] + - [正向先行断言] + - [负向先行断言] + - [正向后行断言] + - [负向后行断言] +- [标记] + - [不区分大小写] + - [全局搜索] + - [多行匹配] +- [常用正则表达式] + +## 1. 基本匹配 + +正则表达式只是我们用于在文本中检索字母和数字的模式。例如正则表达式 `cat`,表示: 字母 `c` 后面跟着一个字母 `a`,再后面跟着一个字母 `t`。
"cat" => The cat sat on the mat
+ +正则表达式 `123` 会匹配字符串 "123"。通过将正则表达式中的每个字符逐个与要匹配的字符串中的每个字符进行比较,来完成正则匹配。 +正则表达式通常区分大小写,因此正则表达式 `Cat` 与字符串 "cat" 不匹配。
"Cat" => The cat sat on the Cat
+ +## 2. 元字符 + +元字符是正则表达式的基本组成元素。元字符在这里跟它通常表达的意思不一样,而是以某种特殊的含义去解释。有些元字符写在方括号内的时候有特殊含义。 +元字符如下: + +|元字符|描述| +|:----:|----| +|.|匹配除换行符以外的任意字符。| +|[ ]|字符类,匹配方括号中包含的任意字符。| +|[^ ]|否定字符类。匹配方括号中不包含的任意字符| +|*|匹配前面的子表达式零次或多次| +|+|匹配前面的子表达式一次或多次| +|?|匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。| +|{n,m}|花括号,匹配前面字符至少 n 次,但是不超过 m 次。| +|(xyz)|字符组,按照确切的顺序匹配字符xyz。| +|||分支结构,匹配符号之前的字符或后面的字符。| +|\|转义符,它可以还原元字符原来的含义,允许你匹配保留字符 [ ] ( ) { } . * + ? ^ $ \ || +|^|匹配行的开始| +|$|匹配行的结束| + +## 2.1 英文句号 + +英文句号 `.` 是元字符的最简单的例子。元字符 `.` 可以匹配任意单个字符。它不会匹配换行符和新行的字符。例如正则表达式 `.ar`,表示: 任意字符后面跟着一个字母 `a`, +再后面跟着一个字母 `r`。
".ar" => The car parked in the garage.
+ +## 2.2 字符集 + +字符集也称为字符类。方括号被用于指定字符集。使用字符集内的连字符来指定字符范围。方括号内的字符范围的顺序并不重要。 +例如正则表达式 `[Tt]he`,表示: 大写 `T` 或小写 `t` ,后跟字母 `h`,再后跟字母 `e`。
"[Tt]he" => The car parked in the garage.
+ +然而,字符集中的英文句号表示它字面的含义。正则表达式 `ar[.]`,表示小写字母 `a`,后面跟着一个字母 `r`,再后面跟着一个英文句号 `.` 字符。
"ar[.]" => A garage is a good place to park a car.
+ +### 2.2.1 否定字符集 + +一般来说插入字符 `^` 表示一个字符串的开始,但是当它在方括号内出现时,它会取消字符集。例如正则表达式 `[^c]ar`,表示: 除了字母 `c` 以外的任意字符,后面跟着字符 `a`, +再后面跟着一个字母 `r`。
"[^c]ar" => The car parked in the garage.
+ +## 2.3 重复 + +以下元字符 `+`,`*` 或 `?` 用于指定子模式可以出现多少次。这些元字符在不同情况下的作用不同。 + +### 2.3.1 星号 + +该符号 `*` 表示匹配上一个匹配规则的零次或多次。正则表达式 `a*` 表示小写字母 `a` 可以重复零次或者多次。但是它如果出现在字符集或者字符类之后,它表示整个字符集的重复。 +例如正则表达式 `[a-z]*`,表示: 一行中可以包含任意数量的小写字母。
"[a-z]*" => The car parked in the garage #21.
+ +该 `*` 符号可以与元符号 `.` 用在一起,用来匹配任意字符串 `.*`。该 `*` 符号可以与空格符 `\s` 一起使用,用来匹配一串空格字符。 +例如正则表达式 `\s*cat\s*`,表示: 零个或多个空格,后面跟小写字母 `c`,再后面跟小写字母 `a`,再再后面跟小写字母 `t`,后面再跟零个或多个空格。
"\s*cat\s*" => The fat cat sat on the cat.
+ +### 2.3.2 加号 + +该符号 `+` 匹配上一个字符的一次或多次。例如正则表达式 `c.+t`,表示: 一个小写字母 `c`,后跟任意数量的字符,后跟小写字母 `t`。
"c.+t" => The fat cat sat on the mat.
+ +### 2.3.3 问号 + +在正则表达式中,元字符 `?` 用来表示前一个字符是可选的。该符号匹配前一个字符的零次或一次。 +例如正则表达式 `[T]?he`,表示: 可选的大写字母 `T`,后面跟小写字母 `h`,后跟小写字母 `e`。
"[T]he" => The car is parked in the garage.
"[T]?he" => The car is parked in the garage.
+ +## 2.4 花括号 + +在正则表达式中花括号(也被称为量词 ?)用于指定字符或一组字符可以重复的次数。例如正则表达式 `[0-9]{2,3}`,表示: 匹配至少2位数字但不超过3位(0到9范围内的字符)。
"[0-9]{2,3}" => The number was 9.9997 but we rounded it off to 10.0.
+ +我们可以省略第二个数字。例如正则表达式 `[0-9]{2,}`,表示: 匹配2个或更多个数字。如果我们也删除逗号,则正则表达式 `[0-9]{2}`,表示: 匹配正好为2位数的数字。
"[0-9]{2,}" => The number was 9.9997 but we rounded it off to 10.0.
"[0-9]{2}" => The number was 9.9997 but we rounded it off to 10.0.
+ +## 2.5 字符组 + +字符组是一组写在圆括号内的子模式 `(...)`。正如我们在正则表达式中讨论的那样,如果我们把一个量词放在一个字符之后,它会重复前一个字符。 +但是,如果我们把量词放在一个字符组之后,它会重复整个字符组。 +例如正则表达式 `(ab)*` 表示匹配零个或多个的字符串 "ab"。我们还可以在字符组中使用元字符 `|`。例如正则表达式 `(c|g|p)ar`,表示: 小写字母 `c`、`g` 或 `p` 后面跟字母 `a`,后跟字母 `r`。
"(c|g|p)ar" => The car is parked in the garage.
+ +## 2.6 分支结构 + +在正则表达式中垂直条 `|` 用来定义分支结构,分支结构就像多个表达式之间的条件。现在你可能认为这个字符集和分支机构的工作方式一样。 +但是字符集和分支结构巨大的区别是字符集只在字符级别上有作用,然而分支结构在表达式级别上依然可以使用。 +例如正则表达式 `(T|t)he|car`,表示: 大写字母 `T` 或小写字母 `t`,后面跟小写字母 `h`,后跟小写字母 `e` 或小写字母 `c`,后跟小写字母 `a`,后跟小写字母 `r`。
"(T|t)he|car" => The car is parked in the garage.
+ +## 2.7 转义特殊字符 + +正则表达式中使用反斜杠 `\` 来转义下一个字符。这将允许你使用保留字符来作为匹配字符 `{ } [ ] / \ + * . $ ^ | ?`。在特殊字符前面加 `\`,就可以使用它来做匹配字符。 +例如正则表达式 `.` 是用来匹配除了换行符以外的任意字符。现在要在输入字符串中匹配 `.` 字符,正则表达式 `(f|c|m)at\.?`,表示: 小写字母 `f`、`c` 或者 `m` 后跟小写字母 `a`,后跟小写字母 `t`,后跟可选的 `.` 字符。
"(f|c|m)at\.?" => The fat cat sat on the mat.
+ +## 2.8 定位符 + +在正则表达式中,为了检查匹配符号是否是起始符号或结尾符号,我们使用定位符。 +定位符有两种类型: 第一种类型是 `^` 检查匹配字符是否是起始字符,第二种类型是 `$`,它检查匹配字符是否是输入字符串的最后一个字符。 + +### 2.8.1 插入符号 + +插入符号 `^` 符号用于检查匹配字符是否是输入字符串的第一个字符。如果我们使用正则表达式 `^a` (如果a是起始符号)匹配字符串 `abc`,它会匹配到 `a`。 +但是如果我们使用正则表达式 `^b`,它是匹配不到任何东西的,因为在字符串 `abc` 中 "b" 不是起始字符。 +让我们来看看另一个正则表达式 `^(T|t)he`,这表示: 大写字母 `T` 或小写字母 `t` 是输入字符串的起始符号,后面跟着小写字母 `h`,后跟小写字母 `e`。
"(T|t)he" => The car is parked in the garage.
"^(T|t)he" => The car is parked in the garage.
+ +### 2.8.2 美元符号 + +美元 `$` 符号用于检查匹配字符是否是输入字符串的最后一个字符。例如正则表达式 `(at\.)$`,表示: 小写字母 `a`,后跟小写字母 `t`,后跟一个 `.` 字符,且这个匹配器必须是字符串的结尾。
"(at\.)" => The fat cat. sat. on the mat.
"(at\.)$" => The fat cat sat on the mat.
+ +## 3. 简写字符集 + +正则表达式为常用的字符集和常用的正则表达式提供了简写。简写字符集如下: + +|简写|描述| +|:----:|----| +|.|匹配除换行符以外的任意字符| +|\w|匹配所有字母和数字的字符: `[a-zA-Z0-9_]`| +|\W|匹配非字母和数字的字符: `[^\w]`| +|\d|匹配数字: `[0-9]`| +|\D|匹配非数字: `[^\d]`| +|\s|匹配空格符: `[\t\n\f\r\p{Z}]`| +|\S|匹配非空格符: `[^\s]`| + +## 4. 断言 + +后行断言和先行断言有时候被称为断言,它们是特殊类型的 ***非捕获组*** (用于匹配模式,但不包括在匹配列表中)。当我们在一种特定模式之前或者之后有这种模式时,会优先使用断言。 +例如我们想获取输入字符串 `$4.44 and $10.88` 中带有前缀 `$` 的所有数字。我们可以使用这个正则表达式 `(?<=\$)[0-9\.]*`,表示: 获取包含 `.` 字符且前缀为 `$` 的所有数字。 +以下是正则表达式中使用的断言: + +|符号|描述| +|:----:|----| +|?=|正向先行断言| +|?!|负向先行断言| +|?<=|正向后行断言| +|?"(T|t)he(?=\sfat)" => The fat cat sat on the mat. + +### 4.2 负向先行断言 + +当我们需要从输入字符串中获取不匹配表达式的内容时,使用负向先行断言。负向先行断言的定义跟我们定义的正向先行断言一样, +唯一的区别是不是等号 `=`,我们使用否定符号 `!`,例如 `(?!...)`。 +我们来看看下面的正则表达式 `(T|t)he(?!\sfat)`,表示: 从输入字符串中获取全部 `The` 或者 `the` 且不匹配 `fat` 前面加上一个空格字符。
"(T|t)he(?!\sfat)" => The fat cat sat on the mat.
+ +### 4.3 正向后行断言 + +正向后行断言是用于获取在特定模式之前的所有匹配内容。正向后行断言表示为 `(?<=...)`。例如正则表达式 `(?<=(T|t)he\s)(fat|mat)`,表示: 从输入字符串中获取在单词 `The` 或 `the` 之后的所有 `fat` 和 `mat` 单词。
"(?<=(T|t)he\s)(fat|mat)" => The fat cat sat on the mat.
+ +### 4.4 负向后行断言 + +负向后行断言是用于获取不在特定模式之前的所有匹配的内容。负向后行断言表示为 `(?"(?<!(T|t)he\s)(cat)" => The cat sat on cat. + +## 5. 标记 + +标记也称为修饰符,因为它会修改正则表达式的输出。这些标志可以以任意顺序或组合使用,并且是正则表达式的一部分。 + +|标记|描述| +|:----:|----| +|i|不区分大小写: 将匹配设置为不区分大小写。| +|g|全局搜索: 搜索整个输入字符串中的所有匹配。| +|m|多行匹配: 会匹配输入字符串每一行。| + +* **数字**: `\d+$` +* **用户名**: `^[\w\d_.]{4,16}$` +* **字母数字字符**: `^[a-zA-Z0-9]*$` +* **带空格的字母数字字符**: `^[a-zA-Z0-9 ]*$` +* **小写字母**: `[a-z]+$` +* **大写字母**: `[A-Z]+$` +* **网址**: `^(((http|https|ftp):\/\/)?([[a-zA-Z0-9]\-\.])+(\.)([[a-zA-Z0-9]]){2,4}([[a-zA-Z0-9]\/+=%&_\.~?\-]*))*$` +* **日期 (MM/DD/YYYY)**: `^(0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])[- /.](19|20)?[0-9]{2}$` +* **日期 (YYYY/MM/DD)**: `^(19|20)?[0-9]{2}[- /.](0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])$` +* **求更求转发致谢**: `[\((【].*?[求更谢乐发推].*?[】)\)]` +* **查找最新章节**: `您可以.*?查找最新章节` +* **ps/PS**: `(?i)ps\b.*` +* **Html标签**: `<[^>]+?>` diff --git a/app/src/main/assets/help/replaceRuleHelp.md b/app/src/main/assets/help/replaceRuleHelp.md new file mode 100644 index 000000000..063c8d3a3 --- /dev/null +++ b/app/src/main/assets/help/replaceRuleHelp.md @@ -0,0 +1,6 @@ +# 替换管理界面帮助 + +* 替换规则是用来替换正文内容的一种规则 + * 菜单可以新建和导入规则 + * 可以拖动排序 + * 可以选择操作 \ No newline at end of file diff --git a/app/src/main/assets/help/ruleHelp.md b/app/src/main/assets/help/ruleHelp.md new file mode 100644 index 000000000..043677f4b --- /dev/null +++ b/app/src/main/assets/help/ruleHelp.md @@ -0,0 +1,177 @@ +# 源规则帮助 + +* [书源帮助文档](https://alanskycn.gitee.io/teachme/Rule/source.html) +* [订阅源帮助文档](https://alanskycn.gitee.io/teachme/Rule/rss.html) +* [js扩展类](https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt) +* 辅助键盘❓中可插入URL参数模板,打开帮助,选择文件 +* 规则标志, {{......}}内使用规则必须有明显的规则标志,没有规则标志当作js执行 +``` +@@ 默认规则,直接写时可以省略@@ +@XPath: xpath规则,直接写时以//开头可省略@XPath +@Json: json规则,直接写时以$.开头可省略@Json +: regex规则,不可省略,只可以用在书籍列表和目录列表 +``` + +* 发现url格式 +```json +[ + { + "title": "xxx", + "url": "", + "style": { + "layout_flexGrow": 0, + "layout_flexShrink": 1, + "layout_alignSelf": "auto", + "layout_flexBasisPercent": -1, + "layout_wrapBefore": false + } + } +] +``` + +* 获取登录后的cookie +``` +java.getCookie("http://baidu.com", null) => userid=1234;pwd=adbcd +java.getCookie("http://baidu.com", "userid") => 1234 +``` + +* 请求头,支持http代理,socks4 socks5代理设置 +``` +socks5代理 +{ + "proxy":"socks5://127.0.0.1:1080" +} +http代理 +{ + "proxy":"http://127.0.0.1:1080" +} +支持代理服务器验证 +{ + "proxy":"socks5://127.0.0.1:1080@用户名@密码" +} +注意:这些请求头是无意义的,会被忽略掉 +``` + +* js 变量和函数 +``` +java 变量-当前类 +baseUrl 变量-当前url,String +result 变量-上一步的结果 +book 变量-书籍类,方法见 io.legado.app.data.entities.Book +cookie 变量-cookie操作类,方法见 io.legado.app.help.http.CookieStore +cache 变量-缓存操作类,方法见 io.legado.app.help.CacheManager +chapter 变量-当前目录类,方法见 io.legado.app.data.entities.BookChapter +title 变量-当前标题,String +src 内容,源码 +``` + +* url添加js参数,解析url时执行,可在访问url时处理url,例 +``` +https://www.baidu.com,{"js":"java.headerMap.put('xxx', 'yyy')"} +https://www.baidu.com,{"js":"java.url=java.url+'yyyy'"} +``` + +* 增加js方法,用于重定向拦截 + * `java.get(urlStr: String, headers: Map)` + * `java.post(urlStr: String, body: String, headers: Map)` +* 对于搜索重定向的源,可以使用此方法获得重定向后的url +``` +(()=>{ + if(page==1){ + let url='https://www.yooread.net/e/search/index.php,'+JSON.stringify({ + "method":"POST", + "body":"show=title&tempid=1&keyboard="+key + }); + return java.put('surl',String(java.connect(url).raw().request().url())); + } else { + return java.get('surl')+'&page='+(page-1) + } +})() +或者 +(()=>{ + let base='https://www.yooread.net/e/search/'; + if(page==1){ + let url=base+'index.php'; + let body='show=title&tempid=1&keyboard='+key; + return base+java.put('surl',java.post(url,body,{}).header("Location")); + } else { + return base+java.get('surl')+'&page='+(page-1); + } +})() +``` + +* 正文图片链接支持修改headers +``` +let options = { +"headers": {"User-Agent": "xxxx","Referrer":baseUrl,"Cookie":"aaa=vbbb;"} +}; +'' +``` + + ## 部分js对象属性说明 +上述js变量与函数中,一些js的对象属性用的频率较高,在此列举。方便写源的时候快速翻阅。 + +### book对象的可用属性 +> 使用方法: 在js中或{{}}中使用book.属性的方式即可获取.如在正文内容后加上 ##{{book.name+"正文卷"+title}} 可以净化 书名+正文卷+章节名称(如 我是大明星正文卷第二章我爸是豪门总裁) 这一类的字符. +``` +bookUrl // 详情页Url(本地书源存储完整文件路径) +tocUrl // 目录页Url (toc=table of Contents) +origin // 书源URL(默认BookType.local) +originName //书源名称 or 本地书籍文件名 +name // 书籍名称(书源获取) +author // 作者名称(书源获取) +kind // 分类信息(书源获取) +customTag // 分类信息(用户修改) +coverUrl // 封面Url(书源获取) +customCoverUrl // 封面Url(用户修改) +intro // 简介内容(书源获取) +customIntro // 简介内容(用户修改) +charset // 自定义字符集名称(仅适用于本地书籍) +type // 0:text 1:audio +group // 自定义分组索引号 +latestChapterTitle // 最新章节标题 +latestChapterTime // 最新章节标题更新时间 +lastCheckTime // 最近一次更新书籍信息的时间 +lastCheckCount // 最近一次发现新章节的数量 +totalChapterNum // 书籍目录总数 +durChapterTitle // 当前章节名称 +durChapterIndex // 当前章节索引 +durChapterPos // 当前阅读的进度(首行字符的索引位置) +durChapterTime // 最近一次阅读书籍的时间(打开正文的时间) +canUpdate // 刷新书架时更新书籍信息 +order // 手动排序 +originOrder //书源排序 +variable // 自定义书籍变量信息(用于书源规则检索书籍信息) + ``` + +### chapter对象的可用属性 +> 使用方法: 在js中或{{}}中使用chapter.属性的方式即可获取.如在正文内容后加上 ##{{chapter.title+chapter.index}} 可以净化 章节标题+序号(如 第二章 天仙下凡2) 这一类的字符. + ``` + url // 章节地址 + title // 章节标题 + baseUrl //用来拼接相对url + bookUrl // 书籍地址 + index // 章节序号 + resourceUrl // 音频真实URL + tag // + start // 章节起始位置 + end // 章节终止位置 + variable //变量 + ``` + +### 字体解析使用 +> 使用方法,在正文替换规则中使用,原理根据f1字体的字形数据到f2中查找字形对应的编码 +``` + +(function(){ + var b64=String(src).match(/ttf;base64,([^\)]+)/); + if(b64){ + var f1 = java.queryBase64TTF(b64[1]); + var f2 = java.queryTTF("https://alanskycn.gitee.io/teachme/assets/font/Source Han Sans CN Regular.ttf"); + return java.replaceFont(result, f1, f2); + } + return result; +})() + +``` + diff --git a/app/src/main/assets/help/webDavHelp.md b/app/src/main/assets/help/webDavHelp.md new file mode 100644 index 000000000..dee978b42 --- /dev/null +++ b/app/src/main/assets/help/webDavHelp.md @@ -0,0 +1,19 @@ +# WebDav备份教程 + +### 阅读支持云备份,采用WebDav协议,所有支持WebDav的云盘都可以,建议采用坚果云,每月免费1G流量,用来备份阅读足够了,下面就采用坚果云来讲解配置步骤. + +1. 打开坚果云网站 https://www.jianguoyun.com/d/home#/ +2. 如果没有注册过坚果云先注册一下 +3. 登录坚果云 +4. 右上角用户名点开点账户信息 +5. 点击安全选项 +6. 在第三方管理里添加应用 +7. 将应用示例里的服务器地址,用户名,和密码填到阅读的WebDav设置里 +8. 阅读的WebDav配置在我的-备份与恢复,创建子文件夹选项保持默认即可 +9. 设置完成后手动执行一下备份,看看是否成功 +10. 恢复时选择想要恢复的备份文件 + +### 自动备份说明 + +* 设置好备份之后每次退出App会自动进行备份 +* WebDav同一天的备份会覆盖,不同日期的备份不会覆盖 \ No newline at end of file diff --git a/app/src/main/assets/updateLog.md b/app/src/main/assets/updateLog.md new file mode 100644 index 000000000..cf68c7d88 --- /dev/null +++ b/app/src/main/assets/updateLog.md @@ -0,0 +1,692 @@ +# 更新日志 + +* 关注公众号 **[开源阅读]** 菜单•软件下载 提前享受新版本。 +* 关注合作公众号 **[小说拾遗]** 获取好看的小说。 + +## **必读** + +【温馨提醒】 *更新前一定要做好备份,以免数据丢失!* + +* 阅读只是一个转码工具,不提供内容,第一次安装app,需要自己手动导入书源,可以从公众号 **[开源阅读]**、QQ群、酷安评论里获取由书友制作分享的书源。 +* 正文出现缺字漏字、内容缺失、排版错乱等情况,有可能是净化规则或简繁转换出现问题。 +* 漫画源看书显示乱码,**阅读与其他软件的源并不通用**,请导入阅读的支持的漫画源! + +**2022/01/03** + +* 重新安装应用后没有权限的本地书籍会提示选择文件所在文件夹,不需要重新添加 +* 更新okhttp和cronet +* 修复web阅读纪录同步问题 解决bug #1478 +* 去除html中一些乱码  等 解决bug 页面乱码 #1465 + +**2022/01/01** + +* 修复本地txt问题,不在拷贝到私有目录,可以正常打开 +* 优化txt目录识别,取目录数量最多的规则 + +**2021/12/28** + +* 用阅读打开本地朗读引擎文件和主体配置文件也可以导入 +* 缓存图片采用多线程 +* 本地书籍不在拷贝到私有目录,有权限的会直接打开,没权限的自己选择文件夹保存 + +**2021/12/19** + +* 修复全面屏手势会触发翻页的bug +* js添加java.logType(*),打印变量类型,方便调试时查看 +* 修复从发现中打开书籍是会打开其它书源书籍的bug +* 全文搜索增加跳转上一个下一个的功能 by Jason Yao +* post可以正确识别contentType + +**2021/12/10** + +* 朗读出错不弹出朗读界面的时候可以长按朗读按钮进入朗读界面切换朗读引擎,这个有很多人不知道 +* 修复cronet访问出错时应用崩溃的bug +* 修复一些epub目录不全或内容不全的问题 +* 修复横屏双页时文字选择的问题 +* 电脑硬盘坏了还好资料恢复出来了,还是要经常备份比较好 + +**2021/11/27** + +* 更新到SDK31,android 12 +* 修复在线朗读引擎新建会替换之前已有的bug +* 修复目录界面自动跳转到顶部bug +* 修复阿里云在线朗读引擎模板中获取时间的兼容性问题 + +**2021/11/20** + +* 修复部分平板双页问题 +* 修复书源太多不能导出和备份的问题 +* 给txt目录规则添加了一个入口,在书源管理的菜单里面 + +**2021/11/13** + +* 修复没有目录时进入阅读界面不自动更新目录的bug +* 使用系统文件夹选择器出错时自动打开应用文件夹选择器,部分系统文件夹选择器被阉割了 + +**2021/11/02** + +* 修复朗读错误时提示不消失的bug +* 修复滚动阅读选择文字错位bug by DuShuYuan +* 朗读语速调节添加微调按钮 + +**2021/10/24** + +* 修复夜间模式不随系统变化的bug + +**2021/10/22** + +* 修复封面 +* 添加全局字体大小设置 +* 导入源和规则时可以先编辑再导入 + +**2021/10/21** + +* 修复自定义封面会因为图片太大崩溃 +* 修复play版本一个会引起崩溃的bug + +**2021/10/17** + +* 修复朗读时可能会崩溃的bug + +**2021/10/16** + +* 再次修复朗读卡住问题 +* 导入书单改为多线程 +* 修复其它一些bug + +**2021/10/14** + +* 修复遇到一些存标点段朗读出错后不继续的问题 +* 朗读出错记录错误日志,现在很多界面的菜单里都可以打开日志 + +**2021/10/10** + +* 阿里云语音自动登录 +* 修复一些bug +* 优化阿里云登录,需重新登录 + +``` +source登录相关方法,可在js内通过source.调用,可以参考阿里云语音登录 +login() +getHeaderMap(hasLoginHeader: Boolean = false) +getLoginHeader(): String? +getLoginHeaderMap(): Map? +putLoginHeader(header: String) +removeLoginHeader() +setVariable(variable: String?) +getVariable(): String? +AnalyzeUrl相关函数,js中通过java.调用 +initUrl() //重新解析url,可以用于登录检测js登录后重新解析url重新访问 +getHeaderMap().putAll(source.getHeaderMap(true)) //重新设置登录头 +getStrResponse( jsStr: String? = null, sourceRegex: String? = null) //返回访问结果,文本类型,书源内部重新登录后可调用此方法重新返回结果 +getResponse(): Response //返回访问结果,网络朗读引擎采用的是这个,调用登录后在调用这方法可以重新访问,参考阿里云登录检测 +``` + +**2021/10/07** + +1. 修复阅读界面长按菜单阻挡选择bug +2. 添加订阅源api + +**2021/10/05** + +1. 优化阅读界面导航栏 +2. 规则添加代码高亮 +3. web写源添加订阅源 +4. httpTts朗读添加登录功能 + +``` +返回语音之前加入了检测是否登录传入result为okhttp的Response,里面有headers和body,检测是否登录的js需返回正确的Response +``` + +**2021/10/02** + +1. 紧急修复弹出框崩溃bug +2. 修复字体变粗后不能变回的bug +3. 修复底部对齐有时无效的bug + +* 不要嫌更新得频繁,这是因为最近新加的功能比较多,出bug很正常,而且我是一个人写软件,没有测试人员,只有发出来大家一起找bug了,遇到bug及时反馈,能修复的我都会在第一时间修复 + +**2021/10/01** + +1. 默认封面名称显示全 +2. 发现js错误时可以查看错误详情 +4. 修复rss标题显示url的问题 +5. 长按正文网址可以选择是否外部浏览器打开,会记住选择 +6. 添加书源操作按钮,编辑书源移到里面,增加网址点击区域包括书名,更容易点击 +7. 优化内置浏览器 +8. 其它一些优化 + +**2021/09/29** + +1. 修复阅读界面导航栏挡住内容的bug +2. 修复webView=ture是自动跳转移动网站的bug +3. 导出添加进度条 + +**2021/09/28** + +1. 添加横屏双页模式 + +**2021/09/27** + +1. 非Play版本内置更新检测和下载,目前从github检测并下载, 不会自动提醒需手动检测, 可以关注公众号,比较重要的更新会在公众号发布然后可以在软件内更新 +2. js添加java.webView(html: String?, url: String?, js: String?): String? +3. 修复一些bug + +**2021/09/22** + +1. 修复在线朗读遇到单独......崩溃的问题 +2. 有人提到在线朗读能及时翻页了,本地行不行,这个是要靠本地的tts支持的,我目前用的谷歌文字转语音就是支持的,其它的我不太清楚 + +**2021/09/21** + +1. 阅读界面区域设置添加朗读上一段和朗读下一段 +2. 在线朗读采用平均速度计算及时翻页 +3. 修复听书定时问题 +4. 阅读小标题也使用替换 + +**2021/09/20** + +1. 修复在线朗读跳段的bug +2. 优化默认封面,添加显示书名作者的配置, 后面会添加书名和作者大小位置配置 + +**2021/09/18** + +1. 朗读可以选择非默认tts +2. 其它一些优化和bug修复 + +**2021/09/16** + +1. 优化正文重复标题的去除,必须包含标题且标题后面有空格或换行才会去除,防止误删 + +```^(\s|\p{P}|${name})*${title}(\s|\p{P})+``` + +**2021/09/15** + +1. 修复因标题加入替换和简繁转换导致的缩进问题 +2. 如果出现标题不显示是净化规则的问题,可以先关闭净化 + +**2021/09/14** + +1. 书架菜单添加了日志,更新失败的和下载失败的信息会显示在里面 +2. 书源添加校验关键字,有校验关键字的书源用此关键字校验 +3. 目录添加购买标识规则 +4. 正文标题使用替换,简繁转换,正文中书名,标题开头的自动去重 +5. 其它一些优化 + +**2021/09/08** + +1. 优化离线缓存 +2. 听书界面添加登录菜单,和拷贝播放url +3. 详情页添加设置源变量和书籍变量 +4. 订阅添加登录菜单,添加设置源变量 + +**2021/09/06** + +1. 采用exoPlayer播放音频,支持更多格式 +2. 替换不再阻塞 +3. 修复详情页初始化:规则bug +4. 书源内的并发率生效,两种格式 + * 时间 格式: 如 500, 访问间隔500毫秒 + * 次数/时间 格式: 如 5/60000, 每分钟最多访问5次 + +5. url参数添加js, 和webJs参数,js参数传入url返回新的url, webJs参数在webView内执行直到返回不为空,和正文规则webJs一样 +6. 重写离线缓存 + +**2021/09/01** + +1. 可以直接导出为链接,方便分享 +2. 修复一些bug + +**2021/08/28** + +1. 发现界面添加登录菜单 +2. 优化调试界面,预设搜索项可以点击 +3. 修复再没有webDav恢复时恢复按钮没反应的bug +4. 自动阅读的设置改为选择翻页动画 + +**2021/08/27** + +1. 修复导入书源问题 +2. 合并cornet版本,添加cornet开关 +3. 详情也选择分组后自动加入书架 +4. 书源管理可以筛选有登录url的书源,分组需登录 +5. 修复定时加快的问题 +7. 修复其它一些小bug + +**2021/08/24** + +1. 修复bug +2. 可以加载证书过期网站的图片 +3. 修复书源不兼容老版本的问题 +4. 书源添加登录ui,和登录检测配置,稍后会给出示例,可以用来制作一些采用token登录的源,稍后会给出示例 +5. 修复web阅读进度没有同步到webDav的问题 + +**2021/08/21** + +1. 阅读时自动更新最新章节 +2. 朗读添加媒体按键配置 +3. 修复rss列表界面分类往回切换时没有数据的bug +4. 修复订阅分类往回切换时不显示内容的bug +5. 导入书源防止非json格式导入 +6. 校验书源显示详细信息 by h11128 +7. 其它一些优化 + +**2021/08/13** + +1. web传书可以使用 +2. 修复一些bug + +**2021/08/09** + +1. 修复选择文字不能选择单个文字的bug +2. 分组可选择封面 + +**2021/08/08** + +1. 背景图片添加模糊设置 +2. 书籍信息界面添加置顶操作 +3. 自动翻页时屏幕常亮 +4. 字典:中文使用百度汉语字典,英文使用海词字典。 by ag2s20150909 +5. 导入规则时可以选择添加分组还是替换分组 + +**2021/08/02** + +1. 换源界面功能添加:置顶,置底,删除 by h11128 +2. Cronet:优化 by ag2s20150909 +3. 优化自动翻页 by jiuZhouWorlds +4. 封面设置移到主题里面,白天和夜间可分别设置 + +**2021/08/01** + +1. 为webService添加快捷操作 +2. 规则内替换使用正则错误时自动切换为不使用正则 +3. 优化Cronet +4. 阅读界面菜单显示的时候停止按键翻页和自动阅读 +5. 切换后台停止自动阅读 + +**2021/07/29** + +1. 修复每次更新都重新导入text规则的bug +2. RSS阅读页添加刷新按钮以应对页面内容过期失效的BUG by JiuZhouWorlds +3. 规则内替换使用正则报错时自动使用非正则替换 + +**2021/07/27** + +1. 修复bug +2. web使用api获取封面,不会再出现没有封面的情况 +3. 阅读亮度手动调节分别记住白天和夜间模式 +4. legado://import/auto?src={url}, 自动识别导入类型 +5. 一些优化并更新了一下web首页,感谢沚水, 传书暂时还不好用 + +**2021/07/22** + +1. 非关键规则添加try防止报错中断解析 +2. 添加获取封面的api +3. 获取正文api使用替换规则 +4. 添加一个ronet版本,网络访问使用Chromium内核 +5. web书架增加【最近一次更新书籍信息的时间】 +6. 采用Flow替换LiveData,优化资源使用 +7. 统一网络一键导入路径legado://import/{path}?src={url} + +* path: bookSource,rssSource,replaceRule,textTocRule,httpTTS,theme,readConfig +* 添加了txt小说规则,在线朗读引擎,主题,排版 的一键导入支持,老url依然可用 + +8. 替换规则管理添加置顶所选和置底所选 + +**2021/07/16** + +1. js扩展函数添加删除本地文件方法 +2. js扩展函数对于文件的读写删操作都是相对路径,只能操作阅读缓存内的文件,/android/data/{package}/cache/... + +**2021/07/15** + +1. 添加js函数来修复开启js沙箱后某些书源失效。by ag2s20150909 + +```kotlin +/** + * 获取网络zip文件里面的数据 + * @param url zip文件的链接 + * @param path 所需获取文件在zip内的路径 + * @return zip指定文件的数据 + */ +fun getZipStringContent(url: String, path: String): String + +/** + * 获取网络zip文件里面的数据 + * @param url zip文件的链接 + * @param path 所需获取文件在zip内的路径 + * @return zip指定文件的数据 + */ +fun getZipByteArrayContent(url: String, path: String): ByteArray? +``` + +* web服务添加一个导航页 + +**2021/07/11** + +1. 开启JS沙箱限制 + +* 禁止在js里exec运行命令 +* 禁止在js里通过geClass反射 +* 禁止在js里创建File对象 +* 禁止在js里获取Packages scope + +2. 优化并修复bug + +**2021/07/10** + +1. 阅读界面长按菜单改回原来样式 +2. 解决导入书源时重命名分组和保留名称冲突的问题 + +**2021/07/09** + +1. 发现url添加json格式, 支持设置标签样式 + +* 样式属性可以搜索 [FleboxLayout子元素支持的属性介绍](https://www.jianshu.com/p/3c471953e36d) +* 样式属性可省略,有默认值 + +```json +[ + { + "title": "xxx", + "url": "", + "style": { + "layout_flexGrow": 0, + "layout_flexShrink": 1, + "layout_alignSelf": "auto", + "layout_flexBasisPercent": -1, + "layout_wrapBefore": false + } + } +] +``` + +**2021/07/07** + +1. 默认规则新增类似`jsonPath`的索引写法 by bushixuanqi + +* 格式形如 `[index,index, ...]` 或 `[!index,index, ...]` 其中`[!`开头表示筛选方式为排除,`index`可以是单个索引,也可以是区间。 +* 区间格式为 `start:end` 或 `start:end:step`,其中`start`为`0`可省略,`end`为`-1`可省略。 +* 索引、区间两端、区间间隔都支持负数 +* 例如 `tag.div[-1, 3:-2:-10, 2]` +* 特殊用法 `tag.div[-1:0]` 可在任意地方让列表反向 + +2. 允许索引作为@分段后每个部分的首规则,此时相当于前面是`children` + +* `head@.1@text` 与 `head@[1]@text` 与 `head@children[1]@text` 等价 + +3. 添加Umd格式支持 by ag2s20150909 +4. 修复web页面按键重复监听的bug +5. 亮度条往中间移了一点,防止误触 +6. 添加内置字典 + +**2021/06/29** + +* 修复html格式化bug +* 订阅界面webView支持css prefers-color-scheme: dark 查询,需webView v76或更高版本 +* 如webView低于v76可以用js调用activity.isNightTheme()来获取当前是否暗模式 +* 修复一些书籍导出epub失败 by ag2s20150909 + +**2021/06/22** + +* 修复隐藏未读设置不生效的bug +* 修复系统字体大小选择大时导入界面按钮显示不全的bug +* 修复听书从后台打开时不对的bug + +**2021/06/20** + +* viewPager2 改回 viewPager +* 添加配置导入文件规则功能 by bushixuanqi +* 文件夹分组样式优化(未完成) +* epub支持外部模板 +* 修复一些bug + +**2021/06/06** + +* 添加自定义导出文件名 +* 添加书架文件夹分组样式,未完成 +* viewPager2 3层嵌套有问题,书架换回viewPager + +**2021/05/29** + +* 谷歌版可使用外部epub模板 +* Asset文件夹下二级以内目录全文件读取,Asset->文件夹->文件 +* epub元数据修改,使修改字体只对正文生效 +* 修复epub模板文件的排序问题 +* epub可自定义模板,模板路径为书籍导出目录的Asset文件夹,[模板范例](https://wwa.lanzoux.com/ibjBspkn05i) + +``` +Asset中里面必须有Text文件夹,Text文件夹里必须有chapter.html,否则导出正文会为空 +chapter.html的关键字有{title}、{content} +其他html文件的关键字有{name}、{author}、{intro}、{kind}、{wordCount} +``` + +**2021/05/24** + +* 反转目录后刷新内容 +* 修复上下滑动会导致左右切换问题 +* 精确搜索增加包含关键词的,比如搜索五行 五行天也显示出来, 五天行不显示 + +**2021/05/21** + +* 添加反转目录功能 +* 修复分享bug +* 详情页添加登录菜单 +* 添加发现界面隐藏配置 + +**2021/05/16** + +* 添加总是使用默认封面配置 +* 添加一种语言 ptbr translation by mezysinc +* epublib 修bug by ag2s20150909 + +**2021/05/08** + +* 预下载章节可调整数目 +* 修复低版本Android使用TTS闪退。 by ag2s20150909 +* 修复WebDav报错 +* 优化翻页动画点击翻页 + +**2021/05/06** + +* 修复bug +* url参数添加重置次数,retry +* 修改默认tts, 手动导入 +* 升级android studio + +**2021/04/30** + +* epub插图,epublib优化,图片解码优化,epub读取导出优化。by ag2s20150909 +* 添加高刷设置 +* 其它一些优化 +* pro版本被play商店下架了,先把pro设置图片背景的功能开放到所有版本,使用pro版本的可以使用备份恢复功能切换最新版本 + +**2021/04/16** + +* 去掉google统计,解决华为手机使用崩溃的bug +* 添加规则订阅时判断重复提醒 +* 添加恢复预设布局的功能, 添加一个微信读书布局作为预设布局 + +**2021/04/08** + +* 缓存时重新检查并缓存图片 +* 订阅源调试添加源码查看 +* web调试不输出源码 +* 修复bug +* 换源优化 --- by ag2s20150909 +* 修复localBook获取书名作者名的逻辑 +* 修复导出的epub的标题文字过大的bug +* 优化图片排版 + +**2021/04/02** + +* 修复bug +* 书源调试添加源码查看 +* 添加导出epub by ag2s20150909 +* 换源添加是否校验作者选项 + +**2021/03/31** + +* 优化epubLib by ag2s20150909 +* 升级库,修改弃用方法 +* tts引擎添加导入导出功能 + +**2021/03/23** + +* 修复繁简转换“勐”“十”问题。使用了剥离HanLP简繁代码的民间库。APK减少6M左右 +* js添加一个并发访问的方法 java.ajaxAll(urlList: Array) 返回 Array +* 优化目录并发访问 +* 添加自定义epublib,支持epub v3解析目录。by ag2s20150909 + +**2021/03/19** + +* 修复图片地址参数缺少的bug +* 修复更改替换规则时多次重新加载正文导致朗读多次停顿的bug +* 修复是否使用替换默认值修改后不及时生效的bug +* 修复繁简转换“勐”“十”问题。使用了剥离HanLP简繁代码的民间库。APK减少6M左右 by hoodie13 +* 百度tsn改为tts + +**2021/03/15** + +* 优化图片TEXT样式显示 +* 图片url在解析正文时就拼接成绝对url +* 修复一些bug + +**2021/03/08** + +* 阅读页面停留10分钟之后自动备份进度 +* 添加了针对中文的断行排版处理-by hoodie13, 需要再阅读界面设置里手动开启 +* 添加朗读快捷方式 +* 优化Epub解析 by hoodie13 +* epub书籍增加cache by hoodie13 +* 修复切换书籍或者章节时的断言崩溃问题。看漫画容易复现。 by hoodie13 +* 修正增加书签alert的正文内容较多时,确定键溢出屏幕问题 by hoodie13 +* 图片样式添加TEXT, 阅读界面菜单里可以选择图片样式 + +**2021/02/26** + +* 添加反转内容功能 +* 更新章节时若无目录url将自动加载详情页 +* 添加变量nextChapterUrl +* 订阅跳转外部应用时提示 +* 修复恢复bug +* 详情页拼接url改为重定向后的地址 +* 不重复解析详情页 + +**2021/02/21** + +* 下一页规则改为在内容规则之后执行 +* 书籍导出增加编码设置和导出文件夹设置,使用替换设置 +* 导入源添加等待框 +* 修复一些崩溃bug + +**2021/02/16** + +* 修复分享内容不对的bug +* 优化主题颜色,添加透明度 +* rss分类url支持js +* 打开阅读时同步阅读进度 + +**2021/02/09** + +* 修复分组内书籍数目少于搜索线程数目,会导致搜索线程数目变低 +* 修复保存书源时不更新书源时间的bug +* 订阅添加夜间模式,需启用js,还不是很完善 +* 优化源导入界面 + +**2021/01/18** + +* 增加三星 S Pen 支持 by [dacer](https://github.com/dacer) +* 订阅添加阅读下载,可以从多个渠道下载 +* 修复一些BUG +**2020/12/30** + +* 解决文件下载异常,在线语音可正常播放 by [Celeter](https://github.com/Celeter) +* 更新默认在线朗读库, 默认id小于0方便下次更新时删除旧数据, 有重复的自己删除 +* 导入导出书单 +* 其它一些优化 + +**2020/12/27** + +* 订阅添加搜索和分组 +* 修复部分手机状态栏bug +* 单url订阅支持内容规则和样式 + +**2020/12/09** + +* 修复bug +* 优化中文排序 +* 优化编码识别 +* 选择文字时优先选词 +* 优化进度同步,进入书籍时同步,每次同步单本书,减少同步文件大小 + +**2020/11/18** + +* 优化导航栏 +* js添加java.log(msg: String)用于调试时输出消息 +* js添加cookie变量,方法见io.legado.app.help.http.api.CookieManager +* js添加cache变量,可以用来存储token之类的临时值,可以设置保存时间,方法见io.legado.app.help.CacheManager +* 需要token的网站可以用js来写了,比如阿里tts + +**2020/11/15** + +* 正文规则添加字体规则,返回ByteArray +* js添加方法: + +``` +base64DecodeToByteArray(str: String?): ByteArray? +base64DecodeToByteArray(str: String?, flags: Int): ByteArray? +``` + +**2020/11/07** + +* 详情页菜单添加拷贝URL +* 解决一些书名太长缓存报错的bug +* 添加备份搜索记录 +* 替换编辑界面添加正则学习教程 +* 去除解析目录时拼接相对url,提升解析速度 +* 自动分段优化 by [tumuyan](https://github.com/tumuyan) +* web支持图片显示 by [六月](https://github.com/Celeter) + +**2020/10/24** + +* 修复选择错误的bug +* 修复长图最后一张不能滚动的bug +* js添加java.getCookie(sourceUrl:String, key:String? = null)来获取登录后的cookie + by [AndyBernie](https://github.com/AndyBernie) + +``` +java.getCookie("http://baidu.com", null) => userid=1234;pwd=adbcd +java.getCookie("http://baidu.com", "userid") => 1234 +``` + +* 修复简繁转换没有处理标题 +* 每本书可以单独设置翻页动画,在菜单里 +* 添加重新分段功能,针对每本书,在菜单里,分段代码来自[tumuyan](https://github.com/tumuyan) + +**2020/10/07** + +* 更新时预下载10章 +* 支持更多分组 +* url添加js参数,解析url时执行,可在访问url时处理url,例 + +``` +https://www.baidu.com,{"js":"java.headerMap.put('xxx', 'yyy')"} +https://www.baidu.com,{"js":"java.url=java.url+'yyyy'"} +``` + +**2020/09/29** + +* 增加了几个方法用于处理文件 by [Celeter](https://github.com/Celeter) + +``` +//文件下载,content为十六进制字符串,url用于生成文件名,返回文件路径 +downloadFile(content: String, url: String): String +//文件解压,zipPath为压缩文件路径,返回解压路径 +unzipFile(zipPath: String): String +//文件夹内所有文件读取 +getTxtInFolder(unzipPath: String): String +``` + +* 增加type字段,返回16进制字符串,例:`https://www.baidu.com,{"type":"zip"}` +* 底部操作栏阴影跟随设置调节 diff --git a/app/src/main/assets/web/.idea/.gitignore b/app/src/main/assets/web/.idea/.gitignore new file mode 100644 index 000000000..b58b603fe --- /dev/null +++ b/app/src/main/assets/web/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/app/src/main/assets/web/.idea/modules.xml b/app/src/main/assets/web/.idea/modules.xml new file mode 100644 index 000000000..f589ca37d --- /dev/null +++ b/app/src/main/assets/web/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/web/.idea/vcs.xml b/app/src/main/assets/web/.idea/vcs.xml new file mode 100644 index 000000000..bc5997070 --- /dev/null +++ b/app/src/main/assets/web/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/web/assets/css/main.css b/app/src/main/assets/web/assets/css/main.css new file mode 100644 index 000000000..3f700d6d4 --- /dev/null +++ b/app/src/main/assets/web/assets/css/main.css @@ -0,0 +1,3953 @@ + /* + Forty by HTML5 UP + html5up.net | @ajlkn + Free for personal and commercial use under the CCA 3.0 license (html5up.net/license) +*/ + +html, body, div, span, applet, object, +iframe, h1, h2, h3, h4, h5, h6, p, blockquote, +pre, a, abbr, acronym, address, big, cite, +code, del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, b, +u, i, center, dl, dt, dd, ol, ul, li, fieldset, +form, label, legend, table, caption, tbody, +tfoot, thead, tr, th, td, article, aside, +canvas, details, embed, figure, figcaption, +footer, header, hgroup, menu, nav, output, ruby, +section, summary, time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline;} + +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block;} + +body { + line-height: 1; + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)),url("../../images/bg.jpg") no-repeat center center fixed; + background-size: 100%; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + + blockquote:before, blockquote:after, q:before, q:after { + content: ''; + content: none; + } + +table { + border-collapse: collapse; + border-spacing: 0; +} + +body { + -webkit-text-size-adjust: none; +} + +mark { + background-color: transparent; + color: inherit; +} + +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +input, select, textarea { + -moz-appearance: none; + -webkit-appearance: none; + -ms-appearance: none; + appearance: none; +} + +/* Basic */ + + @-ms-viewport { + width: device-width; + } + + body { + -ms-overflow-style: scrollbar; + } + + @media screen and (max-width: 480px) { + + html, body { + min-width: 320px; + } + + } + + html { + box-sizing: border-box; + height: 100vh; + } + + *, *:before, *:after { + box-sizing: inherit; + } + + body { + /* background: #242943; */ + height: 100vh; + } + + body.is-preload *, body.is-preload *:before, body.is-preload *:after { + -moz-animation: none !important; + -webkit-animation: none !important; + -ms-animation: none !important; + animation: none !important; + -moz-transition: none !important; + -webkit-transition: none !important; + -ms-transition: none !important; + transition: none !important; + } + +/* Type */ + + body, input, select, textarea { + color: #ffffff; + font-family: "Source Sans Pro", Helvetica, sans-serif; + font-size: 17pt; + font-weight: 300; + letter-spacing: 0.025em; + line-height: 1.65; + } + + @media screen and (max-width: 1680px) { + + body, input, select, textarea { + font-size: 14pt; + } + + } + + @media screen and (max-width: 1280px) { + + body, input, select, textarea { + font-size: 12pt; + } + + } + + @media screen and (max-width: 360px) { + + body, input, select, textarea { + font-size: 11pt; + } + + } + + a { + -moz-transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; + -webkit-transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; + -ms-transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; + transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out; + border-bottom: dotted 1px; + color: inherit; + text-decoration: none; + } + + a:hover { + border-bottom-color: transparent; + color: #9bf1ff !important; + } + + a:active { + color: #53e3fb !important; + } + + strong, b { + color: #ffffff; + font-weight: 600; + } + + em, i { + font-style: italic; + } + + p { + margin: 0 0 2em 0; + } + + h1, h2, h3, h4, h5, h6 { + color: #ffffff; + font-weight: 600; + line-height: 1.65; + margin: 0 0 1em 0; + } + + h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { + color: inherit; + border-bottom: 0; + } + + h1 { + font-size: 2.5em; + } + + h2 { + font-size: 1.75em; + } + + h3 { + font-size: 1.35em; + } + + h4 { + font-size: 1.1em; + } + + h5 { + font-size: 0.9em; + } + + h6 { + font-size: 0.7em; + } + + @media screen and (max-width: 736px) { + + h1 { + font-size: 2em; + } + + h2 { + font-size: 1.5em; + } + + h3 { + font-size: 1.25em; + } + + } + + sub { + font-size: 0.8em; + position: relative; + top: 0.5em; + } + + sup { + font-size: 0.8em; + position: relative; + top: -0.5em; + } + + blockquote { + border-left: solid 4px rgba(212, 212, 255, 0.1); + font-style: italic; + margin: 0 0 2em 0; + padding: 0.5em 0 0.5em 2em; + } + + code { + background: rgba(212, 212, 255, 0.035); + font-family: "Courier New", monospace; + font-size: 0.9em; + margin: 0 0.25em; + padding: 0.25em 0.65em; + } + + pre { + -webkit-overflow-scrolling: touch; + font-family: "Courier New", monospace; + font-size: 0.9em; + margin: 0 0 2em 0; + } + + pre code { + display: block; + line-height: 1.75; + padding: 1em 1.5em; + overflow-x: auto; + } + + hr { + border: 0; + border-bottom: solid 1px rgba(212, 212, 255, 0.1); + margin: 2em 0; + } + + hr.major { + margin: 3em 0; + } + + .align-left { + text-align: left; + } + + .align-center { + text-align: center; + } + + .align-right { + text-align: right; + } + +/* Row */ + + .row { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + align-items: stretch; + } + + .row > * { + box-sizing: border-box; + } + + .row.gtr-uniform > * > :last-child { + margin-bottom: 0; + } + + .row.aln-left { + justify-content: flex-start; + } + + .row.aln-center { + justify-content: center; + } + + .row.aln-right { + justify-content: flex-end; + } + + .row.aln-top { + align-items: flex-start; + } + + .row.aln-middle { + align-items: center; + } + + .row.aln-bottom { + align-items: flex-end; + } + + .row > .imp { + order: -1; + } + + .row > .col-1 { + width: 8.33333%; + } + + .row > .off-1 { + margin-left: 8.33333%; + } + + .row > .col-2 { + width: 16.66667%; + } + + .row > .off-2 { + margin-left: 16.66667%; + } + + .row > .col-3 { + width: 25%; + } + + .row > .off-3 { + margin-left: 25%; + } + + .row > .col-4 { + width: 33.33333%; + } + + .row > .off-4 { + margin-left: 33.33333%; + } + + .row > .col-5 { + width: 41.66667%; + } + + .row > .off-5 { + margin-left: 41.66667%; + } + + .row > .col-6 { + width: 50%; + } + + .row > .off-6 { + margin-left: 50%; + } + + .row > .col-7 { + width: 58.33333%; + } + + .row > .off-7 { + margin-left: 58.33333%; + } + + .row > .col-8 { + width: 66.66667%; + } + + .row > .off-8 { + margin-left: 66.66667%; + } + + .row > .col-9 { + width: 75%; + } + + .row > .off-9 { + margin-left: 75%; + } + + .row > .col-10 { + width: 83.33333%; + } + + .row > .off-10 { + margin-left: 83.33333%; + } + + .row > .col-11 { + width: 91.66667%; + } + + .row > .off-11 { + margin-left: 91.66667%; + } + + .row > .col-12 { + width: 100%; + } + + .row > .off-12 { + margin-left: 100%; + } + + .row.gtr-0 { + margin-top: 0; + margin-left: 0em; + } + + .row.gtr-0 > * { + padding: 0 0 0 0em; + } + + .row.gtr-0.gtr-uniform { + margin-top: 0em; + } + + .row.gtr-0.gtr-uniform > * { + padding-top: 0em; + } + + .row.gtr-25 { + margin-top: 0; + margin-left: -0.5em; + } + + .row.gtr-25 > * { + padding: 0 0 0 0.5em; + } + + .row.gtr-25.gtr-uniform { + margin-top: -0.5em; + } + + .row.gtr-25.gtr-uniform > * { + padding-top: 0.5em; + } + + .row.gtr-50 { + margin-top: 0; + margin-left: -1em; + } + + .row.gtr-50 > * { + padding: 0 0 0 1em; + } + + .row.gtr-50.gtr-uniform { + margin-top: -1em; + } + + .row.gtr-50.gtr-uniform > * { + padding-top: 1em; + } + + .row { + margin-top: 0; + margin-left: -2em; + } + + .row > * { + padding: 0 0 0 2em; + } + + .row.gtr-uniform { + margin-top: -2em; + } + + .row.gtr-uniform > * { + padding-top: 2em; + } + + .row.gtr-150 { + margin-top: 0; + margin-left: -3em; + } + + .row.gtr-150 > * { + padding: 0 0 0 3em; + } + + .row.gtr-150.gtr-uniform { + margin-top: -3em; + } + + .row.gtr-150.gtr-uniform > * { + padding-top: 3em; + } + + .row.gtr-200 { + margin-top: 0; + margin-left: -4em; + } + + .row.gtr-200 > * { + padding: 0 0 0 4em; + } + + .row.gtr-200.gtr-uniform { + margin-top: -4em; + } + + .row.gtr-200.gtr-uniform > * { + padding-top: 4em; + } + + @media screen and (max-width: 1680px) { + + .row { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + align-items: stretch; + } + + .row > * { + box-sizing: border-box; + } + + .row.gtr-uniform > * > :last-child { + margin-bottom: 0; + } + + .row.aln-left { + justify-content: flex-start; + } + + .row.aln-center { + justify-content: center; + } + + .row.aln-right { + justify-content: flex-end; + } + + .row.aln-top { + align-items: flex-start; + } + + .row.aln-middle { + align-items: center; + } + + .row.aln-bottom { + align-items: flex-end; + } + + .row > .imp-xlarge { + order: -1; + } + + .row > .col-1-xlarge { + width: 8.33333%; + } + + .row > .off-1-xlarge { + margin-left: 8.33333%; + } + + .row > .col-2-xlarge { + width: 16.66667%; + } + + .row > .off-2-xlarge { + margin-left: 16.66667%; + } + + .row > .col-3-xlarge { + width: 25%; + } + + .row > .off-3-xlarge { + margin-left: 25%; + } + + .row > .col-4-xlarge { + width: 33.33333%; + } + + .row > .off-4-xlarge { + margin-left: 33.33333%; + } + + .row > .col-5-xlarge { + width: 41.66667%; + } + + .row > .off-5-xlarge { + margin-left: 41.66667%; + } + + .row > .col-6-xlarge { + width: 50%; + } + + .row > .off-6-xlarge { + margin-left: 50%; + } + + .row > .col-7-xlarge { + width: 58.33333%; + } + + .row > .off-7-xlarge { + margin-left: 58.33333%; + } + + .row > .col-8-xlarge { + width: 66.66667%; + } + + .row > .off-8-xlarge { + margin-left: 66.66667%; + } + + .row > .col-9-xlarge { + width: 75%; + } + + .row > .off-9-xlarge { + margin-left: 75%; + } + + .row > .col-10-xlarge { + width: 83.33333%; + } + + .row > .off-10-xlarge { + margin-left: 83.33333%; + } + + .row > .col-11-xlarge { + width: 91.66667%; + } + + .row > .off-11-xlarge { + margin-left: 91.66667%; + } + + .row > .col-12-xlarge { + width: 100%; + } + + .row > .off-12-xlarge { + margin-left: 100%; + } + + .row.gtr-0 { + margin-top: 0; + margin-left: 0em; + } + + .row.gtr-0 > * { + padding: 0 0 0 0em; + } + + .row.gtr-0.gtr-uniform { + margin-top: 0em; + } + + .row.gtr-0.gtr-uniform > * { + padding-top: 0em; + } + + .row.gtr-25 { + margin-top: 0; + margin-left: -0.5em; + } + + .row.gtr-25 > * { + padding: 0 0 0 0.5em; + } + + .row.gtr-25.gtr-uniform { + margin-top: -0.5em; + } + + .row.gtr-25.gtr-uniform > * { + padding-top: 0.5em; + } + + .row.gtr-50 { + margin-top: 0; + margin-left: -1em; + } + + .row.gtr-50 > * { + padding: 0 0 0 1em; + } + + .row.gtr-50.gtr-uniform { + margin-top: -1em; + } + + .row.gtr-50.gtr-uniform > * { + padding-top: 1em; + } + + .row { + margin-top: 0; + margin-left: -2em; + } + + .row > * { + padding: 0 0 0 2em; + } + + .row.gtr-uniform { + margin-top: -2em; + } + + .row.gtr-uniform > * { + padding-top: 2em; + } + + .row.gtr-150 { + margin-top: 0; + margin-left: -3em; + } + + .row.gtr-150 > * { + padding: 0 0 0 3em; + } + + .row.gtr-150.gtr-uniform { + margin-top: -3em; + } + + .row.gtr-150.gtr-uniform > * { + padding-top: 3em; + } + + .row.gtr-200 { + margin-top: 0; + margin-left: -4em; + } + + .row.gtr-200 > * { + padding: 0 0 0 4em; + } + + .row.gtr-200.gtr-uniform { + margin-top: -4em; + } + + .row.gtr-200.gtr-uniform > * { + padding-top: 4em; + } + + } + + @media screen and (max-width: 1280px) { + + .row { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + align-items: stretch; + } + + .row > * { + box-sizing: border-box; + } + + .row.gtr-uniform > * > :last-child { + margin-bottom: 0; + } + + .row.aln-left { + justify-content: flex-start; + } + + .row.aln-center { + justify-content: center; + } + + .row.aln-right { + justify-content: flex-end; + } + + .row.aln-top { + align-items: flex-start; + } + + .row.aln-middle { + align-items: center; + } + + .row.aln-bottom { + align-items: flex-end; + } + + .row > .imp-large { + order: -1; + } + + .row > .col-1-large { + width: 8.33333%; + } + + .row > .off-1-large { + margin-left: 8.33333%; + } + + .row > .col-2-large { + width: 16.66667%; + } + + .row > .off-2-large { + margin-left: 16.66667%; + } + + .row > .col-3-large { + width: 25%; + } + + .row > .off-3-large { + margin-left: 25%; + } + + .row > .col-4-large { + width: 33.33333%; + } + + .row > .off-4-large { + margin-left: 33.33333%; + } + + .row > .col-5-large { + width: 41.66667%; + } + + .row > .off-5-large { + margin-left: 41.66667%; + } + + .row > .col-6-large { + width: 50%; + } + + .row > .off-6-large { + margin-left: 50%; + } + + .row > .col-7-large { + width: 58.33333%; + } + + .row > .off-7-large { + margin-left: 58.33333%; + } + + .row > .col-8-large { + width: 66.66667%; + } + + .row > .off-8-large { + margin-left: 66.66667%; + } + + .row > .col-9-large { + width: 75%; + } + + .row > .off-9-large { + margin-left: 75%; + } + + .row > .col-10-large { + width: 83.33333%; + } + + .row > .off-10-large { + margin-left: 83.33333%; + } + + .row > .col-11-large { + width: 91.66667%; + } + + .row > .off-11-large { + margin-left: 91.66667%; + } + + .row > .col-12-large { + width: 100%; + } + + .row > .off-12-large { + margin-left: 100%; + } + + .row.gtr-0 { + margin-top: 0; + margin-left: 0em; + } + + .row.gtr-0 > * { + padding: 0 0 0 0em; + } + + .row.gtr-0.gtr-uniform { + margin-top: 0em; + } + + .row.gtr-0.gtr-uniform > * { + padding-top: 0em; + } + + .row.gtr-25 { + margin-top: 0; + margin-left: -0.375em; + } + + .row.gtr-25 > * { + padding: 0 0 0 0.375em; + } + + .row.gtr-25.gtr-uniform { + margin-top: -0.375em; + } + + .row.gtr-25.gtr-uniform > * { + padding-top: 0.375em; + } + + .row.gtr-50 { + margin-top: 0; + margin-left: -0.75em; + } + + .row.gtr-50 > * { + padding: 0 0 0 0.75em; + } + + .row.gtr-50.gtr-uniform { + margin-top: -0.75em; + } + + .row.gtr-50.gtr-uniform > * { + padding-top: 0.75em; + } + + .row { + margin-top: 0; + margin-left: -1.5em; + } + + .row > * { + padding: 0 0 0 1.5em; + } + + .row.gtr-uniform { + margin-top: -1.5em; + } + + .row.gtr-uniform > * { + padding-top: 1.5em; + } + + .row.gtr-150 { + margin-top: 0; + margin-left: -2.25em; + } + + .row.gtr-150 > * { + padding: 0 0 0 2.25em; + } + + .row.gtr-150.gtr-uniform { + margin-top: -2.25em; + } + + .row.gtr-150.gtr-uniform > * { + padding-top: 2.25em; + } + + .row.gtr-200 { + margin-top: 0; + margin-left: -3em; + } + + .row.gtr-200 > * { + padding: 0 0 0 3em; + } + + .row.gtr-200.gtr-uniform { + margin-top: -3em; + } + + .row.gtr-200.gtr-uniform > * { + padding-top: 3em; + } + + } + + @media screen and (max-width: 980px) { + + .row { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + align-items: stretch; + } + + .row > * { + box-sizing: border-box; + } + + .row.gtr-uniform > * > :last-child { + margin-bottom: 0; + } + + .row.aln-left { + justify-content: flex-start; + } + + .row.aln-center { + justify-content: center; + } + + .row.aln-right { + justify-content: flex-end; + } + + .row.aln-top { + align-items: flex-start; + } + + .row.aln-middle { + align-items: center; + } + + .row.aln-bottom { + align-items: flex-end; + } + + .row > .imp-medium { + order: -1; + } + + .row > .col-1-medium { + width: 8.33333%; + } + + .row > .off-1-medium { + margin-left: 8.33333%; + } + + .row > .col-2-medium { + width: 16.66667%; + } + + .row > .off-2-medium { + margin-left: 16.66667%; + } + + .row > .col-3-medium { + width: 25%; + } + + .row > .off-3-medium { + margin-left: 25%; + } + + .row > .col-4-medium { + width: 33.33333%; + } + + .row > .off-4-medium { + margin-left: 33.33333%; + } + + .row > .col-5-medium { + width: 41.66667%; + } + + .row > .off-5-medium { + margin-left: 41.66667%; + } + + .row > .col-6-medium { + width: 50%; + } + + .row > .off-6-medium { + margin-left: 50%; + } + + .row > .col-7-medium { + width: 58.33333%; + } + + .row > .off-7-medium { + margin-left: 58.33333%; + } + + .row > .col-8-medium { + width: 66.66667%; + } + + .row > .off-8-medium { + margin-left: 66.66667%; + } + + .row > .col-9-medium { + width: 75%; + } + + .row > .off-9-medium { + margin-left: 75%; + } + + .row > .col-10-medium { + width: 83.33333%; + } + + .row > .off-10-medium { + margin-left: 83.33333%; + } + + .row > .col-11-medium { + width: 91.66667%; + } + + .row > .off-11-medium { + margin-left: 91.66667%; + } + + .row > .col-12-medium { + width: 100%; + } + + .row > .off-12-medium { + margin-left: 100%; + } + + .row.gtr-0 { + margin-top: 0; + margin-left: 0em; + } + + .row.gtr-0 > * { + padding: 0 0 0 0em; + } + + .row.gtr-0.gtr-uniform { + margin-top: 0em; + } + + .row.gtr-0.gtr-uniform > * { + padding-top: 0em; + } + + .row.gtr-25 { + margin-top: 0; + margin-left: -0.375em; + } + + .row.gtr-25 > * { + padding: 0 0 0 0.375em; + } + + .row.gtr-25.gtr-uniform { + margin-top: -0.375em; + } + + .row.gtr-25.gtr-uniform > * { + padding-top: 0.375em; + } + + .row.gtr-50 { + margin-top: 0; + margin-left: -0.75em; + } + + .row.gtr-50 > * { + padding: 0 0 0 0.75em; + } + + .row.gtr-50.gtr-uniform { + margin-top: -0.75em; + } + + .row.gtr-50.gtr-uniform > * { + padding-top: 0.75em; + } + + .row { + margin-top: 0; + margin-left: -1.5em; + } + + .row > * { + padding: 0 0 0 1.5em; + } + + .row.gtr-uniform { + margin-top: -1.5em; + } + + .row.gtr-uniform > * { + padding-top: 1.5em; + } + + .row.gtr-150 { + margin-top: 0; + margin-left: -2.25em; + } + + .row.gtr-150 > * { + padding: 0 0 0 2.25em; + } + + .row.gtr-150.gtr-uniform { + margin-top: -2.25em; + } + + .row.gtr-150.gtr-uniform > * { + padding-top: 2.25em; + } + + .row.gtr-200 { + margin-top: 0; + margin-left: -3em; + } + + .row.gtr-200 > * { + padding: 0 0 0 3em; + } + + .row.gtr-200.gtr-uniform { + margin-top: -3em; + } + + .row.gtr-200.gtr-uniform > * { + padding-top: 3em; + } + + } + + @media screen and (max-width: 736px) { + + .row { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + align-items: stretch; + } + + .row > * { + box-sizing: border-box; + } + + .row.gtr-uniform > * > :last-child { + margin-bottom: 0; + } + + .row.aln-left { + justify-content: flex-start; + } + + .row.aln-center { + justify-content: center; + } + + .row.aln-right { + justify-content: flex-end; + } + + .row.aln-top { + align-items: flex-start; + } + + .row.aln-middle { + align-items: center; + } + + .row.aln-bottom { + align-items: flex-end; + } + + .row > .imp-small { + order: -1; + } + + .row > .col-1-small { + width: 8.33333%; + } + + .row > .off-1-small { + margin-left: 8.33333%; + } + + .row > .col-2-small { + width: 16.66667%; + } + + .row > .off-2-small { + margin-left: 16.66667%; + } + + .row > .col-3-small { + width: 25%; + } + + .row > .off-3-small { + margin-left: 25%; + } + + .row > .col-4-small { + width: 33.33333%; + } + + .row > .off-4-small { + margin-left: 33.33333%; + } + + .row > .col-5-small { + width: 41.66667%; + } + + .row > .off-5-small { + margin-left: 41.66667%; + } + + .row > .col-6-small { + width: 50%; + } + + .row > .off-6-small { + margin-left: 50%; + } + + .row > .col-7-small { + width: 58.33333%; + } + + .row > .off-7-small { + margin-left: 58.33333%; + } + + .row > .col-8-small { + width: 66.66667%; + } + + .row > .off-8-small { + margin-left: 66.66667%; + } + + .row > .col-9-small { + width: 75%; + } + + .row > .off-9-small { + margin-left: 75%; + } + + .row > .col-10-small { + width: 83.33333%; + } + + .row > .off-10-small { + margin-left: 83.33333%; + } + + .row > .col-11-small { + width: 91.66667%; + } + + .row > .off-11-small { + margin-left: 91.66667%; + } + + .row > .col-12-small { + width: 100%; + } + + .row > .off-12-small { + margin-left: 100%; + } + + .row.gtr-0 { + margin-top: 0; + margin-left: 0em; + } + + .row.gtr-0 > * { + padding: 0 0 0 0em; + } + + .row.gtr-0.gtr-uniform { + margin-top: 0em; + } + + .row.gtr-0.gtr-uniform > * { + padding-top: 0em; + } + + .row.gtr-25 { + margin-top: 0; + margin-left: -0.3125em; + } + + .row.gtr-25 > * { + padding: 0 0 0 0.3125em; + } + + .row.gtr-25.gtr-uniform { + margin-top: -0.3125em; + } + + .row.gtr-25.gtr-uniform > * { + padding-top: 0.3125em; + } + + .row.gtr-50 { + margin-top: 0; + margin-left: -0.625em; + } + + .row.gtr-50 > * { + padding: 0 0 0 0.625em; + } + + .row.gtr-50.gtr-uniform { + margin-top: -0.625em; + } + + .row.gtr-50.gtr-uniform > * { + padding-top: 0.625em; + } + + .row { + margin-top: 0; + margin-left: -1.25em; + } + + .row > * { + padding: 0 0 0 1.25em; + } + + .row.gtr-uniform { + margin-top: -1.25em; + } + + .row.gtr-uniform > * { + padding-top: 1.25em; + } + + .row.gtr-150 { + margin-top: 0; + margin-left: -1.875em; + } + + .row.gtr-150 > * { + padding: 0 0 0 1.875em; + } + + .row.gtr-150.gtr-uniform { + margin-top: -1.875em; + } + + .row.gtr-150.gtr-uniform > * { + padding-top: 1.875em; + } + + .row.gtr-200 { + margin-top: 0; + margin-left: -2.5em; + } + + .row.gtr-200 > * { + padding: 0 0 0 2.5em; + } + + .row.gtr-200.gtr-uniform { + margin-top: -2.5em; + } + + .row.gtr-200.gtr-uniform > * { + padding-top: 2.5em; + } + + } + + @media screen and (max-width: 480px) { + + .row { + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + align-items: stretch; + } + + .row > * { + box-sizing: border-box; + } + + .row.gtr-uniform > * > :last-child { + margin-bottom: 0; + } + + .row.aln-left { + justify-content: flex-start; + } + + .row.aln-center { + justify-content: center; + } + + .row.aln-right { + justify-content: flex-end; + } + + .row.aln-top { + align-items: flex-start; + } + + .row.aln-middle { + align-items: center; + } + + .row.aln-bottom { + align-items: flex-end; + } + + .row > .imp-xsmall { + order: -1; + } + + .row > .col-1-xsmall { + width: 8.33333%; + } + + .row > .off-1-xsmall { + margin-left: 8.33333%; + } + + .row > .col-2-xsmall { + width: 16.66667%; + } + + .row > .off-2-xsmall { + margin-left: 16.66667%; + } + + .row > .col-3-xsmall { + width: 25%; + } + + .row > .off-3-xsmall { + margin-left: 25%; + } + + .row > .col-4-xsmall { + width: 33.33333%; + } + + .row > .off-4-xsmall { + margin-left: 33.33333%; + } + + .row > .col-5-xsmall { + width: 41.66667%; + } + + .row > .off-5-xsmall { + margin-left: 41.66667%; + } + + .row > .col-6-xsmall { + width: 50%; + } + + .row > .off-6-xsmall { + margin-left: 50%; + } + + .row > .col-7-xsmall { + width: 58.33333%; + } + + .row > .off-7-xsmall { + margin-left: 58.33333%; + } + + .row > .col-8-xsmall { + width: 66.66667%; + } + + .row > .off-8-xsmall { + margin-left: 66.66667%; + } + + .row > .col-9-xsmall { + width: 75%; + } + + .row > .off-9-xsmall { + margin-left: 75%; + } + + .row > .col-10-xsmall { + width: 83.33333%; + } + + .row > .off-10-xsmall { + margin-left: 83.33333%; + } + + .row > .col-11-xsmall { + width: 91.66667%; + } + + .row > .off-11-xsmall { + margin-left: 91.66667%; + } + + .row > .col-12-xsmall { + width: 100%; + } + + .row > .off-12-xsmall { + margin-left: 100%; + } + + .row.gtr-0 { + margin-top: 0; + margin-left: 0em; + } + + .row.gtr-0 > * { + padding: 0 0 0 0em; + } + + .row.gtr-0.gtr-uniform { + margin-top: 0em; + } + + .row.gtr-0.gtr-uniform > * { + padding-top: 0em; + } + + .row.gtr-25 { + margin-top: 0; + margin-left: -0.3125em; + } + + .row.gtr-25 > * { + padding: 0 0 0 0.3125em; + } + + .row.gtr-25.gtr-uniform { + margin-top: -0.3125em; + } + + .row.gtr-25.gtr-uniform > * { + padding-top: 0.3125em; + } + + .row.gtr-50 { + margin-top: 0; + margin-left: -0.625em; + } + + .row.gtr-50 > * { + padding: 0 0 0 0.625em; + } + + .row.gtr-50.gtr-uniform { + margin-top: -0.625em; + } + + .row.gtr-50.gtr-uniform > * { + padding-top: 0.625em; + } + + .row { + margin-top: 0; + margin-left: -1.25em; + } + + .row > * { + padding: 0 0 0 1.25em; + } + + .row.gtr-uniform { + margin-top: -1.25em; + } + + .row.gtr-uniform > * { + padding-top: 1.25em; + } + + .row.gtr-150 { + margin-top: 0; + margin-left: -1.875em; + } + + .row.gtr-150 > * { + padding: 0 0 0 1.875em; + } + + .row.gtr-150.gtr-uniform { + margin-top: -1.875em; + } + + .row.gtr-150.gtr-uniform > * { + padding-top: 1.875em; + } + + .row.gtr-200 { + margin-top: 0; + margin-left: -2.5em; + } + + .row.gtr-200 > * { + padding: 0 0 0 2.5em; + } + + .row.gtr-200.gtr-uniform { + margin-top: -2.5em; + } + + .row.gtr-200.gtr-uniform > * { + padding-top: 2.5em; + } + + } + +/* Section/Article */ + + section.special, article.special { + text-align: center; + } + + header.major { + width: -moz-max-content; + width: -webkit-max-content; + width: -ms-max-content; + width: max-content; + margin-bottom: 2em; + } + + header.major > :first-child { + margin-bottom: 0; + width: calc(100% + 0.5em); + } + + header.major > :first-child:after { + content: ''; + background-color: #ffffff; + display: block; + height: 2px; + margin: 0.325em 0 0.5em 0; + width: 100%; + } + + header.major > p { + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.25em; + margin-bottom: 0; + text-transform: uppercase; + } + + body.is-ie header.major > :first-child:after { + max-width: 9em; + } + + body.is-ie header.major > h1:after { + max-width: 100% !important; + } + + @media screen and (max-width: 736px) { + + header.major > p br { + display: none; + } + + } + +/* Form */ + + form { + margin: 0 0 2em 0; + } + + form > :last-child { + margin-bottom: 0; + } + + form > .fields { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + width: calc(100% + 3em); + margin: -1.5em 0 2em -1.5em; + } + + form > .fields > .field { + -moz-flex-grow: 0; + -webkit-flex-grow: 0; + -ms-flex-grow: 0; + flex-grow: 0; + -moz-flex-shrink: 0; + -webkit-flex-shrink: 0; + -ms-flex-shrink: 0; + flex-shrink: 0; + padding: 1.5em 0 0 1.5em; + width: calc(100% - 1.5em); + } + + form > .fields > .field.half { + width: calc(50% - 0.75em); + } + + form > .fields > .field.third { + width: calc(100%/3 - 0.5em); + } + + form > .fields > .field.quarter { + width: calc(25% - 0.375em); + } + + @media screen and (max-width: 480px) { + + form > .fields { + width: calc(100% + 3em); + margin: -1.5em 0 2em -1.5em; + } + + form > .fields > .field { + padding: 1.5em 0 0 1.5em; + width: calc(100% - 1.5em); + } + + form > .fields > .field.half { + width: calc(100% - 1.5em); + } + + form > .fields > .field.third { + width: calc(100% - 1.5em); + } + + form > .fields > .field.quarter { + width: calc(100% - 1.5em); + } + + } + + label { + color: #ffffff; + display: block; + font-size: 0.8em; + font-weight: 600; + letter-spacing: 0.25em; + margin: 0 0 1em 0; + text-transform: uppercase; + } + + input[type="text"], + input[type="password"], + input[type="email"], + input[type="tel"], + input[type="search"], + input[type="url"], + select, + textarea { + -moz-appearance: none; + -webkit-appearance: none; + -ms-appearance: none; + appearance: none; + background: rgba(212, 212, 255, 0.035); + border: none; + border-radius: 0; + color: inherit; + display: block; + outline: 0; + padding: 0 1em; + text-decoration: none; + width: 100%; + } + + input[type="text"]:invalid, + input[type="password"]:invalid, + input[type="email"]:invalid, + input[type="tel"]:invalid, + input[type="search"]:invalid, + input[type="url"]:invalid, + select:invalid, + textarea:invalid { + box-shadow: none; + } + + input[type="text"]:focus, + input[type="password"]:focus, + input[type="email"]:focus, + input[type="tel"]:focus, + input[type="search"]:focus, + input[type="url"]:focus, + select:focus, + textarea:focus { + border-color: #9bf1ff; + box-shadow: 0 0 0 2px #9bf1ff; + } + + select { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' preserveAspectRatio='none' viewBox='0 0 40 40'%3E%3Cpath d='M9.4,12.3l10.4,10.4l10.4-10.4c0.2-0.2,0.5-0.4,0.9-0.4c0.3,0,0.6,0.1,0.9,0.4l3.3,3.3c0.2,0.2,0.4,0.5,0.4,0.9 c0,0.4-0.1,0.6-0.4,0.9L20.7,31.9c-0.2,0.2-0.5,0.4-0.9,0.4c-0.3,0-0.6-0.1-0.9-0.4L4.3,17.3c-0.2-0.2-0.4-0.5-0.4-0.9 c0-0.4,0.1-0.6,0.4-0.9l3.3-3.3c0.2-0.2,0.5-0.4,0.9-0.4S9.1,12.1,9.4,12.3z' fill='rgba(212, 212, 255, 0.1)' /%3E%3C/svg%3E"); + background-size: 1.25rem; + background-repeat: no-repeat; + background-position: calc(100% - 1rem) center; + height: 2.75em; + padding-right: 2.75em; + text-overflow: ellipsis; + } + + select option { + color: #ffffff; + background: #242943; + } + + select:focus::-ms-value { + background-color: transparent; + } + + select::-ms-expand { + display: none; + } + + input[type="text"], + input[type="password"], + input[type="email"], + input[type="tel"], + input[type="search"], + input[type="url"], + select { + height: 2.75em; + } + + textarea { + padding: 0.75em 1em; + } + + input[type="checkbox"], + input[type="radio"] { + -moz-appearance: none; + -webkit-appearance: none; + -ms-appearance: none; + appearance: none; + display: block; + float: left; + margin-right: -2em; + opacity: 0; + width: 1em; + z-index: -1; + } + + input[type="checkbox"] + label, + input[type="radio"] + label { + text-decoration: none; + color: #ffffff; + cursor: pointer; + display: inline-block; + font-weight: 300; + padding-left: 2.65em; + padding-right: 0.75em; + position: relative; + } + + input[type="checkbox"] + label:before, + input[type="radio"] + label:before { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; + text-transform: none !important; + font-family: 'Font Awesome 5 Free'; + font-weight: 900; + } + + input[type="checkbox"] + label:before, + input[type="radio"] + label:before { + background: rgba(212, 212, 255, 0.035); + content: ''; + display: inline-block; + font-size: 0.8em; + height: 2.0625em; + left: 0; + letter-spacing: 0; + line-height: 2.0625em; + position: absolute; + text-align: center; + top: 0; + width: 2.0625em; + } + + input[type="checkbox"]:checked + label:before, + input[type="radio"]:checked + label:before { + background: #ffffff; + border-color: #9bf1ff; + content: '\f00c'; + color: #242943; + } + + input[type="checkbox"]:focus + label:before, + input[type="radio"]:focus + label:before { + box-shadow: 0 0 0 2px #9bf1ff; + } + + input[type="radio"] + label:before { + border-radius: 100%; + } + + ::-webkit-input-placeholder { + color: rgba(244, 244, 255, 0.2) !important; + opacity: 1.0; + } + + :-moz-placeholder { + color: rgba(244, 244, 255, 0.2) !important; + opacity: 1.0; + } + + ::-moz-placeholder { + color: rgba(244, 244, 255, 0.2) !important; + opacity: 1.0; + } + + :-ms-input-placeholder { + color: rgba(244, 244, 255, 0.2) !important; + opacity: 1.0; + } + +/* Box */ + + .box { + border: solid 1px rgba(212, 212, 255, 0.1); + margin-bottom: 2em; + padding: 1.5em; + } + + .box > :last-child, + .box > :last-child > :last-child, + .box > :last-child > :last-child > :last-child { + margin-bottom: 0; + } + + .box.alt { + border: 0; + border-radius: 0; + padding: 0; + } + +/* Icon */ + + .icon { + text-decoration: none; + border-bottom: none; + position: relative; + } + + .icon:before { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; + text-transform: none !important; + font-family: 'Font Awesome 5 Free'; + font-weight: 400; + } + + .icon > .label { + display: none; + } + + .icon:before { + line-height: inherit; + } + + .icon.solid:before { + font-weight: 900; + } + + .icon.brands:before { + font-family: 'Font Awesome 5 Brands'; + } + + .icon.alt:before { + background-color: #ffffff; + border-radius: 100%; + color: #242943; + display: inline-block; + height: 2em; + line-height: 2em; + text-align: center; + width: 2em; + } + + a.icon.alt:before { + -moz-transition: background-color 0.2s ease-in-out; + -webkit-transition: background-color 0.2s ease-in-out; + -ms-transition: background-color 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out; + } + + a.icon.alt:hover:before { + background-color: #6fc3df; + } + + a.icon.alt:active:before { + background-color: #37a6cb; + } + +/* Image */ + + .image { + border: 0; + display: inline-block; + position: relative; + } + + .image img { + display: block; + } + + .image.left, .image.right { + max-width: 30%; + } + + .image.left img, .image.right img { + width: 100%; + } + + .image.left { + float: left; + margin: 0 1.5em 1.25em 0; + top: 0.25em; + } + + .image.right { + float: right; + margin: 0 0 1.25em 1.5em; + top: 0.25em; + } + + .image.fit { + display: block; + margin: 0 0 2em 0; + width: 100%; + } + + .image.fit img { + width: 100%; + } + + .image.main { + display: block; + margin: 2.5em 0; + width: 100%; + } + + .image.main img { + width: 100%; + } + + @media screen and (max-width: 736px) { + + .image.main { + margin: 1.5em 0; + } + + } + +/* List */ + + ol { + list-style: decimal; + margin: 0 0 2em 0; + padding-left: 1.25em; + } + + ol li { + padding-left: 0.25em; + } + + ul { + list-style: disc; + margin: 0 0 2em 0; + padding-left: 1em; + } + + ul li { + padding-left: 0.5em; + } + + ul.alt { + list-style: none; + padding-left: 0; + } + + ul.alt li { + border-top: solid 1px rgba(212, 212, 255, 0.1); + padding: 0.5em 0; + } + + ul.alt li:first-child { + border-top: 0; + padding-top: 0; + } + + dl { + margin: 0 0 2em 0; + } + + dl dt { + display: block; + font-weight: 600; + margin: 0 0 1em 0; + } + + dl dd { + margin-left: 2em; + } + +/* Actions */ + + ul.actions { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + cursor: default; + list-style: none; + margin-left: -1em; + padding-left: 0; + } + + ul.actions li { + padding: 0 0 0 1em; + vertical-align: middle; + } + + ul.actions.special { + -moz-justify-content: center; + -webkit-justify-content: center; + -ms-justify-content: center; + justify-content: center; + width: 100%; + margin-left: 0; + } + + ul.actions.special li:first-child { + padding-left: 0; + } + + ul.actions.stacked { + -moz-flex-direction: column; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + margin-left: 0; + } + + ul.actions.stacked li { + padding: 1.3em 0 0 0; + } + + ul.actions.stacked li:first-child { + padding-top: 0; + } + + ul.actions.fit { + width: calc(100% + 1em); + } + + ul.actions.fit li { + -moz-flex-grow: 1; + -webkit-flex-grow: 1; + -ms-flex-grow: 1; + flex-grow: 1; + -moz-flex-shrink: 1; + -webkit-flex-shrink: 1; + -ms-flex-shrink: 1; + flex-shrink: 1; + width: 100%; + } + + ul.actions.fit li > * { + width: 100%; + } + + ul.actions.fit.stacked { + width: 100%; + } + +/* Icons */ + + ul.icons { + cursor: default; + list-style: none; + padding-left: 0; + } + + ul.icons li { + display: inline-block; + padding: 0 1em 0 0; + } + + ul.icons li:last-child { + padding-right: 0; + } + + @media screen and (max-width: 736px) { + + ul.icons li { + padding: 0 0.75em 0 0; + } + + } + +/* Pagination */ + + ul.pagination { + cursor: default; + list-style: none; + padding-left: 0; + } + + ul.pagination li { + display: inline-block; + padding-left: 0; + vertical-align: middle; + } + + ul.pagination li > .page { + -moz-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + -webkit-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + -ms-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + border-bottom: 0; + display: inline-block; + font-size: 0.8em; + font-weight: 600; + height: 1.5em; + line-height: 1.5em; + margin: 0 0.125em; + min-width: 1.5em; + padding: 0 0.5em; + text-align: center; + } + + ul.pagination li > .page.active { + background-color: #ffffff; + color: #242943; + } + + ul.pagination li > .page.active:hover { + background-color: #9bf1ff; + color: #242943 !important; + } + + ul.pagination li > .page.active:active { + background-color: #53e3fb; + } + + ul.pagination li:first-child { + padding-right: 0.75em; + } + + ul.pagination li:last-child { + padding-left: 0.75em; + } + + @media screen and (max-width: 480px) { + + ul.pagination li:nth-child(n+2):nth-last-child(n+2) { + display: none; + } + + ul.pagination li:first-child { + padding-right: 0; + } + + } + +/* Table */ + + .table-wrapper { + -webkit-overflow-scrolling: touch; + overflow-x: auto; + } + + table { + margin: 0 0 2em 0; + width: 100%; + } + + table tbody tr { + border: solid 1px rgba(212, 212, 255, 0.1); + border-left: 0; + border-right: 0; + } + + table tbody tr:nth-child(2n + 1) { + background-color: rgba(212, 212, 255, 0.035); + } + + table td { + padding: 0.75em 0.75em; + } + + table th { + color: #ffffff; + font-size: 0.9em; + font-weight: 600; + padding: 0 0.75em 0.75em 0.75em; + text-align: left; + } + + table thead { + border-bottom: solid 2px rgba(212, 212, 255, 0.1); + } + + table tfoot { + border-top: solid 2px rgba(212, 212, 255, 0.1); + } + + table.alt { + border-collapse: separate; + } + + table.alt tbody tr td { + border: solid 1px rgba(212, 212, 255, 0.1); + border-left-width: 0; + border-top-width: 0; + } + + table.alt tbody tr td:first-child { + border-left-width: 1px; + } + + table.alt tbody tr:first-child td { + border-top-width: 1px; + } + + table.alt thead { + border-bottom: 0; + } + + table.alt tfoot { + border-top: 0; + } + +/* Button */ + + input[type="submit"], + input[type="reset"], + input[type="button"], + button, + .button { + -moz-appearance: none; + -webkit-appearance: none; + -ms-appearance: none; + appearance: none; + -moz-transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; + -webkit-transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; + -ms-transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, color 0.2s ease-in-out; + background-color: transparent; + border: 0; + border-radius: 0; + box-shadow: inset 0 0 0 2px #ffffff; + color: #ffffff; + cursor: pointer; + display: inline-block; + font-size: 0.8em; + font-weight: 600; + height: 3.5em; + letter-spacing: 0.25em; + line-height: 3.5em; + padding: 0 1.75em; + text-align: center; + text-decoration: none; + text-transform: uppercase; + white-space: nowrap; + } + + input[type="submit"]:hover, input[type="submit"]:active, + input[type="reset"]:hover, + input[type="reset"]:active, + input[type="button"]:hover, + input[type="button"]:active, + button:hover, + button:active, + .button:hover, + .button:active { + box-shadow: inset 0 0 0 2px #9bf1ff; + color: #9bf1ff; + } + + input[type="submit"]:active, + input[type="reset"]:active, + input[type="button"]:active, + button:active, + .button:active { + background-color: rgba(155, 241, 255, 0.1); + box-shadow: inset 0 0 0 2px #53e3fb; + color: #53e3fb; + } + + input[type="submit"].icon:before, + input[type="reset"].icon:before, + input[type="button"].icon:before, + button.icon:before, + .button.icon:before { + margin-right: 0.5em; + } + + input[type="submit"].fit, + input[type="reset"].fit, + input[type="button"].fit, + button.fit, + .button.fit { + width: 100%; + } + + input[type="submit"].small, + input[type="reset"].small, + input[type="button"].small, + button.small, + .button.small { + font-size: 0.6em; + } + + input[type="submit"].large, + input[type="reset"].large, + input[type="button"].large, + button.large, + .button.large { + font-size: 1.25em; + height: 3em; + line-height: 3em; + } + + input[type="submit"].next, + input[type="reset"].next, + input[type="button"].next, + button.next, + .button.next { + padding-right: 4.5em; + position: relative; + } + + input[type="submit"].next:before, input[type="submit"].next:after, + input[type="reset"].next:before, + input[type="reset"].next:after, + input[type="button"].next:before, + input[type="button"].next:after, + button.next:before, + button.next:after, + .button.next:before, + .button.next:after { + -moz-transition: opacity 0.2s ease-in-out; + -webkit-transition: opacity 0.2s ease-in-out; + -ms-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + background-position: center right; + background-repeat: no-repeat; + background-size: 36px 24px; + content: ''; + display: block; + height: 100%; + position: absolute; + right: 1.5em; + top: 0; + vertical-align: middle; + width: 36px; + } + + input[type="submit"].next:before, + input[type="reset"].next:before, + input[type="button"].next:before, + button.next:before, + .button.next:before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='36px' height='24px' viewBox='0 0 36 24' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %23ffffff%3B stroke-width: 2px%3B %7D%3C/style%3E%3Cline x1='0' y1='12' x2='34' y2='12' /%3E%3Cline x1='25' y1='4' x2='34' y2='12.5' /%3E%3Cline x1='25' y1='20' x2='34' y2='11.5' /%3E%3C/svg%3E"); + } + + input[type="submit"].next:after, + input[type="reset"].next:after, + input[type="button"].next:after, + button.next:after, + .button.next:after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='36px' height='24px' viewBox='0 0 36 24' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %239bf1ff%3B stroke-width: 2px%3B %7D%3C/style%3E%3Cline x1='0' y1='12' x2='34' y2='12' /%3E%3Cline x1='25' y1='4' x2='34' y2='12.5' /%3E%3Cline x1='25' y1='20' x2='34' y2='11.5' /%3E%3C/svg%3E"); + opacity: 0; + z-index: 1; + } + + input[type="submit"].next:hover:after, input[type="submit"].next:active:after, + input[type="reset"].next:hover:after, + input[type="reset"].next:active:after, + input[type="button"].next:hover:after, + input[type="button"].next:active:after, + button.next:hover:after, + button.next:active:after, + .button.next:hover:after, + .button.next:active:after { + opacity: 1; + } + + @media screen and (max-width: 1280px) { + + input[type="submit"].next, + input[type="reset"].next, + input[type="button"].next, + button.next, + .button.next { + padding-right: 5em; + } + + } + + input[type="submit"].primary, + input[type="reset"].primary, + input[type="button"].primary, + button.primary, + .button.primary { + background-color: #ffffff; + box-shadow: none; + color: #242943; + } + + input[type="submit"].primary:hover, input[type="submit"].primary:active, + input[type="reset"].primary:hover, + input[type="reset"].primary:active, + input[type="button"].primary:hover, + input[type="button"].primary:active, + button.primary:hover, + button.primary:active, + .button.primary:hover, + .button.primary:active { + background-color: #9bf1ff; + color: #242943 !important; + } + + input[type="submit"].primary:active, + input[type="reset"].primary:active, + input[type="button"].primary:active, + button.primary:active, + .button.primary:active { + background-color: #53e3fb; + } + + input[type="submit"].disabled, input[type="submit"]:disabled, + input[type="reset"].disabled, + input[type="reset"]:disabled, + input[type="button"].disabled, + input[type="button"]:disabled, + button.disabled, + button:disabled, + .button.disabled, + .button:disabled { + pointer-events: none; + cursor: default; + opacity: 0.25; + } + +/* Tiles */ + + .tiles { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + border-top: 0 !important; + } + + .tiles + * { + border-top: 0 !important; + } + + .tiles article { + -moz-align-items: center; + -webkit-align-items: center; + -ms-align-items: center; + align-items: center; + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-transition: -moz-transform 0.25s ease, opacity 0.25s ease, -moz-filter 1s ease, -webkit-filter 1s ease; + -webkit-transition: -webkit-transform 0.25s ease, opacity 0.25s ease, -webkit-filter 1s ease, -webkit-filter 1s ease; + -ms-transition: -ms-transform 0.25s ease, opacity 0.25s ease, -ms-filter 1s ease, -webkit-filter 1s ease; + transition: transform 0.25s ease, opacity 0.25s ease, filter 1s ease, -webkit-filter 1s ease; + padding: 4em 4em 2em 4em ; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + cursor: default; + height: 40vh; + max-height: 40em; + min-height: 23em; + overflow: hidden; + position: relative; + width: 40%; + } + + .tiles article .image { + display: none; + } + + .tiles article header { + position: relative; + z-index: 3; + } + + .tiles article h3 { + font-size: 1.75em; + } + + .tiles article h3 a:hover { + color: inherit !important; + } + + .tiles article .link.primary { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 4; + } + + .tiles article:before { + -moz-transition: opacity 0.5s ease; + -webkit-transition: opacity 0.5s ease; + -ms-transition: opacity 0.5s ease; + transition: opacity 0.5s ease; + bottom: 0; + content: ''; + display: block; + height: 100%; + left: 0; + opacity: 0.85; + position: absolute; + width: 100%; + z-index: 2; + } + + .tiles article:after { + background-color: rgba(36, 41, 67, 0.25); + content: ''; + display: block; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 1; + } + + .tiles article:hover:before { + opacity: 0; + } + + .tiles article.is-transitioning { + -moz-transform: scale(0.95); + -webkit-transform: scale(0.95); + -ms-transform: scale(0.95); + transform: scale(0.95); + -moz-filter: blur(0.5em); + -webkit-filter: blur(0.5em); + -ms-filter: blur(0.5em); + filter: blur(0.5em); + opacity: 0; + } + + .tiles article:nth-child(4n - 1), .tiles article:nth-child(4n - 2) { + width: 60%; + } + + .tiles article:nth-child(6n - 5):before { + background-color: #6fc3df; + } + + .tiles article:nth-child(6n - 4):before { + background-color: #8d82c4; + } + + .tiles article:nth-child(6n - 3):before { + background-color: #ec8d81; + } + + .tiles article:nth-child(6n - 2):before { + background-color: #e7b788; + } + + .tiles article:nth-child(6n - 1):before { + background-color: #8ea9e8; + } + + .tiles article:nth-child(6n):before { + background-color: #87c5a4; + } + + @media screen and (max-width: 1280px) { + + .tiles article { + padding: 4em 3em 2em 3em ; + height: 30vh; + max-height: 30em; + min-height: 20em; + } + + } + + @media screen and (max-width: 980px) { + + .tiles article { + width: 50% !important; + } + + } + + @media screen and (max-width: 736px) { + + .tiles article { + padding: 3em 1.5em 1em 1.5em ; + height: 16em; + max-height: none; + min-height: 0; + } + + .tiles article h3 { + font-size: 1.5em; + } + + } + + @media screen and (max-width: 480px) { + + .tiles { + display: block; + } + + .tiles article { + height: 20em; + width: 100% !important; + } + + } + +/* Contact Method */ + + .contact-method { + margin: 0 0 2em 0; + padding-left: 3.25em; + position: relative; + } + + .contact-method .icon { + left: 0; + position: absolute; + top: 0; + } + + .contact-method h3 { + margin: 0 0 0.5em 0; + } + +/* Spotlights */ + + .spotlights { + border-top: 0 !important; + } + + .spotlights + * { + border-top: 0 !important; + } + + .spotlights > section { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-flex-direction: row; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + background-color: #2e3450; + } + + .spotlights > section > .image { + background-position: center center; + background-size: cover; + border-radius: 0; + display: block; + position: relative; + width: 30%; + } + + .spotlights > section > .image img { + border-radius: 0; + display: block; + width: 100%; + } + + .spotlights > section > .image:before { + background: rgba(36, 41, 67, 0.9); + content: ''; + display: block; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; + } + + .spotlights > section > .content { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-flex-direction: column; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -moz-justify-content: center; + -webkit-justify-content: center; + -ms-justify-content: center; + justify-content: center; + -moz-align-items: center; + -webkit-align-items: center; + -ms-align-items: center; + align-items: center; + padding: 2em 3em 0.1em 3em ; + width: 70%; + } + + .spotlights > section > .content > .inner { + margin: 0 auto; + max-width: 100%; + width: 65em; + } + + .spotlights > section:nth-child(2n) { + -moz-flex-direction: row-reverse; + -webkit-flex-direction: row-reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; + background-color: #333856; + } + + .spotlights > section:nth-child(2n) > .content { + -moz-align-items: -moz-flex-end; + -webkit-align-items: -webkit-flex-end; + -ms-align-items: -ms-flex-end; + align-items: flex-end; + } + + @media screen and (max-width: 1680px) { + + .spotlights > section > .image { + width: 40%; + } + + .spotlights > section > .content { + width: 60%; + } + + } + + @media screen and (max-width: 1280px) { + + .spotlights > section > .image { + width: 45%; + } + + .spotlights > section > .content { + width: 55%; + } + + } + + @media screen and (max-width: 980px) { + + .spotlights > section { + display: block; + } + + .spotlights > section > .image { + width: 100%; + } + + .spotlights > section > .content { + padding: 4em 3em 2em 3em ; + width: 100%; + } + + } + + @media screen and (max-width: 736px) { + + .spotlights > section > .content { + padding: 3em 1.5em 1em 1.5em ; + } + + } + +/* Header */ + + @-moz-keyframes reveal-header { + 0% { + top: -4em; + opacity: 0; + } + + 100% { + top: 0; + opacity: 1; + } + } + + @-webkit-keyframes reveal-header { + 0% { + top: -4em; + opacity: 0; + } + + 100% { + top: 0; + opacity: 1; + } + } + + @-ms-keyframes reveal-header { + 0% { + top: -4em; + opacity: 0; + } + + 100% { + top: 0; + opacity: 1; + } + } + + @keyframes reveal-header { + 0% { + top: -4em; + opacity: 0; + } + + 100% { + top: 0; + opacity: 1; + } + } + + #header { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + background-color: #2a2f4a; + box-shadow: 0 0 0.25em 0 rgba(0, 0, 0, 0.15); + cursor: default; + font-weight: 600; + height: 3.25em; + left: 0; + letter-spacing: 0.25em; + line-height: 3.25em; + margin: 0; + position: fixed; + text-transform: uppercase; + top: 0; + width: 100%; + z-index: 10000; + } + + #header .logo { + border: 0; + display: inline-block; + font-size: 0.8em; + height: inherit; + line-height: inherit; + padding: 0 1.5em; + } + + #header .logo strong { + -moz-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + -webkit-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + -ms-transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + background-color: #ffffff; + color: #242943; + display: inline-block; + line-height: 1.65em; + margin-right: 0.325em; + padding: 0 0.125em 0 0.375em; + } + + #header .logo:hover strong { + background-color: #9bf1ff; + } + + #header .logo:active strong { + background-color: #53e3fb; + } + + #header nav { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-justify-content: -moz-flex-end; + -webkit-justify-content: -webkit-flex-end; + -ms-justify-content: -ms-flex-end; + justify-content: flex-end; + -moz-flex-grow: 1; + -webkit-flex-grow: 1; + -ms-flex-grow: 1; + flex-grow: 1; + height: inherit; + line-height: inherit; + } + + #header nav a { + border: 0; + display: block; + font-size: 0.8em; + height: inherit; + line-height: inherit; + padding: 0 0.75em; + position: relative; + vertical-align: middle; + } + + #header nav a:last-child { + padding-right: 1.5em; + } + + #header nav a[href="#menu"] { + padding-right: 3.325em !important; + } + + #header nav a[href="#menu"]:before, #header nav a[href="#menu"]:after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='32' viewBox='0 0 24 32' preserveAspectRatio='none'%3E%3Cstyle%3Eline %7B stroke-width: 2px%3B stroke: %23ffffff%3B %7D%3C/style%3E%3Cline x1='0' y1='11' x2='24' y2='11' /%3E%3Cline x1='0' y1='21' x2='24' y2='21' /%3E%3Cline x1='0' y1='16' x2='24' y2='16' /%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: 24px 32px; + content: ''; + display: block; + height: 100%; + position: absolute; + right: 1.5em; + top: 0; + vertical-align: middle; + width: 24px; + } + + #header nav a[href="#menu"]:after { + -moz-transition: opacity 0.2s ease-in-out; + -webkit-transition: opacity 0.2s ease-in-out; + -ms-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='32' viewBox='0 0 24 32' preserveAspectRatio='none'%3E%3Cstyle%3Eline %7B stroke-width: 2px%3B stroke: %239bf1ff%3B %7D%3C/style%3E%3Cline x1='0' y1='11' x2='24' y2='11' /%3E%3Cline x1='0' y1='21' x2='24' y2='21' /%3E%3Cline x1='0' y1='16' x2='24' y2='16' /%3E%3C/svg%3E"); + opacity: 0; + z-index: 1; + } + + #header nav a[href="#menu"]:hover:after, #header nav a[href="#menu"]:active:after { + opacity: 1; + } + + #header nav a[href="#menu"]:last-child { + padding-right: 3.875em !important; + } + + #header nav a[href="#menu"]:last-child:before, #header nav a[href="#menu"]:last-child:after { + right: 2em; + } + + #header.reveal { + -moz-animation: reveal-header 0.35s ease; + -webkit-animation: reveal-header 0.35s ease; + -ms-animation: reveal-header 0.35s ease; + animation: reveal-header 0.35s ease; + } + + #header.alt { + -moz-transition: opacity 2.5s ease; + -webkit-transition: opacity 2.5s ease; + -ms-transition: opacity 2.5s ease; + transition: opacity 2.5s ease; + -moz-transition-delay: 0.75s; + -webkit-transition-delay: 0.75s; + -ms-transition-delay: 0.75s; + transition-delay: 0.75s; + -moz-animation: none; + -webkit-animation: none; + -ms-animation: none; + animation: none; + background-color: transparent; + box-shadow: none; + position: absolute; + } + + #header.alt.style1 .logo strong { + color: #6fc3df; + } + + #header.alt.style2 .logo strong { + color: #8d82c4; + } + + #header.alt.style3 .logo strong { + color: #ec8d81; + } + + #header.alt.style4 .logo strong { + color: #e7b788; + } + + #header.alt.style5 .logo strong { + color: #8ea9e8; + } + + #header.alt.style6 .logo strong { + color: #87c5a4; + } + + body.is-preload #header.alt { + opacity: 0; + } + + @media screen and (max-width: 1680px) { + + #header nav a[href="#menu"] { + padding-right: 3.75em !important; + } + + #header nav a[href="#menu"]:last-child { + padding-right: 4.25em !important; + } + + } + + @media screen and (max-width: 1280px) { + + #header nav a[href="#menu"] { + padding-right: 4em !important; + } + + #header nav a[href="#menu"]:last-child { + padding-right: 4.5em !important; + } + + } + + @media screen and (max-width: 736px) { + + #header { + height: 2.75em; + line-height: 2.75em; + } + + #header .logo { + padding: 0 1em; + } + + #header nav a { + padding: 0 0.5em; + } + + #header nav a:last-child { + padding-right: 1em; + } + + #header nav a[href="#menu"] { + padding-right: 3.25em !important; + } + + #header nav a[href="#menu"]:before, #header nav a[href="#menu"]:after { + right: 0.75em; + } + + #header nav a[href="#menu"]:last-child { + padding-right: 4em !important; + } + + #header nav a[href="#menu"]:last-child:before, #header nav a[href="#menu"]:last-child:after { + right: 1.5em; + } + + } + + @media screen and (max-width: 480px) { + + #header .logo span { + display: none; + } + + #header nav a[href="#menu"] { + overflow: hidden; + padding-right: 0 !important; + text-indent: 5em; + white-space: nowrap; + width: 5em; + } + + #header nav a[href="#menu"]:before, #header nav a[href="#menu"]:after { + right: 0; + width: inherit; + } + + #header nav a[href="#menu"]:last-child:before, #header nav a[href="#menu"]:last-child:after { + width: 4em; + right: 0; + } + + } + +/* Banner */ + + #banner { + -moz-align-items: center; + -webkit-align-items: center; + -ms-align-items: center; + align-items: center; + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + padding: 12em 0 2em 0 ; + background-attachment: fixed; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-bottom: 0 !important; + cursor: default; + height: 60vh; + margin-bottom: -3.25em; + max-height: 32em; + min-height: 22em; + position: relative; + top: -3.25em; + } + + #banner:after { + -moz-transition: opacity 2.5s ease; + -webkit-transition: opacity 2.5s ease; + -ms-transition: opacity 2.5s ease; + transition: opacity 2.5s ease; + -moz-transition-delay: 0.75s; + -webkit-transition-delay: 0.75s; + -ms-transition-delay: 0.75s; + transition-delay: 0.75s; + pointer-events: none; + background-color: transparent; + content: ''; + display: block; + height: 100%; + left: 0; + opacity: 0.85; + position: absolute; + top: 0; + width: 100%; + z-index: 1; + } + + #banner h1 { + font-size: 2.5em; + } + + #banner > .inner { + -moz-transition: opacity 1.5s ease, -moz-transform 0.5s ease-out, -moz-filter 0.5s ease, -webkit-filter 0.5s ease; + -webkit-transition: opacity 1.5s ease, -webkit-transform 0.5s ease-out, -webkit-filter 0.5s ease, -webkit-filter 0.5s ease; + -ms-transition: opacity 1.5s ease, -ms-transform 0.5s ease-out, -ms-filter 0.5s ease, -webkit-filter 0.5s ease; + transition: opacity 1.5s ease, transform 0.5s ease-out, filter 0.5s ease, -webkit-filter 0.5s ease; + padding: 0 !important; + position: relative; + z-index: 2; + } + + #banner > .inner .image { + display: none; + } + + #banner > .inner header { + width: auto; + } + + #banner > .inner header > :first-child { + width: auto; + } + + #banner > .inner header > :first-child:after { + max-width: 100%; + } + + #banner > .inner .content { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-align-items: center; + -webkit-align-items: center; + -ms-align-items: center; + align-items: center; + margin: 0 0 2em 0; + } + + #banner > .inner .content > * { + margin-right: 1.5em; + margin-bottom: 0; + } + + #banner > .inner .content > :last-child { + margin-right: 0; + } + + #banner > .inner .content p { + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.25em; + text-transform: uppercase; + } + + #banner.major { + height: 75vh; + min-height: 30em; + max-height: 50em; + } + + #banner.major.alt { + opacity: 0.75; + } + + #banner.style1:after { + background-color: #6fc3df; + } + + #banner.style2:after { + background-color: #8d82c4; + } + + #banner.style3:after { + background-color: #ec8d81; + } + + #banner.style4:after { + background-color: #e7b788; + } + + #banner.style5:after { + background-color: #8ea9e8; + } + + #banner.style6:after { + background-color: #87c5a4; + } + + body.is-preload #banner:after { + opacity: 1.0; + } + + body.is-preload #banner > .inner { + -moz-filter: blur(0.125em); + -webkit-filter: blur(0.125em); + -ms-filter: blur(0.125em); + filter: blur(0.125em); + -moz-transform: translateX(-0.5em); + -webkit-transform: translateX(-0.5em); + -ms-transform: translateX(-0.5em); + transform: translateX(-0.5em); + opacity: 0; + } + + @media screen and (max-width: 1280px) { + + #banner { + background-attachment: scroll; + } + + } + + @media screen and (max-width: 736px) { + + #banner { + padding: 5em 0 1em 0 ; + height: auto; + margin-bottom: -2.75em; + max-height: none; + min-height: 0; + top: -2.75em; + } + + #banner h1 { + font-size: 2em; + } + + #banner > .inner .content { + display: block; + } + + #banner > .inner .content > * { + margin-right: 0; + margin-bottom: 2em; + } + + #banner.major { + height: 75vh; + min-height: 0; + max-height: none; + } + + } + + @media screen and (max-width: 480px) { + + #banner { + padding: 6em 0 2em 0 ; + } + + #banner > .inner .content p br { + display: none; + } + + #banner.major { + padding: 8em 0 4em 0 ; + } + + } + +/* Main */ + + #main { + background-color: #2a2f4a; + } + + #main > * { + border-top: solid 1px rgba(212, 212, 255, 0.1); + } + + #main > *:first-child { + border-top: 0; + } + + #main > * > .inner { + padding: 4em 0 2em 0 ; + margin: 0 auto; + max-width: 65em; + width: calc(100% - 6em); + } + + @media screen and (max-width: 736px) { + + #main > * > .inner { + padding: 3em 0 1em 0 ; + width: calc(100% - 3em); + } + + } + + #main.alt { + background-color: transparent; + border-bottom: solid 1px rgba(212, 212, 255, 0.1); + } + +/* Contact */ + + #contact { + border-bottom: solid 1px rgba(212, 212, 255, 0.1); + overflow-x: hidden; + } + + #contact > .inner { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + padding: 0 !important; + } + + #contact > .inner > :nth-child(2n - 1) { + padding: 4em 3em 2em 0 ; + border-right: solid 1px rgba(212, 212, 255, 0.1); + width: 60%; + } + + #contact > .inner > :nth-child(2n) { + padding-left: 3em; + width: 40%; + } + + #contact > .inner > .split { + padding: 0; + } + + #contact > .inner > .split > * { + padding: 3em 0 1em 3em ; + position: relative; + } + + #contact > .inner > .split > *:before { + border-top: solid 1px rgba(212, 212, 255, 0.1); + content: ''; + display: block; + margin-left: -3em; + position: absolute; + top: 0; + width: calc(100vw + 3em); + } + + #contact > .inner > .split > :first-child:before { + display: none; + } + + @media screen and (max-width: 980px) { + + #contact > .inner { + display: block; + } + + #contact > .inner > :nth-child(2n - 1) { + padding: 4em 0 2em 0 ; + border-right: 0; + width: 100%; + } + + #contact > .inner > :nth-child(2n) { + padding-left: 0; + width: 100%; + } + + #contact > .inner > .split > * { + padding: 3em 0 1em 0 ; + } + + #contact > .inner > .split > :first-child:before { + display: block; + } + + } + + @media screen and (max-width: 736px) { + + #contact > .inner > :nth-child(2n - 1) { + padding: 3em 0 1em 0 ; + } + + } + +/* Footer */ + + #footer .copyright { + font-size: 0.8em; + list-style: none; + padding-left: 0; + } + + #footer .copyright li { + border-left: solid 1px rgba(212, 212, 255, 0.1); + color: rgba(244, 244, 255, 0.2); + display: inline-block; + line-height: 1; + margin-left: 1em; + padding-left: 1em; + } + + #footer .copyright li:first-child { + border-left: 0; + margin-left: 0; + padding-left: 0; + } + + @media screen and (max-width: 480px) { + + #footer .copyright li { + display: block; + border-left: 0; + margin-left: 0; + padding-left: 0; + line-height: inherit; + } + + } + +/* Wrapper */ + + #wrapper { + -moz-transition: -moz-filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; + -webkit-transition: -webkit-filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; + -ms-transition: -ms-filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; + transition: filter 0.35s ease, -webkit-filter 0.35s ease, opacity 0.375s ease-out; + padding-top: 3.25em; + height: 100vh; + } + + #wrapper.is-transitioning { + opacity: 0; + } + + #wrapper > * > .inner { + padding: 4em 0 2em 0 ; + margin: 0 auto; + max-width: 65em; + width: calc(100% - 6em); + } + + @media screen and (max-width: 736px) { + + #wrapper > * > .inner { + padding: 3em 0 1em 0 ; + width: calc(100% - 3em); + } + + } + + @media screen and (max-width: 736px) { + + #wrapper { + padding-top: 2.75em; + } + + } + +/* Menu */ + + #menu { + -moz-transition: -moz-transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; + -webkit-transition: -webkit-transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; + -ms-transition: -ms-transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; + transition: transform 0.35s ease, opacity 0.35s ease, visibility 0.35s; + -moz-align-items: center; + -webkit-align-items: center; + -ms-align-items: center; + align-items: center; + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-justify-content: center; + -webkit-justify-content: center; + -ms-justify-content: center; + justify-content: center; + pointer-events: none; + background: rgb(0 0 0 / 50%);; + box-shadow: none; + height: 100%; + left: 0; + opacity: 0; + overflow: hidden; + padding: 3em 2em; + position: fixed; + top: 0; + visibility: hidden; + width: 100%; + z-index: 10002; + } + + #menu .inner { + -moz-transition: -moz-transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; + -webkit-transition: -webkit-transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; + -ms-transition: -ms-transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; + transition: transform 0.35s ease-out, opacity 0.35s ease, visibility 0.35s; + -moz-transform: rotateX(20deg); + -webkit-transform: rotateX(20deg); + -ms-transform: rotateX(20deg); + transform: rotateX(20deg); + -webkit-overflow-scrolling: touch; + max-width: 100%; + max-height: 100vh; + opacity: 0; + overflow: auto; + text-align: center; + visibility: hidden; + width: 18em; + } + + #menu .inner > :first-child { + margin-top: 2em; + } + + #menu .inner > :last-child { + margin-bottom: 3em; + } + + #menu ul { + margin: 0 0 1em 0; + } + + #menu ul.links { + list-style: none; + padding: 0; + } + + #menu ul.links > li { + padding: 0; + } + + #menu ul.links > li > a:not(.button) { + border: 0; + border-top: solid 1px rgba(212, 212, 255, 0.1); + display: block; + font-size: 0.8em; + font-weight: 600; + letter-spacing: 0.25em; + line-height: 4em; + text-decoration: none; + text-transform: uppercase; + } + + #menu ul.links > li > .button { + display: block; + margin: 0.5em 0 0 0; + } + + #menu ul.links > li:first-child > a:not(.button) { + border-top: 0 !important; + } + + #menu .close { + -moz-transition: color 0.2s ease-in-out; + -webkit-transition: color 0.2s ease-in-out; + -ms-transition: color 0.2s ease-in-out; + transition: color 0.2s ease-in-out; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + border: 0; + cursor: pointer; + display: block; + height: 4em; + line-height: 4em; + overflow: hidden; + padding-right: 1.25em; + position: absolute; + right: 0; + text-align: right; + text-indent: 8em; + top: 0; + vertical-align: middle; + white-space: nowrap; + width: 8em; + } + + #menu .close:before, #menu .close:after { + -moz-transition: opacity 0.2s ease-in-out; + -webkit-transition: opacity 0.2s ease-in-out; + -ms-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + background-position: center; + background-repeat: no-repeat; + content: ''; + display: block; + height: 4em; + position: absolute; + right: 0; + top: 0; + width: 4em; + } + + #menu .close:before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='20px' height='20px' viewBox='0 0 20 20' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %23ffffff%3B stroke-width: 2%3B %7D%3C/style%3E%3Cline x1='0' y1='0' x2='20' y2='20' /%3E%3Cline x1='20' y1='0' x2='0' y2='20' /%3E%3C/svg%3E"); + } + + #menu .close:after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='20px' height='20px' viewBox='0 0 20 20' zoomAndPan='disable'%3E%3Cstyle%3Eline %7B stroke: %239bf1ff%3B stroke-width: 2%3B %7D%3C/style%3E%3Cline x1='0' y1='0' x2='20' y2='20' /%3E%3Cline x1='20' y1='0' x2='0' y2='20' /%3E%3C/svg%3E"); + opacity: 0; + } + + #menu .close:hover:after, #menu .close:active:after { + opacity: 1; + } + + body.is-ie #menu { + background: rgba(42, 47, 74, 0.975); + } + + body.is-menu-visible #wrapper { + -moz-filter: blur(0.5em); + -webkit-filter: blur(0.5em); + -ms-filter: blur(0.5em); + filter: blur(0.5em); + } + + body.is-menu-visible #menu { + pointer-events: auto; + opacity: 1; + visibility: visible; + } + + body.is-menu-visible #menu .inner { + -moz-transform: none; + -webkit-transform: none; + -ms-transform: none; + transform: none; + opacity: 1; + visibility: visible; + } \ No newline at end of file diff --git a/app/src/main/assets/web/assets/js/dist.js b/app/src/main/assets/web/assets/js/dist.js new file mode 100644 index 000000000..90e42ac81 --- /dev/null +++ b/app/src/main/assets/web/assets/js/dist.js @@ -0,0 +1,8 @@ +/*! + * Powered by uglifiyJS v2.6.1, Build by http://tool.uis.cc/jsmin/ + * build time: Sun Jul 25 2021 19:58:00 GMT+0800 (中国标准时间) +*/ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&t>0&&t-1 in e)}function A(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1c;c++)(r=e[c]).style&&(n=r.style.display,t?("none"===n&&(l[c]=Q.get(r,"display")||null,l[c]||(r.style.display="")),""===r.style.display&&se(r)&&(l[c]=(u=a=o=void 0,a=(i=r).ownerDocument,s=i.nodeName,(u=ce[s])||(o=a.body.appendChild(a.createElement(s)),u=k.css(o,"display"),o.parentNode.removeChild(o),"none"===u&&(u="block"),ce[s]=u)))):"none"!==n&&(l[c]="none",Q.set(r,"display",n)));for(c=0;f>c;c++)null!=l[c]&&(e[c].style.display=l[c]);return e}function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;r>n;n++)Q.set(e[n],"globalEval",!t||Q.get(t[n],"globalEval"))}function we(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;h>d;d++)if((o=e[d])||0===o)if("object"===w(o))k.merge(p,o.nodeType?[o]:o);else if(be.test(o)){for(a=a||f.appendChild(t.createElement("div")),s=(de.exec(o)||["",""])[1].toLowerCase(),u=ge[s]||ge._default,a.innerHTML=u[1]+k.htmlPrefilter(o)+u[2],c=u[0];c--;)a=a.lastChild;k.merge(p,a.childNodes),(a=f.firstChild).textContent=""}else p.push(t.createTextNode(o));for(f.textContent="",d=0;o=p[d++];)if(r&&-1n;n++)k.event.add(t,i,l[i][n]);J.hasData(e)&&(s=J.access(e),u=k.extend({},s),J.set(t,u))}}function Ie(n,r,i,o){r=g.apply([],r);var e,t,a,s,u,l,c=0,f=n.length,p=f-1,d=r[0],h=m(d);if(h||f>1&&"string"==typeof d&&!y.checkClone&&Le.test(d))return n.each(function(e){var t=n.eq(e);h&&(r[0]=d.call(this,e,t.html())),Ie(t,r,i,o)});if(f&&(t=(e=we(r,n[0].ownerDocument,!1,n,o)).firstChild,1===e.childNodes.length&&(e=t),t||o)){for(s=(a=k.map(ve(e,"script"),Pe)).length;f>c;c++)u=e,c!==p&&(u=k.clone(u,!0,!0),s&&k.merge(a,ve(u,"script"))),i.call(n[c],u,c);if(s)for(l=a[a.length-1].ownerDocument,k.map(a,Re),c=0;s>c;c++)u=a[c],he.test(u.type||"")&&!Q.access(u,"globalEval")&&k.contains(l,u)&&(u.src&&"module"!==(u.type||"").toLowerCase()?k._evalUrl&&!u.noModule&&k._evalUrl(u.src,{nonce:u.nonce||u.getAttribute("nonce")}):b(u.textContent.replace(He,""),u,l))}return n}function We(e,t,n){for(var r,i=t?k.filter(t,e):e,o=0;null!=(r=i[o]);o++)n||1!==r.nodeType||k.cleanData(ve(r)),r.parentNode&&(n&&oe(r)&&ye(ve(r,"script")),r.parentNode.removeChild(r));return e}function _e(e,t,n){var r,i,o,a,s=e.style;return(n=n||Fe(e))&&(""!==(a=n.getPropertyValue(t)||n[t])||oe(e)||(a=k.style(e,t)),!y.pixelBoxStyles()&&$e.test(a)&&Be.test(t)&&(r=s.width,i=s.minWidth,o=s.maxWidth,s.minWidth=s.maxWidth=s.width=a,a=n.width,s.width=r,s.minWidth=i,s.maxWidth=o)),void 0!==a?a+"":a}function ze(e,t){return{get:function(){return e()?void delete this.get:(this.get=t).apply(this,arguments)}}}function Ge(e){var t=k.cssProps[e]||Ve[e];return t||(e in Xe?e:Ve[e]=function(e){for(var t=e[0].toUpperCase()+e.slice(1),n=Ue.length;n--;)if((e=Ue[n]+t)in Xe)return e}(e)||e)}function Ze(e,t,n){var r=ne.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||"px"):t}function et(e,t,n,r,i,o){var a="width"===t?1:0,s=0,u=0;if(n===(r?"border":"content"))return 0;for(;4>a;a+=2)"margin"===n&&(u+=k.css(e,n+re[a],!0,i)),r?("content"===n&&(u-=k.css(e,"padding"+re[a],!0,i)),"margin"!==n&&(u-=k.css(e,"border"+re[a]+"Width",!0,i))):(u+=k.css(e,"padding"+re[a],!0,i),"padding"!==n?u+=k.css(e,"border"+re[a]+"Width",!0,i):s+=k.css(e,"border"+re[a]+"Width",!0,i));return!r&&o>=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))||0),u}function tt(e,t,n){var r=Fe(e),i=(!y.boxSizingReliable()||n)&&"border-box"===k.css(e,"boxSizing",!1,r),o=i,a=_e(e,t,r),s="offset"+t[0].toUpperCase()+t.slice(1);if($e.test(a)){if(!n)return a;a="auto"}return(!y.boxSizingReliable()&&i||"auto"===a||!parseFloat(a)&&"inline"===k.css(e,"display",!1,r))&&e.getClientRects().length&&(i="border-box"===k.css(e,"boxSizing",!1,r),(o=s in e)&&(a=e[s])),(a=parseFloat(a)||0)+et(e,t,n||(i?"border":"content"),o,r,a)+"px"}function nt(e,t,n,r,i){return new nt.prototype.init(e,t,n,r,i)}function lt(){it&&(!1===E.hidden&&C.requestAnimationFrame?C.requestAnimationFrame(lt):C.setTimeout(lt,k.fx.interval),k.fx.tick())}function ct(){return C.setTimeout(function(){rt=void 0}),rt=Date.now()}function ft(e,t){var n,r=0,i={height:e};for(t=t?1:0;4>r;r+=2-t)i["margin"+(n=re[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function pt(e,t,n){for(var r,i=(dt.tweeners[t]||[]).concat(dt.tweeners["*"]),o=0,a=i.length;a>o;o++)if(r=i[o].call(n,t,e))return r}function dt(o,e,t){var n,a,r=0,i=dt.prefilters.length,s=k.Deferred().always(function(){delete u.elem}),u=function(){if(a)return!1;for(var e=rt||ct(),t=Math.max(0,l.startTime+l.duration-e),n=1-(t/l.duration||0),r=0,i=l.tweens.length;i>r;r++)l.tweens[r].run(n);return s.notifyWith(o,[l,n,t]),1>n&&i?t:(i||s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l]),!1)},l=s.promise({elem:o,props:k.extend({},e),opts:k.extend(!0,{specialEasing:{},easing:k.easing._default},t),originalProperties:e,originalOptions:t,startTime:rt||ct(),duration:t.duration,tweens:[],createTween:function(e,t){var n=k.Tween(o,l.opts,e,t,l.opts.specialEasing[e]||l.opts.easing);return l.tweens.push(n),n},stop:function(e){var t=0,n=e?l.tweens.length:0;if(a)return this;for(a=!0;n>t;t++)l.tweens[t].run(1);return e?(s.notifyWith(o,[l,1,0]),s.resolveWith(o,[l,e])):s.rejectWith(o,[l,e]),this}}),c=l.props;for((!function(e,t){var n,r,i,o,a;for(n in e)if(i=t[r=V(n)],o=e[n],Array.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),(a=k.cssHooks[r])&&"expand"in a)for(n in o=a.expand(o),delete e[r],o)n in e||(e[n]=o[n],t[n]=i);else t[r]=i}(c,l.opts.specialEasing));i>r;r++)if(n=dt.prefilters[r].call(l,o,c,l.opts))return m(n.stop)&&(k._queueHooks(l.elem,l.opts.queue).stop=n.stop.bind(n)),n;return k.map(c,pt,l),m(l.opts.start)&&l.opts.start.call(o,l),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always),k.fx.timer(k.extend(u,{elem:o,anim:l,queue:l.opts.queue})),l}function mt(e){return(e.match(R)||[]).join(" ")}function xt(e){return e.getAttribute&&e.getAttribute("class")||""}function bt(e){return Array.isArray(e)?e:"string"==typeof e&&e.match(R)||[]}function qt(n,e,r,i){var t;if(Array.isArray(e))k.each(e,function(e,t){r||Nt.test(n)?i(n,t):qt(n+"["+("object"==typeof t&&null!=t?e:"")+"]",t,r,i)});else if(r||"object"!==w(e))i(n,e);else for(t in e)qt(n+"["+t+"]",e[t],r,i)}function Bt(o){return function(e,t){"string"!=typeof e&&(t=e,e="*");var n,r=0,i=e.toLowerCase().match(R)||[];if(m(t))for(;n=i[r++];)"+"===n[0]?(n=n.slice(1)||"*",(o[n]=o[n]||[]).unshift(t)):(o[n]=o[n]||[]).push(t)}}function _t(t,i,o,a){function l(e){var r;return s[e]=!0,k.each(t[e]||[],function(e,t){var n=t(i,o,a);return"string"!=typeof n||u||s[n]?u?!(r=n):void 0:(i.dataTypes.unshift(n),l(n),!1)}),r}var s={},u=t===Wt;return l(i.dataTypes[0])||!s["*"]&&l("*")}function zt(e,t){var n,r,i=k.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&k.extend(!0,e,r),e}var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0},f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;k.fn=k.prototype={jquery:f,constructor:k,length:0,toArray:function(){return s.call(this)},get:function(e){return null==e?s.call(this):0>e?this[e+this.length]:this[e]},pushStack:function(e){var t=k.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return k.each(this,e)},map:function(n){return this.pushStack(k.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(s.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:u,sort:t.sort,splice:t.splice},k.extend=k.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||m(a)||(a={}),s===u&&(a=this,s--);u>s;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(k.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||k.isPlainObject(n)?n:{},i=!1,a[t]=k.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},k.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==o.call(e)||(t=r(e))&&("function"!=typeof(n=v.call(t,"constructor")&&t.constructor)||a.call(n)!==l))},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t){b(e,{nonce:t&&t.nonce})},each:function(e,t){var n,r=0;if(d(e))for(n=e.length;n>r&&!1!==t.call(e[r],r,e[r]);r++);else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},trim:function(e){return null==e?"":(e+"").replace(p,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(d(Object(e))?k.merge(n,"string"==typeof e?[e]:e):u.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:i.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;n>r;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;o>i;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(d(e))for(r=e.length;r>o;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g.apply([],a)},guid:1,support:y}),"function"==typeof Symbol&&(k.fn[Symbol.iterator]=t[Symbol.iterator]),k.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var h=function(n){function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){for((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;o--;)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){for(var n=e.split("|"),r=n.length;r--;)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)for(;n=n.nextSibling;)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){for(var n,r=a([],e.length,o),i=r.length;i--;)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function me(){}function xe(e){for(var t=0,n=e.length,r="";n>t;t++)r+=e[t].value;return r}function be(s,e,t){var u=e.dir,l=e.next,c=l||u,f=t&&"parentNode"===c,p=r++;return e.first?function(e,t,n){for(;e=e[u];)if(1===e.nodeType||f)return s(e,t,n);return!1}:function(e,t,n){var r,i,o,a=[S,p];if(n){for(;e=e[u];)if((1===e.nodeType||f)&&s(e,t,n))return!0}else for(;e=e[u];)if(1===e.nodeType||f)if(i=(o=e[k]||(e[k]={}))[e.uniqueID]||(o[e.uniqueID]={}),l&&l===e.nodeName.toLowerCase())e=e[u]||e;else{if((r=i[c])&&r[0]===S&&r[1]===p)return a[2]=r[2];if((i[c]=a)[2]=s(e,t,n))return!0}return!1}}function we(i){return 1s;s++)(o=e[s])&&(n&&!n(o,r,i)||(a.push(o),l&&t.push(s)));return a}function Ce(d,h,g,v,y,e){return v&&!v[k]&&(v=Ce(v)),y&&!y[k]&&(y=Ce(y,e)),le(function(e,t,n,r){var i,o,a,s=[],u=[],l=t.length,c=e||function(e,t,n){for(var r=0,i=t.length;i>r;r++)se(e,t[r],n);return n}(h||"*",n.nodeType?[n]:n,[]),f=!d||!e&&h?c:Te(c,s,d,n,r),p=g?y||(e?d:l||v)?[]:t:f;if(g&&g(f,p,n,r),v)for(i=Te(p,u),v(i,[],n,r),o=i.length;o--;)(a=i[o])&&(p[u[o]]=!(f[u[o]]=a));if(e){if(y||d){if(y){for(i=[],o=p.length;o--;)(a=p[o])&&i.push(f[o]=a);y(null,p=[],i,r)}for(o=p.length;o--;)(a=p[o])&&-1<(i=y?P(e,a):s[o])&&(e[i]=!(t[i]=a))}}else p=Te(p===t?p.splice(l,p.length):p),y?y(null,t,p,r):H.apply(t,p)})}function Ee(e){for(var i,t,n,r=e.length,o=b.relative[e[0].type],a=o||b.relative[" "],s=o?1:0,u=be(function(e){return e===i},a,!0),l=be(function(e){return-1s;s++)if(t=b.relative[e[s].type])c=[be(we(c),t)];else{if((t=b.filter[e[s].type].apply(null,e[s].matches))[k]){for(n=++s;r>n&&!b.relative[e[n].type];n++);return Ce(s>1&&we(c),s>1&&xe(e.slice(0,s-1).concat({value:" "===e[s-2].type?"*":""})).replace(B,"$1"),t,n>s&&Ee(e.slice(s,n)),r>n&&Ee(e=e.slice(n)),r>n&&xe(e))}c.push(t)}return we(c)}var e,d,b,o,i,h,f,g,w,u,l,T,C,a,E,v,s,c,y,k="sizzle"+1*new Date,m=n.document,S=0,r=0,p=ue(),x=ue(),N=ue(),A=ue(),D=function(e,t){return e===t&&(l=!0),0},j={}.hasOwnProperty,t=[],q=t.pop,L=t.push,H=t.push,O=t.slice,P=function(e,t){for(var n=0,r=e.length;r>n;n++)if(e[n]===t)return n;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",I="(?:\\\\.|[\\w-]|[^\x00-\\xa0])+",W="\\["+M+"*("+I+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+I+"))|)"+M+"*\\]",$=":("+I+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+W+")*)|.*)\\)|)",F=new RegExp(M+"+","g"),B=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=new RegExp("^"+M+"*,"+M+"*"),z=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:0>r?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\x00"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){for(var n=e.length,r=0;e[n++]=t[r++];);e.length=n-1}}}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];for(i=t.getElementsByName(e),r=0;o=i[r++];)if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){for(;n=o[i++];)1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){return"undefined"!=typeof t.getElementsByClassName&&E?t.getElementsByClassName(e):void 0},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);for(n=e;n=n.parentNode;)a.unshift(n);for(n=t;n=n.parentNode;)s.unshift(n);for(;a[r]===s[r];)r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1=0}}},PSEUDO:function(e,o){var t,a=b.pseudos[e]||b.setFilters[e.toLowerCase()]||se.error("unsupported pseudo: "+e);return a[k]?a(o):1n?n+t:n]}),even:ve(function(e,t){for(var n=0;t>n;n+=2)e.push(n);return e}),odd:ve(function(e,t){for(var n=1;t>n;n+=2)e.push(n);return e}),lt:ve(function(e,t,n){for(var r=0>n?n+t:n>t?t:n;0<=--r;)e.push(r);return e}),gt:ve(function(e,t,n){for(var r=0>n?n+t:n;++r0)for(;l--;)c[l]||f[l]||(f[l]=q.call(r));f=Te(f)}H.apply(r,f),i&&!e&&0:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;k.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?k.find.matchesSelector(r,e)?[r]:[]:k.find.matches(e,k.grep(t,function(e){return 1===e.nodeType}))},k.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(k(e).filter(function(){for(t=0;r>t;t++)if(k.contains(i[t],this))return!0}));for(n=this.pushStack([]),t=0;r>t;t++)k.find(e,i[t],n);return r>1?k.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&N.test(e)?k(e):e||[],!1).length}});var q,L=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;n>e;e++)if(k.contains(this,t[e]))return!0})},closest:function(e,t){var n,r=0,i=this.length,o=[],a="string"!=typeof e&&k(e);if(!N.test(e))for(;i>r;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(n.nodeType<11&&(a?-1=n&&l--}),this},has:function(e){return e?-1i)){if((e=a.apply(n,r))===o.promise())throw new TypeError("Thenable self-resolution");t=e&&("object"==typeof e||"function"==typeof e)&&e.then,m(t)?s?t.call(e,l(u,o,M,s),l(u,o,I,s)):(u++,t.call(e,l(u,o,M,s),l(u,o,I,s),l(u,o,M,o.notifyWith))):(a!==M&&(n=void 0,r=[e]),(s||o.resolveWith)(n,r))}},t=s?e:function(){try{e()}catch(e){k.Deferred.exceptionHook&&k.Deferred.exceptionHook(e,t.stackTrace),i+1>=u&&(a!==I&&(n=void 0,r=[e]),o.rejectWith(n,r))}};i?t():(k.Deferred.getStackHook&&(t.stackTrace=k.Deferred.getStackHook()),C.setTimeout(t))}}var u=0;return k.Deferred(function(e){o[0][3].add(l(0,e,m(r)?r:M,e.notifyWith)),o[1][3].add(l(0,e,m(t)?t:M)),o[2][3].add(l(0,e,m(n)?n:I))}).promise()},promise:function(e){return null!=e?k.extend(e,a):a}},s={};return k.each(o,function(e,t){var n=t[2],r=t[5];a[t[1]]=n.add,r&&n.add(function(){i=r},o[3-e][2].disable,o[3-e][3].disable,o[0][2].lock,o[0][3].lock),n.add(t[3].fire),s[t[0]]=function(){return s[t[0]+"With"](this===s?void 0:this,arguments),this},s[t[0]+"With"]=n.fireWith}),a.promise(s),e&&e.call(s,s),s},when:function(e){var n=arguments.length,t=n,r=Array(t),i=s.call(arguments),o=k.Deferred(),a=function(t){return function(e){r[t]=this,i[t]=1=n&&(W(e,o.done(a(t)).resolve,o.reject,!n),"pending"===o.state()||m(i[t]&&i[t].then)))return o.then();for(;t--;)W(i[t],a(t),o.reject);return o.promise()}});var $=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;k.Deferred.exceptionHook=function(e,t){C.console&&C.console.warn&&e&&$.test(e.name)&&C.console.warn("jQuery.Deferred exception: "+e.message,e.stack,t)},k.readyException=function(e){C.setTimeout(function(){throw e})};var F=k.Deferred();k.fn.ready=function(e){return F.then(e)["catch"](function(e){k.readyException(e)}),this},k.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--k.readyWait:k.isReady)||(k.isReady=!0)!==e&&0<--k.readyWait||F.resolveWith(E,[k])}}),k.ready.then=F.then,"complete"===E.readyState||"loading"!==E.readyState&&!E.documentElement.doScroll?C.setTimeout(k.ready):(E.addEventListener("DOMContentLoaded",B),C.addEventListener("load",B));var _=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===w(n))for(s in i=!0,n)_(e,t,s,n[s],!0,o,a);else if(void 0!==r&&(i=!0,m(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(k(e),n)})),t))for(;u>s;s++)t(e[s],n,a?r:r.call(e[s],s,t(e[s],n)));return i?e:l?t.call(e):u?t(e[0],n):o},z=/^-ms-/,U=/-([a-z])/g,G=function(e){return 1===e.nodeType||9===e.nodeType||!+e.nodeType};Y.uid=1,Y.prototype={cache:function(e){var t=e[this.expando];return t||(t={},G(e)&&(e.nodeType?e[this.expando]=t:Object.defineProperty(e,this.expando,{value:t,configurable:!0}))),t},set:function(e,t,n){var r,i=this.cache(e);if("string"==typeof t)i[V(t)]=n;else for(r in t)i[V(r)]=t[r];return i},get:function(e,t){return void 0===t?this.cache(e):e[this.expando]&&e[this.expando][V(t)]},access:function(e,t,n){return void 0===t||t&&"string"==typeof t&&void 0===n?this.get(e,t):(this.set(e,t,n),void 0!==n?n:t)},remove:function(e,t){var n,r=e[this.expando];if(void 0!==r){if(void 0!==t){n=(t=Array.isArray(t)?t.map(V):(t=V(t))in r?[t]:t.match(R)||[]).length;for(;n--;)delete r[t[n]]}(void 0===t||k.isEmptyObject(r))&&(e.nodeType?e[this.expando]=void 0:delete e[this.expando])}},hasData:function(e){var t=e[this.expando];return void 0!==t&&!k.isEmptyObject(t)}};var Q=new Y,J=new Y,K=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,Z=/[A-Z]/g;k.extend({hasData:function(e){return J.hasData(e)||Q.hasData(e)},data:function(e,t,n){return J.access(e,t,n)},removeData:function(e,t){J.remove(e,t)},_data:function(e,t,n){return Q.access(e,t,n)},_removeData:function(e,t){Q.remove(e,t)}}),k.fn.extend({data:function(n,e){var t,r,i,o=this[0],a=o&&o.attributes;if(void 0===n){if(this.length&&(i=J.get(o),1===o.nodeType&&!Q.get(o,"hasDataAttrs"))){for(t=a.length;t--;)a[t]&&0===(r=a[t].name).indexOf("data-")&&(r=V(r.slice(5)),ee(o,r,i[r]));Q.set(o,"hasDataAttrs",!0)}return i}return"object"==typeof n?this.each(function(){J.set(this,n)}):_(this,function(e){var t;return o&&void 0===e?void 0!==(t=J.get(o,n))?t:void 0!==(t=ee(o,n))?t:void 0:void this.each(function(){J.set(this,n,e)})},null,e,1\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;var me,xe,be=/<|&#?\w+;/;me=E.createDocumentFragment().appendChild(E.createElement("div")),(xe=E.createElement("input")).setAttribute("type","radio"),xe.setAttribute("checked","checked"),xe.setAttribute("name","t"),me.appendChild(xe),y.checkClone=me.cloneNode(!0).cloneNode(!0).lastChild.checked,me.innerHTML="",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v)for(n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;l--;)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){for(l=(t=(t||"").match(R)||[""]).length;l--;)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){for(f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;o--;)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;tn;n++)void 0===a[i=(r=t[n]).selector+" "]&&(a[i]=r.needsContext?-1\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;k.extend({htmlPrefilter:function(e){return e.replace(je,"<$1>")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;i>r;r++)s=o[r],u=a[r],"input"===(l=u.nodeName.toLowerCase())&&pe.test(s.type)?u.checked=s.checked:"input"!==l&&"textarea"!==l||(u.defaultValue=s.defaultValue);if(t)if(n)for(o=o||ve(e),a=a||ve(c),r=0,i=o.length;i>r;r++)Me(o[r],a[r]);else Me(e,c);return 0<(a=ve(c,"script")).length&&ye(a,!f&&ve(e,"script")),c},cleanData:function(e){for(var t,n,r,i=k.event.special,o=0;void 0!==(n=e[o]);o++)if(G(n)){if(t=n[Q.expando]){if(t.events)for(r in t.events)i[r]?k.event.remove(n,r):k.removeEvent(n,r,t.handle);n[Q.expando]=void 0}n[J.expando]&&(n[J.expando]=void 0)}}}),k.fn.extend({detach:function(e){return We(this,e,!0)},remove:function(e){return We(this,e)},text:function(e){return _(this,function(e){return void 0===e?k.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Ie(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Oe(this,e).appendChild(e)})},prepend:function(){return Ie(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Oe(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Ie(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Ie(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(k.cleanData(ve(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return k.clone(this,e,t)})},html:function(e){return _(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!qe.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=k.htmlPrefilter(e);try{for(;r>n;n++)1===(t=this[n]||{}).nodeType&&(k.cleanData(ve(t,!1)),t.innerHTML=e);t=0}catch(e){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var n=[];return Ie(this,arguments,function(e){var t=this.parentNode;k.inArray(this,n)<0&&(k.cleanData(ve(this)),t&&t.replaceChild(e,this))},n)}}),k.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,a){k.fn[e]=function(e){for(var t,n=[],r=k(e),i=r.length-1,o=0;i>=o;o++)t=o===i?this:this.clone(!0),k(r[o])[a](t),u.apply(n,t.get());return this.pushStack(n)}});var $e=new RegExp("^("+te+")(?!px)[a-z%]+$","i"),Fe=function(e){var t=e.ownerDocument.defaultView;return t&&t.opener||(t=C),t.getComputedStyle(e)},Be=new RegExp(re.join("|"),"i");!function(){function e(){if(u){s.style.cssText="position:absolute;left:-11111px;width:60px;margin-top:1px;padding:0;border:0",u.style.cssText="position:relative;display:block;box-sizing:border-box;overflow:scroll;margin:auto;border:1px;padding:1px;width:60%;top:1%",ie.appendChild(s).appendChild(u);var e=C.getComputedStyle(u);n="1%"!==e.top,a=12===t(e.marginLeft),u.style.right="60%",o=36===t(e.right),r=36===t(e.width),u.style.position="absolute",i=12===t(u.offsetWidth/3),ie.removeChild(s),u=null}}function t(e){return Math.round(parseFloat(e))}var n,r,i,o,a,s=E.createElement("div"),u=E.createElement("div");u.style&&(u.style.backgroundClip="content-box",u.cloneNode(!0).style.backgroundClip="",y.clearCloneStyle="content-box"===u.style.backgroundClip,k.extend(y,{boxSizingReliable:function(){return e(),r},pixelBoxStyles:function(){return e(),o},pixelPosition:function(){return e(),n},reliableMarginLeft:function(){return e(),a},scrollboxSize:function(){return e(),i}}))}();var Ue=["Webkit","Moz","ms"],Xe=E.createElement("div").style,Ve={},Ye=/^(none|table(?!-c[ea]).+)/,Qe=/^--/,Je={position:"absolute",visibility:"hidden",display:"block"},Ke={letterSpacing:"0",fontWeight:"400"};k.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=_e(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=V(t),u=Qe.test(t),l=e.style;if(u||(t=Ge(s)),a=k.cssHooks[t]||k.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ne.exec(n))&&i[1]&&(n=le(e,t,i),o="number"),null!=n&&n==n&&("number"!==o||u||(n+=i&&i[3]||(k.cssNumber[s]?"":"px")),y.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=V(t);return Qe.test(t)||(t=Ge(s)),(a=k.cssHooks[t]||k.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=_e(e,t,r)),"normal"===i&&t in Ke&&(i=Ke[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),k.each(["height","width"],function(e,u){k.cssHooks[u]={get:function(e,t,n){return t?!Ye.test(k.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?tt(e,u,n):ue(e,Je,function(){return tt(e,u,n)}):void 0},set:function(e,t,n){var r,i=Fe(e),o=!y.scrollboxSize()&&"absolute"===i.position,a=(o||n)&&"border-box"===k.css(e,"boxSizing",!1,i),s=n?et(e,u,n,a,i):0;return a&&o&&(s-=Math.ceil(e["offset"+u[0].toUpperCase()+u.slice(1)]-parseFloat(i[u])-et(e,u,"border",!1,i)-.5)),s&&(r=ne.exec(t))&&"px"!==(r[3]||"px")&&(e.style[u]=t,t=k.css(e,u)),Ze(0,t,s)}}}),k.cssHooks.marginLeft=ze(y.reliableMarginLeft,function(e,t){return t?(parseFloat(_e(e,"marginLeft"))||e.getBoundingClientRect().left-ue(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px":void 0}),k.each({margin:"",padding:"",border:"Width"},function(i,o){k.cssHooks[i+o]={expand:function(e){for(var t=0,n={},r="string"==typeof e?e.split(" "):[e];4>t;t++)n[i+re[t]+o]=r[t]||r[t-2]||r[0];return n}},"margin"!==i&&(k.cssHooks[i+o].set=Ze)}),k.fn.extend({css:function(e,t){return _(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=Fe(e),i=t.length;i>a;a++)o[t[a]]=k.css(e,t[a],!1,r);return o}return void 0!==n?k.style(e,t,n):k.css(e,t)},e,t,1r;r++)n=e[r],dt.tweeners[n]=dt.tweeners[n]||[],dt.tweeners[n].unshift(t)},prefilters:[function(e,t,n){var r,i,o,a,s,u,l,c,f="width"in t||"height"in t,p=this,d={},h=e.style,g=e.nodeType&&se(e),v=Q.get(e,"fxshow");for(r in n.queue||(null==(a=k._queueHooks(e,"fx")).unqueued&&(a.unqueued=0,s=a.empty.fire,a.empty.fire=function(){a.unqueued||s()}),a.unqueued++,p.always(function(){p.always(function(){a.unqueued--,k.queue(e,"fx").length||a.empty.fire()})})),t)if(i=t[r],st.test(i)){if(delete t[r],o=o||"toggle"===i,i===(g?"hide":"show")){if("show"!==i||!v||void 0===v[r])continue;g=!0}d[r]=v&&v[r]||k.style(e,r)}if((u=!k.isEmptyObject(t))||!k.isEmptyObject(d))for(r in f&&1===e.nodeType&&(n.overflow=[h.overflow,h.overflowX,h.overflowY],null==(l=v&&v.display)&&(l=Q.get(e,"display")),"none"===(c=k.css(e,"display"))&&(l?c=l:(fe([e],!0),l=e.style.display||l,c=k.css(e,"display"),fe([e]))),("inline"===c||"inline-block"===c&&null!=l)&&"none"===k.css(e,"float")&&(u||(p.done(function(){h.display=l}),null==l&&(c=h.display,l="none"===c?"":c)),h.display="inline-block")),n.overflow&&(h.overflow="hidden",p.always(function(){h.overflow=n.overflow[0],h.overflowX=n.overflow[1],h.overflowY=n.overflow[2]})),u=!1,d)u||(v?"hidden"in v&&(g=v.hidden):v=Q.access(e,"fxshow",{display:l}),o&&(v.hidden=!g),g&&fe([e],!0),p.done(function(){for(r in g||fe([e]),Q.remove(e,"fxshow"),d)k.style(e,r,d[r])})),u=pt(g?v[r]:0,r,p),r in v||(v[r]=u.start,g&&(u.end=u.start,u.start=0))}],prefilter:function(e,t){t?dt.prefilters.unshift(e):dt.prefilters.push(e)}}),k.speed=function(e,t,n){var r=e&&"object"==typeof e?k.extend({},e):{complete:n||!n&&t||m(e)&&e,duration:e,easing:n&&t||t&&!m(t)&&t};return k.fx.off?r.duration=0:"number"!=typeof r.duration&&(r.duration in k.fx.speeds?r.duration=k.fx.speeds[r.duration]:r.duration=k.fx.speeds._default),null!=r.queue&&!0!==r.queue||(r.queue="fx"),r.old=r.complete,r.complete=function(){m(r.old)&&r.old.call(this),r.queue&&k.dequeue(this,r.queue)},r},k.fn.extend({fadeTo:function(e,t,n,r){return this.filter(se).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(t,e,n,r){var i=k.isEmptyObject(t),o=k.speed(e,n,r),a=function(){var e=dt(this,k.extend({},t),o);(i||Q.get(this,"finish"))&&e.stop(!0)};return a.finish=a,i||!1===o.queue?this.each(a):this.queue(o.queue,a)},stop:function(i,e,o){var a=function(e){var t=e.stop;delete e.stop,t(o)};return"string"!=typeof i&&(o=e,e=i,i=void 0),e&&!1!==i&&this.queue(i||"fx",[]),this.each(function(){var e=!0,t=null!=i&&i+"queueHooks",n=k.timers,r=Q.get(this);if(t)r[t]&&r[t].stop&&a(r[t]);else for(t in r)r[t]&&r[t].stop&&ut.test(t)&&a(r[t]);for(t=n.length;t--;)n[t].elem!==this||null!=i&&n[t].queue!==i||(n[t].anim.stop(o),e=!1,n.splice(t,1));!e&&o||k.dequeue(this,i)})},finish:function(a){return!1!==a&&(a=a||"fx"),this.each(function(){var e,t=Q.get(this),n=t[a+"queue"],r=t[a+"queueHooks"],i=k.timers,o=n?n.length:0;for(t.finish=!0, +k.queue(this,a,[]),r&&r.stop&&r.stop.call(this,!0),e=i.length;e--;)i[e].elem===this&&i[e].queue===a&&(i[e].anim.stop(!0),i.splice(e,1));for(e=0;o>e;e++)n[e]&&n[e].finish&&n[e].finish.call(this);delete t.finish})}}),k.each(["toggle","show","hide"],function(e,r){var i=k.fn[r];k.fn[r]=function(e,t,n){return null==e||"boolean"==typeof e?i.apply(this,arguments):this.animate(ft(r,!0),e,t,n)}}),k.each({slideDown:ft("show"),slideUp:ft("hide"),slideToggle:ft("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,r){k.fn[e]=function(e,t,n){return this.animate(r,e,t,n)}}),k.timers=[],k.fx.tick=function(){var e,t=0,n=k.timers;for(rt=Date.now();to?u:a?o:0;u>r;r++)if(((n=i[r]).selected||r===o)&&!n.disabled&&(!n.parentNode.disabled||!A(n.parentNode,"optgroup"))){if(t=k(n).val(),a)return t;s.push(t)}return s},set:function(e,t){for(var n,r,i=e.options,o=k.makeArray(t),a=i.length;a--;)((r=i[a]).selected=-11?s:c.bindType||d,(l=(Q.get(o,"events")||{})[e.type]&&Q.get(o,"handle"))&&l.apply(o,t),(l=u&&o[u])&&l.apply&&G(o)&&(e.result=l.apply(o,t),!1===e.result&&e.preventDefault());return e.type=d,r||e.isDefaultPrevented()||c._default&&!1!==c._default.apply(p.pop(),t)||!G(n)||u&&m(n[d])&&!x(n)&&((a=n[u])&&(n[u]=null),k.event.triggered=d,e.isPropagationStopped()&&f.addEventListener(d,Ct),n[d](),e.isPropagationStopped()&&f.removeEventListener(d,Ct),k.event.triggered=void 0,a&&(n[u]=a)),e.result}},simulate:function(e,t,n){var r=k.extend(new k.Event,n,{type:e,isSimulated:!0});k.event.trigger(r,null,t)}}),k.fn.extend({trigger:function(e,t){return this.each(function(){k.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];return n?k.event.trigger(e,t,n,!0):void 0}}),y.focusin||k.each({focus:"focusin",blur:"focusout"},function(n,r){var i=function(e){k.event.simulate(r,e.target,k.event.fix(e))};k.event.special[r]={setup:function(){var e=this.ownerDocument||this,t=Q.access(e,r);t||e.addEventListener(n,i,!0),Q.access(e,r,(t||0)+1)},teardown:function(){var e=this.ownerDocument||this,t=Q.access(e,r)-1;t?Q.access(e,r,t):(e.removeEventListener(n,i,!0),Q.remove(e,r))}}});var Et=C.location,kt=Date.now(),St=/\?/;k.parseXML=function(e){var t;if(!e||"string"!=typeof e)return null;try{t=(new C.DOMParser).parseFromString(e,"text/xml")}catch(e){t=void 0}return t&&!t.getElementsByTagName("parsererror").length||k.error("Invalid XML: "+e),t};var Nt=/\[\]$/,At=/\r?\n/g,Dt=/^(?:submit|button|image|reset|file)$/i,jt=/^(?:input|select|textarea|keygen)/i;k.param=function(e,t){var n,r=[],i=function(e,t){var n=m(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(null==e)return"";if(Array.isArray(e)||e.jquery&&!k.isPlainObject(e))k.each(e,function(){i(this.name,this.value)});else for(n in e)qt(n,e[n],t,i);return r.join("&")},k.fn.extend({serialize:function(){return k.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=k.prop(this,"elements");return e?k.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!k(this).is(":disabled")&&jt.test(this.nodeName)&&!Dt.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=k(this).val();return null==n?null:Array.isArray(n)?k.map(n,function(e){return{name:t.name,value:e.replace(At,"\r\n")}}):{name:t.name,value:n.replace(At,"\r\n")}}).get()}});var Lt=/%20/g,Ht=/#.*$/,Ot=/([?&])_=[^&]*/,Pt=/^(.*?):[ \t]*([^\r\n]*)$/gm,Rt=/^(?:GET|HEAD)$/,Mt=/^\/\//,It={},Wt={},$t="*/".concat("*"),Ft=E.createElement("a");Ft.href=Et.href,k.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Et.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(Et.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":$t,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":k.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,k.ajaxSettings),t):zt(k.ajaxSettings,e)},ajaxPrefilter:Bt(It),ajaxTransport:Bt(Wt),ajax:function(e,t){function l(e,t,n,r){var i,o,a,s,u,l=t;h||(h=!0,d&&C.clearTimeout(d),c=void 0,p=r||"",T.readyState=e>0?4:0,i=e>=200&&300>e||304===e,n&&(s=function(e,t,n){for(var r,i,o,a,s=e.contents,u=e.dataTypes;"*"===u[0];)u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}return o?(o!==u[0]&&u.unshift(o),n[o]):void 0}(v,T,n)),s=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];for(o=c.shift();o;)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}(v,s,T,i),i?(v.ifModified&&((u=T.getResponseHeader("Last-Modified"))&&(k.lastModified[f]=u),(u=T.getResponseHeader("etag"))&&(k.etag[f]=u)),204===e||"HEAD"===v.type?l="nocontent":304===e?l="notmodified":(l=s.state,o=s.data,i=!(a=s.error))):(a=l,!e&&l||(l="error",0>e&&(e=0))),T.status=e,T.statusText=(t||l)+"",i?x.resolveWith(y,[o,l,T]):x.rejectWith(y,[T,l,a]),T.statusCode(w),w=void 0,g&&m.trigger(i?"ajaxSuccess":"ajaxError",[T,v,i?o:a]),b.fireWith(y,[T,l]),g&&(m.trigger("ajaxComplete",[T,v]),--k.active||k.event.trigger("ajaxStop")))}"object"==typeof e&&(t=e,e=void 0),t=t||{};var c,f,p,n,d,r,h,g,i,o,v=k.ajaxSetup({},t),y=v.context||v,m=v.context&&(y.nodeType||y.jquery)?k(y):k.event,x=k.Deferred(),b=k.Callbacks("once memory"),w=v.statusCode||{},a={},s={},u="canceled",T={readyState:0,getResponseHeader:function(e){var t;if(h){if(!n)for(n={};t=Pt.exec(p);)n[t[1].toLowerCase()+" "]=(n[t[1].toLowerCase()+" "]||[]).concat(t[2]);t=n[e.toLowerCase()+" "]}return null==t?null:t.join(", ")},getAllResponseHeaders:function(){return h?p:null},setRequestHeader:function(e,t){return null==h&&(e=s[e.toLowerCase()]=s[e.toLowerCase()]||e,a[e]=t),this},overrideMimeType:function(e){return null==h&&(v.mimeType=e),this},statusCode:function(e){var t;if(e)if(h)T.always(e[T.status]);else for(t in e)w[t]=[w[t],e[t]];return this},abort:function(e){var t=e||u;return c&&c.abort(t),l(0,t),this}};if(x.promise(T),v.url=((e||v.url||Et.href)+"").replace(Mt,Et.protocol+"//"),v.type=t.method||t.type||v.method||v.type,v.dataTypes=(v.dataType||"*").toLowerCase().match(R)||[""],null==v.crossDomain){r=E.createElement("a");try{r.href=v.url,r.href=r.href,v.crossDomain=Ft.protocol+"//"+Ft.host!=r.protocol+"//"+r.host}catch(e){v.crossDomain=!0}}if(v.data&&v.processData&&"string"!=typeof v.data&&(v.data=k.param(v.data,v.traditional)),_t(It,v,t,T),h)return T;for(i in(g=k.event&&v.global)&&0==k.active++&&k.event.trigger("ajaxStart"),v.type=v.type.toUpperCase(),v.hasContent=!Rt.test(v.type),f=v.url.replace(Ht,""),v.hasContent?v.data&&v.processData&&0===(v.contentType||"").indexOf("application/x-www-form-urlencoded")&&(v.data=v.data.replace(Lt,"+")):(o=v.url.slice(f.length),v.data&&(v.processData||"string"==typeof v.data)&&(f+=(St.test(f)?"&":"?")+v.data,delete v.data),!1===v.cache&&(f=f.replace(Ot,"$1"),o=(St.test(f)?"&":"?")+"_="+kt++ +o),v.url=f+o),v.ifModified&&(k.lastModified[f]&&T.setRequestHeader("If-Modified-Since",k.lastModified[f]),k.etag[f]&&T.setRequestHeader("If-None-Match",k.etag[f])),(v.data&&v.hasContent&&!1!==v.contentType||t.contentType)&&T.setRequestHeader("Content-Type",v.contentType),T.setRequestHeader("Accept",v.dataTypes[0]&&v.accepts[v.dataTypes[0]]?v.accepts[v.dataTypes[0]]+("*"!==v.dataTypes[0]?", "+$t+"; q=0.01":""):v.accepts["*"]),v.headers)T.setRequestHeader(i,v.headers[i]);if(v.beforeSend&&(!1===v.beforeSend.call(y,T,v)||h))return T.abort();if(u="abort",b.add(v.complete),T.done(v.success),T.fail(v.error),c=_t(Wt,v,t,T)){if(T.readyState=1,g&&m.trigger("ajaxSend",[T,v]),h)return T;v.async&&0").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}:void 0});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");return a||"jsonp"===e.dataTypes[0]?(r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"):void 0}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return s>-1&&(r=mt(e.slice(s)),e=e.slice(0,s)),m(t)?(n=t,t=void 0):t&&"object"==typeof t&&(i="POST"),0").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{for(t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position");)e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent;e&&"static"===k.css(e,"position");)e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;return x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n?r?r[i]:e[t]:void(r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n)},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){return t?(t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t):void 0})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 01){for(o=0;o1){for(var r=0;r=i&&o>=t};break;case"bottom":h=function(t,e,n,i,o){return n>=i&&o>=n};break;case"middle":h=function(t,e,n,i,o){return e>=i&&o>=e};break;case"top-only":h=function(t,e,n,i,o){return i>=t&&n>=i};break;case"bottom-only":h=function(t,e,n,i,o){return n>=o&&o>=t};break;default:case"default":h=function(t,e,n,i,o){return n>=i&&o>=t}}return c=function(t){var i,o,l,s,r,a,u=this.state,h=!1,c=this.$element.offset();i=n.height(),o=t+i/2,l=t+i,s=this.$element.outerHeight(),r=c.top+e(this.options.top,s,i),a=c.top+s-e(this.options.bottom,s,i),h=this.test(t,o,l,r,a),h!=u&&(this.state=h,h?this.options.enter&&this.options.enter.apply(this.element):this.options.leave&&this.options.leave.apply(this.element)),this.options.scroll&&this.options.scroll.apply(this.element,[(o-r)/(a-r)])},p={id:a,options:u,test:h,handler:c,state:null,element:this,$element:s,timeoutId:null},o[a]=p,s.data("_scrollexId",p.id),p.options.initialize&&p.options.initialize.apply(this),s},jQuery.fn.unscrollex=function(){var e=t(this);if(0==this.length)return e;if(this.length>1){for(var n=0;n0:!!("ontouchstart"in window),e.mobile="wp"==e.os||"android"==e.os||"ios"==e.os||"bb"==e.os}};return e.init(),e}();!function(e,n){"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?module.exports=n():e.browser=n()}(this,function(){return browser});var breakpoints=function(){"use strict";function e(e){t.init(e)}var t={list:null,media:{},events:[],init:function(e){t.list=e,window.addEventListener("resize",t.poll),window.addEventListener("orientationchange",t.poll),window.addEventListener("load",t.poll),window.addEventListener("fullscreenchange",t.poll)},active:function(e){var n,a,s,i,r,d,c;if(!(e in t.media)){if(">="==e.substr(0,2)?(a="gte",n=e.substr(2)):"<="==e.substr(0,2)?(a="lte",n=e.substr(2)):">"==e.substr(0,1)?(a="gt",n=e.substr(1)):"<"==e.substr(0,1)?(a="lt",n=e.substr(1)):"!"==e.substr(0,1)?(a="not",n=e.substr(1)):(a="eq",n=e),n&&n in t.list)if(i=t.list[n],Array.isArray(i)){if(r=parseInt(i[0]),d=parseInt(i[1]),isNaN(r)){if(isNaN(d))return;c=i[1].substr(String(d).length)}else c=i[0].substr(String(r).length);if(isNaN(r))switch(a){case"gte":s="screen";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: -1px)";break;case"not":s="screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (max-width: "+d+c+")"}else if(isNaN(d))switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen";break;case"gt":s="screen and (max-width: -1px)";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+")";break;default:s="screen and (min-width: "+r+c+")"}else switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+"), screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (min-width: "+r+c+") and (max-width: "+d+c+")"}}else s="("==i.charAt(0)?"screen and "+i:i;t.media[e]=!!s&&s}return t.media[e]!==!1&&window.matchMedia(t.media[e]).matches},on:function(e,n){t.events.push({query:e,handler:n,state:!1}),t.active(e)&&n()},poll:function(){var e,n;for(e=0;e'+$this.text()+"")}),b.join("")},$.fn.panel=function(userConfig){if(0==this.length)return $this;if(this.length>1){for(var i=0;idiffY&&diffY>-1*boundary&&diffX>delta;break;case"right":result=boundary>diffY&&diffY>-1*boundary&&-1*delta>diffX;break;case"top":result=boundary>diffX&&diffX>-1*boundary&&diffY>delta;break;case"bottom":result=boundary>diffX&&diffX>-1*boundary&&-1*delta>diffY}if(result)return $this.touchPosX=null,$this.touchPosY=null,$this._hide(),!1}($this.scrollTop()<0&&0>diffY||ts>th-2&&th+2>ts&&diffY>0)&&(event.preventDefault(),event.stopPropagation())}}),$this.on("click touchend touchstart touchmove",function(event){event.stopPropagation()}),$this.on("click",'a[href="#'+id+'"]',function(event){event.preventDefault(),event.stopPropagation(),config.target.removeClass(config.visibleClass)}),$body.on("click touchend",function(event){$this._hide(event)}),$body.on("click",'a[href="#'+id+'"]',function(event){event.preventDefault(),event.stopPropagation(),config.target.toggleClass(config.visibleClass)}),config.hideOnEscape&&$window.on("keydown",function(event){27==event.keyCode&&$this._hide(event)}),$this},$.fn.placeholder=function(){if("undefined"!=typeof document.createElement("input").placeholder)return $(this);if(0==this.length)return $this;if(this.length>1){for(var i=0;i").append(i.clone()).remove().html().replace(/type="password"/i,'type="text"').replace(/type=password/i,"type=text"));""!=i.attr("id")&&x.attr("id",i.attr("id")+"-polyfill-field"),""!=i.attr("name")&&x.attr("name",i.attr("name")+"-polyfill-field"),x.addClass("polyfill-placeholder").val(x.attr("placeholder")).insertAfter(i),""==i.val()?i.hide():x.hide(),i.on("blur",function(event){event.preventDefault();var x=i.parent().find("input[name="+i.attr("name")+"-polyfill-field]");""==i.val()&&(i.hide(),x.show())}),x.on("focus",function(event){event.preventDefault();var i=x.parent().find("input[name="+x.attr("name").replace("-polyfill-field","")+"]");x.hide(),i.show().focus()}).on("keypress",function(event){event.preventDefault(),x.val("")})}),$this.on("submit",function(){$this.find("input[type=text],input[type=password],textarea").each(function(event){var i=$(this);i.attr("name").match(/-polyfill-field$/)&&i.attr("name",""),i.val()==i.attr("placeholder")&&(i.removeClass("polyfill-placeholder"),i.val(""))})}).on("reset",function(event){event.preventDefault(),$this.find("select").val($("option:first").val()),$this.find("input,textarea").each(function(){var x,i=$(this);switch(i.removeClass("polyfill-placeholder"),this.type){case"submit":case"reset":break;case"password":i.val(i.attr("defaultValue")),x=i.parent().find("input[name="+i.attr("name")+"-polyfill-field]"),""==i.val()?(i.hide(),x.show()):(i.show(),x.hide());break;case"checkbox":case"radio":i.attr("checked",i.attr("defaultValue"));break;case"text":case"textarea":i.val(i.attr("defaultValue")),""==i.val()&&(i.addClass("polyfill-placeholder"),i.val(i.attr("placeholder")));break;default:i.val(i.attr("defaultValue"))}})}),$this},$.prioritize=function($elements,condition){var key="__prioritize";"jQuery"!=typeof $elements&&($elements=$($elements)),$elements.each(function(){var $p,$e=$(this),$parent=$e.parent();if(0!=$parent.length)if($e.data(key)){if(condition)return;$p=$e.data(key),$e.insertAfter($p),$e.removeData(key)}else{if(!condition)return;if($p=$e.prev(),0==$p.length)return;$e.prependTo($parent),$e.data(key,$p)}})}}(jQuery),function($){var $window=$(window),$body=$("body"),$wrapper=$("#wrapper"),$header=$("#header"),$banner=$("#banner");breakpoints({xlarge:["1281px","1680px"],large:["981px","1280px"],medium:["737px","980px"],small:["481px","736px"],xsmall:["361px","480px"],xxsmall:[null,"360px"]}),$.fn._parallax="ie"==browser.name||"edge"==browser.name||browser.mobile?function(){return $(this)}:function(intensity){var $window=$(window),$this=$(this);if(0==this.length||0===intensity)return $this;if(this.length>1){for(var i=0;imedium",on)}),$window.off("load._parallax resize._parallax").on("load._parallax resize._parallax",function(){$window.trigger("scroll")}),$(this)},$window.on("load",function(){window.setTimeout(function(){$body.removeClass("is-preload")},100)}),$window.on("unload pagehide",function(){window.setTimeout(function(){$(".is-transitioning").removeClass("is-transitioning")},250)}),("ie"==browser.name||"edge"==browser.name)&&$body.addClass("is-ie"),$(".scrolly").scrolly({offset:function(){return $header.height()-2}});var $tiles=$(".tiles > article");$tiles.each(function(){var x,$this=$(this),$image=$this.find(".image"),$img=$image.find("img"),$link=$this.find(".link");$this.css("background-image","url("+$img.attr("src")+")"),(x=$img.data("position"))&&$image.css("background-position",x),$image.hide(),$link.length>0&&($x=$link.clone().text("").addClass("primary").appendTo($this),$link=$link.add($x),$link.on("click",function(event){var href=$link.attr("href");event.stopPropagation(),event.preventDefault(),"_blank"==$link.attr("target")?window.open(href):($this.addClass("is-transitioning"),$wrapper.addClass("is-transitioning"),window.setTimeout(function(){location.href=href},500))}))}),$banner.length>0&&$header.hasClass("alt")&&($window.on("resize",function(){$window.trigger("scroll")}),$window.on("load",function(){$banner.scrollex({bottom:$header.height()+10,terminate:function(){$header.removeClass("alt")},enter:function(){$header.addClass("alt")},leave:function(){$header.removeClass("alt"),$header.addClass("reveal")}}),window.setTimeout(function(){$window.triggerHandler("scroll")},100)})),$banner.each(function(){var $this=$(this),$image=$this.find(".image"),$img=$image.find("img");$this._parallax(.275),$image.length>0&&($this.css("background-image","url("+$img.attr("src")+")"),$image.hide())});var $menuInner,$menu=$("#menu");$menu.wrapInner('
'),$menuInner=$menu.children(".inner"),$menu._locked=!1,$menu._lock=function(){return $menu._locked?!1:($menu._locked=!0,window.setTimeout(function(){$menu._locked=!1},350),!0)},$menu._show=function(){$menu._lock()&&$body.addClass("is-menu-visible")},$menu._hide=function(){$menu._lock()&&$body.removeClass("is-menu-visible")},$menu._toggle=function(){$menu._lock()&&$body.toggleClass("is-menu-visible")},$menuInner.on("click",function(event){event.stopPropagation()}).on("click","a",function(event){var href=$(this).attr("href");event.preventDefault(),event.stopPropagation(),$menu._hide(),window.setTimeout(function(){window.location.href=href},250)}),$menu.appendTo($body).on("click",function(event){event.stopPropagation(),event.preventDefault(),$body.removeClass("is-menu-visible")}).append('Close'),$body.on("click",'a[href="#menu"]',function(event){event.stopPropagation(),event.preventDefault(),$menu._toggle()}).on("click",function(event){$menu._hide()}).on("keydown",function(event){27==event.keyCode&&$menu._hide()})}(jQuery); \ No newline at end of file diff --git a/app/src/main/assets/web/assets/js/md5.js b/app/src/main/assets/web/assets/js/md5.js new file mode 100644 index 000000000..46d2aab7d --- /dev/null +++ b/app/src/main/assets/web/assets/js/md5.js @@ -0,0 +1,256 @@ +/* + * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message + * Digest Algorithm, as defined in RFC 1321. + * Version 2.1 Copyright (C) Paul Johnston 1999 - 2002. + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for more info. + */ + +/* + * Configurable variables. You may need to tweak these to be compatible with + * the server-side, but the defaults work in most cases. + */ +var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */ +var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */ +var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */ + +/* + * These are the functions you'll usually want to call + * They take string arguments and return either hex or base-64 encoded strings + */ +function hex_md5(s){ return binl2hex(core_md5(str2binl(s), s.length * chrsz));} +function b64_md5(s){ return binl2b64(core_md5(str2binl(s), s.length * chrsz));} +function str_md5(s){ return binl2str(core_md5(str2binl(s), s.length * chrsz));} +function hex_hmac_md5(key, data) { return binl2hex(core_hmac_md5(key, data)); } +function b64_hmac_md5(key, data) { return binl2b64(core_hmac_md5(key, data)); } +function str_hmac_md5(key, data) { return binl2str(core_hmac_md5(key, data)); } + +/* + * Perform a simple self-test to see if the VM is working + */ +function md5_vm_test() +{ + return hex_md5("abc") == "900150983cd24fb0d6963f7d28e17f72"; +} + +/* + * Calculate the MD5 of an array of little-endian words, and a bit length + */ +function core_md5(x, len) +{ + /* append padding */ + x[len >> 5] |= 0x80 << ((len) % 32); + x[(((len + 64) >>> 9) << 4) + 14] = len; + + var a = 1732584193; + var b = -271733879; + var c = -1732584194; + var d = 271733878; + + for(var i = 0; i < x.length; i += 16) + { + var olda = a; + var oldb = b; + var oldc = c; + var oldd = d; + + a = md5_ff(a, b, c, d, x[i+ 0], 7 , -680876936); + d = md5_ff(d, a, b, c, x[i+ 1], 12, -389564586); + c = md5_ff(c, d, a, b, x[i+ 2], 17, 606105819); + b = md5_ff(b, c, d, a, x[i+ 3], 22, -1044525330); + a = md5_ff(a, b, c, d, x[i+ 4], 7 , -176418897); + d = md5_ff(d, a, b, c, x[i+ 5], 12, 1200080426); + c = md5_ff(c, d, a, b, x[i+ 6], 17, -1473231341); + b = md5_ff(b, c, d, a, x[i+ 7], 22, -45705983); + a = md5_ff(a, b, c, d, x[i+ 8], 7 , 1770035416); + d = md5_ff(d, a, b, c, x[i+ 9], 12, -1958414417); + c = md5_ff(c, d, a, b, x[i+10], 17, -42063); + b = md5_ff(b, c, d, a, x[i+11], 22, -1990404162); + a = md5_ff(a, b, c, d, x[i+12], 7 , 1804603682); + d = md5_ff(d, a, b, c, x[i+13], 12, -40341101); + c = md5_ff(c, d, a, b, x[i+14], 17, -1502002290); + b = md5_ff(b, c, d, a, x[i+15], 22, 1236535329); + + a = md5_gg(a, b, c, d, x[i+ 1], 5 , -165796510); + d = md5_gg(d, a, b, c, x[i+ 6], 9 , -1069501632); + c = md5_gg(c, d, a, b, x[i+11], 14, 643717713); + b = md5_gg(b, c, d, a, x[i+ 0], 20, -373897302); + a = md5_gg(a, b, c, d, x[i+ 5], 5 , -701558691); + d = md5_gg(d, a, b, c, x[i+10], 9 , 38016083); + c = md5_gg(c, d, a, b, x[i+15], 14, -660478335); + b = md5_gg(b, c, d, a, x[i+ 4], 20, -405537848); + a = md5_gg(a, b, c, d, x[i+ 9], 5 , 568446438); + d = md5_gg(d, a, b, c, x[i+14], 9 , -1019803690); + c = md5_gg(c, d, a, b, x[i+ 3], 14, -187363961); + b = md5_gg(b, c, d, a, x[i+ 8], 20, 1163531501); + a = md5_gg(a, b, c, d, x[i+13], 5 , -1444681467); + d = md5_gg(d, a, b, c, x[i+ 2], 9 , -51403784); + c = md5_gg(c, d, a, b, x[i+ 7], 14, 1735328473); + b = md5_gg(b, c, d, a, x[i+12], 20, -1926607734); + + a = md5_hh(a, b, c, d, x[i+ 5], 4 , -378558); + d = md5_hh(d, a, b, c, x[i+ 8], 11, -2022574463); + c = md5_hh(c, d, a, b, x[i+11], 16, 1839030562); + b = md5_hh(b, c, d, a, x[i+14], 23, -35309556); + a = md5_hh(a, b, c, d, x[i+ 1], 4 , -1530992060); + d = md5_hh(d, a, b, c, x[i+ 4], 11, 1272893353); + c = md5_hh(c, d, a, b, x[i+ 7], 16, -155497632); + b = md5_hh(b, c, d, a, x[i+10], 23, -1094730640); + a = md5_hh(a, b, c, d, x[i+13], 4 , 681279174); + d = md5_hh(d, a, b, c, x[i+ 0], 11, -358537222); + c = md5_hh(c, d, a, b, x[i+ 3], 16, -722521979); + b = md5_hh(b, c, d, a, x[i+ 6], 23, 76029189); + a = md5_hh(a, b, c, d, x[i+ 9], 4 , -640364487); + d = md5_hh(d, a, b, c, x[i+12], 11, -421815835); + c = md5_hh(c, d, a, b, x[i+15], 16, 530742520); + b = md5_hh(b, c, d, a, x[i+ 2], 23, -995338651); + + a = md5_ii(a, b, c, d, x[i+ 0], 6 , -198630844); + d = md5_ii(d, a, b, c, x[i+ 7], 10, 1126891415); + c = md5_ii(c, d, a, b, x[i+14], 15, -1416354905); + b = md5_ii(b, c, d, a, x[i+ 5], 21, -57434055); + a = md5_ii(a, b, c, d, x[i+12], 6 , 1700485571); + d = md5_ii(d, a, b, c, x[i+ 3], 10, -1894986606); + c = md5_ii(c, d, a, b, x[i+10], 15, -1051523); + b = md5_ii(b, c, d, a, x[i+ 1], 21, -2054922799); + a = md5_ii(a, b, c, d, x[i+ 8], 6 , 1873313359); + d = md5_ii(d, a, b, c, x[i+15], 10, -30611744); + c = md5_ii(c, d, a, b, x[i+ 6], 15, -1560198380); + b = md5_ii(b, c, d, a, x[i+13], 21, 1309151649); + a = md5_ii(a, b, c, d, x[i+ 4], 6 , -145523070); + d = md5_ii(d, a, b, c, x[i+11], 10, -1120210379); + c = md5_ii(c, d, a, b, x[i+ 2], 15, 718787259); + b = md5_ii(b, c, d, a, x[i+ 9], 21, -343485551); + + a = safe_add(a, olda); + b = safe_add(b, oldb); + c = safe_add(c, oldc); + d = safe_add(d, oldd); + } + return Array(a, b, c, d); + +} + +/* + * These functions implement the four basic operations the algorithm uses. + */ +function md5_cmn(q, a, b, x, s, t) +{ + return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s),b); +} +function md5_ff(a, b, c, d, x, s, t) +{ + return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t); +} +function md5_gg(a, b, c, d, x, s, t) +{ + return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t); +} +function md5_hh(a, b, c, d, x, s, t) +{ + return md5_cmn(b ^ c ^ d, a, b, x, s, t); +} +function md5_ii(a, b, c, d, x, s, t) +{ + return md5_cmn(c ^ (b | (~d)), a, b, x, s, t); +} + +/* + * Calculate the HMAC-MD5, of a key and some data + */ +function core_hmac_md5(key, data) +{ + var bkey = str2binl(key); + if(bkey.length > 16) bkey = core_md5(bkey, key.length * chrsz); + + var ipad = Array(16), opad = Array(16); + for(var i = 0; i < 16; i++) + { + ipad[i] = bkey[i] ^ 0x36363636; + opad[i] = bkey[i] ^ 0x5C5C5C5C; + } + + var hash = core_md5(ipad.concat(str2binl(data)), 512 + data.length * chrsz); + return core_md5(opad.concat(hash), 512 + 128); +} + +/* + * Add integers, wrapping at 2^32. This uses 16-bit operations internally + * to work around bugs in some JS interpreters. + */ +function safe_add(x, y) +{ + var lsw = (x & 0xFFFF) + (y & 0xFFFF); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xFFFF); +} + +/* + * Bitwise rotate a 32-bit number to the left. + */ +function bit_rol(num, cnt) +{ + return (num << cnt) | (num >>> (32 - cnt)); +} + +/* + * Convert a string to an array of little-endian words + * If chrsz is ASCII, characters >255 have their hi-byte silently ignored. + */ +function str2binl(str) +{ + var bin = Array(); + var mask = (1 << chrsz) - 1; + for(var i = 0; i < str.length * chrsz; i += chrsz) + bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (i%32); + return bin; +} + +/* + * Convert an array of little-endian words to a string + */ +function binl2str(bin) +{ + var str = ""; + var mask = (1 << chrsz) - 1; + for(var i = 0; i < bin.length * 32; i += chrsz) + str += String.fromCharCode((bin[i>>5] >>> (i % 32)) & mask); + return str; +} + +/* + * Convert an array of little-endian words to a hex string. + */ +function binl2hex(binarray) +{ + var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + var str = ""; + for(var i = 0; i < binarray.length * 4; i++) + { + str += hex_tab.charAt((binarray[i>>2] >> ((i%4)*8+4)) & 0xF) + + hex_tab.charAt((binarray[i>>2] >> ((i%4)*8 )) & 0xF); + } + return str; +} + +/* + * Convert an array of little-endian words to a base-64 string + */ +function binl2b64(binarray) +{ + var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var str = ""; + for(var i = 0; i < binarray.length * 4; i += 3) + { + var triplet = (((binarray[i >> 2] >> 8 * ( i %4)) & 0xFF) << 16) + | (((binarray[i+1 >> 2] >> 8 * ((i+1)%4)) & 0xFF) << 8 ) + | ((binarray[i+2 >> 2] >> 8 * ((i+2)%4)) & 0xFF); + for(var j = 0; j < 4; j++) + { + if(i * 8 + j * 6 > binarray.length * 32) str += b64pad; + else str += tab.charAt((triplet >> 6*(3-j)) & 0x3F); + } + } + return str; +} diff --git a/app/src/main/assets/web/bookSource/index.css b/app/src/main/assets/web/bookSource/index.css new file mode 100644 index 000000000..97cd3ca6e --- /dev/null +++ b/app/src/main/assets/web/bookSource/index.css @@ -0,0 +1,150 @@ +body { + margin: 0; +} +.editor { + display: flex; + align-items: stretch; +} +.setbox, +.menu, +.outbox { + flex: 1; + display: flex; + flex-flow: column; + max-height: 100vh; + overflow-y: auto; +} +.menu { + justify-content: center; + max-width: 90px; + margin: 0 5px; +} +.menu .button { + width: 90px; + height: 30px; + min-height: 30px; + margin: 5px 0px; + cursor: pointer; +} +@keyframes stroker { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -240; + } +} +.button rect { + width: 100%; + height: 100%; + fill: transparent; + stroke: #666; + stroke-width: 2px; +} +.button rect.busy { + stroke: #fd1850; + stroke-dasharray: 30 90; + animation: stroker 1s linear infinite; +} +.button text { + text-anchor: middle; + dominant-baseline: middle; +} +.setbox { + min-width: 40em; +} +.rules { + overflow: auto; +} +.tabbox { + flex: 1; + display: flex; + flex-flow: column; +} +.rules > * { + display: flex; + margin: 2px 0; +} +.rules textarea { + flex: 1; + margin-left: 5px; +} +.rules > *, +.rules > * > div, +.rules textarea { + min-height: 1em; +} +textarea { + word-break: break-all; +} +.tabtitle { + display: flex; + z-index: 1; + justify-content: flex-end; +} +.tabtitle > div { + cursor: pointer; + padding: 1px 10px 0 10px; + border-bottom: 3px solid transparent; + font-weight: bold; +} +.tabtitle > .this { + color: #4f9da6; + border-bottom-color: #4ebbe4; +} +.tabbody { + flex: 1; + display: flex; + margin-top: -1px; + border: 1px solid #a9a9a9; + height: 0; +} +.tabbody > * { + flex: 1; + flex-flow: column; + display: none; +} +.tabbody > .this { + display: flex; +} +.tabbody > * > .titlebar { + display: flex; +} +.tabbody > * > .titlebar > * { + flex: 1; + margin: 1px 1px 1px 1px; +} +.tabbody > * > .context { + flex: 1; + flex-flow: column; + border: 0; + padding: 5px; + overflow-y: auto; +} +.tabbody > * > .inputbox { + border: 0; + border-bottom: #a9a9a9 solid 1px; + height: 15px; + text-align: center; +} +.link > * { + display: flex; + margin: 5px; + border-bottom: 1px solid; + text-decoration: none; +} +#RuleList > label > * { + background: #eee; + padding-left: 3px; + margin: 2px 0; + cursor: pointer; +} +#RuleList input[type="radio"] { + display: none; +} +#RuleList input[type="radio"]:checked + * { + background: #15cda8; +} +.isError { + color: #ff0000; +} diff --git a/app/src/main/assets/web/bookSource/index.html b/app/src/main/assets/web/bookSource/index.html new file mode 100644 index 000000000..d0031c00c --- /dev/null +++ b/app/src/main/assets/web/bookSource/index.html @@ -0,0 +1,428 @@ + + + + + + 阅读3.0源编辑器_V4.0 + + + + + +
+
+
+ ←主页 + 书源 +
+
+
基本
+
+
源域名 :
+ +
+
+
源类型 :
+ +
+
+
源名称 :
+ +
+
+
源分组 :
+ +
+
+
源注释 :
+ +
+
+
登录地址:
+ +
+
+
登录界面:
+ +
+
+
登录检测:
+ +
+
+
并发率 :
+ +
+
+
请求头 :
+ +
+
+
链接验证:
+ +
+

+
搜索
+
+
搜索地址:
+ +
+
+
校验文字:
+ +
+
+
列表规则:
+ +
+
+
书名规则:
+ +
+
+
作者规则:
+ +
+
+
分类规则:
+ +
+
+
字数规则:
+ +
+
+
最新章节:
+ +
+
+
简介规则:
+ +
+
+
封面规则:
+ +
+
+
详情地址:
+ +
+

+
发现
+
+
发现地址:
+ +
+
+
列表规则:
+ +
+
+
书名规则:
+ +
+
+
作者规则:
+ +
+
+
分类规则:
+ +
+
+
字数规则:
+ +
+
+
最新章节:
+ +
+
+
简介规则:
+ +
+
+
封面规则:
+ +
+
+
详情地址:
+ +
+

+
详情
+
+
预处理 :
+ +
+
+
书名规则:
+ +
+
+
作者规则:
+ +
+
+
分类规则:
+ +
+
+
字数规则:
+ +
+
+
最新章节:
+ +
+
+
简介规则:
+ +
+
+
封面规则:
+ +
+
+
目录地址:
+ +
+

+
目录
+
+
列表规则:
+ +
+
+
章节名称:
+ +
+
+
章节地址:
+ +
+
+
收费标识:
+ +
+
+
购买标识:
+ +
+
+
章节信息:
+ +
+
+
翻页规则:
+ +
+

+
正文
+
+
脚本注入:
+ +
+
+
正文规则:
+ +
+
+
翻页规则:
+ +
+
+
资源正则:
+ +
+
+
替换规则:
+ +
+
+
图片样式:
+ +
+

+
其它规则
+
+
启用搜索:
+ +
+
+
启用发现:
+ +
+
+
搜索权重:
+ +
+
+
排序编号:
+ +
+
+
更新时间:
+ +
+
+
+ +
+
+
+
编辑源
+
调试源
+
源列表
+
帮助信息
+
+
+
+ +
+
+ + +
+
+ +
+ + + + +
+
+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/app/src/main/assets/web/bookSource/index.js b/app/src/main/assets/web/bookSource/index.js new file mode 100644 index 000000000..b93661bc1 --- /dev/null +++ b/app/src/main/assets/web/bookSource/index.js @@ -0,0 +1,516 @@ +// 简化js原生选择器 +function $(selector) { return document.querySelector(selector); } +function $$(selector) { return document.querySelectorAll(selector); } +// 读写Hash值(val未赋值时为读取) +function hashParam(key, val) { + let hashstr = decodeURIComponent(window.location.hash); + let regKey = new RegExp(`${key}=([^&]*)`); + let getVal = regKey.test(hashstr) ? hashstr.match(regKey)[1] : null; + if (val == undefined) return getVal; + if (hashstr == '' || hashstr == '#') { + window.location.hash = `#${key}=${val}`; + } + else { + if (getVal) window.location.hash = hashstr.replace(getVal, val); + else { + window.location.hash = hashstr.indexOf(key) > -1 ? hashstr.replace(regKey, `${key}=${val}`) : `${hashstr}&${key}=${val}`; + } + } +} +// 创建源规则容器对象 +function Container() { + let ruleJson = {}; + let searchJson = {}; + let exploreJson = {}; + let bookInfoJson = {}; + let tocJson = {}; + let contentJson = {}; + + // 基本以及其他 + $$('.rules .base').forEach(item => ruleJson[item.title] = ''); + ruleJson.lastUpdateTime = 0; + ruleJson.customOrder = 0; + ruleJson.weight = 0; + ruleJson.enabled = true; + ruleJson.enabledExplore = true; + + // 搜索规则 + $$('.rules .ruleSearch').forEach(item => searchJson[item.title] = ''); + ruleJson.ruleSearch = searchJson; + + // 发现规则 + $$('.rules .ruleExplore').forEach(item => exploreJson[item.title] = ''); + ruleJson.ruleExplore = exploreJson; + + // 详情页规则 + $$('.rules .ruleBookInfo').forEach(item => bookInfoJson[item.title] = ''); + ruleJson.ruleBookInfo = bookInfoJson; + + // 目录规则 + $$('.rules .ruleToc').forEach(item => tocJson[item.title] = ''); + ruleJson.ruleToc = tocJson; + + // 正文规则 + $$('.rules .ruleContent').forEach(item => contentJson[item.title] = ''); + ruleJson.ruleContent = contentJson; + + return ruleJson; +} +// 选项卡Tab切换事件处理 +function showTab(tabName) { + $$('.tabtitle>*').forEach(node => { node.className = node.className.replace(' this', ''); }); + $$('.tabbody>*').forEach(node => { node.className = node.className.replace(' this', ''); }); + $(`.tabbody>.${$(`.tabtitle>*[name=${tabName}]`).className}`).className += ' this'; + $(`.tabtitle>*[name=${tabName}]`).className += ' this'; + hashParam('tab', tabName); +} +// 源列表列表标签构造函数 +function newRule(rule) { + return ``; +} +// 缓存规则列表 +var RuleSources = []; +if (localStorage.getItem('BookSources')) { + RuleSources = JSON.parse(localStorage.getItem('BookSources')); + RuleSources.forEach(item => $('#RuleList').innerHTML += newRule(item)); +} +// 页面加载完成事件 +window.onload = () => { + $$('.tabtitle>*').forEach(item => { + item.addEventListener('click', () => { + showTab(item.innerHTML); + }); + }); + if (hashParam('tab')) showTab(hashParam('tab')); +} +// 获取数据 +function HttpGet(url) { + return fetch(hashParam('domain') ? hashParam('domain') + url : url) + .then(res => res.json()).catch(err => console.error('Error:', err)); +} +// 提交数据 +function HttpPost(url, data) { + return fetch(hashParam('domain') ? hashParam('domain') + url : url, { + body: JSON.stringify(data), + method: 'POST', + mode: "cors", + headers: new Headers({ + 'Content-Type': 'application/json;charset=utf-8' + }) + }).then(res => res.json()).catch(err => console.error('Error:', err)); +} +// 将源表单转化为源对象 +function rule2json() { + let RuleJSON = Container(); + // 转换base + Object.keys(RuleJSON).forEach(key => { + if (!key.startsWith("rule")) { + RuleJSON[key] = $('#' + key).value; + } + }); + + // 转换搜索规则 + let searchJson = {}; + Object.keys(RuleJSON.ruleSearch).forEach(key => { + if ($('#' + 'ruleSearch_' + key).value) + searchJson[key] = $('#' + 'ruleSearch_' + key).value; + }); + RuleJSON.ruleSearch = searchJson; + + // 转换发现规则 + let exploreJson = {}; + Object.keys(RuleJSON.ruleExplore).forEach(key => { + if ($('#' + 'ruleExplore_' + key).value) + exploreJson[key] = $('#' + 'ruleExplore_' + key).value; + }); + RuleJSON.ruleExplore = exploreJson; + + // 转换详情页规则 + let bookInfoJson = {}; + Object.keys(RuleJSON.ruleBookInfo).forEach(key => { + if ($('#' + 'ruleBookInfo_' + key).value) + bookInfoJson[key] = $('#' + 'ruleBookInfo_' + key).value; + }); + RuleJSON.ruleBookInfo = bookInfoJson; + + // 转换目录规则 + let tocJson = {}; + Object.keys(RuleJSON.ruleToc).forEach(key => { + if ($('#' + 'ruleToc_' + key).value) + tocJson[key] = $('#' + 'ruleToc_' + key).value; + }); + RuleJSON.ruleToc = tocJson; + + // 转换正文规则 + let contentJson = {}; + Object.keys(RuleJSON.ruleContent).forEach(key => { + if ($('#' + 'ruleContent_' + key).value) + contentJson[key] = $('#' + 'ruleContent_' + key).value; + }); + RuleJSON.ruleContent = contentJson; + + RuleJSON.lastUpdateTime = new Date().getTime(); + RuleJSON.customOrder = RuleJSON.customOrder == '' ? 0 : parseInt(RuleJSON.customOrder); + RuleJSON.weight = RuleJSON.weight == '' ? 0 : parseInt(RuleJSON.weight); + RuleJSON.bookSourceType == RuleJSON.bookSourceType == '' ? 0 : parseInt(RuleJSON.bookSourceType); + RuleJSON.enabled = RuleJSON.enabled == '' || String(RuleJSON.enabled).toLocaleLowerCase().replace(/^\s*|\s*$/g, '') == 'true'; + RuleJSON.enabledExplore = RuleJSON.enabledExplore == '' || String(RuleJSON.enabledExplore).toLocaleLowerCase().replace(/^\s*|\s*$/g, '') == 'true'; + return RuleJSON; +} +// 将源对象填充到源表单 +function json2rule(RuleEditor) { + let RuleJSON = Container(); + // 转换base + Object.keys(RuleJSON).forEach(key => { + if (!key.startsWith("rule")) { + let val = RuleEditor[key]; + if (typeof val == "number") { + $("#" + key).value = val ? String(val) : '0'; + } + else if (typeof val == "boolean") { + $("#" + key).value = val ? String(val) : 'false'; + } + else { + $("#" + key).value = val ? String(val) : ''; + } + } + }); + + // 转换搜索规则 + if (RuleEditor.ruleSearch) { + let searchJson = RuleEditor.ruleSearch; + Object.keys(RuleJSON.ruleSearch).forEach(key => { + $('#' + 'ruleSearch_' + key).value = searchJson[key] ? searchJson[key] : ''; + }); + } + + // 转换发现规则 + if (RuleEditor.ruleExplore) { + let exploreJson = RuleEditor.ruleExplore; + Object.keys(RuleJSON.ruleExplore).forEach(key => { + $('#' + 'ruleExplore_' + key).value = exploreJson[key] ? exploreJson[key] : ''; + }); + } + + // 转换详情页规则 + if (RuleEditor.ruleBookInfo) { + let bookInfoJson = RuleEditor.ruleBookInfo; + Object.keys(RuleJSON.ruleBookInfo).forEach(key => { + $('#' + 'ruleBookInfo_' + key).value = bookInfoJson[key] ? bookInfoJson[key] : ''; + }); + } + + // 转换目录规则 + if (RuleEditor.ruleToc) { + let tocJson = RuleEditor.ruleToc; + Object.keys(RuleJSON.ruleToc).forEach(key => { + $('#' + 'ruleToc_' + key).value = tocJson[key] ? tocJson[key] : ''; + }); + } + + // 转换正文规则 + if (RuleEditor.ruleContent) { + let contentJson = RuleEditor.ruleContent; + Object.keys(RuleJSON.ruleContent).forEach(key => { + $('#' + 'ruleContent_' + key).value = contentJson[key] ? contentJson[key] : ''; + }); + } +} +// 记录操作过程 +var course = { "old": [], "now": {}, "new": [] }; +if (localStorage.getItem('bookSourceCourse')) { + course = JSON.parse(localStorage.getItem('bookSourceCourse')); + json2rule(course.now); +} +else { + course.now = rule2json(); + window.localStorage.setItem('bookSourceCourse', JSON.stringify(course)); +} +function todo() { + course.old.push(Object.assign({}, course.now)); + course.now = rule2json(); + course.new = []; + if (course.old.length > 50) course.old.shift(); // 限制历史记录堆栈大小 + localStorage.setItem('bookSourceCourse', JSON.stringify(course)); +} +function undo() { + course = JSON.parse(localStorage.getItem('bookSourceCourse')); + if (course.old.length > 0) { + course.new.push(course.now); + course.now = course.old.pop(); + localStorage.setItem('bookSourceCourse', JSON.stringify(course)); + json2rule(course.now); + } +} +function redo() { + course = JSON.parse(localStorage.getItem('bookSourceCourse')); + if (course.new.length > 0) { + course.old.push(course.now); + course.now = course.new.pop(); + localStorage.setItem('bookSourceCourse', JSON.stringify(course)); + json2rule(course.now); + } +} +function setRule(editRule) { + let checkRule = RuleSources.find(x => x.bookSourceUrl == editRule.bookSourceUrl); + if ($(`input[id="${editRule.bookSourceUrl}"]`)) { + Object.keys(checkRule).forEach(key => { checkRule[key] = editRule[key]; }); + $(`input[id="${editRule.bookSourceUrl}"]+*`).innerHTML = `${editRule.bookSourceName}
${editRule.bookSourceUrl}`; + } else { + RuleSources.push(editRule); + $('#RuleList').innerHTML += newRule(editRule); + } +} +$$('input').forEach((item) => { item.addEventListener('change', () => { todo() }) }); +$$('textarea').forEach((item) => { item.addEventListener('change', () => { todo() }) }); +// 处理按钮点击事件 +$('.menu').addEventListener('click', e => { + let thisNode = e.target; + thisNode = thisNode.parentNode.nodeName == 'svg' ? thisNode.parentNode.querySelector('rect') : + thisNode.nodeName == 'svg' ? thisNode.querySelector('rect') : null; + if (!thisNode) return; + if (thisNode.getAttribute('class') == 'busy') return; + thisNode.setAttribute('class', 'busy'); + switch (thisNode.id) { + case 'push': + $$('#RuleList>label>div').forEach(item => { item.className = ''; }); + (async () => { + await HttpPost(`/saveBookSources`, RuleSources).then(json => { + if (json.isSuccess) { + let okData = json.data; + if (Array.isArray(okData)) { + let failMsg = ``; + if (RuleSources.length > okData.length) { + RuleSources.forEach(item => { + if (okData.find(x => x.bookSourceUrl == item.bookSourceUrl)) { } + else { $(`#RuleList #${item.bookSourceUrl}+*`).className += 'isError'; } + }); + failMsg = '\n推送失败的源将用红色字体标注!'; + } + alert(`批量推送源到「阅读3.0APP」\n共计: ${RuleSources.length} 条\n成功: ${okData.length} 条\n失败: ${RuleSources.length - okData.length} 条${failMsg}`); + } + else { + alert(`批量推送源到「阅读3.0APP」成功!\n共计: ${RuleSources.length} 条`); + } + } + else { + alert(`批量推送源失败!\nErrorMsg: ${json.errorMsg}`); + } + }).catch(err => { alert(`批量推送源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + case 'pull': + showTab('源列表'); + (async () => { + await HttpGet(`/getBookSources`).then(json => { + if (json.isSuccess) { + $('#RuleList').innerHTML = '' + localStorage.setItem('BookSources', JSON.stringify(RuleSources = json.data)); + RuleSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + alert(`成功拉取 ${RuleSources.length} 条源`); + } + else { + alert(`批量拉取源失败!\nErrorMsg: ${json.errorMsg}`); + } + }).catch(err => { alert(`批量拉取源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + case 'editor': + if ($('#RuleJsonString').value == '') break; + try { + json2rule(JSON.parse($('#RuleJsonString').value)); + todo(); + } catch (error) { + console.log(error); + alert(error); + } + break; + case 'conver': + showTab('编辑源'); + $('#RuleJsonString').value = JSON.stringify(rule2json(), null, 4); + break; + case 'initial': + $$('.rules textarea').forEach(item => { item.value = '' }); + todo(); + break; + case 'undo': + undo() + break; + case 'redo': + redo() + break; + case 'debug': + showTab('调试源'); + let wsOrigin = (hashParam('domain') || location.origin).replace(/^.*?:/, 'ws:').replace(/\d+$/, (port) => (parseInt(port) + 1)); + let DebugInfos = $('#DebugConsole'); + function DebugPrint(msg) { DebugInfos.value += `\n${msg}`; DebugInfos.scrollTop = DebugInfos.scrollHeight; } + let saveRule = [rule2json()]; + HttpPost(`/saveBookSources`, saveRule).then(sResult => { + if (sResult.isSuccess) { + let sKey = DebugKey.value ? DebugKey.value : '我的'; + $('#DebugConsole').value = `源《${saveRule[0].bookSourceName}》保存成功!使用搜索关键字“${sKey}”开始调试...`; + let ws = new WebSocket(`${wsOrigin}/bookSourceDebug`); + ws.onopen = () => { + ws.send(`{"tag":"${saveRule[0].bookSourceUrl}", "key":"${sKey}"}`); + }; + ws.onmessage = (msg) => { + console.log('[调试]', msg); + DebugPrint(msg.data); + }; + ws.onerror = (err) => { + throw `${err.data}`; + } + ws.onclose = () => { + thisNode.setAttribute('class', ''); + DebugPrint(`\n调试服务已关闭!`); + } + } else throw `${sResult.errorMsg}`; + }).catch(err => { + DebugPrint(`调试过程意外中止,以下是详细错误信息:\n${err}`); + thisNode.setAttribute('class', ''); + }); + return; + case 'accept': + (async () => { + let saveRule = [rule2json()]; + await HttpPost(`/saveBookSource`, saveRule[0]).then(json => { + alert(json.isSuccess ? `源《${saveRule[0].bookSourceName}》已成功保存到「阅读3.0APP」` : `源《${saveRule[0].bookSourceName}》保存失败!\nErrorMsg: ${json.errorMsg}`); + setRule(saveRule[0]); + }).catch(err => { alert(`保存源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + default: + } + setTimeout(() => { thisNode.setAttribute('class', ''); }, 500); +}); +$('#DebugKey').addEventListener('keydown', e => { + if (e.keyCode == 13) { + let clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent("click", true, false); + $('#debug').dispatchEvent(clickEvent); + } +}); +$('#Filter').addEventListener('keydown', e => { + if (e.keyCode == 13) { + let cashList = []; + $('#RuleList').innerHTML = ""; + let sKey = Filter.value ? Filter.value : ''; + if (sKey == '') { + cashList = RuleSources; + } else { + let patt = new RegExp(sKey); + RuleSources.forEach(source => { + if (patt.test(source.bookSourceUrl) || patt.test(source.bookSourceName) || patt.test(source.bookSourceGroup)) { + cashList.push(source); + } + }) + } + cashList.forEach(source => { + $('#RuleList').innerHTML += newRule(source); + }) + } +}); + +// 列表规则更改事件 +$('#RuleList').addEventListener('click', e => { + let editRule = null; + if (e.target && e.target.getAttribute('name') == 'rule') { + editRule = rule2json(); + json2rule(RuleSources.find(x => x.bookSourceUrl == e.target.id)); + } else return; + if (editRule.bookSourceUrl == '') return; + if (editRule.bookSourceName == '') editRule.bookSourceName = editRule.bookSourceUrl.replace(/.*?\/\/|\/.*/g, ''); + setRule(editRule); + localStorage.setItem('BookSources', JSON.stringify(RuleSources)); +}); +// 处理列表按钮事件 +$('.tab3>.titlebar').addEventListener('click', e => { + let thisNode = e.target; + if (thisNode.nodeName != 'BUTTON') return; + switch (thisNode.id) { + case 'Import': + let fileImport = document.createElement('input'); + fileImport.type = 'file'; + fileImport.accept = '.json'; + fileImport.addEventListener('change', () => { + let file = fileImport.files[0]; + let reader = new FileReader(); + reader.onloadend = function (evt) { + if (evt.target.readyState == FileReader.DONE) { + let fileText = evt.target.result; + try { + let fileJson = JSON.parse(fileText); + let newSources = []; + newSources.push(...fileJson); + if (window.confirm(`如何处理导入的源?\n"确定": 覆盖当前列表(不会删除APP源)\n"取消": 插入列表尾部(自动忽略重复源)`)) { + localStorage.setItem('BookSources', JSON.stringify(RuleSources = newSources)); + $('#RuleList').innerHTML = '' + RuleSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + } + else { + newSources = newSources.filter(item => !JSON.stringify(RuleSources).includes(item.bookSourceUrl)); + RuleSources.push(...newSources); + localStorage.setItem('BookSources', JSON.stringify(RuleSources)); + newSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + } + alert(`成功导入 ${newSources.length} 条源`); + } + catch (err) { + alert(`导入源文件失败!\n${err}`); + } + } + }; + reader.readAsText(file); + }, false); + fileImport.click(); + break; + case 'Export': + let fileExport = document.createElement('a'); + fileExport.download = `Rules${Date().replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, '$2$1$3').replace(/:/g, '')}.json`; + let myBlob = new Blob([JSON.stringify(RuleSources, null, 4)], { type: "application/json" }); + fileExport.href = window.URL.createObjectURL(myBlob); + fileExport.click(); + break; + case 'Delete': + let selectRule = $('#RuleList input:checked'); + if (!selectRule) { + alert(`没有源被选中!`); + return; + } + if (confirm(`确定要删除选定源吗?\n(同时删除APP内源)`)) { + let selectRuleUrl = selectRule.id; + let deleteSources = RuleSources.filter(item => item.bookSourceUrl == selectRuleUrl); // 提取待删除的源 + let laveSources = RuleSources.filter(item => !(item.bookSourceUrl == selectRuleUrl)); // 提取待留下的源 + HttpPost(`/deleteBookSources`, deleteSources).then(json => { + if (json.isSuccess) { + let selectNode = document.getElementById(selectRuleUrl).parentNode; + selectNode.parentNode.removeChild(selectNode); + localStorage.setItem('BookSources', JSON.stringify(RuleSources = laveSources)); + if ($('#bookSourceUrl').value == selectRuleUrl) { + $$('.rules textarea').forEach(item => { item.value = '' }); + todo(); + } + console.log(deleteSources); + console.log(`以上源已删除!`) + } + }).catch(err => { alert(`删除源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + } + break; + case 'ClrAll': + if (confirm(`确定要清空当前源列表吗?\n(不会删除APP内源)`)) { + localStorage.setItem('BookSources', JSON.stringify(RuleSources = [])); + $('#RuleList').innerHTML = '' + } + break; + default: + } +}); diff --git a/app/src/main/assets/web/bookshelf/css/about.b9bb4fe0.css b/app/src/main/assets/web/bookshelf/css/about.b9bb4fe0.css new file mode 100644 index 000000000..5ecd15c19 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/css/about.b9bb4fe0.css @@ -0,0 +1 @@ +@charset "UTF-8";@font-face{font-family:FZZCYSK;src:local("☺"),url(../fonts/shelffont.6c094b6d.ttf);font-style:normal;font-weight:400}.index-wrapper[data-v-3cffa1cb]{height:100%;width:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.index-wrapper .navigation-wrapper[data-v-3cffa1cb]{width:260px;min-width:260px;padding:48px 36px;background-color:#f7f7f7}.index-wrapper .navigation-wrapper .navigation-title[data-v-3cffa1cb]{font-size:24px;font-weight:500;font-family:FZZCYSK}.index-wrapper .navigation-wrapper .navigation-sub-title[data-v-3cffa1cb]{font-size:16px;font-weight:300;font-family:FZZCYSK;margin-top:16px;color:#b1b1b1}.index-wrapper .navigation-wrapper .search-wrapper .search-input[data-v-3cffa1cb]{border-radius:50%;margin-top:24px}.index-wrapper .navigation-wrapper .search-wrapper .search-input[data-v-3cffa1cb] .el-input__inner{border-radius:50px;border-color:#e3e3e3}.index-wrapper .navigation-wrapper .recent-wrapper[data-v-3cffa1cb]{margin-top:36px}.index-wrapper .navigation-wrapper .recent-wrapper .recent-title[data-v-3cffa1cb]{font-size:14px;color:#b1b1b1;font-family:FZZCYSK}.index-wrapper .navigation-wrapper .recent-wrapper .reading-recent[data-v-3cffa1cb]{margin:18px 0}.index-wrapper .navigation-wrapper .recent-wrapper .reading-recent .recent-book[data-v-3cffa1cb]{font-size:10px;cursor:pointer}.index-wrapper .navigation-wrapper .setting-wrapper[data-v-3cffa1cb]{margin-top:36px}.index-wrapper .navigation-wrapper .setting-wrapper .setting-title[data-v-3cffa1cb]{font-size:14px;color:#b1b1b1;font-family:FZZCYSK}.index-wrapper .navigation-wrapper .setting-wrapper .no-point[data-v-3cffa1cb]{pointer-events:none}.index-wrapper .navigation-wrapper .setting-wrapper .setting-connect[data-v-3cffa1cb]{font-size:8px;margin-top:16px;cursor:pointer}.index-wrapper .navigation-wrapper .bottom-icons[data-v-3cffa1cb]{position:fixed;bottom:0;height:120px;width:260px;-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.index-wrapper .shelf-wrapper[data-v-3cffa1cb]{padding:48px 48px;width:100%}.index-wrapper .shelf-wrapper[data-v-3cffa1cb] .el-icon-loading{font-size:36px;color:#b5b5b5}.index-wrapper .shelf-wrapper[data-v-3cffa1cb] .el-loading-text{font-weight:500;color:#b5b5b5}.index-wrapper .shelf-wrapper .books-wrapper[data-v-3cffa1cb]{height:100%;overflow:scroll}.index-wrapper .shelf-wrapper .books-wrapper .wrapper[data-v-3cffa1cb]{display:grid;grid-template-columns:repeat(auto-fill,380px);-ms-flex-pack:distribute;justify-content:space-around;grid-gap:10px}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book[data-v-3cffa1cb]{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;display:-webkit-box;display:-ms-flexbox;display:flex;cursor:pointer;margin-bottom:18px;padding:24px 24px;width:360px;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-pack:distribute;justify-content:space-around}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .cover-img .cover[data-v-3cffa1cb],.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .cover-img[data-v-3cffa1cb]{width:84px;height:112px}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info[data-v-3cffa1cb]{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:distribute;justify-content:space-around;-webkit-box-align:left;-ms-flex-align:left;align-items:left;height:112px;margin-left:20px;-webkit-box-flex:1;-ms-flex:1;flex:1}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info .name[data-v-3cffa1cb]{width:-webkit-fit-content;width:-moz-fit-content;width:fit-content;font-size:16px;font-weight:700;color:#33373d}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info .sub[data-v-3cffa1cb]{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;font-size:12px;font-weight:600;color:#6b6b6b}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info .sub .dot[data-v-3cffa1cb]{margin:0 7px}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info .dur-chapter[data-v-3cffa1cb],.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info .intro[data-v-3cffa1cb],.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book .info .last-chapter[data-v-3cffa1cb]{color:#969ba3;font-size:13px;margin-top:3px;font-weight:500;word-wrap:break-word;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;text-align:left}.index-wrapper .shelf-wrapper .books-wrapper .wrapper .book[data-v-3cffa1cb]:hover{background:rgba(0,0,0,.1);-webkit-transition-duration:.5s;transition-duration:.5s}.index-wrapper .shelf-wrapper .books-wrapper .wrapper[data-v-3cffa1cb]:last-child{margin-right:auto}.index-wrapper .shelf-wrapper .books-wrapper[data-v-3cffa1cb]::-webkit-scrollbar{width:0!important} \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/css/app.e4c919b7.css b/app/src/main/assets/web/bookshelf/css/app.e4c919b7.css new file mode 100644 index 000000000..275e8c3ec --- /dev/null +++ b/app/src/main/assets/web/bookshelf/css/app.e4c919b7.css @@ -0,0 +1 @@ +#app{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#2c3e50;margin:0;height:100%} \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/css/chunk-vendors.8a465a1d.css b/app/src/main/assets/web/bookshelf/css/chunk-vendors.8a465a1d.css new file mode 100644 index 000000000..367efc3e0 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/css/chunk-vendors.8a465a1d.css @@ -0,0 +1 @@ +.el-message__closeBtn:focus,.el-message__content:focus{outline-width:0}.el-message{min-width:380px;-webkit-box-sizing:border-box;box-sizing:border-box;border-radius:4px;border-width:1px;border-style:solid;border-color:#ebeef5;position:fixed;left:50%;top:20px;-webkit-transform:translateX(-50%);transform:translateX(-50%);background-color:#edf2fc;-webkit-transition:opacity .3s,top .4s,-webkit-transform .4s;transition:opacity .3s,top .4s,-webkit-transform .4s;transition:opacity .3s,transform .4s,top .4s;transition:opacity .3s,transform .4s,top .4s,-webkit-transform .4s;overflow:hidden;padding:15px 15px 15px 20px;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.el-message.is-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.el-message.is-closable .el-message__content{padding-right:16px}.el-message p{margin:0}.el-message--info .el-message__content{color:#909399}.el-message--success{background-color:#f0f9eb;border-color:#e1f3d8}.el-message--success .el-message__content{color:#67c23a}.el-message--warning{background-color:#fdf6ec;border-color:#faecd8}.el-message--warning .el-message__content{color:#e6a23c}.el-message--error{background-color:#fef0f0;border-color:#fde2e2}.el-message--error .el-message__content{color:#f56c6c}.el-message__icon{margin-right:10px}.el-message__content{padding:0;font-size:14px;line-height:1}.el-message__closeBtn{position:absolute;top:50%;right:15px;-webkit-transform:translateY(-50%);transform:translateY(-50%);cursor:pointer;color:#c0c4cc;font-size:16px}.el-message__closeBtn:hover{color:#909399}.el-message .el-icon-success{color:#67c23a}.el-message .el-icon-error{color:#f56c6c}.el-message .el-icon-info{color:#909399}.el-message .el-icon-warning{color:#e6a23c}.el-message-fade-enter,.el-message-fade-leave-active{opacity:0;-webkit-transform:translate(-50%,-100%);transform:translate(-50%,-100%)}.el-fade-in-enter,.el-fade-in-leave-active,.el-fade-in-linear-enter,.el-fade-in-linear-leave,.el-fade-in-linear-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active{opacity:0}.el-fade-in-linear-enter-active,.el-fade-in-linear-leave-active,.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity .2s linear;transition:opacity .2s linear}.el-fade-in-enter-active,.el-fade-in-leave-active,.el-zoom-in-center-enter-active,.el-zoom-in-center-leave-active{-webkit-transition:all .3s cubic-bezier(.55,0,.1,1);transition:all .3s cubic-bezier(.55,0,.1,1)}.el-zoom-in-center-enter,.el-zoom-in-center-leave-active{opacity:0;-webkit-transform:scaleX(0);transform:scaleX(0)}.el-zoom-in-top-enter-active,.el-zoom-in-top-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);transition:opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);transition:transform .3s cubic-bezier(.23,1,.32,1),opacity .3s cubic-bezier(.23,1,.32,1);transition:transform .3s cubic-bezier(.23,1,.32,1),opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center top;transform-origin:center top}.el-zoom-in-top-enter,.el-zoom-in-top-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.el-zoom-in-bottom-enter-active,.el-zoom-in-bottom-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);transition:opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);transition:transform .3s cubic-bezier(.23,1,.32,1),opacity .3s cubic-bezier(.23,1,.32,1);transition:transform .3s cubic-bezier(.23,1,.32,1),opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center bottom;transform-origin:center bottom}.el-zoom-in-bottom-enter,.el-zoom-in-bottom-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.el-zoom-in-left-enter-active,.el-zoom-in-left-leave-active{opacity:1;-webkit-transform:scale(1);transform:scale(1);-webkit-transition:opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);transition:opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);transition:transform .3s cubic-bezier(.23,1,.32,1),opacity .3s cubic-bezier(.23,1,.32,1);transition:transform .3s cubic-bezier(.23,1,.32,1),opacity .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:top left;transform-origin:top left}.el-zoom-in-left-enter,.el-zoom-in-left-leave-active{opacity:0;-webkit-transform:scale(.45);transform:scale(.45)}.collapse-transition{-webkit-transition:height .3s ease-in-out,padding-top .3s ease-in-out,padding-bottom .3s ease-in-out;transition:height .3s ease-in-out,padding-top .3s ease-in-out,padding-bottom .3s ease-in-out}.horizontal-collapse-transition{-webkit-transition:width .3s ease-in-out,padding-left .3s ease-in-out,padding-right .3s ease-in-out;transition:width .3s ease-in-out,padding-left .3s ease-in-out,padding-right .3s ease-in-out}.el-list-enter-active,.el-list-leave-active{-webkit-transition:all 1s;transition:all 1s}.el-list-enter,.el-list-leave-active{opacity:0;-webkit-transform:translateY(-30px);transform:translateY(-30px)}.el-opacity-transition{-webkit-transition:opacity .3s cubic-bezier(.55,0,.1,1);transition:opacity .3s cubic-bezier(.55,0,.1,1)}@font-face{font-family:element-icons;src:url(../fonts/element-icons.535877f5.woff) format("woff"),url(../fonts/element-icons.732389de.ttf) format("truetype");font-weight:400;font-display:"auto";font-style:normal}[class*=" el-icon-"],[class^=el-icon-]{font-family:element-icons!important;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;line-height:1;vertical-align:baseline;display:inline-block;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.el-icon-ice-cream-round:before{content:"\e6a0"}.el-icon-ice-cream-square:before{content:"\e6a3"}.el-icon-lollipop:before{content:"\e6a4"}.el-icon-potato-strips:before{content:"\e6a5"}.el-icon-milk-tea:before{content:"\e6a6"}.el-icon-ice-drink:before{content:"\e6a7"}.el-icon-ice-tea:before{content:"\e6a9"}.el-icon-coffee:before{content:"\e6aa"}.el-icon-orange:before{content:"\e6ab"}.el-icon-pear:before{content:"\e6ac"}.el-icon-apple:before{content:"\e6ad"}.el-icon-cherry:before{content:"\e6ae"}.el-icon-watermelon:before{content:"\e6af"}.el-icon-grape:before{content:"\e6b0"}.el-icon-refrigerator:before{content:"\e6b1"}.el-icon-goblet-square-full:before{content:"\e6b2"}.el-icon-goblet-square:before{content:"\e6b3"}.el-icon-goblet-full:before{content:"\e6b4"}.el-icon-goblet:before{content:"\e6b5"}.el-icon-cold-drink:before{content:"\e6b6"}.el-icon-coffee-cup:before{content:"\e6b8"}.el-icon-water-cup:before{content:"\e6b9"}.el-icon-hot-water:before{content:"\e6ba"}.el-icon-ice-cream:before{content:"\e6bb"}.el-icon-dessert:before{content:"\e6bc"}.el-icon-sugar:before{content:"\e6bd"}.el-icon-tableware:before{content:"\e6be"}.el-icon-burger:before{content:"\e6bf"}.el-icon-knife-fork:before{content:"\e6c1"}.el-icon-fork-spoon:before{content:"\e6c2"}.el-icon-chicken:before{content:"\e6c3"}.el-icon-food:before{content:"\e6c4"}.el-icon-dish-1:before{content:"\e6c5"}.el-icon-dish:before{content:"\e6c6"}.el-icon-moon-night:before{content:"\e6ee"}.el-icon-moon:before{content:"\e6f0"}.el-icon-cloudy-and-sunny:before{content:"\e6f1"}.el-icon-partly-cloudy:before{content:"\e6f2"}.el-icon-cloudy:before{content:"\e6f3"}.el-icon-sunny:before{content:"\e6f6"}.el-icon-sunset:before{content:"\e6f7"}.el-icon-sunrise-1:before{content:"\e6f8"}.el-icon-sunrise:before{content:"\e6f9"}.el-icon-heavy-rain:before{content:"\e6fa"}.el-icon-lightning:before{content:"\e6fb"}.el-icon-light-rain:before{content:"\e6fc"}.el-icon-wind-power:before{content:"\e6fd"}.el-icon-baseball:before{content:"\e712"}.el-icon-soccer:before{content:"\e713"}.el-icon-football:before{content:"\e715"}.el-icon-basketball:before{content:"\e716"}.el-icon-ship:before{content:"\e73f"}.el-icon-truck:before{content:"\e740"}.el-icon-bicycle:before{content:"\e741"}.el-icon-mobile-phone:before{content:"\e6d3"}.el-icon-service:before{content:"\e6d4"}.el-icon-key:before{content:"\e6e2"}.el-icon-unlock:before{content:"\e6e4"}.el-icon-lock:before{content:"\e6e5"}.el-icon-watch:before{content:"\e6fe"}.el-icon-watch-1:before{content:"\e6ff"}.el-icon-timer:before{content:"\e702"}.el-icon-alarm-clock:before{content:"\e703"}.el-icon-map-location:before{content:"\e704"}.el-icon-delete-location:before{content:"\e705"}.el-icon-add-location:before{content:"\e706"}.el-icon-location-information:before{content:"\e707"}.el-icon-location-outline:before{content:"\e708"}.el-icon-location:before{content:"\e79e"}.el-icon-place:before{content:"\e709"}.el-icon-discover:before{content:"\e70a"}.el-icon-first-aid-kit:before{content:"\e70b"}.el-icon-trophy-1:before{content:"\e70c"}.el-icon-trophy:before{content:"\e70d"}.el-icon-medal:before{content:"\e70e"}.el-icon-medal-1:before{content:"\e70f"}.el-icon-stopwatch:before{content:"\e710"}.el-icon-mic:before{content:"\e711"}.el-icon-copy-document:before{content:"\e718"}.el-icon-full-screen:before{content:"\e719"}.el-icon-switch-button:before{content:"\e71b"}.el-icon-aim:before{content:"\e71c"}.el-icon-crop:before{content:"\e71d"}.el-icon-odometer:before{content:"\e71e"}.el-icon-time:before{content:"\e71f"}.el-icon-bangzhu:before{content:"\e724"}.el-icon-close-notification:before{content:"\e726"}.el-icon-microphone:before{content:"\e727"}.el-icon-turn-off-microphone:before{content:"\e728"}.el-icon-position:before{content:"\e729"}.el-icon-postcard:before{content:"\e72a"}.el-icon-message:before{content:"\e72b"}.el-icon-chat-line-square:before{content:"\e72d"}.el-icon-chat-dot-square:before{content:"\e72e"}.el-icon-chat-dot-round:before{content:"\e72f"}.el-icon-chat-square:before{content:"\e730"}.el-icon-chat-line-round:before{content:"\e731"}.el-icon-chat-round:before{content:"\e732"}.el-icon-set-up:before{content:"\e733"}.el-icon-turn-off:before{content:"\e734"}.el-icon-open:before{content:"\e735"}.el-icon-connection:before{content:"\e736"}.el-icon-link:before{content:"\e737"}.el-icon-cpu:before{content:"\e738"}.el-icon-thumb:before{content:"\e739"}.el-icon-female:before{content:"\e73a"}.el-icon-male:before{content:"\e73b"}.el-icon-guide:before{content:"\e73c"}.el-icon-news:before{content:"\e73e"}.el-icon-price-tag:before{content:"\e744"}.el-icon-discount:before{content:"\e745"}.el-icon-wallet:before{content:"\e747"}.el-icon-coin:before{content:"\e748"}.el-icon-money:before{content:"\e749"}.el-icon-bank-card:before{content:"\e74a"}.el-icon-box:before{content:"\e74b"}.el-icon-present:before{content:"\e74c"}.el-icon-sell:before{content:"\e6d5"}.el-icon-sold-out:before{content:"\e6d6"}.el-icon-shopping-bag-2:before{content:"\e74d"}.el-icon-shopping-bag-1:before{content:"\e74e"}.el-icon-shopping-cart-2:before{content:"\e74f"}.el-icon-shopping-cart-1:before{content:"\e750"}.el-icon-shopping-cart-full:before{content:"\e751"}.el-icon-smoking:before{content:"\e752"}.el-icon-no-smoking:before{content:"\e753"}.el-icon-house:before{content:"\e754"}.el-icon-table-lamp:before{content:"\e755"}.el-icon-school:before{content:"\e756"}.el-icon-office-building:before{content:"\e757"}.el-icon-toilet-paper:before{content:"\e758"}.el-icon-notebook-2:before{content:"\e759"}.el-icon-notebook-1:before{content:"\e75a"}.el-icon-files:before{content:"\e75b"}.el-icon-collection:before{content:"\e75c"}.el-icon-receiving:before{content:"\e75d"}.el-icon-suitcase-1:before{content:"\e760"}.el-icon-suitcase:before{content:"\e761"}.el-icon-film:before{content:"\e763"}.el-icon-collection-tag:before{content:"\e765"}.el-icon-data-analysis:before{content:"\e766"}.el-icon-pie-chart:before{content:"\e767"}.el-icon-data-board:before{content:"\e768"}.el-icon-data-line:before{content:"\e76d"}.el-icon-reading:before{content:"\e769"}.el-icon-magic-stick:before{content:"\e76a"}.el-icon-coordinate:before{content:"\e76b"}.el-icon-mouse:before{content:"\e76c"}.el-icon-brush:before{content:"\e76e"}.el-icon-headset:before{content:"\e76f"}.el-icon-umbrella:before{content:"\e770"}.el-icon-scissors:before{content:"\e771"}.el-icon-mobile:before{content:"\e773"}.el-icon-attract:before{content:"\e774"}.el-icon-monitor:before{content:"\e775"}.el-icon-search:before{content:"\e778"}.el-icon-takeaway-box:before{content:"\e77a"}.el-icon-paperclip:before{content:"\e77d"}.el-icon-printer:before{content:"\e77e"}.el-icon-document-add:before{content:"\e782"}.el-icon-document:before{content:"\e785"}.el-icon-document-checked:before{content:"\e786"}.el-icon-document-copy:before{content:"\e787"}.el-icon-document-delete:before{content:"\e788"}.el-icon-document-remove:before{content:"\e789"}.el-icon-tickets:before{content:"\e78b"}.el-icon-folder-checked:before{content:"\e77f"}.el-icon-folder-delete:before{content:"\e780"}.el-icon-folder-remove:before{content:"\e781"}.el-icon-folder-add:before{content:"\e783"}.el-icon-folder-opened:before{content:"\e784"}.el-icon-folder:before{content:"\e78a"}.el-icon-edit-outline:before{content:"\e764"}.el-icon-edit:before{content:"\e78c"}.el-icon-date:before{content:"\e78e"}.el-icon-c-scale-to-original:before{content:"\e7c6"}.el-icon-view:before{content:"\e6ce"}.el-icon-loading:before{content:"\e6cf"}.el-icon-rank:before{content:"\e6d1"}.el-icon-sort-down:before{content:"\e7c4"}.el-icon-sort-up:before{content:"\e7c5"}.el-icon-sort:before{content:"\e6d2"}.el-icon-finished:before{content:"\e6cd"}.el-icon-refresh-left:before{content:"\e6c7"}.el-icon-refresh-right:before{content:"\e6c8"}.el-icon-refresh:before{content:"\e6d0"}.el-icon-video-play:before{content:"\e7c0"}.el-icon-video-pause:before{content:"\e7c1"}.el-icon-d-arrow-right:before{content:"\e6dc"}.el-icon-d-arrow-left:before{content:"\e6dd"}.el-icon-arrow-up:before{content:"\e6e1"}.el-icon-arrow-down:before{content:"\e6df"}.el-icon-arrow-right:before{content:"\e6e0"}.el-icon-arrow-left:before{content:"\e6de"}.el-icon-top-right:before{content:"\e6e7"}.el-icon-top-left:before{content:"\e6e8"}.el-icon-top:before{content:"\e6e6"}.el-icon-bottom:before{content:"\e6eb"}.el-icon-right:before{content:"\e6e9"}.el-icon-back:before{content:"\e6ea"}.el-icon-bottom-right:before{content:"\e6ec"}.el-icon-bottom-left:before{content:"\e6ed"}.el-icon-caret-top:before{content:"\e78f"}.el-icon-caret-bottom:before{content:"\e790"}.el-icon-caret-right:before{content:"\e791"}.el-icon-caret-left:before{content:"\e792"}.el-icon-d-caret:before{content:"\e79a"}.el-icon-share:before{content:"\e793"}.el-icon-menu:before{content:"\e798"}.el-icon-s-grid:before{content:"\e7a6"}.el-icon-s-check:before{content:"\e7a7"}.el-icon-s-data:before{content:"\e7a8"}.el-icon-s-opportunity:before{content:"\e7aa"}.el-icon-s-custom:before{content:"\e7ab"}.el-icon-s-claim:before{content:"\e7ad"}.el-icon-s-finance:before{content:"\e7ae"}.el-icon-s-comment:before{content:"\e7af"}.el-icon-s-flag:before{content:"\e7b0"}.el-icon-s-marketing:before{content:"\e7b1"}.el-icon-s-shop:before{content:"\e7b4"}.el-icon-s-open:before{content:"\e7b5"}.el-icon-s-management:before{content:"\e7b6"}.el-icon-s-ticket:before{content:"\e7b7"}.el-icon-s-release:before{content:"\e7b8"}.el-icon-s-home:before{content:"\e7b9"}.el-icon-s-promotion:before{content:"\e7ba"}.el-icon-s-operation:before{content:"\e7bb"}.el-icon-s-unfold:before{content:"\e7bc"}.el-icon-s-fold:before{content:"\e7a9"}.el-icon-s-platform:before{content:"\e7bd"}.el-icon-s-order:before{content:"\e7be"}.el-icon-s-cooperation:before{content:"\e7bf"}.el-icon-bell:before{content:"\e725"}.el-icon-message-solid:before{content:"\e799"}.el-icon-video-camera:before{content:"\e772"}.el-icon-video-camera-solid:before{content:"\e796"}.el-icon-camera:before{content:"\e779"}.el-icon-camera-solid:before{content:"\e79b"}.el-icon-download:before{content:"\e77c"}.el-icon-upload2:before{content:"\e77b"}.el-icon-upload:before{content:"\e7c3"}.el-icon-picture-outline-round:before{content:"\e75f"}.el-icon-picture-outline:before{content:"\e75e"}.el-icon-picture:before{content:"\e79f"}.el-icon-close:before{content:"\e6db"}.el-icon-check:before{content:"\e6da"}.el-icon-plus:before{content:"\e6d9"}.el-icon-minus:before{content:"\e6d8"}.el-icon-help:before{content:"\e73d"}.el-icon-s-help:before{content:"\e7b3"}.el-icon-circle-close:before{content:"\e78d"}.el-icon-circle-check:before{content:"\e720"}.el-icon-circle-plus-outline:before{content:"\e723"}.el-icon-remove-outline:before{content:"\e722"}.el-icon-zoom-out:before{content:"\e776"}.el-icon-zoom-in:before{content:"\e777"}.el-icon-error:before{content:"\e79d"}.el-icon-success:before{content:"\e79c"}.el-icon-circle-plus:before{content:"\e7a0"}.el-icon-remove:before{content:"\e7a2"}.el-icon-info:before{content:"\e7a1"}.el-icon-question:before{content:"\e7a4"}.el-icon-warning-outline:before{content:"\e6c9"}.el-icon-warning:before{content:"\e7a3"}.el-icon-goods:before{content:"\e7c2"}.el-icon-s-goods:before{content:"\e7b2"}.el-icon-star-off:before{content:"\e717"}.el-icon-star-on:before{content:"\e797"}.el-icon-more-outline:before{content:"\e6cc"}.el-icon-more:before{content:"\e794"}.el-icon-phone-outline:before{content:"\e6cb"}.el-icon-phone:before{content:"\e795"}.el-icon-user:before{content:"\e6e3"}.el-icon-user-solid:before{content:"\e7a5"}.el-icon-setting:before{content:"\e6ca"}.el-icon-s-tools:before{content:"\e7ac"}.el-icon-delete:before{content:"\e6d7"}.el-icon-delete-solid:before{content:"\e7c9"}.el-icon-eleme:before{content:"\e7c7"}.el-icon-platform-eleme:before{content:"\e7ca"}.el-icon-loading{-webkit-animation:rotating 2s linear infinite;animation:rotating 2s linear infinite}.el-icon--right{margin-left:5px}.el-icon--left{margin-right:5px}@-webkit-keyframes rotating{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes rotating{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.el-button,.el-input__inner{-webkit-appearance:none;outline:0}.el-message-box,.el-popup-parent--hidden{overflow:hidden}.v-modal-enter{-webkit-animation:v-modal-in .2s ease;animation:v-modal-in .2s ease}.v-modal-leave{-webkit-animation:v-modal-out .2s ease forwards;animation:v-modal-out .2s ease forwards}@-webkit-keyframes v-modal-in{0%{opacity:0}}@keyframes v-modal-in{0%{opacity:0}}@-webkit-keyframes v-modal-out{to{opacity:0}}@keyframes v-modal-out{to{opacity:0}}.v-modal{position:fixed;left:0;top:0;width:100%;height:100%;opacity:.5;background:#000}.el-message-box{display:inline-block;width:420px;padding-bottom:10px;vertical-align:middle;background-color:#fff;border-radius:4px;border:1px solid #ebeef5;font-size:18px;-webkit-box-shadow:0 2px 12px 0 rgba(0,0,0,.1);box-shadow:0 2px 12px 0 rgba(0,0,0,.1);text-align:left;-webkit-backface-visibility:hidden;backface-visibility:hidden}.el-message-box__wrapper{position:fixed;top:0;bottom:0;left:0;right:0;text-align:center}.el-message-box__wrapper:after{content:"";display:inline-block;height:100%;width:0;vertical-align:middle}.el-message-box__header{position:relative;padding:15px 15px 10px}.el-message-box__title{padding-left:0;margin-bottom:0;font-size:18px;line-height:1;color:#303133}.el-message-box__headerbtn{position:absolute;top:15px;right:15px;padding:0;border:none;outline:0;background:0 0;font-size:16px;cursor:pointer}.el-message-box__headerbtn .el-message-box__close{color:#909399}.el-message-box__headerbtn:focus .el-message-box__close,.el-message-box__headerbtn:hover .el-message-box__close{color:#409eff}.el-message-box__content{padding:10px 15px;color:#606266;font-size:14px}.el-message-box__container{position:relative}.el-message-box__input{padding-top:15px}.el-message-box__input input.invalid,.el-message-box__input input.invalid:focus{border-color:#f56c6c}.el-message-box__status{position:absolute;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);font-size:24px!important}.el-message-box__status:before{padding-left:1px}.el-message-box__status+.el-message-box__message{padding-left:36px;padding-right:12px}.el-message-box__status.el-icon-success{color:#67c23a}.el-message-box__status.el-icon-info{color:#909399}.el-message-box__status.el-icon-warning{color:#e6a23c}.el-message-box__status.el-icon-error{color:#f56c6c}.el-message-box__message{margin:0}.el-message-box__message p{margin:0;line-height:24px}.el-message-box__errormsg{color:#f56c6c;font-size:12px;min-height:18px;margin-top:2px}.el-message-box__btns{padding:5px 15px 0;text-align:right}.el-message-box__btns button:nth-child(2){margin-left:10px}.el-message-box__btns-reverse{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.el-message-box--center{padding-bottom:30px}.el-message-box--center .el-message-box__header{padding-top:30px}.el-message-box--center .el-message-box__title{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.el-message-box--center .el-message-box__status{position:relative;top:auto;padding-right:5px;text-align:center;-webkit-transform:translateY(-1px);transform:translateY(-1px)}.el-message-box--center .el-message-box__message{margin-left:0}.el-message-box--center .el-message-box__btns,.el-message-box--center .el-message-box__content{text-align:center}.el-message-box--center .el-message-box__content{padding-left:27px;padding-right:27px}.msgbox-fade-enter-active{-webkit-animation:msgbox-fade-in .3s;animation:msgbox-fade-in .3s}.msgbox-fade-leave-active{-webkit-animation:msgbox-fade-out .3s;animation:msgbox-fade-out .3s}@-webkit-keyframes msgbox-fade-in{0%{-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes msgbox-fade-in{0%{-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@-webkit-keyframes msgbox-fade-out{0%{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}to{-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0);opacity:0}}@keyframes msgbox-fade-out{0%{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}to{-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0);opacity:0}}.el-loading-parent--relative{position:relative!important}.el-loading-parent--hidden{overflow:hidden!important}.el-loading-mask{position:absolute;z-index:2000;background-color:hsla(0,0%,100%,.9);margin:0;top:0;right:0;bottom:0;left:0;-webkit-transition:opacity .3s;transition:opacity .3s}.el-loading-mask.is-fullscreen{position:fixed}.el-loading-mask.is-fullscreen .el-loading-spinner{margin-top:-25px}.el-loading-mask.is-fullscreen .el-loading-spinner .circular{height:50px;width:50px}.el-loading-spinner{top:50%;margin-top:-21px;width:100%;text-align:center;position:absolute}.el-loading-spinner .el-loading-text{color:#409eff;margin:3px 0;font-size:14px}.el-loading-spinner .circular{height:42px;width:42px;-webkit-animation:loading-rotate 2s linear infinite;animation:loading-rotate 2s linear infinite}.el-loading-spinner .path{-webkit-animation:loading-dash 1.5s ease-in-out infinite;animation:loading-dash 1.5s ease-in-out infinite;stroke-dasharray:90,150;stroke-dashoffset:0;stroke-width:2;stroke:#409eff;stroke-linecap:round}.el-loading-spinner i{color:#409eff}.el-loading-fade-enter,.el-loading-fade-leave-active{opacity:0}@-webkit-keyframes loading-rotate{to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes loading-rotate{to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@-webkit-keyframes loading-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-40px}to{stroke-dasharray:90,150;stroke-dashoffset:-120px}}@keyframes loading-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-40px}to{stroke-dasharray:90,150;stroke-dashoffset:-120px}}.el-textarea{position:relative;display:inline-block;width:100%;vertical-align:bottom;font-size:14px}.el-textarea__inner{display:block;resize:vertical;padding:5px 15px;line-height:1.5;-webkit-box-sizing:border-box;box-sizing:border-box;width:100%;font-size:inherit;color:#606266;background-color:#fff;background-image:none;border:1px solid #dcdfe6;border-radius:4px;-webkit-transition:border-color .2s cubic-bezier(.645,.045,.355,1);transition:border-color .2s cubic-bezier(.645,.045,.355,1)}.el-textarea__inner::-webkit-input-placeholder{color:#c0c4cc}.el-textarea__inner:-ms-input-placeholder{color:#c0c4cc}.el-textarea__inner::-ms-input-placeholder{color:#c0c4cc}.el-textarea__inner::-moz-placeholder{color:#c0c4cc}.el-textarea__inner::placeholder{color:#c0c4cc}.el-textarea__inner:hover{border-color:#c0c4cc}.el-textarea__inner:focus{outline:0;border-color:#409eff}.el-textarea .el-input__count{color:#909399;background:#fff;position:absolute;font-size:12px;bottom:5px;right:10px}.el-textarea.is-disabled .el-textarea__inner{background-color:#f5f7fa;border-color:#e4e7ed;color:#c0c4cc;cursor:not-allowed}.el-textarea.is-disabled .el-textarea__inner::-webkit-input-placeholder{color:#c0c4cc}.el-textarea.is-disabled .el-textarea__inner:-ms-input-placeholder{color:#c0c4cc}.el-textarea.is-disabled .el-textarea__inner::-ms-input-placeholder{color:#c0c4cc}.el-textarea.is-disabled .el-textarea__inner::-moz-placeholder{color:#c0c4cc}.el-textarea.is-disabled .el-textarea__inner::placeholder{color:#c0c4cc}.el-textarea.is-exceed .el-textarea__inner{border-color:#f56c6c}.el-textarea.is-exceed .el-input__count{color:#f56c6c}.el-input{position:relative;font-size:14px;display:inline-block;width:100%}.el-input::-webkit-scrollbar{z-index:11;width:6px}.el-input::-webkit-scrollbar:horizontal{height:6px}.el-input::-webkit-scrollbar-thumb{border-radius:5px;width:6px;background:#b4bccc}.el-input::-webkit-scrollbar-corner,.el-input::-webkit-scrollbar-track{background:#fff}.el-input::-webkit-scrollbar-track-piece{background:#fff;width:6px}.el-input .el-input__clear{color:#c0c4cc;font-size:14px;cursor:pointer;-webkit-transition:color .2s cubic-bezier(.645,.045,.355,1);transition:color .2s cubic-bezier(.645,.045,.355,1)}.el-input .el-input__clear:hover{color:#909399}.el-input .el-input__count{height:100%;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;color:#909399;font-size:12px}.el-input .el-input__count .el-input__count-inner{background:#fff;line-height:normal;display:inline-block;padding:0 5px}.el-input__inner{-webkit-appearance:none;background-color:#fff;background-image:none;border-radius:4px;border:1px solid #dcdfe6;-webkit-box-sizing:border-box;box-sizing:border-box;color:#606266;display:inline-block;font-size:inherit;height:40px;line-height:40px;outline:0;padding:0 15px;-webkit-transition:border-color .2s cubic-bezier(.645,.045,.355,1);transition:border-color .2s cubic-bezier(.645,.045,.355,1);width:100%}.el-input__prefix,.el-input__suffix{position:absolute;top:0;-webkit-transition:all .3s;text-align:center;height:100%;color:#c0c4cc}.el-input__inner::-ms-reveal{display:none}.el-input__inner::-webkit-input-placeholder{color:#c0c4cc}.el-input__inner:-ms-input-placeholder{color:#c0c4cc}.el-input__inner::-ms-input-placeholder{color:#c0c4cc}.el-input__inner::-moz-placeholder{color:#c0c4cc}.el-input__inner::placeholder{color:#c0c4cc}.el-input__inner:hover{border-color:#c0c4cc}.el-input.is-active .el-input__inner,.el-input__inner:focus{border-color:#409eff;outline:0}.el-input__suffix{right:5px;-webkit-transition:all .3s;transition:all .3s;pointer-events:none}.el-input__suffix-inner{pointer-events:all}.el-input__prefix{left:5px}.el-input__icon,.el-input__prefix{-webkit-transition:all .3s;transition:all .3s}.el-input__icon{height:100%;width:25px;text-align:center;line-height:40px}.el-input__icon:after{content:"";height:100%;width:0;display:inline-block;vertical-align:middle}.el-input__validateIcon{pointer-events:none}.el-input.is-disabled .el-input__inner{background-color:#f5f7fa;border-color:#e4e7ed;color:#c0c4cc;cursor:not-allowed}.el-input.is-disabled .el-input__inner::-webkit-input-placeholder{color:#c0c4cc}.el-input.is-disabled .el-input__inner:-ms-input-placeholder{color:#c0c4cc}.el-input.is-disabled .el-input__inner::-ms-input-placeholder{color:#c0c4cc}.el-input.is-disabled .el-input__inner::-moz-placeholder{color:#c0c4cc}.el-input.is-disabled .el-input__inner::placeholder{color:#c0c4cc}.el-input.is-disabled .el-input__icon{cursor:not-allowed}.el-input.is-exceed .el-input__inner{border-color:#f56c6c}.el-input.is-exceed .el-input__suffix .el-input__count{color:#f56c6c}.el-input--suffix .el-input__inner{padding-right:30px}.el-input--prefix .el-input__inner{padding-left:30px}.el-input--medium{font-size:14px}.el-input--medium .el-input__inner{height:36px;line-height:36px}.el-input--medium .el-input__icon{line-height:36px}.el-input--small{font-size:13px}.el-input--small .el-input__inner{height:32px;line-height:32px}.el-input--small .el-input__icon{line-height:32px}.el-input--mini{font-size:12px}.el-input--mini .el-input__inner{height:28px;line-height:28px}.el-input--mini .el-input__icon{line-height:28px}.el-input-group{line-height:normal;display:inline-table;width:100%;border-collapse:separate;border-spacing:0}.el-input-group>.el-input__inner{vertical-align:middle;display:table-cell}.el-input-group__append,.el-input-group__prepend{background-color:#f5f7fa;color:#909399;vertical-align:middle;display:table-cell;position:relative;border:1px solid #dcdfe6;border-radius:4px;padding:0 20px;width:1px;white-space:nowrap}.el-input-group--prepend .el-input__inner,.el-input-group__append{border-top-left-radius:0;border-bottom-left-radius:0}.el-input-group--append .el-input__inner,.el-input-group__prepend{border-top-right-radius:0;border-bottom-right-radius:0}.el-input-group__append:focus,.el-input-group__prepend:focus{outline:0}.el-input-group__append .el-button,.el-input-group__append .el-select,.el-input-group__prepend .el-button,.el-input-group__prepend .el-select{display:inline-block;margin:-10px -20px}.el-input-group__append button.el-button,.el-input-group__append div.el-select .el-input__inner,.el-input-group__append div.el-select:hover .el-input__inner,.el-input-group__prepend button.el-button,.el-input-group__prepend div.el-select .el-input__inner,.el-input-group__prepend div.el-select:hover .el-input__inner{border-color:transparent;background-color:transparent;color:inherit;border-top:0;border-bottom:0}.el-input-group__append .el-button,.el-input-group__append .el-input,.el-input-group__prepend .el-button,.el-input-group__prepend .el-input{font-size:inherit}.el-input-group__prepend{border-right:0}.el-input-group__append{border-left:0}.el-input-group--append .el-select .el-input.is-focus .el-input__inner,.el-input-group--prepend .el-select .el-input.is-focus .el-input__inner{border-color:transparent}.el-input__inner::-ms-clear{display:none;width:0;height:0}.el-popper .popper__arrow,.el-popper .popper__arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.el-popper .popper__arrow{border-width:6px;-webkit-filter:drop-shadow(0 2px 12px rgba(0,0,0,.03));filter:drop-shadow(0 2px 12px rgba(0,0,0,.03))}.el-popper .popper__arrow:after{content:" ";border-width:6px}.el-popper[x-placement^=top]{margin-bottom:12px}.el-popper[x-placement^=top] .popper__arrow{bottom:-6px;left:50%;margin-right:3px;border-top-color:#ebeef5;border-bottom-width:0}.el-popper[x-placement^=top] .popper__arrow:after{bottom:1px;margin-left:-6px;border-top-color:#fff;border-bottom-width:0}.el-popper[x-placement^=bottom]{margin-top:12px}.el-popper[x-placement^=bottom] .popper__arrow{top:-6px;left:50%;margin-right:3px;border-top-width:0;border-bottom-color:#ebeef5}.el-popper[x-placement^=bottom] .popper__arrow:after{top:1px;margin-left:-6px;border-top-width:0;border-bottom-color:#fff}.el-popper[x-placement^=right]{margin-left:12px}.el-popper[x-placement^=right] .popper__arrow{top:50%;left:-6px;margin-bottom:3px;border-right-color:#ebeef5;border-left-width:0}.el-popper[x-placement^=right] .popper__arrow:after{bottom:-6px;left:1px;border-right-color:#fff;border-left-width:0}.el-popper[x-placement^=left]{margin-right:12px}.el-popper[x-placement^=left] .popper__arrow{top:50%;right:-6px;margin-bottom:3px;border-right-width:0;border-left-color:#ebeef5}.el-popper[x-placement^=left] .popper__arrow:after{right:1px;bottom:-6px;margin-left:-6px;border-right-width:0;border-left-color:#fff}.el-popover{position:absolute;background:#fff;min-width:150px;border-radius:4px;border:1px solid #ebeef5;padding:12px;z-index:2000;color:#606266;line-height:1.4;text-align:justify;font-size:14px;-webkit-box-shadow:0 2px 12px 0 rgba(0,0,0,.1);box-shadow:0 2px 12px 0 rgba(0,0,0,.1);word-break:break-all}.el-popover--plain{padding:18px 20px}.el-popover__title{color:#303133;font-size:16px;line-height:1;margin-bottom:12px}.el-popover:focus,.el-popover:focus:active,.el-popover__reference:focus:hover,.el-popover__reference:focus:not(.focusing){outline-width:0}.el-checkbox-button__inner,.el-tag{-webkit-box-sizing:border-box;white-space:nowrap}.el-checkbox-button__inner{-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}.el-table-column--selection .cell{padding-left:14px;padding-right:14px}.el-table-filter{border:1px solid #ebeef5;border-radius:2px;background-color:#fff;-webkit-box-shadow:0 2px 12px 0 rgba(0,0,0,.1);box-shadow:0 2px 12px 0 rgba(0,0,0,.1);-webkit-box-sizing:border-box;box-sizing:border-box;margin:2px 0}.el-table-filter__list{padding:5px 0;margin:0;list-style:none;min-width:100px}.el-table-filter__list-item{line-height:36px;padding:0 10px;cursor:pointer;font-size:14px}.el-table-filter__list-item:hover{background-color:#ecf5ff;color:#66b1ff}.el-table-filter__list-item.is-active{background-color:#409eff;color:#fff}.el-table-filter__content{min-width:100px}.el-table-filter__bottom{border-top:1px solid #ebeef5;padding:8px}.el-table-filter__bottom button{background:0 0;border:none;color:#606266;cursor:pointer;font-size:13px;padding:0 3px}.el-table-filter__bottom button:hover{color:#409eff}.el-table-filter__bottom button:focus{outline:0}.el-table-filter__bottom button.is-disabled{color:#c0c4cc;cursor:not-allowed}.el-table-filter__wrap{max-height:280px}.el-table-filter__checkbox-group{padding:10px}.el-table-filter__checkbox-group label.el-checkbox{display:block;margin-right:5px;margin-bottom:8px;margin-left:5px}.el-table-filter__checkbox-group .el-checkbox:last-child{margin-bottom:0}.el-checkbox,.el-checkbox__input{display:inline-block;position:relative;white-space:nowrap}.el-table,.el-table__append-wrapper{overflow:hidden}.el-table--hidden,.el-table td.is-hidden>*,.el-table th.is-hidden>*{visibility:hidden}.el-checkbox{color:#606266;font-weight:500;font-size:14px;cursor:pointer;user-select:none;margin-right:30px}.el-checkbox,.el-checkbox-button__inner,.el-table th{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.el-checkbox.is-bordered{padding:9px 20px 9px 10px;border-radius:4px;border:1px solid #dcdfe6;-webkit-box-sizing:border-box;box-sizing:border-box;line-height:normal;height:40px}.el-checkbox.is-bordered.is-checked{border-color:#409eff}.el-checkbox.is-bordered.is-disabled{border-color:#ebeef5;cursor:not-allowed}.el-checkbox.is-bordered+.el-checkbox.is-bordered{margin-left:10px}.el-checkbox.is-bordered.el-checkbox--medium{padding:7px 20px 7px 10px;border-radius:4px;height:36px}.el-checkbox.is-bordered.el-checkbox--medium .el-checkbox__label{line-height:17px;font-size:14px}.el-checkbox.is-bordered.el-checkbox--medium .el-checkbox__inner{height:14px;width:14px}.el-checkbox.is-bordered.el-checkbox--small{padding:5px 15px 5px 10px;border-radius:3px;height:32px}.el-checkbox.is-bordered.el-checkbox--small .el-checkbox__label{line-height:15px;font-size:12px}.el-checkbox.is-bordered.el-checkbox--small .el-checkbox__inner{height:12px;width:12px}.el-checkbox.is-bordered.el-checkbox--small .el-checkbox__inner:after{height:6px;width:2px}.el-checkbox.is-bordered.el-checkbox--mini{padding:3px 15px 3px 10px;border-radius:3px;height:28px}.el-checkbox.is-bordered.el-checkbox--mini .el-checkbox__label{line-height:12px;font-size:12px}.el-checkbox.is-bordered.el-checkbox--mini .el-checkbox__inner{height:12px;width:12px}.el-checkbox.is-bordered.el-checkbox--mini .el-checkbox__inner:after{height:6px;width:2px}.el-checkbox__input{cursor:pointer;outline:0;line-height:1;vertical-align:middle}.el-checkbox__input.is-disabled .el-checkbox__inner{background-color:#edf2fc;border-color:#dcdfe6;cursor:not-allowed}.el-checkbox__input.is-disabled .el-checkbox__inner:after{cursor:not-allowed;border-color:#c0c4cc}.el-checkbox__input.is-disabled .el-checkbox__inner+.el-checkbox__label{cursor:not-allowed}.el-checkbox__input.is-disabled.is-checked .el-checkbox__inner{background-color:#f2f6fc;border-color:#dcdfe6}.el-checkbox__input.is-disabled.is-checked .el-checkbox__inner:after{border-color:#c0c4cc}.el-checkbox__input.is-disabled.is-indeterminate .el-checkbox__inner{background-color:#f2f6fc;border-color:#dcdfe6}.el-checkbox__input.is-disabled.is-indeterminate .el-checkbox__inner:before{background-color:#c0c4cc;border-color:#c0c4cc}.el-checkbox__input.is-checked .el-checkbox__inner,.el-checkbox__input.is-indeterminate .el-checkbox__inner{background-color:#409eff;border-color:#409eff}.el-checkbox__input.is-disabled+span.el-checkbox__label{color:#c0c4cc;cursor:not-allowed}.el-checkbox__input.is-checked .el-checkbox__inner:after{-webkit-transform:rotate(45deg) scaleY(1);transform:rotate(45deg) scaleY(1)}.el-checkbox__input.is-checked+.el-checkbox__label{color:#409eff}.el-checkbox__input.is-focus .el-checkbox__inner{border-color:#409eff}.el-checkbox__input.is-indeterminate .el-checkbox__inner:before{content:"";position:absolute;display:block;background-color:#fff;height:2px;-webkit-transform:scale(.5);transform:scale(.5);left:0;right:0;top:5px}.el-checkbox__input.is-indeterminate .el-checkbox__inner:after{display:none}.el-checkbox__inner{display:inline-block;position:relative;border:1px solid #dcdfe6;border-radius:2px;-webkit-box-sizing:border-box;box-sizing:border-box;width:14px;height:14px;background-color:#fff;z-index:1;-webkit-transition:border-color .25s cubic-bezier(.71,-.46,.29,1.46),background-color .25s cubic-bezier(.71,-.46,.29,1.46);transition:border-color .25s cubic-bezier(.71,-.46,.29,1.46),background-color .25s cubic-bezier(.71,-.46,.29,1.46)}.el-checkbox__inner:hover{border-color:#409eff}.el-checkbox__inner:after{-webkit-box-sizing:content-box;box-sizing:content-box;content:"";border:1px solid #fff;border-left:0;border-top:0;height:7px;left:4px;position:absolute;top:1px;-webkit-transform:rotate(45deg) scaleY(0);transform:rotate(45deg) scaleY(0);width:3px;-webkit-transition:-webkit-transform .15s ease-in .05s;transition:-webkit-transform .15s ease-in .05s;transition:transform .15s ease-in .05s;transition:transform .15s ease-in .05s,-webkit-transform .15s ease-in .05s;-webkit-transform-origin:center;transform-origin:center}.el-checkbox__original{opacity:0;outline:0;position:absolute;margin:0;width:0;height:0;z-index:-1}.el-checkbox-button,.el-checkbox-button__inner{position:relative;display:inline-block}.el-checkbox__label{display:inline-block;padding-left:10px;line-height:19px;font-size:14px}.el-checkbox:last-of-type{margin-right:0}.el-checkbox-button__inner{line-height:1;font-weight:500;white-space:nowrap;vertical-align:middle;cursor:pointer;background:#fff;border:1px solid #dcdfe6;border-left:0;color:#606266;-webkit-appearance:none;text-align:center;-webkit-box-sizing:border-box;box-sizing:border-box;outline:0;margin:0;-webkit-transition:all .3s cubic-bezier(.645,.045,.355,1);transition:all .3s cubic-bezier(.645,.045,.355,1);padding:12px 20px;font-size:14px;border-radius:0}.el-checkbox-button__inner.is-round{padding:12px 20px}.el-checkbox-button__inner:hover{color:#409eff}.el-checkbox-button__inner [class*=el-icon-]{line-height:.9}.el-checkbox-button__inner [class*=el-icon-]+span{margin-left:5px}.el-checkbox-button__original{opacity:0;outline:0;position:absolute;margin:0;z-index:-1}.el-checkbox-button.is-checked .el-checkbox-button__inner{color:#fff;background-color:#409eff;border-color:#409eff;-webkit-box-shadow:-1px 0 0 0 #8cc5ff;box-shadow:-1px 0 0 0 #8cc5ff}.el-checkbox-button.is-checked:first-child .el-checkbox-button__inner{border-left-color:#409eff}.el-checkbox-button.is-disabled .el-checkbox-button__inner{color:#c0c4cc;cursor:not-allowed;background-image:none;background-color:#fff;border-color:#ebeef5;-webkit-box-shadow:none;box-shadow:none}.el-checkbox-button.is-disabled:first-child .el-checkbox-button__inner{border-left-color:#ebeef5}.el-checkbox-button:first-child .el-checkbox-button__inner{border-left:1px solid #dcdfe6;border-radius:4px 0 0 4px;-webkit-box-shadow:none!important;box-shadow:none!important}.el-checkbox-button.is-focus .el-checkbox-button__inner{border-color:#409eff}.el-checkbox-button:last-child .el-checkbox-button__inner{border-radius:0 4px 4px 0}.el-checkbox-button--medium .el-checkbox-button__inner{padding:10px 20px;font-size:14px;border-radius:0}.el-checkbox-button--medium .el-checkbox-button__inner.is-round{padding:10px 20px}.el-checkbox-button--small .el-checkbox-button__inner{padding:9px 15px;font-size:12px;border-radius:0}.el-checkbox-button--small .el-checkbox-button__inner.is-round{padding:9px 15px}.el-checkbox-button--mini .el-checkbox-button__inner{padding:7px 15px;font-size:12px;border-radius:0}.el-checkbox-button--mini .el-checkbox-button__inner.is-round{padding:7px 15px}.el-checkbox-group{font-size:0}.el-tag{background-color:#ecf5ff;border-color:#d9ecff;display:inline-block;height:32px;padding:0 10px;line-height:30px;font-size:12px;color:#409eff;border-width:1px;border-style:solid;border-radius:4px;-webkit-box-sizing:border-box;box-sizing:border-box;white-space:nowrap}.el-tag.is-hit{border-color:#409eff}.el-tag .el-tag__close{color:#409eff}.el-tag .el-tag__close:hover{color:#fff;background-color:#409eff}.el-tag.el-tag--info{background-color:#f4f4f5;border-color:#e9e9eb;color:#909399}.el-tag.el-tag--info.is-hit{border-color:#909399}.el-tag.el-tag--info .el-tag__close{color:#909399}.el-tag.el-tag--info .el-tag__close:hover{color:#fff;background-color:#909399}.el-tag.el-tag--success{background-color:#f0f9eb;border-color:#e1f3d8;color:#67c23a}.el-tag.el-tag--success.is-hit{border-color:#67c23a}.el-tag.el-tag--success .el-tag__close{color:#67c23a}.el-tag.el-tag--success .el-tag__close:hover{color:#fff;background-color:#67c23a}.el-tag.el-tag--warning{background-color:#fdf6ec;border-color:#faecd8;color:#e6a23c}.el-tag.el-tag--warning.is-hit{border-color:#e6a23c}.el-tag.el-tag--warning .el-tag__close{color:#e6a23c}.el-tag.el-tag--warning .el-tag__close:hover{color:#fff;background-color:#e6a23c}.el-tag.el-tag--danger{background-color:#fef0f0;border-color:#fde2e2;color:#f56c6c}.el-tag.el-tag--danger.is-hit{border-color:#f56c6c}.el-tag.el-tag--danger .el-tag__close{color:#f56c6c}.el-tag.el-tag--danger .el-tag__close:hover{color:#fff;background-color:#f56c6c}.el-tag .el-icon-close{border-radius:50%;text-align:center;position:relative;cursor:pointer;font-size:12px;height:16px;width:16px;line-height:16px;vertical-align:middle;top:-1px;right:-5px}.el-tag .el-icon-close:before{display:block}.el-tag--dark{background-color:#409eff;color:#fff}.el-tag--dark,.el-tag--dark.is-hit{border-color:#409eff}.el-tag--dark .el-tag__close{color:#fff}.el-tag--dark .el-tag__close:hover{color:#fff;background-color:#66b1ff}.el-tag--dark.el-tag--info{background-color:#909399;border-color:#909399;color:#fff}.el-tag--dark.el-tag--info.is-hit{border-color:#909399}.el-tag--dark.el-tag--info .el-tag__close{color:#fff}.el-tag--dark.el-tag--info .el-tag__close:hover{color:#fff;background-color:#a6a9ad}.el-tag--dark.el-tag--success{background-color:#67c23a;border-color:#67c23a;color:#fff}.el-tag--dark.el-tag--success.is-hit{border-color:#67c23a}.el-tag--dark.el-tag--success .el-tag__close{color:#fff}.el-tag--dark.el-tag--success .el-tag__close:hover{color:#fff;background-color:#85ce61}.el-tag--dark.el-tag--warning{background-color:#e6a23c;border-color:#e6a23c;color:#fff}.el-tag--dark.el-tag--warning.is-hit{border-color:#e6a23c}.el-tag--dark.el-tag--warning .el-tag__close{color:#fff}.el-tag--dark.el-tag--warning .el-tag__close:hover{color:#fff;background-color:#ebb563}.el-tag--dark.el-tag--danger{background-color:#f56c6c;border-color:#f56c6c;color:#fff}.el-tag--dark.el-tag--danger.is-hit{border-color:#f56c6c}.el-tag--dark.el-tag--danger .el-tag__close{color:#fff}.el-tag--dark.el-tag--danger .el-tag__close:hover{color:#fff;background-color:#f78989}.el-tag--plain{background-color:#fff;border-color:#b3d8ff;color:#409eff}.el-tag--plain.is-hit{border-color:#409eff}.el-tag--plain .el-tag__close{color:#409eff}.el-tag--plain .el-tag__close:hover{color:#fff;background-color:#409eff}.el-tag--plain.el-tag--info{background-color:#fff;border-color:#d3d4d6;color:#909399}.el-tag--plain.el-tag--info.is-hit{border-color:#909399}.el-tag--plain.el-tag--info .el-tag__close{color:#909399}.el-tag--plain.el-tag--info .el-tag__close:hover{color:#fff;background-color:#909399}.el-tag--plain.el-tag--success{background-color:#fff;border-color:#c2e7b0;color:#67c23a}.el-tag--plain.el-tag--success.is-hit{border-color:#67c23a}.el-tag--plain.el-tag--success .el-tag__close{color:#67c23a}.el-tag--plain.el-tag--success .el-tag__close:hover{color:#fff;background-color:#67c23a}.el-tag--plain.el-tag--warning{background-color:#fff;border-color:#f5dab1;color:#e6a23c}.el-tag--plain.el-tag--warning.is-hit{border-color:#e6a23c}.el-tag--plain.el-tag--warning .el-tag__close{color:#e6a23c}.el-tag--plain.el-tag--warning .el-tag__close:hover{color:#fff;background-color:#e6a23c}.el-tag--plain.el-tag--danger{background-color:#fff;border-color:#fbc4c4;color:#f56c6c}.el-tag--plain.el-tag--danger.is-hit{border-color:#f56c6c}.el-tag--plain.el-tag--danger .el-tag__close{color:#f56c6c}.el-tag--plain.el-tag--danger .el-tag__close:hover{color:#fff;background-color:#f56c6c}.el-tag--medium{height:28px;line-height:26px}.el-tag--medium .el-icon-close{-webkit-transform:scale(.8);transform:scale(.8)}.el-tag--small{height:24px;padding:0 8px;line-height:22px}.el-tag--small .el-icon-close{-webkit-transform:scale(.8);transform:scale(.8)}.el-tag--mini{height:20px;padding:0 5px;line-height:19px}.el-tag--mini .el-icon-close{margin-left:-3px;-webkit-transform:scale(.7);transform:scale(.7)}.el-tooltip:focus:hover,.el-tooltip:focus:not(.focusing){outline-width:0}.el-tooltip__popper{position:absolute;border-radius:4px;padding:10px;z-index:2000;font-size:12px;line-height:1.2;min-width:10px;word-wrap:break-word}.el-tooltip__popper .popper__arrow,.el-tooltip__popper .popper__arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.el-tooltip__popper .popper__arrow{border-width:6px}.el-tooltip__popper .popper__arrow:after{content:" ";border-width:5px}.el-tooltip__popper[x-placement^=top]{margin-bottom:12px}.el-tooltip__popper[x-placement^=top] .popper__arrow{bottom:-6px;border-top-color:#303133;border-bottom-width:0}.el-tooltip__popper[x-placement^=top] .popper__arrow:after{bottom:1px;margin-left:-5px;border-top-color:#303133;border-bottom-width:0}.el-tooltip__popper[x-placement^=bottom]{margin-top:12px}.el-tooltip__popper[x-placement^=bottom] .popper__arrow{top:-6px;border-top-width:0;border-bottom-color:#303133}.el-tooltip__popper[x-placement^=bottom] .popper__arrow:after{top:1px;margin-left:-5px;border-top-width:0;border-bottom-color:#303133}.el-tooltip__popper[x-placement^=right]{margin-left:12px}.el-tooltip__popper[x-placement^=right] .popper__arrow{left:-6px;border-right-color:#303133;border-left-width:0}.el-tooltip__popper[x-placement^=right] .popper__arrow:after{bottom:-5px;left:1px;border-right-color:#303133;border-left-width:0}.el-tooltip__popper[x-placement^=left]{margin-right:12px}.el-tooltip__popper[x-placement^=left] .popper__arrow{right:-6px;border-right-width:0;border-left-color:#303133}.el-tooltip__popper[x-placement^=left] .popper__arrow:after{right:1px;bottom:-5px;margin-left:-5px;border-right-width:0;border-left-color:#303133}.el-tooltip__popper.is-dark{background:#303133;color:#fff}.el-table,.el-table__expanded-cell{background-color:#fff}.el-tooltip__popper.is-light{background:#fff;border:1px solid #303133}.el-tooltip__popper.is-light[x-placement^=top] .popper__arrow{border-top-color:#303133}.el-tooltip__popper.is-light[x-placement^=top] .popper__arrow:after{border-top-color:#fff}.el-tooltip__popper.is-light[x-placement^=bottom] .popper__arrow{border-bottom-color:#303133}.el-tooltip__popper.is-light[x-placement^=bottom] .popper__arrow:after{border-bottom-color:#fff}.el-tooltip__popper.is-light[x-placement^=left] .popper__arrow{border-left-color:#303133}.el-tooltip__popper.is-light[x-placement^=left] .popper__arrow:after{border-left-color:#fff}.el-tooltip__popper.is-light[x-placement^=right] .popper__arrow{border-right-color:#303133}.el-tooltip__popper.is-light[x-placement^=right] .popper__arrow:after{border-right-color:#fff}.el-table{position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-box-flex:1;-ms-flex:1;flex:1;width:100%;max-width:100%;font-size:14px;color:#606266}.el-table--mini,.el-table--small,.el-table__expand-icon{font-size:12px}.el-table__empty-block{min-height:60px;text-align:center;width:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.el-table__empty-text{line-height:60px;width:50%;color:#909399}.el-table__expand-column .cell{padding:0;text-align:center}.el-table__expand-icon{position:relative;cursor:pointer;color:#666;-webkit-transition:-webkit-transform .2s ease-in-out;transition:-webkit-transform .2s ease-in-out;transition:transform .2s ease-in-out;transition:transform .2s ease-in-out,-webkit-transform .2s ease-in-out;height:20px}.el-table__expand-icon--expanded{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.el-table__expand-icon>.el-icon{position:absolute;left:50%;top:50%;margin-left:-5px;margin-top:-5px}.el-table__expanded-cell[class*=cell]{padding:20px 50px}.el-table__expanded-cell:hover{background-color:transparent!important}.el-table__placeholder{display:inline-block;width:20px}.el-table--fit{border-right:0;border-bottom:0}.el-table--fit td.gutter,.el-table--fit th.gutter{border-right-width:1px}.el-table--scrollable-x .el-table__body-wrapper{overflow-x:auto}.el-table--scrollable-y .el-table__body-wrapper{overflow-y:auto}.el-table thead{color:#909399;font-weight:500}.el-table thead.is-group th{background:#f5f7fa}.el-table th,.el-table tr{background-color:#fff}.el-table td,.el-table th{padding:12px 0;min-width:0;-webkit-box-sizing:border-box;box-sizing:border-box;text-overflow:ellipsis;vertical-align:middle;position:relative;text-align:left}.el-table td.is-center,.el-table th.is-center{text-align:center}.el-table td.is-right,.el-table th.is-right{text-align:right}.el-table td.gutter,.el-table th.gutter{width:15px;border-right-width:0;border-bottom-width:0;padding:0}.el-table--medium td,.el-table--medium th{padding:10px 0}.el-table--small td,.el-table--small th{padding:8px 0}.el-table--mini td,.el-table--mini th{padding:6px 0}.el-table--border td:first-child .cell,.el-table--border th:first-child .cell,.el-table .cell{padding-left:10px}.el-table tr input[type=checkbox]{margin:0}.el-table td,.el-table th.is-leaf{border-bottom:1px solid #ebeef5}.el-table th.is-sortable{cursor:pointer}.el-table th{overflow:hidden;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.el-table th>.cell{display:inline-block;-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;vertical-align:middle;padding-left:10px;padding-right:10px;width:100%}.el-table th>.cell.highlight{color:#409eff}.el-table th.required>div:before{display:inline-block;content:"";width:8px;height:8px;border-radius:50%;background:#ff4d51;margin-right:5px;vertical-align:middle}.el-table td div{-webkit-box-sizing:border-box;box-sizing:border-box}.el-table td.gutter{width:0}.el-table .cell{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;text-overflow:ellipsis;white-space:normal;word-break:break-all;line-height:23px;padding-right:10px}.el-table .cell.el-tooltip{white-space:nowrap;min-width:50px}.el-table--border,.el-table--group{border:1px solid #ebeef5}.el-table--border:after,.el-table--group:after,.el-table:before{content:"";position:absolute;background-color:#ebeef5;z-index:1}.el-table--border:after,.el-table--group:after{top:0;right:0;width:1px;height:100%}.el-table:before{left:0;bottom:0;width:100%;height:1px}.el-table--border{border-right:none;border-bottom:none}.el-table--border.el-loading-parent--relative{border-color:transparent}.el-table--border td,.el-table--border th,.el-table__body-wrapper .el-table--border.is-scrolling-left~.el-table__fixed{border-right:1px solid #ebeef5}.el-table--border th.gutter:last-of-type{border-bottom:1px solid #ebeef5;border-bottom-width:1px}.el-table--border th,.el-table__fixed-right-patch{border-bottom:1px solid #ebeef5}.el-table__fixed,.el-table__fixed-right{position:absolute;top:0;left:0;overflow-x:hidden;overflow-y:hidden;-webkit-box-shadow:0 0 10px rgba(0,0,0,.12);box-shadow:0 0 10px rgba(0,0,0,.12)}.el-table__fixed-right:before,.el-table__fixed:before{content:"";position:absolute;left:0;bottom:0;width:100%;height:1px;background-color:#ebeef5;z-index:4}.el-table__fixed-right-patch{position:absolute;top:-1px;right:0;background-color:#fff}.el-table__fixed-right{top:0;left:auto;right:0}.el-table__fixed-right .el-table__fixed-body-wrapper,.el-table__fixed-right .el-table__fixed-footer-wrapper,.el-table__fixed-right .el-table__fixed-header-wrapper{left:auto;right:0}.el-table__fixed-header-wrapper{position:absolute;left:0;top:0;z-index:3}.el-table__fixed-footer-wrapper{position:absolute;left:0;bottom:0;z-index:3}.el-table__fixed-footer-wrapper tbody td{border-top:1px solid #ebeef5;background-color:#f5f7fa;color:#606266}.el-table__fixed-body-wrapper{position:absolute;left:0;top:37px;overflow:hidden;z-index:3}.el-table__body-wrapper,.el-table__footer-wrapper,.el-table__header-wrapper{width:100%}.el-table__footer-wrapper{margin-top:-1px}.el-table__footer-wrapper td{border-top:1px solid #ebeef5}.el-table__body,.el-table__footer,.el-table__header{table-layout:fixed;border-collapse:separate}.el-table__footer-wrapper,.el-table__header-wrapper{overflow:hidden}.el-table__footer-wrapper tbody td,.el-table__header-wrapper tbody td{background-color:#f5f7fa;color:#606266}.el-table__body-wrapper{overflow:hidden;position:relative}.el-table__body-wrapper.is-scrolling-left~.el-table__fixed,.el-table__body-wrapper.is-scrolling-none~.el-table__fixed,.el-table__body-wrapper.is-scrolling-none~.el-table__fixed-right,.el-table__body-wrapper.is-scrolling-right~.el-table__fixed-right{-webkit-box-shadow:none;box-shadow:none}.el-table__body-wrapper .el-table--border.is-scrolling-right~.el-table__fixed-right{border-left:1px solid #ebeef5}.el-table .caret-wrapper{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:center;-ms-flex-align:center;align-items:center;height:34px;width:24px;vertical-align:middle;cursor:pointer;overflow:initial;position:relative}.el-table .sort-caret{width:0;height:0;border:5px solid transparent;position:absolute;left:7px}.el-table .sort-caret.ascending{border-bottom-color:#c0c4cc;top:5px}.el-table .sort-caret.descending{border-top-color:#c0c4cc;bottom:7px}.el-table .ascending .sort-caret.ascending{border-bottom-color:#409eff}.el-table .descending .sort-caret.descending{border-top-color:#409eff}.el-table .hidden-columns{visibility:hidden;position:absolute;z-index:-1}.el-table--striped .el-table__body tr.el-table__row--striped td{background:#fafafa}.el-table--striped .el-table__body tr.el-table__row--striped.current-row td{background-color:#ecf5ff}.el-table__body tr.hover-row.current-row>td,.el-table__body tr.hover-row.el-table__row--striped.current-row>td,.el-table__body tr.hover-row.el-table__row--striped>td,.el-table__body tr.hover-row>td{background-color:#f5f7fa}.el-table__body tr.current-row>td{background-color:#ecf5ff}.el-table__column-resize-proxy{position:absolute;left:200px;top:0;bottom:0;width:0;border-left:1px solid #ebeef5;z-index:10}.el-table__column-filter-trigger{display:inline-block;line-height:34px;cursor:pointer}.el-table__column-filter-trigger i{color:#909399;font-size:12px;-webkit-transform:scale(.75);transform:scale(.75)}.el-table--enable-row-transition .el-table__body td{-webkit-transition:background-color .25s ease;transition:background-color .25s ease}.el-table--enable-row-hover .el-table__body tr:hover>td{background-color:#f5f7fa}.el-table--fluid-height .el-table__fixed,.el-table--fluid-height .el-table__fixed-right{bottom:0;overflow:hidden}.el-table [class*=el-table__row--level] .el-table__expand-icon{display:inline-block;width:20px;line-height:20px;height:20px;text-align:center;margin-right:3px}.el-breadcrumb{font-size:14px;line-height:1}.el-breadcrumb:after,.el-breadcrumb:before{display:table;content:""}.el-breadcrumb:after{clear:both}.el-breadcrumb__separator{margin:0 9px;font-weight:700;color:#c0c4cc}.el-breadcrumb__separator[class*=icon]{margin:0 6px;font-weight:400}.el-breadcrumb__item{float:left}.el-breadcrumb__inner{color:#606266}.el-breadcrumb__inner.is-link,.el-breadcrumb__inner a{font-weight:700;text-decoration:none;-webkit-transition:color .2s cubic-bezier(.645,.045,.355,1);transition:color .2s cubic-bezier(.645,.045,.355,1);color:#303133}.el-breadcrumb__inner.is-link:hover,.el-breadcrumb__inner a:hover{color:#409eff;cursor:pointer}.el-breadcrumb__item:last-child .el-breadcrumb__inner,.el-breadcrumb__item:last-child .el-breadcrumb__inner:hover,.el-breadcrumb__item:last-child .el-breadcrumb__inner a,.el-breadcrumb__item:last-child .el-breadcrumb__inner a:hover{font-weight:400;color:#606266;cursor:text}.el-breadcrumb__item:last-child .el-breadcrumb__separator{display:none}.el-divider{background-color:#dcdfe6;position:relative}.el-divider--horizontal{display:block;height:1px;width:100%;margin:24px 0}.el-divider--vertical{display:inline-block;width:1px;height:1em;margin:0 8px;vertical-align:middle;position:relative}.el-divider__text{position:absolute;background-color:#fff;padding:0 20px;font-weight:500;color:#303133;font-size:14px}.el-divider__text.is-left{left:20px;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.el-divider__text.is-center{left:50%;-webkit-transform:translateX(-50%) translateY(-50%);transform:translateX(-50%) translateY(-50%)}.el-divider__text.is-right{right:20px;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.el-button-group>.el-button.is-active,.el-button-group>.el-button.is-disabled,.el-button-group>.el-button:active,.el-button-group>.el-button:focus,.el-button-group>.el-button:hover{z-index:1}.el-button{display:inline-block;line-height:1;white-space:nowrap;cursor:pointer;background:#fff;border:1px solid #dcdfe6;color:#606266;-webkit-appearance:none;text-align:center;-webkit-box-sizing:border-box;box-sizing:border-box;outline:0;margin:0;-webkit-transition:.1s;transition:.1s;font-weight:500;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;padding:12px 20px;font-size:14px;border-radius:4px}.el-button+.el-button{margin-left:10px}.el-button:focus,.el-button:hover{color:#409eff;border-color:#c6e2ff;background-color:#ecf5ff}.el-button:active{color:#3a8ee6;border-color:#3a8ee6;outline:0}.el-button::-moz-focus-inner{border:0}.el-button [class*=el-icon-]+span{margin-left:5px}.el-button.is-plain:focus,.el-button.is-plain:hover{background:#fff;border-color:#409eff;color:#409eff}.el-button.is-active,.el-button.is-plain:active{color:#3a8ee6;border-color:#3a8ee6}.el-button.is-plain:active{background:#fff;outline:0}.el-button.is-disabled,.el-button.is-disabled:focus,.el-button.is-disabled:hover{color:#c0c4cc;cursor:not-allowed;background-image:none;background-color:#fff;border-color:#ebeef5}.el-button.is-disabled.el-button--text{background-color:transparent}.el-button.is-disabled.is-plain,.el-button.is-disabled.is-plain:focus,.el-button.is-disabled.is-plain:hover{background-color:#fff;border-color:#ebeef5;color:#c0c4cc}.el-button.is-loading{position:relative;pointer-events:none}.el-button.is-loading:before{pointer-events:none;content:"";position:absolute;left:-1px;top:-1px;right:-1px;bottom:-1px;border-radius:inherit;background-color:hsla(0,0%,100%,.35)}.el-button.is-round{border-radius:20px;padding:12px 23px}.el-button.is-circle{border-radius:50%;padding:12px}.el-button--primary{color:#fff;background-color:#409eff;border-color:#409eff}.el-button--primary:focus,.el-button--primary:hover{background:#66b1ff;border-color:#66b1ff;color:#fff}.el-button--primary.is-active,.el-button--primary:active{background:#3a8ee6;border-color:#3a8ee6;color:#fff}.el-button--primary:active{outline:0}.el-button--primary.is-disabled,.el-button--primary.is-disabled:active,.el-button--primary.is-disabled:focus,.el-button--primary.is-disabled:hover{color:#fff;background-color:#a0cfff;border-color:#a0cfff}.el-button--primary.is-plain{color:#409eff;background:#ecf5ff;border-color:#b3d8ff}.el-button--primary.is-plain:focus,.el-button--primary.is-plain:hover{background:#409eff;border-color:#409eff;color:#fff}.el-button--primary.is-plain:active{background:#3a8ee6;border-color:#3a8ee6;color:#fff;outline:0}.el-button--primary.is-plain.is-disabled,.el-button--primary.is-plain.is-disabled:active,.el-button--primary.is-plain.is-disabled:focus,.el-button--primary.is-plain.is-disabled:hover{color:#8cc5ff;background-color:#ecf5ff;border-color:#d9ecff}.el-button--success{color:#fff;background-color:#67c23a;border-color:#67c23a}.el-button--success:focus,.el-button--success:hover{background:#85ce61;border-color:#85ce61;color:#fff}.el-button--success.is-active,.el-button--success:active{background:#5daf34;border-color:#5daf34;color:#fff}.el-button--success:active{outline:0}.el-button--success.is-disabled,.el-button--success.is-disabled:active,.el-button--success.is-disabled:focus,.el-button--success.is-disabled:hover{color:#fff;background-color:#b3e19d;border-color:#b3e19d}.el-button--success.is-plain{color:#67c23a;background:#f0f9eb;border-color:#c2e7b0}.el-button--success.is-plain:focus,.el-button--success.is-plain:hover{background:#67c23a;border-color:#67c23a;color:#fff}.el-button--success.is-plain:active{background:#5daf34;border-color:#5daf34;color:#fff;outline:0}.el-button--success.is-plain.is-disabled,.el-button--success.is-plain.is-disabled:active,.el-button--success.is-plain.is-disabled:focus,.el-button--success.is-plain.is-disabled:hover{color:#a4da89;background-color:#f0f9eb;border-color:#e1f3d8}.el-button--warning{color:#fff;background-color:#e6a23c;border-color:#e6a23c}.el-button--warning:focus,.el-button--warning:hover{background:#ebb563;border-color:#ebb563;color:#fff}.el-button--warning.is-active,.el-button--warning:active{background:#cf9236;border-color:#cf9236;color:#fff}.el-button--warning:active{outline:0}.el-button--warning.is-disabled,.el-button--warning.is-disabled:active,.el-button--warning.is-disabled:focus,.el-button--warning.is-disabled:hover{color:#fff;background-color:#f3d19e;border-color:#f3d19e}.el-button--warning.is-plain{color:#e6a23c;background:#fdf6ec;border-color:#f5dab1}.el-button--warning.is-plain:focus,.el-button--warning.is-plain:hover{background:#e6a23c;border-color:#e6a23c;color:#fff}.el-button--warning.is-plain:active{background:#cf9236;border-color:#cf9236;color:#fff;outline:0}.el-button--warning.is-plain.is-disabled,.el-button--warning.is-plain.is-disabled:active,.el-button--warning.is-plain.is-disabled:focus,.el-button--warning.is-plain.is-disabled:hover{color:#f0c78a;background-color:#fdf6ec;border-color:#faecd8}.el-button--danger{color:#fff;background-color:#f56c6c;border-color:#f56c6c}.el-button--danger:focus,.el-button--danger:hover{background:#f78989;border-color:#f78989;color:#fff}.el-button--danger.is-active,.el-button--danger:active{background:#dd6161;border-color:#dd6161;color:#fff}.el-button--danger:active{outline:0}.el-button--danger.is-disabled,.el-button--danger.is-disabled:active,.el-button--danger.is-disabled:focus,.el-button--danger.is-disabled:hover{color:#fff;background-color:#fab6b6;border-color:#fab6b6}.el-button--danger.is-plain{color:#f56c6c;background:#fef0f0;border-color:#fbc4c4}.el-button--danger.is-plain:focus,.el-button--danger.is-plain:hover{background:#f56c6c;border-color:#f56c6c;color:#fff}.el-button--danger.is-plain:active{background:#dd6161;border-color:#dd6161;color:#fff;outline:0}.el-button--danger.is-plain.is-disabled,.el-button--danger.is-plain.is-disabled:active,.el-button--danger.is-plain.is-disabled:focus,.el-button--danger.is-plain.is-disabled:hover{color:#f9a7a7;background-color:#fef0f0;border-color:#fde2e2}.el-button--info{color:#fff;background-color:#909399;border-color:#909399}.el-button--info:focus,.el-button--info:hover{background:#a6a9ad;border-color:#a6a9ad;color:#fff}.el-button--info.is-active,.el-button--info:active{background:#82848a;border-color:#82848a;color:#fff}.el-button--info:active{outline:0}.el-button--info.is-disabled,.el-button--info.is-disabled:active,.el-button--info.is-disabled:focus,.el-button--info.is-disabled:hover{color:#fff;background-color:#c8c9cc;border-color:#c8c9cc}.el-button--info.is-plain{color:#909399;background:#f4f4f5;border-color:#d3d4d6}.el-button--info.is-plain:focus,.el-button--info.is-plain:hover{background:#909399;border-color:#909399;color:#fff}.el-button--info.is-plain:active{background:#82848a;border-color:#82848a;color:#fff;outline:0}.el-button--info.is-plain.is-disabled,.el-button--info.is-plain.is-disabled:active,.el-button--info.is-plain.is-disabled:focus,.el-button--info.is-plain.is-disabled:hover{color:#bcbec2;background-color:#f4f4f5;border-color:#e9e9eb}.el-button--text,.el-button--text.is-disabled,.el-button--text.is-disabled:focus,.el-button--text.is-disabled:hover,.el-button--text:active{border-color:transparent}.el-button--medium{padding:10px 20px;font-size:14px;border-radius:4px}.el-button--mini,.el-button--small{font-size:12px;border-radius:3px}.el-button--medium.is-round{padding:10px 20px}.el-button--medium.is-circle{padding:10px}.el-button--small,.el-button--small.is-round{padding:9px 15px}.el-button--small.is-circle{padding:9px}.el-button--mini,.el-button--mini.is-round{padding:7px 15px}.el-button--mini.is-circle{padding:7px}.el-button--text{color:#409eff;background:0 0;padding-left:0;padding-right:0}.el-button--text:focus,.el-button--text:hover{color:#66b1ff;border-color:transparent;background-color:transparent}.el-button--text:active{color:#3a8ee6;background-color:transparent}.el-button-group{display:inline-block;vertical-align:middle}.el-button-group:after,.el-button-group:before{display:table;content:""}.el-button-group:after{clear:both}.el-button-group>.el-button{float:left;position:relative}.el-button-group>.el-button+.el-button{margin-left:0}.el-button-group>.el-button:first-child{border-top-right-radius:0;border-bottom-right-radius:0}.el-button-group>.el-button:last-child{border-top-left-radius:0;border-bottom-left-radius:0}.el-button-group>.el-button:first-child:last-child{border-radius:4px}.el-button-group>.el-button:first-child:last-child.is-round{border-radius:20px}.el-button-group>.el-button:first-child:last-child.is-circle{border-radius:50%}.el-button-group>.el-button:not(:first-child):not(:last-child){border-radius:0}.el-button-group>.el-button:not(:last-child){margin-right:-1px}.el-button-group>.el-dropdown>.el-button{border-top-left-radius:0;border-bottom-left-radius:0;border-left-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--primary:first-child{border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--primary:last-child{border-left-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--primary:not(:first-child):not(:last-child){border-left-color:hsla(0,0%,100%,.5);border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--success:first-child{border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--success:last-child{border-left-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--success:not(:first-child):not(:last-child){border-left-color:hsla(0,0%,100%,.5);border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--warning:first-child{border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--warning:last-child{border-left-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--warning:not(:first-child):not(:last-child){border-left-color:hsla(0,0%,100%,.5);border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--danger:first-child{border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--danger:last-child{border-left-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--danger:not(:first-child):not(:last-child){border-left-color:hsla(0,0%,100%,.5);border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--info:first-child{border-right-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--info:last-child{border-left-color:hsla(0,0%,100%,.5)}.el-button-group .el-button--info:not(:first-child):not(:last-child){border-left-color:hsla(0,0%,100%,.5);border-right-color:hsla(0,0%,100%,.5)} \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/css/detail.e03dc50b.css b/app/src/main/assets/web/bookshelf/css/detail.e03dc50b.css new file mode 100644 index 000000000..7e6d97d40 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/css/detail.e03dc50b.css @@ -0,0 +1 @@ +@charset "UTF-8";.detail-wrapper[data-v-57115a66]{padding:2% 0}.detail-wrapper .detail .bar .el-breadcrumb[data-v-57115a66]{font-size:24px;font-weight:500;line-height:48px}.detail-wrapper .detail .bar .el-breadcrumb .index[data-v-57115a66]{color:#333}.detail-wrapper .detail .bar .el-breadcrumb .sub[data-v-57115a66]{color:#676767}.detail-wrapper .detail .el-divider[data-v-57115a66]{margin-top:2%}.detail-wrapper .detail .catalog[data-v-57115a66]{margin-top:2%;display:grid;grid-template-columns:repeat(auto-fill,300px);-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.detail-wrapper .detail .catalog .note[data-v-57115a66]{width:200px;font-weight:500;font-size:14px;line-height:40px;padding-left:12px;padding-right:12px;word-wrap:break-word;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;text-align:left}@font-face{font-family:FZZCYSK;src:local("☺"),url(../fonts/popfont.f39ecc1a.ttf);font-style:normal;font-weight:400}.cata-wrapper[data-v-22f8c37b]{margin:-16px;padding:18px 0 24px 25px}.cata-wrapper .title[data-v-22f8c37b]{font-size:18px;font-weight:400;font-family:FZZCYSK;margin:0 0 20px 0;color:#ed4259;width:-webkit-fit-content;width:-moz-fit-content;width:fit-content;border-bottom:1px solid #ed4259}.cata-wrapper .data-wrapper[data-v-22f8c37b]{height:300px;overflow:auto}.cata-wrapper .data-wrapper .cata[data-v-22f8c37b]{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.cata-wrapper .data-wrapper .cata .selected[data-v-22f8c37b]{color:#eb4259}.cata-wrapper .data-wrapper .cata .log[data-v-22f8c37b]{width:50%;height:40px;cursor:pointer;float:left;font:16px/40px PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,Microsoft YaHei,sans-serif}.cata-wrapper .data-wrapper .cata .log .log-text[data-v-22f8c37b]{margin-right:26px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.cata-wrapper .night[data-v-22f8c37b] .log{border-bottom:1px solid #666}.cata-wrapper .day[data-v-22f8c37b] .log{border-bottom:1px solid #f2f2f2}@font-face{font-family:iconfont;src:url(../fonts/iconfont.f9a3fb0e.woff) format("woff")}[data-v-36dafd56] .iconfont,[data-v-36dafd56] .moon-icon{font-family:iconfont;font-style:normal}.settings-wrapper[data-v-36dafd56]{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;margin:-13px;width:478px;height:300px;text-align:left;padding:40px 0 40px 24px;background:#ede7da url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEX48dr48Nf58tv379X17NJtIBxUAAACFUlEQVQ4y1XRUZakMAgF0Af2AiDWApDZgHZqAV1nZv9rGh7Rj7Y8McUFEg1wvcMESMNVD/neU8Xcaz7nYYkYlYO6Ti82PBI4BvIEg1aj3wKwRvIMgZsUy5LdhCawPFh1sZs4SrlyN9fQKpv8s5dgZ2eLyqqJiu+WkCmUEybXkm3INS01WAiv0PapJ0CZc0SJQUzcWnZYbOOY20iFD8Bk+/j2A3wNxH7GdShFYS5ff237kXh9I9zSkQmIAhOsOSVfJ6DIXTMDaPnzkRJ92S1BQQmXl5LdirgRLLDdcYqcGPwe3QN4xCBiGNbrqq9wpW1XCecChwaQdVOsRDpPCpeoolPdxeXp3WNB9PHVzWBHlygy4NJCCrFHREv6bDt0VGwJZASkpONmm1UseGeFKAQexgaAkrfYWl3AGxWOLL2AIMBNbCXpktmS3k3vHeYjGCPBa43wJTurO3ZFVpQSJdAZGLoHTyk1upkjxMEaIxum3iIARcCa5kSkFAW5fi1mUlL9eyOsaanFmOMruwvEdE3ZYzsRSzo5ewRLXyVPPEvknt8ij4DvCg2O7xOgBCUprEzV4z1WekSpUgI8DT2mrnSOXKRfQavwuKA1F+tFnMKdJSUpMA7wQAifWRkMgjUKKZE4lBl6MCM4B1pq1P4uIjDE6Pq6rL0FnW1nIFmta5vrSvq/Ch4tpqG/ZNyyWa5jZPktq81eYv8Bt5s4iFITOp4AAAAASUVORK5CYII=) repeat}.settings-wrapper .settings-title[data-v-36dafd56]{font-size:18px;line-height:22px;margin-bottom:28px;font-family:FZZCYSK;font-weight:400}.settings-wrapper .setting-list ul[data-v-36dafd56]{list-style:none outside none;margin:0;padding:0}.settings-wrapper .setting-list ul li[data-v-36dafd56]{list-style:none outside none}.settings-wrapper .setting-list ul li i[data-v-36dafd56]{font:12px/16px PingFangSC-Regular,-apple-system,Simsun;display:inline-block;min-width:48px;margin-right:16px;vertical-align:middle;color:#666}.settings-wrapper .setting-list ul li .theme-item[data-v-36dafd56]{line-height:32px;width:34px;height:34px;margin-right:16px;border-radius:100%;display:inline-block;cursor:pointer;text-align:center;vertical-align:middle}.settings-wrapper .setting-list ul li .theme-item .iconfont[data-v-36dafd56]{display:none}.settings-wrapper .setting-list ul li .selected[data-v-36dafd56]{color:#ed4259}.settings-wrapper .setting-list ul li .selected .iconfont[data-v-36dafd56]{display:inline}.settings-wrapper .setting-list ul .font-list[data-v-36dafd56]{margin-top:28px}.settings-wrapper .setting-list ul .font-list .font-item[data-v-36dafd56]{width:78px;height:34px;cursor:pointer;margin-right:16px;border-radius:2px;text-align:center;vertical-align:middle;display:inline-block;font:14px/34px PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,Microsoft YaHei,sans-serif}.settings-wrapper .setting-list ul .font-list .font-item[data-v-36dafd56]:hover,.settings-wrapper .setting-list ul .font-list .selected[data-v-36dafd56]{color:#ed4259;border:1px solid #ed4259}.settings-wrapper .setting-list ul .font-size[data-v-36dafd56],.settings-wrapper .setting-list ul .read-width[data-v-36dafd56]{margin-top:28px}.settings-wrapper .setting-list ul .font-size .resize[data-v-36dafd56],.settings-wrapper .setting-list ul .read-width .resize[data-v-36dafd56]{display:inline-block;width:274px;height:34px;vertical-align:middle;border-radius:2px}.settings-wrapper .setting-list ul .font-size .resize span[data-v-36dafd56],.settings-wrapper .setting-list ul .read-width .resize span[data-v-36dafd56]{width:89px;height:34px;line-height:34px;display:inline-block;cursor:pointer;text-align:center;vertical-align:middle}.settings-wrapper .setting-list ul .font-size .resize span em[data-v-36dafd56],.settings-wrapper .setting-list ul .read-width .resize span em[data-v-36dafd56]{font-style:normal}.settings-wrapper .setting-list ul .font-size .resize .less[data-v-36dafd56]:hover,.settings-wrapper .setting-list ul .font-size .resize .more[data-v-36dafd56]:hover,.settings-wrapper .setting-list ul .read-width .resize .less[data-v-36dafd56]:hover,.settings-wrapper .setting-list ul .read-width .resize .more[data-v-36dafd56]:hover{color:#ed4259}.settings-wrapper .setting-list ul .font-size .resize .lang[data-v-36dafd56],.settings-wrapper .setting-list ul .read-width .resize .lang[data-v-36dafd56]{color:#a6a6a6;font-weight:400;font-family:FZZCYSK}.settings-wrapper .setting-list ul .font-size .resize b[data-v-36dafd56],.settings-wrapper .setting-list ul .read-width .resize b[data-v-36dafd56]{display:inline-block;height:20px;vertical-align:middle}.night[data-v-36dafd56] .selected,.night[data-v-36dafd56] .theme-item{border:1px solid #666}.night[data-v-36dafd56] .moon-icon{color:#ed4259}.night[data-v-36dafd56] .font-list .font-item,.night[data-v-36dafd56] .resize{border:1px solid #666;background:rgba(45,45,45,.5)}.night[data-v-36dafd56] .resize b{border-right:1px solid #666}.day[data-v-36dafd56] .theme-item{border:1px solid #e5e5e5}.day[data-v-36dafd56] .selected{border:1px solid #ed4259}.day[data-v-36dafd56] .moon-icon{display:inline;color:hsla(0,0%,100%,.2)}.day[data-v-36dafd56] .font-list .font-item{background:hsla(0,0%,100%,.5);border:1px solid rgba(0,0,0,.1)}.day[data-v-36dafd56] .resize{border:1px solid #e5e5e5;background:hsla(0,0%,100%,.5)}.day[data-v-36dafd56] .resize b{border-right:1px solid #e5e5e5}p[data-v-7b03cca0]{display:block;word-wrap:break-word;word-break:break-all}[data-v-0405dcaf] .pop-setting{margin-left:68px;top:0}[data-v-0405dcaf] .pop-cata{margin-left:10px}.chapter-wrapper[data-v-0405dcaf]{padding:0 4%;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.chapter-wrapper[data-v-0405dcaf] .no-point{pointer-events:none}.chapter-wrapper .tool-bar[data-v-0405dcaf]{position:fixed;top:0;left:50%;z-index:100}.chapter-wrapper .tool-bar .tools[data-v-0405dcaf]{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.chapter-wrapper .tool-bar .tools .tool-icon[data-v-0405dcaf]{font-size:18px;width:58px;height:48px;text-align:center;padding-top:12px;cursor:pointer;outline:none}.chapter-wrapper .tool-bar .tools .tool-icon .iconfont[data-v-0405dcaf]{font-family:iconfont;width:16px;height:16px;font-size:16px;margin:0 auto 6px}.chapter-wrapper .tool-bar .tools .tool-icon .icon-text[data-v-0405dcaf]{font-size:12px}.chapter-wrapper .read-bar[data-v-0405dcaf]{position:fixed;bottom:0;right:50%;z-index:100}.chapter-wrapper .read-bar .tools[data-v-0405dcaf]{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.chapter-wrapper .read-bar .tools .tool-icon[data-v-0405dcaf]{font-size:18px;width:42px;height:31px;padding-top:12px;text-align:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;cursor:pointer;outline:none;margin-top:-1px}.chapter-wrapper .read-bar .tools .tool-icon .iconfont[data-v-0405dcaf]{font-family:iconfont;width:16px;height:16px;font-size:16px;margin:0 auto 6px}.chapter-wrapper .chapter-bar .el-breadcrumb .item[data-v-0405dcaf]{font-size:14px;color:#606266}.chapter-wrapper .chapter[data-v-0405dcaf]{font-family:Microsoft YaHei,PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,sans-serif;text-align:left;padding:0 65px;min-height:100vh;width:670px;margin:0 auto}.chapter-wrapper .chapter[data-v-0405dcaf] .el-icon-loading{font-size:36px;color:#b5b5b5}.chapter-wrapper .chapter[data-v-0405dcaf] .el-loading-text{font-weight:500;color:#b5b5b5}.chapter-wrapper .chapter .content[data-v-0405dcaf]{font-size:18px;line-height:1.8;overflow:hidden;font-family:Microsoft YaHei,PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,sans-serif}.chapter-wrapper .chapter .content .title[data-v-0405dcaf]{margin-bottom:57px;font:24px/32px PingFangSC-Regular,HelveticaNeue-Light,Helvetica Neue Light,Microsoft YaHei,sans-serif}.chapter-wrapper .chapter .content .bottom-bar[data-v-0405dcaf],.chapter-wrapper .chapter .content .top-bar[data-v-0405dcaf]{height:64px}.day[data-v-0405dcaf] .popup{-webkit-box-shadow:0 2px 4px rgba(0,0,0,.12),0 0 6px rgba(0,0,0,.04);box-shadow:0 2px 4px rgba(0,0,0,.12),0 0 6px rgba(0,0,0,.04)}.day[data-v-0405dcaf] .tool-icon{border:1px solid rgba(0,0,0,.1);margin-top:-1px;color:#000}.day[data-v-0405dcaf] .tool-icon .icon-text{color:rgba(0,0,0,.4)}.day[data-v-0405dcaf] .chapter{border:1px solid #d8d8d8;color:#262626}.night[data-v-0405dcaf] .popup{-webkit-box-shadow:0 2px 4px rgba(0,0,0,.48),0 0 6px rgba(0,0,0,.16);box-shadow:0 2px 4px rgba(0,0,0,.48),0 0 6px rgba(0,0,0,.16)}.night[data-v-0405dcaf] .tool-icon{border:1px solid #444;margin-top:-1px;color:#666}.night[data-v-0405dcaf] .tool-icon .icon-text{color:#666}.night[data-v-0405dcaf] .chapter{border:1px solid #444;color:#666}.night[data-v-0405dcaf] .popper__arrow{background:#666} \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/favicon.ico b/app/src/main/assets/web/bookshelf/favicon.ico new file mode 100644 index 000000000..df36fcfb7 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/favicon.ico differ diff --git a/app/src/main/assets/web/bookshelf/fonts/element-icons.535877f5.woff b/app/src/main/assets/web/bookshelf/fonts/element-icons.535877f5.woff new file mode 100644 index 000000000..02b9a2539 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/fonts/element-icons.535877f5.woff differ diff --git a/app/src/main/assets/web/bookshelf/fonts/element-icons.732389de.ttf b/app/src/main/assets/web/bookshelf/fonts/element-icons.732389de.ttf new file mode 100644 index 000000000..91b74de36 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/fonts/element-icons.732389de.ttf differ diff --git a/app/src/main/assets/web/bookshelf/fonts/iconfont.f9a3fb0e.woff b/app/src/main/assets/web/bookshelf/fonts/iconfont.f9a3fb0e.woff new file mode 100644 index 000000000..4af734b10 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/fonts/iconfont.f9a3fb0e.woff differ diff --git a/app/src/main/assets/web/bookshelf/fonts/popfont.f39ecc1a.ttf b/app/src/main/assets/web/bookshelf/fonts/popfont.f39ecc1a.ttf new file mode 100644 index 000000000..e620a106d Binary files /dev/null and b/app/src/main/assets/web/bookshelf/fonts/popfont.f39ecc1a.ttf differ diff --git a/app/src/main/assets/web/bookshelf/fonts/shelffont.6c094b6d.ttf b/app/src/main/assets/web/bookshelf/fonts/shelffont.6c094b6d.ttf new file mode 100644 index 000000000..476a2418d Binary files /dev/null and b/app/src/main/assets/web/bookshelf/fonts/shelffont.6c094b6d.ttf differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/android-chrome-192x192.png b/app/src/main/assets/web/bookshelf/img/icons/android-chrome-192x192.png new file mode 100644 index 000000000..b02aa64d9 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/android-chrome-192x192.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/android-chrome-512x512.png b/app/src/main/assets/web/bookshelf/img/icons/android-chrome-512x512.png new file mode 100644 index 000000000..06088b011 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/android-chrome-512x512.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-120x120.png b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-120x120.png new file mode 100644 index 000000000..1427cf627 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-120x120.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-152x152.png b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-152x152.png new file mode 100644 index 000000000..f24d454a2 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-152x152.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-180x180.png b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-180x180.png new file mode 100644 index 000000000..404e192a9 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-180x180.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-60x60.png b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-60x60.png new file mode 100644 index 000000000..cf10a5602 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-60x60.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-76x76.png b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-76x76.png new file mode 100644 index 000000000..c500769e3 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon-76x76.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon.png b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon.png new file mode 100644 index 000000000..03c0c5d5e Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/apple-touch-icon.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/favicon-16x16.png b/app/src/main/assets/web/bookshelf/img/icons/favicon-16x16.png new file mode 100644 index 000000000..42af00963 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/favicon-16x16.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/favicon-32x32.png b/app/src/main/assets/web/bookshelf/img/icons/favicon-32x32.png new file mode 100644 index 000000000..46ca04dee Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/favicon-32x32.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/msapplication-icon-144x144.png b/app/src/main/assets/web/bookshelf/img/icons/msapplication-icon-144x144.png new file mode 100644 index 000000000..7808237a1 Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/msapplication-icon-144x144.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/mstile-150x150.png b/app/src/main/assets/web/bookshelf/img/icons/mstile-150x150.png new file mode 100644 index 000000000..3b37a43ae Binary files /dev/null and b/app/src/main/assets/web/bookshelf/img/icons/mstile-150x150.png differ diff --git a/app/src/main/assets/web/bookshelf/img/icons/safari-pinned-tab.svg b/app/src/main/assets/web/bookshelf/img/icons/safari-pinned-tab.svg new file mode 100644 index 000000000..732afd8eb --- /dev/null +++ b/app/src/main/assets/web/bookshelf/img/icons/safari-pinned-tab.svg @@ -0,0 +1,149 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/app/src/main/assets/web/bookshelf/index.html b/app/src/main/assets/web/bookshelf/index.html new file mode 100644 index 000000000..3328d9b1b --- /dev/null +++ b/app/src/main/assets/web/bookshelf/index.html @@ -0,0 +1,43 @@ + + + + + + + Legado Bookshelf + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/js/about.9f8f9ac0.js b/app/src/main/assets/web/bookshelf/js/about.9f8f9ac0.js new file mode 100644 index 000000000..5b062a0d6 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/js/about.9f8f9ac0.js @@ -0,0 +1 @@ +(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["about"],{"04d1":function(t,e,n){var r=n("342f"),a=r.match(/firefox\/(\d+)/i);t.exports=!!a&&+a[1]},"0cb2":function(t,e,n){var r=n("7b0b"),a=Math.floor,i="".replace,s=/\$([$&'`]|\d{1,2}|<[^>]*>)/g,c=/\$([$&'`]|\d{1,2})/g;t.exports=function(t,e,n,o,l,u){var d=n+t.length,f=o.length,h=c;return void 0!==l&&(l=r(l),h=s),i.call(u,h,(function(r,i){var s;switch(i.charAt(0)){case"$":return"$";case"&":return t;case"`":return e.slice(0,n);case"'":return e.slice(d);case"<":s=l[i.slice(1,-1)];break;default:var c=+i;if(0===c)return r;if(c>f){var u=a(c/10);return 0===u?r:u<=f?void 0===o[u-1]?i.charAt(1):o[u-1]+i.charAt(1):r}s=o[c-1]}return void 0===s?"":s}))}},"4d63":function(t,e,n){var r=n("83ab"),a=n("da84"),i=n("94ca"),s=n("7156"),c=n("9112"),o=n("9bf2").f,l=n("241c").f,u=n("44e7"),d=n("ad6d"),f=n("9f7f"),h=n("6eeb"),g=n("d039"),v=n("5135"),p=n("69f3").enforce,m=n("2626"),b=n("b622"),w=n("fce3"),x=n("107c"),C=b("match"),A=a.RegExp,I=A.prototype,R=/^\?<[^\s\d!#%&*+<=>@^][^\s!#%&*+<=>@^]*>/,y=/a/g,k=/a/g,E=new A(y)!==y,M=f.UNSUPPORTED_Y,S=r&&(!E||M||w||x||g((function(){return k[C]=!1,A(y)!=y||A(k)==k||"/a/i"!=A(y,"i")}))),B=function(t){for(var e,n=t.length,r=0,a="",i=!1;r<=n;r++)e=t.charAt(r),"\\"!==e?i||"."!==e?("["===e?i=!0:"]"===e&&(i=!1),a+=e):a+="[\\s\\S]":a+=e+t.charAt(++r);return a},T=function(t){for(var e,n=t.length,r=0,a="",i=[],s={},c=!1,o=!1,l=0,u="";r<=n;r++){if(e=t.charAt(r),"\\"===e)e+=t.charAt(++r);else if("]"===e)c=!1;else if(!c)switch(!0){case"["===e:c=!0;break;case"("===e:R.test(t.slice(r+1))&&(r+=2,o=!0),a+=e,l++;continue;case">"===e&&o:if(""===u||v(s,u))throw new SyntaxError("Invalid capture group name");s[u]=!0,i.push([u,l]),o=!1,u="";continue}o?u+=e:a+=e}return[a,i]};if(i("RegExp",S)){for(var $=function(t,e){var n,r,a,i,o,l,f=this instanceof $,h=u(t),g=void 0===e,v=[],m=t;if(!f&&h&&g&&t.constructor===$)return t;if((h||t instanceof $)&&(t=t.source,g&&(e="flags"in m?m.flags:d.call(m))),t=void 0===t?"":String(t),e=void 0===e?"":String(e),m=t,w&&"dotAll"in y&&(r=!!e&&e.indexOf("s")>-1,r&&(e=e.replace(/s/g,""))),n=e,M&&"sticky"in y&&(a=!!e&&e.indexOf("y")>-1,a&&(e=e.replace(/y/g,""))),x&&(i=T(t),t=i[0],v=i[1]),o=s(A(t,e),f?this:I,$),(r||a||v.length)&&(l=p(o),r&&(l.dotAll=!0,l.raw=$(B(t),n)),a&&(l.sticky=!0),v.length&&(l.groups=v)),t!==m)try{c(o,"source",""===m?"(?:)":m)}catch(b){}return o},_=function(t){t in $||o($,t,{configurable:!0,get:function(){return A[t]},set:function(e){A[t]=e}})},D=l(A),z=0;D.length>z;)_(D[z++]);I.constructor=$,$.prototype=I,h(a,"RegExp",$)}m("RegExp")},"4e82":function(t,e,n){"use strict";var r=n("23e7"),a=n("1c0b"),i=n("7b0b"),s=n("50c4"),c=n("d039"),o=n("addb"),l=n("a640"),u=n("04d1"),d=n("d998"),f=n("2d00"),h=n("512c"),g=[],v=g.sort,p=c((function(){g.sort(void 0)})),m=c((function(){g.sort(null)})),b=l("sort"),w=!c((function(){if(f)return f<70;if(!(u&&u>3)){if(d)return!0;if(h)return h<603;var t,e,n,r,a="";for(t=65;t<76;t++){switch(e=String.fromCharCode(t),t){case 66:case 69:case 70:case 72:n=3;break;case 68:case 71:n=4;break;default:n=2}for(r=0;r<47;r++)g.push({k:e+r,v:n})}for(g.sort((function(t,e){return e.v-t.v})),r=0;rString(n)?1:-1}};r({target:"Array",proto:!0,forced:x},{sort:function(t){void 0!==t&&a(t);var e=i(this);if(w)return void 0===t?v.call(e):v.call(e,t);var n,r,c=[],l=s(e.length);for(r=0;r")}));r("replace",(function(t,e,n){var r=b?"$":"$0";return[function(t,n){var r=o(this),a=void 0==t?void 0:t[h];return void 0!==a?a.call(t,r,n):e.call(String(r),t,n)},function(t,a){if("string"===typeof a&&-1===a.indexOf(r)&&-1===a.indexOf("$<")){var o=n(e,this,t,a);if(o.done)return o.value}var f=i(this),h=String(t),m="function"===typeof a;m||(a=String(a));var b=f.global;if(b){var w=f.unicode;f.lastIndex=0}var x=[];while(1){var C=d(f,h);if(null===C)break;if(x.push(C),!b)break;var A=String(C[0]);""===A&&(f.lastIndex=l(h,s(f.lastIndex),w))}for(var I="",R=0,y=0;y=R&&(I+=h.slice(R,E)+$,R=E+k.length)}return I+h.slice(R)}]}),!w||!m||b)},"6e9d":function(t,e,n){},"6fb2":function(t,e,n){"use strict";n("6e9d")},7156:function(t,e,n){var r=n("861d"),a=n("d2bb");t.exports=function(t,e,n){var i,s;return a&&"function"==typeof(i=e.constructor)&&i!==n&&r(s=i.prototype)&&s!==n.prototype&&a(t,s),t}},"7b5b":function(t,e,n){},a640:function(t,e,n){"use strict";var r=n("d039");t.exports=function(t,e){var n=[][t];return!!n&&r((function(){n.call(null,e||function(){throw 1},1)}))}},addb:function(t,e){var n=Math.floor,r=function(t,e){var s=t.length,c=n(s/2);return s<8?a(t,e):i(r(t.slice(0,c),e),r(t.slice(c),e),e)},a=function(t,e){var n,r,a=t.length,i=1;while(i0)t[r]=t[--r];r!==i++&&(t[r]=n)}return t},i=function(t,e,n){var r=t.length,a=e.length,i=0,s=0,c=[];while(ib)","string".charAt(5));return"b"!==e.exec("b").groups.a||"bc"!=="b".replace(e,"$c")}))},"14c3":function(e,t,n){var r=n("c6b6"),o=n("9263");e.exports=function(e,t){var n=e.exec;if("function"===typeof n){var i=n.call(e,t);if("object"!==typeof i)throw TypeError("RegExp exec method returned something other than an Object or null");return i}if("RegExp"!==r(e))throw TypeError("RegExp#exec called on incompatible receiver");return o.call(e,t)}},"1d2b":function(e,t,n){"use strict";e.exports=function(e,t){return function(){for(var n=new Array(arguments.length),r=0;r=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*"}}};r.forEach(["delete","get","head"],(function(e){c.headers[e]={}})),r.forEach(["post","put","patch"],(function(e){c.headers[e]=r.merge(i)})),e.exports=c}).call(this,n("4362"))},"2d83":function(e,t,n){"use strict";var r=n("387f");e.exports=function(e,t,n,o,i){var s=new Error(e);return r(s,t,n,o,i)}},"2e67":function(e,t,n){"use strict";e.exports=function(e){return!(!e||!e.__CANCEL__)}},"30b5":function(e,t,n){"use strict";var r=n("c532");function o(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}e.exports=function(e,t,n){if(!t)return e;var i;if(n)i=n(t);else if(r.isURLSearchParams(t))i=t.toString();else{var s=[];r.forEach(t,(function(e,t){null!==e&&"undefined"!==typeof e&&(r.isArray(e)?t+="[]":e=[e],r.forEach(e,(function(e){r.isDate(e)?e=e.toISOString():r.isObject(e)&&(e=JSON.stringify(e)),s.push(o(t)+"="+o(e))})))})),i=s.join("&")}if(i){var a=e.indexOf("#");-1!==a&&(e=e.slice(0,a)),e+=(-1===e.indexOf("?")?"?":"&")+i}return e}},"387f":function(e,t,n){"use strict";e.exports=function(e,t,n,r,o){return e.config=t,n&&(e.code=n),e.request=r,e.response=o,e.isAxiosError=!0,e.toJSON=function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:this.config,code:this.code}},e}},3934:function(e,t,n){"use strict";var r=n("c532");e.exports=r.isStandardBrowserEnv()?function(){var e,t=/(msie|trident)/i.test(navigator.userAgent),n=document.createElement("a");function o(e){var r=e;return t&&(n.setAttribute("href",r),r=n.href),n.setAttribute("href",r),{href:n.href,protocol:n.protocol?n.protocol.replace(/:$/,""):"",host:n.host,search:n.search?n.search.replace(/^\?/,""):"",hash:n.hash?n.hash.replace(/^#/,""):"",hostname:n.hostname,port:n.port,pathname:"/"===n.pathname.charAt(0)?n.pathname:"/"+n.pathname}}return e=o(window.location.href),function(t){var n=r.isString(t)?o(t):t;return n.protocol===e.protocol&&n.host===e.host}}():function(){return function(){return!0}}()},4362:function(e,t,n){t.nextTick=function(e){var t=Array.prototype.slice.call(arguments);t.shift(),setTimeout((function(){e.apply(null,t)}),0)},t.platform=t.arch=t.execPath=t.title="browser",t.pid=1,t.browser=!0,t.env={},t.argv=[],t.binding=function(e){throw new Error("No such module. (Possibly not yet loaded)")},function(){var e,r="/";t.cwd=function(){return r},t.chdir=function(t){e||(e=n("df7c")),r=e.resolve(t,r)}}(),t.exit=t.kill=t.umask=t.dlopen=t.uptime=t.memoryUsage=t.uvCounters=function(){},t.features={}},"44e7":function(e,t,n){var r=n("861d"),o=n("c6b6"),i=n("b622"),s=i("match");e.exports=function(e){var t;return r(e)&&(void 0!==(t=e[s])?!!t:"RegExp"==o(e))}},"467f":function(e,t,n){"use strict";var r=n("2d83");e.exports=function(e,t,n){var o=n.config.validateStatus;n.status&&o&&!o(n.status)?t(r("Request failed with status code "+n.status,n.config,null,n.request,n)):e(n)}},"4a7b":function(e,t,n){"use strict";var r=n("c532");e.exports=function(e,t){t=t||{};var n={},o=["url","method","data"],i=["headers","auth","proxy","params"],s=["baseURL","transformRequest","transformResponse","paramsSerializer","timeout","timeoutMessage","withCredentials","adapter","responseType","xsrfCookieName","xsrfHeaderName","onUploadProgress","onDownloadProgress","decompress","maxContentLength","maxBodyLength","maxRedirects","transport","httpAgent","httpsAgent","cancelToken","socketPath","responseEncoding"],a=["validateStatus"];function c(e,t){return r.isPlainObject(e)&&r.isPlainObject(t)?r.merge(e,t):r.isPlainObject(t)?r.merge({},t):r.isArray(t)?t.slice():t}function u(o){r.isUndefined(t[o])?r.isUndefined(e[o])||(n[o]=c(void 0,e[o])):n[o]=c(e[o],t[o])}r.forEach(o,(function(e){r.isUndefined(t[e])||(n[e]=c(void 0,t[e]))})),r.forEach(i,u),r.forEach(s,(function(o){r.isUndefined(t[o])?r.isUndefined(e[o])||(n[o]=c(void 0,e[o])):n[o]=c(void 0,t[o])})),r.forEach(a,(function(r){r in t?n[r]=c(e[r],t[r]):r in e&&(n[r]=c(void 0,e[r]))}));var f=o.concat(i).concat(s).concat(a),l=Object.keys(e).concat(Object.keys(t)).filter((function(e){return-1===f.indexOf(e)}));return r.forEach(l,u),n}},5270:function(e,t,n){"use strict";var r=n("c532"),o=n("c401"),i=n("2e67"),s=n("2444");function a(e){e.cancelToken&&e.cancelToken.throwIfRequested()}e.exports=function(e){a(e),e.headers=e.headers||{},e.data=o(e.data,e.headers,e.transformRequest),e.headers=r.merge(e.headers.common||{},e.headers[e.method]||{},e.headers),r.forEach(["delete","get","head","post","put","patch","common"],(function(t){delete e.headers[t]}));var t=e.adapter||s.adapter;return t(e).then((function(t){return a(e),t.data=o(t.data,t.headers,e.transformResponse),t}),(function(t){return i(t)||(a(e),t&&t.response&&(t.response.data=o(t.response.data,t.response.headers,e.transformResponse))),Promise.reject(t)}))}},"5f02":function(e,t,n){"use strict";e.exports=function(e){return"object"===typeof e&&!0===e.isAxiosError}},"7a77":function(e,t,n){"use strict";function r(e){this.message=e}r.prototype.toString=function(){return"Cancel"+(this.message?": "+this.message:"")},r.prototype.__CANCEL__=!0,e.exports=r},"7aac":function(e,t,n){"use strict";var r=n("c532");e.exports=r.isStandardBrowserEnv()?function(){return{write:function(e,t,n,o,i,s){var a=[];a.push(e+"="+encodeURIComponent(t)),r.isNumber(n)&&a.push("expires="+new Date(n).toGMTString()),r.isString(o)&&a.push("path="+o),r.isString(i)&&a.push("domain="+i),!0===s&&a.push("secure"),document.cookie=a.join("; ")},read:function(e){var t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove:function(e){this.write(e,"",Date.now()-864e5)}}}():function(){return{write:function(){},read:function(){return null},remove:function(){}}}()},"83b9":function(e,t,n){"use strict";var r=n("d925"),o=n("e683");e.exports=function(e,t){return e&&!r(t)?o(e,t):t}},"8aa5":function(e,t,n){"use strict";var r=n("6547").charAt;e.exports=function(e,t,n){return t+(n?r(e,t).length:1)}},"8df4":function(e,t,n){"use strict";var r=n("7a77");function o(e){if("function"!==typeof e)throw new TypeError("executor must be a function.");var t;this.promise=new Promise((function(e){t=e}));var n=this;e((function(e){n.reason||(n.reason=new r(e),t(n.reason))}))}o.prototype.throwIfRequested=function(){if(this.reason)throw this.reason},o.source=function(){var e,t=new o((function(t){e=t}));return{token:t,cancel:e}},e.exports=o},9263:function(e,t,n){"use strict";var r=n("ad6d"),o=n("9f7f"),i=n("5692"),s=n("7c73"),a=n("69f3").get,c=n("fce3"),u=n("107c"),f=RegExp.prototype.exec,l=i("native-string-replace",String.prototype.replace),p=f,d=function(){var e=/a/,t=/b*/g;return f.call(e,"a"),f.call(t,"a"),0!==e.lastIndex||0!==t.lastIndex}(),h=o.UNSUPPORTED_Y||o.BROKEN_CARET,m=void 0!==/()??/.exec("")[1],g=d||m||h||c||u;g&&(p=function(e){var t,n,o,i,c,u,g,v=this,x=a(v),y=x.raw;if(y)return y.lastIndex=v.lastIndex,t=p.call(y,e),v.lastIndex=y.lastIndex,t;var b=x.groups,w=h&&v.sticky,E=r.call(v),R=v.source,A=0,C=e;if(w&&(E=E.replace("y",""),-1===E.indexOf("g")&&(E+="g"),C=String(e).slice(v.lastIndex),v.lastIndex>0&&(!v.multiline||v.multiline&&"\n"!==e[v.lastIndex-1])&&(R="(?: "+R+")",C=" "+C,A++),n=new RegExp("^(?:"+R+")",E)),m&&(n=new RegExp("^"+R+"$(?!\\s)",E)),d&&(o=v.lastIndex),i=f.call(w?n:v,C),w?i?(i.input=i.input.slice(A),i[0]=i[0].slice(A),i.index=v.lastIndex,v.lastIndex+=i[0].length):v.lastIndex=0:d&&i&&(v.lastIndex=v.global?i.index+i[0].length:o),m&&i&&i.length>1&&l.call(i[0],n,(function(){for(c=1;c=0)return;s[t]="set-cookie"===t?(s[t]?s[t]:[]).concat([n]):s[t]?s[t]+", "+n:n}})),s):s}},c401:function(e,t,n){"use strict";var r=n("c532");e.exports=function(e,t,n){return r.forEach(n,(function(n){e=n(e,t)})),e}},c532:function(e,t,n){"use strict";var r=n("1d2b"),o=Object.prototype.toString;function i(e){return"[object Array]"===o.call(e)}function s(e){return"undefined"===typeof e}function a(e){return null!==e&&!s(e)&&null!==e.constructor&&!s(e.constructor)&&"function"===typeof e.constructor.isBuffer&&e.constructor.isBuffer(e)}function c(e){return"[object ArrayBuffer]"===o.call(e)}function u(e){return"undefined"!==typeof FormData&&e instanceof FormData}function f(e){var t;return t="undefined"!==typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&e.buffer instanceof ArrayBuffer,t}function l(e){return"string"===typeof e}function p(e){return"number"===typeof e}function d(e){return null!==e&&"object"===typeof e}function h(e){if("[object Object]"!==o.call(e))return!1;var t=Object.getPrototypeOf(e);return null===t||t===Object.prototype}function m(e){return"[object Date]"===o.call(e)}function g(e){return"[object File]"===o.call(e)}function v(e){return"[object Blob]"===o.call(e)}function x(e){return"[object Function]"===o.call(e)}function y(e){return d(e)&&x(e.pipe)}function b(e){return"undefined"!==typeof URLSearchParams&&e instanceof URLSearchParams}function w(e){return e.replace(/^\s*/,"").replace(/\s*$/,"")}function E(){return("undefined"===typeof navigator||"ReactNative"!==navigator.product&&"NativeScript"!==navigator.product&&"NS"!==navigator.product)&&("undefined"!==typeof window&&"undefined"!==typeof document)}function R(e,t){if(null!==e&&"undefined"!==typeof e)if("object"!==typeof e&&(e=[e]),i(e))for(var n=0,r=e.length;n=0;r--){var o=e[r];"."===o?e.splice(r,1):".."===o?(e.splice(r,1),n++):n&&(e.splice(r,1),n--)}if(t)for(;n--;n)e.unshift("..");return e}function r(e){"string"!==typeof e&&(e+="");var t,n=0,r=-1,o=!0;for(t=e.length-1;t>=0;--t)if(47===e.charCodeAt(t)){if(!o){n=t+1;break}}else-1===r&&(o=!1,r=t+1);return-1===r?"":e.slice(n,r)}function o(e,t){if(e.filter)return e.filter(t);for(var n=[],r=0;r=-1&&!r;i--){var s=i>=0?arguments[i]:e.cwd();if("string"!==typeof s)throw new TypeError("Arguments to path.resolve must be strings");s&&(t=s+"/"+t,r="/"===s.charAt(0))}return t=n(o(t.split("/"),(function(e){return!!e})),!r).join("/"),(r?"/":"")+t||"."},t.normalize=function(e){var r=t.isAbsolute(e),s="/"===i(e,-1);return e=n(o(e.split("/"),(function(e){return!!e})),!r).join("/"),e||r||(e="."),e&&s&&(e+="/"),(r?"/":"")+e},t.isAbsolute=function(e){return"/"===e.charAt(0)},t.join=function(){var e=Array.prototype.slice.call(arguments,0);return t.normalize(o(e,(function(e,t){if("string"!==typeof e)throw new TypeError("Arguments to path.join must be strings");return e})).join("/"))},t.relative=function(e,n){function r(e){for(var t=0;t=0;n--)if(""!==e[n])break;return t>n?[]:e.slice(t,n-t+1)}e=t.resolve(e).substr(1),n=t.resolve(n).substr(1);for(var o=r(e.split("/")),i=r(n.split("/")),s=Math.min(o.length,i.length),a=s,c=0;c=1;--i)if(t=e.charCodeAt(i),47===t){if(!o){r=i;break}}else o=!1;return-1===r?n?"/":".":n&&1===r?"/":e.slice(0,r)},t.basename=function(e,t){var n=r(e);return t&&n.substr(-1*t.length)===t&&(n=n.substr(0,n.length-t.length)),n},t.extname=function(e){"string"!==typeof e&&(e+="");for(var t=-1,n=0,r=-1,o=!0,i=0,s=e.length-1;s>=0;--s){var a=e.charCodeAt(s);if(47!==a)-1===r&&(o=!1,r=s+1),46===a?-1===t?t=s:1!==i&&(i=1):-1!==t&&(i=-1);else if(!o){n=s+1;break}}return-1===t||-1===r||0===i||1===i&&t===r-1&&t===n+1?"":e.slice(t,r)};var i="b"==="ab".substr(-1)?function(e,t,n){return e.substr(t,n)}:function(e,t,n){return t<0&&(t=e.length+t),e.substr(t,n)}}).call(this,n("4362"))},e683:function(e,t,n){"use strict";e.exports=function(e,t){return t?e.replace(/\/+$/,"")+"/"+t.replace(/^\/+/,""):e}},f6b4:function(e,t,n){"use strict";var r=n("c532");function o(){this.handlers=[]}o.prototype.use=function(e,t){return this.handlers.push({fulfilled:e,rejected:t}),this.handlers.length-1},o.prototype.eject=function(e){this.handlers[e]&&(this.handlers[e]=null)},o.prototype.forEach=function(e){r.forEach(this.handlers,(function(t){null!==t&&e(t)}))},e.exports=o},fce3:function(e,t,n){var r=n("d039");e.exports=r((function(){var e=RegExp(".","string".charAt(0));return!(e.dotAll&&e.exec("\n")&&"s"===e.flags)}))}}]); \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/js/app.e84ee963.js b/app/src/main/assets/web/bookshelf/js/app.e84ee963.js new file mode 100644 index 000000000..f59f100b4 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/js/app.e84ee963.js @@ -0,0 +1 @@ +(function(e){function t(t){for(var o,r,i=t[0],c=t[1],l=t[2],f=0,d=[];f>>32-t}function o(e,t){var n,o,r,a,u;return r=2147483648&e,a=2147483648&t,n=1073741824&e,o=1073741824&t,u=(1073741823&e)+(1073741823&t),n&o?2147483648^u^r^a:n|o?1073741824&u?3221225472^u^r^a:1073741824^u^r^a:u^r^a}function r(e,t,n){return e&t|~e&n}function a(e,t,n){return e&n|t&~n}function u(e,t,n){return e^t^n}function i(e,t,n){return t^(e|~n)}function c(e,t,a,u,i,c,l){return e=o(e,o(o(r(t,a,u),i),l)),o(n(e,c),t)}function l(e,t,r,u,i,c,l){return e=o(e,o(o(a(t,r,u),i),l)),o(n(e,c),t)}function f(e,t,r,a,i,c,l){return e=o(e,o(o(u(t,r,a),i),l)),o(n(e,c),t)}function d(e,t,r,a,u,c,l){return e=o(e,o(o(i(t,r,a),u),l)),o(n(e,c),t)}function s(e){var t,n=e.length,o=n+8,r=(o-o%64)/64,a=16*(r+1),u=Array(a-1),i=0,c=0;while(c>>29,u}function p(e){var t,n,o="",r="";for(n=0;n<=3;n++)t=e>>>8*n&255,r="0"+t.toString(16),o+=r.substr(r.length-2,2);return o}var b,h,g,m,v,y,w,C,S,P=Array(),O=7,j=12,k=17,T=22,_=5,x=9,E=14,A=20,L=4,$=11,B=16,N=23,V=6,M=10,D=15,q=21;for(P=s(t),y=1732584193,w=4023233417,C=2562383102,S=271733878,b=0;bd;d++)if(h=C(e[d]),h&&h instanceof u)return h;return new u(!1)}c=f.call(e)}v=c.next;while(!(m=v.call(c)).done){try{h=C(m.value)}catch(S){throw l(c),S}if("object"==typeof h&&h&&h instanceof u)return h}return new u(!1)}},"23cb":function(e,t,n){var r=n("a691"),o=Math.max,i=Math.min;e.exports=function(e,t){var n=r(e);return n<0?o(n+t,0):i(n,t)}},"23e7":function(e,t,n){var r=n("da84"),o=n("06cf").f,i=n("9112"),a=n("6eeb"),s=n("ce4e"),l=n("e893"),u=n("94ca");e.exports=function(e,t){var n,c,f,d,p,h,v=e.target,m=e.global,y=e.stat;if(c=m?r:y?r[v]||s(v,{}):(r[v]||{}).prototype,c)for(f in t){if(p=t[f],e.noTargetGet?(h=o(c,f),d=h&&h.value):d=c[f],n=u(m?f:v+(y?".":"#")+f,e.forced),!n&&void 0!==d){if(typeof p===typeof d)continue;l(p,d)}(e.sham||d&&d.sham)&&i(p,"sham",!0),a(c,f,p,e)}}},"241c":function(e,t,n){var r=n("ca84"),o=n("7839"),i=o.concat("length","prototype");t.f=Object.getOwnPropertyNames||function(e){return r(e,i)}},"25f0":function(e,t,n){"use strict";var r=n("6eeb"),o=n("825a"),i=n("d039"),a=n("ad6d"),s="toString",l=RegExp.prototype,u=l[s],c=i((function(){return"/a/b"!=u.call({source:"a",flags:"b"})})),f=u.name!=s;(c||f)&&r(RegExp.prototype,s,(function(){var e=o(this),t=String(e.source),n=e.flags,r=String(void 0===n&&e instanceof RegExp&&!("flags"in l)?a.call(e):n);return"/"+t+"/"+r}),{unsafe:!0})},2626:function(e,t,n){"use strict";var r=n("d066"),o=n("9bf2"),i=n("b622"),a=n("83ab"),s=i("species");e.exports=function(e){var t=r(e),n=o.f;a&&t&&!t[s]&&n(t,s,{configurable:!0,get:function(){return this}})}},2877:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,(u.functional?this.parent:this).$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},"299c":function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=135)}({135:function(e,t,n){"use strict";n.r(t);var r=n(5),o=n.n(r),i=n(17),a=n.n(i),s=n(2),l=n(3),u=n(7),c=n.n(u),f={name:"ElTooltip",mixins:[o.a],props:{openDelay:{type:Number,default:0},disabled:Boolean,manual:Boolean,effect:{type:String,default:"dark"},arrowOffset:{type:Number,default:0},popperClass:String,content:String,visibleArrow:{default:!0},transition:{type:String,default:"el-fade-in-linear"},popperOptions:{default:function(){return{boundariesPadding:10,gpuAcceleration:!1}}},enterable:{type:Boolean,default:!0},hideAfter:{type:Number,default:0},tabindex:{type:Number,default:0}},data:function(){return{tooltipId:"el-tooltip-"+Object(l["generateId"])(),timeoutPending:null,focusing:!1}},beforeCreate:function(){var e=this;this.$isServer||(this.popperVM=new c.a({data:{node:""},render:function(e){return this.node}}).$mount(),this.debounceClose=a()(200,(function(){return e.handleClosePopper()})))},render:function(e){var t=this;this.popperVM&&(this.popperVM.node=e("transition",{attrs:{name:this.transition},on:{afterLeave:this.doDestroy}},[e("div",{on:{mouseleave:function(){t.setExpectedState(!1),t.debounceClose()},mouseenter:function(){t.setExpectedState(!0)}},ref:"popper",attrs:{role:"tooltip",id:this.tooltipId,"aria-hidden":this.disabled||!this.showPopper?"true":"false"},directives:[{name:"show",value:!this.disabled&&this.showPopper}],class:["el-tooltip__popper","is-"+this.effect,this.popperClass]},[this.$slots.content||this.content])]));var n=this.getFirstElement();if(!n)return null;var r=n.data=n.data||{};return r.staticClass=this.addTooltipClass(r.staticClass),n},mounted:function(){var e=this;this.referenceElm=this.$el,1===this.$el.nodeType&&(this.$el.setAttribute("aria-describedby",this.tooltipId),this.$el.setAttribute("tabindex",this.tabindex),Object(s["on"])(this.referenceElm,"mouseenter",this.show),Object(s["on"])(this.referenceElm,"mouseleave",this.hide),Object(s["on"])(this.referenceElm,"focus",(function(){if(e.$slots.default&&e.$slots.default.length){var t=e.$slots.default[0].componentInstance;t&&t.focus?t.focus():e.handleFocus()}else e.handleFocus()})),Object(s["on"])(this.referenceElm,"blur",this.handleBlur),Object(s["on"])(this.referenceElm,"click",this.removeFocusing)),this.value&&this.popperVM&&this.popperVM.$nextTick((function(){e.value&&e.updatePopper()}))},watch:{focusing:function(e){e?Object(s["addClass"])(this.referenceElm,"focusing"):Object(s["removeClass"])(this.referenceElm,"focusing")}},methods:{show:function(){this.setExpectedState(!0),this.handleShowPopper()},hide:function(){this.setExpectedState(!1),this.debounceClose()},handleFocus:function(){this.focusing=!0,this.show()},handleBlur:function(){this.focusing=!1,this.hide()},removeFocusing:function(){this.focusing=!1},addTooltipClass:function(e){return e?"el-tooltip "+e.replace("el-tooltip",""):"el-tooltip"},handleShowPopper:function(){var e=this;this.expectedState&&!this.manual&&(clearTimeout(this.timeout),this.timeout=setTimeout((function(){e.showPopper=!0}),this.openDelay),this.hideAfter>0&&(this.timeoutPending=setTimeout((function(){e.showPopper=!1}),this.hideAfter)))},handleClosePopper:function(){this.enterable&&this.expectedState||this.manual||(clearTimeout(this.timeout),this.timeoutPending&&clearTimeout(this.timeoutPending),this.showPopper=!1,this.disabled&&this.doDestroy())},setExpectedState:function(e){!1===e&&clearTimeout(this.timeoutPending),this.expectedState=e},getFirstElement:function(){var e=this.$slots.default;if(!Array.isArray(e))return null;for(var t=null,n=0;n=0&&Math.floor(t)===t&&isFinite(e)}function p(e){return o(e)&&"function"===typeof e.then&&"function"===typeof e.catch}function h(e){return null==e?"":Array.isArray(e)||c(e)&&e.toString===u?JSON.stringify(e,null,2):String(e)}function v(e){var t=parseFloat(e);return isNaN(t)?e:t}function m(e,t){for(var n=Object.create(null),r=e.split(","),o=0;o-1)return e.splice(n,1)}}var b=Object.prototype.hasOwnProperty;function _(e,t){return b.call(e,t)}function x(e){var t=Object.create(null);return function(n){var r=t[n];return r||(t[n]=e(n))}}var w=/-(\w)/g,C=x((function(e){return e.replace(w,(function(e,t){return t?t.toUpperCase():""}))})),S=x((function(e){return e.charAt(0).toUpperCase()+e.slice(1)})),O=/\B([A-Z])/g,E=x((function(e){return e.replace(O,"-$1").toLowerCase()}));function k(e,t){function n(n){var r=arguments.length;return r?r>1?e.apply(t,arguments):e.call(t,n):e.call(t)}return n._length=e.length,n}function $(e,t){return e.bind(t)}var j=Function.prototype.bind?$:k;function T(e,t){t=t||0;var n=e.length-t,r=new Array(n);while(n--)r[n]=e[n+t];return r}function A(e,t){for(var n in t)e[n]=t[n];return e}function M(e){for(var t={},n=0;n0,ne=Q&&Q.indexOf("edge/")>0,re=(Q&&Q.indexOf("android"),Q&&/iphone|ipad|ipod|ios/.test(Q)||"ios"===Z),oe=(Q&&/chrome\/\d+/.test(Q),Q&&/phantomjs/.test(Q),Q&&Q.match(/firefox\/(\d+)/)),ie={}.watch,ae=!1;if(Y)try{var se={};Object.defineProperty(se,"passive",{get:function(){ae=!0}}),window.addEventListener("test-passive",null,se)}catch(Sa){}var le=function(){return void 0===G&&(G=!Y&&!J&&"undefined"!==typeof e&&(e["process"]&&"server"===e["process"].env.VUE_ENV)),G},ue=Y&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function ce(e){return"function"===typeof e&&/native code/.test(e.toString())}var fe,de="undefined"!==typeof Symbol&&ce(Symbol)&&"undefined"!==typeof Reflect&&ce(Reflect.ownKeys);fe="undefined"!==typeof Set&&ce(Set)?Set:function(){function e(){this.set=Object.create(null)}return e.prototype.has=function(e){return!0===this.set[e]},e.prototype.add=function(e){this.set[e]=!0},e.prototype.clear=function(){this.set=Object.create(null)},e}();var pe=P,he=0,ve=function(){this.id=he++,this.subs=[]};ve.prototype.addSub=function(e){this.subs.push(e)},ve.prototype.removeSub=function(e){g(this.subs,e)},ve.prototype.depend=function(){ve.target&&ve.target.addDep(this)},ve.prototype.notify=function(){var e=this.subs.slice();for(var t=0,n=e.length;t-1)if(i&&!_(o,"default"))a=!1;else if(""===a||a===E(e)){var l=tt(String,o.type);(l<0||s0&&(a=$t(a,(t||"")+"_"+n),kt(a[0])&&kt(u)&&(c[l]=we(u.text+a[0].text),a.shift()),c.push.apply(c,a)):s(a)?kt(u)?c[l]=we(u.text+a):""!==a&&c.push(we(a)):kt(a)&&kt(u)?c[l]=we(u.text+a.text):(i(e._isVList)&&o(a.tag)&&r(a.key)&&o(t)&&(a.key="__vlist"+t+"_"+n+"__"),c.push(a)));return c}function jt(e){var t=e.$options.provide;t&&(e._provided="function"===typeof t?t.call(e):t)}function Tt(e){var t=At(e.$options.inject,e);t&&(je(!1),Object.keys(t).forEach((function(n){Le(e,n,t[n])})),je(!0))}function At(e,t){if(e){for(var n=Object.create(null),r=de?Reflect.ownKeys(e):Object.keys(e),o=0;o0,a=e?!!e.$stable:!i,s=e&&e.$key;if(e){if(e._normalized)return e._normalized;if(a&&r&&r!==n&&s===r.$key&&!i&&!r.$hasNormal)return r;for(var l in o={},e)e[l]&&"$"!==l[0]&&(o[l]=Nt(t,l,e[l]))}else o={};for(var u in t)u in o||(o[u]=It(t,u));return e&&Object.isExtensible(e)&&(e._normalized=o),U(o,"$stable",a),U(o,"$key",s),U(o,"$hasNormal",i),o}function Nt(e,t,n){var r=function(){var e=arguments.length?n.apply(null,arguments):n({});e=e&&"object"===typeof e&&!Array.isArray(e)?[e]:Et(e);var t=e&&e[0];return e&&(!t||1===e.length&&t.isComment&&!Lt(t))?void 0:e};return n.proxy&&Object.defineProperty(e,t,{get:r,enumerable:!0,configurable:!0}),r}function It(e,t){return function(){return e[t]}}function Ft(e,t){var n,r,i,a,s;if(Array.isArray(e)||"string"===typeof e)for(n=new Array(e.length),r=0,i=e.length;r1?T(n):n;for(var r=T(arguments,1),o='event handler for "'+e+'"',i=0,a=n.length;idocument.createEvent("Event").timeStamp&&(Xn=function(){return Yn.now()})}function Jn(){var e,t;for(Gn=Xn(),Un=!0,zn.sort((function(e,t){return e.id-t.id})),qn=0;qnqn&&zn[n].id>e.id)n--;zn.splice(n+1,0,e)}else zn.push(e);Vn||(Vn=!0,vt(Jn))}}var nr=0,rr=function(e,t,n,r,o){this.vm=e,o&&(e._watcher=this),e._watchers.push(this),r?(this.deep=!!r.deep,this.user=!!r.user,this.lazy=!!r.lazy,this.sync=!!r.sync,this.before=r.before):this.deep=this.user=this.lazy=this.sync=!1,this.cb=n,this.id=++nr,this.active=!0,this.dirty=this.lazy,this.deps=[],this.newDeps=[],this.depIds=new fe,this.newDepIds=new fe,this.expression="","function"===typeof t?this.getter=t:(this.getter=K(t),this.getter||(this.getter=P)),this.value=this.lazy?void 0:this.get()};rr.prototype.get=function(){var e;ye(this);var t=this.vm;try{e=this.getter.call(t,t)}catch(Sa){if(!this.user)throw Sa;nt(Sa,t,'getter for watcher "'+this.expression+'"')}finally{this.deep&&yt(e),ge(),this.cleanupDeps()}return e},rr.prototype.addDep=function(e){var t=e.id;this.newDepIds.has(t)||(this.newDepIds.add(t),this.newDeps.push(e),this.depIds.has(t)||e.addSub(this))},rr.prototype.cleanupDeps=function(){var e=this.deps.length;while(e--){var t=this.deps[e];this.newDepIds.has(t.id)||t.removeSub(this)}var n=this.depIds;this.depIds=this.newDepIds,this.newDepIds=n,this.newDepIds.clear(),n=this.deps,this.deps=this.newDeps,this.newDeps=n,this.newDeps.length=0},rr.prototype.update=function(){this.lazy?this.dirty=!0:this.sync?this.run():tr(this)},rr.prototype.run=function(){if(this.active){var e=this.get();if(e!==this.value||l(e)||this.deep){var t=this.value;if(this.value=e,this.user){var n='callback for watcher "'+this.expression+'"';rt(this.cb,this.vm,[e,t],this.vm,n)}else this.cb.call(this.vm,e,t)}}},rr.prototype.evaluate=function(){this.value=this.get(),this.dirty=!1},rr.prototype.depend=function(){var e=this.deps.length;while(e--)this.deps[e].depend()},rr.prototype.teardown=function(){if(this.active){this.vm._isBeingDestroyed||g(this.vm._watchers,this);var e=this.deps.length;while(e--)this.deps[e].removeSub(this);this.active=!1}};var or={enumerable:!0,configurable:!0,get:P,set:P};function ir(e,t,n){or.get=function(){return this[t][n]},or.set=function(e){this[t][n]=e},Object.defineProperty(e,n,or)}function ar(e){e._watchers=[];var t=e.$options;t.props&&sr(e,t.props),t.methods&&vr(e,t.methods),t.data?lr(e):Pe(e._data={},!0),t.computed&&fr(e,t.computed),t.watch&&t.watch!==ie&&mr(e,t.watch)}function sr(e,t){var n=e.$options.propsData||{},r=e._props={},o=e.$options._propKeys=[],i=!e.$parent;i||je(!1);var a=function(i){o.push(i);var a=Ye(i,t,n,e);Le(r,i,a),i in e||ir(e,"_props",i)};for(var s in t)a(s);je(!0)}function lr(e){var t=e.$options.data;t=e._data="function"===typeof t?ur(t,e):t||{},c(t)||(t={});var n=Object.keys(t),r=e.$options.props,o=(e.$options.methods,n.length);while(o--){var i=n[o];0,r&&_(r,i)||V(i)||ir(e,"_data",i)}Pe(t,!0)}function ur(e,t){ye();try{return e.call(t,t)}catch(Sa){return nt(Sa,t,"data()"),{}}finally{ge()}}var cr={lazy:!0};function fr(e,t){var n=e._computedWatchers=Object.create(null),r=le();for(var o in t){var i=t[o],a="function"===typeof i?i:i.get;0,r||(n[o]=new rr(e,a||P,P,cr)),o in e||dr(e,o,i)}}function dr(e,t,n){var r=!le();"function"===typeof n?(or.get=r?pr(t):hr(n),or.set=P):(or.get=n.get?r&&!1!==n.cache?pr(t):hr(n.get):P,or.set=n.set||P),Object.defineProperty(e,t,or)}function pr(e){return function(){var t=this._computedWatchers&&this._computedWatchers[e];if(t)return t.dirty&&t.evaluate(),ve.target&&t.depend(),t.value}}function hr(e){return function(){return e.call(this,this)}}function vr(e,t){e.$options.props;for(var n in t)e[n]="function"!==typeof t[n]?P:j(t[n],e)}function mr(e,t){for(var n in t){var r=t[n];if(Array.isArray(r))for(var o=0;o-1)return this;var n=T(arguments,1);return n.unshift(this),"function"===typeof e.install?e.install.apply(e,n):"function"===typeof e&&e.apply(null,n),t.push(e),this}}function Er(e){e.mixin=function(e){return this.options=Ge(this.options,e),this}}function kr(e){e.cid=0;var t=1;e.extend=function(e){e=e||{};var n=this,r=n.cid,o=e._Ctor||(e._Ctor={});if(o[r])return o[r];var i=e.name||n.options.name;var a=function(e){this._init(e)};return a.prototype=Object.create(n.prototype),a.prototype.constructor=a,a.cid=t++,a.options=Ge(n.options,e),a["super"]=n,a.options.props&&$r(a),a.options.computed&&jr(a),a.extend=n.extend,a.mixin=n.mixin,a.use=n.use,D.forEach((function(e){a[e]=n[e]})),i&&(a.options.components[i]=a),a.superOptions=n.options,a.extendOptions=e,a.sealedOptions=A({},a.options),o[r]=a,a}}function $r(e){var t=e.options.props;for(var n in t)ir(e.prototype,"_props",n)}function jr(e){var t=e.options.computed;for(var n in t)dr(e.prototype,n,t[n])}function Tr(e){D.forEach((function(t){e[t]=function(e,n){return n?("component"===t&&c(n)&&(n.name=n.name||e,n=this.options._base.extend(n)),"directive"===t&&"function"===typeof n&&(n={bind:n,update:n}),this.options[t+"s"][e]=n,n):this.options[t+"s"][e]}}))}function Ar(e){return e&&(e.Ctor.options.name||e.tag)}function Mr(e,t){return Array.isArray(e)?e.indexOf(t)>-1:"string"===typeof e?e.split(",").indexOf(t)>-1:!!f(e)&&e.test(t)}function Pr(e,t){var n=e.cache,r=e.keys,o=e._vnode;for(var i in n){var a=n[i];if(a){var s=a.name;s&&!t(s)&&Lr(n,i,r,o)}}}function Lr(e,t,n,r){var o=e[t];!o||r&&o.tag===r.tag||o.componentInstance.$destroy(),e[t]=null,g(n,t)}_r(Sr),gr(Sr),Tn(Sr),Ln(Sr),_n(Sr);var Rr=[String,RegExp,Array],Nr={name:"keep-alive",abstract:!0,props:{include:Rr,exclude:Rr,max:[String,Number]},methods:{cacheVNode:function(){var e=this,t=e.cache,n=e.keys,r=e.vnodeToCache,o=e.keyToCache;if(r){var i=r.tag,a=r.componentInstance,s=r.componentOptions;t[o]={name:Ar(s),tag:i,componentInstance:a},n.push(o),this.max&&n.length>parseInt(this.max)&&Lr(t,n[0],n,this._vnode),this.vnodeToCache=null}}},created:function(){this.cache=Object.create(null),this.keys=[]},destroyed:function(){for(var e in this.cache)Lr(this.cache,e,this.keys)},mounted:function(){var e=this;this.cacheVNode(),this.$watch("include",(function(t){Pr(e,(function(e){return Mr(t,e)}))})),this.$watch("exclude",(function(t){Pr(e,(function(e){return!Mr(t,e)}))}))},updated:function(){this.cacheVNode()},render:function(){var e=this.$slots.default,t=Sn(e),n=t&&t.componentOptions;if(n){var r=Ar(n),o=this,i=o.include,a=o.exclude;if(i&&(!r||!Mr(i,r))||a&&r&&Mr(a,r))return t;var s=this,l=s.cache,u=s.keys,c=null==t.key?n.Ctor.cid+(n.tag?"::"+n.tag:""):t.key;l[c]?(t.componentInstance=l[c].componentInstance,g(u,c),u.push(c)):(this.vnodeToCache=t,this.keyToCache=c),t.data.keepAlive=!0}return t||e&&e[0]}},Ir={KeepAlive:Nr};function Fr(e){var t={get:function(){return B}};Object.defineProperty(e,"config",t),e.util={warn:pe,extend:A,mergeOptions:Ge,defineReactive:Le},e.set=Re,e.delete=Ne,e.nextTick=vt,e.observable=function(e){return Pe(e),e},e.options=Object.create(null),D.forEach((function(t){e.options[t+"s"]=Object.create(null)})),e.options._base=e,A(e.options.components,Ir),Or(e),Er(e),kr(e),Tr(e)}Fr(Sr),Object.defineProperty(Sr.prototype,"$isServer",{get:le}),Object.defineProperty(Sr.prototype,"$ssrContext",{get:function(){return this.$vnode&&this.$vnode.ssrContext}}),Object.defineProperty(Sr,"FunctionalRenderContext",{value:Qt}),Sr.version="2.6.14";var Hr=m("style,class"),Dr=m("input,textarea,option,select,progress"),zr=function(e,t,n){return"value"===n&&Dr(e)&&"button"!==t||"selected"===n&&"option"===e||"checked"===n&&"input"===e||"muted"===n&&"video"===e},Br=m("contenteditable,draggable,spellcheck"),Wr=m("events,caret,typing,plaintext-only"),Vr=function(e,t){return Xr(t)||"false"===t?"false":"contenteditable"===e&&Wr(t)?t:"true"},Ur=m("allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible"),qr="http://www.w3.org/1999/xlink",Kr=function(e){return":"===e.charAt(5)&&"xlink"===e.slice(0,5)},Gr=function(e){return Kr(e)?e.slice(6,e.length):""},Xr=function(e){return null==e||!1===e};function Yr(e){var t=e.data,n=e,r=e;while(o(r.componentInstance))r=r.componentInstance._vnode,r&&r.data&&(t=Jr(r.data,t));while(o(n=n.parent))n&&n.data&&(t=Jr(t,n.data));return Zr(t.staticClass,t.class)}function Jr(e,t){return{staticClass:Qr(e.staticClass,t.staticClass),class:o(e.class)?[e.class,t.class]:t.class}}function Zr(e,t){return o(e)||o(t)?Qr(e,eo(t)):""}function Qr(e,t){return e?t?e+" "+t:e:t||""}function eo(e){return Array.isArray(e)?to(e):l(e)?no(e):"string"===typeof e?e:""}function to(e){for(var t,n="",r=0,i=e.length;r-1?lo[e]=t.constructor===window.HTMLUnknownElement||t.constructor===window.HTMLElement:lo[e]=/HTMLUnknownElement/.test(t.toString())}var co=m("text,number,password,search,email,tel,url");function fo(e){if("string"===typeof e){var t=document.querySelector(e);return t||document.createElement("div")}return e}function po(e,t){var n=document.createElement(e);return"select"!==e||t.data&&t.data.attrs&&void 0!==t.data.attrs.multiple&&n.setAttribute("multiple","multiple"),n}function ho(e,t){return document.createElementNS(ro[e],t)}function vo(e){return document.createTextNode(e)}function mo(e){return document.createComment(e)}function yo(e,t,n){e.insertBefore(t,n)}function go(e,t){e.removeChild(t)}function bo(e,t){e.appendChild(t)}function _o(e){return e.parentNode}function xo(e){return e.nextSibling}function wo(e){return e.tagName}function Co(e,t){e.textContent=t}function So(e,t){e.setAttribute(t,"")}var Oo=Object.freeze({createElement:po,createElementNS:ho,createTextNode:vo,createComment:mo,insertBefore:yo,removeChild:go,appendChild:bo,parentNode:_o,nextSibling:xo,tagName:wo,setTextContent:Co,setStyleScope:So}),Eo={create:function(e,t){ko(t)},update:function(e,t){e.data.ref!==t.data.ref&&(ko(e,!0),ko(t))},destroy:function(e){ko(e,!0)}};function ko(e,t){var n=e.data.ref;if(o(n)){var r=e.context,i=e.componentInstance||e.elm,a=r.$refs;t?Array.isArray(a[n])?g(a[n],i):a[n]===i&&(a[n]=void 0):e.data.refInFor?Array.isArray(a[n])?a[n].indexOf(i)<0&&a[n].push(i):a[n]=[i]:a[n]=i}}var $o=new be("",{},[]),jo=["create","activate","update","remove","destroy"];function To(e,t){return e.key===t.key&&e.asyncFactory===t.asyncFactory&&(e.tag===t.tag&&e.isComment===t.isComment&&o(e.data)===o(t.data)&&Ao(e,t)||i(e.isAsyncPlaceholder)&&r(t.asyncFactory.error))}function Ao(e,t){if("input"!==e.tag)return!0;var n,r=o(n=e.data)&&o(n=n.attrs)&&n.type,i=o(n=t.data)&&o(n=n.attrs)&&n.type;return r===i||co(r)&&co(i)}function Mo(e,t,n){var r,i,a={};for(r=t;r<=n;++r)i=e[r].key,o(i)&&(a[i]=r);return a}function Po(e){var t,n,a={},l=e.modules,u=e.nodeOps;for(t=0;tv?(f=r(n[g+1])?null:n[g+1].elm,C(e,f,n,h,g,i)):h>g&&O(t,d,v)}function $(e,t,n,r){for(var i=n;i-1?Vo(e,t,n):Ur(t)?Xr(n)?e.removeAttribute(t):(n="allowfullscreen"===t&&"EMBED"===e.tagName?"true":t,e.setAttribute(t,n)):Br(t)?e.setAttribute(t,Vr(t,n)):Kr(t)?Xr(n)?e.removeAttributeNS(qr,Gr(t)):e.setAttributeNS(qr,t,n):Vo(e,t,n)}function Vo(e,t,n){if(Xr(n))e.removeAttribute(t);else{if(ee&&!te&&"TEXTAREA"===e.tagName&&"placeholder"===t&&""!==n&&!e.__ieph){var r=function(t){t.stopImmediatePropagation(),e.removeEventListener("input",r)};e.addEventListener("input",r),e.__ieph=!0}e.setAttribute(t,n)}}var Uo={create:Bo,update:Bo};function qo(e,t){var n=t.elm,i=t.data,a=e.data;if(!(r(i.staticClass)&&r(i.class)&&(r(a)||r(a.staticClass)&&r(a.class)))){var s=Yr(t),l=n._transitionClasses;o(l)&&(s=Qr(s,eo(l))),s!==n._prevClass&&(n.setAttribute("class",s),n._prevClass=s)}}var Ko,Go={create:qo,update:qo},Xo="__r",Yo="__c";function Jo(e){if(o(e[Xo])){var t=ee?"change":"input";e[t]=[].concat(e[Xo],e[t]||[]),delete e[Xo]}o(e[Yo])&&(e.change=[].concat(e[Yo],e.change||[]),delete e[Yo])}function Zo(e,t,n){var r=Ko;return function o(){var i=t.apply(null,arguments);null!==i&&ti(e,o,n,r)}}var Qo=st&&!(oe&&Number(oe[1])<=53);function ei(e,t,n,r){if(Qo){var o=Gn,i=t;t=i._wrapper=function(e){if(e.target===e.currentTarget||e.timeStamp>=o||e.timeStamp<=0||e.target.ownerDocument!==document)return i.apply(this,arguments)}}Ko.addEventListener(e,t,ae?{capture:n,passive:r}:n)}function ti(e,t,n,r){(r||Ko).removeEventListener(e,t._wrapper||t,n)}function ni(e,t){if(!r(e.data.on)||!r(t.data.on)){var n=t.data.on||{},o=e.data.on||{};Ko=t.elm,Jo(n),xt(n,o,ei,ti,Zo,t.context),Ko=void 0}}var ri,oi={create:ni,update:ni};function ii(e,t){if(!r(e.data.domProps)||!r(t.data.domProps)){var n,i,a=t.elm,s=e.data.domProps||{},l=t.data.domProps||{};for(n in o(l.__ob__)&&(l=t.data.domProps=A({},l)),s)n in l||(a[n]="");for(n in l){if(i=l[n],"textContent"===n||"innerHTML"===n){if(t.children&&(t.children.length=0),i===s[n])continue;1===a.childNodes.length&&a.removeChild(a.childNodes[0])}if("value"===n&&"PROGRESS"!==a.tagName){a._value=i;var u=r(i)?"":String(i);ai(a,u)&&(a.value=u)}else if("innerHTML"===n&&io(a.tagName)&&r(a.innerHTML)){ri=ri||document.createElement("div"),ri.innerHTML=""+i+"";var c=ri.firstChild;while(a.firstChild)a.removeChild(a.firstChild);while(c.firstChild)a.appendChild(c.firstChild)}else if(i!==s[n])try{a[n]=i}catch(Sa){}}}}function ai(e,t){return!e.composing&&("OPTION"===e.tagName||si(e,t)||li(e,t))}function si(e,t){var n=!0;try{n=document.activeElement!==e}catch(Sa){}return n&&e.value!==t}function li(e,t){var n=e.value,r=e._vModifiers;if(o(r)){if(r.number)return v(n)!==v(t);if(r.trim)return n.trim()!==t.trim()}return n!==t}var ui={create:ii,update:ii},ci=x((function(e){var t={},n=/;(?![^(]*\))/g,r=/:(.+)/;return e.split(n).forEach((function(e){if(e){var n=e.split(r);n.length>1&&(t[n[0].trim()]=n[1].trim())}})),t}));function fi(e){var t=di(e.style);return e.staticStyle?A(e.staticStyle,t):t}function di(e){return Array.isArray(e)?M(e):"string"===typeof e?ci(e):e}function pi(e,t){var n,r={};if(t){var o=e;while(o.componentInstance)o=o.componentInstance._vnode,o&&o.data&&(n=fi(o.data))&&A(r,n)}(n=fi(e.data))&&A(r,n);var i=e;while(i=i.parent)i.data&&(n=fi(i.data))&&A(r,n);return r}var hi,vi=/^--/,mi=/\s*!important$/,yi=function(e,t,n){if(vi.test(t))e.style.setProperty(t,n);else if(mi.test(n))e.style.setProperty(E(t),n.replace(mi,""),"important");else{var r=bi(t);if(Array.isArray(n))for(var o=0,i=n.length;o-1?t.split(wi).forEach((function(t){return e.classList.add(t)})):e.classList.add(t);else{var n=" "+(e.getAttribute("class")||"")+" ";n.indexOf(" "+t+" ")<0&&e.setAttribute("class",(n+t).trim())}}function Si(e,t){if(t&&(t=t.trim()))if(e.classList)t.indexOf(" ")>-1?t.split(wi).forEach((function(t){return e.classList.remove(t)})):e.classList.remove(t),e.classList.length||e.removeAttribute("class");else{var n=" "+(e.getAttribute("class")||"")+" ",r=" "+t+" ";while(n.indexOf(r)>=0)n=n.replace(r," ");n=n.trim(),n?e.setAttribute("class",n):e.removeAttribute("class")}}function Oi(e){if(e){if("object"===typeof e){var t={};return!1!==e.css&&A(t,Ei(e.name||"v")),A(t,e),t}return"string"===typeof e?Ei(e):void 0}}var Ei=x((function(e){return{enterClass:e+"-enter",enterToClass:e+"-enter-to",enterActiveClass:e+"-enter-active",leaveClass:e+"-leave",leaveToClass:e+"-leave-to",leaveActiveClass:e+"-leave-active"}})),ki=Y&&!te,$i="transition",ji="animation",Ti="transition",Ai="transitionend",Mi="animation",Pi="animationend";ki&&(void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend&&(Ti="WebkitTransition",Ai="webkitTransitionEnd"),void 0===window.onanimationend&&void 0!==window.onwebkitanimationend&&(Mi="WebkitAnimation",Pi="webkitAnimationEnd"));var Li=Y?window.requestAnimationFrame?window.requestAnimationFrame.bind(window):setTimeout:function(e){return e()};function Ri(e){Li((function(){Li(e)}))}function Ni(e,t){var n=e._transitionClasses||(e._transitionClasses=[]);n.indexOf(t)<0&&(n.push(t),Ci(e,t))}function Ii(e,t){e._transitionClasses&&g(e._transitionClasses,t),Si(e,t)}function Fi(e,t,n){var r=Di(e,t),o=r.type,i=r.timeout,a=r.propCount;if(!o)return n();var s=o===$i?Ai:Pi,l=0,u=function(){e.removeEventListener(s,c),n()},c=function(t){t.target===e&&++l>=a&&u()};setTimeout((function(){l0&&(n=$i,c=a,f=i.length):t===ji?u>0&&(n=ji,c=u,f=l.length):(c=Math.max(a,u),n=c>0?a>u?$i:ji:null,f=n?n===$i?i.length:l.length:0);var d=n===$i&&Hi.test(r[Ti+"Property"]);return{type:n,timeout:c,propCount:f,hasTransform:d}}function zi(e,t){while(e.length1}function Ki(e,t){!0!==t.data.show&&Wi(t)}var Gi=Y?{create:Ki,activate:Ki,remove:function(e,t){!0!==e.data.show?Vi(e,t):t()}}:{},Xi=[Uo,Go,oi,ui,xi,Gi],Yi=Xi.concat(zo),Ji=Po({nodeOps:Oo,modules:Yi});te&&document.addEventListener("selectionchange",(function(){var e=document.activeElement;e&&e.vmodel&&ia(e,"input")}));var Zi={inserted:function(e,t,n,r){"select"===n.tag?(r.elm&&!r.elm._vOptions?wt(n,"postpatch",(function(){Zi.componentUpdated(e,t,n)})):Qi(e,t,n.context),e._vOptions=[].map.call(e.options,na)):("textarea"===n.tag||co(e.type))&&(e._vModifiers=t.modifiers,t.modifiers.lazy||(e.addEventListener("compositionstart",ra),e.addEventListener("compositionend",oa),e.addEventListener("change",oa),te&&(e.vmodel=!0)))},componentUpdated:function(e,t,n){if("select"===n.tag){Qi(e,t,n.context);var r=e._vOptions,o=e._vOptions=[].map.call(e.options,na);if(o.some((function(e,t){return!N(e,r[t])}))){var i=e.multiple?t.value.some((function(e){return ta(e,o)})):t.value!==t.oldValue&&ta(t.value,o);i&&ia(e,"change")}}}};function Qi(e,t,n){ea(e,t,n),(ee||ne)&&setTimeout((function(){ea(e,t,n)}),0)}function ea(e,t,n){var r=t.value,o=e.multiple;if(!o||Array.isArray(r)){for(var i,a,s=0,l=e.options.length;s-1,a.selected!==i&&(a.selected=i);else if(N(na(a),r))return void(e.selectedIndex!==s&&(e.selectedIndex=s));o||(e.selectedIndex=-1)}}function ta(e,t){return t.every((function(t){return!N(t,e)}))}function na(e){return"_value"in e?e._value:e.value}function ra(e){e.target.composing=!0}function oa(e){e.target.composing&&(e.target.composing=!1,ia(e.target,"input"))}function ia(e,t){var n=document.createEvent("HTMLEvents");n.initEvent(t,!0,!0),e.dispatchEvent(n)}function aa(e){return!e.componentInstance||e.data&&e.data.transition?e:aa(e.componentInstance._vnode)}var sa={bind:function(e,t,n){var r=t.value;n=aa(n);var o=n.data&&n.data.transition,i=e.__vOriginalDisplay="none"===e.style.display?"":e.style.display;r&&o?(n.data.show=!0,Wi(n,(function(){e.style.display=i}))):e.style.display=r?i:"none"},update:function(e,t,n){var r=t.value,o=t.oldValue;if(!r!==!o){n=aa(n);var i=n.data&&n.data.transition;i?(n.data.show=!0,r?Wi(n,(function(){e.style.display=e.__vOriginalDisplay})):Vi(n,(function(){e.style.display="none"}))):e.style.display=r?e.__vOriginalDisplay:"none"}},unbind:function(e,t,n,r,o){o||(e.style.display=e.__vOriginalDisplay)}},la={model:Zi,show:sa},ua={name:String,appear:Boolean,css:Boolean,mode:String,type:String,enterClass:String,leaveClass:String,enterToClass:String,leaveToClass:String,enterActiveClass:String,leaveActiveClass:String,appearClass:String,appearActiveClass:String,appearToClass:String,duration:[Number,String,Object]};function ca(e){var t=e&&e.componentOptions;return t&&t.Ctor.options.abstract?ca(Sn(t.children)):e}function fa(e){var t={},n=e.$options;for(var r in n.propsData)t[r]=e[r];var o=n._parentListeners;for(var i in o)t[C(i)]=o[i];return t}function da(e,t){if(/\d-keep-alive$/.test(t.tag))return e("keep-alive",{props:t.componentOptions.propsData})}function pa(e){while(e=e.parent)if(e.data.transition)return!0}function ha(e,t){return t.key===e.key&&t.tag===e.tag}var va=function(e){return e.tag||Lt(e)},ma=function(e){return"show"===e.name},ya={name:"transition",props:ua,abstract:!0,render:function(e){var t=this,n=this.$slots.default;if(n&&(n=n.filter(va),n.length)){0;var r=this.mode;0;var o=n[0];if(pa(this.$vnode))return o;var i=ca(o);if(!i)return o;if(this._leaving)return da(e,o);var a="__transition-"+this._uid+"-";i.key=null==i.key?i.isComment?a+"comment":a+i.tag:s(i.key)?0===String(i.key).indexOf(a)?i.key:a+i.key:i.key;var l=(i.data||(i.data={})).transition=fa(this),u=this._vnode,c=ca(u);if(i.data.directives&&i.data.directives.some(ma)&&(i.data.show=!0),c&&c.data&&!ha(i,c)&&!Lt(c)&&(!c.componentInstance||!c.componentInstance._vnode.isComment)){var f=c.data.transition=A({},l);if("out-in"===r)return this._leaving=!0,wt(f,"afterLeave",(function(){t._leaving=!1,t.$forceUpdate()})),da(e,o);if("in-out"===r){if(Lt(i))return u;var d,p=function(){d()};wt(l,"afterEnter",p),wt(l,"enterCancelled",p),wt(f,"delayLeave",(function(e){d=e}))}}return o}}},ga=A({tag:String,moveClass:String},ua);delete ga.mode;var ba={props:ga,beforeMount:function(){var e=this,t=this._update;this._update=function(n,r){var o=Mn(e);e.__patch__(e._vnode,e.kept,!1,!0),e._vnode=e.kept,o(),t.call(e,n,r)}},render:function(e){for(var t=this.tag||this.$vnode.data.tag||"span",n=Object.create(null),r=this.prevChildren=this.children,o=this.$slots.default||[],i=this.children=[],a=fa(this),s=0;sn)t.push(arguments[n++]);return _[++b]=function(){("function"==typeof e?e:Function(e)).apply(void 0,t)},r(b),b},v=function(e){delete _[e]},d?r=function(e){m.nextTick(C(e))}:g&&g.now?r=function(e){g.now(C(e))}:y&&!f?(o=new y,i=o.port2,o.port1.onmessage=S,r=l(i.postMessage,i,1)):a.addEventListener&&"function"==typeof postMessage&&!a.importScripts&&p&&"file:"!==p.protocol&&!s(O)?(r=O,a.addEventListener("message",S,!1)):r=x in c("script")?function(e){u.appendChild(c("script"))[x]=function(){u.removeChild(this),w(e)}}:function(e){setTimeout(C(e),0)}),e.exports={set:h,clear:v}},"2d00":function(e,t,n){var r,o,i=n("da84"),a=n("342f"),s=i.process,l=s&&s.versions,u=l&&l.v8;u?(r=u.split("."),o=r[0]<4?1:r[0]+r[1]):a&&(r=a.match(/Edge\/(\d+)/),(!r||r[1]>=74)&&(r=a.match(/Chrome\/(\d+)/),r&&(o=r[1]))),e.exports=o&&+o},"2f62":function(e,t,n){"use strict";(function(e){ +/*! + * vuex v3.6.2 + * (c) 2021 Evan You + * @license MIT + */ +function n(e){var t=Number(e.version.split(".")[0]);if(t>=2)e.mixin({beforeCreate:r});else{var n=e.prototype._init;e.prototype._init=function(e){void 0===e&&(e={}),e.init=e.init?[r].concat(e.init):r,n.call(this,e)}}function r(){var e=this.$options;e.store?this.$store="function"===typeof e.store?e.store():e.store:e.parent&&e.parent.$store&&(this.$store=e.parent.$store)}}var r="undefined"!==typeof window?window:"undefined"!==typeof e?e:{},o=r.__VUE_DEVTOOLS_GLOBAL_HOOK__;function i(e){o&&(e._devtoolHook=o,o.emit("vuex:init",e),o.on("vuex:travel-to-state",(function(t){e.replaceState(t)})),e.subscribe((function(e,t){o.emit("vuex:mutation",e,t)}),{prepend:!0}),e.subscribeAction((function(e,t){o.emit("vuex:action",e,t)}),{prepend:!0}))}function a(e,t){return e.filter(t)[0]}function s(e,t){if(void 0===t&&(t=[]),null===e||"object"!==typeof e)return e;var n=a(t,(function(t){return t.original===e}));if(n)return n.copy;var r=Array.isArray(e)?[]:{};return t.push({original:e,copy:r}),Object.keys(e).forEach((function(n){r[n]=s(e[n],t)})),r}function l(e,t){Object.keys(e).forEach((function(n){return t(e[n],n)}))}function u(e){return null!==e&&"object"===typeof e}function c(e){return e&&"function"===typeof e.then}function f(e,t){return function(){return e(t)}}var d=function(e,t){this.runtime=t,this._children=Object.create(null),this._rawModule=e;var n=e.state;this.state=("function"===typeof n?n():n)||{}},p={namespaced:{configurable:!0}};p.namespaced.get=function(){return!!this._rawModule.namespaced},d.prototype.addChild=function(e,t){this._children[e]=t},d.prototype.removeChild=function(e){delete this._children[e]},d.prototype.getChild=function(e){return this._children[e]},d.prototype.hasChild=function(e){return e in this._children},d.prototype.update=function(e){this._rawModule.namespaced=e.namespaced,e.actions&&(this._rawModule.actions=e.actions),e.mutations&&(this._rawModule.mutations=e.mutations),e.getters&&(this._rawModule.getters=e.getters)},d.prototype.forEachChild=function(e){l(this._children,e)},d.prototype.forEachGetter=function(e){this._rawModule.getters&&l(this._rawModule.getters,e)},d.prototype.forEachAction=function(e){this._rawModule.actions&&l(this._rawModule.actions,e)},d.prototype.forEachMutation=function(e){this._rawModule.mutations&&l(this._rawModule.mutations,e)},Object.defineProperties(d.prototype,p);var h=function(e){this.register([],e,!1)};function v(e,t,n){if(t.update(n),n.modules)for(var r in n.modules){if(!t.getChild(r))return void 0;v(e.concat(r),t.getChild(r),n.modules[r])}}h.prototype.get=function(e){return e.reduce((function(e,t){return e.getChild(t)}),this.root)},h.prototype.getNamespace=function(e){var t=this.root;return e.reduce((function(e,n){return t=t.getChild(n),e+(t.namespaced?n+"/":"")}),"")},h.prototype.update=function(e){v([],this.root,e)},h.prototype.register=function(e,t,n){var r=this;void 0===n&&(n=!0);var o=new d(t,n);if(0===e.length)this.root=o;else{var i=this.get(e.slice(0,-1));i.addChild(e[e.length-1],o)}t.modules&&l(t.modules,(function(t,o){r.register(e.concat(o),t,n)}))},h.prototype.unregister=function(e){var t=this.get(e.slice(0,-1)),n=e[e.length-1],r=t.getChild(n);r&&r.runtime&&t.removeChild(n)},h.prototype.isRegistered=function(e){var t=this.get(e.slice(0,-1)),n=e[e.length-1];return!!t&&t.hasChild(n)};var m;var y=function(e){var t=this;void 0===e&&(e={}),!m&&"undefined"!==typeof window&&window.Vue&&A(window.Vue);var n=e.plugins;void 0===n&&(n=[]);var r=e.strict;void 0===r&&(r=!1),this._committing=!1,this._actions=Object.create(null),this._actionSubscribers=[],this._mutations=Object.create(null),this._wrappedGetters=Object.create(null),this._modules=new h(e),this._modulesNamespaceMap=Object.create(null),this._subscribers=[],this._watcherVM=new m,this._makeLocalGettersCache=Object.create(null);var o=this,a=this,s=a.dispatch,l=a.commit;this.dispatch=function(e,t){return s.call(o,e,t)},this.commit=function(e,t,n){return l.call(o,e,t,n)},this.strict=r;var u=this._modules.root.state;w(this,u,[],this._modules.root),x(this,u),n.forEach((function(e){return e(t)}));var c=void 0!==e.devtools?e.devtools:m.config.devtools;c&&i(this)},g={state:{configurable:!0}};function b(e,t,n){return t.indexOf(e)<0&&(n&&n.prepend?t.unshift(e):t.push(e)),function(){var n=t.indexOf(e);n>-1&&t.splice(n,1)}}function _(e,t){e._actions=Object.create(null),e._mutations=Object.create(null),e._wrappedGetters=Object.create(null),e._modulesNamespaceMap=Object.create(null);var n=e.state;w(e,n,[],e._modules.root,!0),x(e,n,t)}function x(e,t,n){var r=e._vm;e.getters={},e._makeLocalGettersCache=Object.create(null);var o=e._wrappedGetters,i={};l(o,(function(t,n){i[n]=f(t,e),Object.defineProperty(e.getters,n,{get:function(){return e._vm[n]},enumerable:!0})}));var a=m.config.silent;m.config.silent=!0,e._vm=new m({data:{$$state:t},computed:i}),m.config.silent=a,e.strict&&$(e),r&&(n&&e._withCommit((function(){r._data.$$state=null})),m.nextTick((function(){return r.$destroy()})))}function w(e,t,n,r,o){var i=!n.length,a=e._modules.getNamespace(n);if(r.namespaced&&(e._modulesNamespaceMap[a],e._modulesNamespaceMap[a]=r),!i&&!o){var s=j(t,n.slice(0,-1)),l=n[n.length-1];e._withCommit((function(){m.set(s,l,r.state)}))}var u=r.context=C(e,a,n);r.forEachMutation((function(t,n){var r=a+n;O(e,r,t,u)})),r.forEachAction((function(t,n){var r=t.root?n:a+n,o=t.handler||t;E(e,r,o,u)})),r.forEachGetter((function(t,n){var r=a+n;k(e,r,t,u)})),r.forEachChild((function(r,i){w(e,t,n.concat(i),r,o)}))}function C(e,t,n){var r=""===t,o={dispatch:r?e.dispatch:function(n,r,o){var i=T(n,r,o),a=i.payload,s=i.options,l=i.type;return s&&s.root||(l=t+l),e.dispatch(l,a)},commit:r?e.commit:function(n,r,o){var i=T(n,r,o),a=i.payload,s=i.options,l=i.type;s&&s.root||(l=t+l),e.commit(l,a,s)}};return Object.defineProperties(o,{getters:{get:r?function(){return e.getters}:function(){return S(e,t)}},state:{get:function(){return j(e.state,n)}}}),o}function S(e,t){if(!e._makeLocalGettersCache[t]){var n={},r=t.length;Object.keys(e.getters).forEach((function(o){if(o.slice(0,r)===t){var i=o.slice(r);Object.defineProperty(n,i,{get:function(){return e.getters[o]},enumerable:!0})}})),e._makeLocalGettersCache[t]=n}return e._makeLocalGettersCache[t]}function O(e,t,n,r){var o=e._mutations[t]||(e._mutations[t]=[]);o.push((function(t){n.call(e,r.state,t)}))}function E(e,t,n,r){var o=e._actions[t]||(e._actions[t]=[]);o.push((function(t){var o=n.call(e,{dispatch:r.dispatch,commit:r.commit,getters:r.getters,state:r.state,rootGetters:e.getters,rootState:e.state},t);return c(o)||(o=Promise.resolve(o)),e._devtoolHook?o.catch((function(t){throw e._devtoolHook.emit("vuex:error",t),t})):o}))}function k(e,t,n,r){e._wrappedGetters[t]||(e._wrappedGetters[t]=function(e){return n(r.state,r.getters,e.state,e.getters)})}function $(e){e._vm.$watch((function(){return this._data.$$state}),(function(){0}),{deep:!0,sync:!0})}function j(e,t){return t.reduce((function(e,t){return e[t]}),e)}function T(e,t,n){return u(e)&&e.type&&(n=t,t=e,e=e.type),{type:e,payload:t,options:n}}function A(e){m&&e===m||(m=e,n(m))}g.state.get=function(){return this._vm._data.$$state},g.state.set=function(e){0},y.prototype.commit=function(e,t,n){var r=this,o=T(e,t,n),i=o.type,a=o.payload,s=(o.options,{type:i,payload:a}),l=this._mutations[i];l&&(this._withCommit((function(){l.forEach((function(e){e(a)}))})),this._subscribers.slice().forEach((function(e){return e(s,r.state)})))},y.prototype.dispatch=function(e,t){var n=this,r=T(e,t),o=r.type,i=r.payload,a={type:o,payload:i},s=this._actions[o];if(s){try{this._actionSubscribers.slice().filter((function(e){return e.before})).forEach((function(e){return e.before(a,n.state)}))}catch(u){0}var l=s.length>1?Promise.all(s.map((function(e){return e(i)}))):s[0](i);return new Promise((function(e,t){l.then((function(t){try{n._actionSubscribers.filter((function(e){return e.after})).forEach((function(e){return e.after(a,n.state)}))}catch(u){0}e(t)}),(function(e){try{n._actionSubscribers.filter((function(e){return e.error})).forEach((function(t){return t.error(a,n.state,e)}))}catch(u){0}t(e)}))}))}},y.prototype.subscribe=function(e,t){return b(e,this._subscribers,t)},y.prototype.subscribeAction=function(e,t){var n="function"===typeof e?{before:e}:e;return b(n,this._actionSubscribers,t)},y.prototype.watch=function(e,t,n){var r=this;return this._watcherVM.$watch((function(){return e(r.state,r.getters)}),t,n)},y.prototype.replaceState=function(e){var t=this;this._withCommit((function(){t._vm._data.$$state=e}))},y.prototype.registerModule=function(e,t,n){void 0===n&&(n={}),"string"===typeof e&&(e=[e]),this._modules.register(e,t),w(this,this.state,e,this._modules.get(e),n.preserveState),x(this,this.state)},y.prototype.unregisterModule=function(e){var t=this;"string"===typeof e&&(e=[e]),this._modules.unregister(e),this._withCommit((function(){var n=j(t.state,e.slice(0,-1));m.delete(n,e[e.length-1])})),_(this)},y.prototype.hasModule=function(e){return"string"===typeof e&&(e=[e]),this._modules.isRegistered(e)},y.prototype.hotUpdate=function(e){this._modules.update(e),_(this,!0)},y.prototype._withCommit=function(e){var t=this._committing;this._committing=!0,e(),this._committing=t},Object.defineProperties(y.prototype,g);var M=H((function(e,t){var n={};return I(t).forEach((function(t){var r=t.key,o=t.val;n[r]=function(){var t=this.$store.state,n=this.$store.getters;if(e){var r=D(this.$store,"mapState",e);if(!r)return;t=r.context.state,n=r.context.getters}return"function"===typeof o?o.call(this,t,n):t[o]},n[r].vuex=!0})),n})),P=H((function(e,t){var n={};return I(t).forEach((function(t){var r=t.key,o=t.val;n[r]=function(){var t=[],n=arguments.length;while(n--)t[n]=arguments[n];var r=this.$store.commit;if(e){var i=D(this.$store,"mapMutations",e);if(!i)return;r=i.context.commit}return"function"===typeof o?o.apply(this,[r].concat(t)):r.apply(this.$store,[o].concat(t))}})),n})),L=H((function(e,t){var n={};return I(t).forEach((function(t){var r=t.key,o=t.val;o=e+o,n[r]=function(){if(!e||D(this.$store,"mapGetters",e))return this.$store.getters[o]},n[r].vuex=!0})),n})),R=H((function(e,t){var n={};return I(t).forEach((function(t){var r=t.key,o=t.val;n[r]=function(){var t=[],n=arguments.length;while(n--)t[n]=arguments[n];var r=this.$store.dispatch;if(e){var i=D(this.$store,"mapActions",e);if(!i)return;r=i.context.dispatch}return"function"===typeof o?o.apply(this,[r].concat(t)):r.apply(this.$store,[o].concat(t))}})),n})),N=function(e){return{mapState:M.bind(null,e),mapGetters:L.bind(null,e),mapMutations:P.bind(null,e),mapActions:R.bind(null,e)}};function I(e){return F(e)?Array.isArray(e)?e.map((function(e){return{key:e,val:e}})):Object.keys(e).map((function(t){return{key:t,val:e[t]}})):[]}function F(e){return Array.isArray(e)||u(e)}function H(e){return function(t,n){return"string"!==typeof t?(n=t,t=""):"/"!==t.charAt(t.length-1)&&(t+="/"),e(t,n)}}function D(e,t,n){var r=e._modulesNamespaceMap[n];return r}function z(e){void 0===e&&(e={});var t=e.collapsed;void 0===t&&(t=!0);var n=e.filter;void 0===n&&(n=function(e,t,n){return!0});var r=e.transformer;void 0===r&&(r=function(e){return e});var o=e.mutationTransformer;void 0===o&&(o=function(e){return e});var i=e.actionFilter;void 0===i&&(i=function(e,t){return!0});var a=e.actionTransformer;void 0===a&&(a=function(e){return e});var l=e.logMutations;void 0===l&&(l=!0);var u=e.logActions;void 0===u&&(u=!0);var c=e.logger;return void 0===c&&(c=console),function(e){var f=s(e.state);"undefined"!==typeof c&&(l&&e.subscribe((function(e,i){var a=s(i);if(n(e,f,a)){var l=V(),u=o(e),d="mutation "+e.type+l;B(c,d,t),c.log("%c prev state","color: #9E9E9E; font-weight: bold",r(f)),c.log("%c mutation","color: #03A9F4; font-weight: bold",u),c.log("%c next state","color: #4CAF50; font-weight: bold",r(a)),W(c)}f=a})),u&&e.subscribeAction((function(e,n){if(i(e,n)){var r=V(),o=a(e),s="action "+e.type+r;B(c,s,t),c.log("%c action","color: #03A9F4; font-weight: bold",o),W(c)}})))}}function B(e,t,n){var r=n?e.groupCollapsed:e.group;try{r.call(e,t)}catch(o){e.log(t)}}function W(e){try{e.groupEnd()}catch(t){e.log("—— log end ——")}}function V(){var e=new Date;return" @ "+q(e.getHours(),2)+":"+q(e.getMinutes(),2)+":"+q(e.getSeconds(),2)+"."+q(e.getMilliseconds(),3)}function U(e,t){return new Array(t+1).join(e)}function q(e,t){return U("0",t-e.toString().length)+e}var K={Store:y,install:A,version:"3.6.2",mapState:M,mapMutations:P,mapGetters:L,mapActions:R,createNamespacedHelpers:N,createLogger:z};t["a"]=K}).call(this,n("c8ba"))},"342f":function(e,t,n){var r=n("d066");e.exports=r("navigator","userAgent")||""},"35a1":function(e,t,n){var r=n("f5df"),o=n("3f8c"),i=n("b622"),a=i("iterator");e.exports=function(e){if(void 0!=e)return e[a]||e["@@iterator"]||o[r(e)]}},"37e8":function(e,t,n){var r=n("83ab"),o=n("9bf2"),i=n("825a"),a=n("df75");e.exports=r?Object.defineProperties:function(e,t){i(e);var n,r=a(t),s=r.length,l=0;while(s>l)o.f(e,n=r[l++],t[n]);return e}},"38a0":function(e,t,n){},"3bbe":function(e,t,n){var r=n("861d");e.exports=function(e){if(!r(e)&&null!==e)throw TypeError("Can't set "+String(e)+" as a prototype");return e}},"3c4e":function(e,t,n){"use strict";var r=function(e){return o(e)&&!i(e)};function o(e){return!!e&&"object"===typeof e}function i(e){var t=Object.prototype.toString.call(e);return"[object RegExp]"===t||"[object Date]"===t||l(e)}var a="function"===typeof Symbol&&Symbol.for,s=a?Symbol.for("react.element"):60103;function l(e){return e.$$typeof===s}function u(e){return Array.isArray(e)?[]:{}}function c(e,t){var n=t&&!0===t.clone;return n&&r(e)?p(u(e),e,t):e}function f(e,t,n){var o=e.slice();return t.forEach((function(t,i){"undefined"===typeof o[i]?o[i]=c(t,n):r(t)?o[i]=p(e[i],t,n):-1===e.indexOf(t)&&o.push(c(t,n))})),o}function d(e,t,n){var o={};return r(e)&&Object.keys(e).forEach((function(t){o[t]=c(e[t],n)})),Object.keys(t).forEach((function(i){r(t[i])&&e[i]?o[i]=p(e[i],t[i],n):o[i]=c(t[i],n)})),o}function p(e,t,n){var r=Array.isArray(t),o=Array.isArray(e),i=n||{arrayMerge:f},a=r===o;if(a){if(r){var s=i.arrayMerge||f;return s(e,t,n)}return d(e,t,n)}return c(t,n)}p.all=function(e,t){if(!Array.isArray(e)||e.length<2)throw new Error("first argument should be an array with at least two elements");return e.reduce((function(e,n){return p(e,n,t)}))};var h=p;e.exports=h},"3ca3":function(e,t,n){"use strict";var r=n("6547").charAt,o=n("69f3"),i=n("7dd0"),a="String Iterator",s=o.set,l=o.getterFor(a);i(String,"String",(function(e){s(this,{type:a,string:String(e),index:0})}),(function(){var e,t=l(this),n=t.string,o=t.index;return o>=n.length?{value:void 0,done:!0}:(e=r(n,o),t.index+=e.length,{value:e,done:!1})}))},"3f8c":function(e,t){e.exports={}},4010:function(e,t,n){"use strict";t.__esModule=!0,t.removeResizeListener=t.addResizeListener=void 0;var r=n("6dd8"),o=i(r);function i(e){return e&&e.__esModule?e:{default:e}}var a="undefined"===typeof window,s=function(e){var t=e,n=Array.isArray(t),r=0;for(t=n?t:t[Symbol.iterator]();;){var o;if(n){if(r>=t.length)break;o=t[r++]}else{if(r=t.next(),r.done)break;o=r.value}var i=o,a=i.target.__resizeListeners__||[];a.length&&a.forEach((function(e){e()}))}};t.addResizeListener=function(e,t){a||(e.__resizeListeners__||(e.__resizeListeners__=[],e.__ro__=new o.default(s),e.__ro__.observe(e)),e.__resizeListeners__.push(t))},t.removeResizeListener=function(e,t){e&&e.__resizeListeners__&&(e.__resizeListeners__.splice(e.__resizeListeners__.indexOf(t),1),e.__resizeListeners__.length||e.__ro__.disconnect())}},"417f":function(e,t,n){"use strict";t.__esModule=!0;var r=n("2b0e"),o=a(r),i=n("5924");function a(e){return e&&e.__esModule?e:{default:e}}var s=[],l="@@clickoutsideContext",u=void 0,c=0;function f(e,t,n){return function(){var r=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};!(n&&n.context&&r.target&&o.target)||e.contains(r.target)||e.contains(o.target)||e===r.target||n.context.popperElm&&(n.context.popperElm.contains(r.target)||n.context.popperElm.contains(o.target))||(t.expression&&e[l].methodName&&n.context[e[l].methodName]?n.context[e[l].methodName]():e[l].bindingFn&&e[l].bindingFn())}}!o.default.prototype.$isServer&&(0,i.on)(document,"mousedown",(function(e){return u=e})),!o.default.prototype.$isServer&&(0,i.on)(document,"mouseup",(function(e){s.forEach((function(t){return t[l].documentHandler(e,u)}))})),t.default={bind:function(e,t,n){s.push(e);var r=c++;e[l]={id:r,documentHandler:f(e,t,n),methodName:t.expression,bindingFn:t.value}},update:function(e,t,n){e[l].documentHandler=f(e,t,n),e[l].methodName=t.expression,e[l].bindingFn=t.value},unbind:function(e){for(var t=s.length,n=0;n0){var r=t[t.length-1];if(r.id===e){if(r.modalClass){var o=r.modalClass.trim().split(/\s+/);o.forEach((function(e){return(0,i.removeClass)(n,e)}))}t.pop(),t.length>0&&(n.style.zIndex=t[t.length-1].zIndex)}else for(var a=t.length-1;a>=0;a--)if(t[a].id===e){t.splice(a,1);break}}0===t.length&&(this.modalFade&&(0,i.addClass)(n,"v-modal-leave"),setTimeout((function(){0===t.length&&(n.parentNode&&n.parentNode.removeChild(n),n.style.display="none",d.modalDom=void 0),(0,i.removeClass)(n,"v-modal-leave")}),200))}};Object.defineProperty(d,"zIndex",{configurable:!0,get:function(){return l||(u=u||(o.default.prototype.$ELEMENT||{}).zIndex||2e3,l=!0),u},set:function(e){u=e}});var p=function(){if(!o.default.prototype.$isServer&&d.modalStack.length>0){var e=d.modalStack[d.modalStack.length-1];if(!e)return;var t=d.getInstance(e.id);return t}};o.default.prototype.$isServer||window.addEventListener("keydown",(function(e){if(27===e.keyCode){var t=p();t&&t.closeOnPressEscape&&(t.handleClose?t.handleClose():t.handleAction?t.handleAction("cancel"):t.close())}})),t.default=d},"4d64":function(e,t,n){var r=n("fc6a"),o=n("50c4"),i=n("23cb"),a=function(e){return function(t,n,a){var s,l=r(t),u=o(l.length),c=i(a,u);if(e&&n!=n){while(u>c)if(s=l[c++],s!=s)return!0}else for(;u>c;c++)if((e||c in l)&&l[c]===n)return e||c||0;return!e&&-1}};e.exports={includes:a(!0),indexOf:a(!1)}},"50c4":function(e,t,n){var r=n("a691"),o=Math.min;e.exports=function(e){return e>0?o(r(e),9007199254740991):0}},5128:function(e,t,n){"use strict";t.__esModule=!0,t.PopupManager=void 0;var r=n("2b0e"),o=d(r),i=n("7f4d"),a=d(i),s=n("4b26"),l=d(s),u=n("e62d"),c=d(u),f=n("5924");function d(e){return e&&e.__esModule?e:{default:e}}var p=1,h=void 0;t.default={props:{visible:{type:Boolean,default:!1},openDelay:{},closeDelay:{},zIndex:{},modal:{type:Boolean,default:!1},modalFade:{type:Boolean,default:!0},modalClass:{},modalAppendToBody:{type:Boolean,default:!1},lockScroll:{type:Boolean,default:!0},closeOnPressEscape:{type:Boolean,default:!1},closeOnClickModal:{type:Boolean,default:!1}},beforeMount:function(){this._popupId="popup-"+p++,l.default.register(this._popupId,this)},beforeDestroy:function(){l.default.deregister(this._popupId),l.default.closeModal(this._popupId),this.restoreBodyStyle()},data:function(){return{opened:!1,bodyPaddingRight:null,computedBodyPaddingRight:0,withoutHiddenClass:!0,rendered:!1}},watch:{visible:function(e){var t=this;if(e){if(this._opening)return;this.rendered?this.open():(this.rendered=!0,o.default.nextTick((function(){t.open()})))}else this.close()}},methods:{open:function(e){var t=this;this.rendered||(this.rendered=!0);var n=(0,a.default)({},this.$props||this,e);this._closeTimer&&(clearTimeout(this._closeTimer),this._closeTimer=null),clearTimeout(this._openTimer);var r=Number(n.openDelay);r>0?this._openTimer=setTimeout((function(){t._openTimer=null,t.doOpen(n)}),r):this.doOpen(n)},doOpen:function(e){if(!this.$isServer&&(!this.willOpen||this.willOpen())&&!this.opened){this._opening=!0;var t=this.$el,n=e.modal,r=e.zIndex;if(r&&(l.default.zIndex=r),n&&(this._closing&&(l.default.closeModal(this._popupId),this._closing=!1),l.default.openModal(this._popupId,l.default.nextZIndex(),this.modalAppendToBody?void 0:t,e.modalClass,e.modalFade),e.lockScroll)){this.withoutHiddenClass=!(0,f.hasClass)(document.body,"el-popup-parent--hidden"),this.withoutHiddenClass&&(this.bodyPaddingRight=document.body.style.paddingRight,this.computedBodyPaddingRight=parseInt((0,f.getStyle)(document.body,"paddingRight"),10)),h=(0,c.default)();var o=document.documentElement.clientHeight0&&(o||"scroll"===i)&&this.withoutHiddenClass&&(document.body.style.paddingRight=this.computedBodyPaddingRight+h+"px"),(0,f.addClass)(document.body,"el-popup-parent--hidden")}"static"===getComputedStyle(t).position&&(t.style.position="absolute"),t.style.zIndex=l.default.nextZIndex(),this.opened=!0,this.onOpen&&this.onOpen(),this.doAfterOpen()}},doAfterOpen:function(){this._opening=!1},close:function(){var e=this;if(!this.willClose||this.willClose()){null!==this._openTimer&&(clearTimeout(this._openTimer),this._openTimer=null),clearTimeout(this._closeTimer);var t=Number(this.closeDelay);t>0?this._closeTimer=setTimeout((function(){e._closeTimer=null,e.doClose()}),t):this.doClose()}},doClose:function(){this._closing=!0,this.onClose&&this.onClose(),this.lockScroll&&setTimeout(this.restoreBodyStyle,200),this.opened=!1,this.doAfterClose()},doAfterClose:function(){l.default.closeModal(this._popupId),this._closing=!1},restoreBodyStyle:function(){this.modal&&this.withoutHiddenClass&&(document.body.style.paddingRight=this.bodyPaddingRight,(0,f.removeClass)(document.body,"el-popup-parent--hidden")),this.withoutHiddenClass=!0}}},t.PopupManager=l.default},5135:function(e,t,n){var r=n("7b0b"),o={}.hasOwnProperty;e.exports=Object.hasOwn||function(e,t){return o.call(r(e),t)}},5466:function(e,t,n){},5692:function(e,t,n){var r=n("c430"),o=n("c6cd");(e.exports=function(e,t){return o[e]||(o[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.15.2",mode:r?"pure":"global",copyright:"© 2021 Denis Pushkarev (zloirock.ru)"})},"56ef":function(e,t,n){var r=n("d066"),o=n("241c"),i=n("7418"),a=n("825a");e.exports=r("Reflect","ownKeys")||function(e){var t=o.f(a(e)),n=i.f;return n?t.concat(n(e)):t}},5924:function(e,t,n){"use strict";t.__esModule=!0,t.isInContainer=t.getScrollContainer=t.isScroll=t.getStyle=t.once=t.off=t.on=void 0;var r="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"===typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e};t.hasClass=v,t.addClass=m,t.removeClass=y,t.setStyle=b;var o=n("2b0e"),i=a(o);function a(e){return e&&e.__esModule?e:{default:e}}var s=i.default.prototype.$isServer,l=/([\:\-\_]+(.))/g,u=/^moz([A-Z])/,c=s?0:Number(document.documentMode),f=function(e){return(e||"").replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g,"")},d=function(e){return e.replace(l,(function(e,t,n,r){return r?n.toUpperCase():n})).replace(u,"Moz$1")},p=t.on=function(){return!s&&document.addEventListener?function(e,t,n){e&&t&&n&&e.addEventListener(t,n,!1)}:function(e,t,n){e&&t&&n&&e.attachEvent("on"+t,n)}}(),h=t.off=function(){return!s&&document.removeEventListener?function(e,t,n){e&&t&&e.removeEventListener(t,n,!1)}:function(e,t,n){e&&t&&e.detachEvent("on"+t,n)}}();t.once=function(e,t,n){var r=function r(){n&&n.apply(this,arguments),h(e,t,r)};p(e,t,r)};function v(e,t){if(!e||!t)return!1;if(-1!==t.indexOf(" "))throw new Error("className should not contain space.");return e.classList?e.classList.contains(t):(" "+e.className+" ").indexOf(" "+t+" ")>-1}function m(e,t){if(e){for(var n=e.className,r=(t||"").split(" "),o=0,i=r.length;or.top&&n.right>r.left&&n.lefte?u():!0!==t&&(o=setTimeout(r?c:u,void 0===r?e-s:e))}return"boolean"!==typeof t&&(r=n,n=t,t=void 0),a}},"5c6c":function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},"605d":function(e,t,n){var r=n("c6b6"),o=n("da84");e.exports="process"==r(o.process)},6069:function(e,t){e.exports="object"==typeof window},"60da":function(e,t,n){"use strict";var r=n("83ab"),o=n("d039"),i=n("df75"),a=n("7418"),s=n("d1e7"),l=n("7b0b"),u=n("44ad"),c=Object.assign,f=Object.defineProperty;e.exports=!c||o((function(){if(r&&1!==c({b:1},c(f({},"a",{enumerable:!0,get:function(){f(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var e={},t={},n=Symbol(),o="abcdefghijklmnopqrst";return e[n]=7,o.split("").forEach((function(e){t[e]=e})),7!=c({},e)[n]||i(c({},t)).join("")!=o}))?function(e,t){var n=l(e),o=arguments.length,c=1,f=a.f,d=s.f;while(o>c){var p,h=u(arguments[c++]),v=f?i(h).concat(f(h)):i(h),m=v.length,y=0;while(m>y)p=v[y++],r&&!d.call(h,p)||(n[p]=h[p])}return n}:c},6167:function(e,t,n){"use strict";var r,o;"function"===typeof Symbol&&Symbol.iterator;(function(i,a){r=a,o="function"===typeof r?r.call(t,n,t,e):r,void 0===o||(e.exports=o)})(0,(function(){var e=window,t={placement:"bottom",gpuAcceleration:!0,offset:0,boundariesElement:"viewport",boundariesPadding:5,preventOverflowOrder:["left","right","top","bottom"],flipBehavior:"flip",arrowElement:"[x-arrow]",arrowOffset:0,modifiers:["shift","offset","preventOverflow","keepTogether","arrow","flip","applyStyle"],modifiersIgnored:[],forceAbsolute:!1};function n(e,n,r){this._reference=e.jquery?e[0]:e,this.state={};var o="undefined"===typeof n||null===n,i=n&&"[object Object]"===Object.prototype.toString.call(n);return this._popper=o||i?this.parse(i?n:{}):n.jquery?n[0]:n,this._options=Object.assign({},t,r),this._options.modifiers=this._options.modifiers.map(function(e){if(-1===this._options.modifiersIgnored.indexOf(e))return"applyStyle"===e&&this._popper.setAttribute("x-placement",this._options.placement),this.modifiers[e]||e}.bind(this)),this.state.position=this._getPosition(this._popper,this._reference),f(this._popper,{position:this.state.position,top:0}),this.update(),this._setupEventListeners(),this}function r(t){var n=t.style.display,r=t.style.visibility;t.style.display="block",t.style.visibility="hidden";t.offsetWidth;var o=e.getComputedStyle(t),i=parseFloat(o.marginTop)+parseFloat(o.marginBottom),a=parseFloat(o.marginLeft)+parseFloat(o.marginRight),s={width:t.offsetWidth+a,height:t.offsetHeight+i};return t.style.display=n,t.style.visibility=r,s}function o(e){var t={left:"right",right:"left",bottom:"top",top:"bottom"};return e.replace(/left|right|bottom|top/g,(function(e){return t[e]}))}function i(e){var t=Object.assign({},e);return t.right=t.left+t.width,t.bottom=t.top+t.height,t}function a(e,t){var n,r=0;for(n in e){if(e[n]===t)return r;r++}return null}function s(t,n){var r=e.getComputedStyle(t,null);return r[n]}function l(t){var n=t.offsetParent;return n!==e.document.body&&n?n:e.document.documentElement}function u(t){var n=t.parentNode;return n?n===e.document?e.document.body.scrollTop||e.document.body.scrollLeft?e.document.body:e.document.documentElement:-1!==["scroll","auto"].indexOf(s(n,"overflow"))||-1!==["scroll","auto"].indexOf(s(n,"overflow-x"))||-1!==["scroll","auto"].indexOf(s(n,"overflow-y"))?n:u(t.parentNode):t}function c(t){return t!==e.document.body&&("fixed"===s(t,"position")||(t.parentNode?c(t.parentNode):t))}function f(e,t){function n(e){return""!==e&&!isNaN(parseFloat(e))&&isFinite(e)}Object.keys(t).forEach((function(r){var o="";-1!==["width","height","top","right","bottom","left"].indexOf(r)&&n(t[r])&&(o="px"),e.style[r]=t[r]+o}))}function d(e){var t={};return e&&"[object Function]"===t.toString.call(e)}function p(e){var t={width:e.offsetWidth,height:e.offsetHeight,left:e.offsetLeft,top:e.offsetTop};return t.right=t.left+t.width,t.bottom=t.top+t.height,t}function h(e){var t=e.getBoundingClientRect(),n=-1!=navigator.userAgent.indexOf("MSIE"),r=n&&"HTML"===e.tagName?-e.scrollTop:t.top;return{left:t.left,top:r,right:t.right,bottom:t.bottom,width:t.right-t.left,height:t.bottom-r}}function v(e,t,n){var r=h(e),o=h(t);if(n){var i=u(t);o.top+=i.scrollTop,o.bottom+=i.scrollTop,o.left+=i.scrollLeft,o.right+=i.scrollLeft}var a={top:r.top-o.top,left:r.left-o.left,bottom:r.top-o.top+r.height,right:r.left-o.left+r.width,width:r.width,height:r.height};return a}function m(t){for(var n=["","ms","webkit","moz","o"],r=0;r1&&console.warn("WARNING: the given `parent` query("+t.parent+") matched more than one element, the first one will be used"),0===a.length)throw"ERROR: the given `parent` doesn't exists!";a=a[0]}return a.length>1&&a instanceof Element===!1&&(console.warn("WARNING: you have passed as parent a list of elements, the first one will be used"),a=a[0]),a.appendChild(o),o;function s(e,t){t.forEach((function(t){e.classList.add(t)}))}function l(e,t){t.forEach((function(t){e.setAttribute(t.split(":")[0],t.split(":")[1]||"")}))}},n.prototype._getPosition=function(e,t){var n=l(t);if(this._options.forceAbsolute)return"absolute";var r=c(t,n);return r?"fixed":"absolute"},n.prototype._getOffsets=function(e,t,n){n=n.split("-")[0];var o={};o.position=this.state.position;var i="fixed"===o.position,a=v(t,l(e),i),s=r(e);return-1!==["right","left"].indexOf(n)?(o.top=a.top+a.height/2-s.height/2,o.left="left"===n?a.left-s.width:a.right):(o.left=a.left+a.width/2-s.width/2,o.top="top"===n?a.top-s.height:a.bottom),o.width=s.width,o.height=s.height,{popper:o,reference:a}},n.prototype._setupEventListeners=function(){if(this.state.updateBound=this.update.bind(this),e.addEventListener("resize",this.state.updateBound),"window"!==this._options.boundariesElement){var t=u(this._reference);t!==e.document.body&&t!==e.document.documentElement||(t=e),t.addEventListener("scroll",this.state.updateBound),this.state.scrollTarget=t}},n.prototype._removeEventListeners=function(){e.removeEventListener("resize",this.state.updateBound),"window"!==this._options.boundariesElement&&this.state.scrollTarget&&(this.state.scrollTarget.removeEventListener("scroll",this.state.updateBound),this.state.scrollTarget=null),this.state.updateBound=null},n.prototype._getBoundaries=function(t,n,r){var o,i,a={};if("window"===r){var s=e.document.body,c=e.document.documentElement;i=Math.max(s.scrollHeight,s.offsetHeight,c.clientHeight,c.scrollHeight,c.offsetHeight),o=Math.max(s.scrollWidth,s.offsetWidth,c.clientWidth,c.scrollWidth,c.offsetWidth),a={top:0,right:o,bottom:i,left:0}}else if("viewport"===r){var f=l(this._popper),d=u(this._popper),h=p(f),v=function(e){return e==document.body?Math.max(document.documentElement.scrollTop,document.body.scrollTop):e.scrollTop},m=function(e){return e==document.body?Math.max(document.documentElement.scrollLeft,document.body.scrollLeft):e.scrollLeft},y="fixed"===t.offsets.popper.position?0:v(d),g="fixed"===t.offsets.popper.position?0:m(d);a={top:0-(h.top-y),right:e.document.documentElement.clientWidth-(h.left-g),bottom:e.document.documentElement.clientHeight-(h.top-y),left:0-(h.left-g)}}else a=l(this._popper)===r?{top:0,left:0,right:r.clientWidth,bottom:r.clientHeight}:p(r);return a.left+=n,a.right-=n,a.top=a.top+n,a.bottom=a.bottom-n,a},n.prototype.runModifiers=function(e,t,n){var r=t.slice();return void 0!==n&&(r=this._options.modifiers.slice(0,a(this._options.modifiers,n))),r.forEach(function(t){d(t)&&(e=t.call(this,e))}.bind(this)),e},n.prototype.isModifierRequired=function(e,t){var n=a(this._options.modifiers,e);return!!this._options.modifiers.slice(0,n).filter((function(e){return e===t})).length},n.prototype.modifiers={},n.prototype.modifiers.applyStyle=function(e){var t,n={position:e.offsets.popper.position},r=Math.round(e.offsets.popper.left),o=Math.round(e.offsets.popper.top);return this._options.gpuAcceleration&&(t=m("transform"))?(n[t]="translate3d("+r+"px, "+o+"px, 0)",n.top=0,n.left=0):(n.left=r,n.top=o),Object.assign(n,e.styles),f(this._popper,n),this._popper.setAttribute("x-placement",e.placement),this.isModifierRequired(this.modifiers.applyStyle,this.modifiers.arrow)&&e.offsets.arrow&&f(e.arrowElement,e.offsets.arrow),e},n.prototype.modifiers.shift=function(e){var t=e.placement,n=t.split("-")[0],r=t.split("-")[1];if(r){var o=e.offsets.reference,a=i(e.offsets.popper),s={y:{start:{top:o.top},end:{top:o.top+o.height-a.height}},x:{start:{left:o.left},end:{left:o.left+o.width-a.width}}},l=-1!==["bottom","top"].indexOf(n)?"x":"y";e.offsets.popper=Object.assign(a,s[l][r])}return e},n.prototype.modifiers.preventOverflow=function(e){var t=this._options.preventOverflowOrder,n=i(e.offsets.popper),r={left:function(){var t=n.left;return n.lefte.boundaries.right&&(t=Math.min(n.left,e.boundaries.right-n.width)),{left:t}},top:function(){var t=n.top;return n.tope.boundaries.bottom&&(t=Math.min(n.top,e.boundaries.bottom-n.height)),{top:t}}};return t.forEach((function(t){e.offsets.popper=Object.assign(n,r[t]())})),e},n.prototype.modifiers.keepTogether=function(e){var t=i(e.offsets.popper),n=e.offsets.reference,r=Math.floor;return t.rightr(n.right)&&(e.offsets.popper.left=r(n.right)),t.bottomr(n.bottom)&&(e.offsets.popper.top=r(n.bottom)),e},n.prototype.modifiers.flip=function(e){if(!this.isModifierRequired(this.modifiers.flip,this.modifiers.preventOverflow))return console.warn("WARNING: preventOverflow modifier is required by flip modifier in order to work, be sure to include it before flip!"),e;if(e.flipped&&e.placement===e._originalPlacement)return e;var t=e.placement.split("-")[0],n=o(t),r=e.placement.split("-")[1]||"",a=[];return a="flip"===this._options.flipBehavior?[t,n]:this._options.flipBehavior,a.forEach(function(s,l){if(t===s&&a.length!==l+1){t=e.placement.split("-")[0],n=o(t);var u=i(e.offsets.popper),c=-1!==["right","bottom"].indexOf(t);(c&&Math.floor(e.offsets.reference[t])>Math.floor(u[n])||!c&&Math.floor(e.offsets.reference[t])s[p]&&(e.offsets.popper[f]+=l[f]+h-s[p]);var v=l[f]+(n||l[c]/2-h/2),m=v-s[f];return m=Math.max(Math.min(s[c]-h-8,m),8),o[f]=m,o[d]="",e.offsets.arrow=o,e.arrowElement=t,e},Object.assign||Object.defineProperty(Object,"assign",{enumerable:!1,configurable:!0,writable:!0,value:function(e){if(void 0===e||null===e)throw new TypeError("Cannot convert first argument to object");for(var t=Object(e),n=1;n=u?e?"":void 0:(i=s.charCodeAt(l),i<55296||i>56319||l+1===u||(a=s.charCodeAt(l+1))<56320||a>57343?e?s.charAt(l):i:e?s.slice(l,l+2):a-56320+(i-55296<<10)+65536)}};e.exports={codeAt:i(!1),charAt:i(!0)}},"69f3":function(e,t,n){var r,o,i,a=n("7f9a"),s=n("da84"),l=n("861d"),u=n("9112"),c=n("5135"),f=n("c6cd"),d=n("f772"),p=n("d012"),h="Object already initialized",v=s.WeakMap,m=function(e){return i(e)?o(e):r(e,{})},y=function(e){return function(t){var n;if(!l(t)||(n=o(t)).type!==e)throw TypeError("Incompatible receiver, "+e+" required");return n}};if(a||f.state){var g=f.state||(f.state=new v),b=g.get,_=g.has,x=g.set;r=function(e,t){if(_.call(g,e))throw new TypeError(h);return t.facade=e,x.call(g,e,t),t},o=function(e){return b.call(g,e)||{}},i=function(e){return _.call(g,e)}}else{var w=d("state");p[w]=!0,r=function(e,t){if(c(e,w))throw new TypeError(h);return t.facade=e,u(e,w,t),t},o=function(e){return c(e,w)?e[w]:{}},i=function(e){return c(e,w)}}e.exports={set:r,get:o,has:i,enforce:m,getterFor:y}},"6ac9":function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=73)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},2:function(e,t){e.exports=n("5924")},3:function(e,t){e.exports=n("8122")},5:function(e,t){e.exports=n("e974")},7:function(e,t){e.exports=n("2b0e")},73:function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("span",[n("transition",{attrs:{name:e.transition},on:{"after-enter":e.handleAfterEnter,"after-leave":e.handleAfterLeave}},[n("div",{directives:[{name:"show",rawName:"v-show",value:!e.disabled&&e.showPopper,expression:"!disabled && showPopper"}],ref:"popper",staticClass:"el-popover el-popper",class:[e.popperClass,e.content&&"el-popover--plain"],style:{width:e.width+"px"},attrs:{role:"tooltip",id:e.tooltipId,"aria-hidden":e.disabled||!e.showPopper?"true":"false"}},[e.title?n("div",{staticClass:"el-popover__title",domProps:{textContent:e._s(e.title)}}):e._e(),e._t("default",[e._v(e._s(e.content))])],2)]),n("span",{ref:"wrapper",staticClass:"el-popover__reference-wrapper"},[e._t("reference")],2)],1)},o=[];r._withStripped=!0;var i=n(5),a=n.n(i),s=n(2),l=n(3),u={name:"ElPopover",mixins:[a.a],props:{trigger:{type:String,default:"click",validator:function(e){return["click","focus","hover","manual"].indexOf(e)>-1}},openDelay:{type:Number,default:0},closeDelay:{type:Number,default:200},title:String,disabled:Boolean,content:String,reference:{},popperClass:String,width:{},visibleArrow:{default:!0},arrowOffset:{type:Number,default:0},transition:{type:String,default:"fade-in-linear"},tabindex:{type:Number,default:0}},computed:{tooltipId:function(){return"el-popover-"+Object(l["generateId"])()}},watch:{showPopper:function(e){this.disabled||(e?this.$emit("show"):this.$emit("hide"))}},mounted:function(){var e=this,t=this.referenceElm=this.reference||this.$refs.reference,n=this.popper||this.$refs.popper;!t&&this.$refs.wrapper.children&&(t=this.referenceElm=this.$refs.wrapper.children[0]),t&&(Object(s["addClass"])(t,"el-popover__reference"),t.setAttribute("aria-describedby",this.tooltipId),t.setAttribute("tabindex",this.tabindex),n.setAttribute("tabindex",0),"click"!==this.trigger&&(Object(s["on"])(t,"focusin",(function(){e.handleFocus();var n=t.__vue__;n&&"function"===typeof n.focus&&n.focus()})),Object(s["on"])(n,"focusin",this.handleFocus),Object(s["on"])(t,"focusout",this.handleBlur),Object(s["on"])(n,"focusout",this.handleBlur)),Object(s["on"])(t,"keydown",this.handleKeydown),Object(s["on"])(t,"click",this.handleClick)),"click"===this.trigger?(Object(s["on"])(t,"click",this.doToggle),Object(s["on"])(document,"click",this.handleDocumentClick)):"hover"===this.trigger?(Object(s["on"])(t,"mouseenter",this.handleMouseEnter),Object(s["on"])(n,"mouseenter",this.handleMouseEnter),Object(s["on"])(t,"mouseleave",this.handleMouseLeave),Object(s["on"])(n,"mouseleave",this.handleMouseLeave)):"focus"===this.trigger&&(this.tabindex<0&&console.warn("[Element Warn][Popover]a negative taindex means that the element cannot be focused by tab key"),t.querySelector("input, textarea")?(Object(s["on"])(t,"focusin",this.doShow),Object(s["on"])(t,"focusout",this.doClose)):(Object(s["on"])(t,"mousedown",this.doShow),Object(s["on"])(t,"mouseup",this.doClose)))},beforeDestroy:function(){this.cleanup()},deactivated:function(){this.cleanup()},methods:{doToggle:function(){this.showPopper=!this.showPopper},doShow:function(){this.showPopper=!0},doClose:function(){this.showPopper=!1},handleFocus:function(){Object(s["addClass"])(this.referenceElm,"focusing"),"click"!==this.trigger&&"focus"!==this.trigger||(this.showPopper=!0)},handleClick:function(){Object(s["removeClass"])(this.referenceElm,"focusing")},handleBlur:function(){Object(s["removeClass"])(this.referenceElm,"focusing"),"click"!==this.trigger&&"focus"!==this.trigger||(this.showPopper=!1)},handleMouseEnter:function(){var e=this;clearTimeout(this._timer),this.openDelay?this._timer=setTimeout((function(){e.showPopper=!0}),this.openDelay):this.showPopper=!0},handleKeydown:function(e){27===e.keyCode&&"manual"!==this.trigger&&this.doClose()},handleMouseLeave:function(){var e=this;clearTimeout(this._timer),this.closeDelay?this._timer=setTimeout((function(){e.showPopper=!1}),this.closeDelay):this.showPopper=!1},handleDocumentClick:function(e){var t=this.reference||this.$refs.reference,n=this.popper||this.$refs.popper;!t&&this.$refs.wrapper.children&&(t=this.referenceElm=this.$refs.wrapper.children[0]),this.$el&&t&&!this.$el.contains(e.target)&&!t.contains(e.target)&&n&&!n.contains(e.target)&&(this.showPopper=!1)},handleAfterEnter:function(){this.$emit("after-enter")},handleAfterLeave:function(){this.$emit("after-leave"),this.doDestroy()},cleanup:function(){(this.openDelay||this.closeDelay)&&clearTimeout(this._timer)}},destroyed:function(){var e=this.reference;Object(s["off"])(e,"click",this.doToggle),Object(s["off"])(e,"mouseup",this.doClose),Object(s["off"])(e,"mousedown",this.doShow),Object(s["off"])(e,"focusin",this.doShow),Object(s["off"])(e,"focusout",this.doClose),Object(s["off"])(e,"mousedown",this.doShow),Object(s["off"])(e,"mouseup",this.doClose),Object(s["off"])(e,"mouseleave",this.handleMouseLeave),Object(s["off"])(e,"mouseenter",this.handleMouseEnter),Object(s["off"])(document,"click",this.handleDocumentClick)}},c=u,f=n(0),d=Object(f["a"])(c,r,o,!1,null,null,null);d.options.__file="packages/popover/src/main.vue";var p=d.exports,h=function(e,t,n){var r=t.expression?t.value:t.arg,o=n.context.$refs[r];o&&(Array.isArray(o)?o[0].$refs.reference=e:o.$refs.reference=e)},v={bind:function(e,t,n){h(e,t,n)},inserted:function(e,t,n){h(e,t,n)}},m=n(7),y=n.n(m);y.a.directive("popover",v),p.install=function(e){e.directive("popover",v),e.component(p.name,p)},p.directive=v;t["default"]=p}})},"6b7c":function(e,t,n){"use strict";t.__esModule=!0;var r=n("4897");t.default={methods:{t:function(){for(var e=arguments.length,t=Array(e),n=0;n0},e.prototype.connect_=function(){r&&!this.connected_&&(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),c?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){r&&this.connected_&&(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(e){var t=e.propertyName,n=void 0===t?"":t,r=u.some((function(e){return!!~n.indexOf(e)}));r&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),d=function(e,t){for(var n=0,r=Object.keys(t);n0},e}(),$="undefined"!==typeof WeakMap?new WeakMap:new n,j=function(){function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var n=f.getInstance(),r=new k(t,n,this);$.set(this,r)}return e}();["observe","unobserve","disconnect"].forEach((function(e){j.prototype[e]=function(){var t;return(t=$.get(this))[e].apply(t,arguments)}}));var T=function(){return"undefined"!==typeof o.ResizeObserver?o.ResizeObserver:j}();t["default"]=T}.call(this,n("c8ba"))},"6ed5":function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=72)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},10:function(e,t){e.exports=n("f3ad")},13:function(e,t){e.exports=n("5128")},14:function(e,t){e.exports=n("eedf")},19:function(e,t){e.exports=n("4897")},2:function(e,t){e.exports=n("5924")},23:function(e,t){e.exports=n("41f8")},47:function(e,t){e.exports=n("722f")},6:function(e,t){e.exports=n("6b7c")},7:function(e,t){e.exports=n("2b0e")},72:function(e,t,n){"use strict";n.r(t);var r=n(7),o=n.n(r),i=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("transition",{attrs:{name:"msgbox-fade"}},[n("div",{directives:[{name:"show",rawName:"v-show",value:e.visible,expression:"visible"}],staticClass:"el-message-box__wrapper",attrs:{tabindex:"-1",role:"dialog","aria-modal":"true","aria-label":e.title||"dialog"},on:{click:function(t){return t.target!==t.currentTarget?null:e.handleWrapperClick(t)}}},[n("div",{staticClass:"el-message-box",class:[e.customClass,e.center&&"el-message-box--center"]},[null!==e.title?n("div",{staticClass:"el-message-box__header"},[n("div",{staticClass:"el-message-box__title"},[e.icon&&e.center?n("div",{class:["el-message-box__status",e.icon]}):e._e(),n("span",[e._v(e._s(e.title))])]),e.showClose?n("button",{staticClass:"el-message-box__headerbtn",attrs:{type:"button","aria-label":"Close"},on:{click:function(t){e.handleAction(e.distinguishCancelAndClose?"close":"cancel")},keydown:function(t){if(!("button"in t)&&e._k(t.keyCode,"enter",13,t.key,"Enter"))return null;e.handleAction(e.distinguishCancelAndClose?"close":"cancel")}}},[n("i",{staticClass:"el-message-box__close el-icon-close"})]):e._e()]):e._e(),n("div",{staticClass:"el-message-box__content"},[n("div",{staticClass:"el-message-box__container"},[e.icon&&!e.center&&""!==e.message?n("div",{class:["el-message-box__status",e.icon]}):e._e(),""!==e.message?n("div",{staticClass:"el-message-box__message"},[e._t("default",[e.dangerouslyUseHTMLString?n("p",{domProps:{innerHTML:e._s(e.message)}}):n("p",[e._v(e._s(e.message))])])],2):e._e()]),n("div",{directives:[{name:"show",rawName:"v-show",value:e.showInput,expression:"showInput"}],staticClass:"el-message-box__input"},[n("el-input",{ref:"input",attrs:{type:e.inputType,placeholder:e.inputPlaceholder},nativeOn:{keydown:function(t){return!("button"in t)&&e._k(t.keyCode,"enter",13,t.key,"Enter")?null:e.handleInputEnter(t)}},model:{value:e.inputValue,callback:function(t){e.inputValue=t},expression:"inputValue"}}),n("div",{staticClass:"el-message-box__errormsg",style:{visibility:e.editorErrorMessage?"visible":"hidden"}},[e._v(e._s(e.editorErrorMessage))])],1)]),n("div",{staticClass:"el-message-box__btns"},[e.showCancelButton?n("el-button",{class:[e.cancelButtonClasses],attrs:{loading:e.cancelButtonLoading,round:e.roundButton,size:"small"},on:{keydown:function(t){if(!("button"in t)&&e._k(t.keyCode,"enter",13,t.key,"Enter"))return null;e.handleAction("cancel")}},nativeOn:{click:function(t){e.handleAction("cancel")}}},[e._v("\n "+e._s(e.cancelButtonText||e.t("el.messagebox.cancel"))+"\n ")]):e._e(),n("el-button",{directives:[{name:"show",rawName:"v-show",value:e.showConfirmButton,expression:"showConfirmButton"}],ref:"confirm",class:[e.confirmButtonClasses],attrs:{loading:e.confirmButtonLoading,round:e.roundButton,size:"small"},on:{keydown:function(t){if(!("button"in t)&&e._k(t.keyCode,"enter",13,t.key,"Enter"))return null;e.handleAction("confirm")}},nativeOn:{click:function(t){e.handleAction("confirm")}}},[e._v("\n "+e._s(e.confirmButtonText||e.t("el.messagebox.confirm"))+"\n ")])],1)])])])},a=[];i._withStripped=!0;var s=n(13),l=n.n(s),u=n(6),c=n.n(u),f=n(10),d=n.n(f),p=n(14),h=n.n(p),v=n(2),m=n(19),y=n(47),g=n.n(y),b=void 0,_={success:"success",info:"info",warning:"warning",error:"error"},x={mixins:[l.a,c.a],props:{modal:{default:!0},lockScroll:{default:!0},showClose:{type:Boolean,default:!0},closeOnClickModal:{default:!0},closeOnPressEscape:{default:!0},closeOnHashChange:{default:!0},center:{default:!1,type:Boolean},roundButton:{default:!1,type:Boolean}},components:{ElInput:d.a,ElButton:h.a},computed:{icon:function(){var e=this.type,t=this.iconClass;return t||(e&&_[e]?"el-icon-"+_[e]:"")},confirmButtonClasses:function(){return"el-button--primary "+this.confirmButtonClass},cancelButtonClasses:function(){return""+this.cancelButtonClass}},methods:{getSafeClose:function(){var e=this,t=this.uid;return function(){e.$nextTick((function(){t===e.uid&&e.doClose()}))}},doClose:function(){var e=this;this.visible&&(this.visible=!1,this._closing=!0,this.onClose&&this.onClose(),b.closeDialog(),this.lockScroll&&setTimeout(this.restoreBodyStyle,200),this.opened=!1,this.doAfterClose(),setTimeout((function(){e.action&&e.callback(e.action,e)})))},handleWrapperClick:function(){this.closeOnClickModal&&this.handleAction(this.distinguishCancelAndClose?"close":"cancel")},handleInputEnter:function(){if("textarea"!==this.inputType)return this.handleAction("confirm")},handleAction:function(e){("prompt"!==this.$type||"confirm"!==e||this.validate())&&(this.action=e,"function"===typeof this.beforeClose?(this.close=this.getSafeClose(),this.beforeClose(e,this,this.close)):this.doClose())},validate:function(){if("prompt"===this.$type){var e=this.inputPattern;if(e&&!e.test(this.inputValue||""))return this.editorErrorMessage=this.inputErrorMessage||Object(m["t"])("el.messagebox.error"),Object(v["addClass"])(this.getInputElement(),"invalid"),!1;var t=this.inputValidator;if("function"===typeof t){var n=t(this.inputValue);if(!1===n)return this.editorErrorMessage=this.inputErrorMessage||Object(m["t"])("el.messagebox.error"),Object(v["addClass"])(this.getInputElement(),"invalid"),!1;if("string"===typeof n)return this.editorErrorMessage=n,Object(v["addClass"])(this.getInputElement(),"invalid"),!1}}return this.editorErrorMessage="",Object(v["removeClass"])(this.getInputElement(),"invalid"),!0},getFirstFocus:function(){var e=this.$el.querySelector(".el-message-box__btns .el-button"),t=this.$el.querySelector(".el-message-box__btns .el-message-box__title");return e||t},getInputElement:function(){var e=this.$refs.input.$refs;return e.input||e.textarea},handleClose:function(){this.handleAction("close")}},watch:{inputValue:{immediate:!0,handler:function(e){var t=this;this.$nextTick((function(n){"prompt"===t.$type&&null!==e&&t.validate()}))}},visible:function(e){var t=this;e&&(this.uid++,"alert"!==this.$type&&"confirm"!==this.$type||this.$nextTick((function(){t.$refs.confirm.$el.focus()})),this.focusAfterClosed=document.activeElement,b=new g.a(this.$el,this.focusAfterClosed,this.getFirstFocus())),"prompt"===this.$type&&(e?setTimeout((function(){t.$refs.input&&t.$refs.input.$el&&t.getInputElement().focus()}),500):(this.editorErrorMessage="",Object(v["removeClass"])(this.getInputElement(),"invalid")))}},mounted:function(){var e=this;this.$nextTick((function(){e.closeOnHashChange&&window.addEventListener("hashchange",e.close)}))},beforeDestroy:function(){this.closeOnHashChange&&window.removeEventListener("hashchange",this.close),setTimeout((function(){b.closeDialog()}))},data:function(){return{uid:1,title:void 0,message:"",type:"",iconClass:"",customClass:"",showInput:!1,inputValue:null,inputPlaceholder:"",inputType:"text",inputPattern:null,inputValidator:null,inputErrorMessage:"",showConfirmButton:!0,showCancelButton:!1,action:"",confirmButtonText:"",cancelButtonText:"",confirmButtonLoading:!1,cancelButtonLoading:!1,confirmButtonClass:"",confirmButtonDisabled:!1,cancelButtonClass:"",editorErrorMessage:null,callback:null,dangerouslyUseHTMLString:!1,focusAfterClosed:null,isOnComposition:!1,distinguishCancelAndClose:!1}}},w=x,C=n(0),S=Object(C["a"])(w,i,a,!1,null,null,null);S.options.__file="packages/message-box/src/main.vue";var O=S.exports,E=n(9),k=n.n(E),$=n(23),j="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"===typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},T={title:null,message:"",type:"",iconClass:"",showInput:!1,showClose:!0,modalFade:!0,lockScroll:!0,closeOnClickModal:!0,closeOnPressEscape:!0,closeOnHashChange:!0,inputValue:null,inputPlaceholder:"",inputType:"text",inputPattern:null,inputValidator:null,inputErrorMessage:"",showConfirmButton:!0,showCancelButton:!1,confirmButtonPosition:"right",confirmButtonHighlight:!1,cancelButtonHighlight:!1,confirmButtonText:"",cancelButtonText:"",confirmButtonClass:"",cancelButtonClass:"",customClass:"",beforeClose:null,dangerouslyUseHTMLString:!1,center:!1,roundButton:!1,distinguishCancelAndClose:!1},A=o.a.extend(O),M=void 0,P=void 0,L=[],R=function(e){if(M){var t=M.callback;"function"===typeof t&&(P.showInput?t(P.inputValue,e):t(e)),M.resolve&&("confirm"===e?P.showInput?M.resolve({value:P.inputValue,action:e}):M.resolve(e):!M.reject||"cancel"!==e&&"close"!==e||M.reject(e))}},N=function(){P=new A({el:document.createElement("div")}),P.callback=R},I=function e(){if(P||N(),P.action="",(!P.visible||P.closeTimer)&&L.length>0){M=L.shift();var t=M.options;for(var n in t)t.hasOwnProperty(n)&&(P[n]=t[n]);void 0===t.callback&&(P.callback=R);var r=P.callback;P.callback=function(t,n){r(t,n),e()},Object($["isVNode"])(P.message)?(P.$slots.default=[P.message],P.message=null):delete P.$slots.default,["modal","showClose","closeOnClickModal","closeOnPressEscape","closeOnHashChange"].forEach((function(e){void 0===P[e]&&(P[e]=!0)})),document.body.appendChild(P.$el),o.a.nextTick((function(){P.visible=!0}))}},F=function e(t,n){if(!o.a.prototype.$isServer){if("string"===typeof t||Object($["isVNode"])(t)?(t={message:t},"string"===typeof arguments[1]&&(t.title=arguments[1])):t.callback&&!n&&(n=t.callback),"undefined"!==typeof Promise)return new Promise((function(r,o){L.push({options:k()({},T,e.defaults,t),callback:n,resolve:r,reject:o}),I()}));L.push({options:k()({},T,e.defaults,t),callback:n}),I()}};F.setDefaults=function(e){F.defaults=e},F.alert=function(e,t,n){return"object"===("undefined"===typeof t?"undefined":j(t))?(n=t,t=""):void 0===t&&(t=""),F(k()({title:t,message:e,$type:"alert",closeOnPressEscape:!1,closeOnClickModal:!1},n))},F.confirm=function(e,t,n){return"object"===("undefined"===typeof t?"undefined":j(t))?(n=t,t=""):void 0===t&&(t=""),F(k()({title:t,message:e,$type:"confirm",showCancelButton:!0},n))},F.prompt=function(e,t,n){return"object"===("undefined"===typeof t?"undefined":j(t))?(n=t,t=""):void 0===t&&(t=""),F(k()({title:t,message:e,showCancelButton:!0,showInput:!0,$type:"prompt"},n))},F.close=function(){P.doClose(),P.visible=!1,L=[],M=null};var H=F;t["default"]=H},9:function(e,t){e.exports=n("7f4d")}})},"6eeb":function(e,t,n){var r=n("da84"),o=n("9112"),i=n("5135"),a=n("ce4e"),s=n("8925"),l=n("69f3"),u=l.get,c=l.enforce,f=String(String).split("String");(e.exports=function(e,t,n,s){var l,u=!!s&&!!s.unsafe,d=!!s&&!!s.enumerable,p=!!s&&!!s.noTargetGet;"function"==typeof n&&("string"!=typeof t||i(n,"name")||o(n,"name",t),l=c(n),l.source||(l.source=f.join("string"==typeof t?t:""))),e!==r?(u?!p&&e[t]&&(d=!0):delete e[t],d?e[t]=n:o(e,t,n)):d?e[t]=n:a(t,n)})(Function.prototype,"toString",(function(){return"function"==typeof this&&u(this).source||s(this)}))},"722f":function(e,t,n){"use strict";t.__esModule=!0;var r="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"===typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},o=n("e452"),i=a(o);function a(e){return e&&e.__esModule?e:{default:e}}var s,l=l||{};l.Dialog=function(e,t,n){var o=this;if(this.dialogNode=e,null===this.dialogNode||"dialog"!==this.dialogNode.getAttribute("role"))throw new Error("Dialog() requires a DOM element with ARIA role of dialog.");"string"===typeof t?this.focusAfterClosed=document.getElementById(t):"object"===("undefined"===typeof t?"undefined":r(t))?this.focusAfterClosed=t:this.focusAfterClosed=null,"string"===typeof n?this.focusFirst=document.getElementById(n):"object"===("undefined"===typeof n?"undefined":r(n))?this.focusFirst=n:this.focusFirst=null,this.focusFirst?this.focusFirst.focus():i.default.focusFirstDescendant(this.dialogNode),this.lastFocus=document.activeElement,s=function(e){o.trapFocus(e)},this.addListeners()},l.Dialog.prototype.addListeners=function(){document.addEventListener("focus",s,!0)},l.Dialog.prototype.removeListeners=function(){document.removeEventListener("focus",s,!0)},l.Dialog.prototype.closeDialog=function(){var e=this;this.removeListeners(),this.focusAfterClosed&&setTimeout((function(){e.focusAfterClosed.focus()}))},l.Dialog.prototype.trapFocus=function(e){i.default.IgnoreUtilFocusChanges||(this.dialogNode.contains(e.target)?this.lastFocus=e.target:(i.default.focusFirstDescendant(this.dialogNode),this.lastFocus===document.activeElement&&i.default.focusLastDescendant(this.dialogNode),this.lastFocus=document.activeElement))},t.default=l.Dialog},7418:function(e,t){t.f=Object.getOwnPropertySymbols},"76b9":function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=108)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},108:function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("div",{staticClass:"el-breadcrumb",attrs:{"aria-label":"Breadcrumb",role:"navigation"}},[e._t("default")],2)},o=[];r._withStripped=!0;var i={name:"ElBreadcrumb",props:{separator:{type:String,default:"/"},separatorClass:{type:String,default:""}},provide:function(){return{elBreadcrumb:this}},mounted:function(){var e=this.$el.querySelectorAll(".el-breadcrumb__item");e.length&&e[e.length-1].setAttribute("aria-current","page")}},a=i,s=n(0),l=Object(s["a"])(a,r,o,!1,null,null,null);l.options.__file="packages/breadcrumb/src/breadcrumb.vue";var u=l.exports;u.install=function(e){e.component(u.name,u)};t["default"]=u}})},7839:function(e,t){e.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},"7b0b":function(e,t,n){var r=n("1d80");e.exports=function(e){return Object(r(e))}},"7b3e":function(e,t,n){"use strict";var r,o=n("a3de"); +/** + * Checks if an event is supported in the current execution environment. + * + * NOTE: This will not work correctly for non-generic events such as `change`, + * `reset`, `load`, `error`, and `select`. + * + * Borrows from Modernizr. + * + * @param {string} eventNameSuffix Event name, e.g. "click". + * @param {?boolean} capture Check if the capture phase is supported. + * @return {boolean} True if the event is supported. + * @internal + * @license Modernizr 3.0.0pre (Custom Build) | MIT + */ +function i(e,t){if(!o.canUseDOM||t&&!("addEventListener"in document))return!1;var n="on"+e,i=n in document;if(!i){var a=document.createElement("div");a.setAttribute(n,"return;"),i="function"===typeof a[n]}return!i&&r&&"wheel"===e&&(i=document.implementation.hasFeature("Events.wheel","3.0")),i}o.canUseDOM&&(r=document.implementation&&document.implementation.hasFeature&&!0!==document.implementation.hasFeature("","")),e.exports=i},"7bc3":function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=82)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},82:function(e,t,n){"use strict";n.r(t);var r=function(e,t){var n=t._c;return n("div",t._g(t._b({class:[t.data.staticClass,"el-divider","el-divider--"+t.props.direction]},"div",t.data.attrs,!1),t.listeners),[t.slots().default&&"vertical"!==t.props.direction?n("div",{class:["el-divider__text","is-"+t.props.contentPosition]},[t._t("default")],2):t._e()])},o=[];r._withStripped=!0;var i={name:"ElDivider",props:{direction:{type:String,default:"horizontal",validator:function(e){return-1!==["horizontal","vertical"].indexOf(e)}},contentPosition:{type:String,default:"center",validator:function(e){return-1!==["left","center","right"].indexOf(e)}}}},a=i,s=n(0),l=Object(s["a"])(a,r,o,!0,null,null,null);l.options.__file="packages/divider/src/main.vue";var u=l.exports;u.install=function(e){e.component(u.name,u)};t["default"]=u}})},"7c73":function(e,t,n){var r,o=n("825a"),i=n("37e8"),a=n("7839"),s=n("d012"),l=n("1be4"),u=n("cc12"),c=n("f772"),f=">",d="<",p="prototype",h="script",v=c("IE_PROTO"),m=function(){},y=function(e){return d+h+f+e+d+"/"+h+f},g=function(e){e.write(y("")),e.close();var t=e.parentWindow.Object;return e=null,t},b=function(){var e,t=u("iframe"),n="java"+h+":";return t.style.display="none",l.appendChild(t),t.src=String(n),e=t.contentWindow.document,e.open(),e.write(y("document.F=Object")),e.close(),e.F},_=function(){try{r=document.domain&&new ActiveXObject("htmlfile")}catch(t){}_=r?g(r):b();var e=a.length;while(e--)delete _[p][a[e]];return _()};s[v]=!0,e.exports=Object.create||function(e,t){var n;return null!==e?(m[p]=o(e),n=new m,m[p]=null,n[v]=e):n=_(),void 0===t?n:i(n,t)}},"7dd0":function(e,t,n){"use strict";var r=n("23e7"),o=n("9ed3"),i=n("e163"),a=n("d2bb"),s=n("d44e"),l=n("9112"),u=n("6eeb"),c=n("b622"),f=n("c430"),d=n("3f8c"),p=n("ae93"),h=p.IteratorPrototype,v=p.BUGGY_SAFARI_ITERATORS,m=c("iterator"),y="keys",g="values",b="entries",_=function(){return this};e.exports=function(e,t,n,c,p,x,w){o(n,t,c);var C,S,O,E=function(e){if(e===p&&A)return A;if(!v&&e in j)return j[e];switch(e){case y:return function(){return new n(this,e)};case g:return function(){return new n(this,e)};case b:return function(){return new n(this,e)}}return function(){return new n(this)}},k=t+" Iterator",$=!1,j=e.prototype,T=j[m]||j["@@iterator"]||p&&j[p],A=!v&&T||E(p),M="Array"==t&&j.entries||T;if(M&&(C=i(M.call(new e)),h!==Object.prototype&&C.next&&(f||i(C)===h||(a?a(C,h):"function"!=typeof C[m]&&l(C,m,_)),s(C,k,!0,!0),f&&(d[k]=_))),p==g&&T&&T.name!==g&&($=!0,A=function(){return T.call(this)}),f&&!w||j[m]===A||l(j,m,A),d[t]=A,p)if(S={values:E(g),keys:x?A:E(y),entries:E(b)},w)for(O in S)(v||$||!(O in j))&&u(j,O,S[O]);else r({target:t,proto:!0,forced:v||$},S);return S}},"7f4d":function(e,t,n){"use strict";t.__esModule=!0,t.default=function(e){for(var t=1,n=arguments.length;t0&&void 0!==arguments[0]?arguments[0]:"";return String(e).replace(/[|\\{}()[\]^$+*?.]/g,"\\$&")};var h=t.arrayFindIndex=function(e,t){for(var n=0;n!==e.length;++n)if(t(e[n]))return n;return-1},v=(t.arrayFind=function(e,t){var n=h(e,t);return-1!==n?e[n]:void 0},t.coerceTruthyValueToArray=function(e){return Array.isArray(e)?e:e?[e]:[]},t.isIE=function(){return!i.default.prototype.$isServer&&!isNaN(Number(document.documentMode))},t.isEdge=function(){return!i.default.prototype.$isServer&&navigator.userAgent.indexOf("Edge")>-1},t.isFirefox=function(){return!i.default.prototype.$isServer&&!!window.navigator.userAgent.match(/firefox/i)},t.autoprefixer=function(e){if("object"!==("undefined"===typeof e?"undefined":r(e)))return e;var t=["transform","transition","animation"],n=["ms-","webkit-"];return t.forEach((function(t){var r=e[t];t&&r&&n.forEach((function(n){e[n+t]=r}))})),e},t.kebabCase=function(e){var t=/([^-])([A-Z])/g;return e.replace(t,"$1-$2").replace(t,"$1-$2").toLowerCase()},t.capitalize=function(e){return(0,a.isString)(e)?e.charAt(0).toUpperCase()+e.slice(1):e},t.looseEqual=function(e,t){var n=(0,a.isObject)(e),r=(0,a.isObject)(t);return n&&r?JSON.stringify(e)===JSON.stringify(t):!n&&!r&&String(e)===String(t)}),m=t.arrayEquals=function(e,t){if(e=e||[],t=t||[],e.length!==t.length)return!1;for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:{};if(!o.a.prototype.$isServer){if(e=_()({},w,e),"string"===typeof e.target&&(e.target=document.querySelector(e.target)),e.target=e.target||document.body,e.target!==document.body?e.fullscreen=!1:e.body=!0,e.fullscreen&&C)return C;var t=e.body?document.body:e.target,n=new x({el:document.createElement("div"),data:e});return S(e,t,n),"absolute"!==n.originalPosition&&"fixed"!==n.originalPosition&&Object(d["addClass"])(t,"el-loading-parent--relative"),e.fullscreen&&e.lock&&Object(d["addClass"])(t,"el-loading-parent--hidden"),t.appendChild(n.$el),o.a.nextTick((function(){n.visible=!0})),e.fullscreen&&(C=n),n}},E=O;t["default"]={install:function(e){e.use(g),e.prototype.$loading=E},directive:g,service:E}},9:function(e,t){e.exports=n("7f4d")}})},"8bbc":function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=127)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},127:function(e,t,n){"use strict";n.r(t);var r,o,i={name:"ElTag",props:{text:String,closable:Boolean,type:String,hit:Boolean,disableTransitions:Boolean,color:String,size:String,effect:{type:String,default:"light",validator:function(e){return-1!==["dark","light","plain"].indexOf(e)}}},methods:{handleClose:function(e){e.stopPropagation(),this.$emit("close",e)},handleClick:function(e){this.$emit("click",e)}},computed:{tagSize:function(){return this.size||(this.$ELEMENT||{}).size}},render:function(e){var t=this.type,n=this.tagSize,r=this.hit,o=this.effect,i=["el-tag",t?"el-tag--"+t:"",n?"el-tag--"+n:"",o?"el-tag--"+o:"",r&&"is-hit"],a=e("span",{class:i,style:{backgroundColor:this.color},on:{click:this.handleClick}},[this.$slots.default,this.closable&&e("i",{class:"el-tag__close el-icon-close",on:{click:this.handleClose}})]);return this.disableTransitions?a:e("transition",{attrs:{name:"el-zoom-in-center"}},[a])}},a=i,s=n(0),l=Object(s["a"])(a,r,o,!1,null,null,null);l.options.__file="packages/tag/src/tag.vue";var u=l.exports;u.install=function(e){e.component(u.name,u)};t["default"]=u}})},"8c4f":function(e,t,n){"use strict"; +/*! + * vue-router v3.5.2 + * (c) 2021 Evan You + * @license MIT + */function r(e,t){0}function o(e,t){for(var n in t)e[n]=t[n];return e}var i=/[!'()*]/g,a=function(e){return"%"+e.charCodeAt(0).toString(16)},s=/%2C/g,l=function(e){return encodeURIComponent(e).replace(i,a).replace(s,",")};function u(e){try{return decodeURIComponent(e)}catch(t){0}return e}function c(e,t,n){void 0===t&&(t={});var r,o=n||d;try{r=o(e||"")}catch(s){r={}}for(var i in t){var a=t[i];r[i]=Array.isArray(a)?a.map(f):f(a)}return r}var f=function(e){return null==e||"object"===typeof e?e:String(e)};function d(e){var t={};return e=e.trim().replace(/^(\?|#|&)/,""),e?(e.split("&").forEach((function(e){var n=e.replace(/\+/g," ").split("="),r=u(n.shift()),o=n.length>0?u(n.join("=")):null;void 0===t[r]?t[r]=o:Array.isArray(t[r])?t[r].push(o):t[r]=[t[r],o]})),t):t}function p(e){var t=e?Object.keys(e).map((function(t){var n=e[t];if(void 0===n)return"";if(null===n)return l(t);if(Array.isArray(n)){var r=[];return n.forEach((function(e){void 0!==e&&(null===e?r.push(l(t)):r.push(l(t)+"="+l(e)))})),r.join("&")}return l(t)+"="+l(n)})).filter((function(e){return e.length>0})).join("&"):null;return t?"?"+t:""}var h=/\/?$/;function v(e,t,n,r){var o=r&&r.options.stringifyQuery,i=t.query||{};try{i=m(i)}catch(s){}var a={name:t.name||e&&e.name,meta:e&&e.meta||{},path:t.path||"/",hash:t.hash||"",query:i,params:t.params||{},fullPath:b(t,o),matched:e?g(e):[]};return n&&(a.redirectedFrom=b(n,o)),Object.freeze(a)}function m(e){if(Array.isArray(e))return e.map(m);if(e&&"object"===typeof e){var t={};for(var n in e)t[n]=m(e[n]);return t}return e}var y=v(null,{path:"/"});function g(e){var t=[];while(e)t.unshift(e),e=e.parent;return t}function b(e,t){var n=e.path,r=e.query;void 0===r&&(r={});var o=e.hash;void 0===o&&(o="");var i=t||p;return(n||"/")+i(r)+o}function _(e,t,n){return t===y?e===t:!!t&&(e.path&&t.path?e.path.replace(h,"")===t.path.replace(h,"")&&(n||e.hash===t.hash&&x(e.query,t.query)):!(!e.name||!t.name)&&(e.name===t.name&&(n||e.hash===t.hash&&x(e.query,t.query)&&x(e.params,t.params))))}function x(e,t){if(void 0===e&&(e={}),void 0===t&&(t={}),!e||!t)return e===t;var n=Object.keys(e).sort(),r=Object.keys(t).sort();return n.length===r.length&&n.every((function(n,o){var i=e[n],a=r[o];if(a!==n)return!1;var s=t[n];return null==i||null==s?i===s:"object"===typeof i&&"object"===typeof s?x(i,s):String(i)===String(s)}))}function w(e,t){return 0===e.path.replace(h,"/").indexOf(t.path.replace(h,"/"))&&(!t.hash||e.hash===t.hash)&&C(e.query,t.query)}function C(e,t){for(var n in t)if(!(n in e))return!1;return!0}function S(e){for(var t=0;t=0&&(t=e.slice(r),e=e.slice(0,r));var o=e.indexOf("?");return o>=0&&(n=e.slice(o+1),e=e.slice(0,o)),{path:e,query:n,hash:t}}function T(e){return e.replace(/\/\//g,"/")}var A=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)},M=J,P=F,L=H,R=B,N=Y,I=new RegExp(["(\\\\.)","([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?|(\\*))"].join("|"),"g");function F(e,t){var n,r=[],o=0,i=0,a="",s=t&&t.delimiter||"/";while(null!=(n=I.exec(e))){var l=n[0],u=n[1],c=n.index;if(a+=e.slice(i,c),i=c+l.length,u)a+=u[1];else{var f=e[i],d=n[2],p=n[3],h=n[4],v=n[5],m=n[6],y=n[7];a&&(r.push(a),a="");var g=null!=d&&null!=f&&f!==d,b="+"===m||"*"===m,_="?"===m||"*"===m,x=n[2]||s,w=h||v;r.push({name:p||o++,prefix:d||"",delimiter:x,optional:_,repeat:b,partial:g,asterisk:!!y,pattern:w?V(w):y?".*":"[^"+W(x)+"]+?"})}}return i1||!S.length)return 0===S.length?e():e("span",{},S)}if("a"===this.tag)C.on=x,C.attrs={href:l,"aria-current":g};else{var O=se(this.$slots.default);if(O){O.isStatic=!1;var E=O.data=o({},O.data);for(var k in E.on=E.on||{},E.on){var $=E.on[k];k in x&&(E.on[k]=Array.isArray($)?$:[$])}for(var j in x)j in E.on?E.on[j].push(x[j]):E.on[j]=b;var T=O.data.attrs=o({},O.data.attrs);T.href=l,T["aria-current"]=g}else C.on=x}return e(this.tag,C,this.$slots.default)}};function ae(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&(void 0===e.button||0===e.button)){if(e.currentTarget&&e.currentTarget.getAttribute){var t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function se(e){if(e)for(var t,n=0;n-1&&(s.params[f]=n.params[f]);return s.path=Q(u.path,s.params,'named route "'+l+'"'),d(u,s,a)}if(s.path){s.params={};for(var p=0;p=e.length?n():e[o]?t(e[o],(function(){r(o+1)})):r(o+1)};r(0)}var He={redirected:2,aborted:4,cancelled:8,duplicated:16};function De(e,t){return Ve(e,t,He.redirected,'Redirected when going from "'+e.fullPath+'" to "'+qe(t)+'" via a navigation guard.')}function ze(e,t){var n=Ve(e,t,He.duplicated,'Avoided redundant navigation to current location: "'+e.fullPath+'".');return n.name="NavigationDuplicated",n}function Be(e,t){return Ve(e,t,He.cancelled,'Navigation cancelled from "'+e.fullPath+'" to "'+t.fullPath+'" with a new navigation.')}function We(e,t){return Ve(e,t,He.aborted,'Navigation aborted from "'+e.fullPath+'" to "'+t.fullPath+'" via a navigation guard.')}function Ve(e,t,n,r){var o=new Error(r);return o._isRouter=!0,o.from=e,o.to=t,o.type=n,o}var Ue=["params","query","hash"];function qe(e){if("string"===typeof e)return e;if("path"in e)return e.path;var t={};return Ue.forEach((function(n){n in e&&(t[n]=e[n])})),JSON.stringify(t,null,2)}function Ke(e){return Object.prototype.toString.call(e).indexOf("Error")>-1}function Ge(e,t){return Ke(e)&&e._isRouter&&(null==t||e.type===t)}function Xe(e){return function(t,n,r){var o=!1,i=0,a=null;Ye(e,(function(e,t,n,s){if("function"===typeof e&&void 0===e.cid){o=!0,i++;var l,u=et((function(t){Qe(t)&&(t=t.default),e.resolved="function"===typeof t?t:te.extend(t),n.components[s]=t,i--,i<=0&&r()})),c=et((function(e){var t="Failed to resolve async component "+s+": "+e;a||(a=Ke(e)?e:new Error(t),r(a))}));try{l=e(u,c)}catch(d){c(d)}if(l)if("function"===typeof l.then)l.then(u,c);else{var f=l.component;f&&"function"===typeof f.then&&f.then(u,c)}}})),o||r()}}function Ye(e,t){return Je(e.map((function(e){return Object.keys(e.components).map((function(n){return t(e.components[n],e.instances[n],e,n)}))})))}function Je(e){return Array.prototype.concat.apply([],e)}var Ze="function"===typeof Symbol&&"symbol"===typeof Symbol.toStringTag;function Qe(e){return e.__esModule||Ze&&"Module"===e[Symbol.toStringTag]}function et(e){var t=!1;return function(){var n=[],r=arguments.length;while(r--)n[r]=arguments[r];if(!t)return t=!0,e.apply(this,n)}}var tt=function(e,t){this.router=e,this.base=nt(t),this.current=y,this.pending=null,this.ready=!1,this.readyCbs=[],this.readyErrorCbs=[],this.errorCbs=[],this.listeners=[]};function nt(e){if(!e)if(ue){var t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^https?:\/\/[^\/]+/,"")}else e="/";return"/"!==e.charAt(0)&&(e="/"+e),e.replace(/\/$/,"")}function rt(e,t){var n,r=Math.max(e.length,t.length);for(n=0;n0)){var t=this.router,n=t.options.scrollBehavior,r=Re&&n;r&&this.listeners.push(Ce());var o=function(){var n=e.current,o=dt(e.base);e.current===y&&o===e._startLocation||e.transitionTo(o,(function(e){r&&Se(t,e,n,!0)}))};window.addEventListener("popstate",o),this.listeners.push((function(){window.removeEventListener("popstate",o)}))}},t.prototype.go=function(e){window.history.go(e)},t.prototype.push=function(e,t,n){var r=this,o=this,i=o.current;this.transitionTo(e,(function(e){Ne(T(r.base+e.fullPath)),Se(r.router,e,i,!1),t&&t(e)}),n)},t.prototype.replace=function(e,t,n){var r=this,o=this,i=o.current;this.transitionTo(e,(function(e){Ie(T(r.base+e.fullPath)),Se(r.router,e,i,!1),t&&t(e)}),n)},t.prototype.ensureURL=function(e){if(dt(this.base)!==this.current.fullPath){var t=T(this.base+this.current.fullPath);e?Ne(t):Ie(t)}},t.prototype.getCurrentLocation=function(){return dt(this.base)},t}(tt);function dt(e){var t=window.location.pathname,n=t.toLowerCase(),r=e.toLowerCase();return!e||n!==r&&0!==n.indexOf(T(r+"/"))||(t=t.slice(e.length)),(t||"/")+window.location.search+window.location.hash}var pt=function(e){function t(t,n,r){e.call(this,t,n),r&&ht(this.base)||vt()}return e&&(t.__proto__=e),t.prototype=Object.create(e&&e.prototype),t.prototype.constructor=t,t.prototype.setupListeners=function(){var e=this;if(!(this.listeners.length>0)){var t=this.router,n=t.options.scrollBehavior,r=Re&&n;r&&this.listeners.push(Ce());var o=function(){var t=e.current;vt()&&e.transitionTo(mt(),(function(n){r&&Se(e.router,n,t,!0),Re||bt(n.fullPath)}))},i=Re?"popstate":"hashchange";window.addEventListener(i,o),this.listeners.push((function(){window.removeEventListener(i,o)}))}},t.prototype.push=function(e,t,n){var r=this,o=this,i=o.current;this.transitionTo(e,(function(e){gt(e.fullPath),Se(r.router,e,i,!1),t&&t(e)}),n)},t.prototype.replace=function(e,t,n){var r=this,o=this,i=o.current;this.transitionTo(e,(function(e){bt(e.fullPath),Se(r.router,e,i,!1),t&&t(e)}),n)},t.prototype.go=function(e){window.history.go(e)},t.prototype.ensureURL=function(e){var t=this.current.fullPath;mt()!==t&&(e?gt(t):bt(t))},t.prototype.getCurrentLocation=function(){return mt()},t}(tt);function ht(e){var t=dt(e);if(!/^\/#/.test(t))return window.location.replace(T(e+"/#"+t)),!0}function vt(){var e=mt();return"/"===e.charAt(0)||(bt("/"+e),!1)}function mt(){var e=window.location.href,t=e.indexOf("#");return t<0?"":(e=e.slice(t+1),e)}function yt(e){var t=window.location.href,n=t.indexOf("#"),r=n>=0?t.slice(0,n):t;return r+"#"+e}function gt(e){Re?Ne(yt(e)):window.location.hash=e}function bt(e){Re?Ie(yt(e)):window.location.replace(yt(e))}var _t=function(e){function t(t,n){e.call(this,t,n),this.stack=[],this.index=-1}return e&&(t.__proto__=e),t.prototype=Object.create(e&&e.prototype),t.prototype.constructor=t,t.prototype.push=function(e,t,n){var r=this;this.transitionTo(e,(function(e){r.stack=r.stack.slice(0,r.index+1).concat(e),r.index++,t&&t(e)}),n)},t.prototype.replace=function(e,t,n){var r=this;this.transitionTo(e,(function(e){r.stack=r.stack.slice(0,r.index).concat(e),t&&t(e)}),n)},t.prototype.go=function(e){var t=this,n=this.index+e;if(!(n<0||n>=this.stack.length)){var r=this.stack[n];this.confirmTransition(r,(function(){var e=t.current;t.index=n,t.updateRoute(r),t.router.afterHooks.forEach((function(t){t&&t(r,e)}))}),(function(e){Ge(e,He.duplicated)&&(t.index=n)}))}},t.prototype.getCurrentLocation=function(){var e=this.stack[this.stack.length-1];return e?e.fullPath:"/"},t.prototype.ensureURL=function(){},t}(tt),xt=function(e){void 0===e&&(e={}),this.app=null,this.apps=[],this.options=e,this.beforeHooks=[],this.resolveHooks=[],this.afterHooks=[],this.matcher=he(e.routes||[],this);var t=e.mode||"hash";switch(this.fallback="history"===t&&!Re&&!1!==e.fallback,this.fallback&&(t="hash"),ue||(t="abstract"),this.mode=t,t){case"history":this.history=new ft(this,e.base);break;case"hash":this.history=new pt(this,e.base,this.fallback);break;case"abstract":this.history=new _t(this,e.base);break;default:0}},wt={currentRoute:{configurable:!0}};function Ct(e,t){return e.push(t),function(){var n=e.indexOf(t);n>-1&&e.splice(n,1)}}function St(e,t,n){var r="hash"===n?"#"+t:t;return e?T(e+"/"+r):r}xt.prototype.match=function(e,t,n){return this.matcher.match(e,t,n)},wt.currentRoute.get=function(){return this.history&&this.history.current},xt.prototype.init=function(e){var t=this;if(this.apps.push(e),e.$once("hook:destroyed",(function(){var n=t.apps.indexOf(e);n>-1&&t.apps.splice(n,1),t.app===e&&(t.app=t.apps[0]||null),t.app||t.history.teardown()})),!this.app){this.app=e;var n=this.history;if(n instanceof ft||n instanceof pt){var r=function(e){var r=n.current,o=t.options.scrollBehavior,i=Re&&o;i&&"fullPath"in e&&Se(t,e,r,!1)},o=function(e){n.setupListeners(),r(e)};n.transitionTo(n.getCurrentLocation(),o,o)}n.listen((function(e){t.apps.forEach((function(t){t._route=e}))}))}},xt.prototype.beforeEach=function(e){return Ct(this.beforeHooks,e)},xt.prototype.beforeResolve=function(e){return Ct(this.resolveHooks,e)},xt.prototype.afterEach=function(e){return Ct(this.afterHooks,e)},xt.prototype.onReady=function(e,t){this.history.onReady(e,t)},xt.prototype.onError=function(e){this.history.onError(e)},xt.prototype.push=function(e,t,n){var r=this;if(!t&&!n&&"undefined"!==typeof Promise)return new Promise((function(t,n){r.history.push(e,t,n)}));this.history.push(e,t,n)},xt.prototype.replace=function(e,t,n){var r=this;if(!t&&!n&&"undefined"!==typeof Promise)return new Promise((function(t,n){r.history.replace(e,t,n)}));this.history.replace(e,t,n)},xt.prototype.go=function(e){this.history.go(e)},xt.prototype.back=function(){this.go(-1)},xt.prototype.forward=function(){this.go(1)},xt.prototype.getMatchedComponents=function(e){var t=e?e.matched?e:this.resolve(e).route:this.currentRoute;return t?[].concat.apply([],t.matched.map((function(e){return Object.keys(e.components).map((function(t){return e.components[t]}))}))):[]},xt.prototype.resolve=function(e,t,n){t=t||this.history.current;var r=ee(e,t,n,this),o=this.match(r,t),i=o.redirectedFrom||o.fullPath,a=this.history.base,s=St(a,i,this.mode);return{location:r,route:o,href:s,normalizedTo:r,resolved:o}},xt.prototype.getRoutes=function(){return this.matcher.getRoutes()},xt.prototype.addRoute=function(e,t){this.matcher.addRoute(e,t),this.history.current!==y&&this.history.transitionTo(this.history.getCurrentLocation())},xt.prototype.addRoutes=function(e){this.matcher.addRoutes(e),this.history.current!==y&&this.history.transitionTo(this.history.getCurrentLocation())},Object.defineProperties(xt.prototype,wt),xt.install=le,xt.version="3.5.2",xt.isNavigationFailure=Ge,xt.NavigationFailureType=He,xt.START_LOCATION=y,ue&&window.Vue&&window.Vue.use(xt),t["a"]=xt},"8eb7":function(e,t){var n,r,o,i,a,s,l,u,c,f,d,p,h,v,m,y=!1;function g(){if(!y){y=!0;var e=navigator.userAgent,t=/(?:MSIE.(\d+\.\d+))|(?:(?:Firefox|GranParadiso|Iceweasel).(\d+\.\d+))|(?:Opera(?:.+Version.|.)(\d+\.\d+))|(?:AppleWebKit.(\d+(?:\.\d+)?))|(?:Trident\/\d+\.\d+.*rv:(\d+\.\d+))/.exec(e),g=/(Mac OS X)|(Windows)|(Linux)/.exec(e);if(p=/\b(iPhone|iP[ao]d)/.exec(e),h=/\b(iP[ao]d)/.exec(e),f=/Android/i.exec(e),v=/FBAN\/\w+;/i.exec(e),m=/Mobile/i.exec(e),d=!!/Win64/.exec(e),t){n=t[1]?parseFloat(t[1]):t[5]?parseFloat(t[5]):NaN,n&&document&&document.documentMode&&(n=document.documentMode);var b=/(?:Trident\/(\d+.\d+))/.exec(e);s=b?parseFloat(b[1])+4:n,r=t[2]?parseFloat(t[2]):NaN,o=t[3]?parseFloat(t[3]):NaN,i=t[4]?parseFloat(t[4]):NaN,i?(t=/(?:Chrome\/(\d+\.\d+))/.exec(e),a=t&&t[1]?parseFloat(t[1]):NaN):a=NaN}else n=r=o=a=i=NaN;if(g){if(g[1]){var _=/(?:Mac OS X (\d+(?:[._]\d+)?))/.exec(e);l=!_||parseFloat(_[1].replace("_","."))}else l=!1;u=!!g[2],c=!!g[3]}else l=u=c=!1}}var b={ie:function(){return g()||n},ieCompatibilityMode:function(){return g()||s>n},ie64:function(){return b.ie()&&d},firefox:function(){return g()||r},opera:function(){return g()||o},webkit:function(){return g()||i},safari:function(){return b.webkit()},chrome:function(){return g()||a},windows:function(){return g()||u},osx:function(){return g()||l},linux:function(){return g()||c},iphone:function(){return g()||p},mobile:function(){return g()||p||h||f||m},nativeApp:function(){return g()||v},android:function(){return g()||f},ipad:function(){return g()||h}};e.exports=b},"8f24":function(e,t,n){},"90e3":function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol("+String(void 0===e?"":e)+")_"+(++n+r).toString(36)}},9112:function(e,t,n){var r=n("83ab"),o=n("9bf2"),i=n("5c6c");e.exports=r?function(e,t,n){return o.f(e,t,i(1,n))}:function(e,t,n){return e[t]=n,e}},"94ca":function(e,t,n){var r=n("d039"),o=/#|\.prototype\./,i=function(e,t){var n=s[a(e)];return n==u||n!=l&&("function"==typeof t?r(t):!!t)},a=i.normalize=function(e){return String(e).replace(o,".").toLowerCase()},s=i.data={},l=i.NATIVE="N",u=i.POLYFILL="P";e.exports=i},9619:function(e,t,n){var r=n("597f"),o=n("0e15");e.exports={throttle:r,debounce:o}},"9bf2":function(e,t,n){var r=n("83ab"),o=n("0cfb"),i=n("825a"),a=n("c04e"),s=Object.defineProperty;t.f=r?s:function(e,t,n){if(i(e),t=a(t,!0),i(n),o)try{return s(e,t,n)}catch(r){}if("get"in n||"set"in n)throw TypeError("Accessors not supported");return"value"in n&&(e[t]=n.value),e}},"9d7e":function(e,t,n){"use strict";t.__esModule=!0;var r="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"===typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e};t.default=function(e){function t(e){for(var t=arguments.length,n=Array(t>1?t-1:0),a=1;a0?r:n)(e)}},a742:function(e,t,n){"use strict";t.__esModule=!0,t.isDefined=t.isUndefined=t.isFunction=void 0;var r="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"===typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e};t.isString=s,t.isObject=l,t.isHtmlElement=u;var o=n("2b0e"),i=a(o);function a(e){return e&&e.__esModule?e:{default:e}}function s(e){return"[object String]"===Object.prototype.toString.call(e)}function l(e){return"[object Object]"===Object.prototype.toString.call(e)}function u(e){return e&&e.nodeType===Node.ELEMENT_NODE}var c=function(e){var t={};return e&&"[object Function]"===t.toString.call(e)};"object"===("undefined"===typeof Int8Array?"undefined":r(Int8Array))||!i.default.prototype.$isServer&&"function"===typeof document.childNodes||(t.isFunction=c=function(e){return"function"===typeof e||!1}),t.isFunction=c;t.isUndefined=function(e){return void 0===e},t.isDefined=function(e){return void 0!==e&&null!==e}},a79d:function(e,t,n){"use strict";var r=n("23e7"),o=n("c430"),i=n("fea9"),a=n("d039"),s=n("d066"),l=n("4840"),u=n("cdf9"),c=n("6eeb"),f=!!i&&a((function(){i.prototype["finally"].call({then:function(){}},(function(){}))}));if(r({target:"Promise",proto:!0,real:!0,forced:f},{finally:function(e){var t=l(this,s("Promise")),n="function"==typeof e;return this.then(n?function(n){return u(t,e()).then((function(){return n}))}:e,n?function(n){return u(t,e()).then((function(){throw n}))}:e)}}),!o&&"function"==typeof i){var d=s("Promise").prototype["finally"];i.prototype["finally"]!==d&&c(i.prototype,"finally",d,{unsafe:!0})}},ad41:function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=56)}([function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},,function(e,t){e.exports=n("5924")},function(e,t){e.exports=n("8122")},,function(e,t){e.exports=n("e974")},function(e,t){e.exports=n("6b7c")},function(e,t){e.exports=n("2b0e")},function(e,t,n){"use strict";n.d(t,"b",(function(){return i})),n.d(t,"i",(function(){return s})),n.d(t,"d",(function(){return l})),n.d(t,"e",(function(){return u})),n.d(t,"c",(function(){return c})),n.d(t,"g",(function(){return f})),n.d(t,"f",(function(){return d})),n.d(t,"h",(function(){return h})),n.d(t,"l",(function(){return v})),n.d(t,"k",(function(){return m})),n.d(t,"j",(function(){return y})),n.d(t,"a",(function(){return g})),n.d(t,"m",(function(){return b})),n.d(t,"n",(function(){return _}));var r=n(3),o="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"===typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i=function(e){var t=e.target;while(t&&"HTML"!==t.tagName.toUpperCase()){if("TD"===t.tagName.toUpperCase())return t;t=t.parentNode}return null},a=function(e){return null!==e&&"object"===("undefined"===typeof e?"undefined":o(e))},s=function(e,t,n,o,i){if(!t&&!o&&(!i||Array.isArray(i)&&!i.length))return e;n="string"===typeof n?"descending"===n?-1:1:n&&n<0?-1:1;var s=o?null:function(n,o){return i?(Array.isArray(i)||(i=[i]),i.map((function(t){return"string"===typeof t?Object(r["getValueByPath"])(n,t):t(n,o,e)}))):("$key"!==t&&a(n)&&"$value"in n&&(n=n.$value),[a(n)?Object(r["getValueByPath"])(n,t):n])},l=function(e,t){if(o)return o(e.value,t.value);for(var n=0,r=e.key.length;nt.key[n])return 1}return 0};return e.map((function(e,t){return{value:e,index:t,key:s?s(e,t):null}})).sort((function(e,t){var r=l(e,t);return r||(r=e.index-t.index),r*n})).map((function(e){return e.value}))},l=function(e,t){var n=null;return e.columns.forEach((function(e){e.id===t&&(n=e)})),n},u=function(e,t){for(var n=null,r=0;r2&&void 0!==arguments[2]?arguments[2]:"children",r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"hasChildren",o=function(e){return!(Array.isArray(e)&&e.length)};function i(e,a,s){t(e,a,s),a.forEach((function(e){if(e[r])t(e,null,s+1);else{var a=e[n];o(a)||i(e,a,s+1)}}))}e.forEach((function(e){if(e[r])t(e,null,0);else{var a=e[n];o(a)||i(e,a,0)}}))}},function(e,t){e.exports=n("7f4d")},,function(e,t){e.exports=n("2bb5")},function(e,t){e.exports=n("417f")},function(e,t){e.exports=n("5128")},,function(e,t){e.exports=n("14e9")},function(e,t){e.exports=n("4010")},function(e,t){e.exports=n("0e15")},function(e,t){e.exports=n("dcdc")},,,,,,,,,,,function(e,t){e.exports=n("299c")},,,,,,,,,function(e,t){e.exports=n("e62d")},function(e,t){e.exports=n("7fc1")},,,,function(e,t){e.exports=n("9619")},,,function(e,t){e.exports=n("c098")},,,,,,,,,,function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("div",{staticClass:"el-table",class:[{"el-table--fit":e.fit,"el-table--striped":e.stripe,"el-table--border":e.border||e.isGroup,"el-table--hidden":e.isHidden,"el-table--group":e.isGroup,"el-table--fluid-height":e.maxHeight,"el-table--scrollable-x":e.layout.scrollX,"el-table--scrollable-y":e.layout.scrollY,"el-table--enable-row-hover":!e.store.states.isComplex,"el-table--enable-row-transition":0!==(e.store.states.data||[]).length&&(e.store.states.data||[]).length<100},e.tableSize?"el-table--"+e.tableSize:""],on:{mouseleave:function(t){e.handleMouseLeave(t)}}},[n("div",{ref:"hiddenColumns",staticClass:"hidden-columns"},[e._t("default")],2),e.showHeader?n("div",{directives:[{name:"mousewheel",rawName:"v-mousewheel",value:e.handleHeaderFooterMousewheel,expression:"handleHeaderFooterMousewheel"}],ref:"headerWrapper",staticClass:"el-table__header-wrapper"},[n("table-header",{ref:"tableHeader",style:{width:e.layout.bodyWidth?e.layout.bodyWidth+"px":""},attrs:{store:e.store,border:e.border,"default-sort":e.defaultSort}})],1):e._e(),n("div",{ref:"bodyWrapper",staticClass:"el-table__body-wrapper",class:[e.layout.scrollX?"is-scrolling-"+e.scrollPosition:"is-scrolling-none"],style:[e.bodyHeight]},[n("table-body",{style:{width:e.bodyWidth},attrs:{context:e.context,store:e.store,stripe:e.stripe,"row-class-name":e.rowClassName,"row-style":e.rowStyle,highlight:e.highlightCurrentRow}}),e.data&&0!==e.data.length?e._e():n("div",{ref:"emptyBlock",staticClass:"el-table__empty-block",style:e.emptyBlockStyle},[n("span",{staticClass:"el-table__empty-text"},[e._t("empty",[e._v(e._s(e.emptyText||e.t("el.table.emptyText")))])],2)]),e.$slots.append?n("div",{ref:"appendWrapper",staticClass:"el-table__append-wrapper"},[e._t("append")],2):e._e()],1),e.showSummary?n("div",{directives:[{name:"show",rawName:"v-show",value:e.data&&e.data.length>0,expression:"data && data.length > 0"},{name:"mousewheel",rawName:"v-mousewheel",value:e.handleHeaderFooterMousewheel,expression:"handleHeaderFooterMousewheel"}],ref:"footerWrapper",staticClass:"el-table__footer-wrapper"},[n("table-footer",{style:{width:e.layout.bodyWidth?e.layout.bodyWidth+"px":""},attrs:{store:e.store,border:e.border,"sum-text":e.sumText||e.t("el.table.sumText"),"summary-method":e.summaryMethod,"default-sort":e.defaultSort}})],1):e._e(),e.fixedColumns.length>0?n("div",{directives:[{name:"mousewheel",rawName:"v-mousewheel",value:e.handleFixedMousewheel,expression:"handleFixedMousewheel"}],ref:"fixedWrapper",staticClass:"el-table__fixed",style:[{width:e.layout.fixedWidth?e.layout.fixedWidth+"px":""},e.fixedHeight]},[e.showHeader?n("div",{ref:"fixedHeaderWrapper",staticClass:"el-table__fixed-header-wrapper"},[n("table-header",{ref:"fixedTableHeader",style:{width:e.bodyWidth},attrs:{fixed:"left",border:e.border,store:e.store}})],1):e._e(),n("div",{ref:"fixedBodyWrapper",staticClass:"el-table__fixed-body-wrapper",style:[{top:e.layout.headerHeight+"px"},e.fixedBodyHeight]},[n("table-body",{style:{width:e.bodyWidth},attrs:{fixed:"left",store:e.store,stripe:e.stripe,highlight:e.highlightCurrentRow,"row-class-name":e.rowClassName,"row-style":e.rowStyle}}),e.$slots.append?n("div",{staticClass:"el-table__append-gutter",style:{height:e.layout.appendHeight+"px"}}):e._e()],1),e.showSummary?n("div",{directives:[{name:"show",rawName:"v-show",value:e.data&&e.data.length>0,expression:"data && data.length > 0"}],ref:"fixedFooterWrapper",staticClass:"el-table__fixed-footer-wrapper"},[n("table-footer",{style:{width:e.bodyWidth},attrs:{fixed:"left",border:e.border,"sum-text":e.sumText||e.t("el.table.sumText"),"summary-method":e.summaryMethod,store:e.store}})],1):e._e()]):e._e(),e.rightFixedColumns.length>0?n("div",{directives:[{name:"mousewheel",rawName:"v-mousewheel",value:e.handleFixedMousewheel,expression:"handleFixedMousewheel"}],ref:"rightFixedWrapper",staticClass:"el-table__fixed-right",style:[{width:e.layout.rightFixedWidth?e.layout.rightFixedWidth+"px":"",right:e.layout.scrollY?(e.border?e.layout.gutterWidth:e.layout.gutterWidth||0)+"px":""},e.fixedHeight]},[e.showHeader?n("div",{ref:"rightFixedHeaderWrapper",staticClass:"el-table__fixed-header-wrapper"},[n("table-header",{ref:"rightFixedTableHeader",style:{width:e.bodyWidth},attrs:{fixed:"right",border:e.border,store:e.store}})],1):e._e(),n("div",{ref:"rightFixedBodyWrapper",staticClass:"el-table__fixed-body-wrapper",style:[{top:e.layout.headerHeight+"px"},e.fixedBodyHeight]},[n("table-body",{style:{width:e.bodyWidth},attrs:{fixed:"right",store:e.store,stripe:e.stripe,"row-class-name":e.rowClassName,"row-style":e.rowStyle,highlight:e.highlightCurrentRow}}),e.$slots.append?n("div",{staticClass:"el-table__append-gutter",style:{height:e.layout.appendHeight+"px"}}):e._e()],1),e.showSummary?n("div",{directives:[{name:"show",rawName:"v-show",value:e.data&&e.data.length>0,expression:"data && data.length > 0"}],ref:"rightFixedFooterWrapper",staticClass:"el-table__fixed-footer-wrapper"},[n("table-footer",{style:{width:e.bodyWidth},attrs:{fixed:"right",border:e.border,"sum-text":e.sumText||e.t("el.table.sumText"),"summary-method":e.summaryMethod,store:e.store}})],1):e._e()]):e._e(),e.rightFixedColumns.length>0?n("div",{ref:"rightFixedPatch",staticClass:"el-table__fixed-right-patch",style:{width:e.layout.scrollY?e.layout.gutterWidth+"px":"0",height:e.layout.headerHeight+"px"}}):e._e(),n("div",{directives:[{name:"show",rawName:"v-show",value:e.resizeProxyVisible,expression:"resizeProxyVisible"}],ref:"resizeProxy",staticClass:"el-table__column-resize-proxy"})])},o=[];r._withStripped=!0;var i=n(18),a=n.n(i),s=n(43),l=n(16),u=n(46),c=n.n(u),f="undefined"!==typeof navigator&&navigator.userAgent.toLowerCase().indexOf("firefox")>-1,d=function(e,t){e&&e.addEventListener&&e.addEventListener(f?"DOMMouseScroll":"mousewheel",(function(e){var n=c()(e);t&&t.apply(this,[e,n])}))},p={bind:function(e,t){d(e,t.value)}},h=n(6),v=n.n(h),m=n(11),y=n.n(m),g=n(7),b=n.n(g),_=n(9),x=n.n(_),w=n(8),C={data:function(){return{states:{defaultExpandAll:!1,expandRows:[]}}},methods:{updateExpandRows:function(){var e=this.states,t=e.data,n=void 0===t?[]:t,r=e.rowKey,o=e.defaultExpandAll,i=e.expandRows;if(o)this.states.expandRows=n.slice();else if(r){var a=Object(w["f"])(i,r);this.states.expandRows=n.reduce((function(e,t){var n=Object(w["g"])(t,r),o=a[n];return o&&e.push(t),e}),[])}else this.states.expandRows=[]},toggleRowExpansion:function(e,t){var n=Object(w["m"])(this.states.expandRows,e,t);n&&(this.table.$emit("expand-change",e,this.states.expandRows.slice()),this.scheduleLayout())},setExpandRowKeys:function(e){this.assertRowKey();var t=this.states,n=t.data,r=t.rowKey,o=Object(w["f"])(n,r);this.states.expandRows=e.reduce((function(e,t){var n=o[t];return n&&e.push(n.row),e}),[])},isRowExpanded:function(e){var t=this.states,n=t.expandRows,r=void 0===n?[]:n,o=t.rowKey;if(o){var i=Object(w["f"])(r,o);return!!i[Object(w["g"])(e,o)]}return-1!==r.indexOf(e)}}},S=n(3),O={data:function(){return{states:{_currentRowKey:null,currentRow:null}}},methods:{setCurrentRowKey:function(e){this.assertRowKey(),this.states._currentRowKey=e,this.setCurrentRowByKey(e)},restoreCurrentRowKey:function(){this.states._currentRowKey=null},setCurrentRowByKey:function(e){var t=this.states,n=t.data,r=void 0===n?[]:n,o=t.rowKey,i=null;o&&(i=Object(S["arrayFind"])(r,(function(t){return Object(w["g"])(t,o)===e}))),t.currentRow=i},updateCurrentRow:function(e){var t=this.states,n=this.table,r=t.currentRow;if(e&&e!==r)return t.currentRow=e,void n.$emit("current-change",e,r);!e&&r&&(t.currentRow=null,n.$emit("current-change",null,r))},updateCurrentRowData:function(){var e=this.states,t=this.table,n=e.rowKey,r=e._currentRowKey,o=e.data||[],i=e.currentRow;if(-1===o.indexOf(i)&&i){if(n){var a=Object(w["g"])(i,n);this.setCurrentRowByKey(a)}else e.currentRow=null;null===e.currentRow&&t.$emit("current-change",null,i)}else r&&(this.setCurrentRowByKey(r),this.restoreCurrentRowKey())}}},E=Object.assign||function(e){for(var t=1;t0&&t[0]&&"selection"===t[0].type&&!t[0].fixed&&(t[0].fixed=!0,e.fixedColumns.unshift(t[0]));var n=t.filter((function(e){return!e.fixed}));e.originColumns=[].concat(e.fixedColumns).concat(n).concat(e.rightFixedColumns);var r=j(n),o=j(e.fixedColumns),i=j(e.rightFixedColumns);e.leafColumnsLength=r.length,e.fixedLeafColumnsLength=o.length,e.rightFixedLeafColumnsLength=i.length,e.columns=[].concat(o).concat(r).concat(i),e.isComplex=e.fixedColumns.length>0||e.rightFixedColumns.length>0},scheduleLayout:function(e){e&&this.updateColumns(),this.table.debouncedUpdateLayout()},isSelected:function(e){var t=this.states.selection,n=void 0===t?[]:t;return n.indexOf(e)>-1},clearSelection:function(){var e=this.states;e.isAllSelected=!1;var t=e.selection;t.length&&(e.selection=[],this.table.$emit("selection-change",[]))},cleanSelection:function(){var e=this.states,t=e.data,n=e.rowKey,r=e.selection,o=void 0;if(n){o=[];var i=Object(w["f"])(r,n),a=Object(w["f"])(t,n);for(var s in i)i.hasOwnProperty(s)&&!a[s]&&o.push(i[s].row)}else o=r.filter((function(e){return-1===t.indexOf(e)}));if(o.length){var l=r.filter((function(e){return-1===o.indexOf(e)}));e.selection=l,this.table.$emit("selection-change",l.slice())}},toggleRowSelection:function(e,t){var n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2],r=Object(w["m"])(this.states.selection,e,t);if(r){var o=(this.states.selection||[]).slice();n&&this.table.$emit("select",o,e),this.table.$emit("selection-change",o)}},_toggleAllSelection:function(){var e=this.states,t=e.data,n=void 0===t?[]:t,r=e.selection,o=e.selectOnIndeterminate?!e.isAllSelected:!(e.isAllSelected||r.length);e.isAllSelected=o;var i=!1;n.forEach((function(t,n){e.selectable?e.selectable.call(null,t,n)&&Object(w["m"])(r,t,o)&&(i=!0):Object(w["m"])(r,t,o)&&(i=!0)})),i&&this.table.$emit("selection-change",r?r.slice():[]),this.table.$emit("select-all",r)},updateSelectionByRowKey:function(){var e=this.states,t=e.selection,n=e.rowKey,r=e.data,o=Object(w["f"])(t,n);r.forEach((function(e){var r=Object(w["g"])(e,n),i=o[r];i&&(t[i.index]=e)}))},updateAllSelected:function(){var e=this.states,t=e.selection,n=e.rowKey,r=e.selectable,o=e.data||[];if(0!==o.length){var i=void 0;n&&(i=Object(w["f"])(t,n));for(var a=function(e){return i?!!i[Object(w["g"])(e,n)]:-1!==t.indexOf(e)},s=!0,l=0,u=0,c=o.length;u1?n-1:0),o=1;o1&&void 0!==arguments[1]?arguments[1]:{};if(!e)throw new Error("Table is required.");var n=new A;return n.table=e,n.toggleAllSelection=P()(10,n._toggleAllSelection),Object.keys(t).forEach((function(e){n.states[e]=t[e]})),n}function R(e){var t={};return Object.keys(e).forEach((function(n){var r=e[n],o=void 0;"string"===typeof r?o=function(){return this.store.states[r]}:"function"===typeof r?o=function(){return r.call(this,this.store.states)}:console.error("invalid value type"),o&&(t[n]=o)})),t}var N=n(38),I=n.n(N);function F(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}var H=function(){function e(t){for(var n in F(this,e),this.observers=[],this.table=null,this.store=null,this.columns=null,this.fit=!0,this.showHeader=!0,this.height=null,this.scrollX=!1,this.scrollY=!1,this.bodyWidth=null,this.fixedWidth=null,this.rightFixedWidth=null,this.tableHeight=null,this.headerHeight=44,this.appendHeight=0,this.footerHeight=44,this.viewportHeight=null,this.bodyHeight=null,this.fixedBodyHeight=null,this.gutterWidth=I()(),t)t.hasOwnProperty(n)&&(this[n]=t[n]);if(!this.table)throw new Error("table is required for Table Layout");if(!this.store)throw new Error("store is required for Table Layout")}return e.prototype.updateScrollY=function(){var e=this.height;if(null===e)return!1;var t=this.table.bodyWrapper;if(this.table.$el&&t){var n=t.querySelector(".el-table__body"),r=this.scrollY,o=n.offsetHeight>this.bodyHeight;return this.scrollY=o,r!==o}return!1},e.prototype.setHeight=function(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"height";if(!b.a.prototype.$isServer){var r=this.table.$el;if(e=Object(w["j"])(e),this.height=e,!r&&(e||0===e))return b.a.nextTick((function(){return t.setHeight(e,n)}));"number"===typeof e?(r.style[n]=e+"px",this.updateElsHeight()):"string"===typeof e&&(r.style[n]=e,this.updateElsHeight())}},e.prototype.setMaxHeight=function(e){this.setHeight(e,"max-height")},e.prototype.getFlattenColumns=function(){var e=[],t=this.table.columns;return t.forEach((function(t){t.isColumnGroup?e.push.apply(e,t.columns):e.push(t)})),e},e.prototype.updateElsHeight=function(){var e=this;if(!this.table.$ready)return b.a.nextTick((function(){return e.updateElsHeight()}));var t=this.table.$refs,n=t.headerWrapper,r=t.appendWrapper,o=t.footerWrapper;if(this.appendHeight=r?r.offsetHeight:0,!this.showHeader||n){var i=n?n.querySelector(".el-table__header tr"):null,a=this.headerDisplayNone(i),s=this.headerHeight=this.showHeader?n.offsetHeight:0;if(this.showHeader&&!a&&n.offsetWidth>0&&(this.table.columns||[]).length>0&&s<2)return b.a.nextTick((function(){return e.updateElsHeight()}));var l=this.tableHeight=this.table.$el.clientHeight,u=this.footerHeight=o?o.offsetHeight:0;null!==this.height&&(this.bodyHeight=l-s-u+(o?1:0)),this.fixedBodyHeight=this.scrollX?this.bodyHeight-this.gutterWidth:this.bodyHeight;var c=!(this.store.states.data&&this.store.states.data.length);this.viewportHeight=this.scrollX?l-(c?0:this.gutterWidth):l,this.updateScrollY(),this.notifyObservers("scrollable")}},e.prototype.headerDisplayNone=function(e){if(!e)return!0;var t=e;while("DIV"!==t.tagName){if("none"===getComputedStyle(t).display)return!0;t=t.parentElement}return!1},e.prototype.updateColumnsWidth=function(){if(!b.a.prototype.$isServer){var e=this.fit,t=this.table.$el.clientWidth,n=0,r=this.getFlattenColumns(),o=r.filter((function(e){return"number"!==typeof e.width}));if(r.forEach((function(e){"number"===typeof e.width&&e.realWidth&&(e.realWidth=null)})),o.length>0&&e){r.forEach((function(e){n+=e.width||e.minWidth||80}));var i=this.scrollY?this.gutterWidth:0;if(n<=t-i){this.scrollX=!1;var a=t-i-n;if(1===o.length)o[0].realWidth=(o[0].minWidth||80)+a;else{var s=o.reduce((function(e,t){return e+(t.minWidth||80)}),0),l=a/s,u=0;o.forEach((function(e,t){if(0!==t){var n=Math.floor((e.minWidth||80)*l);u+=n,e.realWidth=(e.minWidth||80)+n}})),o[0].realWidth=(o[0].minWidth||80)+a-u}}else this.scrollX=!0,o.forEach((function(e){e.realWidth=e.minWidth}));this.bodyWidth=Math.max(n,t),this.table.resizeState.width=this.bodyWidth}else r.forEach((function(e){e.width||e.minWidth?e.realWidth=e.width||e.minWidth:e.realWidth=80,n+=e.realWidth})),this.scrollX=n>t,this.bodyWidth=n;var c=this.store.states.fixedColumns;if(c.length>0){var f=0;c.forEach((function(e){f+=e.realWidth||e.width})),this.fixedWidth=f}var d=this.store.states.rightFixedColumns;if(d.length>0){var p=0;d.forEach((function(e){p+=e.realWidth||e.width})),this.rightFixedWidth=p}this.notifyObservers("columns")}},e.prototype.addObserver=function(e){this.observers.push(e)},e.prototype.removeObserver=function(e){var t=this.observers.indexOf(e);-1!==t&&this.observers.splice(t,1)},e.prototype.notifyObservers=function(e){var t=this,n=this.observers;n.forEach((function(n){switch(e){case"columns":n.onColumnsChange(t);break;case"scrollable":n.onScrollableChange(t);break;default:throw new Error("Table Layout don't have event "+e+".")}}))},e}(),D=H,z=n(2),B=n(29),W=n.n(B),V={created:function(){this.tableLayout.addObserver(this)},destroyed:function(){this.tableLayout.removeObserver(this)},computed:{tableLayout:function(){var e=this.layout;if(!e&&this.table&&(e=this.table.layout),!e)throw new Error("Can not find table layout.");return e}},mounted:function(){this.onColumnsChange(this.tableLayout),this.onScrollableChange(this.tableLayout)},updated:function(){this.__updated__||(this.onColumnsChange(this.tableLayout),this.onScrollableChange(this.tableLayout),this.__updated__=!0)},methods:{onColumnsChange:function(e){var t=this.$el.querySelectorAll("colgroup > col");if(t.length){var n=e.getFlattenColumns(),r={};n.forEach((function(e){r[e.id]=e}));for(var o=0,i=t.length;o col[name=gutter]"),n=0,r=t.length;n=this.leftFixedLeafCount:"right"===this.fixed?e=this.columnsCount-this.rightFixedLeafCount},getSpan:function(e,t,n,r){var o=1,i=1,a=this.table.spanMethod;if("function"===typeof a){var s=a({row:e,column:t,rowIndex:n,columnIndex:r});Array.isArray(s)?(o=s[0],i=s[1]):"object"===("undefined"===typeof s?"undefined":U(s))&&(o=s.rowspan,i=s.colspan)}return{rowspan:o,colspan:i}},getRowStyle:function(e,t){var n=this.table.rowStyle;return"function"===typeof n?n.call(null,{row:e,rowIndex:t}):n||null},getRowClass:function(e,t){var n=["el-table__row"];this.table.highlightCurrentRow&&e===this.store.states.currentRow&&n.push("current-row"),this.stripe&&t%2===1&&n.push("el-table__row--striped");var r=this.table.rowClassName;return"string"===typeof r?n.push(r):"function"===typeof r&&n.push(r.call(null,{row:e,rowIndex:t})),this.store.states.expandRows.indexOf(e)>-1&&n.push("expanded"),n},getCellStyle:function(e,t,n,r){var o=this.table.cellStyle;return"function"===typeof o?o.call(null,{rowIndex:e,columnIndex:t,row:n,column:r}):o},getCellClass:function(e,t,n,r){var o=[r.id,r.align,r.className];this.isColumnHidden(t)&&o.push("is-hidden");var i=this.table.cellClassName;return"string"===typeof i?o.push(i):"function"===typeof i&&o.push(i.call(null,{rowIndex:e,columnIndex:t,row:n,column:r})),o.join(" ")},getColspanRealWidth:function(e,t,n){if(t<1)return e[n].realWidth;var r=e.map((function(e){var t=e.realWidth;return t})).slice(n,n+t);return r.reduce((function(e,t){return e+t}),-1)},handleCellMouseEnter:function(e,t){var n=this.table,r=Object(w["b"])(e);if(r){var o=Object(w["c"])(n,r),i=n.hoverState={cell:r,column:o,row:t};n.$emit("cell-mouse-enter",i.row,i.column,i.cell,e)}var a=e.target.querySelector(".cell");if(Object(z["hasClass"])(a,"el-tooltip")&&a.childNodes.length){var s=document.createRange();s.setStart(a,0),s.setEnd(a,a.childNodes.length);var l=s.getBoundingClientRect().width,u=(parseInt(Object(z["getStyle"])(a,"paddingLeft"),10)||0)+(parseInt(Object(z["getStyle"])(a,"paddingRight"),10)||0);if((l+u>a.offsetWidth||a.scrollWidth>a.offsetWidth)&&this.$refs.tooltip){var c=this.$refs.tooltip;this.tooltipContent=r.innerText||r.textContent,c.referenceElm=r,c.$refs.popper&&(c.$refs.popper.style.display="none"),c.doDestroy(),c.setExpectedState(!0),this.activateTooltip(c)}}},handleCellMouseLeave:function(e){var t=this.$refs.tooltip;t&&(t.setExpectedState(!1),t.handleClosePopper());var n=Object(w["b"])(e);if(n){var r=this.table.hoverState||{};this.table.$emit("cell-mouse-leave",r.row,r.column,r.cell,e)}},handleMouseEnter:P()(30,(function(e){this.store.commit("setHoverRow",e)})),handleMouseLeave:P()(30,(function(){this.store.commit("setHoverRow",null)})),handleContextMenu:function(e,t){this.handleEvent(e,t,"contextmenu")},handleDoubleClick:function(e,t){this.handleEvent(e,t,"dblclick")},handleClick:function(e,t){this.store.commit("setCurrentRow",t),this.handleEvent(e,t,"click")},handleEvent:function(e,t,n){var r=this.table,o=Object(w["b"])(e),i=void 0;o&&(i=Object(w["c"])(r,o),i&&r.$emit("cell-"+n,t,i,o,e)),r.$emit("row-"+n,t,i,e)},rowRender:function(e,t,n){var r=this,o=this.$createElement,i=this.treeIndent,a=this.columns,s=this.firstDefaultColumnIndex,l=a.map((function(e,t){return r.isColumnHidden(t)})),u=this.getRowClass(e,t),c=!0;n&&(u.push("el-table__row--level-"+n.level),c=n.display);var f=c?null:{display:"none"};return o("tr",{style:[f,this.getRowStyle(e,t)],class:u,key:this.getKeyOfRow(e,t),on:{dblclick:function(t){return r.handleDoubleClick(t,e)},click:function(t){return r.handleClick(t,e)},contextmenu:function(t){return r.handleContextMenu(t,e)},mouseenter:function(e){return r.handleMouseEnter(t)},mouseleave:this.handleMouseLeave}},[a.map((function(u,c){var f=r.getSpan(e,u,t,c),d=f.rowspan,p=f.colspan;if(!d||!p)return null;var h=q({},u);h.realWidth=r.getColspanRealWidth(a,p,c);var v={store:r.store,_self:r.context||r.table.$vnode.context,column:h,row:e,$index:t};return c===s&&n&&(v.treeNode={indent:n.level*i,level:n.level},"boolean"===typeof n.expanded&&(v.treeNode.expanded=n.expanded,"loading"in n&&(v.treeNode.loading=n.loading),"noLazyChildren"in n&&(v.treeNode.noLazyChildren=n.noLazyChildren))),o("td",{style:r.getCellStyle(t,c,e,u),class:r.getCellClass(t,c,e,u),attrs:{rowspan:d,colspan:p},on:{mouseenter:function(t){return r.handleCellMouseEnter(t,e)},mouseleave:r.handleCellMouseLeave}},[u.renderCell.call(r._renderProxy,r.$createElement,v,l[c])])}))])},wrappedRowRender:function(e,t){var n=this,r=this.$createElement,o=this.store,i=o.isRowExpanded,a=o.assertRowKey,s=o.states,l=s.treeData,u=s.lazyTreeNodeMap,c=s.childrenColumnName,f=s.rowKey;if(this.hasExpandColumn&&i(e)){var d=this.table.renderExpanded,p=this.rowRender(e,t);return d?[[p,r("tr",{key:"expanded-row__"+p.key},[r("td",{attrs:{colspan:this.columnsCount},class:"el-table__expanded-cell"},[d(this.$createElement,{row:e,$index:t,store:this.store})])])]]:(console.error("[Element Error]renderExpanded is required."),p)}if(Object.keys(l).length){a();var h=Object(w["g"])(e,f),v=l[h],m=null;v&&(m={expanded:v.expanded,level:v.level,display:!0},"boolean"===typeof v.lazy&&("boolean"===typeof v.loaded&&v.loaded&&(m.noLazyChildren=!(v.children&&v.children.length)),m.loading=v.loading));var y=[this.rowRender(e,t,m)];if(v){var g=0,b=function e(r,o){r&&r.length&&o&&r.forEach((function(r){var i={display:o.display&&o.expanded,level:o.level+1},a=Object(w["g"])(r,f);if(void 0===a||null===a)throw new Error("for nested data item, row-key is required.");if(v=q({},l[a]),v&&(i.expanded=v.expanded,v.level=v.level||i.level,v.display=!(!v.expanded||!i.display),"boolean"===typeof v.lazy&&("boolean"===typeof v.loaded&&v.loaded&&(i.noLazyChildren=!(v.children&&v.children.length)),i.loading=v.loading)),g++,y.push(n.rowRender(r,t+g,i)),v){var s=u[a]||r[c];e(s,v)}}))};v.display=!0;var _=u[h]||e[c];b(_,v)}return y}return this.rowRender(e,t)}}},G=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("transition",{attrs:{name:"el-zoom-in-top"}},[e.multiple?n("div",{directives:[{name:"clickoutside",rawName:"v-clickoutside",value:e.handleOutsideClick,expression:"handleOutsideClick"},{name:"show",rawName:"v-show",value:e.showPopper,expression:"showPopper"}],staticClass:"el-table-filter"},[n("div",{staticClass:"el-table-filter__content"},[n("el-scrollbar",{attrs:{"wrap-class":"el-table-filter__wrap"}},[n("el-checkbox-group",{staticClass:"el-table-filter__checkbox-group",model:{value:e.filteredValue,callback:function(t){e.filteredValue=t},expression:"filteredValue"}},e._l(e.filters,(function(t){return n("el-checkbox",{key:t.value,attrs:{label:t.value}},[e._v(e._s(t.text))])})),1)],1)],1),n("div",{staticClass:"el-table-filter__bottom"},[n("button",{class:{"is-disabled":0===e.filteredValue.length},attrs:{disabled:0===e.filteredValue.length},on:{click:e.handleConfirm}},[e._v(e._s(e.t("el.table.confirmFilter")))]),n("button",{on:{click:e.handleReset}},[e._v(e._s(e.t("el.table.resetFilter")))])])]):n("div",{directives:[{name:"clickoutside",rawName:"v-clickoutside",value:e.handleOutsideClick,expression:"handleOutsideClick"},{name:"show",rawName:"v-show",value:e.showPopper,expression:"showPopper"}],staticClass:"el-table-filter"},[n("ul",{staticClass:"el-table-filter__list"},[n("li",{staticClass:"el-table-filter__list-item",class:{"is-active":void 0===e.filterValue||null===e.filterValue},on:{click:function(t){e.handleSelect(null)}}},[e._v(e._s(e.t("el.table.clearFilter")))]),e._l(e.filters,(function(t){return n("li",{key:t.value,staticClass:"el-table-filter__list-item",class:{"is-active":e.isActive(t)},attrs:{label:t.value},on:{click:function(n){e.handleSelect(t.value)}}},[e._v(e._s(t.text))])}))],2)])])},X=[];G._withStripped=!0;var Y=n(5),J=n.n(Y),Z=n(13),Q=n(12),ee=n.n(Q),te=[];!b.a.prototype.$isServer&&document.addEventListener("click",(function(e){te.forEach((function(t){var n=e.target;t&&t.$el&&(n===t.$el||t.$el.contains(n)||t.handleOutsideClick&&t.handleOutsideClick(e))}))}));var ne={open:function(e){e&&te.push(e)},close:function(e){var t=te.indexOf(e);-1!==t&&te.splice(e,1)}},re=n(39),oe=n.n(re),ie=n(15),ae=n.n(ie),se={name:"ElTableFilterPanel",mixins:[J.a,v.a],directives:{Clickoutside:ee.a},components:{ElCheckbox:a.a,ElCheckboxGroup:oe.a,ElScrollbar:ae.a},props:{placement:{type:String,default:"bottom-end"}},methods:{isActive:function(e){return e.value===this.filterValue},handleOutsideClick:function(){var e=this;setTimeout((function(){e.showPopper=!1}),16)},handleConfirm:function(){this.confirmFilter(this.filteredValue),this.handleOutsideClick()},handleReset:function(){this.filteredValue=[],this.confirmFilter(this.filteredValue),this.handleOutsideClick()},handleSelect:function(e){this.filterValue=e,"undefined"!==typeof e&&null!==e?this.confirmFilter(this.filteredValue):this.confirmFilter([]),this.handleOutsideClick()},confirmFilter:function(e){this.table.store.commit("filterChange",{column:this.column,values:e}),this.table.store.updateAllSelected()}},data:function(){return{table:null,cell:null,column:null}},computed:{filters:function(){return this.column&&this.column.filters},filterValue:{get:function(){return(this.column.filteredValue||[])[0]},set:function(e){this.filteredValue&&("undefined"!==typeof e&&null!==e?this.filteredValue.splice(0,1,e):this.filteredValue.splice(0,1))}},filteredValue:{get:function(){return this.column&&this.column.filteredValue||[]},set:function(e){this.column&&(this.column.filteredValue=e)}},multiple:function(){return!this.column||this.column.filterMultiple}},mounted:function(){var e=this;this.popperElm=this.$el,this.referenceElm=this.cell,this.table.bodyWrapper.addEventListener("scroll",(function(){e.updatePopper()})),this.$watch("showPopper",(function(t){e.column&&(e.column.filterOpened=t),t?ne.open(e):ne.close(e)}))},watch:{showPopper:function(e){!0===e&&parseInt(this.popperJS._popper.style.zIndex,10)1;return o&&(this.$parent.isGroup=!0),e("table",{class:"el-table__header",attrs:{cellspacing:"0",cellpadding:"0",border:"0"}},[e("colgroup",[this.columns.map((function(t){return e("col",{attrs:{name:t.id},key:t.id})})),this.hasGutter?e("col",{attrs:{name:"gutter"}}):""]),e("thead",{class:[{"is-group":o,"has-gutter":this.hasGutter}]},[this._l(r,(function(n,r){return e("tr",{style:t.getHeaderRowStyle(r),class:t.getHeaderRowClass(r)},[n.map((function(o,i){return e("th",{attrs:{colspan:o.colSpan,rowspan:o.rowSpan},on:{mousemove:function(e){return t.handleMouseMove(e,o)},mouseout:t.handleMouseOut,mousedown:function(e){return t.handleMouseDown(e,o)},click:function(e){return t.handleHeaderClick(e,o)},contextmenu:function(e){return t.handleHeaderContextMenu(e,o)}},style:t.getHeaderCellStyle(r,i,n,o),class:t.getHeaderCellClass(r,i,n,o),key:o.id},[e("div",{class:["cell",o.filteredValue&&o.filteredValue.length>0?"highlight":"",o.labelClassName]},[o.renderHeader?o.renderHeader.call(t._renderProxy,e,{column:o,$index:i,store:t.store,_self:t.$parent.$vnode.context}):o.label,o.sortable?e("span",{class:"caret-wrapper",on:{click:function(e){return t.handleSortClick(e,o)}}},[e("i",{class:"sort-caret ascending",on:{click:function(e){return t.handleSortClick(e,o,"ascending")}}}),e("i",{class:"sort-caret descending",on:{click:function(e){return t.handleSortClick(e,o,"descending")}}})]):"",o.filterable?e("span",{class:"el-table__column-filter-trigger",on:{click:function(e){return t.handleFilterClick(e,o)}}},[e("i",{class:["el-icon-arrow-down",o.filterOpened?"el-icon-arrow-up":""]})]):""])])})),t.hasGutter?e("th",{class:"gutter"}):""])}))])])},props:{fixed:String,store:{required:!0},border:Boolean,defaultSort:{type:Object,default:function(){return{prop:"",order:""}}}},components:{ElCheckbox:a.a},computed:de({table:function(){return this.$parent},hasGutter:function(){return!this.fixed&&this.tableLayout.gutterWidth}},R({columns:"columns",isAllSelected:"isAllSelected",leftFixedLeafCount:"fixedLeafColumnsLength",rightFixedLeafCount:"rightFixedLeafColumnsLength",columnsCount:function(e){return e.columns.length},leftFixedCount:function(e){return e.fixedColumns.length},rightFixedCount:function(e){return e.rightFixedColumns.length}})),created:function(){this.filterPanels={}},mounted:function(){var e=this;this.$nextTick((function(){var t=e.defaultSort,n=t.prop,r=t.order,o=!0;e.store.commit("sort",{prop:n,order:r,init:o})}))},beforeDestroy:function(){var e=this.filterPanels;for(var t in e)e.hasOwnProperty(t)&&e[t]&&e[t].$destroy(!0)},methods:{isCellHidden:function(e,t){for(var n=0,r=0;r=this.leftFixedLeafCount:"right"===this.fixed?n=this.columnsCount-this.rightFixedLeafCount},getHeaderRowStyle:function(e){var t=this.table.headerRowStyle;return"function"===typeof t?t.call(null,{rowIndex:e}):t},getHeaderRowClass:function(e){var t=[],n=this.table.headerRowClassName;return"string"===typeof n?t.push(n):"function"===typeof n&&t.push(n.call(null,{rowIndex:e})),t.join(" ")},getHeaderCellStyle:function(e,t,n,r){var o=this.table.headerCellStyle;return"function"===typeof o?o.call(null,{rowIndex:e,columnIndex:t,row:n,column:r}):o},getHeaderCellClass:function(e,t,n,r){var o=[r.id,r.order,r.headerAlign,r.className,r.labelClassName];0===e&&this.isCellHidden(t,n)&&o.push("is-hidden"),r.children||o.push("is-leaf"),r.sortable&&o.push("is-sortable");var i=this.table.headerCellClassName;return"string"===typeof i?o.push(i):"function"===typeof i&&o.push(i.call(null,{rowIndex:e,columnIndex:t,row:n,column:r})),o.join(" ")},toggleAllSelection:function(e){e.stopPropagation(),this.store.commit("toggleAllSelection")},handleFilterClick:function(e,t){e.stopPropagation();var n=e.target,r="TH"===n.tagName?n:n.parentNode;if(!Object(z["hasClass"])(r,"noclick")){r=r.querySelector(".el-table__column-filter-trigger")||r;var o=this.$parent,i=this.filterPanels[t.id];i&&t.filterOpened?i.showPopper=!1:(i||(i=new b.a(fe),this.filterPanels[t.id]=i,t.filterPlacement&&(i.placement=t.filterPlacement),i.table=o,i.cell=r,i.column=t,!this.$isServer&&i.$mount(document.createElement("div"))),setTimeout((function(){i.showPopper=!0}),16))}},handleHeaderClick:function(e,t){!t.filters&&t.sortable?this.handleSortClick(e,t):t.filterable&&!t.sortable&&this.handleFilterClick(e,t),this.$parent.$emit("header-click",t,e)},handleHeaderContextMenu:function(e,t){this.$parent.$emit("header-contextmenu",t,e)},handleMouseDown:function(e,t){var n=this;if(!this.$isServer&&!(t.children&&t.children.length>0)&&this.draggingColumn&&this.border){this.dragging=!0,this.$parent.resizeProxyVisible=!0;var r=this.$parent,o=r.$el,i=o.getBoundingClientRect().left,a=this.$el.querySelector("th."+t.id),s=a.getBoundingClientRect(),l=s.left-i+30;Object(z["addClass"])(a,"noclick"),this.dragState={startMouseLeft:e.clientX,startLeft:s.right-i,startColumnLeft:s.left-i,tableLeft:i};var u=r.$refs.resizeProxy;u.style.left=this.dragState.startLeft+"px",document.onselectstart=function(){return!1},document.ondragstart=function(){return!1};var c=function(e){var t=e.clientX-n.dragState.startMouseLeft,r=n.dragState.startLeft+t;u.style.left=Math.max(l,r)+"px"},f=function o(){if(n.dragging){var i=n.dragState,s=i.startColumnLeft,l=i.startLeft,f=parseInt(u.style.left,10),d=f-s;t.width=t.realWidth=d,r.$emit("header-dragend",t.width,l-s,t,e),n.store.scheduleLayout(),document.body.style.cursor="",n.dragging=!1,n.draggingColumn=null,n.dragState={},r.resizeProxyVisible=!1}document.removeEventListener("mousemove",c),document.removeEventListener("mouseup",o),document.onselectstart=null,document.ondragstart=null,setTimeout((function(){Object(z["removeClass"])(a,"noclick")}),0)};document.addEventListener("mousemove",c),document.addEventListener("mouseup",f)}},handleMouseMove:function(e,t){if(!(t.children&&t.children.length>0)){var n=e.target;while(n&&"TH"!==n.tagName)n=n.parentNode;if(t&&t.resizable&&!this.dragging&&this.border){var r=n.getBoundingClientRect(),o=document.body.style;r.width>12&&r.right-e.pageX<8?(o.cursor="col-resize",Object(z["hasClass"])(n,"is-sortable")&&(n.style.cursor="col-resize"),this.draggingColumn=t):this.dragging||(o.cursor="",Object(z["hasClass"])(n,"is-sortable")&&(n.style.cursor="pointer"),this.draggingColumn=null)}}},handleMouseOut:function(){this.$isServer||(document.body.style.cursor="")},toggleOrder:function(e){var t=e.order,n=e.sortOrders;if(""===t)return n[0];var r=n.indexOf(t||null);return n[r>n.length-2?0:r+1]},handleSortClick:function(e,t,n){e.stopPropagation();var r=t.order===n?null:n||this.toggleOrder(t),o=e.target;while(o&&"TH"!==o.tagName)o=o.parentNode;if(o&&"TH"===o.tagName&&Object(z["hasClass"])(o,"noclick"))Object(z["removeClass"])(o,"noclick");else if(t.sortable){var i=this.store.states,a=i.sortProp,s=void 0,l=i.sortingColumn;(l!==t||l===t&&null===l.order)&&(l&&(l.order=null),i.sortingColumn=t,a=t.property),s=t.order=r||null,i.sortProp=a,i.sortOrder=s,this.store.commit("changeSortCondition")}}},data:function(){return{draggingColumn:null,dragging:!1,dragState:{}}}},me=Object.assign||function(e){for(var t=1;t=this.leftFixedLeafCount;if("right"===this.fixed){for(var r=0,o=0;o=this.columnsCount-this.rightFixedCount)},getRowClasses:function(e,t){var n=[e.id,e.align,e.labelClassName];return e.className&&n.push(e.className),this.isCellHidden(t,this.columns,e)&&n.push("is-hidden"),e.children||n.push("is-leaf"),n}}},ge=Object.assign||function(e){for(var t=1;t0){var r=n.scrollTop;t.pixelY<0&&0!==r&&e.preventDefault(),t.pixelY>0&&n.scrollHeight-n.clientHeight>r&&e.preventDefault(),n.scrollTop+=Math.ceil(t.pixelY/5)}else n.scrollLeft+=Math.ceil(t.pixelX/5)},handleHeaderFooterMousewheel:function(e,t){var n=t.pixelX,r=t.pixelY;Math.abs(n)>=Math.abs(r)&&(this.bodyWrapper.scrollLeft+=t.pixelX/5)},syncPostion:Object(s["throttle"])(20,(function(){var e=this.bodyWrapper,t=e.scrollLeft,n=e.scrollTop,r=e.offsetWidth,o=e.scrollWidth,i=this.$refs,a=i.headerWrapper,s=i.footerWrapper,l=i.fixedBodyWrapper,u=i.rightFixedBodyWrapper;a&&(a.scrollLeft=t),s&&(s.scrollLeft=t),l&&(l.scrollTop=n),u&&(u.scrollTop=n);var c=o-r-1;this.scrollPosition=t>=c?"right":0===t?"left":"middle"})),bindEvents:function(){this.bodyWrapper.addEventListener("scroll",this.syncPostion,{passive:!0}),this.fit&&Object(l["addResizeListener"])(this.$el,this.resizeListener)},unbindEvents:function(){this.bodyWrapper.removeEventListener("scroll",this.syncPostion,{passive:!0}),this.fit&&Object(l["removeResizeListener"])(this.$el,this.resizeListener)},resizeListener:function(){if(this.$ready){var e=!1,t=this.$el,n=this.resizeState,r=n.width,o=n.height,i=t.offsetWidth;r!==i&&(e=!0);var a=t.offsetHeight;(this.height||this.shouldUpdateHeight)&&o!==a&&(e=!0),e&&(this.resizeState.width=i,this.resizeState.height=a,this.doLayout())}},doLayout:function(){this.shouldUpdateHeight&&this.layout.updateElsHeight(),this.layout.updateColumnsWidth()},sort:function(e,t){this.store.commit("sort",{prop:e,order:t})},toggleAllSelection:function(){this.store.commit("toggleAllSelection")}},computed:ge({tableSize:function(){return this.size||(this.$ELEMENT||{}).size},bodyWrapper:function(){return this.$refs.bodyWrapper},shouldUpdateHeight:function(){return this.height||this.maxHeight||this.fixedColumns.length>0||this.rightFixedColumns.length>0},bodyWidth:function(){var e=this.layout,t=e.bodyWidth,n=e.scrollY,r=e.gutterWidth;return t?t-(n?r:0)+"px":""},bodyHeight:function(){var e=this.layout,t=e.headerHeight,n=void 0===t?0:t,r=e.bodyHeight,o=e.footerHeight,i=void 0===o?0:o;if(this.height)return{height:r?r+"px":""};if(this.maxHeight){var a=Object(w["j"])(this.maxHeight);if("number"===typeof a)return{"max-height":a-i-(this.showHeader?n:0)+"px"}}return{}},fixedBodyHeight:function(){if(this.height)return{height:this.layout.fixedBodyHeight?this.layout.fixedBodyHeight+"px":""};if(this.maxHeight){var e=Object(w["j"])(this.maxHeight);if("number"===typeof e)return e=this.layout.scrollX?e-this.layout.gutterWidth:e,this.showHeader&&(e-=this.layout.headerHeight),e-=this.layout.footerHeight,{"max-height":e+"px"}}return{}},fixedHeight:function(){return this.maxHeight?this.showSummary?{bottom:0}:{bottom:this.layout.scrollX&&this.data.length?this.layout.gutterWidth+"px":""}:this.showSummary?{height:this.layout.tableHeight?this.layout.tableHeight+"px":""}:{height:this.layout.viewportHeight?this.layout.viewportHeight+"px":""}},emptyBlockStyle:function(){if(this.data&&this.data.length)return null;var e="100%";return this.layout.appendHeight&&(e="calc(100% - "+this.layout.appendHeight+"px)"),{width:this.bodyWidth,height:e}}},R({selection:"selection",columns:"columns",tableData:"data",fixedColumns:"fixedColumns",rightFixedColumns:"rightFixedColumns"})),watch:{height:{immediate:!0,handler:function(e){this.layout.setHeight(e)}},maxHeight:{immediate:!0,handler:function(e){this.layout.setMaxHeight(e)}},currentRowKey:{immediate:!0,handler:function(e){this.rowKey&&this.store.setCurrentRowKey(e)}},data:{immediate:!0,handler:function(e){this.store.commit("setData",e)}},expandRowKeys:{immediate:!0,handler:function(e){e&&this.store.setExpandRowKeysAdapter(e)}}},created:function(){var e=this;this.tableId="el-table_"+be++,this.debouncedUpdateLayout=Object(s["debounce"])(50,(function(){return e.doLayout()}))},mounted:function(){var e=this;this.bindEvents(),this.store.updateColumns(),this.doLayout(),this.resizeState={width:this.$el.offsetWidth,height:this.$el.offsetHeight},this.store.states.columns.forEach((function(t){t.filteredValue&&t.filteredValue.length&&e.store.commit("filterChange",{column:t,values:t.filteredValue,silent:!0})})),this.$ready=!0},destroyed:function(){this.unbindEvents()},data:function(){var e=this.treeProps,t=e.hasChildren,n=void 0===t?"hasChildren":t,r=e.children,o=void 0===r?"children":r;this.store=L(this,{rowKey:this.rowKey,defaultExpandAll:this.defaultExpandAll,selectOnIndeterminate:this.selectOnIndeterminate,indent:this.indent,lazy:this.lazy,lazyColumnIdentifier:n,childrenColumnName:o});var i=new D({store:this.store,table:this,fit:this.fit,showHeader:this.showHeader});return{layout:i,isHidden:!1,renderExpanded:null,resizeProxyVisible:!1,resizeState:{width:null,height:null},isGroup:!1,scrollPosition:"left"}}},xe=_e,we=Object(ue["a"])(xe,r,o,!1,null,null,null);we.options.__file="packages/table/src/table.vue";var Ce=we.exports;Ce.install=function(e){e.component(Ce.name,Ce)};t["default"]=Ce}])},ad6d:function(e,t,n){"use strict";var r=n("825a");e.exports=function(){var e=r(this),t="";return e.global&&(t+="g"),e.ignoreCase&&(t+="i"),e.multiline&&(t+="m"),e.dotAll&&(t+="s"),e.unicode&&(t+="u"),e.sticky&&(t+="y"),t}},ae93:function(e,t,n){"use strict";var r,o,i,a=n("d039"),s=n("e163"),l=n("9112"),u=n("5135"),c=n("b622"),f=n("c430"),d=c("iterator"),p=!1,h=function(){return this};[].keys&&(i=[].keys(),"next"in i?(o=s(s(i)),o!==Object.prototype&&(r=o)):p=!0);var v=void 0==r||a((function(){var e={};return r[d].call(e)!==e}));v&&(r={}),f&&!v||u(r,d)||l(r,d,h),e.exports={IteratorPrototype:r,BUGGY_SAFARI_ITERATORS:p}},b041:function(e,t,n){"use strict";var r=n("00ee"),o=n("f5df");e.exports=r?{}.toString:function(){return"[object "+o(this)+"]"}},b575:function(e,t,n){var r,o,i,a,s,l,u,c,f=n("da84"),d=n("06cf").f,p=n("2cf4").set,h=n("1cdc"),v=n("a4b4"),m=n("605d"),y=f.MutationObserver||f.WebKitMutationObserver,g=f.document,b=f.process,_=f.Promise,x=d(f,"queueMicrotask"),w=x&&x.value;w||(r=function(){var e,t;m&&(e=b.domain)&&e.exit();while(o){t=o.fn,o=o.next;try{t()}catch(n){throw o?a():i=void 0,n}}i=void 0,e&&e.enter()},h||m||v||!y||!g?_&&_.resolve?(u=_.resolve(void 0),u.constructor=_,c=u.then,a=function(){c.call(u,r)}):a=m?function(){b.nextTick(r)}:function(){p.call(f,r)}:(s=!0,l=g.createTextNode(""),new y(r).observe(l,{characterData:!0}),a=function(){l.data=s=!s})),e.exports=w||function(e){var t={fn:e,next:void 0};i&&(i.next=t),o||(o=t,a()),i=t}},b622:function(e,t,n){var r=n("da84"),o=n("5692"),i=n("5135"),a=n("90e3"),s=n("4930"),l=n("fdbf"),u=o("wks"),c=r.Symbol,f=l?c:c&&c.withoutSetter||a;e.exports=function(e){return i(u,e)&&(s||"string"==typeof u[e])||(s&&i(c,e)?u[e]=c[e]:u[e]=f("Symbol."+e)),u[e]}},b84d:function(e,t,n){},be4f:function(e,t,n){},c04e:function(e,t,n){var r=n("861d");e.exports=function(e,t){if(!r(e))return e;var n,o;if(t&&"function"==typeof(n=e.toString)&&!r(o=n.call(e)))return o;if("function"==typeof(n=e.valueOf)&&!r(o=n.call(e)))return o;if(!t&&"function"==typeof(n=e.toString)&&!r(o=n.call(e)))return o;throw TypeError("Can't convert object to primitive value")}},c098:function(e,t,n){e.exports=n("d4af")},c216:function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=106)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},106:function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("span",{staticClass:"el-breadcrumb__item"},[n("span",{ref:"link",class:["el-breadcrumb__inner",e.to?"is-link":""],attrs:{role:"link"}},[e._t("default")],2),e.separatorClass?n("i",{staticClass:"el-breadcrumb__separator",class:e.separatorClass}):n("span",{staticClass:"el-breadcrumb__separator",attrs:{role:"presentation"}},[e._v(e._s(e.separator))])])},o=[];r._withStripped=!0;var i={name:"ElBreadcrumbItem",props:{to:{},replace:Boolean},data:function(){return{separator:"",separatorClass:""}},inject:["elBreadcrumb"],mounted:function(){var e=this;this.separator=this.elBreadcrumb.separator,this.separatorClass=this.elBreadcrumb.separatorClass;var t=this.$refs.link;t.setAttribute("role","link"),t.addEventListener("click",(function(t){var n=e.to,r=e.$router;n&&r&&(e.replace?r.replace(n):r.push(n))}))}},a=i,s=n(0),l=Object(s["a"])(a,r,o,!1,null,null,null);l.options.__file="packages/breadcrumb/src/breadcrumb-item.vue";var u=l.exports;u.install=function(e){e.component(u.name,u)};t["default"]=u}})},c430:function(e,t){e.exports=!1},c56a:function(e,t,n){"use strict";t.__esModule=!0,t.default=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:300,r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(!e||!t)throw new Error("instance & callback is required");var o=!1,i=function(){o||(o=!0,t&&t.apply(null,arguments))};r?e.$once("after-leave",i):e.$on("after-leave",i),setTimeout((function(){i()}),n+100)}},c6b6:function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},c6cd:function(e,t,n){var r=n("da84"),o=n("ce4e"),i="__core-js_shared__",a=r[i]||o(i,{});e.exports=a},c8ba:function(e,t){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(r){"object"===typeof window&&(n=window)}e.exports=n},ca84:function(e,t,n){var r=n("5135"),o=n("fc6a"),i=n("4d64").indexOf,a=n("d012");e.exports=function(e,t){var n,s=o(e),l=0,u=[];for(n in s)!r(a,n)&&r(s,n)&&u.push(n);while(t.length>l)r(s,n=t[l++])&&(~i(u,n)||u.push(n));return u}},cbb5:function(e,t,n){},cc12:function(e,t,n){var r=n("da84"),o=n("861d"),i=r.document,a=o(i)&&o(i.createElement);e.exports=function(e){return a?i.createElement(e):{}}},cca6:function(e,t,n){var r=n("23e7"),o=n("60da");r({target:"Object",stat:!0,forced:Object.assign!==o},{assign:o})},cdf9:function(e,t,n){var r=n("825a"),o=n("861d"),i=n("f069");e.exports=function(e,t){if(r(e),o(t)&&t.constructor===e)return t;var n=i.f(e),a=n.resolve;return a(t),n.promise}},ce4e:function(e,t,n){var r=n("da84"),o=n("9112");e.exports=function(e,t){try{o(r,e,t)}catch(n){r[e]=t}return t}},d010:function(e,t,n){"use strict";function r(e,t,n){this.$children.forEach((function(o){var i=o.$options.componentName;i===e?o.$emit.apply(o,[t].concat(n)):r.apply(o,[e,t].concat([n]))}))}t.__esModule=!0,t.default={methods:{dispatch:function(e,t,n){var r=this.$parent||this.$root,o=r.$options.componentName;while(r&&(!o||o!==e))r=r.$parent,r&&(o=r.$options.componentName);r&&r.$emit.apply(r,[t].concat(n))},broadcast:function(e,t,n){r.call(this,e,t,n)}}}},d012:function(e,t){e.exports={}},d039:function(e,t){e.exports=function(e){try{return!!e()}catch(t){return!0}}},d066:function(e,t,n){var r=n("428f"),o=n("da84"),i=function(e){return"function"==typeof e?e:void 0};e.exports=function(e,t){return arguments.length<2?i(r[e])||i(o[e]):r[e]&&r[e][t]||o[e]&&o[e][t]}},d1e7:function(e,t,n){"use strict";var r={}.propertyIsEnumerable,o=Object.getOwnPropertyDescriptor,i=o&&!r.call({1:2},1);t.f=i?function(e){var t=o(this,e);return!!t&&t.enumerable}:r},d2bb:function(e,t,n){var r=n("825a"),o=n("3bbe");e.exports=Object.setPrototypeOf||("__proto__"in{}?function(){var e,t=!1,n={};try{e=Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set,e.call(n,[]),t=n instanceof Array}catch(i){}return function(n,i){return r(n),o(i),t?e.call(n,i):n.__proto__=i,n}}():void 0)},d397:function(e,t,n){"use strict";function r(e){return void 0!==e&&null!==e}function o(e){var t=/([(\uAC00-\uD7AF)|(\u3130-\u318F)])+/gi;return t.test(e)}t.__esModule=!0,t.isDef=r,t.isKorean=o},d3b7:function(e,t,n){var r=n("00ee"),o=n("6eeb"),i=n("b041");r||o(Object.prototype,"toString",i,{unsafe:!0})},d44e:function(e,t,n){var r=n("9bf2").f,o=n("5135"),i=n("b622"),a=i("toStringTag");e.exports=function(e,t,n){e&&!o(e=n?e:e.prototype,a)&&r(e,a,{configurable:!0,value:t})}},d4af:function(e,t,n){"use strict";var r=n("8eb7"),o=n("7b3e"),i=10,a=40,s=800;function l(e){var t=0,n=0,r=0,o=0;return"detail"in e&&(n=e.detail),"wheelDelta"in e&&(n=-e.wheelDelta/120),"wheelDeltaY"in e&&(n=-e.wheelDeltaY/120),"wheelDeltaX"in e&&(t=-e.wheelDeltaX/120),"axis"in e&&e.axis===e.HORIZONTAL_AXIS&&(t=n,n=0),r=t*i,o=n*i,"deltaY"in e&&(o=e.deltaY),"deltaX"in e&&(r=e.deltaX),(r||o)&&e.deltaMode&&(1==e.deltaMode?(r*=a,o*=a):(r*=s,o*=s)),r&&!t&&(t=r<1?-1:1),o&&!n&&(n=o<1?-1:1),{spinX:t,spinY:n,pixelX:r,pixelY:o}}l.getEventType=function(){return r.firefox()?"DOMMouseScroll":o("wheel")?"wheel":"mousewheel"},e.exports=l},da84:function(e,t,n){(function(t){var n=function(e){return e&&e.Math==Math&&e};e.exports=n("object"==typeof globalThis&&globalThis)||n("object"==typeof window&&window)||n("object"==typeof self&&self)||n("object"==typeof t&&t)||function(){return this}()||Function("return this")()}).call(this,n("c8ba"))},dcdc:function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=119)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},119:function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("label",{staticClass:"el-checkbox",class:[e.border&&e.checkboxSize?"el-checkbox--"+e.checkboxSize:"",{"is-disabled":e.isDisabled},{"is-bordered":e.border},{"is-checked":e.isChecked}],attrs:{id:e.id}},[n("span",{staticClass:"el-checkbox__input",class:{"is-disabled":e.isDisabled,"is-checked":e.isChecked,"is-indeterminate":e.indeterminate,"is-focus":e.focus},attrs:{tabindex:!!e.indeterminate&&0,role:!!e.indeterminate&&"checkbox","aria-checked":!!e.indeterminate&&"mixed"}},[n("span",{staticClass:"el-checkbox__inner"}),e.trueLabel||e.falseLabel?n("input",{directives:[{name:"model",rawName:"v-model",value:e.model,expression:"model"}],staticClass:"el-checkbox__original",attrs:{type:"checkbox","aria-hidden":e.indeterminate?"true":"false",name:e.name,disabled:e.isDisabled,"true-value":e.trueLabel,"false-value":e.falseLabel},domProps:{checked:Array.isArray(e.model)?e._i(e.model,null)>-1:e._q(e.model,e.trueLabel)},on:{change:[function(t){var n=e.model,r=t.target,o=r.checked?e.trueLabel:e.falseLabel;if(Array.isArray(n)){var i=null,a=e._i(n,i);r.checked?a<0&&(e.model=n.concat([i])):a>-1&&(e.model=n.slice(0,a).concat(n.slice(a+1)))}else e.model=o},e.handleChange],focus:function(t){e.focus=!0},blur:function(t){e.focus=!1}}}):n("input",{directives:[{name:"model",rawName:"v-model",value:e.model,expression:"model"}],staticClass:"el-checkbox__original",attrs:{type:"checkbox","aria-hidden":e.indeterminate?"true":"false",disabled:e.isDisabled,name:e.name},domProps:{value:e.label,checked:Array.isArray(e.model)?e._i(e.model,e.label)>-1:e.model},on:{change:[function(t){var n=e.model,r=t.target,o=!!r.checked;if(Array.isArray(n)){var i=e.label,a=e._i(n,i);r.checked?a<0&&(e.model=n.concat([i])):a>-1&&(e.model=n.slice(0,a).concat(n.slice(a+1)))}else e.model=o},e.handleChange],focus:function(t){e.focus=!0},blur:function(t){e.focus=!1}}})]),e.$slots.default||e.label?n("span",{staticClass:"el-checkbox__label"},[e._t("default"),e.$slots.default?e._e():[e._v(e._s(e.label))]],2):e._e()])},o=[];r._withStripped=!0;var i=n(4),a=n.n(i),s={name:"ElCheckbox",mixins:[a.a],inject:{elForm:{default:""},elFormItem:{default:""}},componentName:"ElCheckbox",data:function(){return{selfModel:!1,focus:!1,isLimitExceeded:!1}},computed:{model:{get:function(){return this.isGroup?this.store:void 0!==this.value?this.value:this.selfModel},set:function(e){this.isGroup?(this.isLimitExceeded=!1,void 0!==this._checkboxGroup.min&&e.lengththis._checkboxGroup.max&&(this.isLimitExceeded=!0),!1===this.isLimitExceeded&&this.dispatch("ElCheckboxGroup","input",[e])):(this.$emit("input",e),this.selfModel=e)}},isChecked:function(){return"[object Boolean]"==={}.toString.call(this.model)?this.model:Array.isArray(this.model)?this.model.indexOf(this.label)>-1:null!==this.model&&void 0!==this.model?this.model===this.trueLabel:void 0},isGroup:function(){var e=this.$parent;while(e){if("ElCheckboxGroup"===e.$options.componentName)return this._checkboxGroup=e,!0;e=e.$parent}return!1},store:function(){return this._checkboxGroup?this._checkboxGroup.value:this.value},isLimitDisabled:function(){var e=this._checkboxGroup,t=e.max,n=e.min;return!(!t&&!n)&&this.model.length>=t&&!this.isChecked||this.model.length<=n&&this.isChecked},isDisabled:function(){return this.isGroup?this._checkboxGroup.disabled||this.disabled||(this.elForm||{}).disabled||this.isLimitDisabled:this.disabled||(this.elForm||{}).disabled},_elFormItemSize:function(){return(this.elFormItem||{}).elFormItemSize},checkboxSize:function(){var e=this.size||this._elFormItemSize||(this.$ELEMENT||{}).size;return this.isGroup&&this._checkboxGroup.checkboxGroupSize||e}},props:{value:{},label:{},indeterminate:Boolean,disabled:Boolean,checked:Boolean,name:String,trueLabel:[String,Number],falseLabel:[String,Number],id:String,controls:String,border:Boolean,size:String},methods:{addToStore:function(){Array.isArray(this.model)&&-1===this.model.indexOf(this.label)?this.model.push(this.label):this.model=this.trueLabel||!0},handleChange:function(e){var t=this;if(!this.isLimitExceeded){var n=void 0;n=e.target.checked?void 0===this.trueLabel||this.trueLabel:void 0!==this.falseLabel&&this.falseLabel,this.$emit("change",n,e),this.$nextTick((function(){t.isGroup&&t.dispatch("ElCheckboxGroup","change",[t._checkboxGroup.value])}))}}},created:function(){this.checked&&this.addToStore()},mounted:function(){this.indeterminate&&this.$el.setAttribute("aria-controls",this.controls)},watch:{value:function(e){this.dispatch("ElFormItem","el.form.change",e)}}},l=s,u=n(0),c=Object(u["a"])(l,r,o,!1,null,null,null);c.options.__file="packages/checkbox/src/checkbox.vue";var f=c.exports;f.install=function(e){e.component(f.name,f)};t["default"]=f},4:function(e,t){e.exports=n("d010")}})},ddb0:function(e,t,n){var r=n("da84"),o=n("fdbc"),i=n("e260"),a=n("9112"),s=n("b622"),l=s("iterator"),u=s("toStringTag"),c=i.values;for(var f in o){var d=r[f],p=d&&d.prototype;if(p){if(p[l]!==c)try{a(p,l,c)}catch(v){p[l]=c}if(p[u]||a(p,u,f),o[f])for(var h in i)if(p[h]!==i[h])try{a(p,h,i[h])}catch(v){p[h]=i[h]}}}},df75:function(e,t,n){var r=n("ca84"),o=n("7839");e.exports=Object.keys||function(e){return r(e,o)}},e163:function(e,t,n){var r=n("5135"),o=n("7b0b"),i=n("f772"),a=n("e177"),s=i("IE_PROTO"),l=Object.prototype;e.exports=a?Object.getPrototypeOf:function(e){return e=o(e),r(e,s)?e[s]:"function"==typeof e.constructor&&e instanceof e.constructor?e.constructor.prototype:e instanceof Object?l:null}},e177:function(e,t,n){var r=n("d039");e.exports=!r((function(){function e(){}return e.prototype.constructor=null,Object.getPrototypeOf(new e)!==e.prototype}))},e260:function(e,t,n){"use strict";var r=n("fc6a"),o=n("44d2"),i=n("3f8c"),a=n("69f3"),s=n("7dd0"),l="Array Iterator",u=a.set,c=a.getterFor(l);e.exports=s(Array,"Array",(function(e,t){u(this,{type:l,target:r(e),index:0,kind:t})}),(function(){var e=c(this),t=e.target,n=e.kind,r=e.index++;return!t||r>=t.length?(e.target=void 0,{value:void 0,done:!0}):"keys"==n?{value:r,done:!1}:"values"==n?{value:t[r],done:!1}:{value:[r,t[r]],done:!1}}),"values"),i.Arguments=i.Array,o("keys"),o("values"),o("entries")},e2cc:function(e,t,n){var r=n("6eeb");e.exports=function(e,t,n){for(var o in t)r(e,o,t[o],n);return e}},e3ea:function(e,t,n){},e452:function(e,t,n){"use strict";t.__esModule=!0;var r=r||{};r.Utils=r.Utils||{},r.Utils.focusFirstDescendant=function(e){for(var t=0;t=0;t--){var n=e.childNodes[t];if(r.Utils.attemptFocus(n)||r.Utils.focusLastDescendant(n))return!0}return!1},r.Utils.attemptFocus=function(e){if(!r.Utils.isFocusable(e))return!1;r.Utils.IgnoreUtilFocusChanges=!0;try{e.focus()}catch(t){}return r.Utils.IgnoreUtilFocusChanges=!1,document.activeElement===e},r.Utils.isFocusable=function(e){if(e.tabIndex>0||0===e.tabIndex&&null!==e.getAttribute("tabIndex"))return!0;if(e.disabled)return!1;switch(e.nodeName){case"A":return!!e.href&&"ignore"!==e.rel;case"INPUT":return"hidden"!==e.type&&"file"!==e.type;case"BUTTON":case"SELECT":case"TEXTAREA":return!0;default:return!1}},r.Utils.triggerEvent=function(e,t){var n=void 0;n=/^mouse|click/.test(t)?"MouseEvents":/^key/.test(t)?"KeyboardEvent":"HTMLEvents";for(var r=document.createEvent(n),o=arguments.length,i=Array(o>2?o-2:0),a=2;a=51&&/native code/.test(e))return!1;var n=new B((function(e){e(1)})),r=function(e){e((function(){}),(function(){}))},o=n.constructor={};return o[N]=r,oe=n.then((function(){}))instanceof r,!oe||!t&&P&&!Y})),ae=ie||!w((function(e){B.all(e)["catch"]((function(){}))})),se=function(e){var t;return!(!y(e)||"function"!=typeof(t=e.then))&&t},le=function(e,t){if(!e.notified){e.notified=!0;var n=e.reactions;O((function(){var r=e.value,o=e.state==ee,i=0;while(n.length>i){var a,s,l,u=n[i++],c=o?u.ok:u.fail,f=u.resolve,d=u.reject,p=u.domain;try{c?(o||(e.rejection===re&&de(e),e.rejection=ne),!0===c?a=r:(p&&p.enter(),a=c(r),p&&(p.exit(),l=!0)),a===u.promise?d(V("Promise-chain cycle")):(s=se(a))?s.call(a,f,d):f(a)):d(r)}catch(h){p&&!l&&p.exit(),d(h)}}e.reactions=[],e.notified=!1,t&&!e.rejection&&ce(e)}))}},ue=function(e,t,n){var r,o;X?(r=U.createEvent("Event"),r.promise=t,r.reason=n,r.initEvent(e,!1,!0),u.dispatchEvent(r)):r={promise:t,reason:n},!Y&&(o=u["on"+e])?o(r):e===J&&k("Unhandled promise rejection",n)},ce=function(e){S.call(u,(function(){var t,n=e.facade,r=e.value,o=fe(e);if(o&&(t=j((function(){L?q.emit("unhandledRejection",r,n):ue(J,n,r)})),e.rejection=L||fe(e)?re:ne,t.error))throw t.value}))},fe=function(e){return e.rejection!==ne&&!e.parent},de=function(e){S.call(u,(function(){var t=e.facade;L?q.emit("rejectionHandled",t):ue(Z,t,e.value)}))},pe=function(e,t,n){return function(r){e(t,r,n)}},he=function(e,t,n){e.done||(e.done=!0,n&&(e=n),e.value=t,e.state=te,le(e,!0))},ve=function(e,t,n){if(!e.done){e.done=!0,n&&(e=n);try{if(e.facade===t)throw V("Promise can't be resolved itself");var r=se(t);r?O((function(){var n={done:!1};try{r.call(t,pe(ve,n,e),pe(he,n,e))}catch(o){he(n,o,e)}})):(e.value=t,e.state=ee,le(e,!1))}catch(o){he({done:!1},o,e)}}};if(ie&&(B=function(e){b(this,B,I),g(e),r.call(this);var t=F(this);try{e(pe(ve,t),pe(he,t))}catch(n){he(t,n)}},W=B.prototype,r=function(e){H(this,{type:I,done:!1,notified:!1,parent:!1,reactions:[],rejection:!1,state:Q,value:void 0})},r.prototype=p(W,{then:function(e,t){var n=D(this),r=K(C(this,B));return r.ok="function"!=typeof e||e,r.fail="function"==typeof t&&t,r.domain=L?q.domain:void 0,n.parent=!0,n.reactions.push(r),n.state!=Q&&le(n,!1),r.promise},catch:function(e){return this.then(void 0,e)}}),o=function(){var e=new r,t=F(e);this.promise=e,this.resolve=pe(ve,t),this.reject=pe(he,t)},$.f=K=function(e){return e===B||e===i?new o(e):G(e)},!l&&"function"==typeof f&&z!==Object.prototype)){a=z.then,oe||(d(z,"then",(function(e,t){var n=this;return new B((function(e,t){a.call(n,e,t)})).then(e,t)}),{unsafe:!0}),d(z,"catch",W["catch"],{unsafe:!0}));try{delete z.constructor}catch(me){}h&&h(z,W)}s({global:!0,wrap:!0,forced:ie},{Promise:B}),v(B,I,!1,!0),m(I),i=c(I),s({target:I,stat:!0,forced:ie},{reject:function(e){var t=K(this);return t.reject.call(void 0,e),t.promise}}),s({target:I,stat:!0,forced:l||ie},{resolve:function(e){return E(l&&this===i?B:this,e)}}),s({target:I,stat:!0,forced:ae},{all:function(e){var t=this,n=K(t),r=n.resolve,o=n.reject,i=j((function(){var n=g(t.resolve),i=[],a=0,s=1;x(e,(function(e){var l=a++,u=!1;i.push(void 0),s++,n.call(t,e).then((function(e){u||(u=!0,i[l]=e,--s||r(i))}),o)})),--s||r(i)}));return i.error&&o(i.value),n.promise},race:function(e){var t=this,n=K(t),r=n.reject,o=j((function(){var o=g(t.resolve);x(e,(function(e){o.call(t,e).then(n.resolve,r)}))}));return o.error&&r(o.value),n.promise}})},e893:function(e,t,n){var r=n("5135"),o=n("56ef"),i=n("06cf"),a=n("9bf2");e.exports=function(e,t){for(var n=o(t),s=a.f,l=i.f,u=0;u-1?"center "+n:n+" center"}},appendArrow:function(e){var t=void 0;if(!this.appended){for(var n in this.appended=!0,e.attributes)if(/^_v-/.test(e.attributes[n].name)){t=e.attributes[n].name;break}var r=document.createElement("div");t&&r.setAttribute(t,""),r.setAttribute("x-arrow",""),r.className="popper__arrow",e.appendChild(r)}}},beforeDestroy:function(){this.doDestroy(!0),this.popperElm&&this.popperElm.parentNode===document.body&&(this.popperElm.removeEventListener("click",l),document.body.removeChild(this.popperElm))},deactivated:function(){this.$options.beforeDestroy[0].call(this)}}},ecdf:function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=133)}({133:function(e,t,n){"use strict";n.r(t);var r=n(3),o={default:{order:""},selection:{width:48,minWidth:48,realWidth:48,order:"",className:"el-table-column--selection"},expand:{width:48,minWidth:48,realWidth:48,order:""},index:{width:48,minWidth:48,realWidth:48,order:""}},i={selection:{renderHeader:function(e,t){var n=t.store;return e("el-checkbox",{attrs:{disabled:n.states.data&&0===n.states.data.length,indeterminate:n.states.selection.length>0&&!this.isAllSelected,value:this.isAllSelected},nativeOn:{click:this.toggleAllSelection}})},renderCell:function(e,t){var n=t.row,r=t.column,o=t.store,i=t.$index;return e("el-checkbox",{nativeOn:{click:function(e){return e.stopPropagation()}},attrs:{value:o.isSelected(n),disabled:!!r.selectable&&!r.selectable.call(null,n,i)},on:{input:function(){o.commit("rowSelectedChanged",n)}}})},sortable:!1,resizable:!1},index:{renderHeader:function(e,t){var n=t.column;return n.label||"#"},renderCell:function(e,t){var n=t.$index,r=t.column,o=n+1,i=r.index;return"number"===typeof i?o=n+i:"function"===typeof i&&(o=i(n)),e("div",[o])},sortable:!1},expand:{renderHeader:function(e,t){var n=t.column;return n.label||""},renderCell:function(e,t){var n=t.row,r=t.store,o=["el-table__expand-icon"];r.states.expandRows.indexOf(n)>-1&&o.push("el-table__expand-icon--expanded");var i=function(e){e.stopPropagation(),r.toggleRowExpansion(n)};return e("div",{class:o,on:{click:i}},[e("i",{class:"el-icon el-icon-arrow-right"})])},sortable:!1,resizable:!1,className:"el-table__expand-column"}};function a(e,t){var n=t.row,o=t.column,i=t.$index,a=o.property,s=a&&Object(r["getPropByPath"])(n,a).v;return o&&o.formatter?o.formatter(n,o,s,i):s}function s(e,t){var n=t.row,r=t.treeNode,o=t.store;if(!r)return null;var i=[],a=function(e){e.stopPropagation(),o.loadOrToggle(n)};if(r.indent&&i.push(e("span",{class:"el-table__indent",style:{"padding-left":r.indent+"px"}})),"boolean"!==typeof r.expanded||r.noLazyChildren)i.push(e("span",{class:"el-table__placeholder"}));else{var s=["el-table__expand-icon",r.expanded?"el-table__expand-icon--expanded":""],l=["el-icon-arrow-right"];r.loading&&(l=["el-icon-loading"]),i.push(e("div",{class:s,on:{click:a}},[e("i",{class:l})]))}return i}var l=n(8),u=n(18),c=n.n(u),f=Object.assign||function(e){for(var t=1;t-1}))}}},data:function(){return{isSubColumn:!1,columns:[]}},computed:{owner:function(){var e=this.$parent;while(e&&!e.tableId)e=e.$parent;return e},columnOrTableParent:function(){var e=this.$parent;while(e&&!e.tableId&&!e.columnId)e=e.$parent;return e},realWidth:function(){return Object(l["l"])(this.width)},realMinWidth:function(){return Object(l["k"])(this.minWidth)},realAlign:function(){return this.align?"is-"+this.align:null},realHeaderAlign:function(){return this.headerAlign?"is-"+this.headerAlign:this.realAlign}},methods:{getPropsData:function(){for(var e=this,t=arguments.length,n=Array(t),r=0;rt.key[n])return 1}return 0};return e.map((function(e,t){return{value:e,index:t,key:s?s(e,t):null}})).sort((function(e,t){var r=l(e,t);return r||(r=e.index-t.index),r*n})).map((function(e){return e.value}))},l=function(e,t){var n=null;return e.columns.forEach((function(e){e.id===t&&(n=e)})),n},u=function(e,t){for(var n=null,r=0;r2&&void 0!==arguments[2]?arguments[2]:"children",r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"hasChildren",o=function(e){return!(Array.isArray(e)&&e.length)};function i(e,a,s){t(e,a,s),a.forEach((function(e){if(e[r])t(e,null,s+1);else{var a=e[n];o(a)||i(e,a,s+1)}}))}e.forEach((function(e){if(e[r])t(e,null,0);else{var a=e[n];o(a)||i(e,a,0)}}))}}})},eedf:function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=86)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},86:function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("button",{staticClass:"el-button",class:[e.type?"el-button--"+e.type:"",e.buttonSize?"el-button--"+e.buttonSize:"",{"is-disabled":e.buttonDisabled,"is-loading":e.loading,"is-plain":e.plain,"is-round":e.round,"is-circle":e.circle}],attrs:{disabled:e.buttonDisabled||e.loading,autofocus:e.autofocus,type:e.nativeType},on:{click:e.handleClick}},[e.loading?n("i",{staticClass:"el-icon-loading"}):e._e(),e.icon&&!e.loading?n("i",{class:e.icon}):e._e(),e.$slots.default?n("span",[e._t("default")],2):e._e()])},o=[];r._withStripped=!0;var i={name:"ElButton",inject:{elForm:{default:""},elFormItem:{default:""}},props:{type:{type:String,default:"default"},size:String,icon:{type:String,default:""},nativeType:{type:String,default:"button"},loading:Boolean,disabled:Boolean,plain:Boolean,autofocus:Boolean,round:Boolean,circle:Boolean},computed:{_elFormItemSize:function(){return(this.elFormItem||{}).elFormItemSize},buttonSize:function(){return this.size||this._elFormItemSize||(this.$ELEMENT||{}).size},buttonDisabled:function(){return this.disabled||(this.elForm||{}).disabled}},methods:{handleClick:function(e){this.$emit("click",e)}}},a=i,s=n(0),l=Object(s["a"])(a,r,o,!1,null,null,null);l.options.__file="packages/button/src/button.vue";var u=l.exports;u.install=function(e){e.component(u.name,u)};t["default"]=u}})},f069:function(e,t,n){"use strict";var r=n("1c0b"),o=function(e){var t,n;this.promise=new e((function(e,r){if(void 0!==t||void 0!==n)throw TypeError("Bad Promise constructor");t=e,n=r})),this.resolve=r(t),this.reject=r(n)};e.exports.f=function(e){return new o(e)}},f0d9:function(e,t,n){"use strict";t.__esModule=!0,t.default={el:{colorpicker:{confirm:"确定",clear:"清空"},datepicker:{now:"此刻",today:"今天",cancel:"取消",clear:"清空",confirm:"确定",selectDate:"选择日期",selectTime:"选择时间",startDate:"开始日期",startTime:"开始时间",endDate:"结束日期",endTime:"结束时间",prevYear:"前一年",nextYear:"后一年",prevMonth:"上个月",nextMonth:"下个月",year:"年",month1:"1 月",month2:"2 月",month3:"3 月",month4:"4 月",month5:"5 月",month6:"6 月",month7:"7 月",month8:"8 月",month9:"9 月",month10:"10 月",month11:"11 月",month12:"12 月",weeks:{sun:"日",mon:"一",tue:"二",wed:"三",thu:"四",fri:"五",sat:"六"},months:{jan:"一月",feb:"二月",mar:"三月",apr:"四月",may:"五月",jun:"六月",jul:"七月",aug:"八月",sep:"九月",oct:"十月",nov:"十一月",dec:"十二月"}},select:{loading:"加载中",noMatch:"无匹配数据",noData:"无数据",placeholder:"请选择"},cascader:{noMatch:"无匹配数据",loading:"加载中",placeholder:"请选择",noData:"暂无数据"},pagination:{goto:"前往",pagesize:"条/页",total:"共 {total} 条",pageClassifier:"页"},messagebox:{title:"提示",confirm:"确定",cancel:"取消",error:"输入的数据不合法!"},upload:{deleteTip:"按 delete 键可删除",delete:"删除",preview:"查看图片",continue:"继续上传"},table:{emptyText:"暂无数据",confirmFilter:"筛选",resetFilter:"重置",clearFilter:"全部",sumText:"合计"},tree:{emptyText:"暂无数据"},transfer:{noMatch:"无匹配数据",noData:"无数据",titles:["列表 1","列表 2"],filterPlaceholder:"请输入搜索内容",noCheckedFormat:"共 {total} 项",hasCheckedFormat:"已选 {checked}/{total} 项"},image:{error:"加载失败"},pageHeader:{title:"返回"},popconfirm:{confirmButtonText:"确定",cancelButtonText:"取消"},empty:{description:"暂无数据"}}}},f3ad:function(e,t,n){e.exports=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/dist/",n(n.s=79)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,i,a,s){var l,u="function"===typeof e?e.options:e;if(t&&(u.render=t,u.staticRenderFns=n,u._compiled=!0),r&&(u.functional=!0),i&&(u._scopeId="data-v-"+i),a?(l=function(e){e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,e||"undefined"===typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(a)},u._ssrRegister=l):o&&(l=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),l)if(u.functional){u._injectStyles=l;var c=u.render;u.render=function(e,t){return l.call(t),c(e,t)}}else{var f=u.beforeCreate;u.beforeCreate=f?[].concat(f,l):[l]}return{exports:e,options:u}}n.d(t,"a",(function(){return r}))},11:function(e,t){e.exports=n("2bb5")},21:function(e,t){e.exports=n("d397")},4:function(e,t){e.exports=n("d010")},79:function(e,t,n){"use strict";n.r(t);var r=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("div",{class:["textarea"===e.type?"el-textarea":"el-input",e.inputSize?"el-input--"+e.inputSize:"",{"is-disabled":e.inputDisabled,"is-exceed":e.inputExceed,"el-input-group":e.$slots.prepend||e.$slots.append,"el-input-group--append":e.$slots.append,"el-input-group--prepend":e.$slots.prepend,"el-input--prefix":e.$slots.prefix||e.prefixIcon,"el-input--suffix":e.$slots.suffix||e.suffixIcon||e.clearable||e.showPassword}],on:{mouseenter:function(t){e.hovering=!0},mouseleave:function(t){e.hovering=!1}}},["textarea"!==e.type?[e.$slots.prepend?n("div",{staticClass:"el-input-group__prepend"},[e._t("prepend")],2):e._e(),"textarea"!==e.type?n("input",e._b({ref:"input",staticClass:"el-input__inner",attrs:{tabindex:e.tabindex,type:e.showPassword?e.passwordVisible?"text":"password":e.type,disabled:e.inputDisabled,readonly:e.readonly,autocomplete:e.autoComplete||e.autocomplete,"aria-label":e.label},on:{compositionstart:e.handleCompositionStart,compositionupdate:e.handleCompositionUpdate,compositionend:e.handleCompositionEnd,input:e.handleInput,focus:e.handleFocus,blur:e.handleBlur,change:e.handleChange}},"input",e.$attrs,!1)):e._e(),e.$slots.prefix||e.prefixIcon?n("span",{staticClass:"el-input__prefix"},[e._t("prefix"),e.prefixIcon?n("i",{staticClass:"el-input__icon",class:e.prefixIcon}):e._e()],2):e._e(),e.getSuffixVisible()?n("span",{staticClass:"el-input__suffix"},[n("span",{staticClass:"el-input__suffix-inner"},[e.showClear&&e.showPwdVisible&&e.isWordLimitVisible?e._e():[e._t("suffix"),e.suffixIcon?n("i",{staticClass:"el-input__icon",class:e.suffixIcon}):e._e()],e.showClear?n("i",{staticClass:"el-input__icon el-icon-circle-close el-input__clear",on:{mousedown:function(e){e.preventDefault()},click:e.clear}}):e._e(),e.showPwdVisible?n("i",{staticClass:"el-input__icon el-icon-view el-input__clear",on:{click:e.handlePasswordVisible}}):e._e(),e.isWordLimitVisible?n("span",{staticClass:"el-input__count"},[n("span",{staticClass:"el-input__count-inner"},[e._v("\n "+e._s(e.textLength)+"/"+e._s(e.upperLimit)+"\n ")])]):e._e()],2),e.validateState?n("i",{staticClass:"el-input__icon",class:["el-input__validateIcon",e.validateIcon]}):e._e()]):e._e(),e.$slots.append?n("div",{staticClass:"el-input-group__append"},[e._t("append")],2):e._e()]:n("textarea",e._b({ref:"textarea",staticClass:"el-textarea__inner",style:e.textareaStyle,attrs:{tabindex:e.tabindex,disabled:e.inputDisabled,readonly:e.readonly,autocomplete:e.autoComplete||e.autocomplete,"aria-label":e.label},on:{compositionstart:e.handleCompositionStart,compositionupdate:e.handleCompositionUpdate,compositionend:e.handleCompositionEnd,input:e.handleInput,focus:e.handleFocus,blur:e.handleBlur,change:e.handleChange}},"textarea",e.$attrs,!1)),e.isWordLimitVisible&&"textarea"===e.type?n("span",{staticClass:"el-input__count"},[e._v(e._s(e.textLength)+"/"+e._s(e.upperLimit))]):e._e()],2)},o=[];r._withStripped=!0;var i=n(4),a=n.n(i),s=n(11),l=n.n(s),u=void 0,c="\n height:0 !important;\n visibility:hidden !important;\n overflow:hidden !important;\n position:absolute !important;\n z-index:-1000 !important;\n top:0 !important;\n right:0 !important\n",f=["letter-spacing","line-height","padding-top","padding-bottom","font-family","font-weight","font-size","text-rendering","text-transform","width","text-indent","padding-left","padding-right","border-width","box-sizing"];function d(e){var t=window.getComputedStyle(e),n=t.getPropertyValue("box-sizing"),r=parseFloat(t.getPropertyValue("padding-bottom"))+parseFloat(t.getPropertyValue("padding-top")),o=parseFloat(t.getPropertyValue("border-bottom-width"))+parseFloat(t.getPropertyValue("border-top-width")),i=f.map((function(e){return e+":"+t.getPropertyValue(e)})).join(";");return{contextStyle:i,paddingSize:r,borderSize:o,boxSizing:n}}function p(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;u||(u=document.createElement("textarea"),document.body.appendChild(u));var r=d(e),o=r.paddingSize,i=r.borderSize,a=r.boxSizing,s=r.contextStyle;u.setAttribute("style",s+";"+c),u.value=e.value||e.placeholder||"";var l=u.scrollHeight,f={};"border-box"===a?l+=i:"content-box"===a&&(l-=o),u.value="";var p=u.scrollHeight-o;if(null!==t){var h=p*t;"border-box"===a&&(h=h+o+i),l=Math.max(h,l),f.minHeight=h+"px"}if(null!==n){var v=p*n;"border-box"===a&&(v=v+o+i),l=Math.min(v,l)}return f.height=l+"px",u.parentNode&&u.parentNode.removeChild(u),u=null,f}var h=n(9),v=n.n(h),m=n(21),y={name:"ElInput",componentName:"ElInput",mixins:[a.a,l.a],inheritAttrs:!1,inject:{elForm:{default:""},elFormItem:{default:""}},data:function(){return{textareaCalcStyle:{},hovering:!1,focused:!1,isComposing:!1,passwordVisible:!1}},props:{value:[String,Number],size:String,resize:String,form:String,disabled:Boolean,readonly:Boolean,type:{type:String,default:"text"},autosize:{type:[Boolean,Object],default:!1},autocomplete:{type:String,default:"off"},autoComplete:{type:String,validator:function(e){return!0}},validateEvent:{type:Boolean,default:!0},suffixIcon:String,prefixIcon:String,label:String,clearable:{type:Boolean,default:!1},showPassword:{type:Boolean,default:!1},showWordLimit:{type:Boolean,default:!1},tabindex:String},computed:{_elFormItemSize:function(){return(this.elFormItem||{}).elFormItemSize},validateState:function(){return this.elFormItem?this.elFormItem.validateState:""},needStatusIcon:function(){return!!this.elForm&&this.elForm.statusIcon},validateIcon:function(){return{validating:"el-icon-loading",success:"el-icon-circle-check",error:"el-icon-circle-close"}[this.validateState]},textareaStyle:function(){return v()({},this.textareaCalcStyle,{resize:this.resize})},inputSize:function(){return this.size||this._elFormItemSize||(this.$ELEMENT||{}).size},inputDisabled:function(){return this.disabled||(this.elForm||{}).disabled},nativeInputValue:function(){return null===this.value||void 0===this.value?"":String(this.value)},showClear:function(){return this.clearable&&!this.inputDisabled&&!this.readonly&&this.nativeInputValue&&(this.focused||this.hovering)},showPwdVisible:function(){return this.showPassword&&!this.inputDisabled&&!this.readonly&&(!!this.nativeInputValue||this.focused)},isWordLimitVisible:function(){return this.showWordLimit&&this.$attrs.maxlength&&("text"===this.type||"textarea"===this.type)&&!this.inputDisabled&&!this.readonly&&!this.showPassword},upperLimit:function(){return this.$attrs.maxlength},textLength:function(){return"number"===typeof this.value?String(this.value).length:(this.value||"").length},inputExceed:function(){return this.isWordLimitVisible&&this.textLength>this.upperLimit}},watch:{value:function(e){this.$nextTick(this.resizeTextarea),this.validateEvent&&this.dispatch("ElFormItem","el.form.change",[e])},nativeInputValue:function(){this.setNativeInputValue()},type:function(){var e=this;this.$nextTick((function(){e.setNativeInputValue(),e.resizeTextarea(),e.updateIconOffset()}))}},methods:{focus:function(){this.getInput().focus()},blur:function(){this.getInput().blur()},getMigratingConfig:function(){return{props:{icon:"icon is removed, use suffix-icon / prefix-icon instead.","on-icon-click":"on-icon-click is removed."},events:{click:"click is removed."}}},handleBlur:function(e){this.focused=!1,this.$emit("blur",e),this.validateEvent&&this.dispatch("ElFormItem","el.form.blur",[this.value])},select:function(){this.getInput().select()},resizeTextarea:function(){if(!this.$isServer){var e=this.autosize,t=this.type;if("textarea"===t)if(e){var n=e.minRows,r=e.maxRows;this.textareaCalcStyle=p(this.$refs.textarea,n,r)}else this.textareaCalcStyle={minHeight:p(this.$refs.textarea).minHeight}}},setNativeInputValue:function(){var e=this.getInput();e&&e.value!==this.nativeInputValue&&(e.value=this.nativeInputValue)},handleFocus:function(e){this.focused=!0,this.$emit("focus",e)},handleCompositionStart:function(){this.isComposing=!0},handleCompositionUpdate:function(e){var t=e.target.value,n=t[t.length-1]||"";this.isComposing=!Object(m["isKorean"])(n)},handleCompositionEnd:function(e){this.isComposing&&(this.isComposing=!1,this.handleInput(e))},handleInput:function(e){this.isComposing||e.target.value!==this.nativeInputValue&&(this.$emit("input",e.target.value),this.$nextTick(this.setNativeInputValue))},handleChange:function(e){this.$emit("change",e.target.value)},calcIconOffset:function(e){var t=[].slice.call(this.$el.querySelectorAll(".el-input__"+e)||[]);if(t.length){for(var n=null,r=0;r0&&(this.timer=setTimeout((function(){e.closed||e.close()}),this.duration))},keydown:function(e){27===e.keyCode&&(this.closed||this.close())}},mounted:function(){this.startTimer(),document.addEventListener("keydown",this.keydown)},beforeDestroy:function(){document.removeEventListener("keydown",this.keydown)}},u=l,c=n(0),f=Object(c["a"])(u,i,a,!1,null,null,null);f.options.__file="packages/message/src/main.vue";var d=f.exports,p=n(13),h=n(23),v=o.a.extend(d),m=void 0,y=[],g=1,b=function e(t){if(!o.a.prototype.$isServer){t=t||{},"string"===typeof t&&(t={message:t});var n=t.onClose,r="message_"+g++;t.onClose=function(){e.close(r,n)},m=new v({data:t}),m.id=r,Object(h["isVNode"])(m.message)&&(m.$slots.default=[m.message],m.message=null),m.$mount(),document.body.appendChild(m.$el);var i=t.offset||20;return y.forEach((function(e){i+=e.$el.offsetHeight+16})),m.verticalOffset=i,m.visible=!0,m.$el.style.zIndex=p["PopupManager"].nextZIndex(),y.push(m),m}};["success","warning","info","error"].forEach((function(e){b[e]=function(t){return"string"===typeof t&&(t={message:t}),t.type=e,b(t)}})),b.close=function(e,t){for(var n=y.length,r=-1,o=void 0,i=0;iy.length-1))for(var a=r;a=0;e--)y[e].close()};var _=b;t["default"]=_}})},f5df:function(e,t,n){var r=n("00ee"),o=n("c6b6"),i=n("b622"),a=i("toStringTag"),s="Arguments"==o(function(){return arguments}()),l=function(e,t){try{return e[t]}catch(n){}};e.exports=r?o:function(e){var t,n,r;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=l(t=Object(e),a))?n:s?o(t):"Object"==(r=o(t))&&"function"==typeof t.callee?"Arguments":r}},f772:function(e,t,n){var r=n("5692"),o=n("90e3"),i=r("keys");e.exports=function(e){return i[e]||(i[e]=o(e))}},fc6a:function(e,t,n){var r=n("44ad"),o=n("1d80");e.exports=function(e){return r(o(e))}},fdbc:function(e,t){e.exports={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0}},fdbf:function(e,t,n){var r=n("4930");e.exports=r&&!Symbol.sham&&"symbol"==typeof Symbol.iterator},fea9:function(e,t,n){var r=n("da84");e.exports=r.Promise}}]); \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/js/detail.4e6a53a9.js b/app/src/main/assets/web/bookshelf/js/detail.4e6a53a9.js new file mode 100644 index 000000000..eaefb9b7f --- /dev/null +++ b/app/src/main/assets/web/bookshelf/js/detail.4e6a53a9.js @@ -0,0 +1 @@ +(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["detail"],{"057f":function(t,e,n){var o=n("fc6a"),i=n("241c").f,s={}.toString,a="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[],r=function(t){try{return i(t)}catch(e){return a.slice()}};t.exports.f=function(t){return a&&"[object Window]"==s.call(t)?r(t):i(o(t))}},"05b3":function(t,e,n){},"0827":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEUWGBkYGhsdHyAfISI1t/v6AAAB5ElEQVQozxXQsYoTURSA4f/EeycZsDgDdySDjihk38Hy3GWi2J2BCaziQhaiaB+tt9AFu1kwvYUPsIXNPoB9BAUfwAfwEUzKv/v4odGrroyp9/rUaC6rZ5skv5F8qPsfYYP+yKUMymmAEEeW55oUR4o8jr05KNzJ07yvB7w0KKfLwcQUSjfmMU0PJfPHFoEVU+ohNrcKMEzMQ23FDnVSI2dqtYWI7KlLu6vE4UnyvKc3SJuL7lBbeEEl42ItpGLjzIT8PRJCmkRjVpVpsbJFVN0687okJNZiHAr5Z7MV0BnGIDc+THM1zlbieBc1Fq+tH5BH+OpnbWkj40hSqC8Lw2TvFuF0SUFJCk2IytXbjeqcRAt6NHpnrUkUU4KRzZs8RCK8N/Akn2W04LwxMU/V7XK0bDyN2RxfDyx7I4h5vjZby72V8UnOWumZL3qtYc+8DTE0siSBMXGhywx2dMYPnQHbxdFZ7deiNGxCCtD/QWnbwDoGhRYPDzUdUA3krjpnkvdAgDN4ddLkEQSov9qjd42HaDjI34gEqS9TUueAk+sc4qg5ws407KQYKs8G1jv4xBlqBVk6cb4dISZIwVi1Jzu4+HLk6lyfUxkXvwy+1Q+4WVdHIhwfybZ6CWVhxMEhShOgsP/HOW0MvZJeFwAAAABJRU5ErkJggg=="},1276:function(t,e,n){"use strict";var o=n("d784"),i=n("44e7"),s=n("825a"),a=n("1d80"),r=n("4840"),c=n("8aa5"),A=n("50c4"),l=n("14c3"),u=n("9263"),f=n("9f7f"),g=n("d039"),d=f.UNSUPPORTED_Y,h=[].push,p=Math.min,m=4294967295,b=!g((function(){var t=/(?:)/,e=t.exec;t.exec=function(){return e.apply(this,arguments)};var n="ab".split(t);return 2!==n.length||"a"!==n[0]||"b"!==n[1]}));o("split",(function(t,e,n){var o;return o="c"=="abbc".split(/(b)*/)[1]||4!="test".split(/(?:)/,-1).length||2!="ab".split(/(?:ab)*/).length||4!=".".split(/(.?)(.?)/).length||".".split(/()()/).length>1||"".split(/.?/).length?function(t,n){var o=String(a(this)),s=void 0===n?m:n>>>0;if(0===s)return[];if(void 0===t)return[o];if(!i(t))return e.call(o,t,s);var r,c,A,l=[],f=(t.ignoreCase?"i":"")+(t.multiline?"m":"")+(t.unicode?"u":"")+(t.sticky?"y":""),g=0,d=new RegExp(t.source,f+"g");while(r=u.call(d,o)){if(c=d.lastIndex,c>g&&(l.push(o.slice(g,r.index)),r.length>1&&r.index=s))break;d.lastIndex===r.index&&d.lastIndex++}return g===o.length?!A&&d.test("")||l.push(""):l.push(o.slice(g)),l.length>s?l.slice(0,s):l}:"0".split(void 0,0).length?function(t,n){return void 0===t&&0===n?[]:e.call(this,t,n)}:e,[function(e,n){var i=a(this),s=void 0==e?void 0:e[t];return void 0!==s?s.call(e,i,n):o.call(String(i),e,n)},function(t,i){var a=n(o,this,t,i,o!==e);if(a.done)return a.value;var u=s(this),f=String(t),g=r(u,RegExp),h=u.unicode,b=(u.ignoreCase?"i":"")+(u.multiline?"m":"")+(u.unicode?"u":"")+(d?"g":"y"),v=new g(d?"^(?:"+u.source+")":u,b),C=void 0===i?m:i>>>0;if(0===C)return[];if(0===f.length)return null===l(v,f)?[f]:[];var y=0,B=0,S=[];while(B=51||!o((function(){var e=[],n=e.constructor={};return n[a]=function(){return{foo:1}},1!==e[t](Boolean).foo}))}},"1e75":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXM2t7O3ODQ3uLR4OTDp25yAAACdUlEQVQozw3P70sTcQDH8c/3/M7NG+j35mnHwjwh4hRy/QFK3zvPNbeIG1koPZmxfj2IDAwihL53zj0JYisfmEHcZJZOiBUG60lZiI8T/ANusuftgQ+kCPIPeMP7hS5mUrV9c1g6MQCAEZ8tDLHwofImAGRlX+SZK3Vu9rRRPuO4PK6/9nA4GIATsxlODS+rdCMhkAZivpYV0LWoQHSLSA4NfUg+6mY+7BKL2++F9LvnrBDYm6JO9i/YO3i/HJTGQ4pdIV82TbEDFG6vGYCd4wZchgK5J2CrKTLE+Tx0v+YGlIbdWJFcQl4ptBN8fUJQN1MCJLcZLYwUVVo+famGGty8EXJF5ofOEDzcodT3/Fb0I5sHmc1ZG7CcSl8COgxlXx09jT05OafjCZLIHJhGIaU6wDZHsuMQ41wbdjmQXbhKnMq1zlXSYrjCnyZblqexA7fC8RxS74tq2P3OxSQwTuJSApH8OZLzBBp1pOe0i3rdyDUA47GySZ31YmC4EQYSXvFSvieORGBxXF9aeVtUWKGS9WMC4Z9Y2uXnJ2nCUXVMbPOYqNYNmGWWQ7Evr+BWC+a0JAMTImcq/S4Z5INdQMeuOqDIMa9beilxfA60iC6sP1INcPDpmHBW8drZHNmqwyddJtVje9q8WGUgWAOzmbU4FCQBFi8B2Wk6pickBnYhJMenmJGuRmtt2IoKq9NuFGbNFR99sHnvrnLsLysKANDIsxbp6RNMAsoDSKuRpMwZbAAzI68QatIjmZ0aImyM3O8/4e2MNlOHZomFsa/fLDsysliHS+nlYLQJMnynxrH8QO4PaAV2Li8B/+52UgeGIVNFYf8B1XG/kFSmLcUAAAAASUVORK5CYII="},"356c":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXPz8/R0dHT09PU1NToNyAhAAACdElEQVQozw3NP0xTQQDH8d9d7sFrG+QeKVgQ4aoFCwFkYERyLY//0UB8GNGg1WAC0RBGJrzW4mCXQmpgvCYOwEAYiulSpYtza2KiW7s5FgNJFSV2/CzfL7RwpoJ20iadmgA8owOyaxmusKE44scBeb4vIv00dqYgmf6jzWcr7W6INbDQeZbQL9ytXeYgtFfzmW1Fek5msxJlwhyt6qDDxOLQzpVPompYrMPnEnhvLm7M5BxY5nowAj3zkydAkpC0FIG6g7AK+Ub25ybyNWVYwtpseP2rfrQwiGRpfqrnMuPeuvr2dA0p2YsHF2XghkrXKtZ8tLBjR7S2qIaYbKmyLd/QP+EogLjqqwNw5Lq1pDlMLkM5+gNoSvdq+Pxmz9/61EFq6GYM6GqaGvlN95zy3gsmEWI8K3k8OP9OmRLEPO6DP3Wv3g42COinJTZ33dcIvs4ESp6opMTjDs6mcYTEbFeUifuxh989yZrIx4lkpuixxz0nHLCekKbE17suKhYkMGhoYhTZtVBvg4bfq/1L1Im0AGMVpBFwumM0zwyuKiCMi5dqR4Flx47AGyF2xTbxqUdTwCH94BT3DozpLV5WuAL/N8rGtHKjotBOOuOtCJ9E21uqsyBoLOzaXbHPrK5PQBP+fBfeidvJAeMIAmzVt5IkJJ9DBWaZDAepYUhlQqHt0h72SJ3j8TZHom64f516xx9T5evgMPgwG82jZdJaJIDyWp6LAjOCclVyzNA3iTKzIULlBQEPaTXlPHok5gISclmyaWZlqY2aTHdRHpJOwTdDEQ3ZfKtbpclcNhyVClagmY+fIfyKukntPqBgnx5QvZHk/D/MK8JMClrSigAAAABJRU5ErkJggg=="},4039:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEX0/PTx+fH2/vbz+/P4//htSO9OAAAC5UlEQVQ4yyWT0QGjMAxDZTsDWKQDmJQBYrgBUsr+M517x0+LRWw9CyA+pC1YzndrMgHaNXVKQ+di13Of1qbur48nWhuRjj8i6ON8e7pNm7zyag/DBTfS9Z4Hup1fUuXMKY4HEE8QOHCByXkIkl7lDT239RtL9quO4JItmmhOAHXg45QuYKrQFLyGJcRvaTw6kQqZy6mkR6JAPFH/XqsQjEDRmUOA+MNLHGyMUT7AHApoAhjgjIJmCxy6XHdf648AWCdGe57IUDazCeTImQOY4/z+eVYVX2IjOw9RydeAeJwl79iGi4HpgQgHEchWraUZLtayu8scq0lHHHUKMY3Ml8hB7CS1jOckDLG9ccgNeX3124phOcjL9fPnWJhTXpLHeG9DRmHnTxHEaHakS2J51lwAJcUraNbuU7q4gMTDQj3Eripc/x+qFM5VEKAB1roQfAkX5/PxqnS2QpOrxfK1Zft0/omV5T+xCSBUAIbEIwUQgvAfxFE1O8dnk233+1UZiqJ1mAbsue6Yt8tF+yOrxC/YrUhzC4qPlE3EbR5hGKhhHdlrg7J9WunV7L7BcYQwAeE59u2tnN1c6gfVYrQiLSZ9OxZdWDXQq0+r0Pbarh3UqGCwauVvbiXuDsNxCtLDdW9rTF8oQYN4EoXXdfmwNguQP26n/tRjDeo+F2W7PjWtfSr6Bn/z+cXOLp4NnMV4RytvSW4B68m+XN9XfZTFGhO/S+cHTuTqZDC21ccA0N7QsePALaDQC3D1f94U9CWo+aq6BjB3v0rxIimBM12296M3aKPHjXLQE9KQKH4By8RHraJ3AgVto2r4xdFqlaPaiAHLl1ZF4P2pI6cYc+K8UZdcmxy7lqGc1IoPxLmIFuIeEZ6j2sQT88muEg1zwrEDTIX5U/ZmcsqfgVlBumiBLF4sAyhf9BFlXOPKLZ4H0iFb3VoHrGhtHTldKrOvP2/reu2zfV8CXMPqzRdlgd0a5eI7WwB/AYcgavcqxXWEAAAAAElFTkSuQmCC"},4400:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAElBMVEUQERMODxESFBYWGBkaHB0eICLm6ozJAAACkUlEQVQ4yyWTUdLbMAiEASfvoOkBkBy/O5keIE0v8E/uf5h+68qZWALELgu2MG9PP9qyvCzTVhrrsPGOCjvTfXQZvtp/W3Gy6LCITqs4q/DZ+KYl76zKzHVYpY2wNY27nqN1sbLGcrLH3/ENH4oWlGctsDu8AO+HzTLlsYdh8MzP1m6YDMz0ACfcimvakBj+mwO/+5Uta5teOD379sxK1fUxmUhv8MU3jUT5gs26PMephFznkLcpQZ6/dPL9C/GWHcCxDN6oZhD5xBm5qoYBPA+PFE/H1tXDWcWl8TW7rS+4dUzAVy0BIrvC4/HcqW2TkG1HO8q9dC23INAg7NA4AFRFkDTM2lfELPyFzi1VddcpX2z0KjHBUDmdLNJ6dDps4ytrX+FPsZwE31wSL+6OWfHOAJ3+Y0Rk/MiKfmWNPg7oVP/U3Ck9FoCkC2gBpALOiqbMNTkOe8P4FWkTD2Y9Q3+5VmV0uLUJBl68U5uAK2Kl6QDXvLxbwweOL2sixW78uU8p0ysfc7cWrF1j6B1sPJ4WgclYSnJN1bzozrhEcFHmRzBkbJWqqdG+EYJXRFmT5jnLXPUNF6WBdoFbTxYsmDXVLU/WA7MExNc93sJS5hIXDeLxzMScHzdhKvEkibr6cQXYPrmtmTA7JcInISrTzRDvShTdka0uVGrsJAAR6tSn1sKziZtfKVjAxPrJsYgZO0bye+vKTZ/DgoAoLGNO6jYHimZYTL/3pLJHawquJukjBpfz8WOGVSVIWx9ywUfS5iENutidRM4NzkAmxgUSQ68xgNOU+ZLalr4TS2V+D2xqukZig+Z9DilR7Nouzwp1cp/3E5q6Rdlf08obKvAM4qZ6pMr+w3PmQALSSBfjyZn5DwrNRVbywBQiAAAAAElFTkSuQmCC"},"477e":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAACVBMVEX28ef48+n69esoK7jYAAAB4UlEQVQozw2OsW4bQQxEhwLXkDrysGdEqRRgVShfQQq8wOr2jD0jSpXCLvwXbtKfADlFqgSwC/9ljqweZgYzQFnb/QGepYhA9jzmTc1WaSEtQpbFgjWATI00ZZtIckXx8q2Oe5yEByBy+RHOTcM+VVTadULsvxvRC/q8WTwgcWGD+Mnaqa0oy2gw2pKFzK+PzEsus5hP9AHojKslVynLlioVTBEN8cjDNnZoR1uMGTiZAAN47HxMtEkGUE9b8HWzkqNX5Lpk0yVziAJOs46rK1pG/xNuXLjz95fSDoJE5IqG23MAYPtWoeWPvfVtIV/Ng9oH3W0gGMPIOqd4MK4QZ55dV61gOb8Zxp7I9qayaGxp6Q91cmC0ZRdBwEQVHWzSAanlZwVWc9yljeTCeaHjBVvlPSLeyeBUT2rPdJegQI103jVS3uYkyIx1il6mslMDedZuOkwzolsagvPuQAfp7cYg7k9V1NOxfq64PNSvMdwONV4VYEmqlbpZy5OAakRKkjPnL4CBv5/OZRgoWHBmNbxB0LgB1I4vXFj93UoF2/0TPEsWwV9EhbIiTPqYoTHYoMn3enTDjmrFeDTIzaL1bUC/PBIMuF+vSSYSaxoVt90EO3Gu1zrMuMRGUk7Ffv3L+A931Gsb/yBoIgAAAABJRU5ErkJggg=="},"537b":function(t,e,n){"use strict";n.r(e);var o=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"chapter-wrapper",class:{night:t.isNight,day:!t.isNight},style:t.bodyTheme},[n("div",{staticClass:"tool-bar",style:t.leftBarTheme},[n("div",{staticClass:"tools"},[n("el-popover",{attrs:{placement:"right",width:t.cataWidth,trigger:"click","visible-arrow":!1,"popper-class":"pop-cata"},model:{value:t.popCataVisible,callback:function(e){t.popCataVisible=e},expression:"popCataVisible"}},[n("PopCata",{ref:"popCata",staticClass:"popup",on:{getContent:t.getContent}}),n("div",{staticClass:"tool-icon",class:{"no-point":t.noPoint},attrs:{slot:"reference"},slot:"reference"},[n("div",{staticClass:"iconfont"},[t._v("  ")]),n("div",{staticClass:"icon-text"},[t._v("目录")])])],1),n("el-popover",{attrs:{placement:"bottom-right",width:"470",trigger:"click","visible-arrow":!1,"popper-class":"pop-setting"},model:{value:t.readSettingsVisible,callback:function(e){t.readSettingsVisible=e},expression:"readSettingsVisible"}},[n("ReadSettings",{staticClass:"popup"}),n("div",{staticClass:"tool-icon",class:{"no-point":t.noPoint},attrs:{slot:"reference"},slot:"reference"},[n("div",{staticClass:"iconfont"},[t._v("  ")]),n("div",{staticClass:"icon-text"},[t._v("设置")])])],1),n("div",{staticClass:"tool-icon",on:{click:t.toShelf}},[n("div",{staticClass:"iconfont"},[t._v("  ")]),n("div",{staticClass:"icon-text"},[t._v("书架")])]),n("div",{staticClass:"tool-icon",class:{"no-point":t.noPoint},on:{click:t.toTop}},[n("div",{staticClass:"iconfont"},[t._v("  ")]),n("div",{staticClass:"icon-text"},[t._v("顶部")])]),n("div",{staticClass:"tool-icon",class:{"no-point":t.noPoint},on:{click:t.toBottom}},[n("div",{staticClass:"iconfont"},[t._v("  ")]),n("div",{staticClass:"icon-text"},[t._v("底部")])])],1)]),n("div",{staticClass:"read-bar",style:t.rightBarTheme},[n("div",{staticClass:"tools"},[n("div",{staticClass:"tool-icon",class:{"no-point":t.noPoint},on:{click:t.toLastChapter}},[n("div",{staticClass:"iconfont"},[t._v("  ")])]),n("div",{staticClass:"tool-icon",class:{"no-point":t.noPoint},on:{click:t.toNextChapter}},[n("div",{staticClass:"iconfont"},[t._v("  ")])])])]),n("div",{staticClass:"chapter-bar"}),n("div",{ref:"content",staticClass:"chapter",style:t.chapterTheme},[n("div",{staticClass:"content"},[n("div",{ref:"top",staticClass:"top-bar"}),t.show?n("div",{ref:"title",staticClass:"title"},[t._v(t._s(t.title))]):t._e(),n("Pcontent",{attrs:{carray:t.content}}),n("div",{ref:"bottom",staticClass:"bottom-bar"})],1)])])},i=[],s=(n("ac1f"),n("1276"),function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"cata-wrapper",style:t.popupTheme},[n("div",{staticClass:"title"},[t._v(" 目录 ")]),n("div",{ref:"cataData",staticClass:"data-wrapper",class:{night:t.isNight,day:!t.isNight}},[n("div",{staticClass:"cata"},t._l(t.catalog,(function(e,o){return n("div",{key:e.durChapterIndex,ref:"cata",refInFor:!0,staticClass:"log",class:{selected:t.isSelected(o)},on:{click:function(n){return t.gotoChapter(e)}}},[n("div",{staticClass:"log-text"},[t._v(" "+t._s(e.title)+" ")])])})),0)])])}),a=[];n("a4d3"),n("e01a"),n("d3b7"),n("d28b"),n("3ca3"),n("ddb0");function r(t){return r="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"===typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},r(t)}var c,A,l=function(t,e,n,o){return t/=o/2,t<1?n/2*t*t+e:(t--,-n/2*(t*(t-2)-1)+e)},u=function(){var t,e,n,o,i,s,a,c,A,u,f,g,d;function h(){var e=t.scrollTop||t.scrollY||t.pageYOffset;return e="undefined"===typeof e?0:e,e}function p(e){var o=e.getBoundingClientRect().top,i=t.getBoundingClientRect?t.getBoundingClientRect().top:0;return o-i+n}function m(e){t.scrollTo?t.scrollTo(0,e):t.scrollTop=e}function b(t){u||(u=t),f=t-u,g=s(f,n,c,A),m(g),f1&&void 0!==arguments[1]?arguments[1]:{};switch(A=f.duration||1e3,i=f.offset||0,d=f.callback,s=f.easing||l,a=f.a11y||!1,r(f.container)){case"object":t=f.container;break;case"string":t=document.querySelector(f.container);break;default:t=window}switch(n=h(),r(u)){case"number":e=void 0,a=!1,o=n+u;break;case"object":e=u,o=p(e);break;case"string":e=document.querySelector(u),o=p(e);break}switch(c=o-n+i,r(f.duration)){case"number":A=f.duration;break;case"function":A=f.duration(c);break}requestAnimationFrame(b)}return C},f=u(),g=f,d=n("7286"),h=n.n(d),p=n("477e"),m=n.n(p),b=n("e160"),v=n.n(b),C=n("df5e"),y=n.n(C),B=n("ec0f"),S=n.n(B),I=n("b671"),k=n.n(I),w=n("5629"),x=n.n(w),E=n("d0e3"),Q=n.n(E),U=n("4039"),D=n.n(U),V=n("1e75"),O=n.n(V),F=n("1632"),P=n.n(F),R=n("7abd"),M=n.n(R),N=n("356c"),K=n.n(N),H=n("b165"),J=n.n(H),z=n("cf68"),L=n.n(z),W=n("4400"),T=n.n(W),q=n("802e"),G=n.n(q),Z=n("0827"),Y=n.n(Z),j={themes:[{body:"#ede7da url("+h.a+") repeat",content:"#ede7da url("+m.a+") repeat",popup:"#ede7da url("+v.a+") repeat"},{body:"#ede7da url("+y.a+") repeat",content:"#ede7da url("+S.a+") repeat",popup:"#ede7da url("+k.a+") repeat"},{body:"#ede7da url("+x.a+") repeat",content:"#ede7da url("+Q.a+") repeat",popup:"#ede7da url("+D.a+") repeat"},{body:"#ede7da url("+O.a+") repeat",content:"#ede7da url("+P.a+") repeat",popup:"#ede7da url("+M.a+") repeat"},{body:"#ebcece repeat",content:"#f5e4e4 repeat",popup:"#faeceb repeat"},{body:"#ede7da url("+K.a+") repeat",content:"#ede7da url("+J.a+") repeat",popup:"#ede7da url("+L.a+") repeat"},{body:"#ede7da url("+T.a+") repeat",content:"#ede7da url("+G.a+") repeat",popup:"#ede7da url("+Y.a+") repeat"}],fonts:[{fontFamily:"Microsoft YaHei, PingFangSC-Regular, HelveticaNeue-Light, Helvetica Neue Light, sans-serif"},{fontFamily:"PingFangSC-Regular, -apple-system, Simsun"},{fontFamily:"Kaiti"}]},X=j,$=(n("05b3"),{name:"PopCata",data:function(){return{isNight:6==this.$store.state.config.theme,index:this.$store.state.readingBook.index}},computed:{catalog:function(){return this.$store.state.readingBook.catalog},popCataVisible:function(){return this.$store.state.popCataVisible},theme:function(){return this.$store.state.config.theme},popupTheme:function(){return{background:X.themes[this.theme].popup}}},mounted:function(){},watch:{theme:function(t){this.isNight=6==t},popCataVisible:function(){this.$nextTick((function(){var t=this.$store.state.readingBook.index,e=this.$refs.cataData;g(this.$refs.cata[t],{container:e,duration:0})}))}},methods:{isSelected:function(t){return t==this.$store.state.readingBook.index},gotoChapter:function(t){this.index=this.catalog.indexOf(t),this.$store.commit("setPopCataVisible",!1),this.$store.commit("setContentLoading",!0),this.$emit("getContent",this.index)}}}),_=$,tt=(n("9078"),n("2877")),et=Object(tt["a"])(_,s,a,!1,null,"22f8c37b",null),nt=et.exports,ot=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"settings-wrapper",class:{night:t.isNight,day:!t.isNight},style:t.popupTheme},[n("div",{staticClass:"settings-title"},[t._v("设置")]),n("div",{staticClass:"setting-list"},[n("ul",[n("li",{staticClass:"theme-list"},[n("i",[t._v("阅读主题")]),t._l(t.themeColors,(function(e,o){return n("span",{key:o,ref:"themes",refInFor:!0,staticClass:"theme-item",class:{selected:t.selectedTheme==o},style:e,on:{click:function(e){return t.setTheme(o)}}},[o<6?n("em",{staticClass:"iconfont"},[t._v("")]):n("em",{staticClass:"moon-icon"},[t._v(t._s(t.moonIcon))])])}))],2),n("li",{staticClass:"font-list"},[n("i",[t._v("正文字体")]),t._l(t.fonts,(function(e,o){return n("span",{key:o,staticClass:"font-item",class:{selected:t.selectedFont==o},on:{click:function(e){return t.setFont(o)}}},[t._v(t._s(e))])}))],2),n("li",{staticClass:"font-size"},[n("i",[t._v("字体大小")]),n("div",{staticClass:"resize"},[n("span",{staticClass:"less",on:{click:t.lessFontSize}},[n("em",{staticClass:"iconfont"},[t._v("")])]),n("b"),t._v(" "),n("span",{staticClass:"lang"},[t._v(t._s(t.fontSize))]),n("b"),n("span",{staticClass:"more",on:{click:t.moreFontSize}},[n("em",{staticClass:"iconfont"},[t._v("")])])])]),n("li",{staticClass:"read-width"},[n("i",[t._v("页面宽度")]),n("div",{staticClass:"resize"},[n("span",{staticClass:"less",on:{click:t.lessReadWidth}},[n("em",{staticClass:"iconfont"},[t._v("")])]),n("b"),t._v(" "),n("span",{staticClass:"lang"},[t._v(t._s(t.readWidth))]),n("b"),n("span",{staticClass:"more",on:{click:t.moreReadWidth}},[n("em",{staticClass:"iconfont"},[t._v("")])])])])])])])},it=[],st=(n("82da"),{name:"ReadSettings",data:function(){return{theme:0,isNight:6==this.$store.state.config.theme,moonIcon:"",themeColors:[{background:"rgba(250, 245, 235, 0.8)"},{background:"rgba(245, 234, 204, 0.8)"},{background:"rgba(230, 242, 230, 0.8)"},{background:"rgba(228, 241, 245, 0.8)"},{background:"rgba(245, 228, 228, 0.8)"},{background:"rgba(224, 224, 224, 0.8)"},{background:"rgba(0, 0, 0, 0.5)"}],moonIconStyle:{display:"inline",color:"rgba(255,255,255,0.2)"},fonts:["雅黑","宋体","楷书"]}},mounted:function(){var t=this.$store.state.config;this.theme=t.theme,6==this.theme?this.moonIcon="":this.moonIcon=""},computed:{config:function(){return this.$store.state.config},popupTheme:function(){return{background:X.themes[this.config.theme].popup}},selectedTheme:function(){return this.$store.state.config.theme},selectedFont:function(){return this.$store.state.config.font},fontSize:function(){return this.$store.state.config.fontSize},readWidth:function(){return this.$store.state.config.readWidth}},methods:{setTheme:function(t){6==t?(this.isNight=!0,this.moonIcon="",this.moonIconStyle.color="#ed4259"):(this.isNight=!1,this.moonIcon="",this.moonIconStyle.color="rgba(255,255,255,0.2)");var e=this.config;e.theme=t,this.$store.commit("setConfig",e)},setFont:function(t){var e=this.config;e.font=t,this.$store.commit("setConfig",e)},moreFontSize:function(){var t=this.config;t.fontSize<48&&(t.fontSize+=2),this.$store.commit("setConfig",t)},lessFontSize:function(){var t=this.config;t.fontSize>12&&(t.fontSize-=2),this.$store.commit("setConfig",t)},moreReadWidth:function(){var t=this.config;t.readWidth<960&&(t.readWidth+=160),this.$store.commit("setConfig",t)},lessReadWidth:function(){var t=this.config;t.readWidth>640&&(t.readWidth-=160),this.$store.commit("setConfig",t)}}}),at=st,rt=(n("75ab"),Object(tt["a"])(at,ot,it,!1,null,"36dafd56",null)),ct=rt.exports,At=(n("d81d"),{name:"pcontent",data:function(){return{}},props:["carray"],render:function(){var t=arguments[0],e=this.fontFamily,n=this.fontSize,o=e;return o.fontSize=n,this.show?t("div",[this.carray.map((function(e){return t("p",{style:o,domProps:{innerHTML:e}})}))]):t("div")},computed:{show:function(){return this.$store.state.showContent},fontFamily:function(){return X.fonts[this.$store.state.config.font]},fontSize:function(){return this.$store.state.config.fontSize+"px"}},watch:{fontSize:function(){var t=this;t.$store.commit("setShowContent",!1),this.$nextTick((function(){t.$store.commit("setShowContent",!0)}))}}}),lt=At,ut=(n("ca36"),Object(tt["a"])(lt,c,A,!1,null,"7b03cca0",null)),ft=ut.exports,gt=n("bc3a"),dt=n.n(gt),ht={components:{PopCata:nt,Pcontent:ft,ReadSettings:ct},created:function(){var t=JSON.parse(localStorage.getItem("config"));null!=t&&this.$store.commit("setConfig",t)},beforeCreate:function(){var t=JSON.parse(localStorage.getItem("config"));null!=t&&this.$store.commit("setConfig",t)},mounted:function(){var t=this;this.loading=this.$loading({target:this.$refs.content,lock:!0,text:"正在获取内容",spinner:"el-icon-loading",background:"rgba(0,0,0,0)"});var e=this,n=sessionStorage.getItem("bookUrl"),o=sessionStorage.getItem("bookName"),i=sessionStorage.getItem("chapterIndex")||0,s=JSON.parse(localStorage.getItem(n));(null==s||i>0)&&(s={bookName:o,bookUrl:n,index:i},localStorage.setItem(n,JSON.stringify(s))),this.func_keyup=function(t){switch(t.key){case"ArrowLeft":t.stopPropagation(),t.preventDefault(),e.toLastChapter();break;case"ArrowRight":t.stopPropagation(),t.preventDefault(),e.toNextChapter();break;case"ArrowUp":t.stopPropagation(),t.preventDefault(),0===document.documentElement.scrollTop?e.$message.warning("已到达页面顶部"):g(0-document.documentElement.clientHeight+100);break;case"ArrowDown":t.stopPropagation(),t.preventDefault(),document.documentElement.clientHeight+document.documentElement.scrollTop===document.documentElement.scrollHeight?e.$message.warning("已到达页面底部"):g(document.documentElement.clientHeight-100);break}},this.getCatalog(n).then((function(n){var o=n.data.data;s.catalog=o,e.$store.commit("setReadingBook",s);var i=e.$store.state.readingBook.index||0;t.getContent(i),window.addEventListener("keyup",t.func_keyup)}),(function(t){throw e.loading.close(),e.$message.error("获取书籍目录失败"),t}))},destroyed:function(){window.removeEventListener("keyup",this.func_keyup)},watch:{chapterName:function(t){this.title=t},content:function(){this.$store.commit("setContentLoading",!1)},theme:function(t){this.isNight=6==t},bodyColor:function(t){this.bodyTheme.background=t},chapterColor:function(t){this.chapterTheme.background=t},readWidth:function(t){this.chapterTheme.width=t;var e=-((parseInt(t)+130)/2+68)+"px",n=-((parseInt(t)+130)/2+52)+"px";this.leftBarTheme.marginLeft=e,this.rightBarTheme.marginRight=n},popupColor:function(t){this.leftBarTheme.background=t,this.rightBarTheme.background=t},readSettingsVisible:function(t){if(!t){var e=JSON.stringify(this.$store.state.config);localStorage.setItem("config",e)}}},data:function(){return{title:"",content:[],noPoint:!0,isNight:6==this.$store.state.config.theme,bodyTheme:{background:X.themes[this.$store.state.config.theme].body},chapterTheme:{background:X.themes[this.$store.state.config.theme].content,width:this.$store.state.config.readWidth-130+"px"},leftBarTheme:{background:X.themes[this.$store.state.config.theme].popup,marginLeft:-(this.$store.state.config.readWidth/2+68)+"px"},rightBarTheme:{background:X.themes[this.$store.state.config.theme].popup,marginRight:-(this.$store.state.config.readWidth/2+52)+"px"}}},computed:{catalog:function(){return this.$store.state.catalog},windowHeight:function(){return window.innerHeight},contentHeight:function(){return this.$refs.content.offsetHeight},popCataVisible:{get:function(){return this.$store.state.popCataVisible},set:function(t){this.$store.commit("setPopCataVisible",t)}},readSettingsVisible:{get:function(){return this.$store.state.readSettingsVisible},set:function(t){this.$store.commit("setReadSettingsVisible",t)}},config:function(){return this.$store.state.config},theme:function(){return this.config.theme},bodyColor:function(){return X.themes[this.config.theme].body},chapterColor:function(){return X.themes[this.config.theme].content},popupColor:function(){return X.themes[this.config.theme].popup},readWidth:function(){return this.$store.state.config.readWidth-130+"px"},cataWidth:function(){return this.$store.state.config.readWidth-33},show:function(){return this.$store.state.showContent}},methods:{getCatalog:function(t){return dt.a.get("/getChapterList?url="+encodeURIComponent(t))},getContent:function(t){var e=this;this.$store.commit("setShowContent",!1),this.loading.visible||(this.loading=this.$loading({target:this.$refs.content,lock:!0,text:"正在获取内容",spinner:"el-icon-loading",background:"rgba(0,0,0,0)"}));var n=sessionStorage.getItem("bookUrl"),o=JSON.parse(localStorage.getItem(n));o.index=t,localStorage.setItem(n,JSON.stringify(o)),this.$store.state.readingBook.index=t;var i=this.$store.state.readingBook.catalog[t].title,s=this.$store.state.readingBook.catalog[t].index;this.title=i,g(this.$refs.top,{duration:0});var a=this;dt.a.get("/getBookContent?url="+encodeURIComponent(n)+"&index="+s).then((function(t){var n=t.data.data;a.content=n.split(/\n+/),e.$store.commit("setContentLoading",!0),a.loading.close(),a.noPoint=!1,a.$store.commit("setShowContent",!0)}),(function(t){throw a.$message.error("获取章节内容失败"),a.content="  获取章节内容失败!",t}))},toTop:function(){g(this.$refs.top)},toBottom:function(){g(this.$refs.bottom)},toNextChapter:function(){this.$store.commit("setContentLoading",!0);var t=this.$store.state.readingBook.index;t++,"undefined"!==typeof this.$store.state.readingBook.catalog[t]?(this.$message.info("下一章"),this.getContent(t)):this.$message.error("本章是最后一章")},toLastChapter:function(){this.$store.commit("setContentLoading",!0);var t=this.$store.state.readingBook.index;t--,"undefined"!==typeof this.$store.state.readingBook.catalog[t]?(this.$message.info("上一章"),this.getContent(t)):this.$message.error("本章是第一章")},toShelf:function(){this.$router.push("/")}}},pt=ht,mt=(n("ace6"),Object(tt["a"])(pt,o,i,!1,null,"0405dcaf",null));e["default"]=mt.exports},5629:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXN383Q4tDP4c/R5NEInCCXAAACVElEQVQozw3Hv2sTYRwH4M/79pJ7bZL2bXqtERJ97zjUpbZDhg6pfC8qibi8hLR0EaJ0EFxaCSWDxjfpj1zrYBcRBKE6SAfBJWsx9i8IQfdQxDlKtA6t2OnhQfN3lbG7ytYRywF8rVoPCNO0X2sQOKDpAnSDK2VwkHgmh5yLGT8qASt+2KofnNt2Xg1gf1UF8AoM6052cRMNaloLZb7RKQGrKKji2OefsZF+VqIvos5ZLVIZCX61JcwUdk56wASVkgQvzPfvmT2twTSwyYaC/Pl/UhAHorFhBgZtL6XdAZRp1tkPwC1NLa9CWs5prLhI85NBQsLdXvjDymG3/EbYfQhVNYqc3TtktQhWLY3ko0QsdMbSEp+64v0NfxyqLbIGdh6M2xHHlLBGqKTyQo4E/nebBgBfe1GpdeywYXc8CT7D3cKXuMXkBy4xN6o5OuKamYp3DVI6uccO9lxgd2CAlJgI2BGgaAgIJV/TYwKqu3WFccjbMuA+bVkWgS2bfnlRbD1Eb1sDyWMmjKYIBgGAWbqKRicfvzBkBIz3V5AKnguWdglQEysQsSuVzOg6ALy1pitA5ykGCsc857BRYcgCSZyFOdvoOigSGoPc5Ta73mgxshIcQE5sHMHd9D7yqITw7JO+GHVMxjhzYLcKPSEgmz3fU+BRy3iYNtiXLaBssCW8KguReqkQOTb3MStV0Ugt4U1eIs1RZWRII6Ww8xeNNItyGGQI4ZMlpg/3lQtkl2JFnBp1imRyFe0kK2Id3PCslMgiQNMS77gvFeDhG3cSkYvheeg/e7ClIh5oh+IAAAAASUVORK5CYII="},"58c8":function(t,e,n){},"5ca7":function(t,e,n){"use strict";n("a7bd")},"63f0":function(t,e,n){},"65f0":function(t,e,n){var o=n("861d"),i=n("e8b5"),s=n("b622"),a=s("species");t.exports=function(t,e){var n;return i(t)&&(n=t.constructor,"function"!=typeof n||n!==Array&&!i(n.prototype)?o(n)&&(n=n[a],null===n&&(n=void 0)):n=void 0),new(void 0===n?Array:n)(0===e?0:e)}},7286:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXr5djn4dTp49bt59rT6LKxAAACnElEQVQozw3NUUwScRzA8d8R6MF8YMIx8uk47hDSJbj14IPzOGc7jPLvwTGg5uAYDbe2tt56cLtznvEnS6yDqCcEaWi91DvrbLJZz7b1aFtz1aO+2OZWvn+/+4CHeB6BMYaqBLfjPNRY6RFT2JJYby+uAk4WUTrtlmJ4hgPYb2q1XGDQjaK8pgJHvqNaAX+KyuIkDXpgQinb46nOulnn4b5laUHTxLfseeArAoNOeJlOIjdoal0n1FA7tKFv5roK+YaHOqP3P0XyKHPHY+MhTRe5uCZnKhtJKw2eSrSoBDPLtpZuNcFNJcFyiCMxOaaHIfXz1e8HQbWLySrBQ4x0x1qlhnHlnz2HQEC6TNb0gTHXa7IKhcaHqkE015hk9whA0YeWiLIXf7Fa2CZo3DjqjB4tTuF8jIcbfcEx5z/w4sXpQhXW+ju0cqh7icTFmRMaG+v6CIvTjcSpHcH8JEsF3EPh3fRthYdVLLgI2fWXm85/pGFE4l046s70L+yKCcirGFR+jbpy3kMmiCGHrSezVONsn1RBixncyk2PcVWk7DlgxHo8iZwDyq5uAUD854dZhdIFYzKoQig2haUKi1lVufz2RZUZPZ41n/hrOQB6h0Hhg8I367FNoEHgeM/KY7szSeQwD8q2WE3HM35ZLl0K1MJiOtHIkBclRQUwZnyOWcNsRQQgVLj1PSqkjF9DsoOSaSg3iinKzvfmgsNFFfpP/2T3GLGvL4fHEfwIX1sVvXcPqLztehWGcfn9nI2U9nTfCgJPe/jFPLZwgVEzimBgAm0VIyK2tt1cE/AzQdLK+SxLSQ4aDCZnnId94OG2S1XwvnTbNk/ZnhyRCQT+sZM6z9g6LXL1BOBe+zJySiFkHAINCtnQokbCJ/apCv0foqPiZVfhpywAAAAASUVORK5CYII="},"746f":function(t,e,n){var o=n("428f"),i=n("5135"),s=n("e538"),a=n("9bf2").f;t.exports=function(t){var e=o.Symbol||(o.Symbol={});i(e,t)||a(e,t,{value:s.f(t)})}},"75ab":function(t,e,n){"use strict";n("63f0")},"7abd":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXm9PXq+Pno9vfs+vttWKBGAAACPElEQVQozw3RQWrbQACF4TfCMjPqZgIj4RRaxsZKE0PuMBZ2cLKaCI9RDAXFmJJknUWWI1O1UlamOMHJSjGkuFn3AD2Cr9CepDrAg+/xIxK4QwIqHHQkUhQ/WuphInVIFBojl8QXc012Tgq4RTtVHWVLZVFh1tEoI91uiN4joCqde8Ukn/zGM1B2W4ari2PtTwyw55Ld+Wways54qhGPyS6FzbIT3lIY8WwWdCq56Yolx6KmSKzoqrsCB5heAp4TGNQWJ1Pc6XlE5jQD5OlIX9I47A9uiUQcPQxcury/ToyxWJG/za6ki88crxKPocKS59Sl3EtBG7C89fCGflpfqoSzCeC4crioJA7F0V5+8MaSIk4qSCdwzpogmbqzEirVpGiS2dOVJvUuuqFEmhHao06KEpq+8lvHI14NJk3Qrmi9vBuRLwAz0qZB4hsDXQFXgtnlpDX3C6ug9BquSw/CYtwAzuTz5vuQNdr/YibhR68378ehZH30FSpjh71LpQkrsj+Q062h5WwZ5wlRoD6uQJy1DqvSYuCUapMBqT5YA4ZFw4KlWapxoUGlKWrx0eDQvmigu4WMYt97ruru98fYL8/0lG6CTOFcFWBhFK5gKw19h2JN808nh7xhkU6sWKLXdtkqBL6h+lULK5k19wFB/FldnGYf3LDeuf6IC2/MzJOSOP0qPxLqzaGIqtBcFIItrstkazONOkrc1D1czjuwEGESB4JJnjgSMN7PXAu7fZQpl1C236C+9mM4Af8P98Ch4R2TRl8AAAAASUVORK5CYII="},"802e":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEUWGBkYGhsdHyAfISI1t/v6AAAB5ElEQVQozxXQsYoTURSA4f/EeycZsDgDdySDjihk38Hy3GWi2J2BCaziQhaiaB+tt9AFu1kwvYUPsIXNPoB9BAUfwAfwEUzKv/v4odGrroyp9/rUaC6rZ5skv5F8qPsfYYP+yKUMymmAEEeW55oUR4o8jr05KNzJ07yvB7w0KKfLwcQUSjfmMU0PJfPHFoEVU+ohNrcKMEzMQ23FDnVSI2dqtYWI7KlLu6vE4UnyvKc3SJuL7lBbeEEl42ItpGLjzIT8PRJCmkRjVpVpsbJFVN0687okJNZiHAr5Z7MV0BnGIDc+THM1zlbieBc1Fq+tH5BH+OpnbWkj40hSqC8Lw2TvFuF0SUFJCk2IytXbjeqcRAt6NHpnrUkUU4KRzZs8RCK8N/Akn2W04LwxMU/V7XK0bDyN2RxfDyx7I4h5vjZby72V8UnOWumZL3qtYc+8DTE0siSBMXGhywx2dMYPnQHbxdFZ7deiNGxCCtD/QWnbwDoGhRYPDzUdUA3krjpnkvdAgDN4ddLkEQSov9qjd42HaDjI34gEqS9TUueAk+sc4qg5ws407KQYKs8G1jv4xBlqBVk6cb4dISZIwVi1Jzu4+HLk6lyfUxkXvwy+1Q+4WVdHIhwfybZ6CWVhxMEhShOgsP/HOW0MvZJeFwAAAABJRU5ErkJggg=="},"82da":function(t,e,n){},9078:function(t,e,n){"use strict";n("c0bc")},a4d3:function(t,e,n){"use strict";var o=n("23e7"),i=n("da84"),s=n("d066"),a=n("c430"),r=n("83ab"),c=n("4930"),A=n("fdbf"),l=n("d039"),u=n("5135"),f=n("e8b5"),g=n("861d"),d=n("825a"),h=n("7b0b"),p=n("fc6a"),m=n("c04e"),b=n("5c6c"),v=n("7c73"),C=n("df75"),y=n("241c"),B=n("057f"),S=n("7418"),I=n("06cf"),k=n("9bf2"),w=n("d1e7"),x=n("9112"),E=n("6eeb"),Q=n("5692"),U=n("f772"),D=n("d012"),V=n("90e3"),O=n("b622"),F=n("e538"),P=n("746f"),R=n("d44e"),M=n("69f3"),N=n("b727").forEach,K=U("hidden"),H="Symbol",J="prototype",z=O("toPrimitive"),L=M.set,W=M.getterFor(H),T=Object[J],q=i.Symbol,G=s("JSON","stringify"),Z=I.f,Y=k.f,j=B.f,X=w.f,$=Q("symbols"),_=Q("op-symbols"),tt=Q("string-to-symbol-registry"),et=Q("symbol-to-string-registry"),nt=Q("wks"),ot=i.QObject,it=!ot||!ot[J]||!ot[J].findChild,st=r&&l((function(){return 7!=v(Y({},"a",{get:function(){return Y(this,"a",{value:7}).a}})).a}))?function(t,e,n){var o=Z(T,e);o&&delete T[e],Y(t,e,n),o&&t!==T&&Y(T,e,o)}:Y,at=function(t,e){var n=$[t]=v(q[J]);return L(n,{type:H,tag:t,description:e}),r||(n.description=e),n},rt=A?function(t){return"symbol"==typeof t}:function(t){return Object(t)instanceof q},ct=function(t,e,n){t===T&&ct(_,e,n),d(t);var o=m(e,!0);return d(n),u($,o)?(n.enumerable?(u(t,K)&&t[K][o]&&(t[K][o]=!1),n=v(n,{enumerable:b(0,!1)})):(u(t,K)||Y(t,K,b(1,{})),t[K][o]=!0),st(t,o,n)):Y(t,o,n)},At=function(t,e){d(t);var n=p(e),o=C(n).concat(dt(n));return N(o,(function(e){r&&!ut.call(n,e)||ct(t,e,n[e])})),t},lt=function(t,e){return void 0===e?v(t):At(v(t),e)},ut=function(t){var e=m(t,!0),n=X.call(this,e);return!(this===T&&u($,e)&&!u(_,e))&&(!(n||!u(this,e)||!u($,e)||u(this,K)&&this[K][e])||n)},ft=function(t,e){var n=p(t),o=m(e,!0);if(n!==T||!u($,o)||u(_,o)){var i=Z(n,o);return!i||!u($,o)||u(n,K)&&n[K][o]||(i.enumerable=!0),i}},gt=function(t){var e=j(p(t)),n=[];return N(e,(function(t){u($,t)||u(D,t)||n.push(t)})),n},dt=function(t){var e=t===T,n=j(e?_:p(t)),o=[];return N(n,(function(t){!u($,t)||e&&!u(T,t)||o.push($[t])})),o};if(c||(q=function(){if(this instanceof q)throw TypeError("Symbol is not a constructor");var t=arguments.length&&void 0!==arguments[0]?String(arguments[0]):void 0,e=V(t),n=function(t){this===T&&n.call(_,t),u(this,K)&&u(this[K],e)&&(this[K][e]=!1),st(this,e,b(1,t))};return r&&it&&st(T,e,{configurable:!0,set:n}),at(e,t)},E(q[J],"toString",(function(){return W(this).tag})),E(q,"withoutSetter",(function(t){return at(V(t),t)})),w.f=ut,k.f=ct,I.f=ft,y.f=B.f=gt,S.f=dt,F.f=function(t){return at(O(t),t)},r&&(Y(q[J],"description",{configurable:!0,get:function(){return W(this).description}}),a||E(T,"propertyIsEnumerable",ut,{unsafe:!0}))),o({global:!0,wrap:!0,forced:!c,sham:!c},{Symbol:q}),N(C(nt),(function(t){P(t)})),o({target:H,stat:!0,forced:!c},{for:function(t){var e=String(t);if(u(tt,e))return tt[e];var n=q(e);return tt[e]=n,et[n]=e,n},keyFor:function(t){if(!rt(t))throw TypeError(t+" is not a symbol");if(u(et,t))return et[t]},useSetter:function(){it=!0},useSimple:function(){it=!1}}),o({target:"Object",stat:!0,forced:!c,sham:!r},{create:lt,defineProperty:ct,defineProperties:At,getOwnPropertyDescriptor:ft}),o({target:"Object",stat:!0,forced:!c},{getOwnPropertyNames:gt,getOwnPropertySymbols:dt}),o({target:"Object",stat:!0,forced:l((function(){S.f(1)}))},{getOwnPropertySymbols:function(t){return S.f(h(t))}}),G){var ht=!c||l((function(){var t=q();return"[null]"!=G([t])||"{}"!=G({a:t})||"{}"!=G(Object(t))}));o({target:"JSON",stat:!0,forced:ht},{stringify:function(t,e,n){var o,i=[t],s=1;while(arguments.length>s)i.push(arguments[s++]);if(o=e,(g(e)||void 0!==t)&&!rt(t))return f(e)||(e=function(t,e){if("function"==typeof o&&(e=o.call(this,t,e)),!rt(e))return e}),i[1]=e,G.apply(null,i)}})}q[J][z]||x(q[J],z,q[J].valueOf),R(q,H),D[K]=!0},a7bd:function(t,e,n){},ace6:function(t,e,n){"use strict";n("fc0c")},b165:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXe3t7a2trc3Nzg4OCXP9lCAAACoklEQVQozwXBzU/TYBwA4N+QEr4CNbSFwcFuowSqMRvEAwShHWAYNsu7dS0dLnGUSWT4kZB4lGzE4VtcwgIDJqcOWLJxcv4BOoQZuCPxSNSD4WSWLJGL8XmAIiyo2RgJ4A1pxQQlOxRAszLTdnPu2oQGb05RC5slJld7ZAIfo4O44Bn1ud59F0BcjnYOa17Jhwc6EdiKettncsXjT1f8KUBZUW41pK0Jc1Az4dEV3rkkPBtDSZ83Blyt0kSf2PRjzIykoBwINisPbPPtljdVE9iAXRfUPkXLVIgYrCccp5g687NdZbcJ+xa5VE/HhTtT23IKsN5jj/pcUd0dTZNAqCVw72n4gOwnTOC0vvHfaauT8d9zAoRRfPpISZRVyUiw8ELzOG1b2DZpFzkSrHLhq52twDEdyZHwvp2j4uv/bjvOf23/AcEtTuJbY5Cp4YcAer1IGkUzOo2rn8LQOKjFJw3NTw24nprQXY5aF4wxcqcSdbFQ00H4xFl8Drx4X4CikvAM1tuR8bKIBCBoLnKN10KJG4zKAsc7c9WEB9gnCi6BhVjqoco6t20ILAJuVctvaEZK732cRHDRmGfuihOam0o2CHByUZ/epCcVlRs2wmCnMqsd6aSim3ibBJtm1LGyXW3Bb7tJCPlFtUG+SvPdeEUAB60lNdo+VQbLcwRNVtT68FsLcr1+NotgNihlpExS1V2SFgNbeC8bEhgm8sM17wSi6Us2gxVWJU/5GKBpandvfyYbU1yHCLpCgWGbbPXn40rehEsUXKIJr9DMKgICfjc4bl1YfvUhE/YIECGRqjCxSM9hrybAIkND5OeWfFZsXkxB+qDzb7pUQ3EfQ3Ml6EChEt3D+iS01VqC7EQ/Z/DuPQcz4yChoFQJce2Qr+NNAv0HxofmpXGqgHkAAAAASUVORK5CYII="},b671:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEX48dr48Nf58tv379X17NJtIBxUAAACFUlEQVQ4y1XRUZakMAgF0Af2AiDWApDZgHZqAV1nZv9rGh7Rj7Y8McUFEg1wvcMESMNVD/neU8Xcaz7nYYkYlYO6Ti82PBI4BvIEg1aj3wKwRvIMgZsUy5LdhCawPFh1sZs4SrlyN9fQKpv8s5dgZ2eLyqqJiu+WkCmUEybXkm3INS01WAiv0PapJ0CZc0SJQUzcWnZYbOOY20iFD8Bk+/j2A3wNxH7GdShFYS5ff237kXh9I9zSkQmIAhOsOSVfJ6DIXTMDaPnzkRJ92S1BQQmXl5LdirgRLLDdcYqcGPwe3QN4xCBiGNbrqq9wpW1XCecChwaQdVOsRDpPCpeoolPdxeXp3WNB9PHVzWBHlygy4NJCCrFHREv6bDt0VGwJZASkpONmm1UseGeFKAQexgaAkrfYWl3AGxWOLL2AIMBNbCXpktmS3k3vHeYjGCPBa43wJTurO3ZFVpQSJdAZGLoHTyk1upkjxMEaIxum3iIARcCa5kSkFAW5fi1mUlL9eyOsaanFmOMruwvEdE3ZYzsRSzo5ewRLXyVPPEvknt8ij4DvCg2O7xOgBCUprEzV4z1WekSpUgI8DT2mrnSOXKRfQavwuKA1F+tFnMKdJSUpMA7wQAifWRkMgjUKKZE4lBl6MCM4B1pq1P4uIjDE6Pq6rL0FnW1nIFmta5vrSvq/Ch4tpqG/ZNyyWa5jZPktq81eYv8Bt5s4iFITOp4AAAAASUVORK5CYII="},b727:function(t,e,n){var o=n("0366"),i=n("44ad"),s=n("7b0b"),a=n("50c4"),r=n("65f0"),c=[].push,A=function(t){var e=1==t,n=2==t,A=3==t,l=4==t,u=6==t,f=7==t,g=5==t||u;return function(d,h,p,m){for(var b,v,C=s(d),y=i(C),B=o(h,p,3),S=a(y.length),I=0,k=m||r,w=e?k(d,S):n||f?k(d,0):void 0;S>I;I++)if((g||I in y)&&(b=y[I],v=B(b,I,C),t))if(e)w[I]=v;else if(v)switch(t){case 3:return!0;case 5:return b;case 6:return I;case 2:c.call(w,b)}else switch(t){case 4:return!1;case 7:c.call(w,b)}return u?-1:A||l?l:w}};t.exports={forEach:A(0),map:A(1),filter:A(2),some:A(3),every:A(4),find:A(5),findIndex:A(6),filterOut:A(7)}},c0bc:function(t,e,n){},c84b:function(t,e,n){"use strict";n.r(e);var o=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"detail-wrapper"},[n("div",{staticClass:"detail"},[n("div",{staticClass:"bar"},[n("el-breadcrumb",{attrs:{separator:"/"}},[n("el-breadcrumb-item",{staticClass:"index",attrs:{to:{path:"/"}}},[t._v("书架")]),n("el-breadcrumb-item",{staticClass:"sub"},[t._v("目录")])],1)],1),n("el-divider"),n("div",{staticClass:"catalog"},t._l(this.$store.state.catalog,(function(e){return n("div",{key:e.index,staticClass:"note",on:{click:function(n){return t.toChapter(e.url,e.title,e.index)}}},[t._v(" "+t._s(e.title)+" ")])})),0)],1)])},i=[],s=n("bc3a"),a=n.n(s),r={data:function(){return{key:"value"}},mounted:function(){var t=this;a.a.get("/getChapterList?url="+encodeURIComponent(sessionStorage.getItem("bookUrl"))).then((function(e){t.$store.commit("setCatalog",e.data.data),sessionStorage.setItem("catalog",JSON.stringify(e.data.data))}),(function(t){throw t}))},methods:{toChapter:function(t,e,n){sessionStorage.setItem("chapterID",n),this.$router.push({path:"/chapter"})}}},c=r,A=(n("5ca7"),n("2877")),l=Object(A["a"])(c,o,i,!1,null,"57115a66",null);e["default"]=l.exports},ca36:function(t,e,n){"use strict";n("58c8")},cf68:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAAD1BMVEXm5ubo6Ojp6enr6+vt7e1FnZagAAACrklEQVQ4yx1SixUbMQgT3AKAFwDcAfzpBN1/qMrJS5w7bCQhC6IGSUGYQJd6Ox9ZPXi1AGJBavhUTT0JjYPGAab9WcDYIxsmlnxkayX8mhxCmKHA75az5cfRbWybEExiu08xDSgGym0mwuf3j4SvHeQxDJJzh2zp4iOlrD8iOb4SXyC1wiOLRTcnrje+nGamFeXVKWkmzbFIPChkmJ6Fg7mBpV8n+JGOVCd4jv1thThkjeQGNeafpeV3rsEWLfyWc8tC9jOv6FQ8rRzHOOVB+jCYEUAJpDvh8xHNFm/Tm5p5lw94Pp3NhtKEfQsGvnXhowdZE73hPwxKvjDd4i4PCdd0fe3W5fO8ktAsUAacLgstpUw60JCiPLg2XpkgiqPIYYXJd9ksGIT3q+LlevypzItvO+s0F1dBzVr2QDMUkYmuyGcrIS44mVJ7JVKwQXjYuBYp0Uetecbswzsikzu3gUR8bJC/C8Gd/NAzI/xdUGOYQQHDZ8X2d5XuzGRUiXAi9si5CRgoiToRZPtzLJkd0FUHRHZwJf0BHT1sE7gcnh0jmKKlSSF4/GBirGk5+K9NKlGDCfc9JtPhg78JdabH0YQRKNZnJ8tFnPfXHJb4xum1TTCeEmyEdbyEJLjznMLHuFD2Y9NEkSleIBs7SiCbblhgctVi9ch++kDYnn1C9DA5TvdPsToXM55wI6k+8eKT1blwPTqWb5CFJ+7dTBmab+KHy+xwNtItXhZNSpHD2fxnynrxG3ZBKRe8KBpXk11AnadlccEhr9w1nBBvBylNkv7A8eqpGBCDqhitmWQXBjjdS6idr/QjXWLDeMzMbVDoJuM8zN7WenMZWXgZ2vX3F01J3jHZbwk1LRP+DWEvDJtOUoh/AIaBUz5VpWyhuyx4QtgL/NmgC6kM/JvNe+R/C/5aL7BKIbYAAAAASUVORK5CYII="},d0e3:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEXh7eHl8eXj7+Pn8+eTbH1KAAACPElEQVQozxWPQWrbQABF/0xn3JFKQRTZOIuUsbCCbOgdRoYEOauxkYPcTRyTlPQWIxEltrsRwQ6hK9nEQek6F+gNTE/Q3qLLusv34cN7SH3mFicdYW4gNIhJWXPBRVXzjcFD0IqeU4o4PRbAIVjyico0vJpIifqPfL80QN9DAQY5ucRHE/hpHxBldXe9GilaHKcKMlj6pho2zXgkNdBl0oJ8kiF1DSiJF1ZHBJkQr0Dbux/5I42Zp4cFahJDFGeW6/QjBwmFY/Q7vZ2SnoOdW2parv/Cnm81+m0xrEfiVXQ3W4nOXIqVYi3l6AAQBwMFkViVBANMto4enXHPNTkHBB0oVj4r5vHzCWayrgBvxtygDlDB2CNDjd80ZInY69aKVYZcfJ8DW+fWuc+syEODALx+ojqoafHsthTI+ZW27PGpIeo/cR6YKcbqIuIFhHmBrzAovzIOOJk1ucvcDzrMRYGVBH2yvcAOf0KiKwfRovBI3tm/kW1eemtfNWwIIXE2mJNhvoszfmMBfRCv0OPwd2321uDW3nx2q/BDxFVeoN1g7a6Im8yRnoawa8kbdXnU0cHeTMxKfZGlJgvLb3sKsxgglQnDdAfvj9LUnqWRDo0GiUmPwyU7TAsD7wHeIW3Nfy1qVGKoE9NgJCdYCAexNRob9yCn4DAQmXtQuUtera6bEmTTXhZy6h856xi4mnEl6BI9mfISkLbtJyZIMJIAUd5ZOBEu88KRAk71yxfItj/hpIB0Errv4gO1os4/UICf+o3kkqwAAAAASUVORK5CYII="},d28b:function(t,e,n){var o=n("746f");o("iterator")},d81d:function(t,e,n){"use strict";var o=n("23e7"),i=n("b727").map,s=n("1dde"),a=s("map");o({target:"Array",proto:!0,forced:!a},{map:function(t){return i(this,t,arguments.length>1?arguments[1]:void 0)}})},df5e:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAATlBMVEXdzaHh0KPgz6LdzKDezqLczJ7ezZ/fz6Dcy5zi0aXdzZ3fz6Tfz57h0KDg0aLcyZrg0KXi0qPfzZ3j06bh0qbdyJbfzJrhz5/cxpLZwo0vDconAAAFn0lEQVRIxxyPW5LjMAwDAT5FybLl2JnM3P+i6+wXWVC1GoQGaD0h4XM3Q5o4T0HgABHBi6pZ4CDXXcUOFd6VhqC3Kch4EI8w9oMXwvU6m5LOOvcxKMOhuu8i5+5cMjcgb0t4F2uvOoeI3/MlT4IqsbtM9UG2AGSXUOsxzPevnXzK1CSHytZLvx7VdQmUcJsJCxJh2nmHW12Qod1qPjt8pih47uQ9aGpoNWF+yElCt60oH7vdIU/MnlRPSBLC/VwqxcKR8PFqnADN9ih5ufqnTlG9KwCofvs7kKYqOPHTNMQ93j9qNImFw9vjHPZ0F1m8hUUVB/Q/TrRYDMXr9++APMFARAt6sPh6wVAXzxUGhZsFUwCNfPZ8/72TAHebAhvuOuT3gO1Vn5d9Jd5sBRkg0p2seL9B7ulkjFJFIt9HPpLzdSzzMP3UcodAfMqC6pBuET2heHK1itZf1GZ1bi0BwOSxiCS8f/JBHMPMM4XCu3Mt1uz9lJbDJRqsKDZuikzkvskQEz6hanfDfO494azY5JpqPqOF1RhxD9XYEdaNxiqWqakKgmPfmrsta8KAiwF4HBxGVUJAgeSqQaiRRZJ7D2jedhw5t1CIAKxag0CBA60BpoBE6DcUi8O5AuM4pLfN0kHLmeu2B4e6HofqbgxsTWUw3PAODqa1oDtyzgXBlusi1KFdclMPE8O3jvLJ8RNi5/RxDQVzVmXA233XQ4KummunfxvLOZo+iH37964YjP06995CTdu9hsvErqJNzmf4wTrZ5DL7+qW9EoLnadrx67b8dUtrJnBXaT1N1uvPaYRKpWkq52xNsMN7vv4Sdryt/f4MhQoMCKnvVxikai1CQ6ZsnwJDc8+3Y/z8HcfvYQNq66pnAu1Hwa+3KNSwbNu8h3nDPqTl9fl7tx8fBhFfdS0o0F3JUKEZtZG9b/LZEM95lzaR30OnWPzroMxyZYdBIMoMnpN0J+m7/40+/P4soFSUjgzE7yY5zrMJuoZv0CmpVguYx1pprfb5HOviRVhHUVi/352shxCYrYBZxGtVaxiAz/MsaGSIsB7R1t4zJXH//n7RTTQQwxqcGEqEvklFHUgiO2GvJV+jAIPR+N29usWDoiSOVrN3XuqT1egQJAAU9EwslVJC8u0rGcy+WPqktJhjfMpatIG6CDAb0v5H34MGKqiVRue7GGLZ9Otxtt4JIrAhxBDwDuqI9JavcO0A7GlqFt219tH/bln9jBXzaKWAEqJV0CBxs5TwM8EvUPHaa8S86vN303MVWOsl3goDBHPWSoQ9c0kQmCKljfsKNH1+ofEOHW8a9a7glZGS8fPieL/SRSs0LAhI4FDTnXs1QYtubv2+IXPZpHB4bhivRexBkYKsSrYXNjvMUbVXpVJ+N6haV72c1k2zrnv5IYBMJBYTSZx0KTkoM3vY93rU/qs7zHplc/3d2ACadhFWByrn9LUk2IWb5JywvawTQc3F0iz+lgsBmInAIemBJtft2plKIlAFOgcroigrG2XlDsAzywQECNyaI8yr2ogoh7D4qJOYmZBzQgoZAM1PAcB8sDrr1uE5CDMR+nWSSVUGUCHAs8Vd21HOE0FzNj37pX0sLp9p3K8k++xxpkmzDxK64rmTSJnDUuIgTeslui6lg92jonZXI4jqNiUuzN4IagcKMjCniMGCODoo8T4tGDprn2hRww+NrnYiCwokd9iiWrkmbRfXYGLAoZrjO1lVQKExjUy5fIkgJURmz2uGFdASwwlWx5gDVTMK7hP6ISRVsFbYNmqtZL9MQtio285PaekyzDhZmtdexCYB0SZcTmBdhvdbmAEonk8hwcHQuZN1kVqrhyKoHHsnQhQAjF7SG533Da2S4LGjx1LoZqp7XeKQLDUBmYmydG0NQHpMeR5lRIRQc1PQ2ASMQflF4YBDMt0/GFlEHeRwCcEAAAAASUVORK5CYII="},e01a:function(t,e,n){"use strict";var o=n("23e7"),i=n("83ab"),s=n("da84"),a=n("5135"),r=n("861d"),c=n("9bf2").f,A=n("e893"),l=s.Symbol;if(i&&"function"==typeof l&&(!("description"in l.prototype)||void 0!==l().description)){var u={},f=function(){var t=arguments.length<1||void 0===arguments[0]?void 0:String(arguments[0]),e=this instanceof f?new l(t):void 0===t?l():l(t);return""===t&&(u[e]=!0),e};A(f,l);var g=f.prototype=l.prototype;g.constructor=f;var d=g.toString,h="Symbol(test)"==String(l("test")),p=/^Symbol\((.*)\)[^)]+$/;c(g,"description",{configurable:!0,get:function(){var t=r(this)?this.valueOf():this,e=d.call(t);if(a(u,t))return"";var n=h?e.slice(7,-1):e.replace(p,"$1");return""===n?void 0:n}}),o({global:!0,forced:!0},{Symbol:f})}},e160:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAADFBMVEX6+fP8+/X+/ff///kbczPAAAACeElEQVQozxXHQUgUUQAG4P8936yzs6VvZNZmN9QxVxiF9OLBoOjtOC6rQq6ygXjI2fCQBdXBg4egtzFGdqkoI+zgBFbqkm3hQSxhFYLotOcubeKhOnVYoqQy+m4f5g5TvpX0xHLbLY9j8SMhJp+Jk4LfAUS2kVRIjILmnwGBTX42PhCVlDJQkIiy2nWAvaJ1h+oFIpJ0hMSYVbyyrgDWshcMpMyL1brPDQKWmduO+KTJ6XeXAMK9Yc3FpD7atyNwg6kt5XgFpLPhjUTFSYVn2abDiugGShwD8JTVRJVo/2ecuKtRb/qc4BK+9TboFfokog4T2Fn6Oqdnsjk90NMS76Rji6E0NmwkPBAZ4Xbkw8KoDAkAbEhkc78e9omxxgxg6qa5HvMv+UZbCV0qmHnSHKl5TxeA2XTCGWekR581mwC5crBH81PznASqB9va3TbkYAjJPLfg5uBfXaJgIgIBv9eessRIhxe7PA7kj6uUMeMaQ/OEQOYRaaHlqH2Gxwsl6E/pwVY5FH7uCypBZPKvDQyVziYBrAkMURe2MOOOxG/eQpp5PF+bFzUV5HtPj9GeiVSNZDELleifYTp9NAjsoiXg4cW+4ZORkdSMB/B74aAdjhsVakhgkugsbmqcDSLEoWp8zRjrux3tli6Q5uM3E+maT99Wy0RiP7tboiuRZle2c6CYeL2kcUc1KvPtQKucogMadKVTQOJYCeyCYlhQQ/Q7Etfd/vBygy9iqy+LyHeF46saCYvW6ingsbA9RBWtdi8GgUXW+oQx9/wP6bAAX1TWeV+CbShZDlQ9xT6SoSxZmKRAkmXb60kzEzkRF+Ccb94BGspGJoN/UzmyR4wjXHAAAAAASUVORK5CYII="},e538:function(t,e,n){var o=n("b622");e.f=o},e8b5:function(t,e,n){var o=n("c6b6");t.exports=Array.isArray||function(t){return"Array"==o(t)}},ec0f:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyBAMAAADsEZWCAAAALVBMVEXx58b168ny6Mjz6sn06sf27Mvw5sTz6cbw5cLy58T37svv47/168v37s7t4Ltrv0//AAAEjUlEQVQ4yw2Ty2sTURxGf3dmOqmPxb0zmaStCnfmZpL6gpmbxIpUSMZGrSJkxsZiVZimNVaqMklrUnWTRq2KIDFWWx+IFrIRFxXEB4KIgqu6EBdu7M6FIPg32PW3+DhwDmBaYrK56KP4HGIsvg/uvOV0wK+qgBMlO9BujuH4DSJlOseqV5a/BEF97gt0ChyIPqBhXI9BtqtIB8vJB/LdCQ3OVjaLNX0g7+OmoI4e7nkemAqX6o8vg0yyQAyQS7IfgvFbI+6QyI3R4KELxw7kwM2ooQfyQigYnwY5MZbMlHI1DvnQVCoVcrt+R+bO7vPDif3ybNajwqAAe443dpfDsPt379VMWZzGRuqM79mQF+DUz9nt74bQ8J/O80MtVR51U02JKKmTCvTzLVf+vuxP/aHnPo9+2bW+zVsJ0Y630/CrfzX+b+UL+7O68Rczv+7lrMh5etfKXvhc2rk6KforxuoO2xB2tcxKfeXHt18rHOiHI/0RRjW/YGRDkHiwo3nzqL60o58C/bgRuaj7vk+QOwOhpnFNdjuWpKMCGP8Yapu9Ty5FTHKQLGSEFikjd9ADwP9ciaNNjc5qMH6w50AF/LKOsOYqsOG9GjKgc7ZXolqntm6fysJ6Ma6ll2CiqmOgE6O7x1wXExklbeqMYcwsmJmOoigt8SBg2WfilDSsAZJcBxDcrqtBXzFQJqZNHfscyIhoZlygAtyYAceah+elrFbI+46gEHDGiW878Kj7JpWyfhg6iyRMymV1MKBSeVpfgLHIohyTojI6sRyK1VpcqzVZeEBLOnA9unhGKUXPJDYtV9Dxuz4iA5xSkSWhCJdAiJR9PHlvfvbntbrR14FDqUNRAYDJmSnv3oKxuz5+7fiblgVJyYLTbgUM05P7LESkoXvyWNfb0aUU6FZizgQIa25VqKQZqFrk6v6BsqqIHlQmkQ9KrBhkC20/DrFsAFEEYLjM+lj2wYHXCwnNvZQR42XJ2iVK+UBXnI+OBE6oXpUUHiQ1yg0MhA03iwGbnOdQYc1CMiPIPQrCQJFH4L4BMFktAtKd9PN5gnU2Gra4KuK+V+mjtBRpAGIqDVe4wnSnajiFGO5d7smvhVQEMEYwqshrENIEaY7YeblJYtsb3QhAHWZCEKK67swwPMKw0If1Ta+6DgHmlgPzcUTSbi3rrv1Y64/BYEMPQ5SDHUOR022B4QRF6xLUPAaPX/V4IDI5N2BMwx4LqO1uO4j6uW7NvM7lATqGAxY/ZHVgoGZbu7SvkNR75x6qGSB23FdouENVwN7sCbewTdsXGrrnQ5ZZKOCOFtMTIzxlPu6eYmtL+nMFmoK7OeXajn86r9sqWbfmvHC4IagE5qfCPGZvLSq5F55hHIxJFa4/vRxHBlz0og4TojU1l/MOHJX17lybdF0mQhFO44JYUNt3UA473IXw/iPfDWtKG5oFSXIF5iU/VnyDSjxxeDk3jAXRyVyGTNB9FxH9qcFDNJpVbt2y9LytUXkK7Py6+z1RezHQqnoY8XcLimmd8dCnBhQCuaGpJCq3SoIlmYvLz8UkWhJw7T8k+Db/DYEKwgAAAABJRU5ErkJggg=="},fc0c:function(t,e,n){}}]); \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/manifest.json b/app/src/main/assets/web/bookshelf/manifest.json new file mode 100644 index 000000000..0e2f5f966 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/manifest.json @@ -0,0 +1 @@ +{"name":"yd-web-tool","short_name":"yd-web-tool","theme_color":"#4DBA87","icons":[{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}],"start_url":".","display":"standalone","background_color":"#000000"} \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/precache-manifest.5ae9ceec57e7f0f3cc808807b7fe5f32.js b/app/src/main/assets/web/bookshelf/precache-manifest.5ae9ceec57e7f0f3cc808807b7fe5f32.js new file mode 100644 index 000000000..f122b5e28 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/precache-manifest.5ae9ceec57e7f0f3cc808807b7fe5f32.js @@ -0,0 +1,70 @@ +self.__precacheManifest = (self.__precacheManifest || []).concat([ + { + "revision": "c6913d775f2f965ac5f3", + "url": "css/about.b9bb4fe0.css" + }, + { + "revision": "c7b3e35a4e0391b1ed37", + "url": "css/app.e4c919b7.css" + }, + { + "revision": "9a65f05f9810a3ea7f46", + "url": "css/chunk-vendors.8a465a1d.css" + }, + { + "revision": "5ab5c6be15b21e2d609b", + "url": "css/detail.e03dc50b.css" + }, + { + "revision": "535877f50039c0cb49a6196a5b7517cd", + "url": "fonts/element-icons.535877f5.woff" + }, + { + "revision": "732389ded34cb9c52dd88271f1345af9", + "url": "fonts/element-icons.732389de.ttf" + }, + { + "revision": "f9a3fb0e145017e166dd4d91d9280cc4", + "url": "fonts/iconfont.f9a3fb0e.woff" + }, + { + "revision": "f39ecc1a1d2a1eff3aca8aadd818bb61", + "url": "fonts/popfont.f39ecc1a.ttf" + }, + { + "revision": "6c094b6d4ae9404dbed273c41b06fae8", + "url": "fonts/shelffont.6c094b6d.ttf" + }, + { + "revision": "8a8424347500238b7b6c08a98d0f89af", + "url": "index.html" + }, + { + "revision": "c6913d775f2f965ac5f3", + "url": "js/about.9f8f9ac0.js" + }, + { + "revision": "5998ccb313ed338c15e1", + "url": "js/about~detail.8270a871.js" + }, + { + "revision": "c7b3e35a4e0391b1ed37", + "url": "js/app.e84ee963.js" + }, + { + "revision": "9a65f05f9810a3ea7f46", + "url": "js/chunk-vendors.3ef7796f.js" + }, + { + "revision": "5ab5c6be15b21e2d609b", + "url": "js/detail.4e6a53a9.js" + }, + { + "revision": "b46d04eb43bc31ca0f9f95121646440d", + "url": "manifest.json" + }, + { + "revision": "b6216d61c03e6ce0c9aea6ca7808f7ca", + "url": "robots.txt" + } +]); \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf/robots.txt b/app/src/main/assets/web/bookshelf/robots.txt new file mode 100644 index 000000000..eb0536286 --- /dev/null +++ b/app/src/main/assets/web/bookshelf/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/app/src/main/assets/web/bookshelf/service-worker.js b/app/src/main/assets/web/bookshelf/service-worker.js new file mode 100644 index 000000000..e2e43f1cc --- /dev/null +++ b/app/src/main/assets/web/bookshelf/service-worker.js @@ -0,0 +1,34 @@ +/** + * Welcome to your Workbox-powered service worker! + * + * You'll need to register this file in your web app and you should + * disable HTTP caching for this file too. + * See https://goo.gl/nhQhGp + * + * The rest of the code is auto-generated. Please don't update this file + * directly; instead, make changes to your Workbox build configuration + * and re-run your build process. + * See https://goo.gl/2aRDsh + */ + +importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js"); + +importScripts( + "precache-manifest.5ae9ceec57e7f0f3cc808807b7fe5f32.js" +); + +workbox.core.setCacheNameDetails({prefix: "yd-web-tool"}); + +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +/** + * The workboxSW.precacheAndRoute() method efficiently caches and responds to + * requests for URLs in the manifest. + * See https://goo.gl/S9QRab + */ +self.__precacheManifest = [].concat(self.__precacheManifest || []); +workbox.precaching.precacheAndRoute(self.__precacheManifest, {}); diff --git a/app/src/main/assets/web/favicon.ico b/app/src/main/assets/web/favicon.ico new file mode 100644 index 000000000..c3d5e6bc0 Binary files /dev/null and b/app/src/main/assets/web/favicon.ico differ diff --git a/app/src/main/assets/web/images/bg.jpg b/app/src/main/assets/web/images/bg.jpg new file mode 100644 index 000000000..91194db75 Binary files /dev/null and b/app/src/main/assets/web/images/bg.jpg differ diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html new file mode 100644 index 000000000..1b6e48105 --- /dev/null +++ b/app/src/main/assets/web/index.html @@ -0,0 +1,68 @@ + + + + + + Legado web 导航 + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/web/rssSource/index.css b/app/src/main/assets/web/rssSource/index.css new file mode 100644 index 000000000..97cd3ca6e --- /dev/null +++ b/app/src/main/assets/web/rssSource/index.css @@ -0,0 +1,150 @@ +body { + margin: 0; +} +.editor { + display: flex; + align-items: stretch; +} +.setbox, +.menu, +.outbox { + flex: 1; + display: flex; + flex-flow: column; + max-height: 100vh; + overflow-y: auto; +} +.menu { + justify-content: center; + max-width: 90px; + margin: 0 5px; +} +.menu .button { + width: 90px; + height: 30px; + min-height: 30px; + margin: 5px 0px; + cursor: pointer; +} +@keyframes stroker { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -240; + } +} +.button rect { + width: 100%; + height: 100%; + fill: transparent; + stroke: #666; + stroke-width: 2px; +} +.button rect.busy { + stroke: #fd1850; + stroke-dasharray: 30 90; + animation: stroker 1s linear infinite; +} +.button text { + text-anchor: middle; + dominant-baseline: middle; +} +.setbox { + min-width: 40em; +} +.rules { + overflow: auto; +} +.tabbox { + flex: 1; + display: flex; + flex-flow: column; +} +.rules > * { + display: flex; + margin: 2px 0; +} +.rules textarea { + flex: 1; + margin-left: 5px; +} +.rules > *, +.rules > * > div, +.rules textarea { + min-height: 1em; +} +textarea { + word-break: break-all; +} +.tabtitle { + display: flex; + z-index: 1; + justify-content: flex-end; +} +.tabtitle > div { + cursor: pointer; + padding: 1px 10px 0 10px; + border-bottom: 3px solid transparent; + font-weight: bold; +} +.tabtitle > .this { + color: #4f9da6; + border-bottom-color: #4ebbe4; +} +.tabbody { + flex: 1; + display: flex; + margin-top: -1px; + border: 1px solid #a9a9a9; + height: 0; +} +.tabbody > * { + flex: 1; + flex-flow: column; + display: none; +} +.tabbody > .this { + display: flex; +} +.tabbody > * > .titlebar { + display: flex; +} +.tabbody > * > .titlebar > * { + flex: 1; + margin: 1px 1px 1px 1px; +} +.tabbody > * > .context { + flex: 1; + flex-flow: column; + border: 0; + padding: 5px; + overflow-y: auto; +} +.tabbody > * > .inputbox { + border: 0; + border-bottom: #a9a9a9 solid 1px; + height: 15px; + text-align: center; +} +.link > * { + display: flex; + margin: 5px; + border-bottom: 1px solid; + text-decoration: none; +} +#RuleList > label > * { + background: #eee; + padding-left: 3px; + margin: 2px 0; + cursor: pointer; +} +#RuleList input[type="radio"] { + display: none; +} +#RuleList input[type="radio"]:checked + * { + background: #15cda8; +} +.isError { + color: #ff0000; +} diff --git a/app/src/main/assets/web/rssSource/index.html b/app/src/main/assets/web/rssSource/index.html new file mode 100644 index 000000000..472c511c2 --- /dev/null +++ b/app/src/main/assets/web/rssSource/index.html @@ -0,0 +1,243 @@ + + + + + + 阅读3.0订阅源编辑器_V4.0 + + + + + +
+
+
+ ←主页 + 订阅源 +
+
+
基本
+
+
源域名 :
+ +
+
+
源名称 :
+ +
+
+
图标  :
+ +
+
+
源分组 :
+ +
+
+
源注释 :
+ +
+
+
登录地址:
+ +
+
+
登录界面:
+ +
+
+
登录检测:
+ +
+
+
并发率 :
+ +
+
+
请求头 :
+ +
+
+
分类地址:
+ +
+

+
列表规则
+
+
列表样式:
+ +
+
+
列表规则:
+ +
+
+
标题规则:
+ +
+
+
时间规则:
+ +
+
+
翻页规则:
+ +
+

+
WebView规则
+
+
加载地址:
+ +
+
+
启用JS :
+ +
+
+
描述规则:
+ +
+
+
图片地址:
+ +
+
+
原文链接:
+ +
+
+
内容规则:
+ +
+
+
内容样式:
+ +
+

+
其它规则
+
+
启用  :
+ +
+
+
排序编号:
+ +
+
+
+ +
+
+
+
编辑源
+
调试源
+
源列表
+
帮助信息
+
+
+
+ +
+
+ +
+
+ +
+ + + + +
+
+
+
+ +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/app/src/main/assets/web/rssSource/index.js b/app/src/main/assets/web/rssSource/index.js new file mode 100644 index 000000000..235abf332 --- /dev/null +++ b/app/src/main/assets/web/rssSource/index.js @@ -0,0 +1,395 @@ +// 简化js原生选择器 +function $(selector) { return document.querySelector(selector); } +function $$(selector) { return document.querySelectorAll(selector); } +// 读写Hash值(val未赋值时为读取) +function hashParam(key, val) { + let hashstr = decodeURIComponent(window.location.hash); + let regKey = new RegExp(`${key}=([^&]*)`); + let getVal = regKey.test(hashstr) ? hashstr.match(regKey)[1] : null; + if (val == undefined) return getVal; + if (hashstr == '' || hashstr == '#') { + window.location.hash = `#${key}=${val}`; + } + else { + if (getVal) window.location.hash = hashstr.replace(getVal, val); + else { + window.location.hash = hashstr.indexOf(key) > -1 ? hashstr.replace(regKey, `${key}=${val}`) : `${hashstr}&${key}=${val}`; + } + } +} +// 创建源规则容器对象 +function Container() { + let ruleJson = {}; + + // 基本以及其他 + $$('.rules .base').forEach(item => ruleJson[item.title] = ''); + ruleJson.customOrder = 0; + ruleJson.enabled = true; + + return ruleJson; +} +// 选项卡Tab切换事件处理 +function showTab(tabName) { + $$('.tabtitle>*').forEach(node => { node.className = node.className.replace(' this', ''); }); + $$('.tabbody>*').forEach(node => { node.className = node.className.replace(' this', ''); }); + $(`.tabbody>.${$(`.tabtitle>*[name=${tabName}]`).className}`).className += ' this'; + $(`.tabtitle>*[name=${tabName}]`).className += ' this'; + hashParam('tab', tabName); +} +// 源列表列表标签构造函数 +function newRule(rule) { + return ``; +} +// 缓存规则列表 +var RuleSources = []; +if (localStorage.getItem('RssSources')) { + RuleSources = JSON.parse(localStorage.getItem('RssSources')); + RuleSources.forEach(item => $('#RuleList').innerHTML += newRule(item)); +} +// 页面加载完成事件 +window.onload = () => { + $$('.tabtitle>*').forEach(item => { + item.addEventListener('click', () => { + showTab(item.innerHTML); + }); + }); + if (hashParam('tab')) showTab(hashParam('tab')); +} +// 获取数据 +function HttpGet(url) { + return fetch(hashParam('domain') ? hashParam('domain') + url : url) + .then(res => res.json()).catch(err => console.error('Error:', err)); +} +// 提交数据 +function HttpPost(url, data) { + return fetch(hashParam('domain') ? hashParam('domain') + url : url, { + body: JSON.stringify(data), + method: 'POST', + mode: "cors", + headers: new Headers({ + 'Content-Type': 'application/json;charset=utf-8' + }) + }).then(res => res.json()).catch(err => console.error('Error:', err)); +} +// 将源表单转化为源对象 +function rule2json() { + let RuleJSON = Container(); + // 转换base + Object.keys(RuleJSON).forEach(key => { + if (!key.startsWith("rule")) { + RuleJSON[key] = $('#' + key).value; + } + }); + + RuleJSON.lastUpdateTime = new Date().getTime(); + RuleJSON.customOrder = RuleJSON.customOrder == '' ? 0 : parseInt(RuleJSON.customOrder); + RuleJSON.enabled = RuleJSON.enabled == '' || String(RuleJSON.enabled).toLocaleLowerCase().replace(/^\s*|\s*$/g, '') == 'true'; + return RuleJSON; +} +// 将源对象填充到源表单 +function json2rule(RuleEditor) { + let RuleJSON = Container(); + // 转换base + Object.keys(RuleJSON).forEach(key => { + let val = RuleEditor[key]; + if (typeof val == "number") { + $("#" + key).value = val ? String(val) : '0'; + } + else if (typeof val == "boolean") { + $("#" + key).value = val ? String(val) : 'false'; + } + else { + $("#" + key).value = val ? String(val) : ''; + } + }); +} +// 记录操作过程 +var course = { "old": [], "now": {}, "new": [] }; +if (localStorage.getItem('rssSourceCourse')) { + course = JSON.parse(localStorage.getItem('rssSourceCourse')); + json2rule(course.now); +} +else { + course.now = rule2json(); + window.localStorage.setItem('rssSourceCourse', JSON.stringify(course)); +} +function todo() { + course.old.push(Object.assign({}, course.now)); + course.now = rule2json(); + course.new = []; + if (course.old.length > 50) course.old.shift(); // 限制历史记录堆栈大小 + localStorage.setItem('rssSourceCourse', JSON.stringify(course)); +} +function undo() { + course = JSON.parse(localStorage.getItem('rssSourceCourse')); + if (course.old.length > 0) { + course.new.push(course.now); + course.now = course.old.pop(); + localStorage.setItem('rssSourceCourse', JSON.stringify(course)); + json2rule(course.now); + } +} +function redo() { + course = JSON.parse(localStorage.getItem('rssSourceCourse')); + if (course.new.length > 0) { + course.old.push(course.now); + course.now = course.new.pop(); + localStorage.setItem('rssSourceCourse', JSON.stringify(course)); + json2rule(course.now); + } +} +function setRule(editRule) { + let checkRule = RuleSources.find(x => x.sourceUrl == editRule.sourceUrl); + if ($(`input[id="${hex_md5(editRule.sourceUrl)}"]`)) { + Object.keys(checkRule).forEach(key => { checkRule[key] = editRule[key]; }); + $(`input[id="${hex_md5(editRule.sourceUrl)}"]+*`).innerHTML = `${editRule.sourceName}
${editRule.sourceUrl}`; + } else { + RuleSources.push(editRule); + $('#RuleList').innerHTML += newRule(editRule); + } +} +$$('input').forEach((item) => { item.addEventListener('change', () => { todo() }) }); +$$('textarea').forEach((item) => { item.addEventListener('change', () => { todo() }) }); +// 处理按钮点击事件 +$('.menu').addEventListener('click', e => { + let thisNode = e.target; + thisNode = thisNode.parentNode.nodeName == 'svg' ? thisNode.parentNode.querySelector('rect') : + thisNode.nodeName == 'svg' ? thisNode.querySelector('rect') : null; + if (!thisNode) return; + if (thisNode.getAttribute('class') == 'busy') return; + thisNode.setAttribute('class', 'busy'); + switch (thisNode.id) { + case 'push': + $$('#RuleList>label>div').forEach(item => { item.className = ''; }); + (async () => { + await HttpPost(`/saveRssSources`, RuleSources).then(json => { + if (json.isSuccess) { + let okData = json.data; + if (Array.isArray(okData)) { + let failMsg = ``; + if (RuleSources.length > okData.length) { + RuleSources.forEach(item => { + if (okData.find(x => x.sourceUrl == item.sourceUrl)) { } + else { $(`#RuleList #${item.sourceUrl}+*`).className += 'isError'; } + }); + failMsg = '\n推送失败的源将用红色字体标注!'; + } + alert(`批量推送源到「阅读3.0APP」\n共计: ${RuleSources.length} 条\n成功: ${okData.length} 条\n失败: ${RuleSources.length - okData.length} 条${failMsg}`); + } + else { + alert(`批量推送源到「阅读3.0APP」成功!\n共计: ${RuleSources.length} 条`); + } + } + else { + alert(`批量推送源失败!\nErrorMsg: ${json.errorMsg}`); + } + }).catch(err => { alert(`批量推送源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + case 'pull': + showTab('源列表'); + (async () => { + await HttpGet(`/getRssSources`).then(json => { + if (json.isSuccess) { + $('#RuleList').innerHTML = '' + localStorage.setItem('RssSources', JSON.stringify(RuleSources = json.data)); + RuleSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + alert(`成功拉取 ${RuleSources.length} 条源`); + } + else { + alert(`批量拉取源失败!\nErrorMsg: ${json.errorMsg}`); + } + }).catch(err => { alert(`批量拉取源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + case 'editor': + if ($('#RuleJsonString').value == '') break; + try { + json2rule(JSON.parse($('#RuleJsonString').value)); + todo(); + } catch (error) { + console.log(error); + alert(error); + } + break; + case 'conver': + showTab('编辑源'); + $('#RuleJsonString').value = JSON.stringify(rule2json(), null, 4); + break; + case 'initial': + $$('.rules textarea').forEach(item => { item.value = '' }); + todo(); + break; + case 'undo': + undo() + break; + case 'redo': + redo() + break; + case 'debug': + showTab('调试源'); + let wsOrigin = (hashParam('domain') || location.origin).replace(/^.*?:/, 'ws:').replace(/\d+$/, (port) => (parseInt(port) + 1)); + let DebugInfos = $('#DebugConsole'); + function DebugPrint(msg) { DebugInfos.value += `\n${msg}`; DebugInfos.scrollTop = DebugInfos.scrollHeight; } + let saveRule = [rule2json()]; + HttpPost(`/saveRssSource`, saveRule[0]).then(sResult => { + if (sResult.isSuccess) { + $('#DebugConsole').value = `源《${saveRule[0].sourceName}》保存成功!开始调试...`; + let ws = new WebSocket(`${wsOrigin}/rssSourceDebug`); + ws.onopen = () => { + ws.send(`{"tag":"${saveRule[0].sourceUrl}", "key":""}`); + }; + ws.onmessage = (msg) => { + console.log('[调试]', msg); + DebugPrint(msg.data); + }; + ws.onerror = (err) => { + throw `${err.data}`; + } + ws.onclose = () => { + thisNode.setAttribute('class', ''); + DebugPrint(`\n调试服务已关闭!`); + } + } else throw `${sResult.errorMsg}`; + }).catch(err => { + DebugPrint(`调试过程意外中止,以下是详细错误信息:\n${err}`); + thisNode.setAttribute('class', ''); + }); + return; + case 'accept': + (async () => { + let saveRule = [rule2json()]; + await HttpPost(`/saveRssSources`, saveRule).then(json => { + alert(json.isSuccess ? `源《${saveRule[0].sourceName}》已成功保存到「阅读3.0APP」` : `源《${saveRule[0].sourceName}》保存失败!\nErrorMsg: ${json.errorMsg}`); + setRule(saveRule[0]); + }).catch(err => { alert(`保存源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + default: + } + setTimeout(() => { thisNode.setAttribute('class', ''); }, 500); +}); +$('#Filter').addEventListener('keydown', e => { + if (e.keyCode == 13) { + let cashList = []; + $('#RuleList').innerHTML = ""; + let sKey = Filter.value ? Filter.value : ''; + if (sKey == '') { + cashList = RuleSources; + } else { + let patt = new RegExp(sKey); + RuleSources.forEach(source => { + if (patt.test(source.sourceUrl) || patt.test(source.sourceName) || patt.test(source.bookSourceGroup)) { + cashList.push(source); + } + }) + } + cashList.forEach(source => { + $('#RuleList').innerHTML += newRule(source); + }) + } +}); + +// 列表规则更改事件 +$('#RuleList').addEventListener('click', e => { + let editRule = null; + if (e.target && e.target.getAttribute('name') == 'rule') { + editRule = rule2json(); + json2rule(RuleSources.find(x => hex_md5(x.sourceUrl) == e.target.id)); + } else return; + if (editRule.sourceUrl == '') return; + if (editRule.sourceName == '') editRule.sourceName = editRule.sourceUrl.replace(/.*?\/\/|\/.*/g, ''); + setRule(editRule); + localStorage.setItem('RssSources', JSON.stringify(RuleSources)); +}); +// 处理列表按钮事件 +$('.tab3>.titlebar').addEventListener('click', e => { + let thisNode = e.target; + if (thisNode.nodeName != 'BUTTON') return; + switch (thisNode.id) { + case 'Import': + let fileImport = document.createElement('input'); + fileImport.type = 'file'; + fileImport.accept = '.json'; + fileImport.addEventListener('change', () => { + let file = fileImport.files[0]; + let reader = new FileReader(); + reader.onloadend = function (evt) { + if (evt.target.readyState == FileReader.DONE) { + let fileText = evt.target.result; + try { + let fileJson = JSON.parse(fileText); + let newSources = []; + newSources.push(...fileJson); + if (window.confirm(`如何处理导入的源?\n"确定": 覆盖当前列表(不会删除APP源)\n"取消": 插入列表尾部(自动忽略重复源)`)) { + localStorage.setItem('RssSources', JSON.stringify(RuleSources = newSources)); + $('#RuleList').innerHTML = '' + RuleSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + } + else { + newSources = newSources.filter(item => !JSON.stringify(RuleSources).includes(item.sourceUrl)); + RuleSources.push(...newSources); + localStorage.setItem('RssSources', JSON.stringify(RuleSources)); + newSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + } + alert(`成功导入 ${newSources.length} 条源`); + } + catch (err) { + alert(`导入源文件失败!\n${err}`); + } + } + }; + reader.readAsText(file); + }, false); + fileImport.click(); + break; + case 'Export': + let fileExport = document.createElement('a'); + fileExport.download = `Rules${Date().replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, '$2$1$3').replace(/:/g, '')}.json`; + let myBlob = new Blob([JSON.stringify(RuleSources, null, 4)], { type: "application/json" }); + fileExport.href = window.URL.createObjectURL(myBlob); + fileExport.click(); + break; + case 'Delete': + let selectRule = $('#RuleList input:checked'); + if (!selectRule) { + alert(`没有源被选中!`); + return; + } + if (confirm(`确定要删除选定源吗?\n(同时删除APP内源)`)) { + let selectRuleUrl = selectRule.id; + let deleteSources = RuleSources.filter(item => item.sourceUrl == selectRuleUrl); // 提取待删除的源 + let laveSources = RuleSources.filter(item => !(item.sourceUrl == selectRuleUrl)); // 提取待留下的源 + HttpPost(`/deleteRssSources`, deleteSources).then(json => { + if (json.isSuccess) { + let selectNode = document.getElementById(selectRuleUrl).parentNode; + selectNode.parentNode.removeChild(selectNode); + localStorage.setItem('RssSources', JSON.stringify(RuleSources = laveSources)); + if ($('#sourceUrl').value == selectRuleUrl) { + $$('.rules textarea').forEach(item => { item.value = '' }); + todo(); + } + console.log(deleteSources); + console.log(`以上源已删除!`) + } + }).catch(err => { alert(`删除源失败,无法连接到「阅读3.0APP」!\n${err}`); }); + } + break; + case 'ClrAll': + if (confirm(`确定要清空当前源列表吗?\n(不会删除APP内源)`)) { + localStorage.setItem('RssSources', JSON.stringify(RuleSources = [])); + $('#RuleList').innerHTML = '' + } + break; + default: + } +}); diff --git a/app/src/main/assets/web/uploadBook/css/stylewf.css b/app/src/main/assets/web/uploadBook/css/stylewf.css new file mode 100644 index 000000000..ff8d10457 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/css/stylewf.css @@ -0,0 +1,49 @@ +@charset "utf-8"; +/* CSS Document */ +body, div, p, ul, h1, h2, h3, h4, h5,span , img,dl,dt,dd{ margin: 0; padding: 0; } +html, body { height: 100%;} +body { font-family: Helvetica,'微软雅黑';background:#03a9f4 + /*url(../i/bg.jpg)*/; font-size: 14px; color: #313131; } +h1, h2, h3, h4, h5 {margin:0;padding:0;} +em { font-style: normal; } +ul, ol, li { list-style: none } +img { border: 0 none } +a { text-decoration: none; color: #858585; } +.fl{float:left;} +.fr{float:right;} +/*layut css*/ +.wrap{width:860px; margin:0 auto; overflow:hidden;zoom:1;} +.right{width:527px; float:right; background:#eeeeed; overflow:hidden;} +.title{height:50px; background:#8bc34a; line-height:3.0em;color:#fff;font-size:16px;padding-left:25px;} +.jdt{ background:#bebebe; height:3px; position:relative;} +.jdt p{ position:absolute;left:0;top:0; height:3px; } +.jdt p.orange{background:#8bc34a;} +.jdt p.gray{background:#999;} + +.first{background:#8bc34a;} +.right dl{ overflow:hidden;zoom:1;} +.grybg{background:#eaeae9;} +.first dt,.first dd{ background:url(../i/wifi_d_bg.png) no-repeat right top;_background:none;_filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src="wifi_d_bg.png")} +.right dt,.first dt{width:255px;padding-left:25px;} +.right dd,.first dd{width:110px; text-align:center;} +.right dt,.right dd{color:#555;float:left; line-height:2.8em;font-size:16px;} +.right dd.orange{color:#8bc34a; cursor:pointer} +.right dd.gray{color:#aaa;} + +.scroll{height:900px; overflow-y:scroll;padding-bottom:10px;position:relative;z-index:9999;} +.left{margin-right:527px;} +.logo{height:112px; background:url(../i/wifi_logo_3.png) no-repeat left 24px;margin-bottom:36px;} +.wf_box{height:204px;margin-bottom:46px; overflow:hidden;} +.wf_box h3{padding:124px 0 0 0;color:#fff;width:200px; height:76px; margin-left:55px; text-align:center;} +.wf_wifi{ background:url(../i/wifi_img.png) no-repeat 47px top;} +.wf_active,.wf_normal{background:url(../i/wifi_tz0.png) no-repeat;} +.wf_active{background-position:56px -210px;} +.wf_normal{background-position:56px top;} +.wf_btn{width:203px;height:100px;padding-left:37px;margin-bottom:16px;} +.wf_left{width:6px; height:100px; background:url(../i/wifi_left.png) no-repeat left top; float:left;} +.wf_right{width:6px; height:100px; background:url(../i/wifi_right.png) no-repeat left top; float:left;} +.wf_center{width:228px; float:left; background:#8bc34a; text-align:center;color:#fff;height:100px;} +.wf_center h3{font-size:24px; line-height:1.6em;margin-top:22px;} +.wf_center p{font-size:14px; line-height:1.6em;} +.wf_gs{color:#fff; line-height:1.6em;padding-left:37px;} +.right dd.wf_bgn{ background:none;} \ No newline at end of file diff --git a/app/src/main/assets/web/uploadBook/i/wifi_logo_3.png b/app/src/main/assets/web/uploadBook/i/wifi_logo_3.png new file mode 100644 index 000000000..618c4873a Binary files /dev/null and b/app/src/main/assets/web/uploadBook/i/wifi_logo_3.png differ diff --git a/app/src/main/assets/web/uploadBook/i/wifi_logo_3.svg b/app/src/main/assets/web/uploadBook/i/wifi_logo_3.svg new file mode 100644 index 000000000..7361683b0 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/i/wifi_logo_3.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/app/src/main/assets/web/uploadBook/i/wifi_tz0.png b/app/src/main/assets/web/uploadBook/i/wifi_tz0.png new file mode 100644 index 000000000..85aa8a7e4 Binary files /dev/null and b/app/src/main/assets/web/uploadBook/i/wifi_tz0.png differ diff --git a/app/src/main/assets/web/uploadBook/index.html b/app/src/main/assets/web/uploadBook/index.html new file mode 100644 index 000000000..ec02ea035 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/index.html @@ -0,0 +1,42 @@ + + + + + + + + + +
+
+

+
+
+
+
+
+ +
+
    +
    +
    +
    + +
    +

    +
    +
    +
    +
    +

    +
    +
    + + + + + + + + diff --git a/app/src/main/assets/web/uploadBook/js/common.js b/app/src/main/assets/web/uploadBook/js/common.js new file mode 100644 index 000000000..a8ba3ff7d --- /dev/null +++ b/app/src/main/assets/web/uploadBook/js/common.js @@ -0,0 +1,236 @@ +/** + * 公共函数 + */ + +//全局的配置文件 +var config = { + fileTypes: "txt|epub|umd", //允许上传的文件格式 "txt|epub" // |doc|docx|wps|xls|xlsx|et|ppt|pptx|dps + //url : "http://"+location.host+"?action=addBook",//"http://localhost/t/post.php",// + url: "../addLocalBook", + fileLimitSize: 500 * 1024 * 1024, +}; + +var file = { + inQueue: [], //已经在队列里面的文件,包括 HTML5上传和 Flash上传的 + clientHaveFiles: [], // 客户端已经存在的文件列表 +}; + +/** + * HTML5 和 flash 公用,所有文件对象集合 + * @var array + */ +var filesUpload = []; // + +//统计文件大小 +function countFileSize(fileSize) { + var KB = 1024; + var MB = 1024 * 1024; + if (KB >= fileSize) { + return fileSize + "B"; + } else if (MB >= fileSize) { + return (fileSize / KB).toFixed(2) + "KB"; + } else { + return (fileSize / MB).toFixed(2) + "MB"; + } +} + +//如果文件太长进行截取 +function substr_string(name) { + var maxLen = 15; + var len = name.length; + if (len < 17) return name; + + var lastIndex = name.lastIndexOf("."); + var suffix = name.substr(lastIndex); + var pre = name.substr(0, lastIndex); + var preLen = pre.length; + var preStart = preLen - 10; + //前面10个 + 后面5个 + var fileName = + pre.substr(0, 10) + + "...." + + pre.substr(preStart > 4 ? -4 : -preStart, 4) + + suffix; + return fileName; +} + +function checkFile(file) { + if (file.size > config.fileLimitSize) { + return jsonLang.t11; + } + + if ( + !file.name || + !file.name.toLowerCase().match("(" + config.fileTypes + ")$") + ) { + return jsonLang.t12; + } + + var len = filesUpload.length; + for (var i = 0; i < len; i++) { + if (filesUpload[i].name == file.name) { + return jsonLang.t13; + } + } + return null; +} + +/** + * 添加文件时,回调的函数 + * @param object file 文件对象 + * @param int type 0 是swf 上传的,1 是html5上传的 + */ +function fileQueued(file, type) { + var size = 0, + fid = file.id, + name = ""; + type = type || 0; + + if (file != undefined) { + //计算文件大小 单位MB + size = countFileSize(file.size); + name = substr_string(file.name); + //创建要插入的元素 + var li = document.createElement("li"); + li.id = `tr_${fid}`; + li.innerHTML = `
    +
    ${name}
    +
    ${size}
    +
    + 0% ${jsonLang.t9} +
    +
    +

    `; + var table = document.getElementById("tableStyle"); + table.appendChild(li); + //保存falsh_id,为上传做准备 + //global_flash_id.push(file.id); + //更改背景颜色 + changeTrBackGroundColor(); + } +} + +function changeTrBackGroundColor() { + var getTr = document.getElementById("tableStyle").getElementsByTagName("dl"); + trNum = getTr.length; + for (var i = 0; i < trNum; i++) { + if (i % 2 == 0) { + getTr[i].style.backgroundColor = "#f3f3f3"; + } + } +} + +//上传时返回的状态 +function uploadProgress(file, bytesLoaded, bytesTotal) { + var progressClass = document.getElementById( + "progress_bar_p_" + file.id + ).classList; + if (!progressClass.contains("orange")) { + progressClass.add("orange"); + } + document.getElementById("progress_bar_p_" + file.id).style.width = + (bytesLoaded / bytesTotal) * 100 + "%"; + document.getElementById("progress_bar_span_" + file.id).innerHTML = + parseInt((bytesLoaded / bytesTotal) * 100) + "%"; +} + +//上传成功 +function uploadSuccess(file, serverData, res) { + var id = "handle_button_" + file.id; + var dd = document.createElement("dd"); + dd.innerHTML = jsonLang.t10; + document.getElementById(id).replaceWith(dd); +} + +//取消上传 +function userCancelUpload(file_id, type) { + if (type == 0) { + SWFFuns.cancelUpload(file_id); + } else { + HTML5Funs.cancelUpload(file_id); + } + var element = document.getElementById("handle_button_" + file_id); + element.innerHTML = jsonLang.t14; + element.classList.remove("orange"); + element.classList.add("gray"); + //如果已经上传一部分了 + var progressElement = document.getElementById("progress_bar_p_" + file_id); + if (progressElement.classList.contains("orange")) { + progressElement.classList.remove("orange"); + progressElement.classList.add("gray"); + } +} + +/** + * 通过文件名称 从全局的文件列表中获取文件对象 + * + */ +function getFileFomeFilesUpload(filename) { + var len = filesUpload.length; + for (var i = 0; i < len; i++) { + if (filesUpload[i].name == filename) { + return filesUpload[i]; + } + } + + return null; +} + +/** + * 往全局的 上传列表添加一个数据 + */ +function addFileToFilesUpload(file) { + if (typeof file == "string") { + filesUpload.push({ name: file }); + return true; + } else if (typeof file == "object") { + filesUpload.push(file); + return true; + } + + return false; +} + +/** + * 往全局的 上传列表添加一个数据 + */ +function updateFileToFilesUpload(file) { + var len = filesUpload.length; + for (var i = 0; i < len; i++) { + if (filesUpload[i].name == file.name) { + filesUpload[i] = file; + return true; + } + } + + return false; +} + +/** + * 查找在数组中的位置 + */ +function findObjectKey(object, fid) { + var len = object.length; + for (var i = 0; i < len; i++) { + if (object[i].id == fid) { + return i; + } + } + return -1; +} + +/** + * 从全局的文件集合中移除文件,一般上传失败时使用 + * @param array files 文件对象集合 [{},{},{}] + * @param int fid 要删除的文件id + * @return 删除后的数组, 其实数组是引用类型可以不返回 + */ +function removeFileFromFilesUpload(files, fid) { + var filesUploadKey = -1; + + filesUploadKey = findObjectKey(files, fid); + //从全局文件中移除 + if (filesUploadKey > -1) files.splice(filesUploadKey, 1); + + return files; +} diff --git a/app/src/main/assets/web/uploadBook/js/html5_fun.js b/app/src/main/assets/web/uploadBook/js/html5_fun.js new file mode 100644 index 000000000..3ee6a1154 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/js/html5_fun.js @@ -0,0 +1,241 @@ +/** + * 处理拖拽上传 + */ +var isDragOver = false; //拖拽触发点 +var fileNumber = -1; //上传文件编号 +var fileNumberPex = "zyFileUpload_"; //编号前缀 +var currUploadfile = {}; //当前上传的文件对象 + +var uploadQueue = []; //上传队列集合 +var isUploading = false; //是否正在上传 + +var XHR; +try { + XHR = new XMLHttpRequest(); +} catch (e) {} + +(function (isSupportFileUpload) { + //不支持拖拽上传,或者 不支持FormData ,显示WiFi表示 + if (!isSupportFileUpload) { + document.getElementById("drag_area").classList.add("wf_wifi"); + document.getElementById("drag_area").innerHTML = ""; + return; + //更换样式 + } else { + document.getElementById("drag_area").classList.add("wf_normal"); + } + + addEvent(); + + /** + * 添加事件 + */ + function addEvent() { + var dropArea = document.getElementById("drag_area"); + dropArea.addEventListener("dragover", handleDragOver, false); + dropArea.addEventListener("dragleave", handleDragLeave, false); + dropArea.addEventListener("drop", handleDrop, false); + } + + /** + * 松开拖拽文件的处理,进行上传 + */ + function handleDrop(evt) { + evt.stopPropagation(); + evt.preventDefault(); + var classList = document.getElementById("drag_area").classList; + classList.add("wf_normal"); + classList.remove("wf_active"); + + console.log("Drop"); + isDragOver = false; + + var file = {}; + var errorMsgs = []; + var len = evt.dataTransfer.files.length; + + for (var i = 0; i < len; i++) { + fileNumber++; + file = evt.dataTransfer.files[i]; + file.id = fileNumberPex + fileNumber; + + //检测文件 + msg = checkFile(file); + //文件可以通过 + if (!msg) { + //添加全局 + filesUpload.push(file); + //添加上传队列 + uploadQueue.push(file); + //在页面进行展示 + fileQueued(file, 1); + } else { + errorMsgs.push(msg); + } + } + + if (errorMsgs.length > 0) { + //只选择做一个进行上传 + if (len == 1) { + alert(errorMsgs[0]); + } else { + a1 = len; + a2 = len - errorMsgs.length; + + var replaceArr = new Array(a1, a2); + alert(stringReplace(jsonLang.t7, replaceArr)); + } + } + + //拿出第一个,进行上传 + if (!isUploading && uploadQueue.length > 0) + uploadFiles(uploadQueue.shift()); + } + + function handleDragOver(evt) { + evt.stopPropagation(); + evt.preventDefault(); + //防止多次DOM操作 + if (!isDragOver) { + console.log("Over"); + var classList = document.getElementById("drag_area").classList; + classList.add("wf_active"); + classList.remove("wf_normal"); + isDragOver = true; + } + } + + function handleDragLeave(evt) { + console.log("DragLeave"); + evt.stopPropagation(); + evt.preventDefault(); + isDragOver = false; + var classList = document.getElementById("drag_area").classList; + classList.add("wf_normal"); + classList.remove("wf_active"); + } + + function uploadFiles(file) { + //正在上传 + isUploading = true; + //设置上传的数据 + var reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = function (e) { + var data = e.target.result; + var fd = new FormData(); + fd.append("fileName", file.name); + fd.append("fileData", data); + //设置当前的上传对象 + currUploadfile = file; + + if (XHR.readyState > 0) { + XHR = new XMLHttpRequest(); + } + + XHR.upload.addEventListener("progress", progress, false); + XHR.upload.addEventListener("load", requestLoad, false); + XHR.upload.addEventListener("error", error, false); + XHR.upload.addEventListener("abort", abort, false); + XHR.upload.addEventListener("loadend", loadend, false); + XHR.upload.addEventListener("loadstart", loadstart, false); + XHR.open("POST", config.url); + XHR.send(fd); + XHR.onreadystatechange = function () { + //只要上传完成不管成功失败 + if (XHR.readyState == 4) { + console.log("onreadystatechange ", XHR.status, +new Date()); + + if (XHR.status == 200) { + uploadSuccess(currUploadfile, {}, XHR.status); + } else { + uploadError(); + } + + //进行下一个上传 + nextUpload(); + } + }; + }; + } + + //请求完成,无论失败或成功 + function loadend(evt) { + console.log("loadend", +new Date(), evt); + } + //请求开始 + function loadstart(evt) { + console.log("loadstart", evt); + } + + //在请求发送或接收数据期间,在服务器指定的时间间隔触发。 + function progress(evt) { + uploadProgress(currUploadfile, evt.loaded || evt.position, evt.total); + } + + //在请求被取消时触发,例如,在调用 abort() 方法时。 + function abort(evt) { + console.log("abort", evt); + } + + //在请求失败时触发。 + function error(evt) { + //终止ajax请求 + XHR.abort(); + uploadError(); + nextUpload(); + } + + //在请求成功完成时触发。 + function requestLoad(evt) { + console.log("requestLoad", +new Date(), evt); + } + + //进行下一个上传 + function nextUpload() { + isUploading = false; + if (uploadQueue.length > 0) { + uploadFiles(uploadQueue.shift()); + } else { + //米有正在上传的了 + currUploadfile = {}; + } + } + + //上传出错误了,比如断网, + function uploadError() { + //移除全局变量中的,上传出错的 + removeFileFromFilesUpload(filesUpload, currUploadfile.id); + var file = currUploadfile; + var dd = document.createElement("dd") + dd.innerHTML = jsonLang.t8 + document + .getElementById("handle_button_" + file.id) + .replaceWith(dd); + } + + //对外部注册的函数 + var HTML5Funs = { + /** + * 取消上传 + * @param string fid 文件的Id + */ + cancelUpload: function (fid) { + var filesUploadKey = -1; + var uploadQueueKey = -1; + + //从全局中删除文件 + removeFileFromFilesUpload(filesUpload, fid); + + //如果是正在上传的,AJAX取消 + if (currUploadfile.id == fid) { + XHR.abort(); + } else { + //从上传队列中移除 + removeFileFromFilesUpload(uploadQueue, fid); + } + }, + }; + + window.HTML5Funs = HTML5Funs; +})("FormData" in window && "ondrop" in document.body); diff --git a/app/src/main/assets/web/uploadBook/js/langSwich.js b/app/src/main/assets/web/uploadBook/js/langSwich.js new file mode 100644 index 000000000..89eaa9362 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/js/langSwich.js @@ -0,0 +1,111 @@ +/** + * wifi传书功能,供客户端调用 + * 切换语言环境 addby zhongweikang@zhangyue.com 2015/09/23 + */ + +var language; //当前语言 +var jsonEn; //英文 +var jsonZh; //中文 + +/** + * 语言包 + */ +jsonEn = { + t0: "WiFi Upload", + t1: "Documents", + t2: "Name", + t3: "Size", + t4: "Operation", + t5: "Supported Format:TXT、EPUB、UMD", + t6: "Drag here and Drop to upload", + t7: "You choosed [txt] files ,we only can upload [txt] of them。\nPlease choose TXT、EPUB、UMD files, file name cannot be repeated.", + t8: "Failed", + t9: "Cancel", + t10: "Done", + t11: "One of books is over 500MB", + t12: "Bad file mode", + t13: "File already exists", + t14: "Canceled", +}; +jsonZh = { + t0: "传书", + t1: "文档", + t2: "名字", + t3: "大小", + t4: "操作", + t5: "支持类型:TXT、EPUB、UMD", + t6: "拖至此处上传", + t7: "你选择了[txt]个文件,我们只能上传其中的[txt]。\n请选择 TXT、EPUB、UMD 类型的文件,文件的名字不能重复。", + t8: "失败", + t9: "取消", + t10: "完成", + t11: "上传文件不能大于500MB", + t12: "无效的文件格式", + t13: "文件已存在", + t14: "已取消", +}; + +if (navigator.language) { + language = navigator.language; +} else { + language = navigator.browserLanguage; +} +language = language.toLowerCase(language); + +/** + * 切换语言 + */ +function langSwich(s) { + switch (s) { + case "en-us": + _strReplace(jsonEn); + break; + case "zh-cn": + _strReplace(jsonZh); + break; + default: + _strReplace(jsonEn); + break; + } +} +function _strReplace(d) { + window.jsonLang = d; //全局变量在其他js文件中会遇到,勿删。 + for (var i in jsonLang) { + var element = document.querySelector("[data-js=" + i + "]"); + if (element) { + element.innerHTML = d[i]; + } + } +} + +/** + * 特殊字符串替换,例:语言包中的 t7 + * content 替换前的文本 + * replace 待插入的文本 (支持变量或数组) + */ +var isArray = function (obj) { + return Object.prototype.toString.call(obj) === "[object Array]"; +}; +function stringReplace(content, replace) { + var str = content; + if (!str) { + return null; + } + + if (isArray(replace)) { + strs = str.split("[txt]"); + count = strs.length - 1; + + var string = ""; + for (var i = 0; i < count; i++) { + string = string + strs[i] + replace[i]; + } + string = string + strs[count]; + } else { + string = str.replace(replace, "[txt]"); + } + return string; +} + +//执行 +langSwich(language); diff --git a/app/src/main/assets/web/uploadBook/js/swf_fun.js b/app/src/main/assets/web/uploadBook/js/swf_fun.js new file mode 100644 index 000000000..d9de9e176 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/js/swf_fun.js @@ -0,0 +1,184 @@ +/** + * swf 上传 + */ +var swfu;//swfupload 对象 +var swfSelectCount = 0;// 当前选中的文件数量 + +window.onload = function () { + var settings = { + upload_url: config.url, + /*post_params: {"PHPSESSID" : ""},*/ + file_size_limit: config.fileLimitSize + " B", + file_types: "*." + config.fileTypes.split("|").join(";*."), + + file_types_description: "All Files", + file_upload_limit: 1000, //配置上传个数 + file_queue_limit: 0, + custom_settings: { + progressTarget: "fsUploadProgress", + cancelButtonId: "btnCancel" + }, + debug: 0, + + button_cursor: SWFUpload.CURSOR.HAND, + button_image_url: "i/wifi_btn_b.png", + button_width: "240", + button_height: "100", + button_float: "right", + button_placeholder_id: "spanButtonPlaceHolder", + button_text: '', + + assume_success_timeout: 30, + file_queued_handler: swfFileQueued, + file_queue_error_handler: fileQueueError, + file_dialog_complete_handler: fileDialogComplete, + upload_start_handler: uploadStart, + upload_progress_handler: uploadProgress, + upload_error_handler: uploadError, + upload_success_handler: uploadSuccess, + upload_complete_handler: uploadComplete, + queue_complete_handler: queueComplete + }; + swfu = new SWFUpload(settings); +}; + + + +//上传完成 +function uploadComplete(file, server, response) { + //继续下一个文件的上传 + this.startUpload(); +} + +//完成队列里的上传 +function queueComplete(numFilesUploaded) { + +} + +function userStartUpload(file_id) { + swfu.startUpload(file_id); +} + + + + + +function fileQueueError(file, errorCode, message) { + switch (errorCode) { + case -100: + //alert("Over 100 books"); + case -110: + //alert("One of books is over 500MB"); + break; + case -120: + //alert("One of books is 0KB"); + break; + } +} +//入列完毕 +function fileDialogComplete(numFilesSelected, numFilesQueued) { + if (numFilesSelected > 0) { + this.startUpload() + } +} +//开始上传 +function uploadStart(file) { + return true; +} + +//上传出错 +function uploadError(file, errorCode, message) { + switch (errorCode) { + case SWFUpload.UPLOAD_ERROR.HTTP_ERROR: + errorMessage = "Error"; + break; + case SWFUpload.UPLOAD_ERROR.UPLOAD_FAILED: + errorMessage = "Failed"; + break; + case SWFUpload.UPLOAD_ERROR.IO_ERROR: + errorMessage = "Please open wifi upload page"; + break; + case SWFUpload.UPLOAD_ERROR.SECURITY_ERROR: + errorMessage = "Security error"; + break; + case SWFUpload.UPLOAD_ERROR.UPLOAD_LIMIT_EXCEEDED: + errorMessage = "Security error"; + break; + case SWFUpload.UPLOAD_ERROR.FILE_VALIDATION_FAILED: + errorMessage = "Unable to verify. Skip "; + break; + default: + errorMessage = "Unhandled error"; + break; + } + + //从上传队列中移除 + removeFileFromFilesUpload(filesUpload, file.id) + + errorMessage = jsonLang.t8; + var dd = document.createElement("dd") + dd.innerHTML = errorMessage + document.getElementById("handle_button_" + file.id).replaceWith(dd) +} + + +var tmp = 0; +var errorFile = 0; +var errorMsgs = []; + +function swfFileQueued(file) { + + + //本次上传选中的文件个数 + if (swfSelectCount == 0) swfSelectCount = this.getStats().files_queued + //检测文件 + msg = checkFile(file) + + + //文件可以通过 + if (!msg) { + //添加全局的队列 + filesUpload.push(file); + //在页面进行展示 + fileQueued(file, 0) + } else { + //从上传队列移除,验证失败的文件 + this.cancelUpload(file.id, false) + errorMsgs.push(msg) + } + + + + //队列选择完毕,初始化所有的数据 + if (++tmp == swfSelectCount) { + + if (errorMsgs.length > 0) { + //只选择做一个进行上传 + if (swfSelectCount == 1) { + alert(errorMsgs[0]); + } else { + a1 = swfSelectCount; + a2 = swfSelectCount - errorMsgs.length; + + var replaceArr = new Array(a1, a2); + alert(stringReplace(jsonLang.t7, replaceArr)); + } + } + + tmp = 0; + errorFile = 0; + swfSelectCount = 0; + errorMsgs = [] + } + + +} + + + +var SWFFuns = { + cancelUpload: function (fid) { + swfu.cancelUpload(fid, false); + } + +} \ No newline at end of file diff --git a/app/src/main/assets/web/uploadBook/js/swfupload.js b/app/src/main/assets/web/uploadBook/js/swfupload.js new file mode 100644 index 000000000..776d3b0a4 --- /dev/null +++ b/app/src/main/assets/web/uploadBook/js/swfupload.js @@ -0,0 +1,1146 @@ +/** + * SWFUpload: http://www.swfupload.org, http://swfupload.googlecode.com + * + * mmSWFUpload 1.0: Flash upload dialog - http://profandesign.se/swfupload/, http://www.vinterwebb.se/ + * + * SWFUpload is (c) 2006-2007 Lars Huring, Olov Nilz�n and Mammon Media and is released under the MIT License: + * http://www.opensource.org/licenses/mit-license.php + * + * SWFUpload 2 is (c) 2007-2008 Jake Roberts and is released under the MIT License: + * http://www.opensource.org/licenses/mit-license.php + * + * SWFObject v2.2 + * is released under the MIT License + */ + + + +/* ******************* */ +/* Constructor & Init */ +/* ******************* */ +var SWFUpload; +var swfobject; + +if (SWFUpload == undefined) { + SWFUpload = function (settings) { + this.initSWFUpload(settings); + }; +} + +SWFUpload.prototype.initSWFUpload = function (userSettings) { + try { + this.customSettings = {}; // A container where developers can place their own settings associated with this instance. + this.settings = {}; + this.eventQueue = []; + this.movieName = "SWFUpload_" + SWFUpload.movieCount++; + this.movieElement = null; + + + // Setup global control tracking + SWFUpload.instances[this.movieName] = this; + + // Load the settings. Load the Flash movie. + this.initSettings(userSettings); + this.loadSupport(); + if (this.swfuploadPreload()) { + this.loadFlash(); + } + + this.displayDebugInfo(); + } catch (ex) { + delete SWFUpload.instances[this.movieName]; + throw ex; + } +}; + +/* *************** */ +/* Static Members */ +/* *************** */ +SWFUpload.instances = {}; +SWFUpload.movieCount = 0; +SWFUpload.version = "2.5.0 2010-01-15 Beta 2"; +SWFUpload.QUEUE_ERROR = { + QUEUE_LIMIT_EXCEEDED : -100, + FILE_EXCEEDS_SIZE_LIMIT : -110, + ZERO_BYTE_FILE : -120, + INVALID_FILETYPE : -130 +}; +SWFUpload.UPLOAD_ERROR = { + HTTP_ERROR : -200, + MISSING_UPLOAD_URL : -210, + IO_ERROR : -220, + SECURITY_ERROR : -230, + UPLOAD_LIMIT_EXCEEDED : -240, + UPLOAD_FAILED : -250, + SPECIFIED_FILE_ID_NOT_FOUND : -260, + FILE_VALIDATION_FAILED : -270, + FILE_CANCELLED : -280, + UPLOAD_STOPPED : -290, + RESIZE : -300 +}; +SWFUpload.FILE_STATUS = { + QUEUED : -1, + IN_PROGRESS : -2, + ERROR : -3, + COMPLETE : -4, + CANCELLED : -5 +}; +SWFUpload.UPLOAD_TYPE = { + NORMAL : -1, + RESIZED : -2 +}; + +SWFUpload.BUTTON_ACTION = { + SELECT_FILE : -100, + SELECT_FILES : -110, + START_UPLOAD : -120, + JAVASCRIPT : -130, // DEPRECATED + NONE : -130 +}; +SWFUpload.CURSOR = { + ARROW : -1, + HAND : -2 +}; +SWFUpload.WINDOW_MODE = { + WINDOW : "window", + TRANSPARENT : "transparent", + OPAQUE : "opaque" +}; + +SWFUpload.RESIZE_ENCODING = { + JPEG : -1, + PNG : -2 +}; + +// Private: takes a URL, determines if it is relative and converts to an absolute URL +// using the current site. Only processes the URL if it can, otherwise returns the URL untouched +SWFUpload.completeURL = function (url) { + try { + var path = "", indexSlash = -1; + if (typeof(url) !== "string" || url.match(/^https?:\/\//i) || url.match(/^\//) || url === "") { + return url; + } + + indexSlash = window.location.pathname.lastIndexOf("/"); + if (indexSlash <= 0) { + path = "/"; + } else { + path = window.location.pathname.substr(0, indexSlash) + "/"; + } + + return path + url; + } catch (ex) { + return url; + } +}; + +// Public: assign a new function to onload to use swfobject's domLoad functionality +SWFUpload.onload = function () {}; + + +/* ******************** */ +/* Instance Members */ +/* ******************** */ + +// Private: initSettings ensures that all the +// settings are set, getting a default value if one was not assigned. +SWFUpload.prototype.initSettings = function (userSettings) { + this.ensureDefault = function (settingName, defaultValue) { + var setting = userSettings[settingName]; + if (setting != undefined) { + this.settings[settingName] = setting; + } else { + this.settings[settingName] = defaultValue; + } + }; + + // Upload backend settings + this.ensureDefault("upload_url", ""); + this.ensureDefault("preserve_relative_urls", false); + this.ensureDefault("file_post_name", "Filedata"); + this.ensureDefault("post_params", {}); + this.ensureDefault("use_query_string", false); + this.ensureDefault("requeue_on_error", false); + this.ensureDefault("http_success", []); + this.ensureDefault("assume_success_timeout", 0); + + // File Settings + this.ensureDefault("file_types", "*.*"); + this.ensureDefault("file_types_description", "All Files"); + this.ensureDefault("file_size_limit", 0); // Default zero means "unlimited" + this.ensureDefault("file_upload_limit", 0); + this.ensureDefault("file_queue_limit", 0); + + // Flash Settings + this.ensureDefault("flash_url", "swfupload.swf"); + this.ensureDefault("flash9_url", "swfupload_fp9.swf"); + this.ensureDefault("prevent_swf_caching", true); + + // Button Settings + this.ensureDefault("button_image_url", ""); + this.ensureDefault("button_width", 1); + this.ensureDefault("button_height", 1); + this.ensureDefault("button_text", ""); + this.ensureDefault("button_text_style", "color: #000000; font-size: 16pt;"); + this.ensureDefault("button_text_top_padding", 0); + this.ensureDefault("button_text_left_padding", 0); + this.ensureDefault("button_action", SWFUpload.BUTTON_ACTION.SELECT_FILES); + this.ensureDefault("button_disabled", false); + this.ensureDefault("button_placeholder_id", ""); + this.ensureDefault("button_placeholder", null); + this.ensureDefault("button_cursor", SWFUpload.CURSOR.ARROW); + this.ensureDefault("button_window_mode", SWFUpload.WINDOW_MODE.TRANSPARENT); + + // Debug Settings + this.ensureDefault("debug", false); + this.settings.debug_enabled = this.settings.debug; // Here to maintain v2 API + + // Event Handlers + this.settings.return_upload_start_handler = this.returnUploadStart; + this.ensureDefault("swfupload_preload_handler", null); + this.ensureDefault("swfupload_load_failed_handler", null); + this.ensureDefault("swfupload_loaded_handler", null); + this.ensureDefault("file_dialog_start_handler", null); + this.ensureDefault("file_queued_handler", null); + this.ensureDefault("file_queue_error_handler", null); + this.ensureDefault("file_dialog_complete_handler", null); + + this.ensureDefault("upload_resize_start_handler", null); + this.ensureDefault("upload_start_handler", null); + this.ensureDefault("upload_progress_handler", null); + this.ensureDefault("upload_error_handler", null); + this.ensureDefault("upload_success_handler", null); + this.ensureDefault("upload_complete_handler", null); + + this.ensureDefault("mouse_click_handler", null); + this.ensureDefault("mouse_out_handler", null); + this.ensureDefault("mouse_over_handler", null); + + this.ensureDefault("debug_handler", this.debugMessage); + + this.ensureDefault("custom_settings", {}); + + // Other settings + this.customSettings = this.settings.custom_settings; + + // Update the flash url if needed + if (!!this.settings.prevent_swf_caching) { + this.settings.flash_url = this.settings.flash_url + (this.settings.flash_url.indexOf("?") < 0 ? "?" : "&") + "preventswfcaching=" + new Date().getTime(); + this.settings.flash9_url = this.settings.flash9_url + (this.settings.flash9_url.indexOf("?") < 0 ? "?" : "&") + "preventswfcaching=" + new Date().getTime(); + } + + if (!this.settings.preserve_relative_urls) { + this.settings.upload_url = SWFUpload.completeURL(this.settings.upload_url); + this.settings.button_image_url = SWFUpload.completeURL(this.settings.button_image_url); + } + + delete this.ensureDefault; +}; + +// Initializes the supported functionality based the Flash Player version, state, and event that occur during initialization +SWFUpload.prototype.loadSupport = function () { + this.support = { + loading : swfobject.hasFlashPlayerVersion("9.0.28"), + imageResize : swfobject.hasFlashPlayerVersion("10.0.0") + }; + +}; + +// Private: loadFlash replaces the button_placeholder element with the flash movie. +SWFUpload.prototype.loadFlash = function () { + var targetElement, tempParent, wrapperType, flashHTML, els; + + if (!this.support.loading) { + this.queueEvent("swfupload_load_failed_handler", ["Flash Player doesn't support SWFUpload"]); + return; + } + + // Make sure an element with the ID we are going to use doesn't already exist + if (document.getElementById(this.movieName) !== null) { + this.support.loading = false; + this.queueEvent("swfupload_load_failed_handler", ["Element ID already in use"]); + return; + } + + // Get the element where we will be placing the flash movie + targetElement = document.getElementById(this.settings.button_placeholder_id) || this.settings.button_placeholder; + + if (targetElement == undefined) { + this.support.loading = false; + this.queueEvent("swfupload_load_failed_handler", ["button place holder not found"]); + return; + } + + wrapperType = (targetElement.currentStyle && targetElement.currentStyle["display"] || window.getComputedStyle && document.defaultView.getComputedStyle(targetElement, null).getPropertyValue("display")) !== "block" ? "span" : "div"; + + // Append the container and load the flash + tempParent = document.createElement(wrapperType); + + flashHTML = this.getFlashHTML(); + + try { + tempParent.innerHTML = flashHTML; // Using innerHTML is non-standard but the only sensible way to dynamically add Flash in IE (and maybe other browsers) + } catch (ex) { + this.support.loading = false; + this.queueEvent("swfupload_load_failed_handler", ["Exception loading Flash HTML into placeholder"]); + return; + } + + // Try to get the movie element immediately + els = tempParent.getElementsByTagName("object"); + if (!els || els.length > 1 || els.length === 0) { + this.support.loading = false; + this.queueEvent("swfupload_load_failed_handler", ["Unable to find movie after adding to DOM"]); + return; + } else if (els.length === 1) { + this.movieElement = els[0]; + } + + targetElement.parentNode.replaceChild(tempParent.firstChild, targetElement); + + // Fix IE Flash/Form bug + if (window[this.movieName] == undefined) { + window[this.movieName] = this.getMovieElement(); + } +}; + +// Private: getFlashHTML generates the object tag needed to embed the flash in to the document +SWFUpload.prototype.getFlashHTML = function (flashVersion) { + // Flash Satay object syntax: http://www.alistapart.com/articles/flashsatay + + var classid = ""; + //�����IE9,10 ������calssid + if (window.ActiveXObject){ + try{ + if (+navigator.userAgent.toLowerCase().match(/msie ([\d.]+)/)[1].substring(0, 1) >= 9) + classid = ' classid = "clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" '; + }catch(e){ + classid = "" + } + } + + + + return ['', + '', + '', + '', + '', + '', + ''].join(""); +}; + +// Private: getFlashVars builds the parameter string that will be passed +// to flash in the flashvars param. +SWFUpload.prototype.getFlashVars = function () { + // Build a string from the post param object + var httpSuccessString, paramString; + + paramString = this.buildParamString(); + httpSuccessString = this.settings.http_success.join(","); + + // Build the parameter string + return ["movieName=", encodeURIComponent(this.movieName), + "&uploadURL=", encodeURIComponent(this.settings.upload_url), + "&useQueryString=", encodeURIComponent(this.settings.use_query_string), + "&requeueOnError=", encodeURIComponent(this.settings.requeue_on_error), + "&httpSuccess=", encodeURIComponent(httpSuccessString), + "&assumeSuccessTimeout=", encodeURIComponent(this.settings.assume_success_timeout), + "&params=", encodeURIComponent(paramString), + "&filePostName=", encodeURIComponent(this.settings.file_post_name), + "&fileTypes=", encodeURIComponent(this.settings.file_types), + "&fileTypesDescription=", encodeURIComponent(this.settings.file_types_description), + "&fileSizeLimit=", encodeURIComponent(this.settings.file_size_limit), + "&fileUploadLimit=", encodeURIComponent(this.settings.file_upload_limit), + "&fileQueueLimit=", encodeURIComponent(this.settings.file_queue_limit), + "&debugEnabled=", encodeURIComponent(this.settings.debug_enabled), + "&buttonImageURL=", encodeURIComponent(this.settings.button_image_url), + "&buttonWidth=", encodeURIComponent(this.settings.button_width), + "&buttonHeight=", encodeURIComponent(this.settings.button_height), + "&buttonText=", encodeURIComponent(this.settings.button_text), + "&buttonTextTopPadding=", encodeURIComponent(this.settings.button_text_top_padding), + "&buttonTextLeftPadding=", encodeURIComponent(this.settings.button_text_left_padding), + "&buttonTextStyle=", encodeURIComponent(this.settings.button_text_style), + "&buttonAction=", encodeURIComponent(this.settings.button_action), + "&buttonDisabled=", encodeURIComponent(this.settings.button_disabled), + "&buttonCursor=", encodeURIComponent(this.settings.button_cursor) + ].join(""); +}; + +// Public: get retrieves the DOM reference to the Flash element added by SWFUpload +// The element is cached after the first lookup +SWFUpload.prototype.getMovieElement = function () { + if (this.movieElement == undefined) { + this.movieElement = document.getElementById(this.movieName); + } + + if (this.movieElement === null) { + throw "Could not find Flash element"; + } + + return this.movieElement; +}; + +// Private: buildParamString takes the name/value pairs in the post_params setting object +// and joins them up in to a string formatted "name=value&name=value" +SWFUpload.prototype.buildParamString = function () { + var name, postParams, paramStringPairs = []; + + postParams = this.settings.post_params; + + if (typeof(postParams) === "object") { + for (name in postParams) { + if (postParams.hasOwnProperty(name)) { + paramStringPairs.push(encodeURIComponent(name.toString()) + "=" + encodeURIComponent(postParams[name].toString())); + } + } + } + + return paramStringPairs.join("&"); +}; + +// Public: Used to remove a SWFUpload instance from the page. This method strives to remove +// all references to the SWF, and other objects so memory is properly freed. +// Returns true if everything was destroyed. Returns a false if a failure occurs leaving SWFUpload in an inconsistant state. +// Credits: Major improvements provided by steffen +SWFUpload.prototype.destroy = function () { + var movieElement; + + try { + // Make sure Flash is done before we try to remove it + this.cancelUpload(null, false); + + movieElement = this.cleanUp(); + + // Remove the SWFUpload DOM nodes + if (movieElement) { + // Remove the Movie Element from the page + try { + movieElement.parentNode.removeChild(movieElement); + } catch (ex) {} + } + + // Remove IE form fix reference + window[this.movieName] = null; + + // Destroy other references + SWFUpload.instances[this.movieName] = null; + delete SWFUpload.instances[this.movieName]; + + this.movieElement = null; + this.settings = null; + this.customSettings = null; + this.eventQueue = null; + this.movieName = null; + + + return true; + } catch (ex2) { + return false; + } +}; + + +// Public: displayDebugInfo prints out settings and configuration +// information about this SWFUpload instance. +// This function (and any references to it) can be deleted when placing +// SWFUpload in production. +SWFUpload.prototype.displayDebugInfo = function () { + this.debug( + [ + "---SWFUpload Instance Info---\n", + "Version: ", SWFUpload.version, "\n", + "Movie Name: ", this.movieName, "\n", + "Settings:\n", + "\t", "upload_url: ", this.settings.upload_url, "\n", + "\t", "flash_url: ", this.settings.flash_url, "\n", + "\t", "flash9_url: ", this.settings.flash9_url, "\n", + "\t", "use_query_string: ", this.settings.use_query_string.toString(), "\n", + "\t", "requeue_on_error: ", this.settings.requeue_on_error.toString(), "\n", + "\t", "http_success: ", this.settings.http_success.join(", "), "\n", + "\t", "assume_success_timeout: ", this.settings.assume_success_timeout, "\n", + "\t", "file_post_name: ", this.settings.file_post_name, "\n", + "\t", "post_params: ", this.settings.post_params.toString(), "\n", + "\t", "file_types: ", this.settings.file_types, "\n", + "\t", "file_types_description: ", this.settings.file_types_description, "\n", + "\t", "file_size_limit: ", this.settings.file_size_limit, "\n", + "\t", "file_upload_limit: ", this.settings.file_upload_limit, "\n", + "\t", "file_queue_limit: ", this.settings.file_queue_limit, "\n", + "\t", "debug: ", this.settings.debug.toString(), "\n", + + "\t", "prevent_swf_caching: ", this.settings.prevent_swf_caching.toString(), "\n", + + "\t", "button_placeholder_id: ", this.settings.button_placeholder_id.toString(), "\n", + "\t", "button_placeholder: ", (this.settings.button_placeholder ? "Set" : "Not Set"), "\n", + "\t", "button_image_url: ", this.settings.button_image_url.toString(), "\n", + "\t", "button_width: ", this.settings.button_width.toString(), "\n", + "\t", "button_height: ", this.settings.button_height.toString(), "\n", + "\t", "button_text: ", this.settings.button_text.toString(), "\n", + "\t", "button_text_style: ", this.settings.button_text_style.toString(), "\n", + "\t", "button_text_top_padding: ", this.settings.button_text_top_padding.toString(), "\n", + "\t", "button_text_left_padding: ", this.settings.button_text_left_padding.toString(), "\n", + "\t", "button_action: ", this.settings.button_action.toString(), "\n", + "\t", "button_cursor: ", this.settings.button_cursor.toString(), "\n", + "\t", "button_disabled: ", this.settings.button_disabled.toString(), "\n", + + "\t", "custom_settings: ", this.settings.custom_settings.toString(), "\n", + "Event Handlers:\n", + "\t", "swfupload_preload_handler assigned: ", (typeof this.settings.swfupload_preload_handler === "function").toString(), "\n", + "\t", "swfupload_load_failed_handler assigned: ", (typeof this.settings.swfupload_load_failed_handler === "function").toString(), "\n", + "\t", "swfupload_loaded_handler assigned: ", (typeof this.settings.swfupload_loaded_handler === "function").toString(), "\n", + "\t", "mouse_click_handler assigned: ", (typeof this.settings.mouse_click_handler === "function").toString(), "\n", + "\t", "mouse_over_handler assigned: ", (typeof this.settings.mouse_over_handler === "function").toString(), "\n", + "\t", "mouse_out_handler assigned: ", (typeof this.settings.mouse_out_handler === "function").toString(), "\n", + "\t", "file_dialog_start_handler assigned: ", (typeof this.settings.file_dialog_start_handler === "function").toString(), "\n", + "\t", "file_queued_handler assigned: ", (typeof this.settings.file_queued_handler === "function").toString(), "\n", + "\t", "file_queue_error_handler assigned: ", (typeof this.settings.file_queue_error_handler === "function").toString(), "\n", + "\t", "upload_resize_start_handler assigned: ", (typeof this.settings.upload_resize_start_handler === "function").toString(), "\n", + "\t", "upload_start_handler assigned: ", (typeof this.settings.upload_start_handler === "function").toString(), "\n", + "\t", "upload_progress_handler assigned: ", (typeof this.settings.upload_progress_handler === "function").toString(), "\n", + "\t", "upload_error_handler assigned: ", (typeof this.settings.upload_error_handler === "function").toString(), "\n", + "\t", "upload_success_handler assigned: ", (typeof this.settings.upload_success_handler === "function").toString(), "\n", + "\t", "upload_complete_handler assigned: ", (typeof this.settings.upload_complete_handler === "function").toString(), "\n", + "\t", "debug_handler assigned: ", (typeof this.settings.debug_handler === "function").toString(), "\n", + + "Support:\n", + "\t", "Load: ", (this.support.loading ? "Yes" : "No"), "\n", + "\t", "Image Resize: ", (this.support.imageResize ? "Yes" : "No"), "\n" + + ].join("") + ); +}; + +/* Note: addSetting and getSetting are no longer used by SWFUpload but are included + the maintain v2 API compatibility +*/ +// Public: (Deprecated) addSetting adds a setting value. If the value given is undefined or null then the default_value is used. +SWFUpload.prototype.addSetting = function (name, value, default_value) { + if (value == undefined) { + return (this.settings[name] = default_value); + } else { + return (this.settings[name] = value); + } +}; + +// Public: (Deprecated) getSetting gets a setting. Returns an empty string if the setting was not found. +SWFUpload.prototype.getSetting = function (name) { + if (this.settings[name] != undefined) { + return this.settings[name]; + } + + return ""; +}; + + + +// Private: callFlash handles function calls made to the Flash element. +// Calls are made with a setTimeout for some functions to work around +// bugs in the ExternalInterface library. +SWFUpload.prototype.callFlash = function (functionName, argumentArray) { + var movieElement, returnValue, returnString; + + argumentArray = argumentArray || []; + movieElement = this.getMovieElement(); + + // Flash's method if calling ExternalInterface methods (code adapted from MooTools). + try { + if (movieElement != undefined) { + returnString = movieElement.CallFunction('' + __flash__argumentsToXML(argumentArray, 0) + ''); + returnValue = eval(returnString); + } else { + this.debug("Can't call flash because the movie wasn't found."); + } + } catch (ex) { + this.debug("Exception calling flash function '" + functionName + "': " + ex.message); + } + + // Unescape file post param values + if (returnValue != undefined && typeof returnValue.post === "object") { + returnValue = this.unescapeFilePostParams(returnValue); + } + + return returnValue; +}; + +/* ***************************** + -- Flash control methods -- + Your UI should use these + to operate SWFUpload + ***************************** */ + +// WARNING: this function does not work in Flash Player 10 +// Public: selectFile causes a File Selection Dialog window to appear. This +// dialog only allows 1 file to be selected. +SWFUpload.prototype.selectFile = function () { + this.callFlash("SelectFile"); +}; + +// WARNING: this function does not work in Flash Player 10 +// Public: selectFiles causes a File Selection Dialog window to appear/ This +// dialog allows the user to select any number of files +// Flash Bug Warning: Flash limits the number of selectable files based on the combined length of the file names. +// If the selection name length is too long the dialog will fail in an unpredictable manner. There is no work-around +// for this bug. +SWFUpload.prototype.selectFiles = function () { + this.callFlash("SelectFiles"); +}; + + +// Public: startUpload starts uploading the first file in the queue unless +// the optional parameter 'fileID' specifies the ID +SWFUpload.prototype.startUpload = function (fileID) { + this.callFlash("StartUpload", [fileID]); +}; + +// Public: startUpload starts uploading the first file in the queue unless +// the optional parameter 'fileID' specifies the ID +SWFUpload.prototype.startResizedUpload = function (fileID, width, height, encoding, quality, allowEnlarging) { + this.callFlash("StartUpload", [fileID, { "width": width, "height" : height, "encoding" : encoding, "quality" : quality, "allowEnlarging" : allowEnlarging }]); +}; + +// Public: cancelUpload cancels any queued file. The fileID parameter may be the file ID or index. +// If you do not specify a fileID the current uploading file or first file in the queue is cancelled. +// If you do not want the uploadError event to trigger you can specify false for the triggerErrorEvent parameter. +SWFUpload.prototype.cancelUpload = function (fileID, triggerErrorEvent) { + if (triggerErrorEvent !== false) { + triggerErrorEvent = true; + } + this.callFlash("CancelUpload", [fileID, triggerErrorEvent]); +}; + +// Public: stopUpload stops the current upload and requeues the file at the beginning of the queue. +// If nothing is currently uploading then nothing happens. +SWFUpload.prototype.stopUpload = function () { + this.callFlash("StopUpload"); +}; + + +// Public: requeueUpload requeues any file. If the file is requeued or already queued true is returned. +// If the file is not found or is currently uploading false is returned. Requeuing a file bypasses the +// file size, queue size, upload limit and other queue checks. Certain files can't be requeued (e.g, invalid or zero bytes files). +SWFUpload.prototype.requeueUpload = function (indexOrFileID) { + return this.callFlash("RequeueUpload", [indexOrFileID]); +}; + + +/* ************************ + * Settings methods + * These methods change the SWFUpload settings. + * SWFUpload settings should not be changed directly on the settings object + * since many of the settings need to be passed to Flash in order to take + * effect. + * *********************** */ + +// Public: getStats gets the file statistics object. +SWFUpload.prototype.getStats = function () { + return this.callFlash("GetStats"); +}; + +// Public: setStats changes the SWFUpload statistics. You shouldn't need to +// change the statistics but you can. Changing the statistics does not +// affect SWFUpload accept for the successful_uploads count which is used +// by the upload_limit setting to determine how many files the user may upload. +SWFUpload.prototype.setStats = function (statsObject) { + this.callFlash("SetStats", [statsObject]); +}; + +// Public: getFile retrieves a File object by ID or Index. If the file is +// not found then 'null' is returned. +SWFUpload.prototype.getFile = function (fileID) { + if (typeof(fileID) === "number") { + return this.callFlash("GetFileByIndex", [fileID]); + } else { + return this.callFlash("GetFile", [fileID]); + } +}; + +// Public: getFileFromQueue retrieves a File object by ID or Index. If the file is +// not found then 'null' is returned. +SWFUpload.prototype.getQueueFile = function (fileID) { + if (typeof(fileID) === "number") { + return this.callFlash("GetFileByQueueIndex", [fileID]); + } else { + return this.callFlash("GetFile", [fileID]); + } +}; + + +// Public: addFileParam sets a name/value pair that will be posted with the +// file specified by the Files ID. If the name already exists then the +// exiting value will be overwritten. +SWFUpload.prototype.addFileParam = function (fileID, name, value) { + return this.callFlash("AddFileParam", [fileID, name, value]); +}; + +// Public: removeFileParam removes a previously set (by addFileParam) name/value +// pair from the specified file. +SWFUpload.prototype.removeFileParam = function (fileID, name) { + this.callFlash("RemoveFileParam", [fileID, name]); +}; + +// Public: setUploadUrl changes the upload_url setting. +SWFUpload.prototype.setUploadURL = function (url) { + this.settings.upload_url = url.toString(); + this.callFlash("SetUploadURL", [url]); +}; + +// Public: setPostParams changes the post_params setting +SWFUpload.prototype.setPostParams = function (paramsObject) { + this.settings.post_params = paramsObject; + this.callFlash("SetPostParams", [paramsObject]); +}; + +// Public: addPostParam adds post name/value pair. Each name can have only one value. +SWFUpload.prototype.addPostParam = function (name, value) { + this.settings.post_params[name] = value; + this.callFlash("SetPostParams", [this.settings.post_params]); +}; + +// Public: removePostParam deletes post name/value pair. +SWFUpload.prototype.removePostParam = function (name) { + delete this.settings.post_params[name]; + this.callFlash("SetPostParams", [this.settings.post_params]); +}; + +// Public: setFileTypes changes the file_types setting and the file_types_description setting +SWFUpload.prototype.setFileTypes = function (types, description) { + this.settings.file_types = types; + this.settings.file_types_description = description; + this.callFlash("SetFileTypes", [types, description]); +}; + +// Public: setFileSizeLimit changes the file_size_limit setting +SWFUpload.prototype.setFileSizeLimit = function (fileSizeLimit) { + this.settings.file_size_limit = fileSizeLimit; + this.callFlash("SetFileSizeLimit", [fileSizeLimit]); +}; + +// Public: setFileUploadLimit changes the file_upload_limit setting +SWFUpload.prototype.setFileUploadLimit = function (fileUploadLimit) { + this.settings.file_upload_limit = fileUploadLimit; + this.callFlash("SetFileUploadLimit", [fileUploadLimit]); +}; + +// Public: setFileQueueLimit changes the file_queue_limit setting +SWFUpload.prototype.setFileQueueLimit = function (fileQueueLimit) { + this.settings.file_queue_limit = fileQueueLimit; + this.callFlash("SetFileQueueLimit", [fileQueueLimit]); +}; + +// Public: setFilePostName changes the file_post_name setting +SWFUpload.prototype.setFilePostName = function (filePostName) { + this.settings.file_post_name = filePostName; + this.callFlash("SetFilePostName", [filePostName]); +}; + +// Public: setUseQueryString changes the use_query_string setting +SWFUpload.prototype.setUseQueryString = function (useQueryString) { + this.settings.use_query_string = useQueryString; + this.callFlash("SetUseQueryString", [useQueryString]); +}; + +// Public: setRequeueOnError changes the requeue_on_error setting +SWFUpload.prototype.setRequeueOnError = function (requeueOnError) { + this.settings.requeue_on_error = requeueOnError; + this.callFlash("SetRequeueOnError", [requeueOnError]); +}; + +// Public: setHTTPSuccess changes the http_success setting +SWFUpload.prototype.setHTTPSuccess = function (http_status_codes) { + if (typeof http_status_codes === "string") { + http_status_codes = http_status_codes.replace(" ", "").split(","); + } + + this.settings.http_success = http_status_codes; + this.callFlash("SetHTTPSuccess", [http_status_codes]); +}; + +// Public: setHTTPSuccess changes the http_success setting +SWFUpload.prototype.setAssumeSuccessTimeout = function (timeout_seconds) { + this.settings.assume_success_timeout = timeout_seconds; + this.callFlash("SetAssumeSuccessTimeout", [timeout_seconds]); +}; + +// Public: setDebugEnabled changes the debug_enabled setting +SWFUpload.prototype.setDebugEnabled = function (debugEnabled) { + this.settings.debug_enabled = debugEnabled; + this.callFlash("SetDebugEnabled", [debugEnabled]); +}; + +// Public: setButtonImageURL loads a button image sprite +SWFUpload.prototype.setButtonImageURL = function (buttonImageURL) { + if (buttonImageURL == undefined) { + buttonImageURL = ""; + } + + this.settings.button_image_url = buttonImageURL; + this.callFlash("SetButtonImageURL", [buttonImageURL]); +}; + +// Public: setButtonDimensions resizes the Flash Movie and button +SWFUpload.prototype.setButtonDimensions = function (width, height) { + this.settings.button_width = width; + this.settings.button_height = height; + + var movie = this.getMovieElement(); + if (movie != undefined) { + movie.style.width = width + "px"; + movie.style.height = height + "px"; + } + + this.callFlash("SetButtonDimensions", [width, height]); +}; +// Public: setButtonText Changes the text overlaid on the button +SWFUpload.prototype.setButtonText = function (html) { + this.settings.button_text = html; + this.callFlash("SetButtonText", [html]); +}; +// Public: setButtonTextPadding changes the top and left padding of the text overlay +SWFUpload.prototype.setButtonTextPadding = function (left, top) { + this.settings.button_text_top_padding = top; + this.settings.button_text_left_padding = left; + this.callFlash("SetButtonTextPadding", [left, top]); +}; + +// Public: setButtonTextStyle changes the CSS used to style the HTML/Text overlaid on the button +SWFUpload.prototype.setButtonTextStyle = function (css) { + this.settings.button_text_style = css; + this.callFlash("SetButtonTextStyle", [css]); +}; +// Public: setButtonDisabled disables/enables the button +SWFUpload.prototype.setButtonDisabled = function (isDisabled) { + this.settings.button_disabled = isDisabled; + this.callFlash("SetButtonDisabled", [isDisabled]); +}; +// Public: setButtonAction sets the action that occurs when the button is clicked +SWFUpload.prototype.setButtonAction = function (buttonAction) { + this.settings.button_action = buttonAction; + this.callFlash("SetButtonAction", [buttonAction]); +}; + +// Public: setButtonCursor changes the mouse cursor displayed when hovering over the button +SWFUpload.prototype.setButtonCursor = function (cursor) { + this.settings.button_cursor = cursor; + this.callFlash("SetButtonCursor", [cursor]); +}; + +/* ******************************* + Flash Event Interfaces + These functions are used by Flash to trigger the various + events. + + All these functions a Private. + + Because the ExternalInterface library is buggy the event calls + are added to a queue and the queue then executed by a setTimeout. + This ensures that events are executed in a determinate order and that + the ExternalInterface bugs are avoided. +******************************* */ + +SWFUpload.prototype.queueEvent = function (handlerName, argumentArray) { + // Warning: Don't call this.debug inside here or you'll create an infinite loop + var self = this; + + if (argumentArray == undefined) { + argumentArray = []; + } else if (!(argumentArray instanceof Array)) { + argumentArray = [argumentArray]; + } + + if (typeof this.settings[handlerName] === "function") { + // Queue the event + this.eventQueue.push(function () { + this.settings[handlerName].apply(this, argumentArray); + }); + + // Execute the next queued event + setTimeout(function () { + self.executeNextEvent(); + }, 0); + + } else if (this.settings[handlerName] !== null) { + throw "Event handler " + handlerName + " is unknown or is not a function"; + } +}; + +// Private: Causes the next event in the queue to be executed. Since events are queued using a setTimeout +// we must queue them in order to garentee that they are executed in order. +SWFUpload.prototype.executeNextEvent = function () { + // Warning: Don't call this.debug inside here or you'll create an infinite loop + + var f = this.eventQueue ? this.eventQueue.shift() : null; + if (typeof(f) === "function") { + f.apply(this); + } +}; + +// Private: unescapeFileParams is part of a workaround for a flash bug where objects passed through ExternalInterface cannot have +// properties that contain characters that are not valid for JavaScript identifiers. To work around this +// the Flash Component escapes the parameter names and we must unescape again before passing them along. +SWFUpload.prototype.unescapeFilePostParams = function (file) { + var reg = /[$]([0-9a-f]{4})/i, unescapedPost = {}, uk, k, match; + + if (file != undefined) { + for (k in file.post) { + if (file.post.hasOwnProperty(k)) { + uk = k; + while ((match = reg.exec(uk)) !== null) { + uk = uk.replace(match[0], String.fromCharCode(parseInt("0x" + match[1], 16))); + } + unescapedPost[uk] = file.post[k]; + } + } + + file.post = unescapedPost; + } + + return file; +}; + +// Private: This event is called by SWFUpload Init after we've determined what the user's Flash Player supports. +// Use the swfupload_preload_handler event setting to execute custom code when SWFUpload has loaded. +// Return false to prevent SWFUpload from loading and allow your script to do something else if your required feature is +// not supported +SWFUpload.prototype.swfuploadPreload = function () { + var returnValue; + if (typeof this.settings.swfupload_preload_handler === "function") { + returnValue = this.settings.swfupload_preload_handler.call(this); + } else if (this.settings.swfupload_preload_handler != undefined) { + throw "upload_start_handler must be a function"; + } + + // Convert undefined to true so if nothing is returned from the upload_start_handler it is + // interpretted as 'true'. + if (returnValue === undefined) { + returnValue = true; + } + + return !!returnValue; +} + +// Private: This event is called by Flash when it has finished loading. Don't modify this. +// Use the swfupload_loaded_handler event setting to execute custom code when SWFUpload has loaded. +SWFUpload.prototype.flashReady = function () { + // Check that the movie element is loaded correctly with its ExternalInterface methods defined + var movieElement = this.cleanUp(); + + if (!movieElement) { + this.debug("Flash called back ready but the flash movie can't be found."); + return; + } + + this.queueEvent("swfupload_loaded_handler"); +}; + +// Private: removes Flash added fuctions to the DOM node to prevent memory leaks in IE. +// This function is called by Flash each time the ExternalInterface functions are created. +SWFUpload.prototype.cleanUp = function () { + var key, movieElement = this.getMovieElement(); + + // Pro-actively unhook all the Flash functions + try { + if (movieElement && typeof(movieElement.CallFunction) === "unknown") { // We only want to do this in IE + this.debug("Removing Flash functions hooks (this should only run in IE and should prevent memory leaks)"); + for (key in movieElement) { + try { + if (typeof(movieElement[key]) === "function") { + movieElement[key] = null; + } + } catch (ex) { + } + } + } + } catch (ex1) { + + } + + // Fix Flashes own cleanup code so if the SWF Movie was removed from the page + // it doesn't display errors. + window["__flash__removeCallback"] = function (instance, name) { + try { + if (instance) { + instance[name] = null; + } + } catch (flashEx) { + + } + }; + + return movieElement; +}; + +/* When the button_action is set to None this event gets fired and executes the mouse_click_handler */ +SWFUpload.prototype.mouseClick = function () { + this.queueEvent("mouse_click_handler"); +}; +SWFUpload.prototype.mouseOver = function () { + this.queueEvent("mouse_over_handler"); +}; +SWFUpload.prototype.mouseOut = function () { + this.queueEvent("mouse_out_handler"); +}; + +/* This is a chance to do something before the browse window opens */ +SWFUpload.prototype.fileDialogStart = function () { + this.queueEvent("file_dialog_start_handler"); +}; + + +/* Called when a file is successfully added to the queue. */ +SWFUpload.prototype.fileQueued = function (file) { + file = this.unescapeFilePostParams(file); + this.queueEvent("file_queued_handler", file); +}; + + +/* Handle errors that occur when an attempt to queue a file fails. */ +SWFUpload.prototype.fileQueueError = function (file, errorCode, message) { + file = this.unescapeFilePostParams(file); + this.queueEvent("file_queue_error_handler", [file, errorCode, message]); +}; + +/* Called after the file dialog has closed and the selected files have been queued. + You could call startUpload here if you want the queued files to begin uploading immediately. */ +SWFUpload.prototype.fileDialogComplete = function (numFilesSelected, numFilesQueued, numFilesInQueue) { + this.queueEvent("file_dialog_complete_handler", [numFilesSelected, numFilesQueued, numFilesInQueue]); +}; + +SWFUpload.prototype.uploadResizeStart = function (file, resizeSettings) { + file = this.unescapeFilePostParams(file); + this.queueEvent("upload_resize_start_handler", [file, resizeSettings.width, resizeSettings.height, resizeSettings.encoding, resizeSettings.quality]); +}; + +SWFUpload.prototype.uploadStart = function (file) { + file = this.unescapeFilePostParams(file); + this.queueEvent("return_upload_start_handler", file); +}; + +SWFUpload.prototype.returnUploadStart = function (file) { + var returnValue; + if (typeof this.settings.upload_start_handler === "function") { + file = this.unescapeFilePostParams(file); + returnValue = this.settings.upload_start_handler.call(this, file); + } else if (this.settings.upload_start_handler != undefined) { + throw "upload_start_handler must be a function"; + } + + // Convert undefined to true so if nothing is returned from the upload_start_handler it is + // interpretted as 'true'. + if (returnValue === undefined) { + returnValue = true; + } + + returnValue = !!returnValue; + + this.callFlash("ReturnUploadStart", [returnValue]); +}; + + + +SWFUpload.prototype.uploadProgress = function (file, bytesComplete, bytesTotal) { + file = this.unescapeFilePostParams(file); + this.queueEvent("upload_progress_handler", [file, bytesComplete, bytesTotal]); +}; + +SWFUpload.prototype.uploadError = function (file, errorCode, message) { + file = this.unescapeFilePostParams(file); + this.queueEvent("upload_error_handler", [file, errorCode, message]); +}; + +SWFUpload.prototype.uploadSuccess = function (file, serverData, responseReceived) { + file = this.unescapeFilePostParams(file); + this.queueEvent("upload_success_handler", [file, serverData, responseReceived]); +}; + +SWFUpload.prototype.uploadComplete = function (file) { + file = this.unescapeFilePostParams(file); + this.queueEvent("upload_complete_handler", file); +}; + +/* Called by SWFUpload JavaScript and Flash functions when debug is enabled. By default it writes messages to the + internal debug console. You can override this event and have messages written where you want. */ +SWFUpload.prototype.debug = function (message) { + this.queueEvent("debug_handler", message); +}; + + +/* ********************************** + Debug Console + The debug console is a self contained, in page location + for debug message to be sent. The Debug Console adds + itself to the body if necessary. + + The console is automatically scrolled as messages appear. + + If you are using your own debug handler or when you deploy to production and + have debug disabled you can remove these functions to reduce the file size + and complexity. +********************************** */ + +// Private: debugMessage is the default debug_handler. If you want to print debug messages +// call the debug() function. When overriding the function your own function should +// check to see if the debug setting is true before outputting debug information. +SWFUpload.prototype.debugMessage = function (message) { + var exceptionMessage, exceptionValues, key; + + if (this.settings.debug) { + exceptionValues = []; + + // Check for an exception object and print it nicely + if (typeof message === "object" && typeof message.name === "string" && typeof message.message === "string") { + for (key in message) { + if (message.hasOwnProperty(key)) { + exceptionValues.push(key + ": " + message[key]); + } + } + exceptionMessage = exceptionValues.join("\n") || ""; + exceptionValues = exceptionMessage.split("\n"); + exceptionMessage = "EXCEPTION: " + exceptionValues.join("\nEXCEPTION: "); + SWFUpload.Console.writeLine(exceptionMessage); + } else { + SWFUpload.Console.writeLine(message); + } + } +}; + +SWFUpload.Console = {}; +SWFUpload.Console.writeLine = function (message) { + var console, documentForm; + + try { + console = document.getElementById("SWFUpload_Console"); + + if (!console) { + documentForm = document.createElement("form"); + document.getElementsByTagName("body")[0].appendChild(documentForm); + + console = document.createElement("textarea"); + console.id = "SWFUpload_Console"; + console.style.fontFamily = "monospace"; + console.setAttribute("wrap", "off"); + console.wrap = "off"; + console.style.overflow = "auto"; + console.style.width = "700px"; + console.style.height = "350px"; + console.style.margin = "5px"; + documentForm.appendChild(console); + } + + console.value += message + "\n"; + + console.scrollTop = console.scrollHeight - console.clientHeight; + } catch (ex) { + alert("Exception: " + ex.name + " Message: " + ex.message); + } +}; + + +/* SWFObject v2.2 + is released under the MIT License +*/ +swfobject = function(){var D="undefined",r="object",S="Shockwave Flash",W="ShockwaveFlash.ShockwaveFlash",q="application/x-shockwave-flash",R="SWFObjectExprInst",x="onreadystatechange",O=window,j=document,t=navigator,T=false,U=[h],o=[],N=[],I=[],l,Q,E,B,J=false,a=false,n,G,m=true,M=function(){var aa=typeof j.getElementById!=D&&typeof j.getElementsByTagName!=D&&typeof j.createElement!=D,ah=t.userAgent.toLowerCase(),Y=t.platform.toLowerCase(),ae=Y?/win/.test(Y):/win/.test(ah),ac=Y?/mac/.test(Y):/mac/.test(ah),af=/webkit/.test(ah)?parseFloat(ah.replace(/^.*webkit\/(\d+(\.\d+)?).*$/,"$1")):false,X=!+"\v1",ag=[0,0,0],ab=null;if(typeof t.plugins!=D&&typeof t.plugins[S]==r){ab=t.plugins[S].description;if(ab&&!(typeof t.mimeTypes!=D&&t.mimeTypes[q]&&!t.mimeTypes[q].enabledPlugin)){T=true;X=false;ab=ab.replace(/^.*\s+(\S+\s+\S+$)/,"$1");ag[0]=parseInt(ab.replace(/^(.*)\..*$/,"$1"),10);ag[1]=parseInt(ab.replace(/^.*\.(.*)\s.*$/,"$1"),10);ag[2]=/[a-zA-Z]/.test(ab)?parseInt(ab.replace(/^.*[a-zA-Z]+(.*)$/,"$1"),10):0}}else{if(typeof O.ActiveXObject!=D){try{var ad=new ActiveXObject(W);if(ad){ab=ad.GetVariable("$version");if(ab){X=true;ab=ab.split(" ")[1].split(",");ag=[parseInt(ab[0],10),parseInt(ab[1],10),parseInt(ab[2],10)]}}}catch(Z){}}}return{w3:aa,pv:ag,wk:af,ie:X,win:ae,mac:ac}}(),k=function(){if(!M.w3){return}if((typeof j.readyState!=D&&j.readyState=="complete")||(typeof j.readyState==D&&(j.getElementsByTagName("body")[0]||j.body))){f()}if(!J){if(typeof j.addEventListener!=D){j.addEventListener("DOMContentLoaded",f,false)}if(M.ie&&M.win){j.attachEvent(x,function(){if(j.readyState=="complete"){j.detachEvent(x,arguments.callee);f()}});if(O==top){(function(){if(J){return}try{j.documentElement.doScroll("left")}catch(X){setTimeout(arguments.callee,0);return}f()})()}}if(M.wk){(function(){if(J){return}if(!/loaded|complete/.test(j.readyState)){setTimeout(arguments.callee,0);return}f()})()}s(f)}}();function f(){if(J){return}try{var Z=j.getElementsByTagName("body")[0].appendChild(C("span"));Z.parentNode.removeChild(Z)}catch(aa){return}J=true;var X=U.length;for(var Y=0;Y0){for(var af=0;af0){var ae=c(Y);if(ae){if(F(o[af].swfVersion)&&!(M.wk&&M.wk<312)){w(Y,true);if(ab){aa.success=true;aa.ref=z(Y);ab(aa)}}else{if(o[af].expressInstall&&A()){var ai={};ai.data=o[af].expressInstall;ai.width=ae.getAttribute("width")||"0";ai.height=ae.getAttribute("height")||"0";if(ae.getAttribute("class")){ai.styleclass=ae.getAttribute("class")}if(ae.getAttribute("align")){ai.align=ae.getAttribute("align")}var ah={};var X=ae.getElementsByTagName("param");var ac=X.length;for(var ad=0;ad'}}aa.outerHTML='"+af+"";N[N.length]=ai.id;X=c(ai.id)}else{var Z=C(r);Z.setAttribute("type",q);for(var ac in ai){if(ai[ac]!=Object.prototype[ac]){if(ac.toLowerCase()=="styleclass"){Z.setAttribute("class",ai[ac])}else{if(ac.toLowerCase()!="classid"){Z.setAttribute(ac,ai[ac])}}}}for(var ab in ag){if(ag[ab]!=Object.prototype[ab]&&ab.toLowerCase()!="movie"){e(Z,ab,ag[ab])}}aa.parentNode.replaceChild(Z,aa);X=Z}}return X}function e(Z,X,Y){var aa=C("param");aa.setAttribute("name",X);aa.setAttribute("value",Y);Z.appendChild(aa)}function y(Y){var X=c(Y);if(X&&X.nodeName=="OBJECT"){if(M.ie&&M.win){X.style.display="none";(function(){if(X.readyState==4){b(Y)}else{setTimeout(arguments.callee,10)}})()}else{X.parentNode.removeChild(X)}}}function b(Z){var Y=c(Z);if(Y){for(var X in Y){if(typeof Y[X]=="function"){Y[X]=null}}Y.parentNode.removeChild(Y)}}function c(Z){var X=null;try{X=j.getElementById(Z)}catch(Y){}return X}function C(X){return j.createElement(X)}function i(Z,X,Y){Z.attachEvent(X,Y);I[I.length]=[Z,X,Y]}function F(Z){var Y=M.pv,X=Z.split(".");X[0]=parseInt(X[0],10);X[1]=parseInt(X[1],10)||0;X[2]=parseInt(X[2],10)||0;return(Y[0]>X[0]||(Y[0]==X[0]&&Y[1]>X[1])||(Y[0]==X[0]&&Y[1]==X[1]&&Y[2]>=X[2]))?true:false}function v(ac,Y,ad,ab){if(M.ie&&M.mac){return}var aa=j.getElementsByTagName("head")[0];if(!aa){return}var X=(ad&&typeof ad=="string")?ad:"screen";if(ab){n=null;G=null}if(!n||G!=X){var Z=C("style");Z.setAttribute("type","text/css");Z.setAttribute("media",X);n=aa.appendChild(Z);if(M.ie&&M.win&&typeof j.styleSheets!=D&&j.styleSheets.length>0){n=j.styleSheets[j.styleSheets.length-1]}G=X}if(M.ie&&M.win){if(n&&typeof n.addRule==r){n.addRule(ac,Y)}}else{if(n&&typeof j.createTextNode!=D){n.appendChild(j.createTextNode(ac+" {"+Y+"}"))}}}function w(Z,X){if(!m){return}var Y=X?"visible":"hidden";if(J&&c(Z)){c(Z).style.visibility=Y}else{v("#"+Z,"visibility:"+Y)}}function L(Y){var Z=/[\\\"<>\.;]/;var X=Z.exec(Y)!=null;return X&&typeof encodeURIComponent!=D?encodeURIComponent(Y):Y}var d=function(){if(M.ie&&M.win){window.attachEvent("onunload",function(){var ac=I.length;for(var ab=0;ab applyDayNight(this) + } + } + + /** + * 创建通知ID + */ + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + (getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)?.let { + val downloadChannel = NotificationChannel( + channelIdDownload, + getString(R.string.action_download), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + enableLights(false) + enableVibration(false) + setSound(null, null) + } + + val readAloudChannel = NotificationChannel( + channelIdReadAloud, + getString(R.string.read_aloud), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + enableLights(false) + enableVibration(false) + setSound(null, null) + } + + val webChannel = NotificationChannel( + channelIdWeb, + getString(R.string.web_service), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + enableLights(false) + enableVibration(false) + setSound(null, null) + } + + //向notification manager 提交channel + it.createNotificationChannels(listOf(downloadChannel, readAloudChannel, webChannel)) + } + } + +} diff --git a/app/src/main/java/io/legado/app/README.md b/app/src/main/java/io/legado/app/README.md new file mode 100644 index 000000000..a05f70415 --- /dev/null +++ b/app/src/main/java/io/legado/app/README.md @@ -0,0 +1,12 @@ +# 文件结构介绍 + +* base 基类 +* constant 常量 +* data 数据 +* help 帮助 +* lib 库 +* model 解析 +* receiver 广播侦听 +* service 服务 +* ui 界面 +* web web服务 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/api/ReaderProvider.kt b/app/src/main/java/io/legado/app/api/ReaderProvider.kt new file mode 100644 index 000000000..50ebca936 --- /dev/null +++ b/app/src/main/java/io/legado/app/api/ReaderProvider.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2020 w568w + */ +package io.legado.app.api + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.UriMatcher +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import com.google.gson.Gson +import io.legado.app.api.controller.BookController +import io.legado.app.api.controller.BookSourceController +import io.legado.app.api.controller.RssSourceController +import java.util.* + +/** + * Export book data to other app. + */ +class ReaderProvider : ContentProvider() { + private enum class RequestCode { + SaveBookSource, SaveBookSources, DeleteBookSources, GetBookSource, GetBookSources, + SaveRssSource, SaveRssSources, DeleteRssSources, GetRssSource, GetRssSources, + SaveBook, GetBookshelf, RefreshToc, GetChapterList, GetBookContent, GetBookCover + } + + private val postBodyKey = "json" + private val sMatcher by lazy { + UriMatcher(UriMatcher.NO_MATCH).apply { + "${context?.applicationInfo?.packageName}.readerProvider".also { authority -> + addURI(authority, "bookSource/insert", RequestCode.SaveBookSource.ordinal) + addURI(authority, "bookSources/insert", RequestCode.SaveBookSources.ordinal) + addURI(authority, "bookSources/delete", RequestCode.DeleteBookSources.ordinal) + addURI(authority, "bookSource/query", RequestCode.GetBookSource.ordinal) + addURI(authority, "bookSources/query", RequestCode.GetBookSources.ordinal) + addURI(authority, "rssSource/insert", RequestCode.SaveBookSource.ordinal) + addURI(authority, "rssSources/insert", RequestCode.SaveBookSources.ordinal) + addURI(authority, "rssSources/delete", RequestCode.DeleteBookSources.ordinal) + addURI(authority, "rssSource/query", RequestCode.GetBookSource.ordinal) + addURI(authority, "rssSources/query", RequestCode.GetBookSources.ordinal) + addURI(authority, "book/insert", RequestCode.SaveBook.ordinal) + addURI(authority, "books/query", RequestCode.GetBookshelf.ordinal) + addURI(authority, "book/refreshToc/query", RequestCode.RefreshToc.ordinal) + addURI(authority, "book/chapter/query", RequestCode.GetChapterList.ordinal) + addURI(authority, "book/content/query", RequestCode.GetBookContent.ordinal) + addURI(authority, "book/cover/query", RequestCode.GetBookCover.ordinal) + } + } + } + + override fun onCreate() = false + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array? + ): Int { + if (sMatcher.match(uri) < 0) return -1 + when (RequestCode.values()[sMatcher.match(uri)]) { + RequestCode.DeleteBookSources -> BookSourceController.deleteSources(selection) + RequestCode.DeleteRssSources -> BookSourceController.deleteSources(selection) + else -> throw IllegalStateException( + "Unexpected value: " + RequestCode.values()[sMatcher.match(uri)].name + ) + } + return 0 + } + + override fun getType(uri: Uri) = throw UnsupportedOperationException("Not yet implemented") + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + if (sMatcher.match(uri) < 0) return null + when (RequestCode.values()[sMatcher.match(uri)]) { + RequestCode.SaveBookSource -> values?.let { + BookSourceController.saveSource(values.getAsString(postBodyKey)) + } + RequestCode.SaveBookSources -> values?.let { + BookSourceController.saveSources(values.getAsString(postBodyKey)) + } + RequestCode.SaveRssSource -> values?.let { + RssSourceController.saveSource(values.getAsString(postBodyKey)) + } + RequestCode.SaveRssSources -> values?.let { + RssSourceController.saveSources(values.getAsString(postBodyKey)) + } + RequestCode.SaveBook -> values?.let { + BookController.saveBook(values.getAsString(postBodyKey)) + } + else -> throw IllegalStateException( + "Unexpected value: " + RequestCode.values()[sMatcher.match(uri)].name + ) + } + return null + } + + override fun query( + uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String? + ): Cursor? { + val map: MutableMap> = HashMap() + uri.getQueryParameter("url")?.let { + map["url"] = arrayListOf(it) + } + uri.getQueryParameter("index")?.let { + map["index"] = arrayListOf(it) + } + uri.getQueryParameter("path")?.let { + map["path"] = arrayListOf(it) + } + return if (sMatcher.match(uri) < 0) null else when (RequestCode.values()[sMatcher.match(uri)]) { + RequestCode.GetBookSource -> SimpleCursor(BookSourceController.getSource(map)) + RequestCode.GetBookSources -> SimpleCursor(BookSourceController.sources) + RequestCode.GetRssSource -> SimpleCursor(RssSourceController.getSource(map)) + RequestCode.GetRssSources -> SimpleCursor(RssSourceController.sources) + RequestCode.GetBookshelf -> SimpleCursor(BookController.bookshelf) + RequestCode.GetBookContent -> SimpleCursor(BookController.getBookContent(map)) + RequestCode.RefreshToc -> SimpleCursor(BookController.refreshToc(map)) + RequestCode.GetChapterList -> SimpleCursor(BookController.getChapterList(map)) + RequestCode.GetBookCover -> SimpleCursor(BookController.getCover(map)) + else -> throw IllegalStateException( + "Unexpected value: " + RequestCode.values()[sMatcher.match(uri)].name + ) + } + } + + override fun update( + uri: Uri, values: ContentValues?, selection: String?, + selectionArgs: Array? + ) = throw UnsupportedOperationException("Not yet implemented") + + + /** + * Simple inner class to deliver json callback data. + * + * Only getString() makes sense. + */ + private class SimpleCursor(data: ReturnData?) : MatrixCursor(arrayOf("result"), 1) { + + private val mData: String = Gson().toJson(data) + + init { + addRow(arrayOf(mData)) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/api/ReturnData.kt b/app/src/main/java/io/legado/app/api/ReturnData.kt new file mode 100644 index 000000000..f6d6aefdc --- /dev/null +++ b/app/src/main/java/io/legado/app/api/ReturnData.kt @@ -0,0 +1,27 @@ +package io.legado.app.api + + +class ReturnData { + + var isSuccess: Boolean = false + private set + + var errorMsg: String = "未知错误,请联系开发者!" + private set + + var data: Any? = null + private set + + fun setErrorMsg(errorMsg: String): ReturnData { + this.isSuccess = false + this.errorMsg = errorMsg + return this + } + + fun setData(data: Any): ReturnData { + this.isSuccess = true + this.errorMsg = "" + this.data = data + return this + } +} diff --git a/app/src/main/java/io/legado/app/api/controller/BookController.kt b/app/src/main/java/io/legado/app/api/controller/BookController.kt new file mode 100644 index 000000000..eba0c33b9 --- /dev/null +++ b/app/src/main/java/io/legado/app/api/controller/BookController.kt @@ -0,0 +1,222 @@ +package io.legado.app.api.controller + +import android.util.Base64 +import androidx.core.graphics.drawable.toBitmap +import io.legado.app.R +import io.legado.app.api.ReturnData +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.help.BookHelp +import io.legado.app.help.ContentProcessor +import io.legado.app.help.glide.ImageLoader +import io.legado.app.help.storage.AppWebDav +import io.legado.app.model.BookCover +import io.legado.app.model.ReadBook +import io.legado.app.model.localBook.EpubFile +import io.legado.app.model.localBook.LocalBook +import io.legado.app.model.localBook.UmdFile +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.* +import kotlinx.coroutines.runBlocking +import splitties.init.appCtx +import timber.log.Timber + +object BookController { + + /** + * 书架所有书籍 + */ + val bookshelf: ReturnData + get() { + val books = appDb.bookDao.all + val returnData = ReturnData() + return if (books.isEmpty()) { + returnData.setErrorMsg("还没有添加小说") + } else { + val data = when (appCtx.getPrefInt(PreferKey.bookshelfSort)) { + 1 -> books.sortedByDescending { it.latestChapterTime } + 2 -> books.sortedWith { o1, o2 -> + o1.name.cnCompare(o2.name) + } + 3 -> books.sortedBy { it.order } + else -> books.sortedByDescending { it.durChapterTime } + } + returnData.setData(data) + } + } + + fun getCover(parameters: Map>): ReturnData { + val returnData = ReturnData() + val coverPath = parameters["path"]?.firstOrNull() + val ftBitmap = ImageLoader.loadBitmap(appCtx, coverPath).submit() + return try { + returnData.setData(ftBitmap.get()) + } catch (e: Exception) { + returnData.setData(BookCover.defaultDrawable.toBitmap()) + } + } + + /** + * 更新目录 + */ + fun refreshToc(parameters: Map>): ReturnData { + val returnData = ReturnData() + try { + val bookUrl = parameters["url"]?.firstOrNull() + if (bookUrl.isNullOrEmpty()) { + return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") + } + val book = appDb.bookDao.getBook(bookUrl) + ?: return returnData.setErrorMsg("bookUrl不对") + if (book.isLocalBook()) { + val toc = LocalBook.getChapterList(book) + appDb.bookChapterDao.delByBook(book.bookUrl) + appDb.bookChapterDao.insert(*toc.toTypedArray()) + appDb.bookDao.update(book) + return returnData.setData(toc) + } else { + val bookSource = appDb.bookSourceDao.getBookSource(book.origin) + ?: return returnData.setErrorMsg("未找到对应书源,请换源") + val toc = runBlocking { + if (book.tocUrl.isBlank()) { + WebBook.getBookInfoAwait(this, bookSource, book) + } + WebBook.getChapterListAwait(this, bookSource, book) + } + appDb.bookChapterDao.delByBook(book.bookUrl) + appDb.bookChapterDao.insert(*toc.toTypedArray()) + appDb.bookDao.update(book) + return returnData.setData(toc) + } + } catch (e: Exception) { + return returnData.setErrorMsg(e.localizedMessage ?: "refresh toc error") + } + } + + /** + * 获取目录 + */ + fun getChapterList(parameters: Map>): ReturnData { + val bookUrl = parameters["url"]?.firstOrNull() + val returnData = ReturnData() + if (bookUrl.isNullOrEmpty()) { + return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") + } + val chapterList = appDb.bookChapterDao.getChapterList(bookUrl) + if (chapterList.isEmpty()) { + return refreshToc(parameters) + } + return returnData.setData(chapterList) + } + + /** + * 获取正文 + */ + fun getBookContent(parameters: Map>): ReturnData { + val bookUrl = parameters["url"]?.firstOrNull() + val index = parameters["index"]?.firstOrNull()?.toInt() + val returnData = ReturnData() + if (bookUrl.isNullOrEmpty()) { + return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") + } + if (index == null) { + return returnData.setErrorMsg("参数index不能为空, 请指定目录序号") + } + val book = appDb.bookDao.getBook(bookUrl) + val chapter = appDb.bookChapterDao.getChapter(bookUrl, index) + if (book == null || chapter == null) { + return returnData.setErrorMsg("未找到") + } + var content: String? = BookHelp.getContent(book, chapter) + if (content != null) { + val contentProcessor = ContentProcessor.get(book.name, book.origin) + saveBookReadIndex(book, index) + return returnData.setData( + contentProcessor.getContent(book, chapter, content, includeTitle = false) + .joinToString("\n") + ) + } + val bookSource = appDb.bookSourceDao.getBookSource(book.origin) + ?: return returnData.setErrorMsg("未找到书源") + try { + content = runBlocking { + WebBook.getContentAwait(this, bookSource, book, chapter) + } + val contentProcessor = ContentProcessor.get(book.name, book.origin) + saveBookReadIndex(book, index) + returnData.setData( + contentProcessor.getContent(book, chapter, content, includeTitle = false) + .joinToString("\n") + ) + } catch (e: Exception) { + returnData.setErrorMsg(e.msg) + } + return returnData + } + + fun saveBook(postData: String?): ReturnData { + val book = GSON.fromJsonObject(postData) + val returnData = ReturnData() + if (book != null) { + book.save() + AppWebDav.uploadBookProgress(book) + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.book = book + ReadBook.durChapterIndex = book.durChapterIndex + } + return returnData.setData("") + } + return returnData.setErrorMsg("格式不对") + } + + private fun saveBookReadIndex(book: Book, index: Int) { + book.durChapterIndex = index + book.durChapterTime = System.currentTimeMillis() + appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { + book.durChapterTitle = it.title + } + appDb.bookDao.update(book) + AppWebDav.uploadBookProgress(book) + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.book = book + ReadBook.durChapterIndex = index + ReadBook.loadContent(index) + } + } + + fun addLocalBook(parameters: Map>): ReturnData { + val returnData = ReturnData() + try { + val fileName = parameters["fileName"]?.firstOrNull() + ?: return returnData.setErrorMsg("fileName 不能为空") + val fileData = parameters["fileData"]?.firstOrNull() + ?: return returnData.setErrorMsg("fileData 不能为空") + val file = FileUtils.createFileIfNotExist(LocalBook.cacheFolder, fileName) + val fileBytes = Base64.decode(fileData.substringAfter("base64,"), Base64.DEFAULT) + file.writeBytes(fileBytes) + val nameAuthor = LocalBook.analyzeNameAuthor(fileName) + val book = Book( + bookUrl = file.absolutePath, + name = nameAuthor.first, + author = nameAuthor.second, + originName = fileName, + coverUrl = FileUtils.getPath( + appCtx.externalFiles, + "covers", + "${MD5Utils.md5Encode16(file.absolutePath)}.jpg" + ) + ) + if (book.isEpub()) EpubFile.upBookInfo(book) + if (book.isUmd()) UmdFile.upBookInfo(book) + appDb.bookDao.insert(book) + } catch (e: Exception) { + Timber.e(e) + return returnData.setErrorMsg( + e.localizedMessage ?: appCtx.getString(R.string.unknown_error) + ) + } + return returnData.setData(true) + } + +} diff --git a/app/src/main/java/io/legado/app/api/controller/BookSourceController.kt b/app/src/main/java/io/legado/app/api/controller/BookSourceController.kt new file mode 100644 index 000000000..417db03e8 --- /dev/null +++ b/app/src/main/java/io/legado/app/api/controller/BookSourceController.kt @@ -0,0 +1,84 @@ +package io.legado.app.api.controller + + +import android.text.TextUtils +import io.legado.app.api.ReturnData +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.msg + +object BookSourceController { + + val sources: ReturnData + get() { + val bookSources = appDb.bookSourceDao.all + val returnData = ReturnData() + return if (bookSources.isEmpty()) { + returnData.setErrorMsg("设备源列表为空") + } else returnData.setData(bookSources) + } + + fun saveSource(postData: String?): ReturnData { + val returnData = ReturnData() + postData ?: return returnData.setErrorMsg("数据不能为空") + kotlin.runCatching { + val bookSource = BookSource.fromJson(postData) + if (bookSource != null) { + if (TextUtils.isEmpty(bookSource.bookSourceName) || TextUtils.isEmpty(bookSource.bookSourceUrl)) { + returnData.setErrorMsg("源名称和URL不能为空") + } else { + appDb.bookSourceDao.insert(bookSource) + returnData.setData("") + } + } else { + returnData.setErrorMsg("转换源失败") + } + }.onFailure { + returnData.setErrorMsg(it.msg) + } + return returnData + } + + fun saveSources(postData: String?): ReturnData { + postData ?: return ReturnData().setErrorMsg("数据为空") + val okSources = arrayListOf() + val bookSources = BookSource.fromJsonArray(postData) + if (bookSources.isNotEmpty()) { + bookSources.forEach { bookSource -> + if (bookSource.bookSourceName.isNotBlank() + && bookSource.bookSourceUrl.isNotBlank() + ) { + appDb.bookSourceDao.insert(bookSource) + okSources.add(bookSource) + } + } + } else { + return ReturnData().setErrorMsg("转换源失败") + } + return ReturnData().setData(okSources) + } + + fun getSource(parameters: Map>): ReturnData { + val url = parameters["url"]?.firstOrNull() + val returnData = ReturnData() + if (url.isNullOrEmpty()) { + return returnData.setErrorMsg("参数url不能为空,请指定源地址") + } + val bookSource = appDb.bookSourceDao.getBookSource(url) + ?: return returnData.setErrorMsg("未找到源,请检查书源地址") + return returnData.setData(bookSource) + } + + fun deleteSources(postData: String?): ReturnData { + kotlin.runCatching { + GSON.fromJsonArray(postData)?.let { + it.forEach { source -> + appDb.bookSourceDao.delete(source) + } + } + } + return ReturnData().setData("已执行"/*okSources*/) + } +} diff --git a/app/src/main/java/io/legado/app/api/controller/RssSourceController.kt b/app/src/main/java/io/legado/app/api/controller/RssSourceController.kt new file mode 100644 index 000000000..95d80abe0 --- /dev/null +++ b/app/src/main/java/io/legado/app/api/controller/RssSourceController.kt @@ -0,0 +1,82 @@ +package io.legado.app.api.controller + + +import android.text.TextUtils +import io.legado.app.api.ReturnData +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.utils.msg + +object RssSourceController { + + val sources: ReturnData + get() { + val source = appDb.rssSourceDao.all + val returnData = ReturnData() + return if (source.isEmpty()) { + returnData.setErrorMsg("源列表为空") + } else returnData.setData(source) + } + + fun saveSource(postData: String?): ReturnData { + val returnData = ReturnData() + postData ?: return returnData.setErrorMsg("数据不能为空") + kotlin.runCatching { + val source = RssSource.fromJson(postData) + if (source != null) { + if (TextUtils.isEmpty(source.sourceName) || TextUtils.isEmpty(source.sourceUrl)) { + returnData.setErrorMsg("源名称和URL不能为空") + } else { + appDb.rssSourceDao.insert(source) + returnData.setData("") + } + } else { + returnData.setErrorMsg("转换源失败") + } + }.onFailure { + returnData.setErrorMsg(it.msg) + } + return returnData + } + + fun saveSources(postData: String?): ReturnData { + postData ?: return ReturnData().setErrorMsg("数据不能为空") + val okSources = arrayListOf() + val source = RssSource.fromJsonArray(postData) + if (source.isNotEmpty()) { + for (rssSource in source) { + if (rssSource.sourceName.isBlank() || rssSource.sourceUrl.isBlank()) { + continue + } + appDb.rssSourceDao.insert(rssSource) + okSources.add(rssSource) + } + } else { + return ReturnData().setErrorMsg("转换源失败") + } + return ReturnData().setData(okSources) + } + + fun getSource(parameters: Map>): ReturnData { + val url = parameters["url"]?.firstOrNull() + val returnData = ReturnData() + if (url.isNullOrEmpty()) { + return returnData.setErrorMsg("参数url不能为空,请指定书源地址") + } + val source = appDb.rssSourceDao.getByKey(url) + ?: return returnData.setErrorMsg("未找到源,请检查源地址") + return returnData.setData(source) + } + + fun deleteSources(postData: String?): ReturnData { + postData ?: return ReturnData().setErrorMsg("没有传递数据") + kotlin.runCatching { + RssSource.fromJsonArray(postData).let { + it.forEach { source -> + appDb.rssSourceDao.delete(source) + } + } + } + return ReturnData().setData("已执行"/*okSources*/) + } +} diff --git a/app/src/main/java/io/legado/app/base/AppContextWrapper.kt b/app/src/main/java/io/legado/app/base/AppContextWrapper.kt new file mode 100644 index 000000000..510c262d6 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/AppContextWrapper.kt @@ -0,0 +1,96 @@ +package io.legado.app.base + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import android.os.Build +import android.os.LocaleList +import io.legado.app.constant.PreferKey +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.getPrefString +import io.legado.app.utils.sysConfiguration +import java.util.* + + +@Suppress("unused") +object AppContextWrapper { + + fun wrap(context: Context): Context { + + val resources: Resources = context.resources + val configuration: Configuration = resources.configuration + val targetLocale = getSetLocale(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + configuration.setLocale(targetLocale) + configuration.setLocales(LocaleList(targetLocale)) + } else { + @Suppress("DEPRECATION") + configuration.locale = targetLocale + } + configuration.fontScale = getFontScale(context) + return context.createConfigurationContext(configuration) + } + + fun getFontScale(context: Context): Float { + var fontScale = context.getPrefInt(PreferKey.fontScale) / 10f + if (fontScale !in 0.8f..1.6f) { + fontScale = sysConfiguration.fontScale + } + return fontScale + } + + /** + * 当前系统语言 + */ + private fun getSystemLocale(): Locale { + val locale: Locale + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //7.0有多语言设置获取顶部的语言 + locale = sysConfiguration.locales.get(0) + } else { + @Suppress("DEPRECATION") + locale = sysConfiguration.locale + } + return locale + } + + /** + * 当前App语言 + */ + private fun getAppLocale(context: Context): Locale { + val locale: Locale + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + locale = context.resources.configuration.locales[0] + } else { + @Suppress("DEPRECATION") + locale = context.resources.configuration.locale + } + return locale + + } + + /** + * 当前设置语言 + */ + private fun getSetLocale(context: Context): Locale { + return when (context.getPrefString(PreferKey.language)) { + "zh" -> Locale.SIMPLIFIED_CHINESE + "tw" -> Locale.TRADITIONAL_CHINESE + "en" -> Locale.ENGLISH + else -> getSystemLocale() + } + } + + /** + * 判断App语言和设置语言是否相同 + */ + fun isSameWithSetting(context: Context): Boolean { + val locale = getAppLocale(context) + val language = locale.language + val country = locale.country + val pfLocale = getSetLocale(context) + val pfLanguage = pfLocale.language + val pfCountry = pfLocale.country + return language == pfLanguage && country == pfCountry + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseActivity.kt b/app/src/main/java/io/legado/app/base/BaseActivity.kt new file mode 100644 index 000000000..0c1c153d8 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseActivity.kt @@ -0,0 +1,190 @@ +package io.legado.app.base + +import android.content.Context +import android.content.res.Configuration +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.os.Bundle +import android.util.AttributeSet +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppLog +import io.legado.app.constant.Theme +import io.legado.app.help.AppConfig +import io.legado.app.help.ThemeConfig +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.TitleBar +import io.legado.app.utils.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel + + +abstract class BaseActivity( + val fullScreen: Boolean = true, + private val theme: Theme = Theme.Auto, + private val toolBarTheme: Theme = Theme.Auto, + private val transparent: Boolean = false, + private val imageBg: Boolean = true +) : AppCompatActivity(), CoroutineScope by MainScope() { + + protected abstract val binding: VB + + val isInMultiWindow: Boolean + get() { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + isInMultiWindowMode + } else { + false + } + } + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(AppContextWrapper.wrap(newBase)) + } + + override fun onCreateView( + parent: View?, + name: String, + context: Context, + attrs: AttributeSet + ): View? { + if (AppConst.menuViewNames.contains(name) && parent?.parent is FrameLayout) { + (parent.parent as View).setBackgroundColor(backgroundColor) + } + return super.onCreateView(parent, name, context, attrs) + } + + override fun onCreate(savedInstanceState: Bundle?) { + window.decorView.disableAutoFill() + initTheme() + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupSystemBar() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + findViewById(R.id.title_bar) + ?.onMultiWindowModeChanged(isInMultiWindowMode, fullScreen) + } + onActivityCreated(savedInstanceState) + observeLiveBus() + } + + override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) { + super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig) + findViewById(R.id.title_bar) + ?.onMultiWindowModeChanged(isInMultiWindowMode, fullScreen) + setupSystemBar() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + findViewById(R.id.title_bar) + ?.onMultiWindowModeChanged(isInMultiWindow, fullScreen) + setupSystemBar() + } + + override fun onDestroy() { + super.onDestroy() + cancel() + } + + abstract fun onActivityCreated(savedInstanceState: Bundle?) + + final override fun onCreateOptionsMenu(menu: Menu): Boolean { + return menu.let { + val bool = onCompatCreateOptionsMenu(it) + it.applyTint(this, toolBarTheme) + bool + } + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.applyOpenTint(this) + return super.onMenuOpened(featureId, menu) + } + + open fun onCompatCreateOptionsMenu(menu: Menu) = super.onCreateOptionsMenu(menu) + + final override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + supportFinishAfterTransition() + return true + } + return onCompatOptionsItemSelected(item) + } + + open fun onCompatOptionsItemSelected(item: MenuItem) = super.onOptionsItemSelected(item) + + private fun initTheme() { + when (theme) { + Theme.Transparent -> setTheme(R.style.AppTheme_Transparent) + Theme.Dark -> { + setTheme(R.style.AppTheme_Dark) + window.decorView.applyBackgroundTint(backgroundColor) + } + Theme.Light -> { + setTheme(R.style.AppTheme_Light) + window.decorView.applyBackgroundTint(backgroundColor) + } + else -> { + if (ColorUtils.isColorLight(primaryColor)) { + setTheme(R.style.AppTheme_Light) + } else { + setTheme(R.style.AppTheme_Dark) + } + window.decorView.applyBackgroundTint(backgroundColor) + } + } + if (imageBg) { + try { + ThemeConfig.getBgImage(this, windowSize)?.let { + window.decorView.background = BitmapDrawable(resources, it) + } + } catch (e: OutOfMemoryError) { + toastOnUi("背景图片太大,内存溢出") + } catch (e: Exception) { + AppLog.put("加载背景出错\n${e.localizedMessage}", e) + } + } + } + + private fun setupSystemBar() { + if (fullScreen && !isInMultiWindow) { + fullScreen() + } + val isTransparentStatusBar = AppConfig.isTransparentStatusBar + val statusBarColor = ThemeStore.statusBarColor(this, isTransparentStatusBar) + setStatusBarColorAuto(statusBarColor, isTransparentStatusBar, fullScreen) + if (toolBarTheme == Theme.Dark) { + setLightStatusBar(false) + } else if (toolBarTheme == Theme.Light) { + setLightStatusBar(true) + } + upNavigationBarColor() + } + + open fun upNavigationBarColor() { + if (AppConfig.immNavigationBar) { + setNavigationBarColorAuto(ThemeStore.navigationBarColor(this)) + } else { + val nbColor = ColorUtils.darkenColor(ThemeStore.navigationBarColor(this)) + setNavigationBarColorAuto(nbColor) + } + } + + open fun observeLiveBus() { + } + + override fun finish() { + currentFocus?.hideSoftInput() + super.finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseDialogFragment.kt b/app/src/main/java/io/legado/app/base/BaseDialogFragment.kt new file mode 100644 index 000000000..ddb35c2cc --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseDialogFragment.kt @@ -0,0 +1,50 @@ +package io.legado.app.base + +import android.os.Bundle +import android.view.View +import androidx.annotation.LayoutRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.lib.theme.ThemeStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlin.coroutines.CoroutineContext + + +abstract class BaseDialogFragment(@LayoutRes layoutID: Int) : DialogFragment(layoutID), + CoroutineScope by MainScope() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.setBackgroundColor(ThemeStore.backgroundColor()) + onFragmentCreated(view, savedInstanceState) + observeLiveBus() + } + + abstract fun onFragmentCreated(view: View, savedInstanceState: Bundle?) + + override fun show(manager: FragmentManager, tag: String?) { + kotlin.runCatching { + //在每个add事务前增加一个remove事务,防止连续的add + manager.beginTransaction().remove(this).commit() + super.show(manager, tag) + } + } + + override fun onDestroy() { + super.onDestroy() + cancel() + } + + fun execute( + scope: CoroutineScope = this, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T + ) = Coroutine.async(scope, context) { block() } + + open fun observeLiveBus() { + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseFragment.kt b/app/src/main/java/io/legado/app/base/BaseFragment.kt new file mode 100644 index 000000000..99797f226 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseFragment.kt @@ -0,0 +1,87 @@ +package io.legado.app.base + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.annotation.LayoutRes +import androidx.appcompat.view.SupportMenuInflater +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import io.legado.app.R +import io.legado.app.ui.widget.TitleBar +import io.legado.app.utils.applyTint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel + +@Suppress("MemberVisibilityCanBePrivate") +abstract class BaseFragment(@LayoutRes layoutID: Int) : Fragment(layoutID), + CoroutineScope by MainScope() { + + var supportToolbar: Toolbar? = null + private set + + val menuInflater: MenuInflater + @SuppressLint("RestrictedApi") + get() = SupportMenuInflater(requireContext()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onMultiWindowModeChanged() + onFragmentCreated(view, savedInstanceState) + observeLiveBus() + } + + abstract fun onFragmentCreated(view: View, savedInstanceState: Bundle?) + + override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean) { + super.onMultiWindowModeChanged(isInMultiWindowMode) + onMultiWindowModeChanged() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + onMultiWindowModeChanged() + } + + private fun onMultiWindowModeChanged() { + (activity as? BaseActivity<*>)?.let { + view?.findViewById(R.id.title_bar) + ?.onMultiWindowModeChanged(it.isInMultiWindow, it.fullScreen) + } + } + + override fun onDestroy() { + super.onDestroy() + cancel() + } + + fun setSupportToolbar(toolbar: Toolbar) { + supportToolbar = toolbar + supportToolbar?.let { + it.menu.apply { + onCompatCreateOptionsMenu(this) + applyTint(requireContext()) + } + + it.setOnMenuItemClickListener { item -> + onCompatOptionsItemSelected(item) + true + } + } + } + + open fun observeLiveBus() { + } + + open fun onCompatCreateOptionsMenu(menu: Menu) { + } + + open fun onCompatOptionsItemSelected(item: MenuItem) { + } + +} diff --git a/app/src/main/java/io/legado/app/base/BasePreferenceFragment.kt b/app/src/main/java/io/legado/app/base/BasePreferenceFragment.kt new file mode 100644 index 000000000..f444985e3 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BasePreferenceFragment.kt @@ -0,0 +1,63 @@ +package io.legado.app.base + +import android.annotation.SuppressLint +import androidx.fragment.app.DialogFragment +import androidx.preference.* +import io.legado.app.ui.widget.prefs.EditTextPreferenceDialog +import io.legado.app.ui.widget.prefs.ListPreferenceDialog +import io.legado.app.ui.widget.prefs.MultiSelectListPreferenceDialog + +abstract class BasePreferenceFragment : PreferenceFragmentCompat() { + + private val dialogFragmentTag = "androidx.preference.PreferenceFragment.DIALOG" + + @SuppressLint("RestrictedApi") + override fun onDisplayPreferenceDialog(preference: Preference) { + + var handled = false + if (callbackFragment is OnPreferenceDisplayDialogCallback) { + handled = + (callbackFragment as OnPreferenceDisplayDialogCallback) + .onPreferenceDisplayDialog(this, preference) + } + if (!handled && activity is OnPreferenceDisplayDialogCallback) { + handled = (activity as OnPreferenceDisplayDialogCallback) + .onPreferenceDisplayDialog(this, preference) + } + + if (handled) { + return + } + + // check if dialog is already showing + if (parentFragmentManager.findFragmentByTag(dialogFragmentTag) != null) { + return + } + + val f: DialogFragment = when (preference) { + is EditTextPreference -> { + EditTextPreferenceDialog.newInstance(preference.getKey()) + } + is ListPreference -> { + ListPreferenceDialog.newInstance(preference.getKey()) + } + is MultiSelectListPreference -> { + MultiSelectListPreferenceDialog.newInstance(preference.getKey()) + } + else -> { + throw IllegalArgumentException( + "Cannot display dialog for an unknown Preference type: " + + preference.javaClass.simpleName + + ". Make sure to implement onPreferenceDisplayDialog() to handle " + + "displaying a custom dialog for this Preference." + ) + } + } + @Suppress("DEPRECATION") + f.setTargetFragment(this, 0) + + f.show(parentFragmentManager, dialogFragmentTag) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseService.kt b/app/src/main/java/io/legado/app/base/BaseService.kt new file mode 100644 index 000000000..54b409f39 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseService.kt @@ -0,0 +1,45 @@ +package io.legado.app.base + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import androidx.annotation.CallSuper +import io.legado.app.help.LifecycleHelp +import io.legado.app.help.coroutine.Coroutine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlin.coroutines.CoroutineContext + +abstract class BaseService : Service(), CoroutineScope by MainScope() { + + fun execute( + scope: CoroutineScope = this, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T + ) = Coroutine.async(scope, context) { block() } + + @CallSuper + override fun onCreate() { + super.onCreate() + LifecycleHelp.onServiceCreate(this) + } + + @CallSuper + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + stopSelf() + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + @CallSuper + override fun onDestroy() { + super.onDestroy() + cancel() + LifecycleHelp.onServiceDestroy(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseViewModel.kt b/app/src/main/java/io/legado/app/base/BaseViewModel.kt new file mode 100644 index 000000000..2422a8a15 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseViewModel.kt @@ -0,0 +1,35 @@ +package io.legado.app.base + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.App +import io.legado.app.help.coroutine.Coroutine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +@Suppress("unused") +open class BaseViewModel(application: Application) : AndroidViewModel(application) { + + val context: Context by lazy { this.getApplication() } + + fun execute( + scope: CoroutineScope = viewModelScope, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T + ): Coroutine { + return Coroutine.async(scope, context) { block() } + } + + fun submit( + scope: CoroutineScope = viewModelScope, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> Deferred + ): Coroutine { + return Coroutine.async(scope, context) { block().await() } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/README.md b/app/src/main/java/io/legado/app/base/README.md new file mode 100644 index 000000000..ba3d001f1 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/README.md @@ -0,0 +1 @@ +# 基类 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/VMBaseActivity.kt b/app/src/main/java/io/legado/app/base/VMBaseActivity.kt new file mode 100644 index 000000000..3eeb5c91b --- /dev/null +++ b/app/src/main/java/io/legado/app/base/VMBaseActivity.kt @@ -0,0 +1,17 @@ +package io.legado.app.base + +import androidx.lifecycle.ViewModel +import androidx.viewbinding.ViewBinding +import io.legado.app.constant.Theme + +abstract class VMBaseActivity( + fullScreen: Boolean = true, + theme: Theme = Theme.Auto, + toolBarTheme: Theme = Theme.Auto, + transparent: Boolean = false, + imageBg: Boolean = true +) : BaseActivity(fullScreen, theme, toolBarTheme, transparent, imageBg) { + + protected abstract val viewModel: VM + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/VMBaseFragment.kt b/app/src/main/java/io/legado/app/base/VMBaseFragment.kt new file mode 100644 index 000000000..cc78d19dd --- /dev/null +++ b/app/src/main/java/io/legado/app/base/VMBaseFragment.kt @@ -0,0 +1,9 @@ +package io.legado.app.base + +import androidx.lifecycle.ViewModel + +abstract class VMBaseFragment(layoutID: Int) : BaseFragment(layoutID) { + + protected abstract val viewModel: VM + +} diff --git a/app/src/main/java/io/legado/app/base/adapter/DiffRecyclerAdapter.kt b/app/src/main/java/io/legado/app/base/adapter/DiffRecyclerAdapter.kt new file mode 100644 index 000000000..2575ea067 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/DiffRecyclerAdapter.kt @@ -0,0 +1,216 @@ +package io.legado.app.base.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import splitties.views.onLongClick + +/** + * Created by Invincible on 2017/12/15. + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +abstract class DiffRecyclerAdapter(protected val context: Context) : + RecyclerView.Adapter() { + + val inflater: LayoutInflater = LayoutInflater.from(context) + + private val asyncListDiffer: AsyncListDiffer by lazy { + AsyncListDiffer(this, diffItemCallback).apply { + addListListener { _, _ -> + onCurrentListChanged() + } + } + } + + private var itemClickListener: ((holder: ItemViewHolder, item: ITEM) -> Unit)? = null + private var itemLongClickListener: ((holder: ItemViewHolder, item: ITEM) -> Boolean)? = null + + var itemAnimation: ItemAnimation? = null + + abstract val diffItemCallback: DiffUtil.ItemCallback + + fun setOnItemClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Unit) { + itemClickListener = listener + } + + fun setOnItemLongClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Boolean) { + itemLongClickListener = listener + } + + fun bindToRecyclerView(recyclerView: RecyclerView) { + recyclerView.adapter = this + } + + @Synchronized + fun setItems(items: List?) { + kotlin.runCatching { + asyncListDiffer.submitList(items) + } + } + + @Synchronized + fun setItem(position: Int, item: ITEM) { + kotlin.runCatching { + val list = ArrayList(asyncListDiffer.currentList) + list[position] = item + asyncListDiffer.submitList(list) + } + } + + @Synchronized + fun updateItem(item: ITEM) { + kotlin.runCatching { + val index = asyncListDiffer.currentList.indexOf(item) + if (index >= 0) { + asyncListDiffer.currentList[index] = item + notifyItemChanged(index) + } + } + } + + @Synchronized + fun updateItem(position: Int, payload: Any) { + kotlin.runCatching { + val size = itemCount + if (position in 0 until size) { + notifyItemChanged(position, payload) + } + } + } + + @Synchronized + fun updateItems(fromPosition: Int, toPosition: Int, payloads: Any) { + kotlin.runCatching { + val size = itemCount + if (fromPosition in 0 until size && toPosition in 0 until size) { + notifyItemRangeChanged( + fromPosition, + toPosition - fromPosition + 1, + payloads + ) + } + } + } + + fun isEmpty() = asyncListDiffer.currentList.isEmpty() + + fun isNotEmpty() = asyncListDiffer.currentList.isNotEmpty() + + fun getItem(position: Int): ITEM? = asyncListDiffer.currentList.getOrNull(position) + + fun getItems(): List = asyncListDiffer.currentList + + /** + * grid 模式下使用 + */ + protected open fun getSpanSize(viewType: Int, position: Int) = 1 + + final override fun getItemCount() = getItems().size + + final override fun getItemViewType(position: Int): Int { + return 0 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { + val holder = ItemViewHolder(getViewBinding(parent)) + + @Suppress("UNCHECKED_CAST") + registerListener(holder, (holder.binding as VB)) + + if (itemClickListener != null) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + itemClickListener?.invoke(holder, it) + } + } + } + + if (itemLongClickListener != null) { + holder.itemView.onLongClick { + getItem(holder.layoutPosition)?.let { + itemLongClickListener?.invoke(holder, it) + } + } + } + + return holder + } + + protected abstract fun getViewBinding(parent: ViewGroup): VB + + final override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {} + + open fun onCurrentListChanged() { + + } + + @Suppress("UNCHECKED_CAST") + final override fun onBindViewHolder( + holder: ItemViewHolder, + position: Int, + payloads: MutableList + ) { + getItem(holder.layoutPosition)?.let { + convert(holder, (holder.binding as VB), it, payloads) + } + } + + override fun onViewAttachedToWindow(holder: ItemViewHolder) { + super.onViewAttachedToWindow(holder) + addAnimation(holder) + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + val manager = recyclerView.layoutManager + if (manager is GridLayoutManager) { + manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return getSpanSize(getItemViewType(position), position) + } + } + } + } + + private fun addAnimation(holder: ItemViewHolder) { + itemAnimation?.let { + if (it.itemAnimEnabled) { + if (!it.itemAnimFirstOnly || holder.layoutPosition > it.itemAnimStartPosition) { + startAnimation(holder, it) + it.itemAnimStartPosition = holder.layoutPosition + } + } + } + } + + protected open fun startAnimation(holder: ItemViewHolder, item: ItemAnimation) { + item.itemAnimation?.let { + for (anim in it.getAnimators(holder.itemView)) { + anim.setDuration(item.itemAnimDuration).start() + anim.interpolator = item.itemAnimInterpolator + } + } + } + + /** + * 如果使用了事件回调,回调里不要直接使用item,会出现不更新的问题, + * 使用getItem(holder.layoutPosition)来获取item + */ + abstract fun convert( + holder: ItemViewHolder, + binding: VB, + item: ITEM, + payloads: MutableList + ) + + /** + * 注册事件 + */ + abstract fun registerListener(holder: ItemViewHolder, binding: VB) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt new file mode 100644 index 000000000..2285c8242 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt @@ -0,0 +1,85 @@ +package io.legado.app.base.adapter + +import android.view.animation.Interpolator +import android.view.animation.LinearInterpolator +import io.legado.app.base.adapter.animations.* + +/** + * Created by Invincible on 2017/12/15. + */ +@Suppress("unused") +class ItemAnimation private constructor() { + + var itemAnimEnabled = false + var itemAnimFirstOnly = true + var itemAnimation: BaseAnimation? = null + var itemAnimInterpolator: Interpolator = LinearInterpolator() + var itemAnimDuration: Long = 300L + var itemAnimStartPosition: Int = -1 + + fun interpolator(interpolator: Interpolator) = apply { + itemAnimInterpolator = interpolator + } + + fun duration(duration: Long) = apply { + itemAnimDuration = duration + } + + fun startPosition(startPos: Int) = apply { + itemAnimStartPosition = startPos + } + + fun animation(animationType: Int = NONE, animation: BaseAnimation? = null) = apply { + if (animation != null) { + itemAnimation = animation + } else { + when (animationType) { + FADE_IN -> itemAnimation = AlphaInAnimation() + SCALE_IN -> itemAnimation = ScaleInAnimation() + BOTTOM_SLIDE_IN -> itemAnimation = SlideInBottomAnimation() + LEFT_SLIDE_IN -> itemAnimation = SlideInLeftAnimation() + RIGHT_SLIDE_IN -> itemAnimation = SlideInRightAnimation() + } + } + } + + fun enabled(enabled: Boolean) = apply { + itemAnimEnabled = enabled + } + + fun firstOnly(firstOnly: Boolean) = apply { + itemAnimFirstOnly = firstOnly + } + + companion object { + const val NONE: Int = 0x00000000 + + /** + * Use with [.openLoadAnimation] + */ + const val FADE_IN: Int = 0x00000001 + + /** + * Use with [.openLoadAnimation] + */ + const val SCALE_IN: Int = 0x00000002 + + /** + * Use with [.openLoadAnimation] + */ + const val BOTTOM_SLIDE_IN: Int = 0x00000003 + + /** + * Use with [.openLoadAnimation] + */ + const val LEFT_SLIDE_IN: Int = 0x00000004 + + /** + * Use with [.openLoadAnimation] + */ + const val RIGHT_SLIDE_IN: Int = 0x00000005 + + fun create() = ItemAnimation() + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/adapter/ItemViewHolder.kt b/app/src/main/java/io/legado/app/base/adapter/ItemViewHolder.kt new file mode 100644 index 000000000..da57d4627 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/ItemViewHolder.kt @@ -0,0 +1,10 @@ +package io.legado.app.base.adapter + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +/** + * Created by Invincible on 2017/11/28. + */ +@Suppress("MemberVisibilityCanBePrivate") +class ItemViewHolder(val binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/adapter/RecyclerAdapter.kt b/app/src/main/java/io/legado/app/base/adapter/RecyclerAdapter.kt new file mode 100644 index 000000000..fa1472356 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/RecyclerAdapter.kt @@ -0,0 +1,458 @@ +package io.legado.app.base.adapter + +import android.annotation.SuppressLint +import android.content.Context +import android.util.SparseArray +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import splitties.views.onLongClick +import java.util.* + +/** + * Created by Invincible on 2017/11/24. + * + * 通用的adapter 可添加header,footer,以及不同类型item + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +abstract class RecyclerAdapter(protected val context: Context) : + RecyclerView.Adapter() { + + val inflater: LayoutInflater = LayoutInflater.from(context) + + private val headerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() } + private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() } + + private val items: MutableList = mutableListOf() + + private var itemClickListener: ((holder: ItemViewHolder, item: ITEM) -> Unit)? = null + private var itemLongClickListener: ((holder: ItemViewHolder, item: ITEM) -> Boolean)? = null + + var itemAnimation: ItemAnimation? = null + + fun setOnItemClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Unit) { + itemClickListener = listener + } + + fun setOnItemLongClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Boolean) { + itemLongClickListener = listener + } + + fun bindToRecyclerView(recyclerView: RecyclerView) { + recyclerView.adapter = this + } + + @Synchronized + fun addHeaderView(header: ((parent: ViewGroup) -> ViewBinding)) { + kotlin.runCatching { + val index = headerItems.size() + headerItems.put(TYPE_HEADER_VIEW + headerItems.size(), header) + notifyItemInserted(index) + } + } + + @Synchronized + fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) { + kotlin.runCatching { + val index = getActualItemCount() + footerItems.size() + footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer) + notifyItemInserted(index) + } + } + + @Synchronized + fun removeHeaderView(header: ((parent: ViewGroup) -> ViewBinding)) { + kotlin.runCatching { + val index = headerItems.indexOfValue(header) + if (index >= 0) { + headerItems.remove(index) + notifyItemRemoved(index) + } + } + } + + @Synchronized + fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) { + kotlin.runCatching { + val index = footerItems.indexOfValue(footer) + if (index >= 0) { + footerItems.remove(index) + notifyItemRemoved(getActualItemCount() + index - 2) + } + } + } + + @SuppressLint("NotifyDataSetChanged") + @Synchronized + fun setItems(items: List?) { + kotlin.runCatching { + if (this.items.isNotEmpty()) { + this.items.clear() + } + if (items != null) { + this.items.addAll(items) + } + notifyDataSetChanged() + onCurrentListChanged() + } + } + + @Synchronized + fun setItems(items: List?, itemCallback: DiffUtil.ItemCallback) { + kotlin.runCatching { + val callback = object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return itemCount + } + + override fun getNewListSize(): Int { + return (items?.size ?: 0) + getHeaderCount() + getFooterCount() + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = getItem(oldItemPosition - getHeaderCount()) + ?: return true + val newItem = items?.getOrNull(newItemPosition - getHeaderCount()) + ?: return true + return itemCallback.areItemsTheSame(oldItem, newItem) + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem = getItem(oldItemPosition - getHeaderCount()) + ?: return true + val newItem = items?.getOrNull(newItemPosition - getHeaderCount()) + ?: return true + return itemCallback.areContentsTheSame(oldItem, newItem) + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = getItem(oldItemPosition - getHeaderCount()) + ?: return null + val newItem = items?.getOrNull(newItemPosition - getHeaderCount()) + ?: return null + return itemCallback.getChangePayload(oldItem, newItem) + } + } + val diffResult = DiffUtil.calculateDiff(callback) + if (this.items.isNotEmpty()) { + this.items.clear() + } + if (items != null) { + this.items.addAll(items) + } + diffResult.dispatchUpdatesTo(this) + onCurrentListChanged() + } + } + + @Synchronized + fun setItem(position: Int, item: ITEM) { + kotlin.runCatching { + val oldSize = getActualItemCount() + if (position in 0 until oldSize) { + this.items[position] = item + notifyItemChanged(position + getHeaderCount()) + } + onCurrentListChanged() + } + } + + @Synchronized + fun addItem(item: ITEM) { + kotlin.runCatching { + val oldSize = getActualItemCount() + if (this.items.add(item)) { + notifyItemInserted(oldSize + getHeaderCount()) + } + onCurrentListChanged() + } + } + + @Synchronized + fun addItems(position: Int, newItems: List) { + kotlin.runCatching { + if (this.items.addAll(position, newItems)) { + notifyItemRangeInserted(position + getHeaderCount(), newItems.size) + } + onCurrentListChanged() + } + } + + @SuppressLint("NotifyDataSetChanged") + @Synchronized + fun addItems(newItems: List) { + kotlin.runCatching { + val oldSize = getActualItemCount() + if (this.items.addAll(newItems)) { + if (oldSize == 0 && getHeaderCount() == 0) { + notifyDataSetChanged() + } else { + notifyItemRangeInserted(oldSize + getHeaderCount(), newItems.size) + } + } + onCurrentListChanged() + } + } + + @Synchronized + fun removeItem(position: Int) { + kotlin.runCatching { + if (this.items.removeAt(position) != null) { + notifyItemRemoved(position + getHeaderCount()) + } + onCurrentListChanged() + } + } + + @Synchronized + fun removeItem(item: ITEM) { + kotlin.runCatching { + if (this.items.remove(item)) { + notifyItemRemoved(this.items.indexOf(item) + getHeaderCount()) + } + onCurrentListChanged() + } + } + + @SuppressLint("NotifyDataSetChanged") + @Synchronized + fun removeItems(items: List) { + kotlin.runCatching { + if (this.items.removeAll(items)) { + notifyDataSetChanged() + } + onCurrentListChanged() + } + } + + @Synchronized + fun swapItem(oldPosition: Int, newPosition: Int) { + kotlin.runCatching { + val size = getActualItemCount() + if (oldPosition in 0 until size && newPosition in 0 until size) { + val srcPosition = oldPosition + getHeaderCount() + val targetPosition = newPosition + getHeaderCount() + Collections.swap(this.items, srcPosition, targetPosition) + notifyItemMoved(srcPosition, targetPosition) + } + onCurrentListChanged() + } + } + + @Synchronized + fun updateItem(item: ITEM) { + kotlin.runCatching { + val index = this.items.indexOf(item) + if (index >= 0) { + this.items[index] = item + notifyItemChanged(index) + } + onCurrentListChanged() + } + } + + @Synchronized + fun updateItem(position: Int, payload: Any) { + kotlin.runCatching { + val size = getActualItemCount() + if (position in 0 until size) { + notifyItemChanged(position + getHeaderCount(), payload) + } + } + } + + @Synchronized + fun updateItems(fromPosition: Int, toPosition: Int, payloads: Any) { + kotlin.runCatching { + val size = getActualItemCount() + if (fromPosition in 0 until size && toPosition in 0 until size) { + notifyItemRangeChanged( + fromPosition + getHeaderCount(), + toPosition - fromPosition + 1, + payloads + ) + } + } + } + + @SuppressLint("NotifyDataSetChanged") + @Synchronized + fun clearItems() { + kotlin.runCatching { + this.items.clear() + notifyDataSetChanged() + onCurrentListChanged() + } + } + + fun isEmpty() = items.isEmpty() + + fun isNotEmpty() = items.isNotEmpty() + + /** + * 除去header和footer + */ + fun getActualItemCount() = items.size + + + fun getHeaderCount() = headerItems.size() + + + fun getFooterCount() = footerItems.size() + + fun getItem(position: Int): ITEM? = items.getOrNull(position) + + fun getItemByLayoutPosition(position: Int) = items.getOrNull(position - getHeaderCount()) + + fun getItems(): List = items + + protected open fun getItemViewType(item: ITEM, position: Int) = 0 + + /** + * grid 模式下使用 + */ + protected open fun getSpanSize(viewType: Int, position: Int) = 1 + + final override fun getItemCount() = getActualItemCount() + getHeaderCount() + getFooterCount() + + final override fun getItemViewType(position: Int) = when { + isHeader(position) -> TYPE_HEADER_VIEW + position + isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount() - getHeaderCount() + else -> getItem(getActualPosition(position))?.let { + getItemViewType(it, getActualPosition(position)) + } ?: 0 + } + + open fun onCurrentListChanged() { + + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when { + viewType < TYPE_HEADER_VIEW + getHeaderCount() -> { + ItemViewHolder(headerItems.get(viewType).invoke(parent)) + } + + viewType >= TYPE_FOOTER_VIEW -> { + ItemViewHolder(footerItems.get(viewType).invoke(parent)) + } + + else -> { + val holder = ItemViewHolder(getViewBinding(parent)) + + @Suppress("UNCHECKED_CAST") + registerListener(holder, (holder.binding as VB)) + + if (itemClickListener != null) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + itemClickListener?.invoke(holder, it) + } + } + } + + if (itemLongClickListener != null) { + holder.itemView.onLongClick { + getItem(holder.layoutPosition)?.let { + itemLongClickListener?.invoke(holder, it) + } + } + } + + holder + } + } + + protected abstract fun getViewBinding(parent: ViewGroup): VB + + final override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {} + + @Suppress("UNCHECKED_CAST") + final override fun onBindViewHolder( + holder: ItemViewHolder, + position: Int, + payloads: MutableList + ) { + if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) { + getItem(holder.layoutPosition - getHeaderCount())?.let { + convert(holder, (holder.binding as VB), it, payloads) + } + } + } + + override fun onViewAttachedToWindow(holder: ItemViewHolder) { + super.onViewAttachedToWindow(holder) + if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) { + addAnimation(holder) + } + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + val manager = recyclerView.layoutManager + if (manager is GridLayoutManager) { + manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return getSpanSize(getItemViewType(position), position) + } + } + } + } + + private fun isHeader(position: Int) = position < getHeaderCount() + + private fun isFooter(position: Int) = position >= getActualItemCount() + getHeaderCount() + + private fun getActualPosition(position: Int) = position - getHeaderCount() + + private fun addAnimation(holder: ItemViewHolder) { + itemAnimation?.let { + if (it.itemAnimEnabled) { + if (!it.itemAnimFirstOnly || holder.layoutPosition > it.itemAnimStartPosition) { + startAnimation(holder, it) + it.itemAnimStartPosition = holder.layoutPosition + } + } + } + } + + protected open fun startAnimation(holder: ItemViewHolder, item: ItemAnimation) { + item.itemAnimation?.let { + for (anim in it.getAnimators(holder.itemView)) { + anim.setDuration(item.itemAnimDuration).start() + anim.interpolator = item.itemAnimInterpolator + } + } + } + + /** + * 如果使用了事件回调,回调里不要直接使用item,会出现不更新的问题, + * 使用getItem(holder.layoutPosition)来获取item + */ + abstract fun convert( + holder: ItemViewHolder, + binding: VB, + item: ITEM, + payloads: MutableList + ) + + /** + * 注册事件 + */ + abstract fun registerListener(holder: ItemViewHolder, binding: VB) + + companion object { + private const val TYPE_HEADER_VIEW = Int.MIN_VALUE + private const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999 + } + +} + + + + diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt new file mode 100644 index 000000000..f5932a100 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt @@ -0,0 +1,18 @@ +package io.legado.app.base.adapter.animations + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.view.View + + +class AlphaInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_ALPHA_FROM) : + BaseAnimation { + + override fun getAnimators(view: View): Array = + arrayOf(ObjectAnimator.ofFloat(view, "alpha", mFrom, 1f)) + + companion object { + + private const val DEFAULT_ALPHA_FROM = 0f + } +} diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/BaseAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/BaseAnimation.kt new file mode 100644 index 000000000..735ceca57 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/animations/BaseAnimation.kt @@ -0,0 +1,13 @@ +package io.legado.app.base.adapter.animations + +import android.animation.Animator +import android.view.View + +/** + * adapter item 动画 + */ +interface BaseAnimation { + + fun getAnimators(view: View): Array + +} diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/ScaleInAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/ScaleInAnimation.kt new file mode 100644 index 000000000..9566e2ad0 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/animations/ScaleInAnimation.kt @@ -0,0 +1,21 @@ +package io.legado.app.base.adapter.animations + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.view.View + + +class ScaleInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_SCALE_FROM) : + BaseAnimation { + + override fun getAnimators(view: View): Array { + val scaleX = ObjectAnimator.ofFloat(view, "scaleX", mFrom, 1f) + val scaleY = ObjectAnimator.ofFloat(view, "scaleY", mFrom, 1f) + return arrayOf(scaleX, scaleY) + } + + companion object { + + private const val DEFAULT_SCALE_FROM = .5f + } +} diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt new file mode 100644 index 000000000..941562201 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt @@ -0,0 +1,12 @@ +package io.legado.app.base.adapter.animations + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.view.View + +class SlideInBottomAnimation : BaseAnimation { + + + override fun getAnimators(view: View): Array = + arrayOf(ObjectAnimator.ofFloat(view, "translationY", view.measuredHeight.toFloat(), 0f)) +} diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt new file mode 100644 index 000000000..8cfae170b --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt @@ -0,0 +1,13 @@ +package io.legado.app.base.adapter.animations + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.view.View + + +class SlideInLeftAnimation : BaseAnimation { + + + override fun getAnimators(view: View): Array = + arrayOf(ObjectAnimator.ofFloat(view, "translationX", -view.rootView.width.toFloat(), 0f)) +} diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt new file mode 100644 index 000000000..e7f1c85e5 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt @@ -0,0 +1,13 @@ +package io.legado.app.base.adapter.animations + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.view.View + + +class SlideInRightAnimation : BaseAnimation { + + + override fun getAnimators(view: View): Array = + arrayOf(ObjectAnimator.ofFloat(view, "translationX", view.rootView.width.toFloat(), 0f)) +} diff --git a/app/src/main/java/io/legado/app/constant/AppConst.kt b/app/src/main/java/io/legado/app/constant/AppConst.kt new file mode 100644 index 000000000..0f6317d46 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/AppConst.kt @@ -0,0 +1,114 @@ +package io.legado.app.constant + +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.provider.Settings +import io.legado.app.BuildConfig +import io.legado.app.R +import splitties.init.appCtx +import java.text.SimpleDateFormat +import javax.script.ScriptEngine +import javax.script.ScriptEngineManager + +@SuppressLint("SimpleDateFormat") +object AppConst { + + const val APP_TAG = "Legado" + + const val channelIdDownload = "channel_download" + const val channelIdReadAloud = "channel_read_aloud" + const val channelIdWeb = "channel_web" + + const val UA_NAME = "User-Agent" + + const val MAX_THREAD = 9 + + val SCRIPT_ENGINE: ScriptEngine by lazy { + ScriptEngineManager().getEngineByName("rhino") + } + + val timeFormat: SimpleDateFormat by lazy { + SimpleDateFormat("HH:mm") + } + + val dateFormat: SimpleDateFormat by lazy { + SimpleDateFormat("yyyy/MM/dd HH:mm") + } + + val fileNameFormat: SimpleDateFormat by lazy { + SimpleDateFormat("yy-MM-dd-HH-mm-ss") + } + + val keyboardToolChars: List by lazy { + arrayListOf( + "❓", "@css:", "", "{{}}", "##", "&&", "%%", "||", "//", "\\", "$.", + "@", ":", "class", "text", "href", "textNodes", "ownText", "all", "html", + "[", "]", "<", ">", "#", "!", ".", "+", "-", "*", "=", "{'webView': true}" + ) + } + + const val bookGroupAllId = -1L + const val bookGroupLocalId = -2L + const val bookGroupAudioId = -3L + const val bookGroupNoneId = -4L + + const val notificationIdRead = -1122391 + const val notificationIdAudio = -1122392 + const val notificationIdCache = -1122393 + const val notificationIdWeb = -1122394 + const val notificationIdDownload = -1122395 + const val notificationIdCheckSource = -1122395 + + val urlOption: String by lazy { + """ + ,{ + 'charset': '', + 'method': 'POST', + 'body': '', + 'headers': { + 'User-Agent': '' + } + } + """.trimIndent() + } + + val menuViewNames = arrayOf( + "com.android.internal.view.menu.ListMenuItemView", + "androidx.appcompat.view.menu.ListMenuItemView" + ) + + val sysElevation = appCtx.resources.getDimension(R.dimen.design_appbar_elevation).toInt() + + val androidId: String by lazy { + Settings.System.getString(appCtx.contentResolver, Settings.Secure.ANDROID_ID) + } + + val appInfo: AppInfo by lazy { + val appInfo = AppInfo() + appCtx.packageManager.getPackageInfo(appCtx.packageName, PackageManager.GET_ACTIVITIES) + ?.let { + appInfo.versionName = it.versionName + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + appInfo.versionCode = it.longVersionCode + } else { + @Suppress("DEPRECATION") + appInfo.versionCode = it.versionCode.toLong() + } + } + appInfo + } + + val charsets = + arrayListOf("UTF-8", "GB2312", "GB18030", "GBK", "Unicode", "UTF-16", "UTF-16LE", "ASCII") + + data class AppInfo( + var versionCode: Long = 0L, + var versionName: String = "" + ) + + /** + * The authority of a FileProvider defined in a element in your app's manifest. + */ + const val authority = BuildConfig.APPLICATION_ID + ".fileProvider" + +} diff --git a/app/src/main/java/io/legado/app/constant/AppLog.kt b/app/src/main/java/io/legado/app/constant/AppLog.kt new file mode 100644 index 000000000..396f0108f --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/AppLog.kt @@ -0,0 +1,23 @@ +package io.legado.app.constant + +object AppLog { + + private val mLogs = arrayListOf>() + + val logs get() = mLogs.toList() + + @Synchronized + fun put(message: String?, throwable: Throwable? = null) { + message ?: return + if (mLogs.size > 100) { + mLogs.removeLastOrNull() + } + mLogs.add(0, Triple(System.currentTimeMillis(), message, throwable)) + } + + @Synchronized + fun clear() { + mLogs.clear() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/AppPattern.kt b/app/src/main/java/io/legado/app/constant/AppPattern.kt new file mode 100644 index 000000000..2c9d60130 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/AppPattern.kt @@ -0,0 +1,33 @@ +package io.legado.app.constant + +import java.util.regex.Pattern + +@Suppress("RegExpRedundantEscape") +object AppPattern { + val JS_PATTERN: Pattern = + Pattern.compile("([\\w\\W]*?)|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE) + val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}") + + //匹配格式化后的图片格式 + val imgPattern: Pattern = Pattern.compile("]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>") + + val nameRegex = Regex("\\s+作\\s*者.*|\\s+\\S+\\s+著") + val authorRegex = Regex("^\\s*作\\s*者[::\\s]+|\\s+著") + val fileNameRegex = Regex("[\\\\/:*?\"<>|.]") + val splitGroupRegex = Regex("[,;,;]") + + /** + * 所有标点 + */ + val bdRegex = Regex("(\\p{P})+") + + /** + * 换行 + */ + val rnRegex = Regex("[\\r\\n]") + + /** + * 不发音段落判断 + */ + val notReadAloudRegex = Regex("^(\\s|\\p{C}|\\p{P}|\\p{Z}|\\p{S})+$") +} diff --git a/app/src/main/java/io/legado/app/constant/BookType.kt b/app/src/main/java/io/legado/app/constant/BookType.kt new file mode 100644 index 000000000..0e48ffc88 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/BookType.kt @@ -0,0 +1,8 @@ +package io.legado.app.constant + +object BookType { + const val default = 0 // 0 文本 + const val audio = 1 // 1 音频 + const val image = 3 //图片 + const val local = "loc_book" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/EventBus.kt b/app/src/main/java/io/legado/app/constant/EventBus.kt new file mode 100644 index 000000000..37bbf4be9 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/EventBus.kt @@ -0,0 +1,31 @@ +package io.legado.app.constant + +object EventBus { + const val MEDIA_BUTTON = "mediaButton" + const val RECREATE = "RECREATE" + const val UP_BOOKSHELF = "upBookToc" + const val BOOKSHELF_REFRESH = "bookshelfRefresh" + const val ALOUD_STATE = "aloud_state" + const val TTS_PROGRESS = "ttsStart" + const val TTS_DS = "ttsDs" + const val BATTERY_CHANGED = "batteryChanged" + const val TIME_CHANGED = "timeChanged" + const val UP_CONFIG = "upConfig" + const val OPEN_CHAPTER = "openChapter" + const val AUDIO_SUB_TITLE = "audioSubTitle" + const val AUDIO_STATE = "audioState" + const val AUDIO_PROGRESS = "audioProgress" + const val AUDIO_SIZE = "audioSize" + const val AUDIO_SPEED = "audioSpeed" + const val AUDIO_ERROR = "audioError" + const val NOTIFY_MAIN = "notifyMain" + const val WEB_SERVICE = "webService" + const val UP_DOWNLOAD = "upDownload" + const val SAVE_CONTENT = "saveContent" + const val CHECK_SOURCE = "checkSource" + const val CHECK_SOURCE_MESSAGE = "checkSourceMessage" + const val CHECK_SOURCE_DONE = "checkSourceDone" + const val TIP_COLOR = "tipColor" + const val SOURCE_CHANGED = "sourceChanged" + const val SEARCH_RESULT = "searchResult" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/IntentAction.kt b/app/src/main/java/io/legado/app/constant/IntentAction.kt new file mode 100644 index 000000000..f55e486ac --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/IntentAction.kt @@ -0,0 +1,21 @@ +package io.legado.app.constant + +object IntentAction { + const val start = "start" + const val play = "play" + const val stop = "stop" + const val resume = "resume" + const val pause = "pause" + const val addTimer = "addTimer" + const val setTimer = "setTimer" + const val prevParagraph = "prevParagraph" + const val nextParagraph = "nextParagraph" + const val upTtsSpeechRate = "upTtsSpeechRate" + const val adjustProgress = "adjustProgress" + const val adjustSpeed = "adjustSpeed" + const val prev = "prev" + const val next = "next" + const val moveTo = "moveTo" + const val init = "init" + const val remove = "remove" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt new file mode 100644 index 000000000..640aa1bc5 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -0,0 +1,108 @@ +package io.legado.app.constant + +object PreferKey { + const val language = "language" + const val fontScale = "fontScale" + const val themeMode = "themeMode" + const val userAgent = "userAgent" + const val showUnread = "showUnread" + const val bookGroupStyle = "bookGroupStyle" + const val useDefaultCover = "useDefaultCover" + const val coverShowName = "coverShowName" + const val coverShowAuthor = "coverShowAuthor" + const val coverShowNameN = "coverShowNameN" + const val coverShowAuthorN = "coverShowAuthorN" + const val hideStatusBar = "hideStatusBar" + const val clickActionTL = "clickActionTopLeft" + const val clickActionTC = "clickActionTopCenter" + const val clickActionTR = "clickActionTopRight" + const val clickActionML = "clickActionMiddleLeft" + const val clickActionMC = "clickActionMiddleCenter" + const val clickActionMR = "clickActionMiddleRight" + const val clickActionBL = "clickActionBottomLeft" + const val clickActionBC = "clickActionBottomCenter" + const val clickActionBR = "clickActionBottomRight" + const val hideNavigationBar = "hideNavigationBar" + const val precisionSearch = "precisionSearch" + const val readAloudByPage = "readAloudByPage" + const val ttsEngine = "appTtsEngine" + const val ttsSpeechRate = "ttsSpeechRate" + const val prevKeys = "prevKeyCodes" + const val nextKeys = "nextKeyCodes" + const val showDiscovery = "showDiscovery" + const val showRss = "showRss" + const val bookshelfLayout = "bookshelfLayout" + const val bookshelfSort = "bookshelfSort" + const val bookExportFileName = "bookExportFileName" + const val bookImportFileName = "bookImportFileName" + const val recordLog = "recordLog" + const val processText = "process_text" + const val cleanCache = "cleanCache" + const val saveTabPosition = "saveTabPosition" + const val fontFolder = "fontFolder" + const val backupPath = "backupUri" + const val restoreIgnore = "restoreIgnore" + const val threadCount = "threadCount" + const val webPort = "webPort" + const val keepLight = "keep_light" + const val webService = "webService" + const val webDavUrl = "web_dav_url" + const val webDavAccount = "web_dav_account" + const val webDavPassword = "web_dav_password" + const val webDavCreateDir = "webDavCreateDir" + const val exportToWebDav = "webDavCacheBackup" + const val exportNoChapterName = "exportNoChapterName" + const val exportType = "exportType" + const val changeSourceCheckAuthor = "changeSourceCheckAuthor" + const val changeSourceLoadToc = "changeSourceLoadToc" + const val changeSourceLoadInfo = "changeSourceLoadInfo" + const val chineseConverterType = "chineseConverterType" + const val launcherIcon = "launcherIcon" + const val textSelectAble = "selectText" + const val lastBackup = "lastBackup" + const val shareLayout = "shareLayout" + const val readStyleSelect = "readStyleSelect" + const val systemTypefaces = "system_typefaces" + const val readBodyToLh = "readBodyToLh" + const val textFullJustify = "textFullJustify" + const val textBottomJustify = "textBottomJustify" + const val autoReadSpeed = "autoReadSpeed" + const val barElevation = "barElevation" + const val transparentStatusBar = "transparentStatusBar" + const val immNavigationBar = "immNavigationBar" + const val defaultCover = "defaultCover" + const val defaultCoverDark = "defaultCoverDark" + const val replaceEnableDefault = "replaceEnableDefault" + const val showBrightnessView = "showBrightnessView" + const val autoClearExpired = "autoClearExpired" + const val autoChangeSource = "autoChangeSource" + const val importKeepName = "importKeepName" + const val screenOrientation = "screenOrientation" + const val syncBookProgress = "syncBookProgress" + const val preDownloadNum = "preDownloadNum" + const val autoRefresh = "auto_refresh" + const val defaultToRead = "defaultToRead" + const val exportCharset = "exportCharset" + const val exportUseReplace = "exportUseReplace" + const val useZhLayout = "useZhLayout" + const val brightness = "brightness" + const val nightBrightness = "nightBrightness" + const val expandTextMenu = "expandTextMenu" + const val doublePageHorizontal = "doublePageHorizontal" + const val readUrlOpenInBrowser = "readUrlInBrowser" + const val defaultBookTreeUri = "defaultBookTreeUri" + + const val cPrimary = "colorPrimary" + const val cAccent = "colorAccent" + const val cBackground = "colorBackground" + const val cBBackground = "colorBottomBackground" + const val bgImage = "backgroundImage" + const val bgImageBlurring = "backgroundImageBlurring" + + const val cNPrimary = "colorPrimaryNight" + const val cNAccent = "colorAccentNight" + const val cNBackground = "colorBackgroundNight" + const val cNBBackground = "colorBottomBackgroundNight" + const val bgImageN = "backgroundImageNight" + const val bgImageNBlurring = "backgroundImageNightBlurring" +} diff --git a/app/src/main/java/io/legado/app/constant/Status.kt b/app/src/main/java/io/legado/app/constant/Status.kt new file mode 100644 index 000000000..678c62221 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/Status.kt @@ -0,0 +1,7 @@ +package io.legado.app.constant + +object Status { + const val STOP = 0 + const val PLAY = 1 + const val PAUSE = 3 +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/Theme.kt b/app/src/main/java/io/legado/app/constant/Theme.kt new file mode 100644 index 000000000..f83474b69 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/Theme.kt @@ -0,0 +1,22 @@ +package io.legado.app.constant + +import io.legado.app.help.AppConfig +import io.legado.app.utils.ColorUtils + +enum class Theme { + Dark, Light, Auto, Transparent, EInk; + + companion object { + fun getTheme() = when { + AppConfig.isEInkMode -> EInk + AppConfig.isNightTheme -> Dark + else -> Light + } + + @Suppress("unused") + fun fromBackground(backgroundColor: Int) = + if (ColorUtils.isColorLight(backgroundColor)) Light + else Dark + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt new file mode 100644 index 000000000..5d645e824 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -0,0 +1,95 @@ +package io.legado.app.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import io.legado.app.constant.AppConst +import io.legado.app.data.dao.* +import io.legado.app.data.entities.* +import splitties.init.appCtx +import java.util.* + +val appDb by lazy { + AppDatabase.createDatabase(appCtx) +} + +@Database( + version = 42, + exportSchema = true, + entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, + ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, + RssSource::class, Bookmark::class, RssArticle::class, RssReadRecord::class, + RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class, + RuleSub::class] +) +abstract class AppDatabase : RoomDatabase() { + + abstract val bookDao: BookDao + abstract val bookGroupDao: BookGroupDao + abstract val bookSourceDao: BookSourceDao + abstract val bookChapterDao: BookChapterDao + abstract val replaceRuleDao: ReplaceRuleDao + abstract val searchBookDao: SearchBookDao + abstract val searchKeywordDao: SearchKeywordDao + abstract val rssSourceDao: RssSourceDao + abstract val bookmarkDao: BookmarkDao + abstract val rssArticleDao: RssArticleDao + abstract val rssStarDao: RssStarDao + abstract val cookieDao: CookieDao + abstract val txtTocRuleDao: TxtTocRuleDao + abstract val readRecordDao: ReadRecordDao + abstract val httpTTSDao: HttpTTSDao + abstract val cacheDao: CacheDao + abstract val ruleSubDao: RuleSubDao + + companion object { + + private const val DATABASE_NAME = "legado.db" + + fun createDatabase(context: Context) = + Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) + .fallbackToDestructiveMigrationFrom(1, 2, 3, 4, 5, 6, 7, 8, 9) + .addMigrations(*DatabaseMigrations.migrations) + .allowMainThreadQueries() + .addCallback(dbCallback) + .build() + + private val dbCallback = object : Callback() { + + override fun onCreate(db: SupportSQLiteDatabase) { + db.setLocale(Locale.CHINESE) + } + + override fun onOpen(db: SupportSQLiteDatabase) { + db.execSQL( + """insert into book_groups(groupId, groupName, 'order', show) + select ${AppConst.bookGroupAllId}, '全部', -10, 1 + where not exists (select * from book_groups where groupId = ${AppConst.bookGroupAllId})""" + ) + db.execSQL( + """insert into book_groups(groupId, groupName, 'order', show) + select ${AppConst.bookGroupLocalId}, '本地', -9, 1 + where not exists (select * from book_groups where groupId = ${AppConst.bookGroupLocalId})""" + ) + db.execSQL( + """insert into book_groups(groupId, groupName, 'order', show) + select ${AppConst.bookGroupAudioId}, '音频', -8, 1 + where not exists (select * from book_groups where groupId = ${AppConst.bookGroupAudioId})""" + ) + db.execSQL( + """insert into book_groups(groupId, groupName, 'order', show) + select ${AppConst.bookGroupNoneId}, '未分组', -7, 1 + where not exists (select * from book_groups where groupId = ${AppConst.bookGroupNoneId})""" + ) + db.execSQL("update book_sources set loginUi = null where loginUi = 'null'") + db.execSQL("update rssSources set loginUi = null where loginUi = 'null'") + db.execSQL("update httpTTS set loginUi = null where loginUi = 'null'") + db.execSQL("update httpTTS set concurrentRate = '0' where loginUi is null") + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt new file mode 100644 index 000000000..095cb7a47 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt @@ -0,0 +1,339 @@ +package io.legado.app.data + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.legado.app.constant.AppConst + +object DatabaseMigrations { + + val migrations: Array by lazy { + arrayOf( + migration_10_11, + migration_11_12, + migration_12_13, + migration_13_14, + migration_14_15, + migration_15_17, + migration_17_18, + migration_18_19, + migration_19_20, + migration_20_21, + migration_21_22, + migration_22_23, + migration_23_24, + migration_24_25, + migration_25_26, + migration_26_27, + migration_27_28, + migration_28_29, + migration_29_30, + migration_30_31, + migration_31_32, + migration_32_33, + migration_33_34, + migration_34_35, + migration_35_36, + migration_36_37, + migration_37_38, + migration_38_39, + migration_39_40, + migration_40_41, + migration_41_42 + ) + } + + private val migration_10_11 = object : Migration(10, 11) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE txtTocRules") + database.execSQL( + """CREATE TABLE txtTocRules(id INTEGER NOT NULL, + name TEXT NOT NULL, rule TEXT NOT NULL, serialNumber INTEGER NOT NULL, + enable INTEGER NOT NULL, PRIMARY KEY (id))""" + ) + } + } + + private val migration_11_12 = object : Migration(11, 12) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE rssSources ADD style TEXT ") + } + } + + private val migration_12_13 = object : Migration(12, 13) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE rssSources ADD articleStyle INTEGER NOT NULL DEFAULT 0 ") + } + } + + private val migration_13_14 = object : Migration(13, 14) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `books_new` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, + `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, + `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, + `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL, + `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL, + `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL, + `originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))""" + ) + database.execSQL("INSERT INTO books_new select * from books ") + database.execSQL("DROP TABLE books") + database.execSQL("ALTER TABLE books_new RENAME TO books") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `books` (`name`, `author`) ") + } + } + + private val migration_14_15 = object : Migration(14, 15) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE bookmarks ADD bookAuthor TEXT NOT NULL DEFAULT ''") + } + } + + private val migration_15_17 = object : Migration(15, 17) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `readRecord` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))") + } + } + + private val migration_17_18 = object : Migration(17, 18) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `httpTTS` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))") + } + } + + private val migration_18_19 = object : Migration(18, 19) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `readRecordNew` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, + PRIMARY KEY(`androidId`, `bookName`))""" + ) + database.execSQL("INSERT INTO readRecordNew(androidId, bookName, readTime) select '${AppConst.androidId}' as androidId, bookName, readTime from readRecord") + database.execSQL("DROP TABLE readRecord") + database.execSQL("ALTER TABLE readRecordNew RENAME TO readRecord") + } + } + private val migration_19_20 = object : Migration(19, 20) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE book_sources ADD bookSourceComment TEXT") + } + } + + private val migration_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE book_groups ADD show INTEGER NOT NULL DEFAULT 1") + } + } + + private val migration_21_22 = object : Migration(21, 22) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `books_new` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, + `originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, + `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, + `group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, + `lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, + `durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, + `order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))""" + ) + database.execSQL( + """INSERT INTO books_new select `bookUrl`, `tocUrl`, `origin`, `originName`, `name`, `author`, `kind`, `customTag`, `coverUrl`, + `customCoverUrl`, `intro`, `customIntro`, `charset`, `type`, `group`, `latestChapterTitle`, `latestChapterTime`, `lastCheckTime`, + `lastCheckCount`, `totalChapterNum`, `durChapterTitle`, `durChapterIndex`, `durChapterPos`, `durChapterTime`, `wordCount`, `canUpdate`, + `order`, `originOrder`, `variable`, null + from books""" + ) + database.execSQL("DROP TABLE books") + database.execSQL("ALTER TABLE books_new RENAME TO books") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `books` (`name`, `author`) ") + } + } + + private val migration_22_23 = object : Migration(22, 23) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chapters ADD baseUrl TEXT NOT NULL DEFAULT ''") + } + } + + private val migration_23_24 = object : Migration(23, 24) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `caches` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `caches` (`key`)") + } + } + + private val migration_24_25 = object : Migration(24, 25) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `sourceSubs` + (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, + PRIMARY KEY(`id`))""" + ) + } + } + + private val migration_25_26 = object : Migration(25, 26) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `ruleSubs` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, + `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))""" + ) + database.execSQL(" insert into `ruleSubs` select *, 0, 0 from `sourceSubs` ") + database.execSQL("DROP TABLE `sourceSubs`") + } + } + + private val migration_26_27 = object : Migration(26, 27) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(" ALTER TABLE rssSources ADD singleUrl INTEGER NOT NULL DEFAULT 0 ") + database.execSQL( + """CREATE TABLE IF NOT EXISTS `bookmarks1` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL, + `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, + `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))""" + ) + database.execSQL( + """insert into `bookmarks1` + select `time`, `bookUrl`, `bookName`, `bookAuthor`, `chapterIndex`, `pageIndex`, `chapterName`, '', `content` + from bookmarks""" + ) + database.execSQL(" DROP TABLE `bookmarks` ") + database.execSQL(" ALTER TABLE bookmarks1 RENAME TO bookmarks ") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `bookmarks` (`time`)") + } + } + + private val migration_27_28 = object : Migration(27, 28) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE rssArticles ADD variable TEXT") + database.execSQL("ALTER TABLE rssStars ADD variable TEXT") + } + } + + private val migration_28_29 = object : Migration(28, 29) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE rssSources ADD sourceComment TEXT") + } + } + + private val migration_29_30 = object : Migration(29, 30) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chapters ADD `startFragmentId` TEXT") + database.execSQL("ALTER TABLE chapters ADD `endFragmentId` TEXT") + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `epubChapters` + (`bookUrl` TEXT NOT NULL, `href` TEXT NOT NULL, `parentHref` TEXT, + PRIMARY KEY(`bookUrl`, `href`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE ) + """ + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_epubChapters_bookUrl` ON `epubChapters` (`bookUrl`)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_epubChapters_bookUrl_href` ON `epubChapters` (`bookUrl`, `href`)") + } + } + + private val migration_30_31 = object : Migration(30, 31) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE readRecord RENAME TO readRecord1") + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `readRecord` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`)) + """ + ) + database.execSQL("insert into readRecord (deviceId, bookName, readTime) select androidId, bookName, readTime from readRecord1") + } + } + + private val migration_31_32 = object : Migration(31, 32) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE `epubChapters`") + } + } + + private val migration_32_33 = object : Migration(32, 33) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE bookmarks RENAME TO bookmarks_old") + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `bookmarks` (`time` INTEGER NOT NULL, + `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, + `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, + `content` TEXT NOT NULL, PRIMARY KEY(`time`)) + """ + ) + database.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `bookmarks` (`bookName`, `bookAuthor`) + """ + ) + database.execSQL( + """ + insert into bookmarks (time, bookName, bookAuthor, chapterIndex, chapterPos, chapterName, bookText, content) + select time, ifNull(b.name, bookName) bookName, ifNull(b.author, bookAuthor) bookAuthor, + chapterIndex, chapterPos, chapterName, bookText, content from bookmarks_old o + left join books b on o.bookUrl = b.bookUrl + """ + ) + } + } + + private val migration_33_34 = object : Migration(33, 34) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `book_groups` ADD `cover` TEXT") + } + } + + private val migration_34_35 = object : Migration(34, 35) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `book_sources` ADD `concurrentRate` TEXT") + } + } + + private val migration_35_36 = object : Migration(35, 36) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `book_sources` ADD `loginUi` TEXT") + database.execSQL("ALTER TABLE `book_sources` ADD`loginCheckJs` TEXT") + } + } + + private val migration_36_37 = object : Migration(36, 37) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `rssSources` ADD `loginUrl` TEXT") + database.execSQL("ALTER TABLE `rssSources` ADD `loginUi` TEXT") + database.execSQL("ALTER TABLE `rssSources` ADD `loginCheckJs` TEXT") + } + } + + private val migration_37_38 = object : Migration(37, 38) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `book_sources` ADD `respondTime` INTEGER NOT NULL DEFAULT 180000") + } + } + + private val migration_38_39 = object : Migration(38, 39) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `rssSources` ADD `concurrentRate` TEXT") + } + } + + private val migration_39_40 = object : Migration(39, 40) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `chapters` ADD `isVip` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `chapters` ADD `isPay` INTEGER NOT NULL DEFAULT 0") + } + } + + private val migration_40_41 = object : Migration(40, 41) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `httpTTS` ADD `loginUrl` TEXT") + database.execSQL("ALTER TABLE `httpTTS` ADD `loginUi` TEXT") + database.execSQL("ALTER TABLE `httpTTS` ADD `loginCheckJs` TEXT") + database.execSQL("ALTER TABLE `httpTTS` ADD `header` TEXT") + database.execSQL("ALTER TABLE `httpTTS` ADD `concurrentRate` TEXT") + } + } + + private val migration_41_42 = object : Migration(41, 42) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE 'httpTTS' ADD `contentType` TEXT") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/README.md b/app/src/main/java/io/legado/app/data/README.md new file mode 100644 index 000000000..a9354610f --- /dev/null +++ b/app/src/main/java/io/legado/app/data/README.md @@ -0,0 +1,17 @@ +# 存储数据用 +* dao 数据操作 +* entities 数据模型 +* \Book 书籍信息 +* \BookChapter 目录信息 +* \BookGroup 书籍分组 +* \Bookmark 书签 +* \BookSource 书源 +* \Cookie http cookie +* \ReplaceRule 替换规则 +* \RssArticle rss条目 +* \RssReadRecord rss阅读记录 +* \RssSource rss源 +* \RssStar rss收藏 +* \SearchBook 搜索结果 +* \SearchKeyword 搜索关键字 +* \TxtTocRule txt文件目录规则 diff --git a/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt new file mode 100644 index 000000000..6e5d7e399 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt @@ -0,0 +1,40 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.BookChapter +import kotlinx.coroutines.flow.Flow + +@Dao +interface BookChapterDao { + + @Query("select * from chapters where bookUrl = :bookUrl order by `index`") + fun flowByBook(bookUrl: String): Flow> + + @Query("SELECT * FROM chapters where bookUrl = :bookUrl and title like '%'||:key||'%' order by `index`") + fun flowSearch(bookUrl: String, key: String): Flow> + + @Query("select * from chapters where bookUrl = :bookUrl order by `index`") + fun getChapterList(bookUrl: String): List + + @Query("select * from chapters where bookUrl = :bookUrl and `index` >= :start and `index` <= :end order by `index`") + fun getChapterList(bookUrl: String, start: Int, end: Int): List + + @Query("select * from chapters where bookUrl = :bookUrl and `index` = :index") + fun getChapter(bookUrl: String, index: Int): BookChapter? + + @Query("select * from chapters where bookUrl = :bookUrl and `title` = :title") + fun getChapter(bookUrl: String, title: String): BookChapter? + + @Query("select count(url) from chapters where bookUrl = :bookUrl") + fun getChapterCount(bookUrl: String): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookChapter: BookChapter) + + @Update + fun upDate(vararg bookChapter: BookChapter) + + @Query("delete from chapters where bookUrl = :bookUrl") + fun delByBook(bookUrl: String) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt new file mode 100644 index 000000000..42ceea9a4 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -0,0 +1,89 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import kotlinx.coroutines.flow.Flow + +@Dao +interface BookDao { + + @Query("SELECT * FROM books order by durChapterTime desc") + fun flowAll(): Flow> + + @Query("SELECT * FROM books WHERE type = ${BookType.audio}") + fun flowAudio(): Flow> + + @Query("SELECT * FROM books WHERE origin = '${BookType.local}'") + fun flowLocal(): Flow> + + @Query("select * from books where type != ${BookType.audio} and origin != '${BookType.local}' and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0") + fun flowNoGroup(): Flow> + + @Query("SELECT bookUrl FROM books WHERE origin = '${BookType.local}'") + fun flowLocalUri(): Flow> + + @Query("SELECT * FROM books WHERE (`group` & :group) > 0") + fun flowByGroup(group: Long): Flow> + + @Query("SELECT * FROM books WHERE name like '%'||:key||'%' or author like '%'||:key||'%'") + fun flowSearch(key: String): Flow> + + @Query("SELECT * FROM books WHERE (`group` & :group) > 0") + fun getBooksByGroup(group: Long): List + + @Query("SELECT * FROM books WHERE `name` in (:names)") + fun findByName(vararg names: String): List + + @Query("SELECT * FROM books WHERE bookUrl = :bookUrl") + fun getBook(bookUrl: String): Book? + + @Query("SELECT * FROM books WHERE name = :name and author = :author") + fun getBook(name: String, author: String): Book? + + @get:Query("select count(bookUrl) from books where (SELECT sum(groupId) FROM book_groups) & `group` = 0") + val noGroupSize: Int + + @get:Query("SELECT * FROM books where origin <> '${BookType.local}' and type = 0") + val webBooks: List + + @get:Query("SELECT * FROM books where origin <> '${BookType.local}' and canUpdate = 1") + val hasUpdateBooks: List + + @get:Query("SELECT * FROM books") + val all: List + + @get:Query("SELECT * FROM books where type = 0 ORDER BY durChapterTime DESC limit 1") + val lastReadBook: Book? + + @get:Query("SELECT bookUrl FROM books") + val allBookUrls: List + + @get:Query("SELECT COUNT(*) FROM books") + val allBookCount: Int + + @get:Query("select min(`order`) from books") + val minOrder: Int + + @get:Query("select max(`order`) from books") + val maxOrder: Int + + @Query("select 1 from books where bookUrl = :bookUrl") + fun has(bookUrl: String): Boolean? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg book: Book) + + @Update + fun update(vararg book: Book) + + @Delete + fun delete(vararg book: Book) + + @Query("update books set durChapterPos = :pos where bookUrl = :bookUrl") + fun upProgress(bookUrl: String, pos: Int) + + @Query("update books set `group` = :newGroupId where `group` = :oldGroupId") + fun upGroup(oldGroupId: Long, newGroupId: Long) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt b/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt new file mode 100644 index 000000000..10a077b40 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt @@ -0,0 +1,74 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.legado.app.constant.BookType +import io.legado.app.data.entities.BookGroup +import kotlinx.coroutines.flow.Flow + +@Dao +interface BookGroupDao { + + @Query("select * from book_groups where groupId = :id") + fun getByID(id: Long): BookGroup? + + @Query("select * from book_groups where groupName = :groupName") + fun getByName(groupName: String): BookGroup? + + @Query("SELECT * FROM book_groups ORDER BY `order`") + fun flowAll(): Flow> + + @get:Query( + """ + SELECT * FROM book_groups where (groupId >= 0 and show > 0) + or (groupId = -4 and show > 0 and (select count(bookUrl) from books where type != ${BookType.audio} and origin != '${BookType.local}' and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0) > 0) + or (groupId = -3 and show > 0 and (select count(bookUrl) from books where type = ${BookType.audio}) > 0) + or (groupId = -2 and show > 0 and (select count(bookUrl) from books where origin = '${BookType.local}') > 0) + or (groupId = -1 and show > 0) + ORDER BY `order`""" + ) + val show: LiveData> + + @Query("SELECT * FROM book_groups where groupId >= 0 ORDER BY `order`") + fun flowSelect(): Flow> + + @get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0") + val idsSum: Long + + @get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0") + val maxOrder: Int + + @get:Query("SELECT * FROM book_groups ORDER BY `order`") + val all: List + + @Query("update book_groups set show = 1 where groupId = :groupId") + fun enableGroup(groupId: Long) + + @Query("select groupName from book_groups where groupId > 0 and (groupId & :id) > 0") + fun getGroupNames(id: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookGroup: BookGroup) + + @Update + fun update(vararg bookGroup: BookGroup) + + @Delete + fun delete(vararg bookGroup: BookGroup) + + fun isInRules(id: Long): Boolean { + if (id < 0) { + return true + } + return id and (id - 1) == 0L + } + + fun getUnusedId(): Long { + var id = 1L + val idsSum = idsSum + while (id and idsSum != 0L) { + id = id.shl(1) + } + return id + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt new file mode 100644 index 000000000..4790cb5c3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt @@ -0,0 +1,118 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.BookSource +import kotlinx.coroutines.flow.Flow + +@Dao +interface BookSourceDao { + + @Query("select * from book_sources order by customOrder asc") + fun flowAll(): Flow> + + @Query( + """select * from book_sources + where bookSourceName like :searchKey + or bookSourceGroup like :searchKey + or bookSourceUrl like :searchKey + or bookSourceComment like :searchKey + order by customOrder asc""" + ) + fun flowSearch(searchKey: String): Flow> + + @Query("select * from book_sources where bookSourceGroup like :searchKey order by customOrder asc") + fun flowGroupSearch(searchKey: String): Flow> + + @Query("select * from book_sources where enabled = 1 order by customOrder asc") + fun flowEnabled(): Flow> + + @Query("select * from book_sources where enabled = 0 order by customOrder asc") + fun flowDisabled(): Flow> + + @Query("select * from book_sources where enabledExplore = 1 and trim(exploreUrl) <> '' order by customOrder asc") + fun flowExplore(): Flow> + + @Query("select * from book_sources where loginUrl is not null and loginUrl != ''") + fun flowLogin(): Flow> + + @Query( + """select * from book_sources + where enabledExplore = 1 + and trim(exploreUrl) <> '' + and (bookSourceGroup like :key or bookSourceName like :key) + order by customOrder asc""" + ) + fun flowExplore(key: String): Flow> + + @Query( + """select * from book_sources + where enabledExplore = 1 + and trim(exploreUrl) <> '' + and (bookSourceGroup like :key) + order by customOrder asc""" + ) + fun flowGroupExplore(key: String): Flow> + + @Query("select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''") + fun flowGroup(): Flow> + + @Query("select distinct bookSourceGroup from book_sources where enabled = 1 and trim(bookSourceGroup) <> ''") + fun flowGroupEnabled(): Flow> + + @Query( + """select distinct bookSourceGroup from book_sources + where enabledExplore = 1 + and trim(exploreUrl) <> '' + and trim(bookSourceGroup) <> '' + order by customOrder""" + ) + fun flowExploreGroup(): Flow> + + @Query("select * from book_sources where bookSourceGroup like '%' || :group || '%'") + fun getByGroup(group: String): List + + @Query("select * from book_sources where enabled = 1 and bookSourceGroup like '%' || :group || '%'") + fun getEnabledByGroup(group: String): List + + @get:Query("select * from book_sources where trim(bookUrlPattern) <> ''") + val hasBookUrlPattern: List + + @get:Query("select * from book_sources where bookSourceGroup is null or bookSourceGroup = ''") + val noGroup: List + + @get:Query("select * from book_sources order by customOrder asc") + val all: List + + @get:Query("select * from book_sources where enabled = 1 order by customOrder") + val allEnabled: List + + @get:Query("select * from book_sources where enabled = 1 and bookSourceType = 0 order by customOrder") + val allTextEnabled: List + + @get:Query("select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''") + val allGroup: List + + @Query("select * from book_sources where bookSourceUrl = :key") + fun getBookSource(key: String): BookSource? + + @Query("select count(*) from book_sources") + fun allCount(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookSource: BookSource) + + @Update + fun update(vararg bookSource: BookSource) + + @Delete + fun delete(vararg bookSource: BookSource) + + @Query("delete from book_sources where bookSourceUrl = :key") + fun delete(key: String) + + @get:Query("select min(customOrder) from book_sources") + val minOrder: Int + + @get:Query("select max(customOrder) from book_sources") + val maxOrder: Int +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt b/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt new file mode 100644 index 000000000..830e83d6c --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt @@ -0,0 +1,38 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.Bookmark +import kotlinx.coroutines.flow.Flow + + +@Dao +interface BookmarkDao { + + @get:Query("select * from bookmarks") + val all: List + + @Query( + """select * from bookmarks + where bookName = :bookName and bookAuthor = :bookAuthor + order by chapterIndex""" + ) + fun flowByBook(bookName: String, bookAuthor: String): Flow> + + @Query( + """SELECT * FROM bookmarks + where bookName = :bookName and bookAuthor = :bookAuthor + and chapterName like '%'||:key||'%' or content like '%'||:key||'%' + order by chapterIndex""" + ) + fun flowSearch(bookName: String, bookAuthor: String, key: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookmark: Bookmark) + + @Update + fun update(bookmark: Bookmark) + + @Delete + fun delete(vararg bookmark: Bookmark) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/CacheDao.kt b/app/src/main/java/io/legado/app/data/dao/CacheDao.kt new file mode 100644 index 000000000..863199b75 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/CacheDao.kt @@ -0,0 +1,24 @@ +package io.legado.app.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.legado.app.data.entities.Cache + +@Dao +interface CacheDao { + + @Query("select value from caches where `key` = :key and (deadline = 0 or deadline > :now)") + fun get(key: String, now: Long): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg cache: Cache) + + @Query("delete from caches where `key` = :key") + fun delete(key: String) + + @Query("delete from caches where deadline > 0 and deadline < :now") + fun clearDeadline(now: Long) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/CookieDao.kt b/app/src/main/java/io/legado/app/data/dao/CookieDao.kt new file mode 100644 index 000000000..f667b7d2d --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/CookieDao.kt @@ -0,0 +1,26 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.Cookie + +@Dao +interface CookieDao { + + @Query("SELECT * FROM cookies Where url = :url") + fun get(url: String): Cookie? + + @Query("select * from cookies where url like '%|%'") + fun getOkHttpCookies(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg cookie: Cookie) + + @Update + fun update(vararg cookie: Cookie) + + @Query("delete from cookies where url = :url") + fun delete(url: String) + + @Query("delete from cookies where url like '%|%'") + fun deleteOkHttp() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/HttpTTSDao.kt b/app/src/main/java/io/legado/app/data/dao/HttpTTSDao.kt new file mode 100644 index 000000000..b75fd290a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/HttpTTSDao.kt @@ -0,0 +1,36 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.HttpTTS +import kotlinx.coroutines.flow.Flow + +@Dao +interface HttpTTSDao { + + @get:Query("select * from httpTTS order by name") + val all: List + + @Query("select * from httpTTS order by name") + fun flowAll(): Flow> + + @get:Query("select count(*) from httpTTS") + val count: Int + + @Query("select * from httpTTS where id = :id") + fun get(id: Long): HttpTTS? + + @Query("select name from httpTTS where id = :id") + fun getName(id: Long): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg httpTTS: HttpTTS) + + @Delete + fun delete(vararg httpTTS: HttpTTS) + + @Update + fun update(vararg httpTTS: HttpTTS) + + @Query("delete from httpTTS where id < 0") + fun deleteDefault() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt new file mode 100644 index 000000000..7aaaf916e --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -0,0 +1,39 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.ReadRecord +import io.legado.app.data.entities.ReadRecordShow + +@Dao +interface ReadRecordDao { + + @get:Query("select * from readRecord") + val all: List + + @get:Query("select bookName, sum(readTime) as readTime from readRecord group by bookName order by bookName collate localized") + val allShow: List + + @get:Query("select sum(readTime) from readRecord") + val allTime: Long + + @Query("select sum(readTime) from readRecord where bookName = :bookName") + fun getReadTime(bookName: String): Long? + + @Query("select readTime from readRecord where deviceId = :androidId and bookName = :bookName") + fun getReadTime(androidId: String, bookName: String): Long? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg readRecord: ReadRecord) + + @Update + fun update(vararg record: ReadRecord) + + @Delete + fun delete(vararg record: ReadRecord) + + @Query("delete from readRecord") + fun clear() + + @Query("delete from readRecord where bookName = :bookName") + fun deleteByName(bookName: String) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt new file mode 100644 index 000000000..891e0bf77 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt @@ -0,0 +1,71 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.ReplaceRule +import kotlinx.coroutines.flow.Flow + + +@Dao +interface ReplaceRuleDao { + + @Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC") + fun flowAll(): Flow> + + @Query("SELECT * FROM replace_rules where `group` like :key or name like :key ORDER BY sortOrder ASC") + fun flowSearch(key: String): Flow> + + @Query("SELECT * FROM replace_rules where `group` like :key ORDER BY sortOrder ASC") + fun flowGroupSearch(key: String): Flow> + + @Query("select `group` from replace_rules where `group` is not null and `group` <> ''") + fun flowGroup(): Flow> + + @get:Query("SELECT MIN(sortOrder) FROM replace_rules") + val minOrder: Int + + @get:Query("SELECT MAX(sortOrder) FROM replace_rules") + val maxOrder: Int + + @get:Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC") + val all: List + + @get:Query("select distinct `group` from replace_rules where trim(`group`) <> ''") + val allGroup: List + + @get:Query("SELECT * FROM replace_rules WHERE isEnabled = 1 ORDER BY sortOrder ASC") + val allEnabled: List + + @Query("SELECT * FROM replace_rules WHERE id = :id") + fun findById(id: Long): ReplaceRule? + + @Query("SELECT * FROM replace_rules WHERE id in (:ids)") + fun findByIds(vararg ids: Long): List + + @Query( + """SELECT * FROM replace_rules WHERE isEnabled = 1 + AND (scope LIKE '%' || :name || '%' or scope LIKE '%' || :origin || '%' or scope is null or scope = '') + order by sortOrder""" + ) + fun findEnabledByScope(name: String, origin: String): List + + @Query("select * from replace_rules where `group` like '%' || :group || '%'") + fun getByGroup(group: String): List + + @get:Query("select * from replace_rules where `group` is null or `group` = ''") + val noGroup: List + + @get:Query("SELECT COUNT(*) - SUM(isEnabled) FROM replace_rules") + val summary: Int + + @Query("UPDATE replace_rules SET isEnabled = :enable") + fun enableAll(enable: Boolean) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg replaceRule: ReplaceRule): List + + @Update + fun update(vararg replaceRules: ReplaceRule) + + @Delete + fun delete(vararg replaceRules: ReplaceRule) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt b/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt new file mode 100644 index 000000000..30caf63da --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt @@ -0,0 +1,39 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssReadRecord +import kotlinx.coroutines.flow.Flow + +@Dao +interface RssArticleDao { + + @Query("select * from rssArticles where origin = :origin and link = :link") + fun get(origin: String, link: String): RssArticle? + + @Query( + """select t1.link, t1.sort, t1.origin, t1.`order`, t1.title, t1.content, + t1.description, t1.image, t1.pubDate, t1.variable, ifNull(t2.read, 0) as read + from rssArticles as t1 left join rssReadRecords as t2 + on t1.link = t2.record where origin = :origin and sort = :sort + order by `order` desc""" + ) + fun flowByOriginSort(origin: String, sort: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg rssArticle: RssArticle) + + @Query("delete from rssArticles where origin = :origin and sort = :sort and `order` < :order") + fun clearOld(origin: String, sort: String, order: Long) + + @Update + fun update(vararg rssArticle: RssArticle) + + @Query("delete from rssArticles where origin = :origin") + fun delete(origin: String) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertRecord(vararg rssReadRecord: RssReadRecord) + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt new file mode 100644 index 000000000..85605b3cf --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt @@ -0,0 +1,74 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.RssSource +import kotlinx.coroutines.flow.Flow + +@Dao +interface RssSourceDao { + + @Query("select * from rssSources where sourceUrl = :key") + fun getByKey(key: String): RssSource? + + @Query("select * from rssSources where sourceUrl in (:sourceUrls)") + fun getRssSources(vararg sourceUrls: String): List + + @get:Query("SELECT * FROM rssSources") + val all: List + + @get:Query("select count(sourceUrl) from rssSources") + val size: Int + + @Query("SELECT * FROM rssSources order by customOrder") + fun flowAll(): Flow> + + @Query("SELECT * FROM rssSources where sourceName like :key or sourceUrl like :key or sourceGroup like :key order by customOrder") + fun flowSearch(key: String): Flow> + + @Query("SELECT * FROM rssSources where sourceGroup like :key order by customOrder") + fun flowGroupSearch(key: String): Flow> + + @Query("SELECT * FROM rssSources where enabled = 1 order by customOrder") + fun flowEnabled(): Flow> + + @Query( + """SELECT * FROM rssSources + where enabled = 1 + and (sourceName like :searchKey or sourceGroup like :searchKey or sourceUrl like :searchKey) + order by customOrder""" + ) + fun flowEnabled(searchKey: String): Flow> + + @Query("SELECT * FROM rssSources where enabled = 1 and sourceGroup like :searchKey order by customOrder") + fun flowEnabledByGroup(searchKey: String): Flow> + + @Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> ''") + fun flowGroup(): Flow> + + @get:Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> ''") + val allGroup: List + + @get:Query("select min(customOrder) from rssSources") + val minOrder: Int + + @get:Query("select max(customOrder) from rssSources") + val maxOrder: Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg rssSource: RssSource) + + @Update + fun update(vararg rssSource: RssSource) + + @Delete + fun delete(vararg rssSource: RssSource) + + @Query("delete from rssSources where sourceUrl = :sourceUrl") + fun delete(sourceUrl: String) + + @get:Query("select * from rssSources where sourceGroup is null or sourceGroup = ''") + val noGroup: List + + @Query("select * from rssSources where sourceGroup like '%' || :group || '%'") + fun getByGroup(group: String): List +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/RssStarDao.kt b/app/src/main/java/io/legado/app/data/dao/RssStarDao.kt new file mode 100644 index 000000000..2c40349c0 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/RssStarDao.kt @@ -0,0 +1,30 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.RssStar +import kotlinx.coroutines.flow.Flow + +@Dao +interface RssStarDao { + + @get:Query("select * from rssStars order by starTime desc") + val all: List + + @Query("select * from rssStars where origin = :origin and link = :link") + fun get(origin: String, link: String): RssStar? + + @Query("select * from rssStars order by starTime desc") + fun liveAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg rssStar: RssStar) + + @Update + fun update(vararg rssStar: RssStar) + + @Query("delete from rssStars where origin = :origin") + fun delete(origin: String) + + @Query("delete from rssStars where origin = :origin and link = :link") + fun delete(origin: String, link: String) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/RuleSubDao.kt b/app/src/main/java/io/legado/app/data/dao/RuleSubDao.kt new file mode 100644 index 000000000..56d87b262 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/RuleSubDao.kt @@ -0,0 +1,30 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.RuleSub +import kotlinx.coroutines.flow.Flow + +@Dao +interface RuleSubDao { + + @get:Query("select * from ruleSubs order by customOrder") + val all: List + + @Query("select * from ruleSubs order by customOrder") + fun flowAll(): Flow> + + @get:Query("select customOrder from ruleSubs order by customOrder limit 0,1") + val maxOrder: Int + + @Query("select * from ruleSubs where url = :url") + fun findByUrl(url: String): RuleSub? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg ruleSub: RuleSub) + + @Delete + fun delete(vararg ruleSub: RuleSub) + + @Update + fun update(vararg ruleSub: RuleSub) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt new file mode 100644 index 000000000..0c5a6183f --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt @@ -0,0 +1,70 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.SearchBook + +@Dao +interface SearchBookDao { + + @Query("select * from searchBooks where bookUrl = :bookUrl") + fun getSearchBook(bookUrl: String): SearchBook? + + @Query("select * from searchBooks where name = :name and author = :author and origin in (select bookSourceUrl from book_sources) order by originOrder limit 1") + fun getFirstByNameAuthor(name: String, author: String): SearchBook? + + @Query( + """select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, + t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, + t1.wordCount, t2.customOrder as originOrder + from searchBooks as t1 inner join book_sources as t2 + on t1.origin = t2.bookSourceUrl + where t1.name = :name and t1.author like '%'||:author||'%' + and t2.enabled = 1 and t2.bookSourceGroup like '%'||:sourceGroup||'%' + order by t2.customOrder""" + ) + fun getChangeSourceSearch(name: String, author: String, sourceGroup: String): List + + @Query( + """select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, + t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, + t1.wordCount, t2.customOrder as originOrder + from searchBooks as t1 inner join book_sources as t2 + on t1.origin = t2.bookSourceUrl + where t1.name = :name and t1.author = :author + and originName like '%'||:key||'%' and t2.enabled = 1 + and t2.bookSourceGroup like '%'||:sourceGroup||'%' + order by t2.customOrder""" + ) + fun getChangeSourceSearch( + name: String, + author: String, + key: String, + sourceGroup: String + ): List + + @Query( + """ + select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, t1.wordCount, t2.customOrder as originOrder + from searchBooks as t1 inner join book_sources as t2 + on t1.origin = t2.bookSourceUrl + where t1.name = :name and t1.author = :author and t1.coverUrl is not null and t1.coverUrl <> '' and t2.enabled = 1 + order by t2.customOrder + """ + ) + fun getEnableHasCover(name: String, author: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg searchBook: SearchBook): List + + @Query("delete from searchBooks where name = :name and author = :author") + fun clear(name: String, author: String) + + @Query("delete from searchBooks where time < :time") + fun clearExpired(time: Long) + + @Update + fun update(vararg searchBook: SearchBook) + + @Delete + fun delete(vararg searchBook: SearchBook) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt b/app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt new file mode 100644 index 000000000..2b433e488 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt @@ -0,0 +1,38 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.SearchKeyword +import kotlinx.coroutines.flow.Flow + + +@Dao +interface SearchKeywordDao { + + @get:Query("SELECT * FROM search_keywords") + val all: List + + @Query("SELECT * FROM search_keywords ORDER BY usage DESC") + fun flowByUsage(): Flow> + + @Query("SELECT * FROM search_keywords ORDER BY lastUseTime DESC") + fun flowByTime(): Flow> + + @Query("SELECT * FROM search_keywords where word like '%'||:key||'%' ORDER BY usage DESC") + fun flowSearch(key: String): Flow> + + @Query("select * from search_keywords where word = :key") + fun get(key: String): SearchKeyword? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg keywords: SearchKeyword) + + @Update + fun update(vararg keywords: SearchKeyword) + + @Delete + fun delete(vararg keywords: SearchKeyword) + + @Query("DELETE FROM search_keywords") + fun deleteAll() + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt new file mode 100644 index 000000000..0449e0d3e --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt @@ -0,0 +1,36 @@ +package io.legado.app.data.dao + +import androidx.room.* +import io.legado.app.data.entities.TxtTocRule +import kotlinx.coroutines.flow.Flow + +@Dao +interface TxtTocRuleDao { + + @Query("select * from txtTocRules order by serialNumber") + fun observeAll(): Flow> + + @get:Query("select * from txtTocRules order by serialNumber") + val all: List + + @get:Query("select * from txtTocRules where enable = 1 order by serialNumber") + val enabled: List + + @get:Query("select ifNull(min(serialNumber), 0) from txtTocRules") + val minOrder: Int + + @get:Query("select ifNull(max(serialNumber), 0) from txtTocRules") + val maxOrder: Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg rule: TxtTocRule) + + @Update(onConflict = OnConflictStrategy.REPLACE) + fun update(vararg rule: TxtTocRule) + + @Delete + fun delete(vararg rule: TxtTocRule) + + @Query("delete from txtTocRules where id < 0") + fun deleteDefault() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BaseBook.kt b/app/src/main/java/io/legado/app/data/entities/BaseBook.kt new file mode 100644 index 000000000..5b3f76e0c --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BaseBook.kt @@ -0,0 +1,27 @@ +package io.legado.app.data.entities + +import io.legado.app.model.analyzeRule.RuleDataInterface +import io.legado.app.utils.splitNotBlank + +interface BaseBook : RuleDataInterface { + var name: String + var author: String + var bookUrl: String + var kind: String? + var wordCount: String? + + var infoHtml: String? + var tocHtml: String? + + fun getKindList(): List { + val kindList = arrayListOf() + wordCount?.let { + if (it.isNotBlank()) kindList.add(it) + } + kind?.let { + val kinds = it.splitNotBlank(",", "\n") + kindList.addAll(kinds) + } + return kindList + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BaseSource.kt b/app/src/main/java/io/legado/app/data/entities/BaseSource.kt new file mode 100644 index 000000000..2065799b1 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BaseSource.kt @@ -0,0 +1,169 @@ +package io.legado.app.data.entities + +import android.util.Base64 +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.rule.RowUi +import io.legado.app.help.AppConfig +import io.legado.app.help.CacheManager +import io.legado.app.help.JsExtensions +import io.legado.app.help.http.CookieStore +import io.legado.app.utils.EncoderUtils +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.fromJsonObject +import timber.log.Timber +import javax.script.SimpleBindings + +/** + * 可在js里调用,source.xxx() + */ +@Suppress("unused") +interface BaseSource : JsExtensions { + + var concurrentRate: String? // 并发率 + var loginUrl: String? // 登录地址 + var loginUi: String? // 登录UI + var header: String? // 请求头 + + fun getTag(): String + + fun getKey(): String + + fun loginUi(): List? { + return GSON.fromJsonArray(loginUi) + } + + fun getLoginJs(): String? { + val loginJs = loginUrl + return when { + loginJs == null -> null + loginJs.startsWith("@js:") -> loginJs.substring(4) + loginJs.startsWith("") -> + loginJs.substring(4, loginJs.lastIndexOf("<")) + else -> loginJs + } + } + + fun login() { + getLoginJs()?.let { + evalJS(it) + } + } + + /** + * 解析header规则 + */ + fun getHeaderMap(hasLoginHeader: Boolean = false) = HashMap().apply { + this[AppConst.UA_NAME] = AppConfig.userAgent + header?.let { + GSON.fromJsonObject>( + when { + it.startsWith("@js:", true) -> + evalJS(it.substring(4)).toString() + it.startsWith("", true) -> + evalJS(it.substring(4, it.lastIndexOf("<"))).toString() + else -> it + } + )?.let { map -> + putAll(map) + } + } + if (hasLoginHeader) { + getLoginHeaderMap()?.let { + putAll(it) + } + } + } + + /** + * 获取用于登录的头部信息 + */ + fun getLoginHeader(): String? { + return CacheManager.get("loginHeader_${getKey()}") + } + + fun getLoginHeaderMap(): Map? { + val cache = getLoginHeader() ?: return null + return GSON.fromJsonObject(cache) + } + + /** + * 保存登录头部信息,map格式,访问时自动添加 + */ + fun putLoginHeader(header: String) { + CacheManager.put("loginHeader_${getKey()}", header) + } + + fun removeLoginHeader() { + CacheManager.delete("loginHeader_${getKey()}") + } + + /** + * 获取用户信息,可以用来登录 + * 用户信息采用aes加密存储 + */ + fun getLoginInfo(): String? { + try { + val key = AppConst.androidId.encodeToByteArray(0, 8) + val cache = CacheManager.get("userInfo_${getKey()}") ?: return null + val encodeBytes = Base64.decode(cache, Base64.DEFAULT) + val decodeBytes = EncoderUtils.decryptAES(encodeBytes, key) + ?: return null + return String(decodeBytes) + } catch (e: Exception) { + Timber.e(e) + return null + } + } + + fun getLoginInfoMap(): Map? { + return GSON.fromJsonObject(getLoginInfo()) + } + + /** + * 保存用户信息,aes加密 + */ + fun putLoginInfo(info: String): Boolean { + return try { + val key = (AppConst.androidId).encodeToByteArray(0, 8) + val encodeBytes = EncoderUtils.encryptAES(info.toByteArray(), key) + val encodeStr = Base64.encodeToString(encodeBytes, Base64.DEFAULT) + CacheManager.put("userInfo_${getKey()}", encodeStr) + true + } catch (e: Exception) { + Timber.e(e) + false + } + } + + fun removeLoginInfo() { + CacheManager.delete("userInfo_${getKey()}") + } + + fun setVariable(variable: String?) { + if (variable != null) { + CacheManager.put("sourceVariable_${getKey()}", variable) + } else { + CacheManager.delete("sourceVariable_${getKey()}") + } + } + + fun getVariable(): String? { + return CacheManager.get("sourceVariable_${getKey()}") + } + + /** + * 执行JS + */ + @Throws(Exception::class) + fun evalJS(jsStr: String, bindingsConfig: SimpleBindings.() -> Unit = {}): Any? { + val bindings = SimpleBindings() + bindings.apply(bindingsConfig) + bindings["java"] = this + bindings["source"] = this + bindings["baseUrl"] = getKey() + bindings["cookie"] = CookieStore + bindings["cache"] = CacheManager + return AppConst.SCRIPT_ENGINE.eval(jsStr, bindings) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Book.kt b/app/src/main/java/io/legado/app/data/entities/Book.kt new file mode 100644 index 000000000..5b61a6cc7 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/Book.kt @@ -0,0 +1,294 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.* +import io.legado.app.constant.AppPattern +import io.legado.app.constant.BookType +import io.legado.app.data.appDb +import io.legado.app.help.AppConfig +import io.legado.app.model.ReadBook +import io.legado.app.utils.GSON +import io.legado.app.utils.MD5Utils +import io.legado.app.utils.fromJsonObject +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.nio.charset.Charset +import kotlin.math.max +import kotlin.math.min + +@Parcelize +@TypeConverters(Book.Converters::class) +@Entity( + tableName = "books", + indices = [Index(value = ["name", "author"], unique = true)] +) +data class Book( + @PrimaryKey + override var bookUrl: String = "", // 详情页Url(本地书源存储完整文件路径) + var tocUrl: String = "", // 目录页Url (toc=table of Contents) + var origin: String = BookType.local, // 书源URL(默认BookType.local) + var originName: String = "", //书源名称 or 本地书籍文件名 + override var name: String = "", // 书籍名称(书源获取) + override var author: String = "", // 作者名称(书源获取) + override var kind: String? = null, // 分类信息(书源获取) + var customTag: String? = null, // 分类信息(用户修改) + var coverUrl: String? = null, // 封面Url(书源获取) + var customCoverUrl: String? = null, // 封面Url(用户修改) + var intro: String? = null, // 简介内容(书源获取) + var customIntro: String? = null, // 简介内容(用户修改) + var charset: String? = null, // 自定义字符集名称(仅适用于本地书籍) + var type: Int = 0, // 0:text 1:audio + var group: Long = 0, // 自定义分组索引号 + var latestChapterTitle: String? = null, // 最新章节标题 + var latestChapterTime: Long = System.currentTimeMillis(), // 最新章节标题更新时间 + var lastCheckTime: Long = System.currentTimeMillis(), // 最近一次更新书籍信息的时间 + var lastCheckCount: Int = 0, // 最近一次发现新章节的数量 + var totalChapterNum: Int = 0, // 书籍目录总数 + var durChapterTitle: String? = null, // 当前章节名称 + var durChapterIndex: Int = 0, // 当前章节索引 + var durChapterPos: Int = 0, // 当前阅读的进度(首行字符的索引位置) + var durChapterTime: Long = System.currentTimeMillis(), // 最近一次阅读书籍的时间(打开正文的时间) + override var wordCount: String? = null, + var canUpdate: Boolean = true, // 刷新书架时更新书籍信息 + var order: Int = 0, // 手动排序 + var originOrder: Int = 0, //书源排序 + var variable: String? = null, // 自定义书籍变量信息(用于书源规则检索书籍信息) + var readConfig: ReadConfig? = null +) : Parcelable, BaseBook { + + fun isLocalBook(): Boolean { + return origin == BookType.local + } + + fun isLocalTxt(): Boolean { + return isLocalBook() && originName.endsWith(".txt", true) + } + + fun isEpub(): Boolean { + return originName.endsWith(".epub", true) + } + + fun isUmd(): Boolean { + return originName.endsWith(".umd", true) + } + + fun isOnLineTxt(): Boolean { + return !isLocalBook() && type == 0 + } + + override fun equals(other: Any?): Boolean { + if (other is Book) { + return other.bookUrl == bookUrl + } + return false + } + + override fun hashCode(): Int { + return bookUrl.hashCode() + } + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + override val variableMap by lazy { + GSON.fromJsonObject>(variable) ?: HashMap() + } + + override fun putVariable(key: String, value: String?) { + if (value != null) { + variableMap[key] = value + } else { + variableMap.remove(key) + } + variable = GSON.toJson(variableMap) + } + + @Ignore + @IgnoredOnParcel + override var infoHtml: String? = null + + @Ignore + @IgnoredOnParcel + override var tocHtml: String? = null + + fun getRealAuthor() = author.replace(AppPattern.authorRegex, "") + + fun getUnreadChapterNum() = max(totalChapterNum - durChapterIndex - 1, 0) + + fun getDisplayTag() = if (customTag.isNullOrBlank()) kind else customTag + + fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl + + fun getDisplayIntro() = if (customIntro.isNullOrEmpty()) intro else customIntro + + //自定义简介有自动更新的需求时,可通过更新intro再调用upCustomIntro()完成 + @Suppress("unused") + fun upCustomIntro() { + customIntro = intro + } + + fun fileCharset(): Charset { + return charset(charset ?: "UTF-8") + } + + private fun config(): ReadConfig { + if (readConfig == null) { + readConfig = ReadConfig() + } + return readConfig!! + } + + fun setReverseToc(reverseToc: Boolean) { + config().reverseToc = reverseToc + } + + fun getReverseToc(): Boolean { + return config().reverseToc + } + + fun setUseReplaceRule(useReplaceRule: Boolean) { + config().useReplaceRule = useReplaceRule + } + + fun getUseReplaceRule(): Boolean { + return config().useReplaceRule + } + + fun getReSegment(): Boolean { + return config().reSegment + } + + fun setReSegment(reSegment: Boolean) { + config().reSegment = reSegment + } + + fun getPageAnim(): Int { + return config().pageAnim + } + + fun setPageAnim(pageAnim: Int) { + config().pageAnim = pageAnim + } + + fun getImageStyle(): String? { + return config().imageStyle + } + + fun setImageStyle(imageStyle: String?) { + config().imageStyle = imageStyle + } + + fun setDelTag(tag: Long) { + config().delTag = + if ((config().delTag and tag) == tag) config().delTag and tag.inv() else config().delTag or tag + } + + fun getDelTag(tag: Long): Boolean { + return config().delTag and tag == tag + } + + fun getFolderName(): String { + //防止书名过长,只取9位 + var folderName = name.replace(AppPattern.fileNameRegex, "") + folderName = folderName.substring(0, min(9, folderName.length)) + return folderName + MD5Utils.md5Encode16(bookUrl) + } + + fun toSearchBook() = SearchBook( + name = name, + author = author, + kind = kind, + bookUrl = bookUrl, + origin = origin, + originName = originName, + type = type, + wordCount = wordCount, + latestChapterTitle = latestChapterTitle, + coverUrl = coverUrl, + intro = intro, + tocUrl = tocUrl, + originOrder = originOrder, + variable = variable + ).apply { + this.infoHtml = this@Book.infoHtml + this.tocHtml = this@Book.tocHtml + } + + fun changeTo(newBook: Book) { + newBook.group = group + newBook.order = order + newBook.customCoverUrl = customCoverUrl + newBook.customIntro = customIntro + newBook.customTag = customTag + newBook.canUpdate = canUpdate + newBook.readConfig = readConfig + delete(this) + appDb.bookDao.insert(newBook) + } + + fun upInfoFromOld(oldBook: Book?) { + oldBook?.let { + group = oldBook.group + durChapterIndex = oldBook.durChapterIndex + durChapterPos = oldBook.durChapterPos + durChapterTitle = oldBook.durChapterTitle + customCoverUrl = oldBook.customCoverUrl + customIntro = oldBook.customIntro + order = oldBook.order + if (coverUrl.isNullOrEmpty()) { + coverUrl = oldBook.getDisplayCover() + } + } + } + + fun createBookMark(): Bookmark { + return Bookmark( + bookName = name, + bookAuthor = author, + ) + } + + fun save() { + if (appDb.bookDao.has(bookUrl) == true) { + appDb.bookDao.update(this) + } else { + appDb.bookDao.insert(this) + } + } + + companion object { + const val hTag = 2L + const val rubyTag = 4L + const val imgTag = 8L + const val imgStyleDefault = "DEFAULT" + const val imgStyleFull = "FULL" + const val imgStyleText = "TEXT" + + fun delete(book: Book?) { + book ?: return + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.book = null + } + appDb.bookDao.delete(book) + } + } + + @Parcelize + data class ReadConfig( + var reverseToc: Boolean = false, + var pageAnim: Int = -1, + var reSegment: Boolean = false, + var imageStyle: String? = null, + var useReplaceRule: Boolean = AppConfig.replaceEnableDefault,// 正文使用净化替换规则 + var delTag: Long = 0L,//去除标签 + ) : Parcelable + + class Converters { + + @TypeConverter + fun readConfigToString(config: ReadConfig?): String = GSON.toJson(config) + + @TypeConverter + fun stringToReadConfig(json: String?) = GSON.fromJsonObject(json) + } +} diff --git a/app/src/main/java/io/legado/app/data/entities/BookChapter.kt b/app/src/main/java/io/legado/app/data/entities/BookChapter.kt new file mode 100644 index 000000000..39b8ea6b0 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookChapter.kt @@ -0,0 +1,125 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import com.github.liuyueyi.quick.transfer.ChineseUtils +import io.legado.app.R +import io.legado.app.constant.AppPattern +import io.legado.app.help.AppConfig +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.analyzeRule.RuleDataInterface +import io.legado.app.utils.* +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import splitties.init.appCtx + +@Suppress("unused") +@Parcelize +@Entity( + tableName = "chapters", + primaryKeys = ["url", "bookUrl"], + indices = [(Index(value = ["bookUrl"], unique = false)), + (Index(value = ["bookUrl", "index"], unique = true))], + foreignKeys = [(ForeignKey( + entity = Book::class, + parentColumns = ["bookUrl"], + childColumns = ["bookUrl"], + onDelete = ForeignKey.CASCADE + ))] +) // 删除书籍时自动删除章节 +data class BookChapter( + var url: String = "", // 章节地址 + var title: String = "", // 章节标题 + var baseUrl: String = "", // 用来拼接相对url + var bookUrl: String = "", // 书籍地址 + var index: Int = 0, // 章节序号 + var isVip: Boolean = false, // 是否VIP + var isPay: Boolean = false, // 是否已购买 + var resourceUrl: String? = null, // 音频真实URL + var tag: String? = null, // + var start: Long? = null, // 章节起始位置 + var end: Long? = null, // 章节终止位置 + var startFragmentId: String? = null, //EPUB书籍当前章节的fragmentId + var endFragmentId: String? = null, //EPUB书籍下一章节的fragmentId + var variable: String? = null //变量 +) : Parcelable, RuleDataInterface { + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + override val variableMap by lazy { + GSON.fromJsonObject>(variable) ?: HashMap() + } + + override fun putVariable(key: String, value: String?) { + if (value != null) { + variableMap[key] = value + } else { + variableMap.remove(key) + } + variable = GSON.toJson(variableMap) + } + + override fun hashCode() = url.hashCode() + + override fun equals(other: Any?): Boolean { + if (other is BookChapter) { + return other.url == url + } + return false + } + + @Suppress("unused") + fun getDisplayTitle( + replaceRules: Array? = null, + useReplace: Boolean = true, + chineseConvert: Boolean = true, + ): String { + var displayTitle = title.replace(AppPattern.rnRegex, "") + if (useReplace && replaceRules != null) { + replaceRules.forEach { item -> + if (item.pattern.isNotEmpty()) { + try { + displayTitle = if (item.isRegex) { + displayTitle.replace(item.pattern.toRegex(), item.replacement) + } else { + displayTitle.replace(item.pattern, item.replacement) + } + } catch (e: Exception) { + appCtx.toastOnUi("${item.name}替换出错") + } + } + } + } + if (chineseConvert) { + when (AppConfig.chineseConverterType) { + 1 -> displayTitle = ChineseUtils.t2s(displayTitle) + 2 -> displayTitle = ChineseUtils.s2t(displayTitle) + } + } + return when { + !isVip -> displayTitle + isPay -> appCtx.getString(R.string.payed_title, displayTitle) + else -> appCtx.getString(R.string.vip_title, displayTitle) + } + } + + fun getAbsoluteURL(): String { + val urlMatcher = AnalyzeUrl.paramPattern.matcher(url) + val urlBefore = if (urlMatcher.find()) url.substring(0, urlMatcher.start()) else url + val urlAbsoluteBefore = NetworkUtils.getAbsoluteURL(baseUrl, urlBefore) + return if (urlBefore.length == url.length) { + urlAbsoluteBefore + } else { + "$urlAbsoluteBefore," + url.substring(urlMatcher.end()) + } + } + + fun getFileName(): String = String.format("%05d-%s.nb", index, MD5Utils.md5Encode16(title)) + + fun getFontName(): String = String.format("%05d-%s.ttf", index, MD5Utils.md5Encode16(title)) +} + diff --git a/app/src/main/java/io/legado/app/data/entities/BookGroup.kt b/app/src/main/java/io/legado/app/data/entities/BookGroup.kt new file mode 100644 index 000000000..f19f5f05c --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookGroup.kt @@ -0,0 +1,47 @@ +package io.legado.app.data.entities + +import android.content.Context +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.legado.app.R +import io.legado.app.constant.AppConst +import kotlinx.parcelize.Parcelize + +@Parcelize +@Entity(tableName = "book_groups") +data class BookGroup( + @PrimaryKey + val groupId: Long = 0b1, + var groupName: String, + var cover: String? = null, + var order: Int = 0, + var show: Boolean = true +) : Parcelable { + + fun getManageName(context: Context): String { + return when (groupId) { + AppConst.bookGroupAllId -> "$groupName(${context.getString(R.string.all)})" + AppConst.bookGroupAudioId -> "$groupName(${context.getString(R.string.audio)})" + AppConst.bookGroupLocalId -> "$groupName(${context.getString(R.string.local)})" + AppConst.bookGroupNoneId -> "$groupName(${context.getString(R.string.no_group)})" + else -> groupName + } + } + + override fun hashCode(): Int { + return groupId.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other is BookGroup) { + return other.groupId == groupId + && other.groupName == groupName + && other.cover == cover + && other.order == order + && other.show == show + } + return false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BookProgress.kt b/app/src/main/java/io/legado/app/data/entities/BookProgress.kt new file mode 100644 index 000000000..d5fd70d10 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookProgress.kt @@ -0,0 +1,21 @@ +package io.legado.app.data.entities + +data class BookProgress( + val name: String, + val author: String, + val durChapterIndex: Int, + val durChapterPos: Int, + val durChapterTime: Long, + val durChapterTitle: String? +) { + + constructor(book: Book) : this( + name = book.name, + author = book.author, + durChapterIndex = book.durChapterIndex, + durChapterPos = book.durChapterPos, + durChapterTime = book.durChapterTime, + durChapterTitle = book.durChapterTitle + ) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BookSource.kt b/app/src/main/java/io/legado/app/data/entities/BookSource.kt new file mode 100644 index 000000000..12cf72bb9 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookSource.kt @@ -0,0 +1,202 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import android.text.TextUtils +import androidx.room.* +import io.legado.app.constant.BookType +import io.legado.app.data.entities.rule.* +import io.legado.app.help.SourceAnalyzer +import io.legado.app.utils.* +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import splitties.init.appCtx +import timber.log.Timber + +@Parcelize +@TypeConverters(BookSource.Converters::class) +@Entity( + tableName = "book_sources", + indices = [(Index(value = ["bookSourceUrl"], unique = false))] +) +data class BookSource( + @PrimaryKey + var bookSourceUrl: String = "", // 地址,包括 http/https + var bookSourceName: String = "", // 名称 + var bookSourceGroup: String? = null, // 分组 + var bookSourceType: Int = BookType.default, // 类型,0 文本,1 音频, 3 图片 + var bookUrlPattern: String? = null, // 详情页url正则 + var customOrder: Int = 0, // 手动排序编号 + var enabled: Boolean = true, // 是否启用 + var enabledExplore: Boolean = true, // 启用发现 + override var concurrentRate: String? = null, // 并发率 + override var header: String? = null, // 请求头 + override var loginUrl: String? = null, // 登录地址 + override var loginUi: String? = null, // 登录UI + var loginCheckJs: String? = null, // 登录检测js + var bookSourceComment: String? = null, // 注释 + var lastUpdateTime: Long = 0, // 最后更新时间,用于排序 + var respondTime: Long = 180000L, // 响应时间,用于排序 + var weight: Int = 0, // 智能排序的权重 + var exploreUrl: String? = null, // 发现url + var ruleExplore: ExploreRule? = null, // 发现规则 + var searchUrl: String? = null, // 搜索url + var ruleSearch: SearchRule? = null, // 搜索规则 + var ruleBookInfo: BookInfoRule? = null, // 书籍信息页规则 + var ruleToc: TocRule? = null, // 目录页规则 + var ruleContent: ContentRule? = null // 正文页规则 +) : Parcelable, BaseSource { + + override fun getTag(): String { + return bookSourceName + } + + override fun getKey(): String { + return bookSourceUrl + } + + override fun getSource(): BaseSource { + return this + } + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + val exploreKinds: List by lazy { + val exploreUrl = exploreUrl ?: return@lazy emptyList() + val kinds = arrayListOf() + var ruleStr = exploreUrl + if (ruleStr.isNotBlank()) { + kotlin.runCatching { + if (exploreUrl.startsWith("", false) + || exploreUrl.startsWith("@js:", false) + ) { + val aCache = ACache.get(appCtx, "explore") + ruleStr = aCache.getAsString(bookSourceUrl) ?: "" + if (ruleStr.isBlank()) { + val jsStr = if (exploreUrl.startsWith("@")) { + exploreUrl.substring(4) + } else { + exploreUrl.substring(4, exploreUrl.lastIndexOf("<")) + } + ruleStr = evalJS(jsStr).toString().trim() + aCache.put(bookSourceUrl, ruleStr) + } + } + if (ruleStr.isJsonArray()) { + GSON.fromJsonArray(ruleStr)?.let { + kinds.addAll(it) + } + } else { + ruleStr.split("(&&|\n)+".toRegex()).forEach { kindStr -> + val kindCfg = kindStr.split("::") + kinds.add(ExploreKind(kindCfg.first(), kindCfg.getOrNull(1))) + } + } + }.onFailure { + kinds.add(ExploreKind("ERROR:${it.localizedMessage}", it.stackTraceToString())) + Timber.e(it) + } + } + return@lazy kinds + } + + override fun hashCode(): Int { + return bookSourceUrl.hashCode() + } + + override fun equals(other: Any?) = + if (other is BookSource) other.bookSourceUrl == bookSourceUrl else false + + fun getSearchRule() = ruleSearch ?: SearchRule() + + fun getExploreRule() = ruleExplore ?: ExploreRule() + + fun getBookInfoRule() = ruleBookInfo ?: BookInfoRule() + + fun getTocRule() = ruleToc ?: TocRule() + + fun getContentRule() = ruleContent ?: ContentRule() + + fun addGroup(group: String) { + bookSourceGroup?.let { + if (!it.contains(group)) { + bookSourceGroup = "$it,$group" + } + } ?: let { + bookSourceGroup = group + } + } + + fun removeGroup(group: String) { + bookSourceGroup?.splitNotBlank("[,;,;]".toRegex())?.toHashSet()?.let { + it.remove(group) + bookSourceGroup = TextUtils.join(",", it) + } + } + + fun equal(source: BookSource) = + equal(bookSourceName, source.bookSourceName) + && equal(bookSourceUrl, source.bookSourceUrl) + && equal(bookSourceGroup, source.bookSourceGroup) + && bookSourceType == source.bookSourceType + && equal(bookUrlPattern, source.bookUrlPattern) + && equal(bookSourceComment, source.bookSourceComment) + && enabled == source.enabled + && enabledExplore == source.enabledExplore + && equal(header, source.header) + && loginUrl == source.loginUrl + && equal(exploreUrl, source.exploreUrl) + && equal(searchUrl, source.searchUrl) + && getSearchRule() == source.getSearchRule() + && getExploreRule() == source.getExploreRule() + && getBookInfoRule() == source.getBookInfoRule() + && getTocRule() == source.getTocRule() + && getContentRule() == source.getContentRule() + + private fun equal(a: String?, b: String?) = a == b || (a.isNullOrEmpty() && b.isNullOrEmpty()) + + companion object { + + fun fromJson(json: String): BookSource? { + return SourceAnalyzer.jsonToBookSource(json) + } + + fun fromJsonArray(json: String): List { + return SourceAnalyzer.jsonToBookSources(json) + } + } + + class Converters { + + @TypeConverter + fun exploreRuleToString(exploreRule: ExploreRule?): String = GSON.toJson(exploreRule) + + @TypeConverter + fun stringToExploreRule(json: String?) = GSON.fromJsonObject(json) + + @TypeConverter + fun searchRuleToString(searchRule: SearchRule?): String = GSON.toJson(searchRule) + + @TypeConverter + fun stringToSearchRule(json: String?) = GSON.fromJsonObject(json) + + @TypeConverter + fun bookInfoRuleToString(bookInfoRule: BookInfoRule?): String = GSON.toJson(bookInfoRule) + + @TypeConverter + fun stringToBookInfoRule(json: String?) = GSON.fromJsonObject(json) + + @TypeConverter + fun tocRuleToString(tocRule: TocRule?): String = GSON.toJson(tocRule) + + @TypeConverter + fun stringToTocRule(json: String?) = GSON.fromJsonObject(json) + + @TypeConverter + fun contentRuleToString(contentRule: ContentRule?): String = GSON.toJson(contentRule) + + @TypeConverter + fun stringToContentRule(json: String?) = GSON.fromJsonObject(json) + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Bookmark.kt b/app/src/main/java/io/legado/app/data/entities/Bookmark.kt new file mode 100644 index 000000000..109397042 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/Bookmark.kt @@ -0,0 +1,24 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + +@Parcelize +@Entity( + tableName = "bookmarks", + indices = [(Index(value = ["bookName", "bookAuthor"], unique = false))] +) +data class Bookmark( + @PrimaryKey + val time: Long = System.currentTimeMillis(), + val bookName: String = "", + val bookAuthor: String = "", + var chapterIndex: Int = 0, + var chapterPos: Int = 0, + var chapterName: String = "", + var bookText: String = "", + var content: String = "" +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Cache.kt b/app/src/main/java/io/legado/app/data/entities/Cache.kt new file mode 100644 index 000000000..f9687dea7 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/Cache.kt @@ -0,0 +1,13 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "caches", indices = [(Index(value = ["key"], unique = true))]) +data class Cache( + @PrimaryKey + val key: String = "", + var value: String? = null, + var deadline: Long = 0L +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Cookie.kt b/app/src/main/java/io/legado/app/data/entities/Cookie.kt new file mode 100644 index 000000000..8e12febf4 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/Cookie.kt @@ -0,0 +1,12 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "cookies", indices = [(Index(value = ["url"], unique = true))]) +data class Cookie( + @PrimaryKey + var url: String = "", + var cookie: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/HttpTTS.kt b/app/src/main/java/io/legado/app/data/entities/HttpTTS.kt new file mode 100644 index 000000000..3b1db4512 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/HttpTTS.kt @@ -0,0 +1,78 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.jayway.jsonpath.DocumentContext +import io.legado.app.utils.GSON +import io.legado.app.utils.jsonPath +import io.legado.app.utils.readLong +import io.legado.app.utils.readString + +/** + * 在线朗读引擎 + */ +@Entity(tableName = "httpTTS") +data class HttpTTS( + @PrimaryKey + val id: Long = System.currentTimeMillis(), + var name: String = "", + var url: String = "", + var contentType: String? = null, + override var concurrentRate: String? = "0", + override var loginUrl: String? = null, + override var loginUi: String? = null, + override var header: String? = null, + var loginCheckJs: String? = null, +) : BaseSource { + + override fun getTag(): String { + return name + } + + override fun getKey(): String { + return "httpTts:$id" + } + + override fun getSource(): BaseSource { + return this + } + + @Suppress("MemberVisibilityCanBePrivate") + companion object { + + fun fromJsonDoc(doc: DocumentContext): HttpTTS? { + return kotlin.runCatching { + val loginUi = doc.read("$.loginUi") + HttpTTS( + id = doc.readLong("$.id") ?: System.currentTimeMillis(), + name = doc.readString("$.name")!!, + url = doc.readString("$.url")!!, + contentType = doc.readString("$.contentType"), + concurrentRate = doc.readString("$.concurrentRate"), + loginUrl = doc.readString("$.loginUrl"), + loginUi = if (loginUi is List<*>) GSON.toJson(loginUi) else loginUi?.toString(), + header = doc.readString("$.header"), + loginCheckJs = doc.readString("$.loginCheckJs") + ) + }.getOrNull() + } + + fun fromJson(json: String): HttpTTS? { + return fromJsonDoc(jsonPath.parse(json)) + } + + fun fromJsonArray(jsonArray: String): ArrayList { + val sources = arrayListOf() + val doc = jsonPath.parse(jsonArray).read>("$") + doc.forEach { + val jsonItem = jsonPath.parse(it) + fromJsonDoc(jsonItem)?.let { source -> + sources.add(source) + } + } + return sources + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/ReadRecord.kt b/app/src/main/java/io/legado/app/data/entities/ReadRecord.kt new file mode 100644 index 000000000..2b2b5caf4 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/ReadRecord.kt @@ -0,0 +1,10 @@ +package io.legado.app.data.entities + +import androidx.room.Entity + +@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"]) +data class ReadRecord( + var deviceId: String = "", + var bookName: String = "", + var readTime: Long = 0L +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt b/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt new file mode 100644 index 000000000..79795b0d9 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt @@ -0,0 +1,6 @@ +package io.legado.app.data.entities + +data class ReadRecordShow( + var bookName: String = "", + var readTime: Long = 0L +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt b/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt new file mode 100644 index 000000000..2941a4d5a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt @@ -0,0 +1,57 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import android.text.TextUtils +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize +import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException + +@Parcelize +@Entity( + tableName = "replace_rules", + indices = [(Index(value = ["id"]))] +) +data class ReplaceRule( + @PrimaryKey(autoGenerate = true) + var id: Long = System.currentTimeMillis(), + var name: String = "", + var group: String? = null, + var pattern: String = "", + var replacement: String = "", + var scope: String? = null, + var isEnabled: Boolean = true, + var isRegex: Boolean = true, + @ColumnInfo(name = "sortOrder") + var order: Int = 0 +) : Parcelable { + + override fun equals(other: Any?): Boolean { + if (other is ReplaceRule) { + return other.id == id + } + return super.equals(other) + } + + override fun hashCode(): Int { + return id.hashCode() + } + + fun isValid(): Boolean { + if (TextUtils.isEmpty(pattern)) { + return false + } + //判断正则表达式是否正确 + if (isRegex) { + try { + Pattern.compile(pattern) + } catch (ex: PatternSyntaxException) { + return false + } + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssArticle.kt b/app/src/main/java/io/legado/app/data/entities/RssArticle.kt new file mode 100644 index 000000000..911fb16b8 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RssArticle.kt @@ -0,0 +1,63 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Ignore +import io.legado.app.model.analyzeRule.RuleDataInterface +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import kotlinx.parcelize.IgnoredOnParcel + + +@Entity( + tableName = "rssArticles", + primaryKeys = ["origin", "link"] +) +data class RssArticle( + var origin: String = "", + var sort: String = "", + var title: String = "", + var order: Long = 0, + var link: String = "", + var pubDate: String? = null, + var description: String? = null, + var content: String? = null, + var image: String? = null, + var read: Boolean = false, + var variable: String? = null +) : RuleDataInterface { + + override fun hashCode() = link.hashCode() + + override fun equals(other: Any?): Boolean { + other ?: return false + return if (other is RssArticle) origin == other.origin && link == other.link else false + } + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + override val variableMap by lazy { + GSON.fromJsonObject>(variable) ?: HashMap() + } + + override fun putVariable(key: String, value: String?) { + if (value != null) { + variableMap[key] = value + } else { + variableMap.remove(key) + } + variable = GSON.toJson(variableMap) + } + + fun toStar() = RssStar( + origin = origin, + sort = sort, + title = title, + starTime = System.currentTimeMillis(), + link = link, + pubDate = pubDate, + description = description, + content = content, + image = image + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt b/app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt new file mode 100644 index 000000000..edbb4c15d --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt @@ -0,0 +1,7 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "rssReadRecords") +data class RssReadRecord(@PrimaryKey val record: String, val read: Boolean = true) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssSource.kt b/app/src/main/java/io/legado/app/data/entities/RssSource.kt new file mode 100644 index 000000000..78830285a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RssSource.kt @@ -0,0 +1,170 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.jayway.jsonpath.DocumentContext +import io.legado.app.utils.* +import kotlinx.parcelize.Parcelize +import splitties.init.appCtx + +@Parcelize +@Entity(tableName = "rssSources", indices = [(Index(value = ["sourceUrl"], unique = false))]) +data class RssSource( + @PrimaryKey + var sourceUrl: String = "", + var sourceName: String = "", + var sourceIcon: String = "", + var sourceGroup: String? = null, + var sourceComment: String? = null, + var enabled: Boolean = true, + override var concurrentRate: String? = null, //并发率 + override var header: String? = null, // 请求头 + override var loginUrl: String? = null, // 登录地址 + override var loginUi: String? = null, //登录UI + var loginCheckJs: String? = null, //登录检测js + var sortUrl: String? = null, + var singleUrl: Boolean = false, + //列表规则 + var articleStyle: Int = 0, //列表样式,0,1,2 + var ruleArticles: String? = null, + var ruleNextPage: String? = null, + var ruleTitle: String? = null, + var rulePubDate: String? = null, + //webView规则 + var ruleDescription: String? = null, + var ruleImage: String? = null, + var ruleLink: String? = null, + var ruleContent: String? = null, + var style: String? = null, + var enableJs: Boolean = true, + var loadWithBaseUrl: Boolean = true, + var customOrder: Int = 0 +) : Parcelable, BaseSource { + + override fun getTag(): String { + return sourceName + } + + override fun getKey(): String { + return sourceUrl + } + + override fun getSource(): BaseSource { + return this + } + + override fun equals(other: Any?): Boolean { + if (other is RssSource) { + return other.sourceUrl == sourceUrl + } + return false + } + + override fun hashCode() = sourceUrl.hashCode() + + fun equal(source: RssSource): Boolean { + return equal(sourceUrl, source.sourceUrl) + && equal(sourceIcon, source.sourceIcon) + && enabled == source.enabled + && equal(sourceGroup, source.sourceGroup) + && equal(ruleArticles, source.ruleArticles) + && equal(ruleNextPage, source.ruleNextPage) + && equal(ruleTitle, source.ruleTitle) + && equal(rulePubDate, source.rulePubDate) + && equal(ruleDescription, source.ruleDescription) + && equal(ruleLink, source.ruleLink) + && equal(ruleContent, source.ruleContent) + && enableJs == source.enableJs + && loadWithBaseUrl == source.loadWithBaseUrl + } + + private fun equal(a: String?, b: String?): Boolean { + return a == b || (a.isNullOrEmpty() && b.isNullOrEmpty()) + } + + fun sortUrls(): List> = arrayListOf>().apply { + kotlin.runCatching { + var a = sortUrl + if (sortUrl?.startsWith("", false) == true + || sortUrl?.startsWith("@js:", false) == true + ) { + val aCache = ACache.get(appCtx, "rssSortUrl") + a = aCache.getAsString(sourceUrl) ?: "" + if (a.isBlank()) { + val jsStr = if (sortUrl!!.startsWith("@")) { + sortUrl!!.substring(4) + } else { + sortUrl!!.substring(4, sortUrl!!.lastIndexOf("<")) + } + a = evalJS(jsStr).toString() + aCache.put(sourceUrl, a) + } + } + a?.split("(&&|\n)+".toRegex())?.forEach { c -> + val d = c.split("::") + if (d.size > 1) + add(Pair(d[0], d[1])) + } + if (isEmpty()) { + add(Pair("", sourceUrl)) + } + } + } + + @Suppress("MemberVisibilityCanBePrivate") + companion object { + + fun fromJsonDoc(doc: DocumentContext): RssSource? { + return kotlin.runCatching { + val loginUi = doc.read("$.loginUi") + RssSource( + sourceUrl = doc.readString("$.sourceUrl")!!, + sourceName = doc.readString("$.sourceName")!!, + sourceIcon = doc.readString("$.sourceIcon") ?: "", + sourceGroup = doc.readString("$.sourceGroup"), + sourceComment = doc.readString("$.sourceComment"), + enabled = doc.readBool("$.enabled") ?: true, + concurrentRate = doc.readString("$.concurrentRate"), + header = doc.readString("$.header"), + loginUrl = doc.readString("$.loginUrl"), + loginUi = if (loginUi is List<*>) GSON.toJson(loginUi) else loginUi?.toString(), + loginCheckJs = doc.readString("$.loginCheckJs"), + sortUrl = doc.readString("$.sortUrl"), + singleUrl = doc.readBool("$.singleUrl") ?: false, + articleStyle = doc.readInt("$.articleStyle") ?: 0, + ruleArticles = doc.readString("$.ruleArticles"), + ruleNextPage = doc.readString("$.ruleNextPage"), + ruleTitle = doc.readString("$.ruleTitle"), + rulePubDate = doc.readString("$.rulePubDate"), + ruleDescription = doc.readString("$.ruleDescription"), + ruleImage = doc.readString("$.ruleImage"), + ruleLink = doc.readString("$.ruleLink"), + ruleContent = doc.readString("$.ruleContent"), + style = doc.readString("$.style"), + enableJs = doc.readBool("$.enableJs") ?: true, + loadWithBaseUrl = doc.readBool("$.loadWithBaseUrl") ?: true, + customOrder = doc.readInt("$.customOrder") ?: 0 + ) + }.getOrNull() + } + + fun fromJson(json: String): RssSource? { + return fromJsonDoc(jsonPath.parse(json)) + } + + fun fromJsonArray(jsonArray: String): ArrayList { + val sources = arrayListOf() + val doc = jsonPath.parse(jsonArray).read>("$") + doc.forEach { + val jsonItem = jsonPath.parse(it) + fromJsonDoc(jsonItem)?.let { source -> + sources.add(source) + } + } + return sources + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssStar.kt b/app/src/main/java/io/legado/app/data/entities/RssStar.kt new file mode 100644 index 000000000..df4bd546f --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RssStar.kt @@ -0,0 +1,54 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Ignore +import io.legado.app.model.analyzeRule.RuleDataInterface +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import kotlinx.parcelize.IgnoredOnParcel + + +@Entity( + tableName = "rssStars", + primaryKeys = ["origin", "link"] +) +data class RssStar( + var origin: String = "", + var sort: String = "", + var title: String = "", + var starTime: Long = 0, + var link: String = "", + var pubDate: String? = null, + var description: String? = null, + var content: String? = null, + var image: String? = null, + var variable: String? = null +) : RuleDataInterface { + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + override val variableMap by lazy { + GSON.fromJsonObject>(variable) ?: HashMap() + } + + override fun putVariable(key: String, value: String?) { + if (value != null) { + variableMap[key] = value + } else { + variableMap.remove(key) + } + variable = GSON.toJson(variableMap) + } + + fun toRssArticle() = RssArticle( + origin = origin, + sort = sort, + title = title, + link = link, + pubDate = pubDate, + description = description, + content = content, + image = image + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RuleSub.kt b/app/src/main/java/io/legado/app/data/entities/RuleSub.kt new file mode 100644 index 000000000..2b43787fe --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RuleSub.kt @@ -0,0 +1,16 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "ruleSubs") +data class RuleSub( + @PrimaryKey + val id: Long = System.currentTimeMillis(), + var name: String = "", + var url: String = "", + var type: Int = 0, + var customOrder: Int = 0, + var autoUpdate: Boolean = false, + var update: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/io/legado/app/data/entities/SearchBook.kt b/app/src/main/java/io/legado/app/data/entities/SearchBook.kt new file mode 100644 index 000000000..96c525b3a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/SearchBook.kt @@ -0,0 +1,121 @@ +package io.legado.app.data.entities + +import android.content.Context +import android.os.Parcelable +import androidx.room.* +import io.legado.app.R +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +@Entity( + tableName = "searchBooks", + indices = [(Index(value = ["bookUrl"], unique = true)), + (Index(value = ["origin"], unique = false))], + foreignKeys = [(ForeignKey( + entity = BookSource::class, + parentColumns = ["bookSourceUrl"], + childColumns = ["origin"], + onDelete = ForeignKey.CASCADE + ))] +) +data class SearchBook( + @PrimaryKey + override var bookUrl: String = "", + var origin: String = "", // 书源规则 + var originName: String = "", + var type: Int = 0, // @BookType + override var name: String = "", + override var author: String = "", + override var kind: String? = null, + var coverUrl: String? = null, + var intro: String? = null, + override var wordCount: String? = null, + var latestChapterTitle: String? = null, + var tocUrl: String = "", // 目录页Url (toc=table of Contents) + var time: Long = System.currentTimeMillis(), + var variable: String? = null, + var originOrder: Int = 0 +) : Parcelable, BaseBook, Comparable { + + @Ignore + @IgnoredOnParcel + override var infoHtml: String? = null + + @Ignore + @IgnoredOnParcel + override var tocHtml: String? = null + + override fun equals(other: Any?) = other is SearchBook && other.bookUrl == bookUrl + + override fun hashCode() = bookUrl.hashCode() + + override fun compareTo(other: SearchBook): Int { + return other.originOrder - this.originOrder + } + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + override val variableMap by lazy { + GSON.fromJsonObject>(variable) ?: HashMap() + } + + override fun putVariable(key: String, value: String?) { + if (value != null) { + variableMap[key] = value + } else { + variableMap.remove(key) + } + variable = GSON.toJson(variableMap) + } + + @delegate:Transient + @delegate:Ignore + @IgnoredOnParcel + val origins: LinkedHashSet by lazy { linkedSetOf(origin) } + + fun addOrigin(origin: String) { + origins.add(origin) + } + + fun getDisplayLastChapterTitle(): String { + latestChapterTitle?.let { + if (it.isNotEmpty()) { + return it + } + } + return "无最新章节" + } + + fun trimIntro(context: Context): String { + val trimIntro = intro?.trim() + return if (trimIntro.isNullOrEmpty()) { + context.getString(R.string.intro_show_null) + } else { + context.getString(R.string.intro_show, trimIntro) + } + } + + fun toBook() = Book( + name = name, + author = author, + kind = kind, + bookUrl = bookUrl, + origin = origin, + originName = originName, + type = type, + wordCount = wordCount, + latestChapterTitle = latestChapterTitle, + coverUrl = coverUrl, + intro = intro, + tocUrl = tocUrl, + originOrder = originOrder, + variable = variable + ).apply { + this.infoHtml = this@SearchBook.infoHtml + this.tocUrl = this@SearchBook.tocUrl + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt b/app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt new file mode 100644 index 000000000..955e5fe3d --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt @@ -0,0 +1,17 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + + +@Parcelize +@Entity(tableName = "search_keywords", indices = [(Index(value = ["word"], unique = true))]) +data class SearchKeyword( + @PrimaryKey + var word: String = "", // 搜索关键词 + var usage: Int = 1, // 使用次数 + var lastUseTime: Long = System.currentTimeMillis() // 最后一次使用时间 +) : Parcelable diff --git a/app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt b/app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt new file mode 100644 index 000000000..54429bbf3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/TxtTocRule.kt @@ -0,0 +1,21 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey + + +@Entity(tableName = "txtTocRules") +data class TxtTocRule( + @PrimaryKey + var id: Long = System.currentTimeMillis(), + var name: String = "", + var rule: String = "", + var serialNumber: Int = -1, + var enable: Boolean = true +) { + + @Ignore + constructor() : this(name = "") + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt new file mode 100644 index 000000000..9f3a0c9d0 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt @@ -0,0 +1,20 @@ +package io.legado.app.data.entities.rule + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + + +@Parcelize +data class BookInfoRule( + var init: String? = null, + var name: String? = null, + var author: String? = null, + var intro: String? = null, + var kind: String? = null, + var lastChapter: String? = null, + var updateTime: String? = null, + var coverUrl: String? = null, + var tocUrl: String? = null, + var wordCount: String? = null, + var canReName: String? = null +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt new file mode 100644 index 000000000..31676052d --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt @@ -0,0 +1,14 @@ +package io.legado.app.data.entities.rule + +interface BookListRule { + var bookList: String? + var name: String? + var author: String? + var intro: String? + var kind: String? + var lastChapter: String? + var updateTime: String? + var bookUrl: String? + var coverUrl: String? + var wordCount: String? +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt new file mode 100644 index 000000000..4016c455e --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt @@ -0,0 +1,15 @@ +package io.legado.app.data.entities.rule + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ContentRule( + var content: String? = null, + var nextContentUrl: String? = null, + var webJs: String? = null, + var sourceRegex: String? = null, + var replaceRegex: String? = null, //替换规则 + var imageStyle: String? = null, //默认大小居中,FULL最大宽度 + var payAction: String? = null, //购买操作,url/js +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt b/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt new file mode 100644 index 000000000..49fe4937a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt @@ -0,0 +1,39 @@ +package io.legado.app.data.entities.rule + +data class ExploreKind( + val title: String, + val url: String? = null, + val style: Style? = null +) { + + companion object { + val defaultStyle = Style() + } + + fun style(): Style { + return style ?: defaultStyle + } + + data class Style( + val layout_flexGrow: Float = 0F, + val layout_flexShrink: Float = 1F, + val layout_alignSelf: String = "auto", + val layout_flexBasisPercent: Float = -1F, + val layout_wrapBefore: Boolean = false, + ) { + + fun alignSelf(): Int { + return when (layout_alignSelf) { + "auto" -> -1 + "flex_start" -> 0 + "flex_end" -> 1 + "center" -> 2 + "baseline" -> 3 + "stretch" -> 4 + else -> -1 + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt new file mode 100644 index 000000000..bc2a425f5 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt @@ -0,0 +1,18 @@ +package io.legado.app.data.entities.rule + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ExploreRule( + override var bookList: String? = null, + override var name: String? = null, + override var author: String? = null, + override var intro: String? = null, + override var kind: String? = null, + override var lastChapter: String? = null, + override var updateTime: String? = null, + override var bookUrl: String? = null, + override var coverUrl: String? = null, + override var wordCount: String? = null +) : BookListRule, Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt b/app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt new file mode 100644 index 000000000..e983d1a44 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt @@ -0,0 +1,11 @@ +package io.legado.app.data.entities.rule + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RowUi( + var name: String, + var type: String?, + var action: String? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt new file mode 100644 index 000000000..47969369f --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt @@ -0,0 +1,20 @@ +package io.legado.app.data.entities.rule + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + + +@Parcelize +data class SearchRule( + var checkKeyWord: String? = null, // 校验关键字 + override var bookList: String? = null, + override var name: String? = null, + override var author: String? = null, + override var intro: String? = null, + override var kind: String? = null, + override var lastChapter: String? = null, + override var updateTime: String? = null, + override var bookUrl: String? = null, + override var coverUrl: String? = null, + override var wordCount: String? = null +) : BookListRule, Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt new file mode 100644 index 000000000..a368b8c09 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt @@ -0,0 +1,15 @@ +package io.legado.app.data.entities.rule + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TocRule( + var chapterList: String? = null, + var chapterName: String? = null, + var chapterUrl: String? = null, + var isVip: String? = null, + var isPay: String? = null, + var updateTime: String? = null, + var nextTocUrl: String? = null +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/AppConfig.kt b/app/src/main/java/io/legado/app/help/AppConfig.kt new file mode 100644 index 000000000..b7229d709 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/AppConfig.kt @@ -0,0 +1,279 @@ +package io.legado.app.help + +import android.content.Context +import android.content.SharedPreferences +import io.legado.app.constant.AppConst +import io.legado.app.constant.PreferKey +import io.legado.app.utils.* +import splitties.init.appCtx + +@Suppress("MemberVisibilityCanBePrivate") +object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { + val isGooglePlay = appCtx.channel == "google" + val isCronet = appCtx.getPrefBoolean("Cronet") + var userAgent: String = getPrefUserAgent() + var isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3" + var clickActionTL = appCtx.getPrefInt(PreferKey.clickActionTL, 2) + var clickActionTC = appCtx.getPrefInt(PreferKey.clickActionTC, 2) + var clickActionTR = appCtx.getPrefInt(PreferKey.clickActionTR, 1) + var clickActionML = appCtx.getPrefInt(PreferKey.clickActionML, 2) + var clickActionMC = appCtx.getPrefInt(PreferKey.clickActionMC, 0) + var clickActionMR = appCtx.getPrefInt(PreferKey.clickActionMR, 1) + var clickActionBL = appCtx.getPrefInt(PreferKey.clickActionBL, 2) + var clickActionBC = appCtx.getPrefInt(PreferKey.clickActionBC, 1) + var clickActionBR = appCtx.getPrefInt(PreferKey.clickActionBR, 1) + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PreferKey.themeMode -> isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3" + PreferKey.clickActionTL -> clickActionTL = + appCtx.getPrefInt(PreferKey.clickActionTL, 2) + PreferKey.clickActionTC -> clickActionTC = + appCtx.getPrefInt(PreferKey.clickActionTC, 2) + PreferKey.clickActionTR -> clickActionTR = + appCtx.getPrefInt(PreferKey.clickActionTR, 2) + PreferKey.clickActionML -> clickActionML = + appCtx.getPrefInt(PreferKey.clickActionML, 2) + PreferKey.clickActionMC -> clickActionMC = + appCtx.getPrefInt(PreferKey.clickActionMC, 2) + PreferKey.clickActionMR -> clickActionMR = + appCtx.getPrefInt(PreferKey.clickActionMR, 2) + PreferKey.clickActionBL -> clickActionBL = + appCtx.getPrefInt(PreferKey.clickActionBL, 2) + PreferKey.clickActionBC -> clickActionBC = + appCtx.getPrefInt(PreferKey.clickActionBC, 2) + PreferKey.clickActionBR -> clickActionBR = + appCtx.getPrefInt(PreferKey.clickActionBR, 2) + PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh = + appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) + PreferKey.useZhLayout -> ReadBookConfig.useZhLayout = + appCtx.getPrefBoolean(PreferKey.useZhLayout) + PreferKey.userAgent -> userAgent = getPrefUserAgent() + } + } + + fun isNightTheme(context: Context): Boolean { + return when (context.getPrefString(PreferKey.themeMode, "0")) { + "1" -> false + "2" -> true + "3" -> false + else -> sysConfiguration.isNightMode + } + } + + var isNightTheme: Boolean + get() = isNightTheme(appCtx) + set(value) { + if (isNightTheme != value) { + if (value) { + appCtx.putPrefString(PreferKey.themeMode, "2") + } else { + appCtx.putPrefString(PreferKey.themeMode, "1") + } + } + } + + var showUnread: Boolean + get() = appCtx.getPrefBoolean(PreferKey.showUnread, true) + set(value) { + appCtx.putPrefBoolean(PreferKey.showUnread, value) + } + + var readBrightness: Int + get() = if (isNightTheme) { + appCtx.getPrefInt(PreferKey.nightBrightness, 100) + } else { + appCtx.getPrefInt(PreferKey.brightness, 100) + } + set(value) { + if (isNightTheme) { + appCtx.putPrefInt(PreferKey.nightBrightness, value) + } else { + appCtx.putPrefInt(PreferKey.brightness, value) + } + } + + val useDefaultCover: Boolean + get() = appCtx.getPrefBoolean(PreferKey.useDefaultCover, false) + + val isTransparentStatusBar: Boolean + get() = appCtx.getPrefBoolean(PreferKey.transparentStatusBar, true) + + val immNavigationBar: Boolean + get() = appCtx.getPrefBoolean(PreferKey.immNavigationBar, true) + + val screenOrientation: String? + get() = appCtx.getPrefString(PreferKey.screenOrientation) + + var bookGroupStyle: Int + get() = appCtx.getPrefInt(PreferKey.bookGroupStyle, 0) + set(value) { + appCtx.putPrefInt(PreferKey.bookGroupStyle, value) + } + + var bookExportFileName: String? + get() = appCtx.getPrefString(PreferKey.bookExportFileName) + set(value) { + appCtx.putPrefString(PreferKey.bookExportFileName, value) + } + + var bookImportFileName: String? + get() = appCtx.getPrefString(PreferKey.bookImportFileName) + set(value) { + appCtx.putPrefString(PreferKey.bookImportFileName, value) + } + + var backupPath: String? + get() = appCtx.getPrefString(PreferKey.backupPath) + set(value) { + if (value.isNullOrEmpty()) { + appCtx.removePref(PreferKey.backupPath) + } else { + appCtx.putPrefString(PreferKey.backupPath, value) + } + } + + var defaultBookTreeUri: String? + get() = appCtx.getPrefString(PreferKey.defaultBookTreeUri) + set(value) { + if (value.isNullOrEmpty()) { + appCtx.removePref(PreferKey.defaultBookTreeUri) + } else { + appCtx.putPrefString(PreferKey.defaultBookTreeUri, value) + } + } + + val showDiscovery: Boolean + get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true) + + val showRSS: Boolean + get() = appCtx.getPrefBoolean(PreferKey.showRss, true) + + val autoRefreshBook: Boolean + get() = appCtx.getPrefBoolean(PreferKey.autoRefresh) + + var threadCount: Int + get() = appCtx.getPrefInt(PreferKey.threadCount, 16) + set(value) { + appCtx.putPrefInt(PreferKey.threadCount, value) + } + + var importBookPath: String? + get() = appCtx.getPrefString("importBookPath") + set(value) { + if (value == null) { + appCtx.removePref("importBookPath") + } else { + appCtx.putPrefString("importBookPath", value) + } + } + + var ttsSpeechRate: Int + get() = appCtx.getPrefInt(PreferKey.ttsSpeechRate, 5) + set(value) { + appCtx.putPrefInt(PreferKey.ttsSpeechRate, value) + } + + var chineseConverterType: Int + get() = appCtx.getPrefInt(PreferKey.chineseConverterType) + set(value) { + appCtx.putPrefInt(PreferKey.chineseConverterType, value) + } + + var systemTypefaces: Int + get() = appCtx.getPrefInt(PreferKey.systemTypefaces) + set(value) { + appCtx.putPrefInt(PreferKey.systemTypefaces, value) + } + + var elevation: Int + get() = appCtx.getPrefInt(PreferKey.barElevation, AppConst.sysElevation) + set(value) { + appCtx.putPrefInt(PreferKey.barElevation, value) + } + + var readUrlInBrowser: Boolean + get() = appCtx.getPrefBoolean(PreferKey.readUrlOpenInBrowser) + set(value) { + appCtx.putPrefBoolean(PreferKey.readUrlOpenInBrowser, value) + } + + var exportCharset: String + get() { + val c = appCtx.getPrefString(PreferKey.exportCharset) + if (c.isNullOrBlank()) { + return "UTF-8" + } + return c + } + set(value) { + appCtx.putPrefString(PreferKey.exportCharset, value) + } + + var exportUseReplace: Boolean + get() = appCtx.getPrefBoolean(PreferKey.exportUseReplace, true) + set(value) { + appCtx.putPrefBoolean(PreferKey.exportUseReplace, value) + } + + var exportToWebDav: Boolean + get() = appCtx.getPrefBoolean(PreferKey.exportToWebDav) + set(value) { + appCtx.putPrefBoolean(PreferKey.exportToWebDav, value) + } + var exportNoChapterName: Boolean + get() = appCtx.getPrefBoolean(PreferKey.exportNoChapterName) + set(value) { + appCtx.putPrefBoolean(PreferKey.exportNoChapterName, value) + } + var exportType: Int + get() = appCtx.getPrefInt(PreferKey.exportType) + set(value) { + appCtx.putPrefInt(PreferKey.exportType, value) + } + + var changeSourceCheckAuthor: Boolean + get() = appCtx.getPrefBoolean(PreferKey.changeSourceCheckAuthor) + set(value) { + appCtx.putPrefBoolean(PreferKey.changeSourceCheckAuthor, value) + } + + var ttsEngine: String? + get() = appCtx.getPrefString(PreferKey.ttsEngine) + set(value) { + appCtx.putPrefString(PreferKey.ttsEngine, value) + } + + val autoChangeSource: Boolean + get() = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true) + + val changeSourceLoadInfo get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo) + + val changeSourceLoadToc get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadToc) + + val importKeepName get() = appCtx.getPrefBoolean(PreferKey.importKeepName) + + val syncBookProgress get() = appCtx.getPrefBoolean(PreferKey.syncBookProgress, true) + + var preDownloadNum + get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10) + set(value) { + appCtx.putPrefInt(PreferKey.preDownloadNum, value) + } + + val mediaButtonOnExit get() = appCtx.getPrefBoolean("mediaButtonOnExit", true) + + val replaceEnableDefault get() = appCtx.getPrefBoolean(PreferKey.replaceEnableDefault, true) + + val doublePageHorizontal: Boolean + get() = appCtx.getPrefBoolean(PreferKey.doublePageHorizontal, true) + + private fun getPrefUserAgent(): String { + val ua = appCtx.getPrefString(PreferKey.userAgent) + if (ua.isNullOrBlank()) { + return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + return ua + } +} + diff --git a/app/src/main/java/io/legado/app/help/AppUpdate.kt b/app/src/main/java/io/legado/app/help/AppUpdate.kt new file mode 100644 index 000000000..c19713d6c --- /dev/null +++ b/app/src/main/java/io/legado/app/help/AppUpdate.kt @@ -0,0 +1,53 @@ +package io.legado.app.help + +import io.legado.app.constant.AppConst +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.http.newCallStrResponse +import io.legado.app.help.http.okHttpClient +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.jsonPath +import io.legado.app.utils.readString +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.CoroutineScope +import splitties.init.appCtx + +object AppUpdate { + + fun checkFromGitHub( + scope: CoroutineScope, + showErrorMsg: Boolean = true, + callback: (newVersion: String, updateBody: String, url: String, fileName: String) -> Unit + ) { + Coroutine.async(scope) { + val lastReleaseUrl = "https://api.github.com/repos/gedoor/legado/releases/latest" + val body = okHttpClient.newCallStrResponse { + url(lastReleaseUrl) + }.body + if (body.isNullOrBlank()) { + throw NoStackTraceException("获取新版本出错") + } + val rootDoc = jsonPath.parse(body) + val tagName = rootDoc.readString("$.tag_name") + ?: throw NoStackTraceException("获取新版本出错") + if (tagName > AppConst.appInfo.versionName) { + val updateBody = rootDoc.readString("$.body") + ?: throw NoStackTraceException("获取新版本出错") + val downloadUrl = rootDoc.readString("$.assets[0].browser_download_url") + ?: throw NoStackTraceException("获取新版本出错") + val fileName = rootDoc.readString("$.assets[0].name") + ?: throw NoStackTraceException("获取新版本出错") + return@async arrayOf(tagName, updateBody, downloadUrl, fileName) + } else { + throw NoStackTraceException("已是最新版本") + } + }.timeout(10000) + .onSuccess { + callback.invoke(it[0], it[1], it[2], it[3]) + }.onError { + if (showErrorMsg) { + appCtx.toastOnUi("检测更新\n${it.localizedMessage}") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/BlurTransformation.kt b/app/src/main/java/io/legado/app/help/BlurTransformation.kt new file mode 100644 index 000000000..af4ab506a --- /dev/null +++ b/app/src/main/java/io/legado/app/help/BlurTransformation.kt @@ -0,0 +1,70 @@ +package io.legado.app.help + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import java.security.MessageDigest +import kotlin.math.min +import kotlin.math.roundToInt + + +/** + * 模糊 + * @radius: 0..25 + */ +class BlurTransformation(context: Context, private val radius: Int) : CenterCrop() { + private val rs: RenderScript = RenderScript.create(context) + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + override fun transform( + pool: BitmapPool, + toTransform: Bitmap, + outWidth: Int, + outHeight: Int + ): Bitmap { + val transform = super.transform(pool, toTransform, outWidth, outHeight) + //图片缩小1/2 + val width = (min(outWidth, transform.width) / 2f).roundToInt() + val height = (min(outHeight, transform.height) / 2f).roundToInt() + val blurredBitmap = Bitmap.createScaledBitmap(transform, width, height, false) + // Allocate memory for Renderscript to work with + //分配用于渲染脚本的内存 + val input = Allocation.createFromBitmap( + rs, + blurredBitmap, + Allocation.MipmapControl.MIPMAP_FULL, + Allocation.USAGE_SHARED + ) + val output = Allocation.createTyped(rs, input.type) + + // Load up an instance of the specific script that we want to use. + //加载我们想要使用的特定脚本的实例。 + val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + script.setInput(input) + + // Set the blur radius + //设置模糊半径0..25 + script.setRadius(radius.toFloat()) + + // Start the ScriptIntrinsicBlur + //启动 ScriptIntrinsicBlur, + script.forEach(output) + + // Copy the output to the blurred bitmap + //将输出复制到模糊的位图 + output.copyTo(blurredBitmap) + + return blurredBitmap + } + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update("blur transformation".toByteArray()) + } +} diff --git a/app/src/main/java/io/legado/app/help/BookHelp.kt b/app/src/main/java/io/legado/app/help/BookHelp.kt new file mode 100644 index 000000000..63dc4464d --- /dev/null +++ b/app/src/main/java/io/legado/app/help/BookHelp.kt @@ -0,0 +1,401 @@ +package io.legado.app.help + +import io.legado.app.constant.AppPattern +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.localBook.LocalBook +import io.legado.app.utils.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import org.apache.commons.text.similarity.JaccardSimilarity +import splitties.init.appCtx +import timber.log.Timber +import java.io.File +import java.util.concurrent.CopyOnWriteArraySet +import java.util.regex.Pattern +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +@Suppress("unused") +object BookHelp { + val downloadDir: File = appCtx.externalFiles + const val cacheFolderName = "book_cache" + private const val cacheImageFolderName = "images" + private val downloadImages = CopyOnWriteArraySet() + + fun clearCache() { + FileUtils.deleteFile( + FileUtils.getPath(downloadDir, cacheFolderName) + ) + } + + fun clearCache(book: Book) { + val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName()) + FileUtils.deleteFile(filePath) + } + + /** + * 清除已删除书的缓存 + */ + fun clearRemovedCache() { + Coroutine.async { + val bookFolderNames = arrayListOf() + appDb.bookDao.all.forEach { + bookFolderNames.add(it.getFolderName()) + } + val file = downloadDir.getFile(cacheFolderName) + file.listFiles()?.forEach { bookFile -> + if (!bookFolderNames.contains(bookFile.name)) { + FileUtils.deleteFile(bookFile.absolutePath) + } + } + } + } + + suspend fun saveContent( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + bookChapter: BookChapter, + content: String + ) { + saveText(book, bookChapter, content) + saveImages(scope, bookSource, book, bookChapter, content) + postEvent(EventBus.SAVE_CONTENT, bookChapter) + } + + private fun saveText( + book: Book, + bookChapter: BookChapter, + content: String + ) { + if (content.isEmpty()) return + //保存文本 + FileUtils.createFileIfNotExist( + downloadDir, + cacheFolderName, + book.getFolderName(), + bookChapter.getFileName(), + ).writeText(content) + } + + private suspend fun saveImages( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + bookChapter: BookChapter, + content: String + ) { + val awaitList = arrayListOf>() + content.split("\n").forEach { + val matcher = AppPattern.imgPattern.matcher(it) + if (matcher.find()) { + matcher.group(1)?.let { src -> + val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src) + awaitList.add(scope.async { + saveImage(bookSource, book, mSrc) + }) + } + } + } + awaitList.forEach { + it.await() + } + } + + suspend fun saveImage(bookSource: BookSource?, book: Book, src: String) { + while (downloadImages.contains(src)) { + delay(100) + } + if (getImage(book, src).exists()) { + return + } + downloadImages.add(src) + val analyzeUrl = AnalyzeUrl(src, source = bookSource) + try { + analyzeUrl.getByteArrayAwait().let { + FileUtils.createFileIfNotExist( + downloadDir, + cacheFolderName, + book.getFolderName(), + cacheImageFolderName, + "${MD5Utils.md5Encode16(src)}${getImageSuffix(src)}" + ).writeBytes(it) + } + } catch (e: Exception) { + Timber.e(e) + } finally { + downloadImages.remove(src) + } + } + + fun getImage(book: Book, src: String): File { + return downloadDir.getFile( + cacheFolderName, + book.getFolderName(), + cacheImageFolderName, + "${MD5Utils.md5Encode16(src)}${getImageSuffix(src)}" + ) + } + + fun getImageSuffix(src: String): String { + var suffix = src.substringAfterLast(".").substringBefore(",") + if (suffix.length > 5) { + suffix = ".jpg" + } + return suffix + } + + fun getChapterFiles(book: Book): List { + val fileNameList = arrayListOf() + if (book.isLocalTxt()) { + return fileNameList + } + FileUtils.createFolderIfNotExist( + downloadDir, + subDirs = arrayOf(cacheFolderName, book.getFolderName()) + ).list()?.let { + fileNameList.addAll(it) + } + return fileNameList + } + + /** + * 检测该章节是否下载 + */ + fun hasContent(book: Book, bookChapter: BookChapter): Boolean { + return if (book.isLocalTxt()) { + true + } else { + downloadDir.exists( + cacheFolderName, + book.getFolderName(), + bookChapter.getFileName() + ) + } + } + + /** + * 检测图片是否下载 + */ + fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean { + if (!hasContent(book, bookChapter)) { + return false + } + getContent(book, bookChapter)?.let { + val matcher = AppPattern.imgPattern.matcher(it) + while (matcher.find()) { + matcher.group(1)?.let { src -> + val image = getImage(book, src) + if (!image.exists()) { + return false + } + } + } + } + return true + } + + /** + * 读取章节内容 + */ + fun getContent(book: Book, bookChapter: BookChapter): String? { + if (book.isLocalTxt() || book.isUmd()) { + return LocalBook.getContent(book, bookChapter) + } else if (book.isEpub() && !hasContent(book, bookChapter)) { + val string = LocalBook.getContent(book, bookChapter) + string?.let { + saveText(book, bookChapter, it) + } + return string + } else { + val file = downloadDir.getFile( + cacheFolderName, + book.getFolderName(), + bookChapter.getFileName() + ) + if (file.exists()) { + return file.readText() + } + } + return null + } + + /** + * 反转章节内容 + */ + fun reverseContent(book: Book, bookChapter: BookChapter) { + if (!book.isLocalBook()) { + val file = downloadDir.getFile( + cacheFolderName, + book.getFolderName(), + bookChapter.getFileName() + ) + if (file.exists()) { + val text = file.readText() + val stringBuilder = StringBuilder() + text.toStringArray().forEach { + stringBuilder.insert(0, it) + } + file.writeText(stringBuilder.toString()) + } + } + } + + /** + * 删除章节内容 + */ + fun delContent(book: Book, bookChapter: BookChapter) { + if (book.isLocalTxt()) { + return + } else { + FileUtils.createFileIfNotExist( + downloadDir, + cacheFolderName, + book.getFolderName(), + bookChapter.getFileName() + ).delete() + } + } + + /** + * 格式化书名 + */ + fun formatBookName(name: String): String { + return name + .replace(AppPattern.nameRegex, "") + .trim { it <= ' ' } + } + + /** + * 格式化作者 + */ + fun formatBookAuthor(author: String): String { + return author + .replace(AppPattern.authorRegex, "") + .trim { it <= ' ' } + } + + private val jaccardSimilarity by lazy { + JaccardSimilarity() + } + + /** + * 根据目录名获取当前章节 + */ + fun getDurChapter( + oldDurChapterIndex: Int, + oldChapterListSize: Int, + oldDurChapterName: String?, + newChapterList: List + ): Int { + if (oldChapterListSize == 0) return oldDurChapterIndex + if (newChapterList.isEmpty()) return oldDurChapterIndex + val oldChapterNum = getChapterNum(oldDurChapterName) + val oldName = getPureChapterName(oldDurChapterName) + val newChapterSize = newChapterList.size + val min = max( + 0, + min( + oldDurChapterIndex, + oldDurChapterIndex - oldChapterListSize + newChapterSize + ) - 10 + ) + val max = min( + newChapterSize - 1, + max( + oldDurChapterIndex, + oldDurChapterIndex - oldChapterListSize + newChapterSize + ) + 10 + ) + var nameSim = 0.0 + var newIndex = 0 + var newNum = 0 + if (oldName.isNotEmpty()) { + for (i in min..max) { + val newName = getPureChapterName(newChapterList[i].title) + val temp = jaccardSimilarity.apply(oldName, newName) + if (temp > nameSim) { + nameSim = temp + newIndex = i + } + } + } + if (nameSim < 0.96 && oldChapterNum > 0) { + for (i in min..max) { + val temp = getChapterNum(newChapterList[i].title) + if (temp == oldChapterNum) { + newNum = temp + newIndex = i + break + } else if (abs(temp - oldChapterNum) < abs(newNum - oldChapterNum)) { + newNum = temp + newIndex = i + } + } + } + return if (nameSim > 0.96 || abs(newNum - oldChapterNum) < 1) { + newIndex + } else { + min(max(0, newChapterList.size - 1), oldDurChapterIndex) + } + } + + private val chapterNamePattern1 by lazy { + Pattern.compile(".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]") + } + + private val chapterNamePattern2 by lazy { + Pattern.compile("^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、]|\\.[^\\d])") + } + + private val regexA by lazy { + return@lazy "\\s".toRegex() + } + + private fun getChapterNum(chapterName: String?): Int { + chapterName ?: return -1 + val chapterName1 = StringUtils.fullToHalf(chapterName).replace(regexA, "") + return StringUtils.stringToInt( + ( + chapterNamePattern1.matcher(chapterName1).takeIf { it.find() } + ?: chapterNamePattern2.matcher(chapterName1).takeIf { it.find() } + )?.group(1) + ?: "-1" + ) + } + + @Suppress("SpellCheckingInspection") + private val regexOther by lazy { + // 所有非字母数字中日韩文字 CJK区+扩展A-F区 + @Suppress("RegExpDuplicateCharacterInClass") + return@lazy "[^\\w\\u4E00-\\u9FEF〇\\u3400-\\u4DBF\\u20000-\\u2A6DF\\u2A700-\\u2EBEF]".toRegex() + } + + private val regexB by lazy { + //章节序号,排除处于结尾的状况,避免将章节名替换为空字串 + return@lazy "^.*?第(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话](?!$)|^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、](?!$)|\\.(?=[^\\d]))".toRegex() + } + + private val regexC by lazy { + //前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串 + return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》】〗\\]}]+)?[)〕》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》】〗\\]}]+[〕》】〗\\]})])?(?!$)".toRegex() + } + + private fun getPureChapterName(chapterName: String?): String { + return if (chapterName == null) "" else StringUtils.fullToHalf(chapterName) + .replace(regexA, "") + .replace(regexB, "") + .replace(regexC, "") + .replace(regexOther, "") + } + +} diff --git a/app/src/main/java/io/legado/app/help/CacheManager.kt b/app/src/main/java/io/legado/app/help/CacheManager.kt new file mode 100644 index 000000000..60627ee35 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/CacheManager.kt @@ -0,0 +1,67 @@ +package io.legado.app.help + +import io.legado.app.data.appDb +import io.legado.app.data.entities.Cache +import io.legado.app.model.analyzeRule.QueryTTF +import io.legado.app.utils.ACache +import splitties.init.appCtx + +@Suppress("unused") +object CacheManager { + + private val queryTTFMap = hashMapOf>() + + /** + * saveTime 单位为秒 + */ + @JvmOverloads + fun put(key: String, value: Any, saveTime: Int = 0) { + val deadline = + if (saveTime == 0) 0 else System.currentTimeMillis() + saveTime * 1000 + when (value) { + is QueryTTF -> queryTTFMap[key] = Pair(deadline, value) + is ByteArray -> ACache.get(appCtx).put(key, value, saveTime) + else -> { + val cache = Cache(key, value.toString(), deadline) + appDb.cacheDao.insert(cache) + } + } + } + + fun get(key: String): String? { + return appDb.cacheDao.get(key, System.currentTimeMillis()) + } + + fun getInt(key: String): Int? { + return get(key)?.toIntOrNull() + } + + fun getLong(key: String): Long? { + return get(key)?.toLongOrNull() + } + + fun getDouble(key: String): Double? { + return get(key)?.toDoubleOrNull() + } + + fun getFloat(key: String): Float? { + return get(key)?.toFloatOrNull() + } + + fun getByteArray(key: String): ByteArray? { + return ACache.get(appCtx).getAsBinary(key) + } + + fun getQueryTTF(key: String): QueryTTF? { + val cache = queryTTFMap[key] ?: return null + if (cache.first == 0L || cache.first > System.currentTimeMillis()) { + return cache.second + } + return null + } + + fun delete(key: String) { + appDb.cacheDao.delete(key) + ACache.get(appCtx).remove(key) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ContentHelp.kt b/app/src/main/java/io/legado/app/help/ContentHelp.kt new file mode 100644 index 000000000..ad742d058 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ContentHelp.kt @@ -0,0 +1,621 @@ +package io.legado.app.help + +import java.util.* +import java.util.regex.Pattern +import kotlin.math.max +import kotlin.math.min + +@Suppress("SameParameterValue", "RegExpRedundantEscape") +object ContentHelp { + + /** + * 段落重排算法入口。把整篇内容输入,连接错误的分段,再把每个段落调用其他方法重新切分 + * + * @param content 正文 + * @param chapterName 标题 + * @return + */ + fun reSegment(content: String, chapterName: String): String { + var content1 = content + val dict = makeDict(content1) + var p = content1 + .replace(""".toRegex(), "“") + .replace("[::]['\"‘”“]+".toRegex(), ":“") + .replace("[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“") + .split("\n(\\s*)".toRegex()).toTypedArray() + + //初始化StringBuffer的长度,在原content的长度基础上做冗余 + var buffer = StringBuffer((content1.length * 1.15).toInt()) + // 章节的文本格式为章节标题-空行-首段,所以处理段落时需要略过第一行文本。 + buffer.append(" ") + if (chapterName.trim { it <= ' ' } != p[0].trim { it <= ' ' }) { + // 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内 + buffer.append(p[0].replace("[\u3000\\s]+".toRegex(), "")) + } + + //如果原文存在分段错误,需要把段落重新黏合 + for (i in 1 until p.size) { + if (match(MARK_SENTENCES_END, buffer[buffer.length - 1])) buffer.append("\n") + // 段落开头以外的地方不应该有空格 + // 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内 + buffer.append(p[i].replace("[\u3000\\s]".toRegex(), "")) + } + // 预分段预处理 + // ”“处理为”\n“。 + // ”。“处理为”。\n“。不考虑“?” “!”的情况。 + // ”。xxx处理为 ”。\n xxx + p = buffer.toString() + .replace("[\"”“]+[\\s]*[\"”“]+".toRegex(), "”\n“") + .replace("[\"”“]+(?。!?!~)[\"”“]+".toRegex(), "”$1\n“") + .replace("[\"”“]+(?。!?!~)([^\"”“])".toRegex(), "”$1\n$2") + .replace( + "([问说喊唱叫骂道着答])[\\.。]".toRegex(), + "$1。\n" + ) + .split("\n".toRegex()).toTypedArray() + buffer = StringBuffer((content1.length * 1.15).toInt()) + for (s in p) { + buffer.append("\n") + buffer.append(findNewLines(s, dict)) + } + buffer = reduceLength(buffer) + content1 = (buffer.toString() // 处理章节头部空格和换行 + .replaceFirst("^\\s+".toRegex(), "") + .replace("\\s*[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“") + .replace("[::][”“\"\\s]+".toRegex(), ":“") + .replace("\n[\"“”]([^\n\"“”]+)([,:,:][\"”“])([^\n\"“”]+)".toRegex(), "\n$1:“$3") + .replace("\n(\\s*)".toRegex(), "\n")) + return content1 + } + + /** + * 强制切分,减少段落内的句子 + * 如果连续2对引号的段落没有提示语,进入对话模式。最后一对引号后强制切分段落 + * 如果引号内的内容长于5句,可能引号状态有误,随机分段 + * 如果引号外的内容长于3句,随机分段 + * + * @param str + * @return + */ + private fun reduceLength(str: StringBuffer): StringBuffer { + val p = str.toString().split("\n".toRegex()).toTypedArray() + val l = p.size + val b = BooleanArray(l) + for (i in 0 until l) { + b[i] = p[i].matches(PARAGRAPH_DIAGLOG) + } + var dialogue = 0 + for (i in 0 until l) { + if (b[i]) { + if (dialogue < 0) dialogue = 1 else if (dialogue < 2) dialogue++ + } else { + if (dialogue > 1) { + p[i] = splitQuote(p[i]) + dialogue-- + } else if (dialogue > 0 && i < l - 2) { + if (b[i + 1]) p[i] = splitQuote(p[i]) + } + } + } + val string = StringBuffer() + for (i in 0 until l) { + string.append('\n') + string.append(p[i]) + //System.out.print(" "+b[i]); + } + //System.out.println(" " + str); + return string + } + + // 强制切分进入对话模式后,未构成 “xxx” 形式的段落 + private fun splitQuote(str: String): String { + val length = str.length + if (length < 3) return str + if (match(MARK_QUOTATION, str[0])) { + val i = seekIndex(str, MARK_QUOTATION, 1, length - 2, true) + 1 + if (i > 1) if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) { + return "${str.substring(0, i)}\n${str.substring(i)}" + } + } else if (match(MARK_QUOTATION, str[length - 1])) { + val i = length - 1 - seekIndex(str, MARK_QUOTATION, 1, length - 2, false) + if (i > 1) { + if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) { + return "${str.substring(0, i)}\n${str.substring(i)}" + } + } + } + return str + } + + /** + * 计算随机插入换行符的位置。 + * @param str 字符串 + * @param offset 传回的结果需要叠加的偏移量 + * @param min 最低几个句子,随机插入换行 + * @param gain 倍率。每个句子插入换行的数学期望 = 1 / gain , gain越大越不容易插入换行 + * @return + */ + private fun forceSplit( + str: String, + offset: Int, + min: Int, + gain: Int, + tigger: Int + ): ArrayList { + val result = ArrayList() + val arrayEnd = seekIndexs(str, MARK_SENTENCES_END_P, 0, str.length - 2, true) + val arrayMid = seekIndexs(str, MARK_SENTENCES_MID, 0, str.length - 2, true) + if (arrayEnd.size < tigger && arrayMid.size < tigger * 3) return result + var j = 0 + var i = min + while (i < arrayEnd.size) { + var k = 0 + while (j < arrayMid.size) { + if (arrayMid[j] < arrayEnd[i]) k++ + j++ + } + if (Math.random() * gain < 0.8 + k / 2.5) { + result.add(arrayEnd[i] + offset) + i = max(i + min, i) + } + i++ + } + return result + } + + // 对内容重新划分段落.输入参数str已经使用换行符预分割 + private fun findNewLines(str: String, dict: List): String { + val string = StringBuffer(str) + // 标记string中每个引号的位置.特别的,用引号进行列举时视为只有一对引号。 如:“锅”、“碗”视为“锅、碗”,从而避免误断句。 + val arrayQuote: MutableList = ArrayList() + // 标记插入换行符的位置,int为插入位置(str的char下标) + var insN = ArrayList() + + //mod[i]标记str的每一段处于引号内还是引号外。范围: str.substring( array_quote.get(i), array_quote.get(i+1) )的状态。 + //长度:array_quote.size(),但是初始化时未预估占用的长度,用空间换时间 + //0未知,正数引号内,负数引号外。 + //如果相邻的两个标记都为+1,那么需要增加1个引号。 + //引号内不进行断句 + val mod = IntArray(str.length) + var waitClose = false + for (i in str.indices) { + val c = str[i] + if (match(MARK_QUOTATION, c)) { + val size = arrayQuote.size + + // 把“xxx”、“yy”合并为“xxx_yy”进行处理 + if (size > 0) { + val quotePre = arrayQuote[size - 1] + if (i - quotePre == 2) { + var remove = false + if (waitClose) { + if (match(",,、/", str[i - 1])) { + // 考虑出现“和”这种特殊情况 + remove = true + } + } else if (match(",,、/和与或", str[i - 1])) { + remove = true + } + if (remove) { + string.setCharAt(i, '“') + string.setCharAt(i - 2, '”') + arrayQuote.removeAt(size - 1) + mod[size - 1] = 1 + mod[size] = -1 + continue + } + } + } + arrayQuote.add(i) + + // 为xxx:“xxx”做标记 + if (i > 1) { + // 当前发言的正引号的前一个字符 + val charB1 = str[i - 1] + // 上次发言的正引号的前一个字符 + var charB2 = 0.toChar() + if (match(MARK_QUOTATION_BEFORE, charB1)) { + // 如果不是第一处引号,寻找上一处断句,进行分段 + if (arrayQuote.size > 1) { + val lastQuote = arrayQuote[arrayQuote.size - 2] + var p = 0 + if (charB1 == ',' || charB1 == ',') { + if (arrayQuote.size > 2) { + p = arrayQuote[arrayQuote.size - 3] + if (p > 0) { + charB2 = str[p - 1] + } + } + } + //if(char_b2=='.' || char_b2=='。') + if (match(MARK_SENTENCES_END_P, charB2)) { + insN.add(p - 1) + } else if (!match("的", charB2)) { + val lastEnd = seekLast(str, MARK_SENTENCES_END, i, lastQuote) + if (lastEnd > 0) insN.add(lastEnd) else insN.add(lastQuote) + } + } + waitClose = true + mod[size] = 1 + if (size > 0) { + mod[size - 1] = -1 + if (size > 1) { + mod[size - 2] = 1 + } + } + } else if (waitClose) { + run { + waitClose = false + insN.add(i) + } + } + } + } + } + val size = arrayQuote.size + + + //标记循环状态,此位置前的引号是否已经配对 + var opend = false + if (size > 0) { + //第1次遍历array_quote,令其元素的值不为0 + for (i in 0 until size) { + if (mod[i] > 0) { + opend = true + } else if (mod[i] < 0) { + //连续2个反引号表明存在冲突,强制把前一个设为正引号 + if (!opend) { + if (i > 0) mod[i] = 3 + } + opend = false + } else { + opend = !opend + if (opend) mod[i] = 2 else mod[i] = -2 + } + } + // 修正,断尾必须封闭引号 + if (opend) { + if (arrayQuote[size - 1] - string.length > -3) { + //if((match(MARK_QUOTATION,string.charAt(string.length()-1)) || match(MARK_QUOTATION,string.charAt(string.length()-2)))){ + if (size > 1) mod[size - 2] = 4 + // 0<=i=1 + mod[size - 1] = -4 + } else if (!match(MARK_SENTENCES_SAY, string[string.length - 2])) string.append( + "”" + ) + } + + + //第2次循环,mod[i]由负变正时,前1字符如果是句末,需要插入换行 + var loop2Mod1 = -1 //上一个引号跟随内容的状态 + var loop2Mod2: Int //当前引号跟随内容的状态 + var i = 0 + var j = arrayQuote[0] - 1 //当前引号前一字符的序号 + if (j < 0) { + i = 1 + loop2Mod1 = 0 + } + while (i < size) { + j = arrayQuote[i] - 1 + loop2Mod2 = mod[i] + if (loop2Mod1 < 0 && loop2Mod2 > 0) { + if (match(MARK_SENTENCES_END, string[j])) insN.add(j) + } + loop2Mod1 = loop2Mod2 + i++ + } + } + + //第3次循环,匹配并插入换行。 + //"xxxx" xxxx。\n xxx“xxxx” + //未实现 + + // 使用字典验证ins_n , 避免插入不必要的换行。 + // 由于目前没有插入、的列表,无法解决 “xx”、“xx”“xx” 被插入换行的问题 + val insN1 = ArrayList() + for (i in insN) { + if (match("\"'”“", string[i])) { + val start: Int = seekLast( + str, + "\"'”“", + i - 1, + i - WORD_MAX_LENGTH + ) + if (start > 0) { + val word = str.substring(start + 1, i) + if (dict.contains(word)) { + //System.out.println("使用字典验证 跳过\tins_n=" + i + " word=" + word); + //引号内如果是字典词条,后方不插入换行符(前方不需要优化) + continue + } else { + //System.out.println("使用字典验证 插入\tins_n=" + i + " word=" + word); + if (match("的地得", str[start])) { + //xx的“xx”,后方不插入换行符(前方不需要优化) + continue + } + } + } + } + insN1.add(i) + } + insN = insN1 + +// 随机在句末插入换行符 + insN = ArrayList(HashSet(insN)) + insN.sort() + run { + var subs: String + var j = 0 + var progress = 0 + var nextLine = -1 + if (insN.size > 0) nextLine = insN[j] + var gain = 3 + var min = 0 + var trigger = 2 + for (i in arrayQuote.indices) { + val qutoe = arrayQuote[i] + if (qutoe > 0) { + gain = 4 + min = 2 + trigger = 4 + } else { + gain = 3 + min = 0 + trigger = 2 + } + +// 把引号前的换行符与内容相间插入 + while (j < insN.size) { + +// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况 + if (nextLine >= qutoe) break + nextLine = insN[j] + if (progress < nextLine) { + subs = string.substring(progress, nextLine) + insN.addAll(forceSplit(subs, progress, min, gain, trigger)) + progress = nextLine + 1 + } + j++ + } + if (progress < qutoe) { + subs = string.substring(progress, qutoe + 1) + insN.addAll(forceSplit(subs, progress, min, gain, trigger)) + progress = qutoe + 1 + } + } + while (j < insN.size) { + nextLine = insN[j] + if (progress < nextLine) { + subs = string.substring(progress, nextLine) + insN.addAll(forceSplit(subs, progress, min, gain, trigger)) + progress = nextLine + 1 + } + j++ + } + if (progress < string.length) { + subs = string.substring(progress, string.length) + insN.addAll(forceSplit(subs, progress, min, gain, trigger)) + } + } + +// 根据段落状态修正引号方向、计算需要插入引号的位置 +// ins_quote跟随array_quote ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”' + val insQuote = BooleanArray(size) + opend = false + for (i in 0 until size) { + val p = arrayQuote[i] + if (mod[i] > 0) { + string.setCharAt(p, '“') + if (opend) insQuote[i] = true + opend = true + } else if (mod[i] < 0) { + string.setCharAt(p, '”') + opend = false + } else { + opend = !opend + if (opend) string.setCharAt(p, '“') else string.setCharAt(p, '”') + } + } + insN = ArrayList(HashSet(insN)) + insN.sort() + +// 完成字符串拼接(从string复制、插入引号和换行 +// ins_quote 在引号前插入一个引号。 ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”' +// ins_n 插入换行。数组的值表示插入换行符的位置 + val buffer = StringBuffer((str.length * 1.15).toInt()) + var j = 0 + var progress = 0 + var nextLine = -1 + if (insN.size > 0) nextLine = insN[j] + for (i in arrayQuote.indices) { + val qutoe = arrayQuote[i] + +// 把引号前的换行符与内容相间插入 + while (j < insN.size) { + +// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况 + if (nextLine >= qutoe) break + nextLine = insN[j] + buffer.append(string, progress, nextLine + 1) + buffer.append('\n') + progress = nextLine + 1 + j++ + } + if (progress < qutoe) { + buffer.append(string, progress, qutoe + 1) + progress = qutoe + 1 + } + if (insQuote[i] && buffer.length > 2) { + if (buffer[buffer.length - 1] == '\n') buffer.append('“') else buffer.insert( + buffer.length - 1, + "”\n" + ) + } + } + while (j < insN.size) { + nextLine = insN[j] + if (progress <= nextLine) { + buffer.append(string, progress, nextLine + 1) + buffer.append('\n') + progress = nextLine + 1 + } + j++ + } + if (progress < string.length) { + buffer.append(string, progress, string.length) + } + return buffer.toString() + } + + /** + * 从字符串提取引号包围,且不止出现一次的内容为字典 + * + * @param str + * @return 词条列表 + */ + private fun makeDict(str: String): List { + + // 引号中间不包含任何标点 + val patten = Pattern.compile( + """ + (?<=["'”“])([^ + \p{P}]{1,${WORD_MAX_LENGTH}})(?=["'”“]) + """.trimIndent() + ) + //Pattern patten = Pattern.compile("(?<=[\"'”“])([^\n\"'”“]{1,16})(?=[\"'”“])"); + val matcher = patten.matcher(str) + val cache: MutableList = ArrayList() + val dict: MutableList = ArrayList() + while (matcher.find()) { + val word = matcher.group() + if (cache.contains(word)) { + if (!dict.contains(word)) dict.add(word) + } else cache.add(word) + } + return dict + } + + /** + * 计算匹配到字典的每个字符的位置 + * + * @param str 待匹配的字符串 + * @param key 字典 + * @param from 从字符串的第几个字符开始匹配 + * @param to 匹配到第几个字符结束 + * @param inOrder 是否按照从前向后的顺序匹配 + * @return 返回距离构成的ArrayList + */ + private fun seekIndexs( + str: String, + key: String, + from: Int, + to: Int, + inOrder: Boolean + ): ArrayList { + val list = ArrayList() + if (str.length - from < 1) return list + var i = 0 + if (from > i) i = from + var t = str.length + if (to > 0) t = min(t, to) + var c: Char + while (i < t) { + c = if (inOrder) str[i] else str[str.length - i - 1] + if (key.indexOf(c) != -1) { + list.add(i) + } + i++ + } + return list + } + + /** + * 计算字符串最后出现与字典中字符匹配的位置 + * + * @param str 数据字符串 + * @param key 字典字符串 + * @param from 从哪个字符开始匹配,默认最末位 + * @param to 匹配到哪个字符(不包含此字符)默认0 + * @return 位置(正向计算) + */ + private fun seekLast(str: String, key: String, from: Int, to: Int): Int { + if (str.length - from < 1) return -1 + var i = str.lastIndex + if (from < i && i > 0) i = from + var t = 0 + if (to > 0) t = to + var c: Char + while (i > t) { + c = str[i] + if (key.indexOf(c) != -1) { + return i + } + i-- + } + return -1 + } + + /** + * 计算字符串与字典中字符的最短距离 + * + * @param str 数据字符串 + * @param key 字典字符串 + * @param from 从哪个字符开始匹配,默认0 + * @param to 匹配到哪个字符(不包含此字符)默认匹配到最末位 + * @param inOrder 是否从正向开始匹配 + * @return 返回最短距离, 注意不是str的char的下标 + */ + private fun seekIndex(str: String, key: String, from: Int, to: Int, inOrder: Boolean): Int { + if (str.length - from < 1) return -1 + var i = 0 + if (from > i) i = from + var t = str.length + if (to > 0) t = min(t, to) + var c: Char + while (i < t) { + c = if (inOrder) str[i] else str[str.length - i - 1] + if (key.indexOf(c) != -1) { + return i + } + i++ + } + return -1 + } + + /* 搜寻引号并进行分段。处理了一、二、五三类常见情况 + 参照百科词条[引号#应用示例](https://baike.baidu.com/item/%E5%BC%95%E5%8F%B7/998963?#5)对引号内容进行矫正并分句。 + 一、完整引用说话内容,在反引号内侧有断句标点。例如: + 1) 丫姑折断几枝扔下来,边叫我的小名儿边说:“先喂饱你!” + 2)“哎呀,真是美极了!”皇帝说,“我十分满意!” + 3)“怕什么!海的美就在这里!”我说道。 + 二、部分引用,在反引号外侧有断句标点: + 4)适当地改善自己的生活,岂但“你管得着吗”,而且是顺乎天理,合乎人情的。 + 5)现代画家徐悲鸿笔下的马,正如有的评论家所说的那样,“形神兼备,充满生机”。 + 6)唐朝的张嘉贞说它“制造奇特,人不知其所为”。 + 三、一段接着一段地直接引用时,中间段落只在段首用起引号,该段段尾却不用引回号。但是正统文学不在考虑范围内。 + 四、引号里面又要用引号时,外面一层用双引号,里面一层用单引号。暂时不需要考虑 + 五、反语和强调,周围没有断句符号。 + */ + + // 句子结尾的标点。因为引号可能存在误判,不包含引号。 + private const val MARK_SENTENCES_END = "?。!?!~" + private const val MARK_SENTENCES_END_P = ".?。!?!~" + + // 句中标点,由于某些网站常把“,”写为".",故英文句点按照句中标点判断 + private const val MARK_SENTENCES_MID = ".,、,—…" + private const val MARK_SENTENCES_SAY = "问说喊唱叫骂道着答" + + // XXX说:“”的冒号 + private const val MARK_QUOTATION_BEFORE = ",:,:" + + // 引号 + private const val MARK_QUOTATION = "\"“”" + private val PARAGRAPH_DIAGLOG = "^[\"”“][^\"”“]+[\"”“]$".toRegex() + + // 限制字典的长度 + private const val WORD_MAX_LENGTH = 16 + + private fun match(rule: String, chr: Char): Boolean { + return rule.indexOf(chr) != -1 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ContentProcessor.kt b/app/src/main/java/io/legado/app/help/ContentProcessor.kt new file mode 100644 index 000000000..1b905ec71 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ContentProcessor.kt @@ -0,0 +1,128 @@ +package io.legado.app.help + +import com.github.liuyueyi.quick.transfer.ChineseUtils +import io.legado.app.constant.AppLog +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.utils.toastOnUi +import splitties.init.appCtx +import java.lang.ref.WeakReference +import java.util.regex.Pattern + +class ContentProcessor private constructor( + private val bookName: String, + private val bookOrigin: String +) { + + companion object { + private val processors = hashMapOf>() + + fun get(bookName: String, bookOrigin: String): ContentProcessor { + val processorWr = processors[bookName + bookOrigin] + var processor: ContentProcessor? = processorWr?.get() + if (processor == null) { + processor = ContentProcessor(bookName, bookOrigin) + processors[bookName + bookOrigin] = WeakReference(processor) + } + return processor + } + + fun upReplaceRules() { + processors.forEach { + it.value.get()?.upReplaceRules() + } + } + + } + + private val replaceRules = arrayListOf() + + init { + upReplaceRules() + } + + @Synchronized + fun upReplaceRules() { + replaceRules.clear() + replaceRules.addAll(appDb.replaceRuleDao.findEnabledByScope(bookName, bookOrigin)) + } + + @Synchronized + fun getReplaceRules(): Array { + return replaceRules.toTypedArray() + } + + fun getContent( + book: Book, + chapter: BookChapter, //已经经过简繁转换 + content: String, + includeTitle: Boolean = true, + useReplace: Boolean = true, + chineseConvert: Boolean = true, + reSegment: Boolean = true + ): List { + var mContent = content + //去除重复标题 + try { + val name = Pattern.quote(book.name) + val title = Pattern.quote(chapter.title) + val titleRegex = "^(\\s|\\p{P}|${name})*${title}(\\s|\\p{P})+".toRegex() + mContent = mContent.replace(titleRegex, "") + } catch (e: Exception) { + AppLog.put("去除重复标题出错\n${e.localizedMessage}", e) + } + if (reSegment && book.getReSegment()) { + //重新分段 + mContent = ContentHelp.reSegment(mContent, chapter.title) + } + if (includeTitle) { + //重新添加标题 + mContent = chapter.getDisplayTitle() + "\n" + mContent + } + if (useReplace && book.getUseReplaceRule()) { + //替换 + getReplaceRules().forEach { item -> + if (item.pattern.isNotEmpty()) { + try { + mContent = if (item.isRegex) { + mContent.replace(item.pattern.toRegex(), item.replacement) + } else { + mContent.replace(item.pattern, item.replacement) + } + } catch (e: Exception) { + AppLog.put("${item.name}替换出错\n${e.localizedMessage}") + appCtx.toastOnUi("${item.name}替换出错") + } + } + } + } + if (chineseConvert) { + //简繁转换 + try { + when (AppConfig.chineseConverterType) { + 1 -> mContent = ChineseUtils.t2s(mContent) + 2 -> mContent = ChineseUtils.s2t(mContent) + } + } catch (e: Exception) { + appCtx.toastOnUi("简繁转换出错") + } + } + val contents = arrayListOf() + mContent.split("\n").forEach { str -> + val paragraph = str.trim { + it.code <= 0x20 || it == ' ' + } + if (paragraph.isNotEmpty()) { + if (contents.isEmpty() && includeTitle) { + contents.add(paragraph) + } else { + contents.add("${ReadBookConfig.paragraphIndent}$paragraph") + } + } + } + return contents + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/CrashHandler.kt b/app/src/main/java/io/legado/app/help/CrashHandler.kt new file mode 100644 index 000000000..72f10574c --- /dev/null +++ b/app/src/main/java/io/legado/app/help/CrashHandler.kt @@ -0,0 +1,117 @@ +package io.legado.app.help + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import io.legado.app.constant.AppConst +import io.legado.app.model.ReadAloud +import io.legado.app.utils.FileUtils +import io.legado.app.utils.getFile +import io.legado.app.utils.longToastOnUi +import io.legado.app.utils.msg +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * 异常管理类 + */ +@Suppress("DEPRECATION") +class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler { + + /** + * 系统默认UncaughtExceptionHandler + */ + private var mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + /** + * 存储异常和参数信息 + */ + private val paramsMap = HashMap() + + /** + * 格式化时间 + */ + @SuppressLint("SimpleDateFormat") + private val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss") + + init { + //设置该CrashHandler为系统默认的 + Thread.setDefaultUncaughtExceptionHandler(this) + } + + /** + * uncaughtException 回调函数 + */ + override fun uncaughtException(thread: Thread, ex: Throwable) { + ReadAloud.stop(context) + handleException(ex) + mDefaultHandler?.uncaughtException(thread, ex) + } + + /** + * 处理该异常 + */ + private fun handleException(ex: Throwable?) { + if (ex == null) return + //收集设备参数信息 + collectDeviceInfo() + //保存日志文件 + saveCrashInfo2File(ex) + context.longToastOnUi(ex.msg) + Thread.sleep(3000) + } + + /** + * 收集设备参数信息 + */ + private fun collectDeviceInfo() { + kotlin.runCatching { + //获取系统信息 + paramsMap["MANUFACTURER"] = Build.MANUFACTURER + paramsMap["BRAND"] = Build.BRAND + //获取app版本信息 + AppConst.appInfo.let { + paramsMap["versionName"] = it.versionName + paramsMap["versionCode"] = it.versionCode.toString() + } + } + } + + /** + * 保存错误信息到文件中 + */ + private fun saveCrashInfo2File(ex: Throwable) { + val sb = StringBuilder() + for ((key, value) in paramsMap) { + sb.append(key).append("=").append(value).append("\n") + } + + val writer = StringWriter() + val printWriter = PrintWriter(writer) + ex.printStackTrace(printWriter) + var cause: Throwable? = ex.cause + while (cause != null) { + cause.printStackTrace(printWriter) + cause = cause.cause + } + printWriter.close() + val result = writer.toString() + sb.append(result) + val timestamp = System.currentTimeMillis() + val time = format.format(Date()) + val fileName = "crash-$time-$timestamp.log" + context.externalCacheDir?.let { rootFile -> + rootFile.getFile("crash").listFiles()?.forEach { + if (it.lastModified() < System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) { + it.delete() + } + } + FileUtils.createFileIfNotExist(rootFile, "crash", fileName) + .writeText(sb.toString()) + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/DefaultData.kt b/app/src/main/java/io/legado/app/help/DefaultData.kt new file mode 100644 index 000000000..06d05173b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/DefaultData.kt @@ -0,0 +1,72 @@ +package io.legado.app.help + +import io.legado.app.data.appDb +import io.legado.app.data.entities.HttpTTS +import io.legado.app.data.entities.RssSource +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import splitties.init.appCtx +import java.io.File + +object DefaultData { + + const val httpTtsFileName = "httpTTS.json" + const val txtTocRuleFileName = "txtTocRule.json" + + val httpTTS: List by lazy { + val json = + String( + appCtx.assets.open("defaultData${File.separator}$httpTtsFileName") + .readBytes() + ) + HttpTTS.fromJsonArray(json) + } + + val readConfigs: List by lazy { + val json = String( + appCtx.assets.open("defaultData${File.separator}${ReadBookConfig.configFileName}") + .readBytes() + ) + GSON.fromJsonArray(json)!! + } + + val txtTocRules: List by lazy { + val json = String( + appCtx.assets.open("defaultData${File.separator}$txtTocRuleFileName") + .readBytes() + ) + GSON.fromJsonArray(json)!! + } + + val themeConfigs: List by lazy { + val json = String( + appCtx.assets.open("defaultData${File.separator}${ThemeConfig.configFileName}") + .readBytes() + ) + GSON.fromJsonArray(json)!! + } + + val rssSources: List by lazy { + val json = String( + appCtx.assets.open("defaultData${File.separator}rssSources.json") + .readBytes() + ) + RssSource.fromJsonArray(json) + } + + fun importDefaultHttpTTS() { + appDb.httpTTSDao.deleteDefault() + appDb.httpTTSDao.insert(*httpTTS.toTypedArray()) + } + + fun importDefaultTocRules() { + appDb.txtTocRuleDao.deleteDefault() + appDb.txtTocRuleDao.insert(*txtTocRules.toTypedArray()) + } + + fun importDefaultRssSources() { + appDb.rssSourceDao.insert(*rssSources.toTypedArray()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/DirectLinkUpload.kt b/app/src/main/java/io/legado/app/help/DirectLinkUpload.kt new file mode 100644 index 000000000..fcceecfd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/DirectLinkUpload.kt @@ -0,0 +1,82 @@ +package io.legado.app.help + +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.analyzeRule.RuleData +import io.legado.app.utils.jsonPath +import io.legado.app.utils.readString +import splitties.init.appCtx +import java.io.File + +object DirectLinkUpload { + + private const val uploadUrlKey = "directLinkUploadUrl" + private const val downloadUrlRuleKey = "directLinkDownloadUrlRule" + private const val summaryKey = "directSummary" + + suspend fun upLoad(fileName: String, file: Any, contentType: String): String { + val url = getUploadUrl() + if (url.isNullOrBlank()) { + throw NoStackTraceException("上传url未配置") + } + val downloadUrlRule = getDownloadUrlRule() + if (downloadUrlRule.isNullOrBlank()) { + throw NoStackTraceException("下载地址规则未配置") + } + val analyzeUrl = AnalyzeUrl(url) + val res = analyzeUrl.upload(fileName, file, contentType) + val analyzeRule = AnalyzeRule(RuleData()).setContent(res.body, res.url) + val downloadUrl = analyzeRule.getString(downloadUrlRule) + if (downloadUrl.isBlank()) { + throw NoStackTraceException("上传失败,${res.body}") + } + return downloadUrl + } + + private val ruleDoc by lazy { + val json = String( + appCtx.assets.open("defaultData${File.separator}directLinkUpload.json") + .readBytes() + ) + jsonPath.parse(json) + } + + fun getUploadUrl(): String? { + return CacheManager.get(uploadUrlKey) + ?: ruleDoc.readString("$.UploadUrl") + } + + fun putUploadUrl(url: String) { + CacheManager.put(uploadUrlKey, url) + } + + fun getDownloadUrlRule(): String? { + return CacheManager.get(downloadUrlRuleKey) + ?: ruleDoc.readString("$.DownloadUrlRule") + } + + fun putDownloadUrlRule(rule: String) { + CacheManager.put(downloadUrlRuleKey, rule) + } + + fun getSummary(): String? { + return CacheManager.get(summaryKey) + ?: ruleDoc.readString("summary") + } + + fun putSummary(summary: String?) { + if (summary != null) { + CacheManager.put(summaryKey, summary) + } else { + CacheManager.delete(summaryKey) + } + } + + fun delete() { + CacheManager.delete(uploadUrlKey) + CacheManager.delete(downloadUrlRuleKey) + CacheManager.delete(summaryKey) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/EventMessage.kt b/app/src/main/java/io/legado/app/help/EventMessage.kt new file mode 100644 index 000000000..31e8f662e --- /dev/null +++ b/app/src/main/java/io/legado/app/help/EventMessage.kt @@ -0,0 +1,49 @@ +package io.legado.app.help + +import android.text.TextUtils + +@Suppress("unused") +class EventMessage { + + var what: Int? = null + var tag: String? = null + var obj: Any? = null + + fun isFrom(tag: String): Boolean { + return TextUtils.equals(this.tag, tag) + } + + fun maybeFrom(vararg tags: String): Boolean { + return listOf(*tags).contains(tag) + } + + companion object { + + fun obtain(tag: String): EventMessage { + val message = EventMessage() + message.tag = tag + return message + } + + fun obtain(what: Int): EventMessage { + val message = EventMessage() + message.what = what + return message + } + + fun obtain(what: Int, obj: Any): EventMessage { + val message = EventMessage() + message.what = what + message.obj = obj + return message + } + + fun obtain(tag: String, obj: Any): EventMessage { + val message = EventMessage() + message.tag = tag + message.obj = obj + return message + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/IntentData.kt b/app/src/main/java/io/legado/app/help/IntentData.kt new file mode 100644 index 000000000..2ce48bde3 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/IntentData.kt @@ -0,0 +1,31 @@ +package io.legado.app.help + +object IntentData { + + private val bigData: MutableMap = mutableMapOf() + + @Synchronized + fun put(key: String, data: Any?) { + data?.let { + bigData[key] = data + } + } + + @Synchronized + fun put(data: Any?): String { + val key = System.currentTimeMillis().toString() + data?.let { + bigData[key] = data + } + return key + } + + @Suppress("UNCHECKED_CAST") + @Synchronized + fun get(key: String?): T? { + if (key == null) return null + val data = bigData[key] + bigData.remove(key) + return data as? T + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/IntentHelp.kt b/app/src/main/java/io/legado/app/help/IntentHelp.kt new file mode 100644 index 000000000..aeb0e9be5 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/IntentHelp.kt @@ -0,0 +1,35 @@ +package io.legado.app.help + +import android.content.Context +import android.content.Intent +import io.legado.app.R +import io.legado.app.utils.toastOnUi + +@Suppress("unused") +object IntentHelp { + + + fun toTTSSetting(context: Context) { + //跳转到文字转语音设置界面 + kotlin.runCatching { + val intent = Intent() + intent.action = "com.android.settings.TTS_SETTINGS" + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + }.onFailure { + context.toastOnUi(R.string.tip_cannot_jump_setting_page) + } + } + + fun toInstallUnknown(context: Context) { + kotlin.runCatching { + val intent = Intent() + intent.action = "android.settings.MANAGE_UNKNOWN_APP_SOURCES" + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + }.onFailure { + context.toastOnUi("无法打开设置") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt new file mode 100644 index 000000000..90ec06c55 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt @@ -0,0 +1,666 @@ +package io.legado.app.help + +import android.net.Uri +import android.util.Base64 +import androidx.annotation.Keep +import io.legado.app.BuildConfig +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppConst.dateFormat +import io.legado.app.data.entities.BaseSource +import io.legado.app.help.http.* +import io.legado.app.model.Debug +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.analyzeRule.QueryTTF +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.jsoup.Connection +import org.jsoup.Jsoup +import splitties.init.appCtx +import timber.log.Timber +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.URLEncoder +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * js扩展类, 在js中通过java变量调用 + * 所有对于文件的读写删操作都是相对路径,只能操作阅读缓存内的文件 + * /android/data/{package}/cache/... + */ +@Keep +@Suppress("unused") +interface JsExtensions { + + fun getSource(): BaseSource? + + /** + * 访问网络,返回String + */ + fun ajax(urlStr: String): String? { + return runBlocking { + kotlin.runCatching { + val analyzeUrl = AnalyzeUrl(urlStr, source = getSource()) + analyzeUrl.getStrResponseAwait().body + }.onFailure { + log("ajax(${urlStr}) error\n${it.stackTraceToString()}") + Timber.e(it) + }.getOrElse { + it.msg + } + } + } + + /** + * 并发访问网络 + */ + fun ajaxAll(urlList: Array): Array { + return runBlocking { + val asyncArray = Array(urlList.size) { + async(IO) { + val url = urlList[it] + val analyzeUrl = AnalyzeUrl(url, source = getSource()) + analyzeUrl.getStrResponseAwait() + } + } + val resArray = Array(urlList.size) { + asyncArray[it].await() + } + resArray + } + } + + /** + * 访问网络,返回Response + */ + fun connect(urlStr: String): StrResponse { + return runBlocking { + val analyzeUrl = AnalyzeUrl(urlStr, source = getSource()) + kotlin.runCatching { + analyzeUrl.getStrResponseAwait() + }.onFailure { + log("connect(${urlStr}) error\n${it.stackTraceToString()}") + Timber.e(it) + }.getOrElse { + StrResponse(analyzeUrl.url, it.localizedMessage) + } + } + } + + fun connect(urlStr: String, header: String?): StrResponse { + return runBlocking { + val headerMap = GSON.fromJsonObject>(header) + val analyzeUrl = AnalyzeUrl(urlStr, headerMapF = headerMap, source = getSource()) + kotlin.runCatching { + analyzeUrl.getStrResponseAwait() + }.onFailure { + log("ajax($urlStr,$header) error\n${it.stackTraceToString()}") + Timber.e(it) + }.getOrElse { + StrResponse(analyzeUrl.url, it.localizedMessage) + } + } + } + + /** + * 使用webView访问网络 + * @param html 直接用webView载入的html, 如果html为空直接访问url + * @param url html内如果有相对路径的资源不传入url访问不了 + * @param js 用来取返回值的js语句, 没有就返回整个源代码 + * @return 返回js获取的内容 + */ + fun webView(html: String?, url: String?, js: String?): String? { + return runBlocking { + BackstageWebView( + url = url, + html = html, + javaScript = js + ).getStrResponse().body + } + } + + /** + * 实现16进制字符串转文件 + * @param content 需要转成文件的16进制字符串 + * @param url 通过url里的参数来判断文件类型 + * @return 相对路径 + */ + fun downloadFile(content: String, url: String): String { + val type = AnalyzeUrl(url, source = getSource()).type ?: return "" + val zipPath = FileUtils.getPath( + FileUtils.createFolderIfNotExist(FileUtils.getCachePath()), + "${MD5Utils.md5Encode16(url)}.${type}" + ) + FileUtils.deleteFile(zipPath) + val zipFile = FileUtils.createFileIfNotExist(zipPath) + StringUtils.hexStringToByte(content).let { + if (it.isNotEmpty()) { + zipFile.writeBytes(it) + } + } + return zipPath.substring(FileUtils.getCachePath().length) + } + + /** + * js实现重定向拦截,网络访问get + */ + fun get(urlStr: String, headers: Map): Connection.Response { + return Jsoup.connect(urlStr) + .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) + .ignoreContentType(true) + .followRedirects(false) + .headers(headers) + .method(Connection.Method.GET) + .execute() + } + + /** + * 网络访问post + */ + fun post(urlStr: String, body: String, headers: Map): Connection.Response { + return Jsoup.connect(urlStr) + .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) + .ignoreContentType(true) + .followRedirects(false) + .requestBody(body) + .headers(headers) + .method(Connection.Method.POST) + .execute() + } + + /** + *js实现读取cookie + */ + fun getCookie(tag: String, key: String? = null): String { + val cookie = CookieStore.getCookie(tag) + val cookieMap = CookieStore.cookieToMap(cookie) + return if (key != null) { + cookieMap[key] ?: "" + } else { + cookie + } + } + + /** + * js实现解码,不能删 + */ + fun base64Decode(str: String): String { + return EncoderUtils.base64Decode(str, Base64.NO_WRAP) + } + + fun base64Decode(str: String, flags: Int): String { + return EncoderUtils.base64Decode(str, flags) + } + + fun base64DecodeToByteArray(str: String?): ByteArray? { + if (str.isNullOrBlank()) { + return null + } + return Base64.decode(str, Base64.DEFAULT) + } + + fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? { + if (str.isNullOrBlank()) { + return null + } + return Base64.decode(str, flags) + } + + fun base64Encode(str: String): String? { + return EncoderUtils.base64Encode(str, Base64.NO_WRAP) + } + + fun base64Encode(str: String, flags: Int): String? { + return EncoderUtils.base64Encode(str, flags) + } + + fun md5Encode(str: String): String { + return MD5Utils.md5Encode(str) + } + + fun md5Encode16(str: String): String { + return MD5Utils.md5Encode16(str) + } + + /** + * 格式化时间 + */ + fun timeFormatUTC(time: Long, format: String, sh: Int): String? { + val utc = SimpleTimeZone(sh, "UTC") + return SimpleDateFormat(format, Locale.getDefault()).run { + timeZone = utc + format(Date(time)) + } + } + + /** + * 时间格式化 + */ + fun timeFormat(time: Long): String { + return dateFormat.format(Date(time)) + } + + /** + * utf8编码转gbk编码 + */ + fun utf8ToGbk(str: String): String { + val utf8 = String(str.toByteArray(charset("UTF-8"))) + val unicode = String(utf8.toByteArray(), charset("UTF-8")) + return String(unicode.toByteArray(charset("GBK"))) + } + + fun encodeURI(str: String): String { + return try { + URLEncoder.encode(str, "UTF-8") + } catch (e: Exception) { + "" + } + } + + fun encodeURI(str: String, enc: String): String { + return try { + URLEncoder.encode(str, enc) + } catch (e: Exception) { + "" + } + } + + fun htmlFormat(str: String): String { + return HtmlFormatter.formatKeepImg(str) + } + + //****************文件操作******************// + + /** + * 获取本地文件 + * @param path 相对路径 + * @return File + */ + fun getFile(path: String): File { + val cachePath = appCtx.externalCache.absolutePath + val aPath = if (path.startsWith(File.separator)) { + cachePath + path + } else { + cachePath + File.separator + path + } + return File(aPath) + } + + fun readFile(path: String): ByteArray? { + val file = getFile(path) + if (file.exists()) { + return file.readBytes() + } + return null + } + + fun readTxtFile(path: String): String { + val file = getFile(path) + if (file.exists()) { + val charsetName = EncodingDetect.getEncode(file) + return String(file.readBytes(), charset(charsetName)) + } + return "" + } + + fun readTxtFile(path: String, charsetName: String): String { + val file = getFile(path) + if (file.exists()) { + return String(file.readBytes(), charset(charsetName)) + } + return "" + } + + /** + * 删除本地文件 + */ + fun deleteFile(path: String) { + val file = getFile(path) + FileUtils.delete(file, true) + } + + /** + * js实现压缩文件解压 + * @param zipPath 相对路径 + * @return 相对路径 + */ + fun unzipFile(zipPath: String): String { + if (zipPath.isEmpty()) return "" + val unzipPath = FileUtils.getPath( + FileUtils.createFolderIfNotExist(FileUtils.getCachePath()), + FileUtils.getNameExcludeExtension(zipPath) + ) + FileUtils.deleteFile(unzipPath) + val zipFile = getFile(zipPath) + val unzipFolder = FileUtils.createFolderIfNotExist(unzipPath) + ZipUtils.unzipFile(zipFile, unzipFolder) + FileUtils.deleteFile(zipFile.absolutePath) + return unzipPath.substring(FileUtils.getCachePath().length) + } + + /** + * js实现文件夹内所有文件读取 + */ + fun getTxtInFolder(unzipPath: String): String { + if (unzipPath.isEmpty()) return "" + val unzipFolder = getFile(unzipPath) + val contents = StringBuilder() + unzipFolder.listFiles().let { + if (it != null) { + for (f in it) { + val charsetName = EncodingDetect.getEncode(f) + contents.append(String(f.readBytes(), charset(charsetName))) + .append("\n") + } + contents.deleteCharAt(contents.length - 1) + } + } + FileUtils.deleteFile(unzipFolder.absolutePath) + return contents.toString() + } + + /** + * 获取网络zip文件里面的数据 + * @param url zip文件的链接或十六进制字符串 + * @param path 所需获取文件在zip内的路径 + * @return zip指定文件的数据 + */ + fun getZipStringContent(url: String, path: String): String { + val byteArray = getZipByteArrayContent(url, path) ?: return "" + val charsetName = EncodingDetect.getEncode(byteArray) + return String(byteArray, Charset.forName(charsetName)) + } + + fun getZipStringContent(url: String, path: String, charsetName: String): String { + val byteArray = getZipByteArrayContent(url, path) ?: return "" + return String(byteArray, Charset.forName(charsetName)) + } + + /** + * 获取网络zip文件里面的数据 + * @param url zip文件的链接或十六进制字符串 + * @param path 所需获取文件在zip内的路径 + * @return zip指定文件的数据 + */ + fun getZipByteArrayContent(url: String, path: String): ByteArray? { + val bytes = if (url.startsWith("http://") || url.startsWith("https://")) { + runBlocking { + return@runBlocking okHttpClient.newCallResponseBody { url(url) }.bytes() + } + } else { + StringUtils.hexStringToByte(url) + } + val bos = ByteArrayOutputStream() + val zis = ZipInputStream(ByteArrayInputStream(bytes)) + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + if (entry.name.equals(path)) { + zis.use { it.copyTo(bos) } + return bos.toByteArray() + } + entry = zis.nextEntry + } + log("getZipContent 未发现内容") + return null + } + + //******************文件操作************************// + + /** + * 解析字体,返回字体解析类 + */ + fun queryBase64TTF(base64: String?): QueryTTF? { + base64DecodeToByteArray(base64)?.let { + return QueryTTF(it) + } + return null + } + + /** + * 返回字体解析类 + * @param str 支持url,本地文件,base64,自动判断,自动缓存 + */ + fun queryTTF(str: String?): QueryTTF? { + str ?: return null + val key = md5Encode16(str) + var qTTF = CacheManager.getQueryTTF(key) + if (qTTF != null) return qTTF + val font: ByteArray? = when { + str.isAbsUrl() -> runBlocking { + var x = CacheManager.getByteArray(key) + if (x == null) { + x = okHttpClient.newCallResponseBody { url(str) }.bytes() + x.let { + CacheManager.put(key, it) + } + } + return@runBlocking x + } + str.isContentScheme() -> Uri.parse(str).readBytes(appCtx) + str.startsWith("/storage") -> File(str).readBytes() + else -> base64DecodeToByteArray(str) + } + font ?: return null + qTTF = QueryTTF(font) + CacheManager.put(key, qTTF) + return qTTF + } + + /** + * @param text 包含错误字体的内容 + * @param font1 错误的字体 + * @param font2 正确的字体 + */ + fun replaceFont( + text: String, + font1: QueryTTF?, + font2: QueryTTF? + ): String { + if (font1 == null || font2 == null) return text + val contentArray = text.toCharArray() + contentArray.forEachIndexed { index, s -> + val oldCode = s.code + if (font1.inLimit(s)) { + val glyf = font1.getGlyfByCode(oldCode) + val code = font2.getCodeByGlyf(glyf) + if (code != 0) { + contentArray[index] = code.toChar() + } + } + } + return contentArray.joinToString("") + } + + /** + * 输出调试日志 + */ + fun log(msg: String): String { + getSource()?.let { + Debug.log(it.getKey(), msg) + } ?: Debug.log(msg) + if (BuildConfig.DEBUG) { + Timber.d(msg) + } + return msg + } + + /** + * 输出对象类型 + */ + fun logType(any: Any?) { + if (any == null) { + log("null") + } else { + log(any.javaClass.name) + } + } + + /** + * 生成UUID + */ + fun randomUUID(): String { + return UUID.randomUUID().toString() + } + + /** + * AES 解码为 ByteArray + * @param str 传入的AES加密的数据 + * @param key AES 解密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + fun aesDecodeToByteArray( + str: String, key: String, transformation: String, iv: String + ): ByteArray? { + return try { + EncoderUtils.decryptAES( + data = str.encodeToByteArray(), + key = key.encodeToByteArray(), + transformation, + iv.encodeToByteArray() + ) + } catch (e: Exception) { + Timber.e(e) + log(e.localizedMessage ?: "aesDecodeToByteArrayERROR") + null + } + } + + /** + * AES 解码为 String + * @param str 传入的AES加密的数据 + * @param key AES 解密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + + fun aesDecodeToString( + str: String, key: String, transformation: String, iv: String + ): String? { + return aesDecodeToByteArray(str, key, transformation, iv)?.let { String(it) } + } + + /** + * 已经base64的AES 解码为 ByteArray + * @param str 传入的AES Base64加密的数据 + * @param key AES 解密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + + fun aesBase64DecodeToByteArray( + str: String, key: String, transformation: String, iv: String + ): ByteArray? { + return try { + EncoderUtils.decryptBase64AES( + str.encodeToByteArray(), + key.encodeToByteArray(), + transformation, + iv.encodeToByteArray() + ) + } catch (e: Exception) { + Timber.e(e) + log(e.localizedMessage ?: "aesDecodeToByteArrayERROR") + null + } + } + + /** + * 已经base64的AES 解码为 String + * @param str 传入的AES Base64加密的数据 + * @param key AES 解密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + + fun aesBase64DecodeToString( + str: String, key: String, transformation: String, iv: String + ): String? { + return aesBase64DecodeToByteArray(str, key, transformation, iv)?.let { String(it) } + } + + /** + * 加密aes为ByteArray + * @param data 传入的原始数据 + * @param key AES加密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + fun aesEncodeToByteArray( + data: String, key: String, transformation: String, iv: String + ): ByteArray? { + return try { + EncoderUtils.encryptAES( + data.encodeToByteArray(), + key = key.encodeToByteArray(), + transformation, + iv.encodeToByteArray() + ) + } catch (e: Exception) { + Timber.e(e) + log(e.localizedMessage ?: "aesEncodeToByteArrayERROR") + null + } + } + + /** + * 加密aes为String + * @param data 传入的原始数据 + * @param key AES加密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + fun aesEncodeToString( + data: String, key: String, transformation: String, iv: String + ): String? { + return aesEncodeToByteArray(data, key, transformation, iv)?.let { String(it) } + } + + /** + * 加密aes后Base64化的ByteArray + * @param data 传入的原始数据 + * @param key AES加密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + fun aesEncodeToBase64ByteArray( + data: String, key: String, transformation: String, iv: String + ): ByteArray? { + return try { + EncoderUtils.encryptAES2Base64( + data.encodeToByteArray(), + key.encodeToByteArray(), + transformation, + iv.encodeToByteArray() + ) + } catch (e: Exception) { + Timber.e(e) + log(e.localizedMessage ?: "aesEncodeToBase64ByteArrayERROR") + null + } + } + + /** + * 加密aes后Base64化的String + * @param data 传入的原始数据 + * @param key AES加密的key + * @param transformation AES加密的方式 + * @param iv ECB模式的偏移向量 + */ + fun aesEncodeToBase64String( + data: String, key: String, transformation: String, iv: String + ): String? { + return aesEncodeToBase64ByteArray(data, key, transformation, iv)?.let { String(it) } + } + + fun android(): String { + return AppConst.androidId + } + +} diff --git a/app/src/main/java/io/legado/app/help/LauncherIconHelp.kt b/app/src/main/java/io/legado/app/help/LauncherIconHelp.kt new file mode 100644 index 000000000..3a226e071 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/LauncherIconHelp.kt @@ -0,0 +1,65 @@ +package io.legado.app.help + +import android.content.ComponentName +import android.content.pm.PackageManager +import android.os.Build +import io.legado.app.R +import io.legado.app.ui.welcome.* +import io.legado.app.utils.toastOnUi +import splitties.init.appCtx + +/** + * Created by GKF on 2018/2/27. + * 更换图标 + */ +object LauncherIconHelp { + private val packageManager: PackageManager = appCtx.packageManager + private val componentNames = arrayListOf( + ComponentName(appCtx, Launcher1::class.java.name), + ComponentName(appCtx, Launcher2::class.java.name), + ComponentName(appCtx, Launcher3::class.java.name), + ComponentName(appCtx, Launcher4::class.java.name), + ComponentName(appCtx, Launcher5::class.java.name), + ComponentName(appCtx, Launcher6::class.java.name) + ) + + fun changeIcon(icon: String?) { + if (icon.isNullOrEmpty()) return + if (Build.VERSION.SDK_INT < 26) { + appCtx.toastOnUi(R.string.change_icon_error) + return + } + var hasEnabled = false + componentNames.forEach { + if (icon.equals(it.className.substringAfterLast("."), true)) { + hasEnabled = true + //启用 + packageManager.setComponentEnabledSetting( + it, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + } else { + //禁用 + packageManager.setComponentEnabledSetting( + it, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } + if (hasEnabled) { + packageManager.setComponentEnabledSetting( + ComponentName(appCtx, WelcomeActivity::class.java.name), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } else { + packageManager.setComponentEnabledSetting( + ComponentName(appCtx, WelcomeActivity::class.java.name), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/LayoutManager.kt b/app/src/main/java/io/legado/app/help/LayoutManager.kt new file mode 100644 index 000000000..4dee2ab06 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/LayoutManager.kt @@ -0,0 +1,73 @@ +package io.legado.app.help + +import androidx.annotation.IntDef +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager + +@Suppress("unused") +object LayoutManager { + + interface LayoutManagerFactory { + fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager + } + + @IntDef(LinearLayoutManager.HORIZONTAL, LinearLayoutManager.VERTICAL) + @Retention(AnnotationRetention.SOURCE) + annotation class Orientation + + fun linear(): LayoutManagerFactory { + return object : LayoutManagerFactory { + override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { + return LinearLayoutManager(recyclerView.context) + } + } + } + + + fun linear(@Orientation orientation: Int, reverseLayout: Boolean): LayoutManagerFactory { + return object : LayoutManagerFactory { + override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { + return LinearLayoutManager(recyclerView.context, orientation, reverseLayout) + } + } + } + + + fun grid(spanCount: Int): LayoutManagerFactory { + return object : LayoutManagerFactory { + override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { + return GridLayoutManager(recyclerView.context, spanCount) + } + } + } + + + fun grid( + spanCount: Int, + @Orientation orientation: Int, + reverseLayout: Boolean + ): LayoutManagerFactory { + return object : LayoutManagerFactory { + override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { + return GridLayoutManager( + recyclerView.context, + spanCount, + orientation, + reverseLayout + ) + } + } + } + + + fun staggeredGrid(spanCount: Int, @Orientation orientation: Int): LayoutManagerFactory { + return object : LayoutManagerFactory { + override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager { + return StaggeredGridLayoutManager(spanCount, orientation) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/LifecycleHelp.kt b/app/src/main/java/io/legado/app/help/LifecycleHelp.kt new file mode 100644 index 000000000..dad8ca883 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/LifecycleHelp.kt @@ -0,0 +1,111 @@ +package io.legado.app.help + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import io.legado.app.base.BaseService +import java.lang.ref.WeakReference +import java.util.* + +/** + * Activity管理器,管理项目中Activity的状态 + */ +@Suppress("unused") +object LifecycleHelp : Application.ActivityLifecycleCallbacks { + + private val activities: MutableList> = arrayListOf() + private val services: MutableList> = arrayListOf() + private var appFinishedListener: (() -> Unit)? = null + + fun activitySize(): Int { + return activities.size + } + + /** + * 判断指定Activity是否存在 + */ + fun isExistActivity(activityClass: Class<*>): Boolean { + activities.forEach { item -> + if (item.get()?.javaClass == activityClass) { + return true + } + } + return false + } + + /** + * 关闭指定 activity(class) + */ + fun finishActivity(vararg activityClasses: Class<*>) { + val waitFinish = ArrayList>() + for (temp in activities) { + for (activityClass in activityClasses) { + if (temp.get()?.javaClass == activityClass) { + waitFinish.add(temp) + break + } + } + } + waitFinish.forEach { + it.get()?.finish() + } + } + + fun setOnAppFinishedListener(appFinishedListener: (() -> Unit)) { + this.appFinishedListener = appFinishedListener + } + + override fun onActivityPaused(activity: Activity) { + } + + override fun onActivityResumed(activity: Activity) { + } + + override fun onActivityStarted(activity: Activity) { + + } + + override fun onActivityDestroyed(activity: Activity) { + for (temp in activities) { + if (temp.get() != null && temp.get() === activity) { + activities.remove(temp) + if (services.size == 0 && activities.size == 0) { + onAppFinished() + } + break + } + } + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + } + + override fun onActivityStopped(activity: Activity) { + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activities.add(WeakReference(activity)) + } + + @Synchronized + fun onServiceCreate(service: BaseService) { + services.add(WeakReference(service)) + } + + @Synchronized + fun onServiceDestroy(service: BaseService) { + for (temp in services) { + if (temp.get() != null && temp.get() === service) { + services.remove(temp) + if (services.size == 0 && activities.size == 0) { + onAppFinished() + } + break + } + } + } + + private fun onAppFinished() { + appFinishedListener?.invoke() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/LocalConfig.kt b/app/src/main/java/io/legado/app/help/LocalConfig.kt new file mode 100644 index 000000000..ecdbd6930 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/LocalConfig.kt @@ -0,0 +1,73 @@ +package io.legado.app.help + +import android.content.Context +import androidx.core.content.edit +import splitties.init.appCtx + +object LocalConfig { + private const val versionCodeKey = "appVersionCode" + + private val localConfig = + appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) + + val readHelpVersionIsLast: Boolean + get() = isLastVersion(1, "readHelpVersion", "firstRead") + + val backupHelpVersionIsLast: Boolean + get() = isLastVersion(1, "backupHelpVersion", "firstBackup") + + val readMenuHelpVersionIsLast: Boolean + get() = isLastVersion(1, "readMenuHelpVersion", "firstReadMenu") + + val bookSourcesHelpVersionIsLast: Boolean + get() = isLastVersion(1, "bookSourceHelpVersion", "firstOpenBookSources") + + val ruleHelpVersionIsLast: Boolean + get() = isLastVersion(1, "ruleHelpVersion") + + val needUpHttpTTS: Boolean + get() = !isLastVersion(5, "httpTtsVersion") + + val needUpTxtTocRule: Boolean + get() = !isLastVersion(1, "txtTocRuleVersion") + + val needUpRssSources: Boolean + get() = !isLastVersion(4, "rssSourceVersion") + + var versionCode + get() = localConfig.getLong(versionCodeKey, 0) + set(value) { + localConfig.edit { + putLong(versionCodeKey, value) + } + } + + val isFirstOpenApp: Boolean + get() { + val value = localConfig.getBoolean("firstOpen", true) + if (value) { + localConfig.edit { putBoolean("firstOpen", false) } + } + return value + } + + @Suppress("SameParameterValue") + private fun isLastVersion( + lastVersion: Int, + versionKey: String, + firstOpenKey: String? = null + ): Boolean { + var version = localConfig.getInt(versionKey, 0) + if (version == 0 && firstOpenKey != null) { + if (!localConfig.getBoolean(firstOpenKey, true)) { + version = 1 + } + } + if (version < lastVersion) { + localConfig.edit { putInt(versionKey, lastVersion) } + return false + } + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/MediaHelp.kt b/app/src/main/java/io/legado/app/help/MediaHelp.kt new file mode 100644 index 000000000..ed69f64a2 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/MediaHelp.kt @@ -0,0 +1,71 @@ +package io.legado.app.help + +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer +import android.support.v4.media.session.PlaybackStateCompat +import androidx.media.AudioAttributesCompat +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import io.legado.app.R + +object MediaHelp { + const val MEDIA_SESSION_ACTIONS = (PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + or PlaybackStateCompat.ACTION_REWIND + or PlaybackStateCompat.ACTION_PLAY + or PlaybackStateCompat.ACTION_PLAY_PAUSE + or PlaybackStateCompat.ACTION_PAUSE + or PlaybackStateCompat.ACTION_STOP + or PlaybackStateCompat.ACTION_FAST_FORWARD + or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + or PlaybackStateCompat.ACTION_SEEK_TO + or PlaybackStateCompat.ACTION_SET_RATING + or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + or PlaybackStateCompat.ACTION_PLAY_FROM_URI + or PlaybackStateCompat.ACTION_PREPARE + or PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID + or PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH + or PlaybackStateCompat.ACTION_PREPARE_FROM_URI + or PlaybackStateCompat.ACTION_SET_REPEAT_MODE + or PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE + or PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED) + + fun getFocusRequest(audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener): AudioFocusRequestCompat { + val mPlaybackAttributes = AudioAttributesCompat.Builder() + .setUsage(AudioAttributesCompat.USAGE_MEDIA) + .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) + .build() + return AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) + .setAudioAttributes(mPlaybackAttributes) + //.setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + } + + /** + * @return 音频焦点 + */ + fun requestFocus( + audioManager: AudioManager, + focusRequest: AudioFocusRequestCompat? + ): Boolean { + val request = focusRequest?.let { + AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) + } ?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return request == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + } + + /** + * 播放静音音频,用来获取音频焦点 + */ + fun playSilentSound(mContext: Context) { + kotlin.runCatching { + // Stupid Android 8 "Oreo" hack to make media buttons work + val mMediaPlayer = MediaPlayer.create(mContext, R.raw.silent_sound) + mMediaPlayer.setOnCompletionListener { mMediaPlayer.release() } + mMediaPlayer.start() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/README.md b/app/src/main/java/io/legado/app/help/README.md new file mode 100644 index 000000000..9bf5306f7 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/README.md @@ -0,0 +1 @@ +# 放置一些帮助类 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ReadBookConfig.kt b/app/src/main/java/io/legado/app/help/ReadBookConfig.kt new file mode 100644 index 000000000..0e0664565 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ReadBookConfig.kt @@ -0,0 +1,580 @@ +package io.legado.app.help + +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import androidx.annotation.Keep +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext +import splitties.init.appCtx +import timber.log.Timber +import java.io.File + +/** + * 阅读界面配置 + */ +@Keep +object ReadBookConfig { + const val configFileName = "readConfig.json" + const val shareConfigFileName = "shareReadConfig.json" + val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) + val shareConfigFilePath = FileUtils.getPath(appCtx.filesDir, shareConfigFileName) + val configList: ArrayList = arrayListOf() + lateinit var shareConfig: Config + var durConfig + get() = getConfig(styleSelect) + set(value) { + configList[styleSelect] = value + if (shareLayout) { + shareConfig = value + } + upBg() + } + + var bg: Drawable? = null + var bgMeanColor: Int = 0 + val textColor: Int get() = durConfig.curTextColor() + + init { + initConfigs() + initShareConfig() + } + + @Synchronized + fun getConfig(index: Int): Config { + if (configList.size < 5) { + resetAll() + } + return configList.getOrNull(index) ?: configList[0] + } + + fun initConfigs() { + val configFile = File(configFilePath) + var configs: List? = null + if (configFile.exists()) { + try { + val json = configFile.readText() + configs = GSON.fromJsonArray(json) + } catch (e: Exception) { + Timber.e(e) + } + } + (configs ?: DefaultData.readConfigs).let { + configList.clear() + configList.addAll(it) + } + } + + fun initShareConfig() { + val configFile = File(shareConfigFilePath) + var c: Config? = null + if (configFile.exists()) { + try { + val json = configFile.readText() + c = GSON.fromJsonObject(json) + } catch (e: Exception) { + Timber.e(e) + } + } + shareConfig = c ?: configList.getOrNull(5) ?: Config() + } + + fun upBg() { + val resources = appCtx.resources + val dm = resources.displayMetrics + val width = dm.widthPixels + val height = dm.heightPixels + bg = durConfig.curBgDrawable(width, height).apply { + if (this is BitmapDrawable) { + bgMeanColor = bitmap.getMeanColor() + } else if (this is ColorDrawable) { + bgMeanColor = color + } + } + } + + fun save() { + Coroutine.async { + synchronized(this) { + GSON.toJson(configList).let { + FileUtils.deleteFile(configFilePath) + FileUtils.createFileIfNotExist(configFilePath).writeText(it) + } + GSON.toJson(shareConfig).let { + FileUtils.deleteFile(shareConfigFilePath) + FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it) + } + } + } + } + + fun deleteDur(): Boolean { + if (configList.size > 5) { + configList.removeAt(styleSelect) + if (styleSelect > 0) { + styleSelect -= 1 + } + upBg() + return true + } + return false + } + + private fun resetAll() { + DefaultData.readConfigs.let { + configList.clear() + configList.addAll(it) + save() + } + } + + //配置写入读取 + var readBodyToLh = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) + var autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10) + set(value) { + field = value + appCtx.putPrefInt(PreferKey.autoReadSpeed, value) + } + var styleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect) + set(value) { + field = value + if (appCtx.getPrefInt(PreferKey.readStyleSelect) != value) { + appCtx.putPrefInt(PreferKey.readStyleSelect, value) + } + } + var shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout) + set(value) { + field = value + if (appCtx.getPrefBoolean(PreferKey.shareLayout) != value) { + appCtx.putPrefBoolean(PreferKey.shareLayout, value) + } + } + val textFullJustify get() = appCtx.getPrefBoolean(PreferKey.textFullJustify, true) + val textBottomJustify get() = appCtx.getPrefBoolean(PreferKey.textBottomJustify, true) + var hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar) + var hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar) + var useZhLayout = appCtx.getPrefBoolean(PreferKey.useZhLayout) + + val config get() = if (shareLayout) shareConfig else durConfig + + var pageAnim: Int + get() = config.curPageAnim() + set(value) { + config.setCurPageAnim(value) + } + + var textFont: String + get() = config.textFont + set(value) { + config.textFont = value + } + + var textBold: Int + get() = config.textBold + set(value) { + config.textBold = value + } + + var textSize: Int + get() = config.textSize + set(value) { + config.textSize = value + } + + var letterSpacing: Float + get() = config.letterSpacing + set(value) { + config.letterSpacing = value + } + + var lineSpacingExtra: Int + get() = config.lineSpacingExtra + set(value) { + config.lineSpacingExtra = value + } + + var paragraphSpacing: Int + get() = config.paragraphSpacing + set(value) { + config.paragraphSpacing = value + } + + var titleMode: Int + get() = config.titleMode + set(value) { + config.titleMode = value + } + var titleSize: Int + get() = config.titleSize + set(value) { + config.titleSize = value + } + + var titleTopSpacing: Int + get() = config.titleTopSpacing + set(value) { + config.titleTopSpacing = value + } + + var titleBottomSpacing: Int + get() = config.titleBottomSpacing + set(value) { + config.titleBottomSpacing = value + } + + var paragraphIndent: String + get() = config.paragraphIndent + set(value) { + config.paragraphIndent = value + } + + var paddingBottom: Int + get() = config.paddingBottom + set(value) { + config.paddingBottom = value + } + + var paddingLeft: Int + get() = config.paddingLeft + set(value) { + config.paddingLeft = value + } + + var paddingRight: Int + get() = config.paddingRight + set(value) { + config.paddingRight = value + } + + var paddingTop: Int + get() = config.paddingTop + set(value) { + config.paddingTop = value + } + + var headerPaddingBottom: Int + get() = config.headerPaddingBottom + set(value) { + config.headerPaddingBottom = value + } + + var headerPaddingLeft: Int + get() = config.headerPaddingLeft + set(value) { + config.headerPaddingLeft = value + } + + var headerPaddingRight: Int + get() = config.headerPaddingRight + set(value) { + config.headerPaddingRight = value + } + + var headerPaddingTop: Int + get() = config.headerPaddingTop + set(value) { + config.headerPaddingTop = value + } + + var footerPaddingBottom: Int + get() = config.footerPaddingBottom + set(value) { + config.footerPaddingBottom = value + } + + var footerPaddingLeft: Int + get() = config.footerPaddingLeft + set(value) { + config.footerPaddingLeft = value + } + + var footerPaddingRight: Int + get() = config.footerPaddingRight + set(value) { + config.footerPaddingRight = value + } + + var footerPaddingTop: Int + get() = config.footerPaddingTop + set(value) { + config.footerPaddingTop = value + } + + var showHeaderLine: Boolean + get() = config.showHeaderLine + set(value) { + config.showHeaderLine = value + } + + var showFooterLine: Boolean + get() = config.showFooterLine + set(value) { + config.showFooterLine = value + } + + fun getExportConfig(): Config { + val exportConfig = GSON.fromJsonObject(GSON.toJson(durConfig))!! + if (shareLayout) { + exportConfig.textFont = shareConfig.textFont + exportConfig.textBold = shareConfig.textBold + exportConfig.textSize = shareConfig.textSize + exportConfig.letterSpacing = shareConfig.letterSpacing + exportConfig.lineSpacingExtra = shareConfig.lineSpacingExtra + exportConfig.paragraphSpacing = shareConfig.paragraphSpacing + exportConfig.titleMode = shareConfig.titleMode + exportConfig.titleSize = shareConfig.titleSize + exportConfig.titleTopSpacing = shareConfig.titleTopSpacing + exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing + exportConfig.paddingBottom = shareConfig.paddingBottom + exportConfig.paddingLeft = shareConfig.paddingLeft + exportConfig.paddingRight = shareConfig.paddingRight + exportConfig.paddingTop = shareConfig.paddingTop + exportConfig.headerPaddingBottom = shareConfig.headerPaddingBottom + exportConfig.headerPaddingLeft = shareConfig.headerPaddingLeft + exportConfig.headerPaddingRight = shareConfig.headerPaddingRight + exportConfig.headerPaddingTop = shareConfig.headerPaddingTop + exportConfig.footerPaddingBottom = shareConfig.footerPaddingBottom + exportConfig.footerPaddingLeft = shareConfig.footerPaddingLeft + exportConfig.footerPaddingRight = shareConfig.footerPaddingRight + exportConfig.footerPaddingTop = shareConfig.footerPaddingTop + exportConfig.showHeaderLine = shareConfig.showHeaderLine + exportConfig.showFooterLine = shareConfig.showFooterLine + exportConfig.tipHeaderLeft = shareConfig.tipHeaderLeft + exportConfig.tipHeaderMiddle = shareConfig.tipHeaderMiddle + exportConfig.tipHeaderRight = shareConfig.tipHeaderRight + exportConfig.tipFooterLeft = shareConfig.tipFooterLeft + exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle + exportConfig.tipFooterRight = shareConfig.tipFooterRight + exportConfig.tipColor = shareConfig.tipColor + exportConfig.headerMode = shareConfig.headerMode + exportConfig.footerMode = shareConfig.footerMode + } + return exportConfig + } + + suspend fun import(byteArray: ByteArray): Config { + return withContext(IO) { + val configZipPath = FileUtils.getPath(appCtx.externalCache, configFileName) + FileUtils.deleteFile(configZipPath) + val zipFile = FileUtils.createFileIfNotExist(configZipPath) + zipFile.writeBytes(byteArray) + val configDirPath = FileUtils.getPath(appCtx.externalCache, "readConfig") + FileUtils.deleteFile(configDirPath) + @Suppress("BlockingMethodInNonBlockingContext") + ZipUtils.unzipFile(zipFile, FileUtils.createFolderIfNotExist(configDirPath)) + val configDir = FileUtils.createFolderIfNotExist(configDirPath) + val configFile = configDir.getFile("readConfig.json") + val config: Config = GSON.fromJsonObject(configFile.readText())!! + if (config.textFont.isNotEmpty()) { + val fontName = FileUtils.getName(config.textFont) + val fontPath = + FileUtils.getPath(appCtx.externalFiles, "font", fontName) + if (!FileUtils.exist(fontPath)) { + configDir.getFile(fontName).copyTo(File(fontPath)) + } + config.textFont = fontPath + } + if (config.bgType == 2) { + val bgName = FileUtils.getName(config.bgStr) + val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) + if (!FileUtils.exist(bgPath)) { + val bgFile = configDir.getFile(bgName) + if (bgFile.exists()) { + bgFile.copyTo(File(bgPath)) + } + } + } + if (config.bgTypeNight == 2) { + val bgName = FileUtils.getName(config.bgStrNight) + val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) + if (!FileUtils.exist(bgPath)) { + val bgFile = configDir.getFile(bgName) + if (bgFile.exists()) { + bgFile.copyTo(File(bgPath)) + } + } + } + if (config.bgTypeEInk == 2) { + val bgName = FileUtils.getName(config.bgStrEInk) + val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) + if (!FileUtils.exist(bgPath)) { + val bgFile = configDir.getFile(bgName) + if (bgFile.exists()) { + bgFile.copyTo(File(bgPath)) + } + } + } + return@withContext config + } + } + + @Keep + class Config( + var name: String = "", + var bgStr: String = "#EEEEEE",//白天背景 + var bgStrNight: String = "#000000",//夜间背景 + var bgStrEInk: String = "#FFFFFF", + var bgType: Int = 0,//白天背景类型 0:颜色, 1:assets图片, 2其它图片 + var bgTypeNight: Int = 0,//夜间背景类型 + var bgTypeEInk: Int = 0, + private var darkStatusIcon: Boolean = true,//白天是否暗色状态栏 + private var darkStatusIconNight: Boolean = false,//晚上是否暗色状态栏 + private var darkStatusIconEInk: Boolean = true, + private var textColor: String = "#3E3D3B",//白天文字颜色 + private var textColorNight: String = "#ADADAD",//夜间文字颜色 + private var textColorEInk: String = "#000000", + private var pageAnim: Int = 0, + private var pageAnimEInk: Int = 3, + var textFont: String = "",//字体 + var textBold: Int = 0,//是否粗体字 0:正常, 1:粗体, 2:细体 + var textSize: Int = 20,//文字大小 + var letterSpacing: Float = 0.1f,//字间距 + var lineSpacingExtra: Int = 12,//行间距 + var paragraphSpacing: Int = 2,//段距 + var titleMode: Int = 0,//标题居中 + var titleSize: Int = 0, + var titleTopSpacing: Int = 0, + var titleBottomSpacing: Int = 0, + var paragraphIndent: String = "  ",//段落缩进 + var paddingBottom: Int = 6, + var paddingLeft: Int = 16, + var paddingRight: Int = 16, + var paddingTop: Int = 6, + var headerPaddingBottom: Int = 0, + var headerPaddingLeft: Int = 16, + var headerPaddingRight: Int = 16, + var headerPaddingTop: Int = 0, + var footerPaddingBottom: Int = 6, + var footerPaddingLeft: Int = 16, + var footerPaddingRight: Int = 16, + var footerPaddingTop: Int = 6, + var showHeaderLine: Boolean = false, + var showFooterLine: Boolean = true, + var tipHeaderLeft: Int = ReadTipConfig.time, + var tipHeaderMiddle: Int = ReadTipConfig.none, + var tipHeaderRight: Int = ReadTipConfig.battery, + var tipFooterLeft: Int = ReadTipConfig.chapterTitle, + var tipFooterMiddle: Int = ReadTipConfig.none, + var tipFooterRight: Int = ReadTipConfig.pageAndTotal, + var tipColor: Int = 0, + var headerMode: Int = 0, + var footerMode: Int = 0 + ) { + + fun setCurTextColor(color: Int) { + when { + AppConfig.isEInkMode -> textColorEInk = "#${color.hexString}" + AppConfig.isNightTheme -> textColorNight = "#${color.hexString}" + else -> textColor = "#${color.hexString}" + } + ChapterProvider.upStyle() + } + + fun curTextColor(): Int { + return when { + AppConfig.isEInkMode -> Color.parseColor(textColorEInk) + AppConfig.isNightTheme -> Color.parseColor(textColorNight) + else -> Color.parseColor(textColor) + } + } + + fun setCurStatusIconDark(isDark: Boolean) { + when { + AppConfig.isEInkMode -> darkStatusIconEInk = isDark + AppConfig.isNightTheme -> darkStatusIconNight = isDark + else -> darkStatusIcon = isDark + } + } + + fun curStatusIconDark(): Boolean { + return when { + AppConfig.isEInkMode -> darkStatusIconEInk + AppConfig.isNightTheme -> darkStatusIconNight + else -> darkStatusIcon + } + } + + fun setCurPageAnim(anim: Int) { + when { + AppConfig.isEInkMode -> pageAnimEInk = anim + else -> pageAnim = anim + } + } + + fun curPageAnim(): Int { + return when { + AppConfig.isEInkMode -> pageAnimEInk + else -> pageAnim + } + } + + fun setCurBg(bgType: Int, bg: String) { + when { + AppConfig.isEInkMode -> { + bgTypeEInk = bgType + bgStrEInk = bg + } + AppConfig.isNightTheme -> { + bgTypeNight = bgType + bgStrNight = bg + } + else -> { + this.bgType = bgType + bgStr = bg + } + } + } + + fun curBgStr(): String { + return when { + AppConfig.isEInkMode -> bgStrEInk + AppConfig.isNightTheme -> bgStrNight + else -> bgStr + } + } + + fun curBgType(): Int { + return when { + AppConfig.isEInkMode -> bgTypeEInk + AppConfig.isNightTheme -> bgTypeNight + else -> bgType + } + } + + fun curBgDrawable(width: Int, height: Int): Drawable { + var bgDrawable: Drawable? = null + val resources = appCtx.resources + try { + bgDrawable = when (curBgType()) { + 0 -> ColorDrawable(Color.parseColor(curBgStr())) + 1 -> { + BitmapDrawable( + resources, + BitmapUtils.decodeAssetsBitmap( + appCtx, + "bg" + File.separator + curBgStr(), + width, + height + ) + ) + } + else -> BitmapDrawable( + resources, + BitmapUtils.decodeBitmap(curBgStr(), width, height) + ) + } + } catch (e: OutOfMemoryError) { + Timber.e(e) + } catch (e: Exception) { + Timber.e(e) + } + return bgDrawable ?: ColorDrawable(appCtx.getCompatColor(R.color.background)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ReadTipConfig.kt b/app/src/main/java/io/legado/app/help/ReadTipConfig.kt new file mode 100644 index 000000000..5bff7869d --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ReadTipConfig.kt @@ -0,0 +1,96 @@ +package io.legado.app.help + +import android.content.Context +import io.legado.app.R +import splitties.init.appCtx + +object ReadTipConfig { + val tips by lazy { + appCtx.resources.getStringArray(R.array.read_tip).toList() + } + const val none = 0 + const val chapterTitle = 1 + const val time = 2 + const val battery = 3 + const val page = 4 + const val totalProgress = 5 + const val pageAndTotal = 6 + const val bookName = 7 + const val timeBattery = 8 + + val tipHeaderLeftStr: String get() = tips.getOrElse(tipHeaderLeft) { tips[none] } + val tipHeaderMiddleStr: String get() = tips.getOrElse(tipHeaderMiddle) { tips[none] } + val tipHeaderRightStr: String get() = tips.getOrElse(tipHeaderRight) { tips[none] } + val tipFooterLeftStr: String get() = tips.getOrElse(tipFooterLeft) { tips[none] } + val tipFooterMiddleStr: String get() = tips.getOrElse(tipFooterMiddle) { tips[none] } + val tipFooterRightStr: String get() = tips.getOrElse(tipFooterRight) { tips[none] } + + var tipHeaderLeft: Int + get() = ReadBookConfig.config.tipHeaderLeft + set(value) { + ReadBookConfig.config.tipHeaderLeft = value + } + + var tipHeaderMiddle: Int + get() = ReadBookConfig.config.tipHeaderMiddle + set(value) { + ReadBookConfig.config.tipHeaderMiddle = value + } + + var tipHeaderRight: Int + get() = ReadBookConfig.config.tipHeaderRight + set(value) { + ReadBookConfig.config.tipHeaderRight = value + } + + var tipFooterLeft: Int + get() = ReadBookConfig.config.tipFooterLeft + set(value) { + ReadBookConfig.config.tipFooterLeft = value + } + + var tipFooterMiddle: Int + get() = ReadBookConfig.config.tipFooterMiddle + set(value) { + ReadBookConfig.config.tipFooterMiddle = value + } + + var tipFooterRight: Int + get() = ReadBookConfig.config.tipFooterRight + set(value) { + ReadBookConfig.config.tipFooterRight = value + } + + var headerMode: Int + get() = ReadBookConfig.config.headerMode + set(value) { + ReadBookConfig.config.headerMode = value + } + + var footerMode: Int + get() = ReadBookConfig.config.footerMode + set(value) { + ReadBookConfig.config.footerMode = value + } + + var tipColor: Int + get() = ReadBookConfig.config.tipColor + set(value) { + ReadBookConfig.config.tipColor = value + } + + fun getHeaderModes(context: Context): LinkedHashMap { + return linkedMapOf( + Pair(0, context.getString(R.string.hide_when_status_bar_show)), + Pair(1, context.getString(R.string.show)), + Pair(2, context.getString(R.string.hide)) + ) + } + + fun getFooterModes(context: Context): LinkedHashMap { + return linkedMapOf( + Pair(0, context.getString(R.string.show)), + Pair(1, context.getString(R.string.hide)) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/SourceAnalyzer.kt b/app/src/main/java/io/legado/app/help/SourceAnalyzer.kt new file mode 100644 index 000000000..f84159c01 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/SourceAnalyzer.kt @@ -0,0 +1,320 @@ +package io.legado.app.help + +import androidx.annotation.Keep +import com.jayway.jsonpath.JsonPath +import io.legado.app.constant.AppConst +import io.legado.app.constant.BookType +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.* +import io.legado.app.utils.* +import timber.log.Timber +import java.util.regex.Pattern + +@Suppress("RegExpRedundantEscape") +object SourceAnalyzer { + private val headerPattern = Pattern.compile("@Header:\\{.+?\\}", Pattern.CASE_INSENSITIVE) + private val jsPattern = Pattern.compile("\\{\\{.+?\\}\\}", Pattern.CASE_INSENSITIVE) + + fun jsonToBookSources(json: String): List { + val bookSources = mutableListOf() + val items: List> = jsonPath.parse(json).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + jsonToBookSource(jsonItem.jsonString())?.let { + bookSources.add(it) + } + } + return bookSources + } + + fun jsonToBookSource(json: String): BookSource? { + val source = BookSource() + val sourceAny = try { + GSON.fromJsonObject(json.trim()) + } catch (e: Exception) { + null + } + try { + if (sourceAny?.ruleToc == null) { + source.apply { + val jsonItem = jsonPath.parse(json.trim()) + bookSourceUrl = jsonItem.readString("bookSourceUrl") ?: return null + bookSourceName = jsonItem.readString("bookSourceName") ?: "" + bookSourceGroup = jsonItem.readString("bookSourceGroup") + loginUrl = jsonItem.readString("loginUrl") + loginUi = jsonItem.readString("loginUi") + loginCheckJs = jsonItem.readString("loginCheckJs") + bookSourceComment = jsonItem.readString("bookSourceComment") ?: "" + bookUrlPattern = jsonItem.readString("ruleBookUrlPattern") + customOrder = jsonItem.readInt("serialNumber") ?: 0 + header = uaToHeader(jsonItem.readString("httpUserAgent")) + searchUrl = toNewUrl(jsonItem.readString("ruleSearchUrl")) + exploreUrl = toNewUrls(jsonItem.readString("ruleFindUrl")) + bookSourceType = + if (jsonItem.readString("bookSourceType") == "AUDIO") BookType.audio else BookType.default + enabled = jsonItem.readBool("enable") ?: true + if (exploreUrl.isNullOrBlank()) { + enabledExplore = false + } + ruleSearch = SearchRule( + bookList = toNewRule(jsonItem.readString("ruleSearchList")), + name = toNewRule(jsonItem.readString("ruleSearchName")), + author = toNewRule(jsonItem.readString("ruleSearchAuthor")), + intro = toNewRule(jsonItem.readString("ruleSearchIntroduce")), + kind = toNewRule(jsonItem.readString("ruleSearchKind")), + bookUrl = toNewRule(jsonItem.readString("ruleSearchNoteUrl")), + coverUrl = toNewRule(jsonItem.readString("ruleSearchCoverUrl")), + lastChapter = toNewRule(jsonItem.readString("ruleSearchLastChapter")) + ) + ruleExplore = ExploreRule( + bookList = toNewRule(jsonItem.readString("ruleFindList")), + name = toNewRule(jsonItem.readString("ruleFindName")), + author = toNewRule(jsonItem.readString("ruleFindAuthor")), + intro = toNewRule(jsonItem.readString("ruleFindIntroduce")), + kind = toNewRule(jsonItem.readString("ruleFindKind")), + bookUrl = toNewRule(jsonItem.readString("ruleFindNoteUrl")), + coverUrl = toNewRule(jsonItem.readString("ruleFindCoverUrl")), + lastChapter = toNewRule(jsonItem.readString("ruleFindLastChapter")) + ) + ruleBookInfo = BookInfoRule( + init = toNewRule(jsonItem.readString("ruleBookInfoInit")), + name = toNewRule(jsonItem.readString("ruleBookName")), + author = toNewRule(jsonItem.readString("ruleBookAuthor")), + intro = toNewRule(jsonItem.readString("ruleIntroduce")), + kind = toNewRule(jsonItem.readString("ruleBookKind")), + coverUrl = toNewRule(jsonItem.readString("ruleCoverUrl")), + lastChapter = toNewRule(jsonItem.readString("ruleBookLastChapter")), + tocUrl = toNewRule(jsonItem.readString("ruleChapterUrl")) + ) + ruleToc = TocRule( + chapterList = toNewRule(jsonItem.readString("ruleChapterList")), + chapterName = toNewRule(jsonItem.readString("ruleChapterName")), + chapterUrl = toNewRule(jsonItem.readString("ruleContentUrl")), + nextTocUrl = toNewRule(jsonItem.readString("ruleChapterUrlNext")) + ) + var content = toNewRule(jsonItem.readString("ruleBookContent")) ?: "" + if (content.startsWith("$") && !content.startsWith("$.")) { + content = content.substring(1) + } + ruleContent = ContentRule( + content = content, + replaceRegex = toNewRule(jsonItem.readString("ruleBookContentReplace")), + nextContentUrl = toNewRule(jsonItem.readString("ruleContentUrlNext")) + ) + } + } else { + source.bookSourceUrl = sourceAny.bookSourceUrl + source.bookSourceName = sourceAny.bookSourceName + source.bookSourceGroup = sourceAny.bookSourceGroup + source.bookSourceType = sourceAny.bookSourceType + source.bookUrlPattern = sourceAny.bookUrlPattern + source.customOrder = sourceAny.customOrder + source.enabled = sourceAny.enabled + source.enabledExplore = sourceAny.enabledExplore + source.concurrentRate = sourceAny.concurrentRate + source.header = sourceAny.header + source.loginUrl = when (sourceAny.loginUrl) { + null -> null + is String -> sourceAny.loginUrl.toString() + else -> JsonPath.parse(sourceAny.loginUrl).readString("url") + } + source.loginUi = if (sourceAny.loginUi is List<*>) { + GSON.toJson(sourceAny.loginUi) + } else { + sourceAny.loginUi?.toString() + } + source.loginCheckJs = sourceAny.loginCheckJs + source.bookSourceComment = sourceAny.bookSourceComment + source.lastUpdateTime = sourceAny.lastUpdateTime + source.respondTime = sourceAny.respondTime + source.weight = sourceAny.weight + source.exploreUrl = sourceAny.exploreUrl + source.ruleExplore = if (sourceAny.ruleExplore is String) { + GSON.fromJsonObject(sourceAny.ruleExplore.toString()) + } else { + GSON.fromJsonObject(GSON.toJson(sourceAny.ruleExplore)) + } + source.searchUrl = sourceAny.searchUrl + source.ruleSearch = if (sourceAny.ruleSearch is String) { + GSON.fromJsonObject(sourceAny.ruleSearch.toString()) + } else { + GSON.fromJsonObject(GSON.toJson(sourceAny.ruleSearch)) + } + source.ruleBookInfo = if (sourceAny.ruleBookInfo is String) { + GSON.fromJsonObject(sourceAny.ruleBookInfo.toString()) + } else { + GSON.fromJsonObject(GSON.toJson(sourceAny.ruleBookInfo)) + } + source.ruleToc = if (sourceAny.ruleToc is String) { + GSON.fromJsonObject(sourceAny.ruleToc.toString()) + } else { + GSON.fromJsonObject(GSON.toJson(sourceAny.ruleToc)) + } + source.ruleContent = if (sourceAny.ruleContent is String) { + GSON.fromJsonObject(sourceAny.ruleContent.toString()) + } else { + GSON.fromJsonObject(GSON.toJson(sourceAny.ruleContent)) + } + } + } catch (e: Exception) { + Timber.e(e) + } + return source + } + + @Keep + data class BookSourceAny( + var bookSourceName: String = "", // 名称 + var bookSourceGroup: String? = null, // 分组 + var bookSourceUrl: String = "", // 地址,包括 http/https + var bookSourceType: Int = BookType.default, // 类型,0 文本,1 音频 + var bookUrlPattern: String? = null, // 详情页url正则 + var customOrder: Int = 0, // 手动排序编号 + var enabled: Boolean = true, // 是否启用 + var enabledExplore: Boolean = true, // 启用发现 + var concurrentRate: String? = null, // 并发率 + var header: String? = null, // 请求头 + var loginUrl: Any? = null, // 登录规则 + var loginUi: Any? = null, // 登录UI + var loginCheckJs: String? = null, //登录检测js + var bookSourceComment: String? = "", //书源注释 + var lastUpdateTime: Long = 0, // 最后更新时间,用于排序 + var respondTime: Long = 180000L, // 响应时间,用于排序 + var weight: Int = 0, // 智能排序的权重 + var exploreUrl: String? = null, // 发现url + var ruleExplore: Any? = null, // 发现规则 + var searchUrl: String? = null, // 搜索url + var ruleSearch: Any? = null, // 搜索规则 + var ruleBookInfo: Any? = null, // 书籍信息页规则 + var ruleToc: Any? = null, // 目录页规则 + var ruleContent: Any? = null // 正文页规则 + ) + + // default规则适配 + // #正则#替换内容 替换成 ##正则##替换内容 + // | 替换成 || + // & 替换成 && + private fun toNewRule(oldRule: String?): String? { + if (oldRule.isNullOrBlank()) return null + var newRule = oldRule + var reverse = false + var allinone = false + if (oldRule.startsWith("-")) { + reverse = true + newRule = oldRule.substring(1) + } + if (newRule.startsWith("+")) { + allinone = true + newRule = newRule.substring(1) + } + if (!newRule.startsWith("@CSS:", true) && + !newRule.startsWith("@XPath:", true) && + !newRule.startsWith("//") && + !newRule.startsWith("##") && + !newRule.startsWith(":") && + !newRule.contains("@js:", true) && + !newRule.contains("", true) + ) { + if (newRule.contains("#") && !newRule.contains("##")) { + newRule = oldRule.replace("#", "##") + } + if (newRule.contains("|") && !newRule.contains("||")) { + if (newRule.contains("##")) { + val list = newRule.split("##") + if (list[0].contains("|")) { + newRule = list[0].replace("|", "||") + for (i in 1 until list.size) { + newRule += "##" + list[i] + } + } + } else { + newRule = newRule.replace("|", "||") + } + } + if (newRule.contains("&") + && !newRule.contains("&&") + && !newRule.contains("http") + && !newRule.startsWith("/") + ) { + newRule = newRule.replace("&", "&&") + } + } + if (allinone) { + newRule = "+$newRule" + } + if (reverse) { + newRule = "-$newRule" + } + return newRule + } + + private fun toNewUrls(oldUrls: String?): String? { + if (oldUrls.isNullOrBlank()) return null + if (oldUrls.startsWith("@js:") || oldUrls.startsWith("")) { + return oldUrls + } + if (!oldUrls.contains("\n") && !oldUrls.contains("&&")) { + return toNewUrl(oldUrls) + } + val urls = oldUrls.split("(&&|\r?\n)+".toRegex()) + return urls.map { + toNewUrl(it)?.replace("\n\\s*".toRegex(), "") + }.joinToString("\n") + } + + private fun toNewUrl(oldUrl: String?): String? { + if (oldUrl.isNullOrBlank()) return null + var url: String = oldUrl + if (oldUrl.startsWith("", true)) { + url = url.replace("=searchKey", "={{key}}") + .replace("=searchPage", "={{page}}") + return url + } + val map = HashMap() + var mather = headerPattern.matcher(url) + if (mather.find()) { + val header = mather.group() + url = url.replace(header, "") + map["headers"] = header.substring(8) + } + var urlList = url.split("|") + url = urlList[0] + if (urlList.size > 1) { + map["charset"] = urlList[1].split("=")[1] + } + mather = jsPattern.matcher(url) + val jsList = arrayListOf() + while (mather.find()) { + jsList.add(mather.group()) + url = url.replace(jsList.last(), "$${jsList.size - 1}") + } + url = url.replace("{", "<").replace("}", ">") + url = url.replace("searchKey", "{{key}}") + url = url.replace("".toRegex(), "{{page$1}}") + .replace("searchPage([-+]1)".toRegex(), "{{page$1}}") + .replace("searchPage", "{{page}}") + for ((index, item) in jsList.withIndex()) { + url = url.replace( + "$$index", + item.replace("searchKey", "key").replace("searchPage", "page") + ) + } + urlList = url.split("@") + url = urlList[0] + if (urlList.size > 1) { + map["method"] = "POST" + map["body"] = urlList[1] + } + if (map.size > 0) { + url += "," + GSON.toJson(map) + } + return url + } + + private fun uaToHeader(ua: String?): String? { + if (ua.isNullOrEmpty()) return null + val map = mapOf(Pair(AppConst.UA_NAME, ua)) + return GSON.toJson(map) + } + +} diff --git a/app/src/main/java/io/legado/app/help/SourceHelp.kt b/app/src/main/java/io/legado/app/help/SourceHelp.kt new file mode 100644 index 000000000..be977ec87 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/SourceHelp.kt @@ -0,0 +1,68 @@ +package io.legado.app.help + +import android.os.Handler +import android.os.Looper +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.RssSource +import io.legado.app.utils.EncoderUtils +import io.legado.app.utils.NetworkUtils +import io.legado.app.utils.splitNotBlank +import io.legado.app.utils.toastOnUi +import splitties.init.appCtx + +object SourceHelp { + + private val handler = Handler(Looper.getMainLooper()) + private val list18Plus by lazy { + try { + return@lazy String(appCtx.assets.open("18PlusList.txt").readBytes()) + .splitNotBlank("\n") + } catch (e: Exception) { + return@lazy arrayOf() + } + } + + fun insertRssSource(vararg rssSources: RssSource) { + rssSources.forEach { rssSource -> + if (is18Plus(rssSource.sourceUrl)) { + handler.post { + appCtx.toastOnUi("${rssSource.sourceName}是18+网址,禁止导入.") + } + } else { + appDb.rssSourceDao.insert(rssSource) + } + } + } + + fun insertBookSource(vararg bookSources: BookSource) { + bookSources.forEach { bookSource -> + if (is18Plus(bookSource.bookSourceUrl)) { + handler.post { + appCtx.toastOnUi("${bookSource.bookSourceName}是18+网址,禁止导入.") + } + } else { + appDb.bookSourceDao.insert(bookSource) + } + } + } + + private fun is18Plus(url: String?): Boolean { + url ?: return false + val baseUrl = NetworkUtils.getBaseUrl(url) + baseUrl ?: return false + if (AppConfig.isGooglePlay) return false + try { + val host = baseUrl.split("//", ".") + val base64Url = EncoderUtils.base64Encode("${host[host.lastIndex - 1]}.${host.last()}") + list18Plus.forEach { + if (base64Url == it) { + return true + } + } + } catch (e: Exception) { + } + return false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ThemeConfig.kt b/app/src/main/java/io/legado/app/help/ThemeConfig.kt new file mode 100644 index 000000000..482c91df6 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ThemeConfig.kt @@ -0,0 +1,249 @@ +package io.legado.app.help + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.util.DisplayMetrics +import androidx.annotation.Keep +import androidx.appcompat.app.AppCompatDelegate +import io.legado.app.R +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.constant.Theme +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.model.BookCover +import io.legado.app.utils.* +import splitties.init.appCtx +import timber.log.Timber +import java.io.File + +object ThemeConfig { + const val configFileName = "themeConfig.json" + val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) + + val configList: ArrayList by lazy { + val cList = getConfigs() ?: DefaultData.themeConfigs + ArrayList(cList) + } + + fun applyDayNight(context: Context) { + ReadBookConfig.upBg() + applyTheme(context) + initNightMode() + BookCover.upDefaultCover() + postEvent(EventBus.RECREATE, "") + } + + private fun initNightMode() { + val targetMode = + if (AppConfig.isNightTheme) { + AppCompatDelegate.MODE_NIGHT_YES + } else { + AppCompatDelegate.MODE_NIGHT_NO + } + AppCompatDelegate.setDefaultNightMode(targetMode) + } + + fun getBgImage(context: Context, metrics: DisplayMetrics): Bitmap? { + val bgCfg = when (Theme.getTheme()) { + Theme.Light -> Pair( + context.getPrefString(PreferKey.bgImage), + context.getPrefInt(PreferKey.bgImageBlurring, 0) + ) + Theme.Dark -> Pair( + context.getPrefString(PreferKey.bgImageN), + context.getPrefInt(PreferKey.bgImageNBlurring, 0) + ) + else -> null + } ?: return null + if (bgCfg.first.isNullOrBlank()) return null + val bgImage = BitmapUtils + .decodeBitmap(bgCfg.first!!, metrics.widthPixels, metrics.heightPixels) + if (bgCfg.second == 0) { + return bgImage + } + return bgImage.stackBlur(bgCfg.second.toFloat()) + } + + fun upConfig() { + getConfigs()?.forEach { config -> + addConfig(config) + } + } + + fun save() { + val json = GSON.toJson(configList) + FileUtils.deleteFile(configFilePath) + FileUtils.createFileIfNotExist(configFilePath).writeText(json) + } + + fun delConfig(index: Int) { + configList.removeAt(index) + save() + } + + fun addConfig(json: String): Boolean { + GSON.fromJsonObject(json.trim { it < ' ' })?.let { + addConfig(it) + return true + } + return false + } + + fun addConfig(newConfig: Config) { + configList.forEachIndexed { index, config -> + if (newConfig.themeName == config.themeName) { + configList[index] = newConfig + return + } + } + configList.add(newConfig) + save() + } + + private fun getConfigs(): List? { + val configFile = File(configFilePath) + if (configFile.exists()) { + kotlin.runCatching { + val json = configFile.readText() + return GSON.fromJsonArray(json) + }.onFailure { + Timber.e(it) + } + } + return null + } + + fun applyConfig(context: Context, config: Config) { + val primary = Color.parseColor(config.primaryColor) + val accent = Color.parseColor(config.accentColor) + val background = Color.parseColor(config.backgroundColor) + val bBackground = Color.parseColor(config.bottomBackground) + if (config.isNightTheme) { + context.putPrefInt(PreferKey.cNPrimary, primary) + context.putPrefInt(PreferKey.cNAccent, accent) + context.putPrefInt(PreferKey.cNBackground, background) + context.putPrefInt(PreferKey.cNBBackground, bBackground) + } else { + context.putPrefInt(PreferKey.cPrimary, primary) + context.putPrefInt(PreferKey.cAccent, accent) + context.putPrefInt(PreferKey.cBackground, background) + context.putPrefInt(PreferKey.cBBackground, bBackground) + } + AppConfig.isNightTheme = config.isNightTheme + applyDayNight(context) + } + + fun saveDayTheme(context: Context, name: String) { + val primary = + context.getPrefInt(PreferKey.cPrimary, context.getCompatColor(R.color.md_brown_500)) + val accent = + context.getPrefInt(PreferKey.cAccent, context.getCompatColor(R.color.md_red_600)) + val background = + context.getPrefInt(PreferKey.cBackground, context.getCompatColor(R.color.md_grey_100)) + val bBackground = + context.getPrefInt(PreferKey.cBBackground, context.getCompatColor(R.color.md_grey_200)) + val config = Config( + themeName = name, + isNightTheme = false, + primaryColor = "#${primary.hexString}", + accentColor = "#${accent.hexString}", + backgroundColor = "#${background.hexString}", + bottomBackground = "#${bBackground.hexString}" + ) + addConfig(config) + } + + fun saveNightTheme(context: Context, name: String) { + val primary = + context.getPrefInt( + PreferKey.cNPrimary, + context.getCompatColor(R.color.md_blue_grey_600) + ) + val accent = + context.getPrefInt( + PreferKey.cNAccent, + context.getCompatColor(R.color.md_deep_orange_800) + ) + val background = + context.getPrefInt(PreferKey.cNBackground, context.getCompatColor(R.color.md_grey_900)) + val bBackground = + context.getPrefInt(PreferKey.cNBBackground, context.getCompatColor(R.color.md_grey_850)) + val config = Config( + themeName = name, + isNightTheme = true, + primaryColor = "#${primary.hexString}", + accentColor = "#${accent.hexString}", + backgroundColor = "#${background.hexString}", + bottomBackground = "#${bBackground.hexString}" + ) + addConfig(config) + } + + /** + * 更新主题 + */ + fun applyTheme(context: Context) = with(context) { + when { + AppConfig.isEInkMode -> { + ThemeStore.editTheme(this) + .primaryColor(Color.WHITE) + .accentColor(Color.BLACK) + .backgroundColor(Color.WHITE) + .bottomBackground(Color.WHITE) + .apply() + } + AppConfig.isNightTheme -> { + val primary = + getPrefInt(PreferKey.cNPrimary, getCompatColor(R.color.md_blue_grey_600)) + val accent = + getPrefInt(PreferKey.cNAccent, getCompatColor(R.color.md_deep_orange_800)) + var background = + getPrefInt(PreferKey.cNBackground, getCompatColor(R.color.md_grey_900)) + if (ColorUtils.isColorLight(background)) { + background = getCompatColor(R.color.md_grey_900) + putPrefInt(PreferKey.cNBackground, background) + } + val bBackground = + getPrefInt(PreferKey.cNBBackground, getCompatColor(R.color.md_grey_850)) + ThemeStore.editTheme(this) + .primaryColor(ColorUtils.withAlpha(primary, 1f)) + .accentColor(ColorUtils.withAlpha(accent, 1f)) + .backgroundColor(ColorUtils.withAlpha(background, 1f)) + .bottomBackground(ColorUtils.withAlpha(bBackground, 1f)) + .apply() + } + else -> { + val primary = + getPrefInt(PreferKey.cPrimary, getCompatColor(R.color.md_brown_500)) + val accent = + getPrefInt(PreferKey.cAccent, getCompatColor(R.color.md_red_600)) + var background = + getPrefInt(PreferKey.cBackground, getCompatColor(R.color.md_grey_100)) + if (!ColorUtils.isColorLight(background)) { + background = getCompatColor(R.color.md_grey_100) + putPrefInt(PreferKey.cBackground, background) + } + val bBackground = + getPrefInt(PreferKey.cBBackground, getCompatColor(R.color.md_grey_200)) + ThemeStore.editTheme(this) + .primaryColor(ColorUtils.withAlpha(primary, 1f)) + .accentColor(ColorUtils.withAlpha(accent, 1f)) + .backgroundColor(ColorUtils.withAlpha(background, 1f)) + .bottomBackground(ColorUtils.withAlpha(bBackground, 1f)) + .apply() + } + } + } + + @Keep + class Config( + var themeName: String, + var isNightTheme: Boolean, + var primaryColor: String, + var accentColor: String, + var backgroundColor: String, + var bottomBackground: String + ) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt b/app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt new file mode 100644 index 000000000..14c123378 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt @@ -0,0 +1,84 @@ +package io.legado.app.help.coroutine + +@Suppress("unused") +class CompositeCoroutine : CoroutineContainer { + + private var resources: HashSet>? = null + + val size: Int + get() = resources?.size ?: 0 + + val isEmpty: Boolean + get() = size == 0 + + constructor() + + constructor(vararg coroutines: Coroutine<*>) { + this.resources = hashSetOf(*coroutines) + } + + constructor(coroutines: Iterable>) { + this.resources = hashSetOf() + for (d in coroutines) { + this.resources?.add(d) + } + } + + override fun add(coroutine: Coroutine<*>): Boolean { + synchronized(this) { + var set: HashSet>? = resources + if (resources == null) { + set = hashSetOf() + resources = set + } + return set!!.add(coroutine) + } + } + + override fun addAll(vararg coroutines: Coroutine<*>): Boolean { + synchronized(this) { + var set: HashSet>? = resources + if (resources == null) { + set = hashSetOf() + resources = set + } + for (coroutine in coroutines) { + val add = set!!.add(coroutine) + if (!add) { + return false + } + } + } + return true + } + + override fun remove(coroutine: Coroutine<*>): Boolean { + if (delete(coroutine)) { + coroutine.cancel() + return true + } + return false + } + + override fun delete(coroutine: Coroutine<*>): Boolean { + synchronized(this) { + val set = resources + if (set == null || !set.remove(coroutine)) { + return false + } + } + return true + } + + override fun clear() { + val set: HashSet>? + synchronized(this) { + set = resources + resources = null + } + + set?.forEachIndexed { _, coroutine -> + coroutine.cancel() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt b/app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt new file mode 100644 index 000000000..779cf609b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt @@ -0,0 +1,216 @@ +package io.legado.app.help.coroutine + +import kotlinx.coroutines.* +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + + +@Suppress("unused") +class Coroutine( + val scope: CoroutineScope, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T +) { + + companion object { + + private val DEFAULT = MainScope() + + fun async( + scope: CoroutineScope = DEFAULT, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T + ): Coroutine { + return Coroutine(scope, context, block) + } + + } + + private val job: Job + + private var start: VoidCallback? = null + private var success: Callback? = null + private var error: Callback? = null + private var finally: VoidCallback? = null + private var cancel: VoidCallback? = null + + private var timeMillis: Long? = null + private var errorReturn: Result? = null + + val isCancelled: Boolean + get() = job.isCancelled + + val isActive: Boolean + get() = job.isActive + + val isCompleted: Boolean + get() = job.isCompleted + + init { + this.job = executeInternal(context, block) + } + + fun timeout(timeMillis: () -> Long): Coroutine { + this.timeMillis = timeMillis() + return this@Coroutine + } + + fun timeout(timeMillis: Long): Coroutine { + this.timeMillis = timeMillis + return this@Coroutine + } + + fun onErrorReturn(value: () -> T?): Coroutine { + this.errorReturn = Result(value()) + return this@Coroutine + } + + fun onErrorReturn(value: T?): Coroutine { + this.errorReturn = Result(value) + return this@Coroutine + } + + fun onStart( + context: CoroutineContext? = null, + block: (suspend CoroutineScope.() -> Unit) + ): Coroutine { + this.start = VoidCallback(context, block) + return this@Coroutine + } + + fun onSuccess( + context: CoroutineContext? = null, + block: suspend CoroutineScope.(T) -> Unit + ): Coroutine { + this.success = Callback(context, block) + return this@Coroutine + } + + fun onError( + context: CoroutineContext? = null, + block: suspend CoroutineScope.(Throwable) -> Unit + ): Coroutine { + this.error = Callback(context, block) + return this@Coroutine + } + + fun onFinally( + context: CoroutineContext? = null, + block: suspend CoroutineScope.() -> Unit + ): Coroutine { + this.finally = VoidCallback(context, block) + return this@Coroutine + } + + fun onCancel( + context: CoroutineContext? = null, + block: suspend CoroutineScope.() -> Unit + ): Coroutine { + this.cancel = VoidCallback(context, block) + return this@Coroutine + } + + //取消当前任务 + fun cancel(cause: CancellationException? = null) { + job.cancel(cause) + cancel?.let { + MainScope().launch { + if (null == it.context) { + it.block.invoke(scope) + } else { + withContext(scope.coroutineContext.plus(it.context)) { + it.block.invoke(this) + } + } + } + } + } + + fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle { + return job.invokeOnCompletion(handler) + } + + private fun executeInternal( + context: CoroutineContext, + block: suspend CoroutineScope.() -> T + ): Job { + return scope.plus(Dispatchers.Main).launch { + try { + start?.let { dispatchVoidCallback(this, it) } + ensureActive() + val value = executeBlock(scope, context, timeMillis ?: 0L, block) + ensureActive() + success?.let { dispatchCallback(this, value, it) } + } catch (e: CancellationException) { + Timber.e("任务取消") + } catch (e: Throwable) { + Timber.e(e) + val consume: Boolean = errorReturn?.value?.let { value -> + if (isActive) { + success?.let { dispatchCallback(this, value, it) } + } + true + } ?: false + if (!consume && isActive) { + error?.let { dispatchCallback(this, e, it) } + } + } finally { + if (isActive) { + finally?.let { dispatchVoidCallback(this, it) } + } + } + } + } + + private suspend inline fun dispatchVoidCallback(scope: CoroutineScope, callback: VoidCallback) { + if (null == callback.context) { + callback.block.invoke(scope) + } else { + withContext(scope.coroutineContext.plus(callback.context)) { + callback.block.invoke(this) + } + } + } + + private suspend inline fun dispatchCallback( + scope: CoroutineScope, + value: R, + callback: Callback + ) { + if (!scope.isActive) return + if (null == callback.context) { + callback.block.invoke(scope, value) + } else { + withContext(scope.coroutineContext.plus(callback.context)) { + callback.block.invoke(this, value) + } + } + } + + private suspend inline fun executeBlock( + scope: CoroutineScope, + context: CoroutineContext, + timeMillis: Long, + noinline block: suspend CoroutineScope.() -> T + ): T { + return withContext(scope.coroutineContext.plus(context)) { + if (timeMillis > 0L) withTimeout(timeMillis) { + block() + } else { + block() + } + } + } + + private data class Result(val value: T?) + + private inner class VoidCallback( + val context: CoroutineContext?, + val block: suspend CoroutineScope.() -> Unit + ) + + private inner class Callback( + val context: CoroutineContext?, + val block: suspend CoroutineScope.(VALUE) -> Unit + ) +} diff --git a/app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt b/app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt new file mode 100644 index 000000000..8ef02af1f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt @@ -0,0 +1,15 @@ +package io.legado.app.help.coroutine + +internal interface CoroutineContainer { + + fun add(coroutine: Coroutine<*>): Boolean + + fun addAll(vararg coroutines: Coroutine<*>): Boolean + + fun remove(coroutine: Coroutine<*>): Boolean + + fun delete(coroutine: Coroutine<*>): Boolean + + fun clear() + +} diff --git a/app/src/main/java/io/legado/app/help/exoplayer/ExoPlayerHelper.kt b/app/src/main/java/io/legado/app/help/exoplayer/ExoPlayerHelper.kt new file mode 100644 index 000000000..fe684de62 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/exoplayer/ExoPlayerHelper.kt @@ -0,0 +1,63 @@ +package io.legado.app.help.exoplayer + +import android.net.Uri +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.database.StandaloneDatabaseProvider +import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.upstream.cache.Cache +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import io.legado.app.help.http.okHttpClient +import splitties.init.appCtx +import java.io.File + + +object ExoPlayerHelper { + + fun createMediaSource( + uri: Uri, + defaultRequestProperties: Map + ): MediaSource { + val mediaItem = MediaItem.fromUri(uri) + val mediaSourceFactory = ProgressiveMediaSource.Factory( + cacheDataSourceFactory.setDefaultRequestProperties(defaultRequestProperties) + ) + return mediaSourceFactory.createMediaSource(mediaItem) + } + + + /** + * 支持缓存的DataSource.Factory + */ + private val cacheDataSourceFactory by lazy { + //使用自定义的CacheDataSource以支持设置UA + return@lazy OkhttpCacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(okhttpDataFactory) + } + + /** + * Okhttp DataSource.Factory + */ + private val okhttpDataFactory by lazy { + OkHttpDataSource.Factory(okHttpClient) + } + + /** + * Exoplayer 内置的缓存 + */ + private val cache: Cache by lazy { + val databaseProvider = StandaloneDatabaseProvider(appCtx) + return@lazy SimpleCache( + //Exoplayer的缓存路径 + File(appCtx.externalCacheDir, "exoplayer"), + //100M的缓存 + LeastRecentlyUsedCacheEvictor((100 * 1024 * 1024).toLong()), + //记录缓存的数据库 + databaseProvider + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/exoplayer/OkhttpCacheDataSource.java b/app/src/main/java/io/legado/app/help/exoplayer/OkhttpCacheDataSource.java new file mode 100644 index 000000000..bcd63e897 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/exoplayer/OkhttpCacheDataSource.java @@ -0,0 +1,915 @@ +package io.legado.app.help.exoplayer; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; + +import android.net.Uri; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource; +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DummyDataSource; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.PriorityDataSource; +import com.google.android.exoplayer2.upstream.TeeDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import com.google.android.exoplayer2.upstream.cache.CacheDataSink; +import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; +import com.google.android.exoplayer2.upstream.cache.CacheSpan; +import com.google.android.exoplayer2.upstream.cache.ContentMetadata; +import com.google.android.exoplayer2.upstream.cache.ContentMetadataMutations; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.PriorityTaskManager; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A {@link DataSource} that reads and writes a {@link Cache}. Requests are fulfilled from the cache + * when possible. When data is not cached it is requested from an upstream {@link DataSource} and + * written into the cache. + */ +@SuppressWarnings("unused") +public final class OkhttpCacheDataSource implements DataSource { + + /** + * {@link DataSource.Factory} for {@link OkhttpCacheDataSource} instances. + */ + @SuppressWarnings("unused") + public static final class Factory implements DataSource.Factory { + + private Cache cache; + private DataSource.Factory cacheReadDataSourceFactory; + @Nullable + private DataSink.Factory cacheWriteDataSinkFactory; + private CacheKeyFactory cacheKeyFactory; + private boolean cacheIsReadOnly; + @Nullable + private OkHttpDataSource.Factory upstreamDataSourceFactory; + @Nullable + private PriorityTaskManager upstreamPriorityTaskManager; + private int upstreamPriority; + @OkhttpCacheDataSource.Flags + private int flags; + @Nullable + private OkhttpCacheDataSource.EventListener eventListener; + + public Factory() { + cacheReadDataSourceFactory = new FileDataSource.Factory(); + cacheKeyFactory = CacheKeyFactory.DEFAULT; + } + + /** + * Sets the cache that will be used. + * + *

    Must be called before the factory is used. + * + * @param cache The cache that will be used. + * @return This factory. + */ + public Factory setCache(Cache cache) { + this.cache = cache; + return this; + } + + /** + * Returns the cache that will be used, or {@code null} if {@link #setCache} has yet to be + * called. + */ + @Nullable + public Cache getCache() { + return cache; + } + + /** + * Sets the {@link DataSource.Factory} for {@link DataSource DataSources} for reading from the + * cache. + * + *

    The default is a {@link FileDataSource.Factory} in its default configuration. + * + * @param cacheReadDataSourceFactory The {@link DataSource.Factory} for reading from the cache. + * @return This factory. + */ + public Factory setCacheReadDataSourceFactory(DataSource.Factory cacheReadDataSourceFactory) { + this.cacheReadDataSourceFactory = cacheReadDataSourceFactory; + return this; + } + + /** + * Sets the {@link DataSink.Factory} for generating {@link DataSink DataSinks} for writing data + * to the cache. Passing {@code null} causes the cache to be read-only. + * + *

    The default is a {@link CacheDataSink.Factory} in its default configuration. + * + * @param cacheWriteDataSinkFactory The {@link DataSink.Factory} for generating {@link DataSink + * DataSinks} for writing data to the cache, or {@code null} to disable writing. + * @return This factory. + */ + public Factory setCacheWriteDataSinkFactory( + @Nullable DataSink.Factory cacheWriteDataSinkFactory) { + this.cacheWriteDataSinkFactory = cacheWriteDataSinkFactory; + this.cacheIsReadOnly = cacheWriteDataSinkFactory == null; + return this; + } + + /** + * Sets the {@link CacheKeyFactory}. + * + *

    The default is {@link CacheKeyFactory#DEFAULT}. + * + * @param cacheKeyFactory The {@link CacheKeyFactory}. + * @return This factory. + */ + public Factory setCacheKeyFactory(CacheKeyFactory cacheKeyFactory) { + this.cacheKeyFactory = cacheKeyFactory; + return this; + } + + /** + * Returns the {@link CacheKeyFactory} that will be used. + */ + public CacheKeyFactory getCacheKeyFactory() { + return cacheKeyFactory; + } + + /** + * Sets the {@link DataSource.Factory} for upstream {@link DataSource DataSources}, which are + * used to read data in the case of a cache miss. + * + *

    The default is {@code null}, and so this method must be called before the factory is used + * in order for data to be read from upstream in the case of a cache miss. + * + * @param upstreamDataSourceFactory The upstream {@link DataSource} for reading data not in the + * cache, or {@code null} to cause failure in the case of a cache miss. + * @return This factory. + */ + public Factory setUpstreamDataSourceFactory( + @Nullable OkHttpDataSource.Factory upstreamDataSourceFactory) { + this.upstreamDataSourceFactory = upstreamDataSourceFactory; + return this; + } + + public Factory setUserAgent(String userAgent) { + if (this.upstreamDataSourceFactory != null) { + this.upstreamDataSourceFactory.setUserAgent(userAgent); + } + return this; + } + + public Factory setDefaultRequestProperties(Map defaultRequestProperties){ + if (this.upstreamDataSourceFactory != null) { + this.upstreamDataSourceFactory.setDefaultRequestProperties(defaultRequestProperties); + } + return this; + } + + /** + * Sets an optional {@link PriorityTaskManager} to use when requesting data from upstream. + * + *

    If set, reads from the upstream {@link DataSource} will only be allowed to proceed if + * there are no higher priority tasks registered to the {@link PriorityTaskManager}. If there + * exists a higher priority task then {@link PriorityTaskManager.PriorityTooLowException} will + * be thrown instead. + * + *

    Note that requests to {@link OkhttpCacheDataSource} instances are intended to be used as parts + * of (possibly larger) tasks that are registered with the {@link PriorityTaskManager}, and + * hence {@link OkhttpCacheDataSource} does not register a task by itself. This must be done + * by the surrounding code that uses the {@link OkhttpCacheDataSource} instances. + * + *

    The default is {@code null}. + * + * @param upstreamPriorityTaskManager The upstream {@link PriorityTaskManager}. + * @return This factory. + */ + public Factory setUpstreamPriorityTaskManager( + @Nullable PriorityTaskManager upstreamPriorityTaskManager) { + this.upstreamPriorityTaskManager = upstreamPriorityTaskManager; + return this; + } + + /** + * Returns the {@link PriorityTaskManager} that will bs used when requesting data from upstream, + * or {@code null} if there is none. + */ + @Nullable + public PriorityTaskManager getUpstreamPriorityTaskManager() { + return upstreamPriorityTaskManager; + } + + /** + * Sets the priority to use when requesting data from upstream. The priority is only used if a + * {@link PriorityTaskManager} is set by calling {@link #setUpstreamPriorityTaskManager}. + * + *

    The default is {@link C#PRIORITY_PLAYBACK}. + * + * @param upstreamPriority The priority to use when requesting data from upstream. + * @return This factory. + */ + public Factory setUpstreamPriority(int upstreamPriority) { + this.upstreamPriority = upstreamPriority; + return this; + } + + /** + * Sets the {@link OkhttpCacheDataSource.Flags}. + * + *

    The default is {@code 0}. + * + * @param flags The {@link OkhttpCacheDataSource.Flags}. + * @return This factory. + */ + public Factory setFlags(@OkhttpCacheDataSource.Flags int flags) { + this.flags = flags; + return this; + } + + /** + * Sets the {link EventListener} to which events are delivered. + * + *

    The default is {@code null}. + * + * @param eventListener The {@link EventListener}. + * @return This factory. + */ + public Factory setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + return this; + } + + @Override + public OkhttpCacheDataSource createDataSource() { + return createDataSourceInternal( + upstreamDataSourceFactory != null ? upstreamDataSourceFactory.createDataSource() : null, + flags, + upstreamPriority); + } + + /** + * Returns an instance suitable for downloading content. The created instance is equivalent to + * one that would be created by {@link #createDataSource()}, except: + * + *

      + *
    • The {@link #FLAG_BLOCK_ON_CACHE} is always set. + *
    • The task priority is overridden to be {@link C#PRIORITY_DOWNLOAD}. + *
    + * + * @return An instance suitable for downloading content. + */ + public OkhttpCacheDataSource createDataSourceForDownloading() { + return createDataSourceInternal( + upstreamDataSourceFactory != null ? upstreamDataSourceFactory.createDataSource() : null, + flags | FLAG_BLOCK_ON_CACHE, + C.PRIORITY_DOWNLOAD); + } + + /** + * Returns an instance suitable for reading cached content as part of removing a download. The + * created instance is equivalent to one that would be created by {@link #createDataSource()}, + * except: + * + *
      + *
    • The upstream is overridden to be {@code null}, since when removing content we don't + * want to request anything that's not already cached. + *
    • The {@link #FLAG_BLOCK_ON_CACHE} is always set. + *
    • The task priority is overridden to be {@link C#PRIORITY_DOWNLOAD}. + *
    + * + * @return An instance suitable for reading cached content as part of removing a download. + */ + public OkhttpCacheDataSource createDataSourceForRemovingDownload() { + return createDataSourceInternal( + /* upstreamDataSource= */ null, flags | FLAG_BLOCK_ON_CACHE, C.PRIORITY_DOWNLOAD); + } + + private OkhttpCacheDataSource createDataSourceInternal( + @Nullable DataSource upstreamDataSource, @Flags int flags, int upstreamPriority) { + Cache cache = checkNotNull(this.cache); + @Nullable DataSink cacheWriteDataSink; + if (cacheIsReadOnly || upstreamDataSource == null) { + cacheWriteDataSink = null; + } else if (cacheWriteDataSinkFactory != null) { + cacheWriteDataSink = cacheWriteDataSinkFactory.createDataSink(); + } else { + cacheWriteDataSink = new CacheDataSink.Factory().setCache(cache).createDataSink(); + } + return new OkhttpCacheDataSource( + cache, + upstreamDataSource, + cacheReadDataSourceFactory.createDataSource(), + cacheWriteDataSink, + cacheKeyFactory, + flags, + upstreamPriorityTaskManager, + upstreamPriority, + eventListener); + } + } + + /** + * Listener of {@link OkhttpCacheDataSource} events. + */ + public interface EventListener { + + /** + * Called when bytes have been read from the cache. + * + * @param cacheSizeBytes Current cache size in bytes. + * @param cachedBytesRead Total bytes read from the cache since this method was last called. + */ + void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead); + + /** + * Called when the current request ignores cache. + * + * @param reason Reason cache is bypassed. + */ + void onCacheIgnored(@CacheIgnoredReason int reason); + } + + /** + * Flags controlling the OkhttpCacheDataSource's behavior. Possible flag values are {@link + * #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} and {@link + * #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FLAG_BLOCK_ON_CACHE, + FLAG_IGNORE_CACHE_ON_ERROR, + FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS + }) + public @interface Flags { + } + + /** + * A flag indicating whether we will block reads if the cache key is locked. If unset then data is + * read from upstream if the cache key is locked, regardless of whether the data is cached. + */ + public static final int FLAG_BLOCK_ON_CACHE = 1; + + /** + * A flag indicating whether the cache is bypassed following any cache related error. If set then + * cache related exceptions may be thrown for one cycle of open, read and close calls. Subsequent + * cycles of these calls will then bypass the cache. + */ + public static final int FLAG_IGNORE_CACHE_ON_ERROR = 1 << 1; // 2 + + /** + * A flag indicating that the cache should be bypassed for requests whose lengths are unset. This + * flag is provided for legacy reasons only. + */ + public static final int FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS = 1 << 2; // 4 + + /** + * Reasons the cache may be ignored. One of {@link #CACHE_IGNORED_REASON_ERROR} or {@link + * #CACHE_IGNORED_REASON_UNSET_LENGTH}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({CACHE_IGNORED_REASON_ERROR, CACHE_IGNORED_REASON_UNSET_LENGTH}) + public @interface CacheIgnoredReason { + } + + /** + * Cache not ignored. + */ + private static final int CACHE_NOT_IGNORED = -1; + + /** + * Cache ignored due to a cache related error. + */ + public static final int CACHE_IGNORED_REASON_ERROR = 0; + + /** + * Cache ignored due to a request with an unset length. + */ + public static final int CACHE_IGNORED_REASON_UNSET_LENGTH = 1; + + /** + * Minimum number of bytes to read before checking cache for availability. + */ + private static final long MIN_READ_BEFORE_CHECKING_CACHE = 100 * 1024; + + private final Cache cache; + private final DataSource cacheReadDataSource; + @Nullable + private final DataSource cacheWriteDataSource; + private final DataSource upstreamDataSource; + private final CacheKeyFactory cacheKeyFactory; + @Nullable + private final EventListener eventListener; + + private final boolean blockOnCache; + private final boolean ignoreCacheOnError; + private final boolean ignoreCacheForUnsetLengthRequests; + + @Nullable + private Uri actualUri; + @Nullable + private DataSpec requestDataSpec; + @Nullable + private DataSpec currentDataSpec; + @Nullable + private DataSource currentDataSource; + private long currentDataSourceBytesRead; + private long readPosition; + private long bytesRemaining; + @Nullable + private CacheSpan currentHoleSpan; + private boolean seenCacheError; + private boolean currentRequestIgnoresCache; + private long totalCachedBytesRead; + private long checkCachePosition; + + /** + * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. + * + * @param cache The cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. + */ + public OkhttpCacheDataSource(Cache cache, @Nullable DataSource upstreamDataSource) { + this(cache, upstreamDataSource, /* flags= */ 0); + } + + /** + * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. + * + * @param cache The cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} + * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. + */ + public OkhttpCacheDataSource(Cache cache, @Nullable DataSource upstreamDataSource, @Flags int flags) { + this( + cache, + upstreamDataSource, + new FileDataSource(), + new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), + flags, + /* eventListener= */ null); + } + + /** + * Constructs an instance with arbitrary {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. One use of this constructor is to allow data to be transformed + * before it is written to disk. + * + * @param cache The cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. + * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. + * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is + * accessed read-only. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} + * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. + * @param eventListener An optional {@link EventListener} to receive events. + */ + public OkhttpCacheDataSource( + Cache cache, + @Nullable DataSource upstreamDataSource, + DataSource cacheReadDataSource, + @Nullable DataSink cacheWriteDataSink, + @Flags int flags, + @Nullable EventListener eventListener) { + this( + cache, + upstreamDataSource, + cacheReadDataSource, + cacheWriteDataSink, + flags, + eventListener, + /* cacheKeyFactory= */ null); + } + + /** + * Constructs an instance with arbitrary {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. One use of this constructor is to allow data to be transformed + * before it is written to disk. + * + * @param cache The cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. + * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. + * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is + * accessed read-only. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} + * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. + * @param eventListener An optional {@link EventListener} to receive events. + * @param cacheKeyFactory An optional factory for cache keys. + */ + public OkhttpCacheDataSource( + Cache cache, + @Nullable DataSource upstreamDataSource, + DataSource cacheReadDataSource, + @Nullable DataSink cacheWriteDataSink, + @Flags int flags, + @Nullable EventListener eventListener, + @Nullable CacheKeyFactory cacheKeyFactory) { + this( + cache, + upstreamDataSource, + cacheReadDataSource, + cacheWriteDataSink, + cacheKeyFactory, + flags, + /* upstreamPriorityTaskManager= */ null, + /* upstreamPriority= */ C.PRIORITY_PLAYBACK, + eventListener); + } + + private OkhttpCacheDataSource( + Cache cache, + @Nullable DataSource upstreamDataSource, + DataSource cacheReadDataSource, + @Nullable DataSink cacheWriteDataSink, + @Nullable CacheKeyFactory cacheKeyFactory, + @Flags int flags, + @Nullable PriorityTaskManager upstreamPriorityTaskManager, + int upstreamPriority, + @Nullable EventListener eventListener) { + this.cache = cache; + this.cacheReadDataSource = cacheReadDataSource; + this.cacheKeyFactory = cacheKeyFactory != null ? cacheKeyFactory : CacheKeyFactory.DEFAULT; + this.blockOnCache = (flags & FLAG_BLOCK_ON_CACHE) != 0; + this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0; + this.ignoreCacheForUnsetLengthRequests = + (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0; + if (upstreamDataSource != null) { + if (upstreamPriorityTaskManager != null) { + upstreamDataSource = + new PriorityDataSource( + upstreamDataSource, upstreamPriorityTaskManager, upstreamPriority); + } + this.upstreamDataSource = upstreamDataSource; + this.cacheWriteDataSource = + cacheWriteDataSink != null + ? new TeeDataSource(upstreamDataSource, cacheWriteDataSink) + : null; + } else { + this.upstreamDataSource = DummyDataSource.INSTANCE; + this.cacheWriteDataSource = null; + } + this.eventListener = eventListener; + } + + /** + * Returns the {@link Cache} used by this instance. + */ + public Cache getCache() { + return cache; + } + + /** + * Returns the {@link CacheKeyFactory} used by this instance. + */ + public CacheKeyFactory getCacheKeyFactory() { + return cacheKeyFactory; + } + + @Override + public void addTransferListener(@NonNull TransferListener transferListener) { + checkNotNull(transferListener); + cacheReadDataSource.addTransferListener(transferListener); + upstreamDataSource.addTransferListener(transferListener); + } + + @Override + public long open(@NonNull DataSpec dataSpec) throws IOException { + try { + String key = cacheKeyFactory.buildCacheKey(dataSpec); + DataSpec requestDataSpec = dataSpec.buildUpon().setKey(key).build(); + this.requestDataSpec = requestDataSpec; + actualUri = getRedirectedUriOrDefault(cache, key, /* defaultUri= */ requestDataSpec.uri); + readPosition = dataSpec.position; + + int reason = shouldIgnoreCacheForRequest(dataSpec); + currentRequestIgnoresCache = reason != CACHE_NOT_IGNORED; + if (currentRequestIgnoresCache) { + notifyCacheIgnored(reason); + } + + if (currentRequestIgnoresCache) { + bytesRemaining = C.LENGTH_UNSET; + } else { + bytesRemaining = ContentMetadata.getContentLength(cache.getContentMetadata(key)); + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= dataSpec.position; + if (bytesRemaining < 0) { + throw new DataSourceException( + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE); + } + } + } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = + bytesRemaining == C.LENGTH_UNSET + ? dataSpec.length + : min(bytesRemaining, dataSpec.length); + } + if (bytesRemaining > 0 || bytesRemaining == C.LENGTH_UNSET) { + openNextSource(requestDataSpec, false); + } + return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : bytesRemaining; + } catch (Throwable e) { + handleBeforeThrow(e); + throw e; + } + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int length) throws IOException { + DataSpec requestDataSpec = checkNotNull(this.requestDataSpec); + DataSpec currentDataSpec = checkNotNull(this.currentDataSpec); + if (length == 0) { + return 0; + } + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + try { + if (readPosition >= checkCachePosition) { + openNextSource(requestDataSpec, true); + } + int bytesRead = checkNotNull(currentDataSource).read(buffer, offset, length); + if (bytesRead != C.RESULT_END_OF_INPUT) { + if (isReadingFromCache()) { + totalCachedBytesRead += bytesRead; + } + readPosition += bytesRead; + currentDataSourceBytesRead += bytesRead; + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + } else if (isReadingFromUpstream() + && (currentDataSpec.length == C.LENGTH_UNSET + || currentDataSourceBytesRead < currentDataSpec.length)) { + // We've encountered RESULT_END_OF_INPUT from the upstream DataSource at a position not + // imposed by the current DataSpec. This must mean that we've reached the end of the + // resource. + setNoBytesRemainingAndMaybeStoreLength(castNonNull(requestDataSpec.key)); + } else if (bytesRemaining > 0 || bytesRemaining == C.LENGTH_UNSET) { + closeCurrentSource(); + openNextSource(requestDataSpec, false); + return read(buffer, offset, length); + } + return bytesRead; + } catch (Throwable e) { + handleBeforeThrow(e); + throw e; + } + } + + + @SuppressWarnings("NullableProblems") + @Nullable + @Override + public Uri getUri() { + return actualUri; + } + + @NonNull + @Override + public Map> getResponseHeaders() { + // TODO: Implement. + return isReadingFromUpstream() + ? upstreamDataSource.getResponseHeaders() + : Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + requestDataSpec = null; + actualUri = null; + readPosition = 0; + notifyBytesRead(); + try { + closeCurrentSource(); + } catch (Throwable e) { + handleBeforeThrow(e); + throw e; + } + } + + /** + * Opens the next source. If the cache contains data spanning the current read position then + * {@link #cacheReadDataSource} is opened to read from it. Else {@link #upstreamDataSource} is + * opened to read from the upstream source and write into the cache. + * + *

    There must not be a currently open source when this method is called, except in the case + * that {@code checkCache} is true. If {@code checkCache} is true then there must be a currently + * open source, and it must be {@link #upstreamDataSource}. It will be closed and a new source + * opened if it's possible to switch to reading from or writing to the cache. If a switch isn't + * possible then the current source is left unchanged. + * + * @param requestDataSpec The original {@link DataSpec} to build upon for the next source. + * @param checkCache If true tries to switch to reading from or writing to cache instead of + * reading from {@link #upstreamDataSource}, which is the currently open source. + */ + private void openNextSource(DataSpec requestDataSpec, boolean checkCache) throws IOException { + @Nullable CacheSpan nextSpan; + String key = castNonNull(requestDataSpec.key); + if (currentRequestIgnoresCache) { + nextSpan = null; + } else if (blockOnCache) { + try { + nextSpan = cache.startReadWrite(key, readPosition, bytesRemaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException(); + } + } else { + nextSpan = cache.startReadWriteNonBlocking(key, readPosition, bytesRemaining); + } + + DataSpec nextDataSpec; + DataSource nextDataSource; + if (nextSpan == null) { + // The data is locked in the cache, or we're ignoring the cache. Bypass the cache and read + // from upstream. + nextDataSource = upstreamDataSource; + nextDataSpec = + requestDataSpec.buildUpon().setPosition(readPosition).setLength(bytesRemaining).build(); + } else if (nextSpan.isCached) { + // Data is cached in a span file starting at nextSpan.position. + Uri fileUri = Uri.fromFile(castNonNull(nextSpan.file)); + long filePositionOffset = nextSpan.position; + long positionInFile = readPosition - filePositionOffset; + long length = nextSpan.length - positionInFile; + if (bytesRemaining != C.LENGTH_UNSET) { + length = min(length, bytesRemaining); + } + nextDataSpec = + requestDataSpec + .buildUpon() + .setUri(fileUri) + .setUriPositionOffset(filePositionOffset) + .setPosition(positionInFile) + .setLength(length) + .build(); + nextDataSource = cacheReadDataSource; + } else { + // Data is not cached, and data is not locked, read from upstream with cache backing. + long length; + if (nextSpan.isOpenEnded()) { + length = bytesRemaining; + } else { + length = nextSpan.length; + if (bytesRemaining != C.LENGTH_UNSET) { + length = min(length, bytesRemaining); + } + } + nextDataSpec = + requestDataSpec.buildUpon().setPosition(readPosition).setLength(length).build(); + if (cacheWriteDataSource != null) { + nextDataSource = cacheWriteDataSource; + } else { + nextDataSource = upstreamDataSource; + cache.releaseHoleSpan(nextSpan); + nextSpan = null; + } + } + + checkCachePosition = + !currentRequestIgnoresCache && nextDataSource == upstreamDataSource + ? readPosition + MIN_READ_BEFORE_CHECKING_CACHE + : Long.MAX_VALUE; + if (checkCache) { + Assertions.checkState(isBypassingCache()); + if (nextDataSource == upstreamDataSource) { + // Continue reading from upstream. + return; + } + // We're switching to reading from or writing to the cache. + try { + closeCurrentSource(); + } catch (Throwable e) { + if (castNonNull(nextSpan).isHoleSpan()) { + // Release the hole span before throwing, else we'll hold it forever. + cache.releaseHoleSpan(nextSpan); + } + throw e; + } + } + + if (nextSpan != null && nextSpan.isHoleSpan()) { + currentHoleSpan = nextSpan; + } + currentDataSource = nextDataSource; + currentDataSpec = nextDataSpec; + currentDataSourceBytesRead = 0; + long resolvedLength = nextDataSource.open(nextDataSpec); + + // Update bytesRemaining, actualUri and (if writing to cache) the cache metadata. + ContentMetadataMutations mutations = new ContentMetadataMutations(); + if (nextDataSpec.length == C.LENGTH_UNSET && resolvedLength != C.LENGTH_UNSET) { + bytesRemaining = resolvedLength; + ContentMetadataMutations.setContentLength(mutations, readPosition + bytesRemaining); + } + if (isReadingFromUpstream()) { + actualUri = nextDataSource.getUri(); + boolean isRedirected = !requestDataSpec.uri.equals(actualUri); + ContentMetadataMutations.setRedirectedUri(mutations, isRedirected ? actualUri : null); + } + if (isWritingToCache()) { + cache.applyContentMetadataMutations(key, mutations); + } + } + + private void setNoBytesRemainingAndMaybeStoreLength(String key) throws IOException { + bytesRemaining = 0; + if (isWritingToCache()) { + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setContentLength(mutations, readPosition); + cache.applyContentMetadataMutations(key, mutations); + } + } + + private static Uri getRedirectedUriOrDefault(Cache cache, String key, Uri defaultUri) { + @Nullable Uri redirectedUri = ContentMetadata.getRedirectedUri(cache.getContentMetadata(key)); + return redirectedUri != null ? redirectedUri : defaultUri; + } + + private boolean isReadingFromUpstream() { + return !isReadingFromCache(); + } + + private boolean isBypassingCache() { + return currentDataSource == upstreamDataSource; + } + + private boolean isReadingFromCache() { + return currentDataSource == cacheReadDataSource; + } + + private boolean isWritingToCache() { + return currentDataSource == cacheWriteDataSource; + } + + private void closeCurrentSource() throws IOException { + if (currentDataSource == null) { + return; + } + try { + currentDataSource.close(); + } finally { + currentDataSpec = null; + currentDataSource = null; + if (currentHoleSpan != null) { + cache.releaseHoleSpan(currentHoleSpan); + currentHoleSpan = null; + } + } + } + + private void handleBeforeThrow(Throwable exception) { + if (isReadingFromCache() || exception instanceof CacheException) { + seenCacheError = true; + } + } + + private int shouldIgnoreCacheForRequest(DataSpec dataSpec) { + if (ignoreCacheOnError && seenCacheError) { + return CACHE_IGNORED_REASON_ERROR; + } else if (ignoreCacheForUnsetLengthRequests && dataSpec.length == C.LENGTH_UNSET) { + return CACHE_IGNORED_REASON_UNSET_LENGTH; + } else { + return CACHE_NOT_IGNORED; + } + } + + private void notifyCacheIgnored(@CacheIgnoredReason int reason) { + if (eventListener != null) { + eventListener.onCacheIgnored(reason); + } + } + + private void notifyBytesRead() { + if (eventListener != null && totalCachedBytesRead > 0) { + eventListener.onCachedBytesRead(cache.getCacheSpace(), totalCachedBytesRead); + totalCachedBytesRead = 0; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/ImageLoader.kt b/app/src/main/java/io/legado/app/help/glide/ImageLoader.kt new file mode 100644 index 000000000..c6d011aeb --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/ImageLoader.kt @@ -0,0 +1,89 @@ +package io.legado.app.help.glide + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.annotation.DrawableRes +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.isAbsUrl +import io.legado.app.utils.isContentScheme +import java.io.File + +@Suppress("unused") +object ImageLoader { + + /** + * 自动判断path类型 + */ + fun load(context: Context, path: String?): RequestBuilder { + return when { + path.isNullOrEmpty() -> Glide.with(context).load(path) + path.isAbsUrl() -> { + val url = kotlin.runCatching { + AnalyzeUrl(path).getGlideUrl() + }.getOrDefault(path) + GlideApp.with(context).load(url) + } + path.isContentScheme() -> Glide.with(context).load(Uri.parse(path)) + else -> kotlin.runCatching { + Glide.with(context).load(File(path)) + }.getOrElse { + Glide.with(context).load(path) + } + } + } + + fun loadBitmap(context: Context, path: String?): RequestBuilder { + return when { + path.isNullOrEmpty() -> Glide.with(context).asBitmap().load(path) + path.isAbsUrl() -> Glide.with(context).asBitmap().load(AnalyzeUrl(path).getGlideUrl()) + path.isContentScheme() -> Glide.with(context).asBitmap().load(Uri.parse(path)) + else -> kotlin.runCatching { + Glide.with(context).asBitmap().load(File(path)) + }.getOrElse { + Glide.with(context).asBitmap().load(path) + } + } + } + + fun loadFile(context: Context, path: String?): RequestBuilder { + return when { + path.isNullOrEmpty() -> Glide.with(context).asFile().load(path) + path.isAbsUrl() -> Glide.with(context).asFile().load(AnalyzeUrl(path).getGlideUrl()) + path.isContentScheme() -> Glide.with(context).asFile().load(Uri.parse(path)) + else -> kotlin.runCatching { + Glide.with(context).asFile().load(File(path)) + }.getOrElse { + Glide.with(context).asFile().load(path) + } + } + } + + fun load(context: Context, @DrawableRes resId: Int?): RequestBuilder { + return Glide.with(context).load(resId) + } + + fun load(context: Context, file: File?): RequestBuilder { + return Glide.with(context).load(file) + } + + fun load(context: Context, uri: Uri?): RequestBuilder { + return Glide.with(context).load(uri) + } + + fun load(context: Context, drawable: Drawable?): RequestBuilder { + return Glide.with(context).load(drawable) + } + + fun load(context: Context, bitmap: Bitmap?): RequestBuilder { + return Glide.with(context).load(bitmap) + } + + fun load(context: Context, bytes: ByteArray?): RequestBuilder { + return Glide.with(context).load(bytes) + } + +} diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpGlideModule.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpGlideModule.kt new file mode 100644 index 000000000..73d01a620 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpGlideModule.kt @@ -0,0 +1,21 @@ +package io.legado.app.help.glide + +import android.content.Context +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.module.AppGlideModule +import java.io.InputStream + +@Suppress("unused") +@GlideModule +class OkHttpGlideModule : AppGlideModule() { + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + registry.replace( + GlideUrl::class.java, + InputStream::class.java, + OkHttpModeLoaderFactory + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt new file mode 100644 index 000000000..7b99fb0fb --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt @@ -0,0 +1,20 @@ +package io.legado.app.help.glide + +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import java.io.InputStream + + +object OkHttpModeLoaderFactory : ModelLoaderFactory { + + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { + return OkHttpModelLoader + } + + override fun teardown() { + // Do nothing, this instance doesn't own the client. + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt new file mode 100644 index 000000000..2d3daa60f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt @@ -0,0 +1,23 @@ +package io.legado.app.help.glide + +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.ModelLoader +import java.io.InputStream + +object OkHttpModelLoader : ModelLoader { + + override fun buildLoadData( + model: GlideUrl, + width: Int, + height: Int, + options: Options + ): ModelLoader.LoadData { + return ModelLoader.LoadData(model, OkHttpStreamFetcher(model)) + } + + override fun handles(model: GlideUrl): Boolean { + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt new file mode 100644 index 000000000..ea847a65e --- /dev/null +++ b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt @@ -0,0 +1,73 @@ +package io.legado.app.help.glide + +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.HttpException +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.util.ContentLengthInputStream +import com.bumptech.glide.util.Preconditions +import io.legado.app.help.http.okHttpClient +import okhttp3.Call +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import java.io.IOException +import java.io.InputStream + + +class OkHttpStreamFetcher(private val url: GlideUrl) : + DataFetcher, okhttp3.Callback { + private var stream: InputStream? = null + private var responseBody: ResponseBody? = null + private var callback: DataFetcher.DataCallback? = null + private var call: Call? = null + + override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { + val requestBuilder: Request.Builder = Request.Builder().url(url.toStringUrl()) + for ((key, value) in url.headers.entries) { + requestBuilder.addHeader(key, value) + } + val request: Request = requestBuilder.build() + this.callback = callback + call = okHttpClient.newCall(request) + call?.enqueue(this) + } + + override fun cleanup() { + try { + stream?.close() + } catch (e: IOException) { + // Ignored + } + responseBody?.close() + callback = null + } + + override fun cancel() { + call?.cancel() + } + + override fun getDataClass(): Class { + return InputStream::class.java + } + + override fun getDataSource(): DataSource { + return DataSource.REMOTE + } + + override fun onFailure(call: Call, e: IOException) { + callback?.onLoadFailed(e) + } + + override fun onResponse(call: Call, response: Response) { + responseBody = response.body + if (response.isSuccessful) { + val contentLength: Long = Preconditions.checkNotNull(responseBody).contentLength() + stream = ContentLengthInputStream.obtain(responseBody!!.byteStream(), contentLength) + callback!!.onDataReady(stream) + } else { + callback!!.onLoadFailed(HttpException(response.message, response.code)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/BackstageWebView.kt b/app/src/main/java/io/legado/app/help/http/BackstageWebView.kt new file mode 100644 index 000000000..914b9400f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/BackstageWebView.kt @@ -0,0 +1,217 @@ +package io.legado.app.help.http + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import android.util.AndroidRuntimeException +import android.webkit.CookieManager +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import io.legado.app.constant.AppConst +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.runOnUI +import kotlinx.coroutines.* +import org.apache.commons.text.StringEscapeUtils +import splitties.init.appCtx +import java.lang.ref.WeakReference +import kotlin.coroutines.resume + +/** + * 后台webView + */ +class BackstageWebView( + private val url: String? = null, + private val html: String? = null, + private val encode: String? = null, + private val tag: String? = null, + private val headerMap: Map? = null, + private val sourceRegex: String? = null, + private val javaScript: String? = null, +) { + + private val mHandler = Handler(Looper.getMainLooper()) + private var callback: Callback? = null + private var mWebView: WebView? = null + + suspend fun getStrResponse(): StrResponse = suspendCancellableCoroutine { block -> + block.invokeOnCancellation { + runOnUI { + destroy() + } + } + callback = object : BackstageWebView.Callback() { + override fun onResult(response: StrResponse) { + if (!block.isCompleted) + block.resume(response) + } + + override fun onError(error: Throwable) { + if (!block.isCompleted) + block.cancel(error) + } + } + runOnUI { + try { + load() + } catch (error: Throwable) { + block.cancel(error) + } + } + } + + private fun getEncoding(): String { + return encode ?: "utf-8" + } + + @Throws(AndroidRuntimeException::class) + private fun load() { + val webView = createWebView() + mWebView = webView + try { + when { + !html.isNullOrEmpty() -> if (url.isNullOrEmpty()) { + webView.loadData(html, "text/html", getEncoding()) + } else { + webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url) + } + else -> if (headerMap == null) { + webView.loadUrl(url!!) + } else { + webView.loadUrl(url!!, headerMap) + } + } + } catch (e: Exception) { + callback?.onError(e) + } + } + + @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") + private fun createWebView(): WebView { + val webView = WebView(appCtx) + val settings = webView.settings + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.blockNetworkImage = true + settings.userAgentString = headerMap?.get(AppConst.UA_NAME) + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + if (sourceRegex.isNullOrEmpty()) { + webView.webViewClient = HtmlWebViewClient() + } else { + webView.webViewClient = SnifferWebClient() + } + return webView + } + + private fun destroy() { + mWebView?.destroy() + mWebView = null + } + + private fun getJs(): String { + javaScript?.let { + if (it.isNotEmpty()) { + return it + } + } + return JS + } + + private fun setCookie(url: String) { + tag?.let { + val cookie = CookieManager.getInstance().getCookie(url) + CookieStore.setCookie(it, cookie) + } + } + + private inner class HtmlWebViewClient : WebViewClient() { + + override fun onPageFinished(view: WebView, url: String) { + setCookie(url) + val runnable = EvalJsRunnable(view, url, getJs()) + mHandler.postDelayed(runnable, 1000) + } + + } + + private inner class EvalJsRunnable( + webView: WebView, + private val url: String, + private val mJavaScript: String + ) : Runnable { + var retry = 0 + private val mWebView: WeakReference = WeakReference(webView) + override fun run() { + mWebView.get()?.evaluateJavascript(mJavaScript) { + if (it.isNotEmpty() && it != "null") { + val content = StringEscapeUtils.unescapeJson(it) + .replace("^\"|\"$".toRegex(), "") + try { + val response = StrResponse(url, content) + callback?.onResult(response) + } catch (e: Exception) { + callback?.onError(e) + } + mHandler.removeCallbacks(this) + destroy() + return@evaluateJavascript + } + if (retry > 30) { + callback?.onError(NoStackTraceException("js执行超时")) + mHandler.removeCallbacks(this) + destroy() + return@evaluateJavascript + } + retry++ + mHandler.removeCallbacks(this) + mHandler.postDelayed(this, 1000) + } + } + } + + private inner class SnifferWebClient : WebViewClient() { + + override fun onLoadResource(view: WebView, resUrl: String) { + sourceRegex?.let { + if (resUrl.matches(it.toRegex())) { + try { + val response = StrResponse(url!!, resUrl) + callback?.onResult(response) + } catch (e: Exception) { + callback?.onError(e) + } + destroy() + } + } + } + + override fun onPageFinished(webView: WebView, url: String) { + setCookie(url) + val js = javaScript + if (!js.isNullOrEmpty()) { + val runnable = LoadJsRunnable(webView, javaScript) + mHandler.postDelayed(runnable, 1000L) + } + } + + } + + private class LoadJsRunnable( + webView: WebView, + private val mJavaScript: String? + ) : Runnable { + private val mWebView: WeakReference = WeakReference(webView) + override fun run() { + mWebView.get()?.loadUrl("javascript:${mJavaScript ?: ""}") + } + } + + companion object { + const val JS = "document.documentElement.outerHTML" + } + + abstract class Callback { + abstract fun onResult(response: StrResponse) + abstract fun onError(error: Throwable) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/CookieStore.kt b/app/src/main/java/io/legado/app/help/http/CookieStore.kt new file mode 100644 index 000000000..c8d516091 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/CookieStore.kt @@ -0,0 +1,83 @@ +@file:Suppress("unused") + +package io.legado.app.help.http + +import android.text.TextUtils +import io.legado.app.data.appDb +import io.legado.app.data.entities.Cookie +import io.legado.app.help.http.api.CookieManager +import io.legado.app.utils.NetworkUtils + +object CookieStore : CookieManager { + + override fun setCookie(url: String, cookie: String?) { + val cookieBean = Cookie(NetworkUtils.getSubDomain(url), cookie ?: "") + appDb.cookieDao.insert(cookieBean) + } + + override fun replaceCookie(url: String, cookie: String) { + if (TextUtils.isEmpty(url) || TextUtils.isEmpty(cookie)) { + return + } + val oldCookie = getCookie(url) + if (TextUtils.isEmpty(oldCookie)) { + setCookie(url, cookie) + } else { + val cookieMap = cookieToMap(oldCookie) + cookieMap.putAll(cookieToMap(cookie)) + val newCookie = mapToCookie(cookieMap) + setCookie(url, newCookie) + } + } + + override fun getCookie(url: String): String { + val cookieBean = appDb.cookieDao.get(NetworkUtils.getSubDomain(url)) + return cookieBean?.cookie ?: "" + } + + override fun removeCookie(url: String) { + appDb.cookieDao.delete(NetworkUtils.getSubDomain(url)) + } + + override fun cookieToMap(cookie: String): MutableMap { + val cookieMap = mutableMapOf() + if (cookie.isBlank()) { + return cookieMap + } + val pairArray = cookie.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + for (pair in pairArray) { + val pairs = pair.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (pairs.size == 1) { + continue + } + val key = pairs[0].trim { it <= ' ' } + val value = pairs[1] + if (value.isNotBlank() || value.trim { it <= ' ' } == "null") { + cookieMap[key] = value.trim { it <= ' ' } + } + } + return cookieMap + } + + override fun mapToCookie(cookieMap: Map?): String? { + if (cookieMap == null || cookieMap.isEmpty()) { + return null + } + val builder = StringBuilder() + for (key in cookieMap.keys) { + val value = cookieMap[key] + if (value?.isNotBlank() == true) { + builder.append(key) + .append("=") + .append(value) + .append(";") + } + } + return builder.deleteCharAt(builder.lastIndexOf(";")).toString() + } + + fun clear() { + appDb.cookieDao.deleteOkHttp() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt new file mode 100644 index 000000000..7b7d91d08 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt @@ -0,0 +1,94 @@ +package io.legado.app.help.http + +import io.legado.app.help.AppConfig +import io.legado.app.help.http.cronet.CronetInterceptor +import io.legado.app.help.http.cronet.CronetLoader +import okhttp3.ConnectionSpec +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +private val proxyClientCache: ConcurrentHashMap by lazy { + ConcurrentHashMap() +} + +val okHttpClient: OkHttpClient by lazy { + val specs = arrayListOf( + ConnectionSpec.MODERN_TLS, + ConnectionSpec.COMPATIBLE_TLS, + ConnectionSpec.CLEARTEXT + ) + + val builder = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(60,TimeUnit.SECONDS) + .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory, SSLHelper.unsafeTrustManager) + .retryOnConnectionFailure(true) + .hostnameVerifier(SSLHelper.unsafeHostnameVerifier) + .connectionSpecs(specs) + .followRedirects(true) + .followSslRedirects(true) + .addInterceptor(Interceptor { chain -> + val request = chain.request() + .newBuilder() + .addHeader("Keep-Alive", "300") + .addHeader("Connection", "Keep-Alive") + .addHeader("Cache-Control", "no-cache") + .build() + chain.proceed(request) + }) + if (!AppConfig.isGooglePlay && AppConfig.isCronet && CronetLoader.install()) { + builder.addInterceptor(CronetInterceptor(null)) + } + builder.build() +} + +/** + * 缓存代理okHttp + */ +fun getProxyClient(proxy: String? = null): OkHttpClient { + if (proxy.isNullOrBlank()) { + return okHttpClient + } + proxyClientCache[proxy]?.let { + return it + } + val r = Regex("(http|socks4|socks5)://(.*):(\\d{2,5})(@.*@.*)?") + val ms = r.findAll(proxy) + val group = ms.first() + var username = "" //代理服务器验证用户名 + var password = "" //代理服务器验证密码 + val type = if (group.groupValues[1] == "http") "http" else "socks" + val host = group.groupValues[2] + val port = group.groupValues[3].toInt() + if (group.groupValues[4] != "") { + username = group.groupValues[4].split("@")[1] + password = group.groupValues[4].split("@")[2] + } + if (type != "direct" && host != "") { + val builder = okHttpClient.newBuilder() + if (type == "http") { + builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))) + } else { + builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(host, port))) + } + if (username != "" && password != "") { + builder.proxyAuthenticator { _, response -> //设置代理服务器账号密码 + val credential: String = Credentials.basic(username, password) + response.request.newBuilder() + .header("Proxy-Authorization", credential) + .build() + } + } + val proxyClient = builder.build() + proxyClientCache[proxy] = proxyClient + return proxyClient + } + return okHttpClient +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/OkHttpUtils.kt b/app/src/main/java/io/legado/app/help/http/OkHttpUtils.kt new file mode 100644 index 000000000..c92d93945 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/OkHttpUtils.kt @@ -0,0 +1,187 @@ +package io.legado.app.help.http + +import io.legado.app.constant.AppConst +import io.legado.app.help.AppConfig +import io.legado.app.utils.EncodingDetect +import io.legado.app.utils.GSON +import io.legado.app.utils.UTF8BOMFighter +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.io.IOException +import java.nio.charset.Charset +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +suspend fun OkHttpClient.newCallResponse( + retry: Int = 0, + builder: Request.Builder.() -> Unit +): Response { + return withContext(IO) { + val requestBuilder = Request.Builder() + requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent) + requestBuilder.apply(builder) + var response: Response? = null + for (i in 0..retry) { + response = this@newCallResponse.newCall(requestBuilder.build()).await() + if (response.isSuccessful) { + return@withContext response + } + } + return@withContext response!! + } +} + +suspend fun OkHttpClient.newCallResponseBody( + retry: Int = 0, + builder: Request.Builder.() -> Unit +): ResponseBody { + return withContext(IO) { + val requestBuilder = Request.Builder() + requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent) + requestBuilder.apply(builder) + var response: Response? = null + for (i in 0..retry) { + response = this@newCallResponseBody.newCall(requestBuilder.build()).await() + if (response.isSuccessful) { + return@withContext response.body!! + } + } + return@withContext response!!.body ?: throw IOException(response.message) + } +} + +suspend fun OkHttpClient.newCallStrResponse( + retry: Int = 0, + builder: Request.Builder.() -> Unit +): StrResponse { + return withContext(IO) { + val requestBuilder = Request.Builder() + requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent) + requestBuilder.apply(builder) + var response: Response? = null + for (i in 0..retry) { + response = this@newCallStrResponse.newCall(requestBuilder.build()).await() + if (response.isSuccessful) { + return@withContext StrResponse(response, response.body!!.text()) + } + } + return@withContext StrResponse(response!!, response.body?.text() ?: response.message) + } +} + +suspend fun Call.await(): Response = suspendCancellableCoroutine { block -> + + block.invokeOnCancellation { + cancel() + } + + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + block.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + block.resume(response) + } + }) + +} + +fun ResponseBody.text(encode: String? = null): String { + val responseBytes = UTF8BOMFighter.removeUTF8BOM(bytes()) + var charsetName: String? = encode + + charsetName?.let { + return String(responseBytes, Charset.forName(charsetName)) + } + + //根据http头判断 + contentType()?.charset()?.let { + return String(responseBytes, it) + } + + //根据内容判断 + charsetName = EncodingDetect.getHtmlEncode(responseBytes) + return String(responseBytes, Charset.forName(charsetName)) +} + +fun Request.Builder.addHeaders(headers: Map) { + headers.forEach { + if (it.key == AppConst.UA_NAME) { + //防止userAgent重复 + removeHeader(AppConst.UA_NAME) + } + addHeader(it.key, it.value) + } +} + +fun Request.Builder.get(url: String, queryMap: Map, encoded: Boolean = false) { + val httpBuilder = url.toHttpUrl().newBuilder() + queryMap.forEach { + if (encoded) { + httpBuilder.addEncodedQueryParameter(it.key, it.value) + } else { + httpBuilder.addQueryParameter(it.key, it.value) + } + } + url(httpBuilder.build()) +} + +fun Request.Builder.postForm(form: Map, encoded: Boolean = false) { + val formBody = FormBody.Builder() + form.forEach { + if (encoded) { + formBody.addEncoded(it.key, it.value) + } else { + formBody.add(it.key, it.value) + } + } + post(formBody.build()) +} + +fun Request.Builder.postMultipart(type: String?, form: Map) { + val multipartBody = MultipartBody.Builder() + type?.let { + multipartBody.setType(type.toMediaType()) + } + form.forEach { + when (val value = it.value) { + is Map<*, *> -> { + val fileName = value["fileName"] as String + val file = value["file"] + val mediaType = (value["contentType"] as? String)?.toMediaType() + val requestBody = when (file) { + is File -> { + file.asRequestBody(mediaType) + } + is ByteArray -> { + file.toRequestBody(mediaType) + } + is String -> { + file.toRequestBody(mediaType) + } + else -> { + GSON.toJson(file).toRequestBody(mediaType) + } + } + multipartBody.addFormDataPart(it.key, fileName, requestBody) + } + else -> multipartBody.addFormDataPart(it.key, it.value.toString()) + } + } + post(multipartBody.build()) +} + +fun Request.Builder.postJson(json: String?) { + json?.let { + val requestBody = json.toRequestBody("application/json; charset=UTF-8".toMediaType()) + post(requestBody) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/RequestMethod.kt b/app/src/main/java/io/legado/app/help/http/RequestMethod.kt new file mode 100644 index 000000000..bba9f9761 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/RequestMethod.kt @@ -0,0 +1,5 @@ +package io.legado.app.help.http + +enum class RequestMethod { + GET, POST +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/SSLHelper.kt b/app/src/main/java/io/legado/app/help/http/SSLHelper.kt new file mode 100644 index 000000000..ecbcc1f02 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/SSLHelper.kt @@ -0,0 +1,184 @@ +package io.legado.app.help.http + +import android.annotation.SuppressLint + +import timber.log.Timber +import java.io.IOException +import java.io.InputStream +import java.security.KeyManagementException +import java.security.KeyStore +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.* + +@Suppress("unused") +object SSLHelper { + + /** + * 为了解决客户端不信任服务器数字证书的问题, + * 网络上大部分的解决方案都是让客户端不对证书做任何检查, + * 这是一种有很大安全漏洞的办法 + */ + val unsafeTrustManager: X509TrustManager = + @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + @Throws(CertificateException::class) + override fun checkClientTrusted(chain: Array, authType: String) { + //do nothing,接受任意客户端证书 + } + + @SuppressLint("TrustAllX509TrustManager") + @Throws(CertificateException::class) + override fun checkServerTrusted(chain: Array, authType: String) { + //do nothing,接受任意客户端证书 + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + } + + val unsafeSSLSocketFactory: SSLSocketFactory by lazy { + try { + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, arrayOf(unsafeTrustManager), SecureRandom()) + sslContext.socketFactory + } catch (e: Exception) { + throw RuntimeException(e) + } + } + + /** + * 此类是用于主机名验证的基接口。 在握手期间,如果 URL 的主机名和服务器的标识主机名不匹配, + * 则验证机制可以回调此接口的实现程序来确定是否应该允许此连接。策略可以是基于证书的或依赖于其他验证方案。 + * 当验证 URL 主机名使用的默认规则失败时使用这些回调。如果主机名是可接受的,则返回 true + */ + val unsafeHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true } + + class SSLParams { + lateinit var sSLSocketFactory: SSLSocketFactory + lateinit var trustManager: X509TrustManager + } + + /** + * https单向认证 + * 可以额外配置信任服务端的证书策略,否则默认是按CA证书去验证的,若不是CA可信任的证书,则无法通过验证 + */ + fun getSslSocketFactory(trustManager: X509TrustManager): SSLParams? { + return getSslSocketFactoryBase(trustManager, null, null) + } + + /** + * https单向认证 + * 用含有服务端公钥的证书校验服务端证书 + */ + fun getSslSocketFactory(vararg certificates: InputStream): SSLParams? { + return getSslSocketFactoryBase(null, null, null, *certificates) + } + + /** + * https双向认证 + * bksFile 和 password -> 客户端使用bks证书校验服务端证书 + * certificates -> 用含有服务端公钥的证书校验服务端证书 + */ + fun getSslSocketFactory( + bksFile: InputStream, + password: String, + vararg certificates: InputStream + ): SSLParams? { + return getSslSocketFactoryBase(null, bksFile, password, *certificates) + } + + /** + * https双向认证 + * bksFile 和 password -> 客户端使用bks证书校验服务端证书 + * X509TrustManager -> 如果需要自己校验,那么可以自己实现相关校验,如果不需要自己校验,那么传null即可 + */ + fun getSslSocketFactory( + bksFile: InputStream, + password: String, + trustManager: X509TrustManager + ): SSLParams? { + return getSslSocketFactoryBase(trustManager, bksFile, password) + } + + private fun getSslSocketFactoryBase( + trustManager: X509TrustManager?, + bksFile: InputStream?, + password: String?, + vararg certificates: InputStream + ): SSLParams? { + val sslParams = SSLParams() + try { + val keyManagers = prepareKeyManager(bksFile, password) + val trustManagers = prepareTrustManager(*certificates) + val manager: X509TrustManager = trustManager ?: chooseTrustManager(trustManagers) + // 创建TLS类型的SSLContext对象, that uses our TrustManager + val sslContext = SSLContext.getInstance("TLS") + // 用上面得到的trustManagers初始化SSLContext,这样sslContext就会信任keyStore中的证书 + // 第一个参数是授权的密钥管理器,用来授权验证,比如授权自签名的证书验证。第二个是被授权的证书管理器,用来验证服务器端的证书 + sslContext.init(keyManagers, arrayOf(manager), null) + // 通过sslContext获取SSLSocketFactory对象 + sslParams.sSLSocketFactory = sslContext.socketFactory + sslParams.trustManager = manager + return sslParams + } catch (e: NoSuchAlgorithmException) { + Timber.e(e) + } catch (e: KeyManagementException) { + Timber.e(e) + } + return null + } + + private fun prepareKeyManager(bksFile: InputStream?, password: String?): Array? { + try { + if (bksFile == null || password == null) return null + val clientKeyStore = KeyStore.getInstance("BKS") + clientKeyStore.load(bksFile, password.toCharArray()) + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(clientKeyStore, password.toCharArray()) + return kmf.keyManagers + } catch (e: Exception) { + Timber.e(e) + } + return null + } + + private fun prepareTrustManager(vararg certificates: InputStream): Array { + val certificateFactory = CertificateFactory.getInstance("X.509") + // 创建一个默认类型的KeyStore,存储我们信任的证书 + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + for ((index, certStream) in certificates.withIndex()) { + val certificateAlias = index.toString() + // 证书工厂根据证书文件的流生成证书 cert + val cert = certificateFactory.generateCertificate(certStream) + // 将 cert 作为可信证书放入到keyStore中 + keyStore.setCertificateEntry(certificateAlias, cert) + try { + certStream.close() + } catch (e: IOException) { + Timber.e(e) + } + } + //我们创建一个默认类型的TrustManagerFactory + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + //用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书 + tmf.init(keyStore) + //通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书 + return tmf.trustManagers + } + + private fun chooseTrustManager(trustManagers: Array): X509TrustManager { + for (trustManager in trustManagers) { + if (trustManager is X509TrustManager) { + return trustManager + } + } + throw NullPointerException() + } +} diff --git a/app/src/main/java/io/legado/app/help/http/StrResponse.kt b/app/src/main/java/io/legado/app/help/http/StrResponse.kt new file mode 100644 index 000000000..0e11ea3db --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/StrResponse.kt @@ -0,0 +1,78 @@ +package io.legado.app.help.http + +import okhttp3.* +import okhttp3.Response.Builder + +/** + * An HTTP response. + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +class StrResponse { + var raw: Response + private set + var body: String? = null + private set + var errorBody: ResponseBody? = null + private set + + constructor(rawResponse: Response, body: String?) { + this.raw = rawResponse + this.body = body + } + + constructor(url: String, body: String?) { + val request = try { + Request.Builder().url(url).build() + } catch (e: Exception) { + Request.Builder().url("http://localhost/").build() + } + raw = Builder() + .code(200) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .request(request) + .build() + this.body = body + } + + constructor(rawResponse: Response, errorBody: ResponseBody?) { + this.raw = rawResponse + this.errorBody = errorBody + } + + fun raw() = raw + + fun url(): String { + raw.networkResponse?.let { + return it.request.url.toString() + } + return raw.request.url.toString() + } + + val url: String get() = url() + + fun body() = body + + fun code(): Int { + return raw.code + } + + fun message(): String { + return raw.message + } + + fun headers(): Headers { + return raw.headers + } + + fun isSuccessful(): Boolean = raw.isSuccessful + + fun errorBody(): ResponseBody? { + return errorBody + } + + override fun toString(): String { + return raw.toString() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/api/CookieManager.kt b/app/src/main/java/io/legado/app/help/http/api/CookieManager.kt new file mode 100644 index 000000000..525aa712d --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/api/CookieManager.kt @@ -0,0 +1,28 @@ +package io.legado.app.help.http.api + +interface CookieManager { + + /** + * 保存cookie + */ + fun setCookie(url: String, cookie: String?) + + /** + * 替换cookie + */ + fun replaceCookie(url: String, cookie: String) + + /** + * 获取cookie + */ + fun getCookie(url: String): String + + /** + * 移除cookie + */ + fun removeCookie(url: String) + + fun cookieToMap(cookie: String): MutableMap + + fun mapToCookie(cookieMap: Map?): String? +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetHelper.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetHelper.kt new file mode 100644 index 000000000..44d82438e --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetHelper.kt @@ -0,0 +1,76 @@ +package io.legado.app.help.http.cronet + +import io.legado.app.constant.AppLog +import io.legado.app.help.AppConfig +import okhttp3.Headers +import okhttp3.MediaType +import okhttp3.Request +import okio.Buffer +import org.chromium.net.CronetEngine.Builder.HTTP_CACHE_DISK +import org.chromium.net.ExperimentalCronetEngine +import org.chromium.net.UploadDataProviders +import org.chromium.net.UrlRequest +import splitties.init.appCtx +import timber.log.Timber +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + + +val executor: ExecutorService by lazy { Executors.newCachedThreadPool() } + +val cronetEngine: ExperimentalCronetEngine? by lazy { + if (!AppConfig.isGooglePlay) { + CronetLoader.preDownload() + } + val builder = ExperimentalCronetEngine.Builder(appCtx).apply { + if (!AppConfig.isGooglePlay && CronetLoader.install()) { + setLibraryLoader(CronetLoader)//设置自定义so库加载 + } + setStoragePath(appCtx.externalCacheDir?.absolutePath)//设置缓存路径 + enableHttpCache(HTTP_CACHE_DISK, (1024 * 1024 * 50).toLong())//设置50M的磁盘缓存 + enableQuic(true)//设置支持http/3 + enableHttp2(true) //设置支持http/2 + enablePublicKeyPinningBypassForLocalTrustAnchors(true) + enableBrotli(true)//Brotli压缩 + } + try { + val engine = builder.build() + Timber.d("Cronet Version:" + engine.versionString) + return@lazy engine + } catch (e: UnsatisfiedLinkError) { + AppLog.put("初始化cronetEngine出错", e) + Timber.e(e, "初始化cronetEngine出错") + return@lazy null + } +} + +fun buildRequest(request: Request, callback: UrlRequest.Callback): UrlRequest? { + val url = request.url.toString() + val headers: Headers = request.headers + val requestBody = request.body + return cronetEngine?.newUrlRequestBuilder(url, callback, executor)?.apply { + setHttpMethod(request.method)//设置 + allowDirectExecutor() + headers.forEachIndexed { index, _ -> + addHeader(headers.name(index), headers.value(index)) + } + if (requestBody != null) { + val contentType: MediaType? = requestBody.contentType() + if (contentType != null) { + addHeader("Content-Type", contentType.toString()) + } else { + addHeader("Content-Type", "text/plain") + } + val buffer = Buffer() + requestBody.writeTo(buffer) + setUploadDataProvider( + UploadDataProviders.create(buffer.readByteArray()), + executor + ) + + } + + }?.build() + +} + diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetInterceptor.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetInterceptor.kt new file mode 100644 index 000000000..e1b2372b4 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetInterceptor.kt @@ -0,0 +1,69 @@ +package io.legado.app.help.http.cronet + +import io.legado.app.help.http.CookieStore +import okhttp3.* +import timber.log.Timber + +class CronetInterceptor(private val cookieJar: CookieJar?) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original: Request = chain.request() + //Cronet未初始化 + return if (!CronetLoader.install() || cronetEngine == null) { + chain.proceed(original) + } else try { + val builder: Request.Builder = original.newBuilder() + //移除Keep-Alive,手动设置会导致400 BadRequest + builder.removeHeader("Keep-Alive") + builder.removeHeader("Accept-Encoding") + val cookieStr = getCookie(original.url) + //设置Cookie + if (cookieStr.length > 3) { + builder.header("Cookie", cookieStr) + } + val new = builder.build() + proceedWithCronet(new, chain.call())?.let { response -> + //从Response 中保存Cookie到CookieJar + cookieJar?.saveFromResponse(new.url, Cookie.parseAll(new.url, response.headers)) + response + } ?: chain.proceed(original) + } catch (e: Exception) { + //不能抛出错误,抛出错误会导致应用崩溃 + //遇到Cronet处理有问题时的情况,如证书过期等等,回退到okhttp处理 + if (!e.message.toString().contains("ERR_CERT_", true) + && !e.message.toString().contains("ERR_SSL_", true) + ) { + Timber.e(e) + } + chain.proceed(original) + } + + } + + private fun proceedWithCronet(request: Request, call: Call): Response? { + val callback = CronetRequestCallback(request, call) + buildRequest(request, callback)?.let { + it.start() + return callback.waitForDone(it) + } + return null + } + + private fun getCookie(url: HttpUrl): String { + val sb = StringBuilder() + //处理从 Cookiejar 获取到的Cookies + if (cookieJar != null) { + val cookies = cookieJar.loadForRequest(url) + for (cookie in cookies) { + sb.append(cookie.name).append("=").append(cookie.value).append("; ") + } + } + //处理自定义的Cookie + val cookie = CookieStore.getCookie(url.toString()) + if (cookie.length > 3) { + sb.append(cookie) + } + return sb.toString() + } + +} diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetLoader.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetLoader.kt new file mode 100644 index 000000000..abcecfda2 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetLoader.kt @@ -0,0 +1,366 @@ +package io.legado.app.help.http.cronet + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build +import android.text.TextUtils +import io.legado.app.BuildConfig +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.Coroutine + +import org.chromium.net.CronetEngine +import org.json.JSONObject +import splitties.init.appCtx +import timber.log.Timber +import java.io.* +import java.math.BigInteger +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.util.* + + +object CronetLoader : CronetEngine.Builder.LibraryLoader() { + //https://storage.googleapis.com/chromium-cronet/android/92.0.4515.159/Release/cronet/libs/arm64-v8a/libcronet.92.0.4515.159.so + + private const val soVersion = BuildConfig.Cronet_Version + private const val soName = "libcronet.$soVersion.so" + private val soUrl: String + private val soFile: File + private val downloadFile: File + private var cpuAbi: String? = null + private var md5: String + var download = false + private var cacheInstall = false + + init { + soUrl = ("https://storage.googleapis.com/chromium-cronet/android/" + + soVersion + "/Release/cronet/libs/" + + getCpuAbi(appCtx) + "/" + soName) + md5 = getMd5(appCtx) + val dir = appCtx.getDir("cronet", Context.MODE_PRIVATE) + soFile = File(dir.toString() + "/" + getCpuAbi(appCtx), soName) + downloadFile = File(appCtx.cacheDir.toString() + "/so_download", soName) + Timber.d("soName+:$soName") + Timber.d("destSuccessFile:$soFile") + Timber.d("tempFile:$downloadFile") + Timber.d("soUrl:$soUrl") + } + + /** + * 判断Cronet是否安装完成 + */ + fun install(): Boolean { + if (cacheInstall) { + return true + } + if (AppConfig.isGooglePlay) { + return false + } + if (md5.length != 32 || !soFile.exists() || md5 != getFileMD5(soFile)) { + cacheInstall = false + return cacheInstall + } + cacheInstall = soFile.exists() + return cacheInstall + } + + + /** + * 预加载Cronet + */ + fun preDownload() { + if (AppConfig.isGooglePlay) { + return + } + Coroutine.async { + //md5 = getUrlMd5(md5Url) + if (soFile.exists() && md5 == getFileMD5(soFile)) { + Timber.d("So 库已存在") + } else { + download(soUrl, md5, downloadFile, soFile) + } + Timber.d(soName) + } + } + + private fun getMd5(context: Context): String { + val stringBuilder = StringBuilder() + return try { + //获取assets资源管理器 + val assetManager = context.assets + //通过管理器打开文件并读取 + val bf = BufferedReader( + InputStreamReader( + assetManager.open("cronet.json") + ) + ) + var line: String? + while (bf.readLine().also { line = it } != null) { + stringBuilder.append(line) + } + JSONObject(stringBuilder.toString()).optString(getCpuAbi(context), "") + } catch (e: java.lang.Exception) { + return "" + } + } + + @SuppressLint("UnsafeDynamicallyLoadedCode") + override fun loadLibrary(libName: String) { + Timber.d("libName:$libName") + val start = System.currentTimeMillis() + @Suppress("SameParameterValue") + try { + //非cronet的so调用系统方法加载 + if (!libName.contains("cronet")) { + System.loadLibrary(libName) + return + } + //以下逻辑为cronet加载,优先加载本地,否则从远程加载 + //首先调用系统行为进行加载 + System.loadLibrary(libName) + Timber.d("load from system") + } catch (e: Throwable) { + //如果找不到,则从远程下载 + //删除历史文件 + deleteHistoryFile(Objects.requireNonNull(soFile.parentFile), soFile) + //md5 = getUrlMd5(md5Url) + Timber.d("soMD5:$md5") + if (md5.length != 32 || soUrl.isEmpty()) { + //如果md5或下载的url为空,则调用系统行为进行加载 + System.loadLibrary(libName) + return + } + if (!soFile.exists() || !soFile.isFile) { + soFile.delete() + download(soUrl, md5, downloadFile, soFile) + //如果文件不存在或不是文件,则调用系统行为进行加载 + System.loadLibrary(libName) + return + } + if (soFile.exists()) { + //如果文件存在,则校验md5值 + val fileMD5 = getFileMD5(soFile) + if (fileMD5 != null && fileMD5.equals(md5, ignoreCase = true)) { + //md5值一样,则加载 + System.load(soFile.absolutePath) + Timber.d("load from:$soFile") + return + } + //md5不一样则删除 + soFile.delete() + } + //不存在则下载 + download(soUrl, md5, downloadFile, soFile) + //使用系统加载方法 + System.loadLibrary(libName) + } finally { + Timber.d("time:" + (System.currentTimeMillis() - start)) + } + } + + @SuppressLint("DiscouragedPrivateApi") + private fun getCpuAbi(context: Context): String? { + if (cpuAbi != null) { + return cpuAbi + } + // 5.0以上Application才有primaryCpuAbi字段 + try { + val appInfo = context.applicationInfo + val abiField = ApplicationInfo::class.java.getDeclaredField("primaryCpuAbi") + abiField.isAccessible = true + cpuAbi = abiField.get(appInfo) as String? + } catch (e: Exception) { + Timber.e(e) + } + if (TextUtils.isEmpty(cpuAbi)) { + cpuAbi = Build.SUPPORTED_ABIS[0] + } + return cpuAbi + } + + + /** + * 删除历史文件 + */ + private fun deleteHistoryFile(dir: File, currentFile: File?) { + val files = dir.listFiles() + @Suppress("SameParameterValue") + if (files != null && files.isNotEmpty()) { + for (f in files) { + if (f.exists() && (currentFile == null || f.absolutePath != currentFile.absolutePath)) { + val delete = f.delete() + Timber.d("delete file: $f result: $delete") + if (!delete) { + f.deleteOnExit() + } + } + } + } + } + + /** + * 下载文件 + */ + private fun downloadFileIfNotExist(url: String, destFile: File): Boolean { + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + try { + val connection = URL(url).openConnection() as HttpURLConnection + inputStream = connection.inputStream + if (destFile.exists()) { + return true + } + destFile.parentFile!!.mkdirs() + destFile.createNewFile() + outputStream = FileOutputStream(destFile) + val buffer = ByteArray(32768) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + outputStream.flush() + } + return true + } catch (e: Throwable) { + Timber.e(e) + if (destFile.exists() && !destFile.delete()) { + destFile.deleteOnExit() + } + } finally { + if (inputStream != null) { + try { + inputStream.close() + } catch (e: IOException) { + Timber.e(e) + } + } + if (outputStream != null) { + try { + outputStream.close() + } catch (e: IOException) { + Timber.e(e) + } + } + } + return false + } + + /** + * 下载并拷贝文件 + */ + @Suppress("SameParameterValue") + @Synchronized + private fun download( + url: String, + md5: String?, + downloadTempFile: File, + destSuccessFile: File + ) { + if (download || AppConfig.isGooglePlay) { + return + } + download = true + executor.execute { + val result = downloadFileIfNotExist(url, downloadTempFile) + Timber.d("download result:$result") + //文件md5再次校验 + val fileMD5 = getFileMD5(downloadTempFile) + if (md5 != null && !md5.equals(fileMD5, ignoreCase = true)) { + val delete = downloadTempFile.delete() + if (!delete) { + downloadTempFile.deleteOnExit() + } + download = false + return@execute + } + Timber.d("download success, copy to $destSuccessFile") + //下载成功拷贝文件 + copyFile(downloadTempFile, destSuccessFile) + cacheInstall = false + val parentFile = downloadTempFile.parentFile + @Suppress("SameParameterValue") + deleteHistoryFile(parentFile!!, null) + } + } + + /** + * 拷贝文件 + */ + private fun copyFile(source: File?, dest: File?): Boolean { + if (source == null || !source.exists() || !source.isFile || dest == null) { + return false + } + if (source.absolutePath == dest.absolutePath) { + return true + } + var fileInputStream: FileInputStream? = null + var os: FileOutputStream? = null + val parent = dest.parentFile + if (parent != null && !parent.exists()) { + val mkdirs = parent.mkdirs() + if (!mkdirs) { + parent.mkdirs() + } + } + try { + fileInputStream = FileInputStream(source) + os = FileOutputStream(dest, false) + val buffer = ByteArray(1024 * 512) + var length: Int + while (fileInputStream.read(buffer).also { length = it } > 0) { + os.write(buffer, 0, length) + } + return true + } catch (e: Exception) { + Timber.e(e) + } finally { + if (fileInputStream != null) { + try { + fileInputStream.close() + } catch (e: Exception) { + Timber.e(e) + } + } + if (os != null) { + try { + os.close() + } catch (e: Exception) { + Timber.e(e) + } + } + } + return false + } + + /** + * 获得文件md5 + */ + private fun getFileMD5(file: File): String? { + var fileInputStream: FileInputStream? = null + try { + fileInputStream = FileInputStream(file) + val md5 = MessageDigest.getInstance("MD5") + val buffer = ByteArray(1024) + var numRead: Int + while (fileInputStream.read(buffer).also { numRead = it } > 0) { + md5.update(buffer, 0, numRead) + } + return String.format("%032x", BigInteger(1, md5.digest())).lowercase() + } catch (e: Exception) { + Timber.e(e) + } catch (e: OutOfMemoryError) { + Timber.e(e) + } finally { + if (fileInputStream != null) { + try { + fileInputStream.close() + } catch (e: Exception) { + Timber.e(e) + } + } + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetRequestCallback.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetRequestCallback.kt new file mode 100644 index 000000000..30807dafc --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetRequestCallback.kt @@ -0,0 +1,231 @@ +package io.legado.app.help.http.cronet + +import android.os.ConditionVariable +import io.legado.app.help.http.okHttpClient +import okhttp3.* +import okhttp3.EventListener +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.asResponseBody +import okio.Buffer +import org.chromium.net.CronetException +import org.chromium.net.UrlRequest +import org.chromium.net.UrlResponseInfo +import timber.log.Timber +import java.io.IOException +import java.nio.ByteBuffer +import java.util.* + +class CronetRequestCallback @JvmOverloads internal constructor( + private val originalRequest: Request, + private val mCall: Call, + eventListener: EventListener? = null, + responseCallback: Callback? = null +) : UrlRequest.Callback() { + + private val eventListener: EventListener? + private val responseCallback: Callback? + private var followCount = 0 + private var mResponse: Response + private var mException: IOException? = null + private val mResponseCondition = ConditionVariable() + private val mBuffer = Buffer() + + @Throws(IOException::class) + fun waitForDone(urlRequest: UrlRequest): Response { + //获取okhttp call的完整请求的超时时间 + val timeOutMs: Long = mCall.timeout().timeoutNanos() / 1000000 + if (timeOutMs > 0) { + mResponseCondition.block(timeOutMs) + } else { + mResponseCondition.block() + } + //ConditionVariable 正常open或者超时open后,检查urlRequest是否完成 + if (!urlRequest.isDone) { + urlRequest.cancel() + mException = IOException("Cronet timeout after wait " + timeOutMs + "ms") + } + + if (mException != null) { + throw mException as IOException + } + return this.mResponse + } + + override fun onRedirectReceived( + request: UrlRequest, + info: UrlResponseInfo, + newLocationUrl: String + ) { + if (followCount > MAX_FOLLOW_COUNT) { + request.cancel() + } + followCount += 1 + val client = okHttpClient + if (originalRequest.url.isHttps && newLocationUrl.startsWith("http://") && client.followSslRedirects) { + request.followRedirect() + } else if (!originalRequest.url.isHttps && newLocationUrl.startsWith("https://") && client.followSslRedirects) { + request.followRedirect() + } else if (client.followRedirects) { + request.followRedirect() + } else { + request.cancel() + } + } + + override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { + this.mResponse = responseFromResponse(this.mResponse, info) +// 用于调试 +// val sb: StringBuilder = StringBuilder(info.url).append("\r\n") +// sb.append("[Cached:").append(info.wasCached()).append("][StatusCode:") +// .append(info.httpStatusCode).append("][StatusText:").append(info.httpStatusText) +// .append("][Protocol:").append(info.negotiatedProtocol).append("][ByteCount:") +// .append(info.receivedByteCount).append("]\r\n"); +// val httpHeaders=info.allHeadersAsList +// httpHeaders.forEach { h -> +// sb.append("[").append(h.key).append("]").append(h.value).append("\r\n"); +// } +// Log.e("Cronet", sb.toString()) + //打印协议,用于调试 + Timber.i(info.negotiatedProtocol) + if (eventListener != null) { + eventListener.responseHeadersEnd(mCall, this.mResponse) + eventListener.responseBodyStart(mCall) + } + request.read(ByteBuffer.allocateDirect(32 * 1024)) + } + + @Throws(Exception::class) + override fun onReadCompleted( + request: UrlRequest, + info: UrlResponseInfo, + byteBuffer: ByteBuffer + ) { + + + byteBuffer.flip() + + try { + mBuffer.write(byteBuffer) + } catch (e: IOException) { + Timber.i(e, "IOException during ByteBuffer read. Details: ") + throw e + } + byteBuffer.clear() + request.read(byteBuffer) + } + + override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { + eventListener?.responseBodyEnd(mCall, info.receivedByteCount) + val contentType: MediaType? = (this.mResponse.header("content-type") + ?: "text/plain; charset=\"utf-8\"").toMediaTypeOrNull() + val responseBody: ResponseBody = + mBuffer.asResponseBody(contentType) + val newRequest = originalRequest.newBuilder().url(info.url).build() + this.mResponse = this.mResponse.newBuilder().body(responseBody).request(newRequest).build() + mResponseCondition.open() + eventListener?.callEnd(mCall) + if (responseCallback != null) { + try { + responseCallback.onResponse(mCall, this.mResponse) + } catch (e: IOException) { + // Pass? + } + } + } + + //UrlResponseInfo可能为null + override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { + Timber.e(error.message.toString()) + val msg = error.localizedMessage + val e = IOException(msg?.substring(msg.indexOf("net::")), error) + mException = e + mResponseCondition.open() + + this.eventListener?.callFailed(mCall, e) + responseCallback?.onFailure(mCall, e) + } + + override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) { + mResponseCondition.open() + this.eventListener?.callEnd(mCall) + + + } + + companion object { + private const val MAX_FOLLOW_COUNT = 20 + + private fun protocolFromNegotiatedProtocol(responseInfo: UrlResponseInfo): Protocol { + val negotiatedProtocol = responseInfo.negotiatedProtocol.lowercase(Locale.getDefault()) + return when { + negotiatedProtocol.contains("h3") -> { + return Protocol.QUIC + } + negotiatedProtocol.contains("quic") -> { + Protocol.QUIC + } + negotiatedProtocol.contains("spdy") -> { + @Suppress("DEPRECATION") + Protocol.SPDY_3 + } + negotiatedProtocol.contains("h2") -> { + Protocol.HTTP_2 + } + negotiatedProtocol.contains("1.1") -> { + Protocol.HTTP_1_1 + } + else -> { + Protocol.HTTP_1_0 + } + } + } + + private fun headersFromResponse(responseInfo: UrlResponseInfo): Headers { + val headers = responseInfo.allHeadersAsList + return Headers.Builder().apply { + for ((key, value) in headers) { + try { + + if (key.equals("content-encoding", ignoreCase = true)) { + // Strip all content encoding headers as decoding is done handled by cronet + continue + } + add(key, value) + } catch (e: Exception) { + Timber.w("Invalid HTTP header/value: $key$value") + // Ignore that header + } + } + + }.build() + + } + + private fun responseFromResponse( + response: Response, + responseInfo: UrlResponseInfo + ): Response { + val protocol = protocolFromNegotiatedProtocol(responseInfo) + val headers = headersFromResponse(responseInfo) + return response.newBuilder() + .receivedResponseAtMillis(System.currentTimeMillis()) + .protocol(protocol) + .code(responseInfo.httpStatusCode) + .message(responseInfo.httpStatusText) + .headers(headers) + .build() + } + } + + init { + this.mResponse = Response.Builder() + .sentRequestAtMillis(System.currentTimeMillis()) + .request(originalRequest) + .protocol(Protocol.HTTP_1_0) + .code(0) + .message("") + .build() + this.responseCallback = responseCallback + this.eventListener = eventListener + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/AppWebDav.kt b/app/src/main/java/io/legado/app/help/storage/AppWebDav.kt new file mode 100644 index 000000000..1fc779247 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/AppWebDav.kt @@ -0,0 +1,177 @@ +package io.legado.app.help.storage + +import android.content.Context +import android.os.Handler +import android.os.Looper +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookProgress +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.webdav.HttpAuth +import io.legado.app.lib.webdav.WebDav +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.withContext +import splitties.init.appCtx +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +object AppWebDav { + private const val defaultWebDavUrl = "https://dav.jianguoyun.com/dav/" + private val bookProgressUrl = "${rootWebDavUrl}bookProgress/" + private val zipFilePath = "${appCtx.externalFiles.absolutePath}${File.separator}backup.zip" + + private val rootWebDavUrl: String + get() { + var url = appCtx.getPrefString(PreferKey.webDavUrl)?.trim() + if (url.isNullOrEmpty()) { + url = defaultWebDavUrl + } + if (!url.endsWith("/")) url = "${url}/" + if (appCtx.getPrefBoolean(PreferKey.webDavCreateDir, true)) { + url = "${url}legado/" + } + return url + } + + @Throws(Exception::class) + suspend fun initWebDav(): Boolean { + val account = appCtx.getPrefString(PreferKey.webDavAccount) + val password = appCtx.getPrefString(PreferKey.webDavPassword) + if (!account.isNullOrBlank() && !password.isNullOrBlank()) { + HttpAuth.auth = HttpAuth.Auth(account, password) + WebDav(rootWebDavUrl).makeAsDir() + WebDav(bookProgressUrl).makeAsDir() + return true + } + return false + } + + @Throws(Exception::class) + private suspend fun getWebDavFileNames(): ArrayList { + val url = rootWebDavUrl + val names = arrayListOf() + if (initWebDav()) { + var files = WebDav(url).listFiles() + files = files.reversed() + files.forEach { + val name = it.displayName + if (name?.startsWith("backup") == true) { + names.add(name) + } + } + } else { + throw NoStackTraceException("webDav没有配置") + } + return names + } + + suspend fun showRestoreDialog(context: Context) { + val names = withContext(IO) { getWebDavFileNames() } + if (names.isNotEmpty()) { + withContext(Main) { + context.selector( + title = context.getString(R.string.select_restore_file), + items = names + ) { _, index -> + if (index in 0 until names.size) { + Coroutine.async { + restoreWebDav(names[index]) + }.onError { + appCtx.toastOnUi("WebDavError:${it.localizedMessage}") + } + } + } + } + } else { + throw NoStackTraceException("Web dav no back up file") + } + } + + private suspend fun restoreWebDav(name: String) { + rootWebDavUrl.let { + val webDav = WebDav(it + name) + webDav.downloadTo(zipFilePath, true) + @Suppress("BlockingMethodInNonBlockingContext") + ZipUtils.unzipFile(zipFilePath, Backup.backupPath) + Restore.restoreDatabase() + Restore.restoreConfig() + } + } + + suspend fun backUpWebDav(path: String) { + try { + if (initWebDav() && NetworkUtils.isAvailable()) { + val paths = arrayListOf(*Backup.backupFileNames) + for (i in 0 until paths.size) { + paths[i] = path + File.separator + paths[i] + } + FileUtils.deleteFile(zipFilePath) + if (ZipUtils.zipFiles(paths, zipFilePath)) { + val backupDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + .format(Date(System.currentTimeMillis())) + val putUrl = "${rootWebDavUrl}backup${backupDate}.zip" + WebDav(putUrl).upload(zipFilePath) + } + } + } catch (e: Exception) { + appCtx.toastOnUi("WebDav\n${e.localizedMessage}") + } + } + + suspend fun exportWebDav(byteArray: ByteArray, fileName: String) { + try { + if (initWebDav() && NetworkUtils.isAvailable()) { + // 默认导出到legado文件夹下exports目录 + val exportsWebDavUrl = rootWebDavUrl + EncoderUtils.escape("exports") + "/" + // 在legado文件夹创建exports目录,如果不存在的话 + WebDav(exportsWebDavUrl).makeAsDir() + // 如果导出的本地文件存在,开始上传 + val putUrl = exportsWebDavUrl + fileName + WebDav(putUrl).upload(byteArray, "text/plain") + } + } catch (e: Exception) { + Handler(Looper.getMainLooper()).post { + appCtx.toastOnUi("WebDav导出\n${e.localizedMessage}") + } + } + } + + fun uploadBookProgress(book: Book) { + if (!AppConfig.syncBookProgress) return + if (!NetworkUtils.isAvailable()) return + Coroutine.async { + val bookProgress = BookProgress(book) + val json = GSON.toJson(bookProgress) + val url = getProgressUrl(book) + if (initWebDav()) { + WebDav(url).upload(json.toByteArray(), "application/json") + } + } + } + + suspend fun getBookProgress(book: Book): BookProgress? { + if (initWebDav() && NetworkUtils.isAvailable()) { + val url = getProgressUrl(book) + WebDav(url).download()?.let { byteArray -> + val json = String(byteArray) + if (json.isJson()) { + GSON.fromJsonObject(json)?.let { + return it + } + } + } + } + return null + } + + private fun getProgressUrl(book: Book): String { + return bookProgressUrl + book.name + "_" + book.author + ".json" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/Backup.kt b/app/src/main/java/io/legado/app/help/storage/Backup.kt new file mode 100644 index 000000000..5ff67a0fa --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/Backup.kt @@ -0,0 +1,165 @@ +package io.legado.app.help.storage + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.legado.app.R +import io.legado.app.constant.AppLog +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.help.DefaultData +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.ThemeConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext +import splitties.init.appCtx +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + + +object Backup : BackupRestore() { + + val backupPath: String by lazy { + appCtx.filesDir.getFile("backup").absolutePath + } + + val backupFileNames by lazy { + arrayOf( + "bookshelf.json", + "bookmark.json", + "bookGroup.json", + "bookSource.json", + "rssSources.json", + "rssStar.json", + "replaceRule.json", + "readRecord.json", + "searchHistory.json", + "sourceSub.json", + DefaultData.txtTocRuleFileName, + DefaultData.httpTtsFileName, + ReadBookConfig.configFileName, + ReadBookConfig.shareConfigFileName, + ThemeConfig.configFileName, + "config.xml" + ) + } + + fun autoBack(context: Context) { + val lastBackup = context.getPrefLong(PreferKey.lastBackup) + if (lastBackup + TimeUnit.DAYS.toMillis(1) < System.currentTimeMillis()) { + Coroutine.async { + backup(context, context.getPrefString(PreferKey.backupPath) ?: "", true) + }.onError { + AppLog.put("备份出错\n${it.localizedMessage}", it) + appCtx.toastOnUi(appCtx.getString(R.string.autobackup_fail, it.localizedMessage)) + } + } + } + + suspend fun backup(context: Context, path: String, isAuto: Boolean = false) { + context.putPrefLong(PreferKey.lastBackup, System.currentTimeMillis()) + withContext(IO) { + FileUtils.deleteFile(backupPath) + writeListToJson(appDb.bookDao.all, "bookshelf.json", backupPath) + writeListToJson(appDb.bookmarkDao.all, "bookmark.json", backupPath) + writeListToJson(appDb.bookGroupDao.all, "bookGroup.json", backupPath) + writeListToJson(appDb.bookSourceDao.all, "bookSource.json", backupPath) + writeListToJson(appDb.rssSourceDao.all, "rssSources.json", backupPath) + writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath) + writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath) + writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath) + writeListToJson(appDb.searchKeywordDao.all, "searchHistory.json", backupPath) + writeListToJson(appDb.ruleSubDao.all, "sourceSub.json", backupPath) + writeListToJson(appDb.txtTocRuleDao.all, DefaultData.txtTocRuleFileName, backupPath) + writeListToJson(appDb.httpTTSDao.all, DefaultData.httpTtsFileName, backupPath) + GSON.toJson(ReadBookConfig.configList).let { + FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.configFileName) + .writeText(it) + } + GSON.toJson(ReadBookConfig.shareConfig).let { + FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.shareConfigFileName) + .writeText(it) + } + GSON.toJson(ThemeConfig.configList).let { + FileUtils.createFileIfNotExist(backupPath + File.separator + ThemeConfig.configFileName) + .writeText(it) + } + Preferences.getSharedPreferences(appCtx, backupPath, "config")?.let { sp -> + val edit = sp.edit() + appCtx.defaultSharedPreferences.all.forEach { (key, value) -> + if (keyIsNotIgnore(key)) { + when (value) { + is Int -> edit.putInt(key, value) + is Boolean -> edit.putBoolean(key, value) + is Long -> edit.putLong(key, value) + is Float -> edit.putFloat(key, value) + is String -> edit.putString(key, value) + } + } + } + edit.commit() + } + AppWebDav.backUpWebDav(backupPath) + if (path.isContentScheme()) { + copyBackup(context, Uri.parse(path), isAuto) + } else { + if (path.isEmpty()) { + copyBackup(context.getExternalFilesDir(null)!!, false) + } else { + copyBackup(File(path), isAuto) + } + } + } + } + + private fun writeListToJson(list: List, fileName: String, path: String) { + if (list.isNotEmpty()) { + val file = FileUtils.createFileIfNotExist(path + File.separator + fileName) + FileOutputStream(file).use { + GSON.writeToOutputStream(it, list) + } + } + } + + @Throws(java.lang.Exception::class) + private fun copyBackup(context: Context, uri: Uri, isAuto: Boolean) { + DocumentFile.fromTreeUri(context, uri)?.let { treeDoc -> + for (fileName in backupFileNames) { + val file = File(backupPath + File.separator + fileName) + if (file.exists()) { + if (isAuto) { + treeDoc.findFile("auto")?.findFile(fileName)?.delete() + DocumentUtils.createFileIfNotExist( + treeDoc, + fileName, + subDirs = arrayOf("auto") + )?.writeBytes(context, file.readBytes()) + } else { + treeDoc.findFile(fileName)?.delete() + treeDoc.createFile("", fileName) + ?.writeBytes(context, file.readBytes()) + } + } + } + } + } + + @Throws(java.lang.Exception::class) + private fun copyBackup(rootFile: File, isAuto: Boolean) { + for (fileName in backupFileNames) { + val file = File(backupPath + File.separator + fileName) + if (file.exists()) { + file.copyTo( + if (isAuto) { + FileUtils.createFileIfNotExist(rootFile, "auto", fileName) + } else { + FileUtils.createFileIfNotExist(rootFile, fileName) + }, true + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/BackupRestore.kt b/app/src/main/java/io/legado/app/help/storage/BackupRestore.kt new file mode 100644 index 000000000..0db50c607 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/BackupRestore.kt @@ -0,0 +1,83 @@ +package io.legado.app.help.storage + +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.utils.FileUtils +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import splitties.init.appCtx + +abstract class BackupRestore { + + private val ignoreConfigPath = FileUtils.getPath(appCtx.filesDir, "restoreIgnore.json") + val ignoreConfig: HashMap by lazy { + val file = FileUtils.createFileIfNotExist(ignoreConfigPath) + val json = file.readText() + GSON.fromJsonObject>(json) ?: hashMapOf() + } + + //忽略key + val ignoreKeys = arrayOf( + "readConfig", + PreferKey.themeMode, + PreferKey.bookshelfLayout, + PreferKey.showRss, + PreferKey.threadCount, + PreferKey.defaultBookTreeUri + ) + + //忽略标题 + val ignoreTitle = arrayOf( + appCtx.getString(R.string.read_config), + appCtx.getString(R.string.theme_mode), + appCtx.getString(R.string.bookshelf_layout), + appCtx.getString(R.string.show_rss), + appCtx.getString(R.string.thread_count) + ) + + //默认忽略keys + private val ignorePrefKeys = arrayOf( + PreferKey.themeMode, + PreferKey.defaultCover, + PreferKey.defaultCoverDark + ) + + //阅读配置 + private val readPrefKeys = arrayOf( + PreferKey.readStyleSelect, + PreferKey.shareLayout, + PreferKey.hideStatusBar, + PreferKey.hideNavigationBar, + PreferKey.autoReadSpeed + ) + + + protected fun keyIsNotIgnore(key: String): Boolean { + return when { + ignorePrefKeys.contains(key) -> false + readPrefKeys.contains(key) && ignoreReadConfig -> false + PreferKey.themeMode == key && ignoreThemeMode -> false + PreferKey.bookshelfLayout == key && ignoreBookshelfLayout -> false + PreferKey.showRss == key && ignoreShowRss -> false + PreferKey.threadCount == key && ignoreThreadCount -> false + else -> true + } + } + + protected val ignoreReadConfig: Boolean + get() = ignoreConfig["readConfig"] == true + private val ignoreThemeMode: Boolean + get() = ignoreConfig[PreferKey.themeMode] == true + private val ignoreBookshelfLayout: Boolean + get() = ignoreConfig[PreferKey.bookshelfLayout] == true + private val ignoreShowRss: Boolean + get() = ignoreConfig[PreferKey.showRss] == true + private val ignoreThreadCount: Boolean + get() = ignoreConfig[PreferKey.threadCount] == true + + fun saveIgnoreConfig() { + val json = GSON.toJson(ignoreConfig) + FileUtils.createFileIfNotExist(ignoreConfigPath).writeText(json) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/ImportOldData.kt b/app/src/main/java/io/legado/app/help/storage/ImportOldData.kt new file mode 100644 index 000000000..b2709cc35 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/ImportOldData.kt @@ -0,0 +1,102 @@ +package io.legado.app.help.storage + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.utils.* +import java.io.File + +object ImportOldData { + + fun importUri(context: Context, uri: Uri) { + if (uri.isContentScheme()) { + DocumentFile.fromTreeUri(context, uri)?.listFiles()?.forEach { doc -> + when (doc.name) { + "myBookShelf.json" -> + kotlin.runCatching { + DocumentUtils.readText(context, doc.uri).let { json -> + val importCount = importOldBookshelf(json) + context.toastOnUi("成功导入书籍${importCount}") + } + }.onFailure { + context.toastOnUi("导入书籍失败\n${it.localizedMessage}") + } + "myBookSource.json" -> + kotlin.runCatching { + DocumentUtils.readText(context, doc.uri).let { json -> + val importCount = importOldSource(json) + context.toastOnUi("成功导入书源${importCount}") + } + }.onFailure { + context.toastOnUi("导入源失败\n${it.localizedMessage}") + } + "myBookReplaceRule.json" -> + kotlin.runCatching { + DocumentUtils.readText(context, doc.uri).let { json -> + val importCount = importOldReplaceRule(json) + context.toastOnUi("成功导入替换规则${importCount}") + } + }.onFailure { + context.toastOnUi("导入替换规则失败\n${it.localizedMessage}") + } + } + } + } else { + uri.path?.let { path -> + val file = File(path) + kotlin.runCatching {// 导入书架 + val shelfFile = + FileUtils.createFileIfNotExist(file, "myBookShelf.json") + val json = shelfFile.readText() + val importCount = importOldBookshelf(json) + context.toastOnUi("成功导入书籍${importCount}") + }.onFailure { + context.toastOnUi("导入书籍失败\n${it.localizedMessage}") + } + + kotlin.runCatching {// Book source + val sourceFile = + file.getFile("myBookSource.json") + val json = sourceFile.readText() + val importCount = importOldSource(json) + context.toastOnUi("成功导入书源${importCount}") + }.onFailure { + context.toastOnUi("导入源失败\n${it.localizedMessage}") + } + + kotlin.runCatching {// Replace rules + val ruleFile = file.getFile("myBookReplaceRule.json") + if (ruleFile.exists()) { + val json = ruleFile.readText() + val importCount = importOldReplaceRule(json) + context.toastOnUi("成功导入替换规则${importCount}") + } else { + context.toastOnUi("未找到替换规则") + } + }.onFailure { + context.toastOnUi("导入替换规则失败\n${it.localizedMessage}") + } + } + } + } + + private fun importOldBookshelf(json: String): Int { + val books = OldBook.toNewBook(json) + appDb.bookDao.insert(*books.toTypedArray()) + return books.size + } + + fun importOldSource(json: String): Int { + val bookSources = BookSource.fromJsonArray(json) + appDb.bookSourceDao.insert(*bookSources.toTypedArray()) + return bookSources.size + } + + private fun importOldReplaceRule(json: String): Int { + val rules = OldReplace.jsonToReplaceRules(json) + appDb.replaceRuleDao.insert(*rules.toTypedArray()) + return rules.size + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/OldBook.kt b/app/src/main/java/io/legado/app/help/storage/OldBook.kt new file mode 100644 index 000000000..64f8e45c4 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/OldBook.kt @@ -0,0 +1,50 @@ +package io.legado.app.help.storage + +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.utils.* +import timber.log.Timber + +object OldBook { + + fun toNewBook(json: String): List { + val books = mutableListOf() + val items: List> = jsonPath.parse(json).read("$") + val existingBooks = appDb.bookDao.allBookUrls.toSet() + for (item in items) { + val jsonItem = jsonPath.parse(item) + val book = Book() + book.bookUrl = jsonItem.readString("$.noteUrl") ?: "" + if (book.bookUrl.isBlank()) continue + book.name = jsonItem.readString("$.bookInfoBean.name") ?: "" + if (book.bookUrl in existingBooks) { + Timber.d("Found existing book: " + book.name) + continue + } + book.origin = jsonItem.readString("$.tag") ?: "" + book.originName = jsonItem.readString("$.bookInfoBean.origin") ?: "" + book.author = jsonItem.readString("$.bookInfoBean.author") ?: "" + book.type = + if (jsonItem.readString("$.bookInfoBean.bookSourceType") == "AUDIO") 1 else 0 + book.tocUrl = jsonItem.readString("$.bookInfoBean.chapterUrl") ?: book.bookUrl + book.coverUrl = jsonItem.readString("$.bookInfoBean.coverUrl") + book.customCoverUrl = jsonItem.readString("$.customCoverPath") + book.lastCheckTime = jsonItem.readLong("$.bookInfoBean.finalRefreshData") ?: 0 + book.canUpdate = jsonItem.readBool("$.allowUpdate") == true + book.totalChapterNum = jsonItem.readInt("$.chapterListSize") ?: 0 + book.durChapterIndex = jsonItem.readInt("$.durChapter") ?: 0 + book.durChapterTitle = jsonItem.readString("$.durChapterName") + book.durChapterPos = jsonItem.readInt("$.durChapterPage") ?: 0 + book.durChapterTime = jsonItem.readLong("$.finalDate") ?: 0 + book.intro = jsonItem.readString("$.bookInfoBean.introduce") + book.latestChapterTitle = jsonItem.readString("$.lastChapterName") + book.lastCheckCount = jsonItem.readInt("$.newChapters") ?: 0 + book.order = jsonItem.readInt("$.serialNumber") ?: 0 + book.variable = jsonItem.readString("$.variable") + book.setUseReplaceRule(jsonItem.readBool("$.useReplaceRule") == true) + books.add(book) + } + return books + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/OldReplace.kt b/app/src/main/java/io/legado/app/help/storage/OldReplace.kt new file mode 100644 index 000000000..66664637d --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/OldReplace.kt @@ -0,0 +1,46 @@ +package io.legado.app.help.storage + +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.utils.* + +object OldReplace { + + fun jsonToReplaceRules(json: String): List { + val replaceRules = mutableListOf() + val items: List> = jsonPath.parse(json).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + jsonToReplaceRule(jsonItem.jsonString())?.let { + if (it.isValid()) { + replaceRules.add(it) + } + } + } + return replaceRules + } + + private fun jsonToReplaceRule(json: String): ReplaceRule? { + var replaceRule: ReplaceRule? = null + runCatching { + replaceRule = GSON.fromJsonObject(json.trim()) + } + runCatching { + if (replaceRule == null || replaceRule?.pattern.isNullOrBlank()) { + val jsonItem = jsonPath.parse(json.trim()) + val rule = ReplaceRule() + rule.id = jsonItem.readLong("$.id") ?: System.currentTimeMillis() + rule.pattern = jsonItem.readString("$.regex") ?: "" + if (rule.pattern.isEmpty()) return null + rule.name = jsonItem.readString("$.replaceSummary") ?: "" + rule.replacement = jsonItem.readString("$.replacement") ?: "" + rule.isRegex = jsonItem.readBool("$.isRegex") == true + rule.scope = jsonItem.readString("$.useTo") + rule.isEnabled = jsonItem.readBool("$.enable") == true + rule.order = jsonItem.readInt("$.serialNumber") ?: 0 + return rule + } + } + return replaceRule + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/Preferences.kt b/app/src/main/java/io/legado/app/help/storage/Preferences.kt new file mode 100644 index 000000000..3559ae944 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/Preferences.kt @@ -0,0 +1,49 @@ +package io.legado.app.help.storage + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.SharedPreferences + +import timber.log.Timber +import java.io.File + +object Preferences { + + /** + * 用反射生成 SharedPreferences + * @param context + * @param dir + * @param fileName 文件名,不需要 '.xml' 后缀 + * @return + */ + fun getSharedPreferences( + context: Context, + dir: String, + fileName: String + ): SharedPreferences? { + try { + // 获取 ContextWrapper对象中的mBase变量。该变量保存了 ContextImpl 对象 + val fieldMBase = ContextWrapper::class.java.getDeclaredField("mBase") + fieldMBase.isAccessible = true + // 获取 mBase变量 + val objMBase = fieldMBase.get(context) + // 获取 ContextImpl。mPreferencesDir变量,该变量保存了数据文件的保存路径 + val fieldMPreferencesDir = objMBase.javaClass.getDeclaredField("mPreferencesDir") + fieldMPreferencesDir.isAccessible = true + // 创建自定义路径 + val file = File(dir) + // 修改mPreferencesDir变量的值 + fieldMPreferencesDir.set(objMBase, file) + // 返回修改路径以后的 SharedPreferences :%FILE_PATH%/%fileName%.xml + return context.getSharedPreferences(fileName, Activity.MODE_PRIVATE) + } catch (e: NoSuchFieldException) { + Timber.e(e) + } catch (e: IllegalArgumentException) { + Timber.e(e) + } catch (e: IllegalAccessException) { + Timber.e(e) + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt new file mode 100644 index 000000000..a8a6e789b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/Restore.kt @@ -0,0 +1,203 @@ +package io.legado.app.help.storage + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.legado.app.BuildConfig +import io.legado.app.R +import io.legado.app.constant.AppConst.androidId +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.* +import io.legado.app.help.DefaultData +import io.legado.app.help.LauncherIconHelp +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.ThemeConfig +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import splitties.init.appCtx +import timber.log.Timber +import java.io.File + + +object Restore : BackupRestore() { + + suspend fun restore(context: Context, path: String) { + withContext(IO) { + if (path.isContentScheme()) { + DocumentFile.fromTreeUri(context, Uri.parse(path))?.listFiles()?.forEach { doc -> + for (fileName in Backup.backupFileNames) { + if (doc.name == fileName) { + DocumentUtils.readText(context, doc.uri).let { + FileUtils.createFileIfNotExist("${Backup.backupPath}${File.separator}$fileName") + .writeText(it) + } + } + } + } + } else { + try { + val file = File(path) + for (fileName in Backup.backupFileNames) { + file.getFile(fileName).let { + if (it.exists()) { + it.copyTo( + FileUtils.createFileIfNotExist("${Backup.backupPath}${File.separator}$fileName"), + true + ) + } + } + } + } catch (e: Exception) { + Timber.e(e) + } + } + } + restoreDatabase() + restoreConfig() + } + + suspend fun restoreDatabase(path: String = Backup.backupPath) { + withContext(IO) { + fileToListT(path, "bookshelf.json")?.let { + appDb.bookDao.insert(*it.toTypedArray()) + } + fileToListT(path, "bookmark.json")?.let { + appDb.bookmarkDao.insert(*it.toTypedArray()) + } + fileToListT(path, "bookGroup.json")?.let { + appDb.bookGroupDao.insert(*it.toTypedArray()) + } + fileToListT(path, "bookSource.json")?.let { + appDb.bookSourceDao.insert(*it.toTypedArray()) + } ?: run { + val bookSourceFile = + FileUtils.createFileIfNotExist(path + File.separator + "bookSource.json") + val json = bookSourceFile.readText() + ImportOldData.importOldSource(json) + } + fileToListT(path, "rssSources.json")?.let { + appDb.rssSourceDao.insert(*it.toTypedArray()) + } + fileToListT(path, "rssStar.json")?.let { + appDb.rssStarDao.insert(*it.toTypedArray()) + } + fileToListT(path, "replaceRule.json")?.let { + appDb.replaceRuleDao.insert(*it.toTypedArray()) + } + fileToListT(path, "searchHistory.json")?.let { + appDb.searchKeywordDao.insert(*it.toTypedArray()) + } + fileToListT(path, "sourceSub.json")?.let { + appDb.ruleSubDao.insert(*it.toTypedArray()) + } + fileToListT(path, DefaultData.txtTocRuleFileName)?.let { + appDb.txtTocRuleDao.insert(*it.toTypedArray()) + } + fileToListT(path, DefaultData.httpTtsFileName)?.let { + appDb.httpTTSDao.insert(*it.toTypedArray()) + } + fileToListT(path, "readRecord.json")?.let { + it.forEach { readRecord -> + //判断是不是本机记录 + if (readRecord.deviceId != androidId) { + appDb.readRecordDao.insert(readRecord) + } else { + val time = appDb.readRecordDao + .getReadTime(readRecord.deviceId, readRecord.bookName) + if (time == null || time < readRecord.readTime) { + appDb.readRecordDao.insert(readRecord) + } + } + } + } + } + } + + suspend fun restoreConfig(path: String = Backup.backupPath) { + withContext(IO) { + try { + val file = + FileUtils.createFileIfNotExist("$path${File.separator}${ThemeConfig.configFileName}") + if (file.exists()) { + FileUtils.deleteFile(ThemeConfig.configFilePath) + file.copyTo(File(ThemeConfig.configFilePath)) + ThemeConfig.upConfig() + } + } catch (e: Exception) { + Timber.e(e) + } + if (!ignoreReadConfig) { + //恢复阅读界面配置 + try { + val file = + FileUtils.createFileIfNotExist("$path${File.separator}${ReadBookConfig.configFileName}") + if (file.exists()) { + FileUtils.deleteFile(ReadBookConfig.configFilePath) + file.copyTo(File(ReadBookConfig.configFilePath)) + ReadBookConfig.initConfigs() + } + } catch (e: Exception) { + Timber.e(e) + } + try { + val file = + FileUtils.createFileIfNotExist("$path${File.separator}${ReadBookConfig.shareConfigFileName}") + if (file.exists()) { + FileUtils.deleteFile(ReadBookConfig.shareConfigFilePath) + file.copyTo(File(ReadBookConfig.shareConfigFilePath)) + ReadBookConfig.initShareConfig() + } + } catch (e: Exception) { + Timber.e(e) + } + } + Preferences.getSharedPreferences(appCtx, path, "config")?.all?.let { map -> + val edit = appCtx.defaultSharedPreferences.edit() + map.forEach { (key, value) -> + if (keyIsNotIgnore(key)) { + when (value) { + is Int -> edit.putInt(key, value) + is Boolean -> edit.putBoolean(key, value) + is Long -> edit.putLong(key, value) + is Float -> edit.putFloat(key, value) + is String -> edit.putString(key, value) + } + } + } + edit.apply() + } + ReadBookConfig.apply { + styleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect) + shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout) + hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar) + hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar) + autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 46) + } + } + appCtx.toastOnUi(R.string.restore_success) + withContext(Main) { + delay(100) + if (!BuildConfig.DEBUG) { + LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon)) + } + postEvent(EventBus.RECREATE, "") + } + } + + private inline fun fileToListT(path: String, fileName: String): List? { + try { + val file = FileUtils.createFileIfNotExist(path + File.separator + fileName) + val json = file.readText() + return GSON.fromJsonArray(json) + } catch (e: Exception) { + Timber.e(e) + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/README.md b/app/src/main/java/io/legado/app/lib/README.md new file mode 100644 index 000000000..de7b7bd78 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/README.md @@ -0,0 +1,6 @@ +# 放置一些copy过来的库 +* dialogs 弹出框 +* icu4j 编码识别库 +* permission 权限申请库 +* theme 主题 +* webDav 网络存储 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt b/app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt new file mode 100644 index 000000000..5dab676bb --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt @@ -0,0 +1,106 @@ +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package io.legado.app.lib.dialogs + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.view.KeyEvent +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.legado.app.R + +@SuppressLint("SupportAnnotationUsage") +interface AlertBuilder { + val ctx: Context + + fun setTitle(title: CharSequence) + + fun setTitle(titleResource: Int) + + fun setMessage(message: CharSequence) + + fun setMessage(messageResource: Int) + + fun setIcon(icon: Drawable) + + fun setIcon(@DrawableRes iconResource: Int) + + fun setCustomTitle(customTitle: View) + + fun setCustomView(customView: View) + + fun setCancelable(isCancelable: Boolean) + + fun positiveButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + fun positiveButton( + @StringRes buttonTextResource: Int, + onClicked: ((dialog: DialogInterface) -> Unit)? = null + ) + + fun negativeButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + fun negativeButton( + @StringRes buttonTextResource: Int, + onClicked: ((dialog: DialogInterface) -> Unit)? = null + ) + + fun neutralButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + fun neutralButton( + @StringRes buttonTextResource: Int, + onClicked: ((dialog: DialogInterface) -> Unit)? = null + ) + + fun onCancelled(handler: (dialog: DialogInterface) -> Unit) + + fun onKeyPressed(handler: (dialog: DialogInterface, keyCode: Int, e: KeyEvent) -> Boolean) + + fun onDismiss(handler: (dialog: DialogInterface) -> Unit) + + fun items( + items: List, + onItemSelected: (dialog: DialogInterface, index: Int) -> Unit + ) + + fun items( + items: List, + onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit + ) + + fun multiChoiceItems( + items: Array, + checkedItems: BooleanArray, + onClick: (dialog: DialogInterface, which: Int, isChecked: Boolean) -> Unit + ) + + fun singleChoiceItems( + items: Array, + checkedItem: Int = 0, + onClick: ((dialog: DialogInterface, which: Int) -> Unit)? = null + ) + + fun build(): D + fun show(): D + + + fun customTitle(view: () -> View) { + setCustomTitle(view()) + } + + fun customView(view: () -> View) { + setCustomView(view()) + } + + fun okButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = + positiveButton(android.R.string.ok, handler) + + fun cancelButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = + negativeButton(android.R.string.cancel, handler) + + fun yesButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = + positiveButton(R.string.yes, handler) + + fun noButton(handler: ((dialog: DialogInterface) -> Unit)? = null) = + negativeButton(R.string.no, handler) +} diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt b/app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt new file mode 100644 index 000000000..0c1f7950f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt @@ -0,0 +1,145 @@ +package io.legado.app.lib.dialogs + +import android.content.Context +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.view.KeyEvent +import android.view.View +import androidx.appcompat.app.AlertDialog +import io.legado.app.utils.applyTint + +internal class AndroidAlertBuilder(override val ctx: Context) : AlertBuilder { + private val builder = AlertDialog.Builder(ctx) + + override fun setTitle(title: CharSequence) { + builder.setTitle(title) + } + + override fun setTitle(titleResource: Int) { + builder.setTitle(titleResource) + } + + override fun setMessage(message: CharSequence) { + builder.setMessage(message) + } + + override fun setMessage(messageResource: Int) { + builder.setMessage(messageResource) + } + + override fun setIcon(icon: Drawable) { + builder.setIcon(icon) + } + + override fun setIcon(iconResource: Int) { + builder.setIcon(iconResource) + } + + override fun setCustomTitle(customTitle: View) { + builder.setCustomTitle(customTitle) + } + + override fun setCustomView(customView: View) { + builder.setView(customView) + } + + override fun setCancelable(isCancelable: Boolean) { + builder.setCancelable(isCancelable) + } + + override fun onCancelled(handler: (DialogInterface) -> Unit) { + builder.setOnCancelListener(handler) + } + + override fun onKeyPressed(handler: (dialog: DialogInterface, keyCode: Int, e: KeyEvent) -> Boolean) { + builder.setOnKeyListener(handler) + } + + override fun positiveButton( + buttonText: String, + onClicked: ((dialog: DialogInterface) -> Unit)? + ) { + builder.setPositiveButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun positiveButton( + buttonTextResource: Int, + onClicked: ((dialog: DialogInterface) -> Unit)? + ) { + builder.setPositiveButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun negativeButton( + buttonText: String, + onClicked: ((dialog: DialogInterface) -> Unit)? + ) { + builder.setNegativeButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun negativeButton( + buttonTextResource: Int, + onClicked: ((dialog: DialogInterface) -> Unit)? + ) { + builder.setNegativeButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun neutralButton( + buttonText: String, + onClicked: ((dialog: DialogInterface) -> Unit)? + ) { + builder.setNeutralButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun neutralButton( + buttonTextResource: Int, + onClicked: ((dialog: DialogInterface) -> Unit)? + ) { + builder.setNeutralButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun onDismiss(handler: (dialog: DialogInterface) -> Unit) { + builder.setOnDismissListener(handler) + } + + override fun items( + items: List, + onItemSelected: (dialog: DialogInterface, index: Int) -> Unit + ) { + builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which -> + onItemSelected(dialog, which) + } + } + + override fun items( + items: List, + onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit + ) { + builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which -> + onItemSelected(dialog, items[which], which) + } + } + + override fun multiChoiceItems( + items: Array, + checkedItems: BooleanArray, + onClick: (dialog: DialogInterface, which: Int, isChecked: Boolean) -> Unit + ) { + builder.setMultiChoiceItems(items, checkedItems) { dialog, which, isChecked -> + onClick(dialog, which, isChecked) + } + } + + override fun singleChoiceItems( + items: Array, + checkedItem: Int, + onClick: ((dialog: DialogInterface, which: Int) -> Unit)? + ) { + builder.setSingleChoiceItems(items, checkedItem) { dialog, which -> + onClick?.invoke(dialog, which) + } + } + + override fun build(): AlertDialog = builder.create() + + override fun show(): AlertDialog = builder.show().applyTint() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt b/app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt new file mode 100644 index 000000000..5d99e575e --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt @@ -0,0 +1,186 @@ +@file:Suppress("NOTHING_TO_INLINE", "unused", "DEPRECATION") + +package io.legado.app.lib.dialogs + +import android.app.ProgressDialog +import android.content.Context +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment + +fun Context.alert( + title: CharSequence? = null, + message: CharSequence? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertDialog { + return AndroidAlertBuilder(this).apply { + if (title != null) { + this.setTitle(title) + } + if (message != null) { + this.setMessage(message) + } + if (init != null) init() + }.show() +} + +inline fun Fragment.alert( + title: CharSequence? = null, + message: CharSequence? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = requireActivity().alert(title, message, init) + +fun Context.alert( + titleResource: Int? = null, + messageResource: Int? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertDialog { + return AndroidAlertBuilder(this).apply { + if (titleResource != null) { + this.setTitle(titleResource) + } + if (messageResource != null) { + this.setMessage(messageResource) + } + if (init != null) init() + }.show() +} + +inline fun Fragment.alert( + titleResource: Int? = null, + message: Int? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = requireActivity().alert(titleResource, message, init) + +fun Context.alert(init: AlertBuilder.() -> Unit): AlertDialog = + AndroidAlertBuilder(this).apply { + init() + }.show() + +inline fun Fragment.alert(noinline init: AlertBuilder.() -> Unit) = + requireContext().alert(init) + +inline fun Fragment.progressDialog( + title: Int? = null, + message: Int? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().progressDialog(title, message, init) + +fun Context.progressDialog( + title: Int? = null, + message: Int? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title?.let { getString(it) }, message?.let { getString(it) }, false, init) + + +inline fun Fragment.indeterminateProgressDialog( + title: Int? = null, + message: Int? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().indeterminateProgressDialog(title, message, init) + +fun Context.indeterminateProgressDialog( + title: Int? = null, + message: Int? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title?.let { getString(it) }, message?.let { getString(it) }, true, init) + +inline fun Fragment.progressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().progressDialog(title, message, init) + +fun Context.progressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title, message, false, init) + + +inline fun Fragment.indeterminateProgressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().indeterminateProgressDialog(title, message, init) + +fun Context.indeterminateProgressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title, message, true, init) + + +private fun Context.progressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + indeterminate: Boolean, + init: (ProgressDialog.() -> Unit)? = null +) = ProgressDialog(this).apply { + isIndeterminate = indeterminate + if (!indeterminate) setProgressStyle(ProgressDialog.STYLE_HORIZONTAL) + if (message != null) setMessage(message) + if (title != null) setTitle(title) + if (init != null) init() + show() +} + +typealias AlertBuilderFactory = (Context) -> AlertBuilder + +inline fun Fragment.alert( + noinline factory: AlertBuilderFactory, + title: String? = null, + message: String? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = activity?.alert(factory, title, message, init) + +fun Context.alert( + factory: AlertBuilderFactory, + title: String? = null, + message: String? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertBuilder { + return factory(this).apply { + if (title != null) { + this.setTitle(title) + } + if (message != null) { + this.setMessage(message) + } + if (init != null) init() + } +} + +inline fun Fragment.alert( + noinline factory: AlertBuilderFactory, + titleResource: Int? = null, + messageResource: Int? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = requireActivity().alert(factory, titleResource, messageResource, init) + +fun Context.alert( + factory: AlertBuilderFactory, + titleResource: Int? = null, + messageResource: Int? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertBuilder { + return factory(this).apply { + if (titleResource != null) { + this.setTitle(titleResource) + } + if (messageResource != null) { + this.setMessage(messageResource) + } + if (init != null) init() + } +} + +inline fun Fragment.alert( + noinline factory: AlertBuilderFactory, + noinline init: AlertBuilder.() -> Unit +) = requireActivity().alert(factory, init) + +fun Context.alert( + factory: AlertBuilderFactory, + init: AlertBuilder.() -> Unit +): AlertBuilder = factory(this).apply { init() } diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt b/app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt new file mode 100644 index 000000000..c09adc7c0 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package io.legado.app.lib.dialogs + +import android.content.Context +import android.content.DialogInterface + +fun Context.selector( + items: List, + onClick: (DialogInterface, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + items(items, onClick) + show() + } +} + +fun Context.selector( + items: List, + onClick: (DialogInterface, T, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + items(items, onClick) + show() + } +} + +fun Context.selector( + title: CharSequence, + items: List, + onClick: (DialogInterface, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + this.setTitle(title) + items(items, onClick) + show() + } +} + +fun Context.selector( + title: CharSequence, + items: List, + onClick: (DialogInterface, T, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + this.setTitle(title) + items(items, onClick) + show() + } +} + +fun Context.selector( + titleSource: Int, + items: List, + onClick: (DialogInterface, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + this.setTitle(titleSource) + items(items, onClick) + show() + } +} + +fun Context.selector( + titleSource: Int, + items: List, + onClick: (DialogInterface, T, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + this.setTitle(titleSource) + items(items, onClick) + show() + } +} diff --git a/app/src/main/java/io/legado/app/lib/dialogs/SelectItem.kt b/app/src/main/java/io/legado/app/lib/dialogs/SelectItem.kt new file mode 100644 index 000000000..71a8f613a --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/SelectItem.kt @@ -0,0 +1,13 @@ +package io.legado.app.lib.dialogs + +@Suppress("unused") +data class SelectItem( + val title: String, + val value: T +) { + + override fun toString(): String { + return title + } + +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetDetector.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetDetector.java new file mode 100644 index 000000000..a2da7a174 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetDetector.java @@ -0,0 +1,571 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* + ****************************************************************************** + Copyright (C) 2005-2016, International Business Machines Corporation and * + others. All Rights Reserved. * + ****************************************************************************** + */ +package io.legado.app.lib.icu4j; + +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + + +/** + * CharsetDetector provides a facility for detecting the + * charset or encoding of character data in an unknown format. + * The input data can either be from an input stream or an array of bytes. + * The result of the detection operation is a list of possibly matching + * charsets, or, for simple use, you can just ask for a Java Reader that + * will will work over the input data. + *

    + * Character set detection is at best an imprecise operation. The detection + * process will attempt to identify the charset that best matches the characteristics + * of the byte data, but the process is partly statistical in nature, and + * the results can not be guaranteed to always be correct. + *

    + * For best accuracy in charset detection, the input data should be primarily + * in a single language, and a minimum of a few hundred bytes worth of plain text + * in the language are needed. The detection process will attempt to + * ignore html or xml style markup that could otherwise obscure the content. + *

    + * + * @stable ICU 3.4 + */ +@SuppressWarnings({"JavaDoc", "unused", "RedundantSuppression"}) +public class CharsetDetector { + +// Question: Should we have getters corresponding to the setters for input text +// and declared encoding? + +// A thought: If we were to create our own type of Java Reader, we could defer +// figuring out an actual charset for data that starts out with too much English +// only ASCII until the user actually read through to something that didn't look +// like 7 bit English. If nothing else ever appeared, we would never need to +// actually choose the "real" charset. All assuming that the application just +// wants the data, and doesn't care about a char set name. + + /** + * Constructor + * + * @stable ICU 3.4 + */ + public CharsetDetector() { + } + + /** + * Set the declared encoding for charset detection. + * The declared encoding of an input text is an encoding obtained + * from an http header or xml declaration or similar source that + * can be provided as additional information to the charset detector. + * A match between a declared encoding and a possible detected encoding + * will raise the quality of that detected encoding by a small delta, + * and will also appear as a "reason" for the match. + *

    + * A declared encoding that is incompatible with the input data being + * analyzed will not be added to the list of possible encodings. + * + * @param encoding The declared encoding + * @stable ICU 3.4 + */ + public CharsetDetector setDeclaredEncoding(String encoding) { + fDeclaredEncoding = encoding; + return this; + } + + /** + * Set the input text (byte) data whose charset is to be detected. + * + * @param in the input text of unknown encoding + * @return This CharsetDetector + * @stable ICU 3.4 + */ + public CharsetDetector setText(byte[] in) { + fRawInput = in; + fRawLength = in.length; + + return this; + } + + private static final int kBufSize = 8000; + + /** + * Set the input text (byte) data whose charset is to be detected. + *

    + * The input stream that supplies the character data must have markSupported() + * == true; the charset detection process will read a small amount of data, + * then return the stream to its original position via + * the InputStream.reset() operation. The exact amount that will + * be read depends on the characteristics of the data itself. + * + * @param in the input text of unknown encoding + * @return This CharsetDetector + * @stable ICU 3.4 + */ + + public CharsetDetector setText(InputStream in) throws IOException { + fInputStream = in; + fInputStream.mark(kBufSize); + fRawInput = new byte[kBufSize]; // Always make a new buffer because the + // previous one may have come from the caller, + // in which case we can't touch it. + fRawLength = 0; + int remainingLength = kBufSize; + while (remainingLength > 0) { + // read() may give data in smallish chunks, esp. for remote sources. Hence, this loop. + int bytesRead = fInputStream.read(fRawInput, fRawLength, remainingLength); + if (bytesRead <= 0) { + break; + } + fRawLength += bytesRead; + remainingLength -= bytesRead; + } + fInputStream.reset(); + + return this; + } + + + /** + * Return the charset that best matches the supplied input data. + *

    + * Note though, that because the detection + * only looks at the start of the input data, + * there is a possibility that the returned charset will fail to handle + * the full set of input data. + * p> + * aise an exception if + *

      + *
    • no charset appears to match the data.
    • + *
    • no input text has been provided
    • + *
    + * + * @return a CharsetMatch object representing the best matching charset, or + * null if there are no matches. + * @stable ICU 3.4 + */ + @Nullable + public CharsetMatch detect() { +// TODO: A better implementation would be to copy the detect loop from +// detectAll(), and cut it short as soon as a match with a high confidence +// is found. This is something to be done later, after things are otherwise +// working. + CharsetMatch[] matches = detectAll(); + + if (matches == null || matches.length == 0) { + return null; + } + + return matches[0]; + } + + /** + * Return an array of all charsets that appear to be plausible + * matches with the input data. The array is ordered with the + * best quality match first. + *

    + * aise an exception if + *

      + *
    • no charsets appear to match the input data.
    • + *
    • no input text has been provided
    • + *
    + * + * @return An array of CharsetMatch objects representing possibly matching charsets. + * @stable ICU 3.4 + */ + public CharsetMatch[] detectAll() { + ArrayList matches = new ArrayList<>(); + + MungeInput(); // Strip html markup, collect byte stats. + + // Iterate over all possible charsets, remember all that + // give a match quality > 0. + for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { + CSRecognizerInfo rcinfo = ALL_CS_RECOGNIZERS.get(i); + boolean active = (fEnabledRecognizers != null) ? fEnabledRecognizers[i] : rcinfo.isDefaultEnabled; + if (active) { + CharsetMatch m = rcinfo.recognizer.match(this); + if (m != null) { + matches.add(m); + } + } + } + Collections.sort(matches); // CharsetMatch compares on confidence + Collections.reverse(matches); // Put best match first. + CharsetMatch[] resultArray = new CharsetMatch[matches.size()]; + resultArray = matches.toArray(resultArray); + return resultArray; + } + + + /** + * Autodetect the charset of an inputStream, and return a Java Reader + * to access the converted input data. + *

    + * This is a convenience method that is equivalent to + * this.setDeclaredEncoding(declaredEncoding).setText(in).detect().getReader(); + *

    + * For the input stream that supplies the character data, markSupported() + * must be true; the charset detection will read a small amount of data, + * then return the stream to its original position via + * the InputStream.reset() operation. The exact amount that will + * be read depends on the characteristics of the data itself. + *

    + * Raise an exception if no charsets appear to match the input data. + * + * @param in The source of the byte data in the unknown charset. + * @param declaredEncoding A declared encoding for the data, if available, + * or null or an empty string if none is available. + * @stable ICU 3.4 + */ + public Reader getReader(InputStream in, String declaredEncoding) { + fDeclaredEncoding = declaredEncoding; + + try { + setText(in); + + CharsetMatch match = detect(); + + if (match == null) { + return null; + } + + return match.getReader(); + } catch (IOException e) { + return null; + } + } + + /** + * Autodetect the charset of an inputStream, and return a String + * containing the converted input data. + *

    + * This is a convenience method that is equivalent to + * this.setDeclaredEncoding(declaredEncoding).setText(in).detect().getString(); + *

    + * Raise an exception if no charsets appear to match the input data. + * + * @param in The source of the byte data in the unknown charset. + * @param declaredEncoding A declared encoding for the data, if available, + * or null or an empty string if none is available. + * @stable ICU 3.4 + */ + public String getString(byte[] in, String declaredEncoding) { + fDeclaredEncoding = declaredEncoding; + + try { + setText(in); + + CharsetMatch match = detect(); + + if (match == null) { + return null; + } + + return match.getString(-1); + } catch (IOException e) { + return null; + } + } + + + /** + * Get the names of all charsets supported by CharsetDetector class. + *

    + * Note: Multiple different charset encodings in a same family may use + * a single shared name in this implementation. For example, this method returns + * an array including "ISO-8859-1" (ISO Latin 1), but not including "windows-1252" + * (Windows Latin 1). However, actual detection result could be "windows-1252" + * when the input data matches Latin 1 code points with any points only available + * in "windows-1252". + * + * @return an array of the names of all charsets supported by + * CharsetDetector class. + * @stable ICU 3.4 + */ + public static String[] getAllDetectableCharsets() { + String[] allCharsetNames = new String[ALL_CS_RECOGNIZERS.size()]; + for (int i = 0; i < allCharsetNames.length; i++) { + allCharsetNames[i] = ALL_CS_RECOGNIZERS.get(i).recognizer.getName(); + } + return allCharsetNames; + } + + /** + * Test whether or not input filtering is enabled. + * + * @return true if input text will be filtered. + * @stable ICU 3.4 + * @see #enableInputFilter + */ + public boolean inputFilterEnabled() { + return fStripTags; + } + + /** + * Enable filtering of input text. If filtering is enabled, + * text within angle brackets ("<" and ">") will be removed + * before detection. + * + * @param filter true to enable input text filtering. + * @return The previous setting. + * @stable ICU 3.4 + */ + public boolean enableInputFilter(boolean filter) { + boolean previous = fStripTags; + + fStripTags = filter; + + return previous; + } + + /* + * MungeInput - after getting a set of raw input data to be analyzed, preprocess + * it by removing what appears to be html markup. + */ + private void MungeInput() { + int srci; + int dsti = 0; + byte b; + boolean inMarkup = false; + int openTags = 0; + int badTags = 0; + + // + // html / xml markup stripping. + // quick and dirty, not 100% accurate, but hopefully good enough, statistically. + // discard everything within < brackets > + // Count how many total '<' and illegal (nested) '<' occur, so we can make some + // guess as to whether the input was actually marked up at all. + if (fStripTags) { + for (srci = 0; srci < fRawLength && dsti < fInputBytes.length; srci++) { + b = fRawInput[srci]; + if (b == (byte) '<') { + if (inMarkup) { + badTags++; + } + inMarkup = true; + openTags++; + } + + if (!inMarkup) { + fInputBytes[dsti++] = b; + } + + if (b == (byte) '>') { + inMarkup = false; + } + } + + fInputLen = dsti; + } + + // + // If it looks like this input wasn't marked up, or if it looks like it's + // essentially nothing but markup abandon the markup stripping. + // Detection will have to work on the unstripped input. + // + if (openTags < 5 || openTags / 5 < badTags || + (fInputLen < 100 && fRawLength > 600)) { + int limit = fRawLength; + + if (limit > kBufSize) { + limit = kBufSize; + } + + for (srci = 0; srci < limit; srci++) { + fInputBytes[srci] = fRawInput[srci]; + } + fInputLen = srci; + } + + // + // Tally up the byte occurence statistics. + // These are available for use by the various detectors. + // + Arrays.fill(fByteStats, (short) 0); + for (srci = 0; srci < fInputLen; srci++) { + int val = fInputBytes[srci] & 0x00ff; + fByteStats[val]++; + } + + fC1Bytes = false; + for (int i = 0x80; i <= 0x9F; i += 1) { + if (fByteStats[i] != 0) { + fC1Bytes = true; + break; + } + } + } + + /* + * The following items are accessed by individual CharsetRecongizers during + * the recognition process + * + */ + byte[] fInputBytes = // The text to be checked. Markup will have been + new byte[kBufSize]; // removed if appropriate. + + int fInputLen; // Length of the byte data in fInputBytes. + + short[] fByteStats = // byte frequency statistics for the input text. + new short[256]; // Value is percent, not absolute. + // Value is rounded up, so zero really means zero occurences. + + boolean fC1Bytes = // True if any bytes in the range 0x80 - 0x9F are in the input; + false; + + String fDeclaredEncoding; + + + byte[] fRawInput; // Original, untouched input bytes. + // If user gave us a byte array, this is it. + // If user gave us a stream, it's read to a + // buffer here. + int fRawLength; // Length of data in fRawInput array. + + InputStream fInputStream; // User's input stream, or null if the user + // gave us a byte array. + + // + // Stuff private to CharsetDetector + // + private boolean fStripTags = // If true, setText() will strip tags from input text. + false; + + private boolean[] fEnabledRecognizers; // If not null, active set of charset recognizers had + // been changed from the default. The array index is + // corresponding to ALL_RECOGNIZER. See setDetectableCharset(). + + private static class CSRecognizerInfo { + CharsetRecognizer recognizer; + boolean isDefaultEnabled; + + CSRecognizerInfo(CharsetRecognizer recognizer, boolean isDefaultEnabled) { + this.recognizer = recognizer; + this.isDefaultEnabled = isDefaultEnabled; + } + } + + /* + * List of recognizers for all charsets known to the implementation. + */ + private static final List ALL_CS_RECOGNIZERS; + + static { + List list = new ArrayList<>(); + + list.add(new CSRecognizerInfo(new CharsetRecog_UTF8(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_16_BE(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_16_LE(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_32_BE(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_32_LE(), true)); + + list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_sjis(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022JP(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022CN(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022KR(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_gb_18030(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_euc_jp(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_euc_kr(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_big5(), true)); + + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_1(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_2(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_5_ru(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_6_ar(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_7_el(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_8_I_he(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_8_he(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_windows_1251(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_windows_1256(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_KOI8_R(), true)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_9_tr(), true)); + + // IBM 420/424 recognizers are disabled by default + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM424_he_rtl(), false)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM424_he_ltr(), false)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM420_ar_rtl(), false)); + list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM420_ar_ltr(), false)); + + //noinspection Java9CollectionFactory + ALL_CS_RECOGNIZERS = Collections.unmodifiableList(list); + } + + /** + * Get the names of charsets that can be recognized by this CharsetDetector instance. + * + * @return an array of the names of charsets that can be recognized by this CharsetDetector + * instance. + * @internal + * @deprecated This API is ICU internal only. + */ + @Deprecated + public String[] getDetectableCharsets() { + List csnames = new ArrayList<>(ALL_CS_RECOGNIZERS.size()); + for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { + CSRecognizerInfo rcinfo = ALL_CS_RECOGNIZERS.get(i); + boolean active = (fEnabledRecognizers == null) ? rcinfo.isDefaultEnabled : fEnabledRecognizers[i]; + if (active) { + csnames.add(rcinfo.recognizer.getName()); + } + } + return csnames.toArray(new String[0]); + } + + /** + * Enable or disable individual charset encoding. + * A name of charset encoding must be included in the names returned by + * {@link #getAllDetectableCharsets()}. + * + * @param encoding the name of charset encoding. + * @param enabled true to enable, or false to disable the + * charset encoding. + * @return A reference to this CharsetDetector. + * @throws IllegalArgumentException when the name of charset encoding is + * not supported. + * @internal + * @deprecated This API is ICU internal only. + */ + @Deprecated + public CharsetDetector setDetectableCharset(String encoding, boolean enabled) { + int modIdx = -1; + boolean isDefaultVal = false; + for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { + CSRecognizerInfo csrinfo = ALL_CS_RECOGNIZERS.get(i); + if (csrinfo.recognizer.getName().equals(encoding)) { + modIdx = i; + isDefaultVal = (csrinfo.isDefaultEnabled == enabled); + break; + } + } + if (modIdx < 0) { + // No matching encoding found + throw new IllegalArgumentException("Invalid encoding: " + "\"" + encoding + "\""); + } + + if (fEnabledRecognizers == null && !isDefaultVal) { + // Create an array storing the non default setting + fEnabledRecognizers = new boolean[ALL_CS_RECOGNIZERS.size()]; + + // Initialize the array with default info + for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) { + fEnabledRecognizers[i] = ALL_CS_RECOGNIZERS.get(i).isDefaultEnabled; + } + } + + if (fEnabledRecognizers != null) { + fEnabledRecognizers[modIdx] = enabled; + } + + return this; + } +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetMatch.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetMatch.java new file mode 100644 index 000000000..04bbe6222 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetMatch.java @@ -0,0 +1,241 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* + * ****************************************************************************** + * Copyright (C) 2005-2016, International Business Machines Corporation and * + * others. All Rights Reserved. * + * ****************************************************************************** + */ +package io.legado.app.lib.icu4j; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; + + +/** + * This class represents a charset that has been identified by a CharsetDetector + * as a possible encoding for a set of input data. From an instance of this + * class, you can ask for a confidence level in the charset identification, + * or for Java Reader or String to access the original byte data in Unicode form. + *

    + * Instances of this class are created only by CharsetDetectors. + *

    + * Note: this class has a natural ordering that is inconsistent with equals. + * The natural ordering is based on the match confidence value. + * + * @stable ICU 3.4 + */ +@SuppressWarnings({"JavaDoc", "unused"}) +public class CharsetMatch implements Comparable { + + + /** + * Create a java.io.Reader for reading the Unicode character data corresponding + * to the original byte data supplied to the Charset detect operation. + *

    + * CAUTION: if the source of the byte data was an InputStream, a Reader + * can be created for only one matching char set using this method. If more + * than one charset needs to be tried, the caller will need to reset + * the InputStream and create InputStreamReaders itself, based on the charset name. + * + * @return the Reader for the Unicode character data. + * @stable ICU 3.4 + */ + public Reader getReader() { + InputStream inputStream = fInputStream; + + if (inputStream == null) { + inputStream = new ByteArrayInputStream(fRawInput, 0, fRawLength); + } + + try { + inputStream.reset(); + return new InputStreamReader(inputStream, getName()); + } catch (IOException e) { + return null; + } + } + + /** + * Create a Java String from Unicode character data corresponding + * to the original byte data supplied to the Charset detect operation. + * + * @return a String created from the converted input data. + * @stable ICU 3.4 + */ + public String getString() throws java.io.IOException { + return getString(-1); + + } + + /** + * Create a Java String from Unicode character data corresponding + * to the original byte data supplied to the Charset detect operation. + * The length of the returned string is limited to the specified size; + * the string will be trunctated to this length if necessary. A limit value of + * zero or less is ignored, and treated as no limit. + * + * @param maxLength The maximium length of the String to be created when the + * source of the data is an input stream, or -1 for + * unlimited length. + * @return a String created from the converted input data. + * @stable ICU 3.4 + */ + public String getString(int maxLength) throws java.io.IOException { + String result; + if (fInputStream != null) { + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[1024]; + Reader reader = getReader(); + int max = maxLength < 0 ? Integer.MAX_VALUE : maxLength; + int bytesRead; + + while ((bytesRead = reader.read(buffer, 0, Math.min(max, 1024))) >= 0) { + sb.append(buffer, 0, bytesRead); + max -= bytesRead; + } + + reader.close(); + + return sb.toString(); + } else { + String name = getName(); + /* + * getName() may return a name with a suffix 'rtl' or 'ltr'. This cannot + * be used to open a charset (e.g. IBM424_rtl). The ending '_rtl' or 'ltr' + * should be stripped off before creating the string. + */ + int startSuffix = !name.contains("_rtl") ? name.indexOf("_ltr") : name.indexOf("_rtl"); + if (startSuffix > 0) { + name = name.substring(0, startSuffix); + } + result = new String(fRawInput, name); + } + return result; + + } + + /** + * Get an indication of the confidence in the charset detected. + * Confidence values range from 0-100, with larger numbers indicating + * a better match of the input data to the characteristics of the + * charset. + * + * @return the confidence in the charset match + * @stable ICU 3.4 + */ + public int getConfidence() { + return fConfidence; + } + + /** + * Get the name of the detected charset. + * The name will be one that can be used with other APIs on the + * platform that accept charset names. It is the "Canonical name" + * as defined by the class java.nio.charset.Charset; for + * charsets that are registered with the IANA charset registry, + * this is the MIME-preferred registerd name. + * + * @return The name of the charset. + * @stable ICU 3.4 + * @see java.nio.charset.Charset + * @see java.io.InputStreamReader + */ + public String getName() { + return fCharsetName; + } + + /** + * Get the ISO code for the language of the detected charset. + * + * @return The ISO code for the language or null if the language cannot be determined. + * @stable ICU 3.4 + */ + public String getLanguage() { + return fLang; + } + + /** + * Compare to other CharsetMatch objects. + * Comparison is based on the match confidence value, which + * allows CharsetDetector.detectAll() to order its results. + * + * @param other the CharsetMatch object to compare against. + * @return a negative integer, zero, or a positive integer as the + * confidence level of this CharsetMatch + * is less than, equal to, or greater than that of + * the argument. + * @throws ClassCastException if the argument is not a CharsetMatch. + * @stable ICU 4.4 + */ + @Override + public int compareTo(CharsetMatch other) { + int compareResult = 0; + if (this.fConfidence > other.fConfidence) { + compareResult = 1; + } else if (this.fConfidence < other.fConfidence) { + compareResult = -1; + } + return compareResult; + } + + /* + * Constructor. Implementation internal + */ + CharsetMatch(CharsetDetector det, CharsetRecognizer rec, int conf) { + fConfidence = conf; + + // The references to the original application input data must be copied out + // of the charset recognizer to here, in case the application resets the + // recognizer before using this CharsetMatch. + if (det.fInputStream == null) { + // We only want the existing input byte data if it came straight from the user, + // not if is just the head of a stream. + fRawInput = det.fRawInput; + fRawLength = det.fRawLength; + } + fInputStream = det.fInputStream; + fCharsetName = rec.getName(); + fLang = rec.getLanguage(); + } + + /* + * Constructor. Implementation internal + */ + CharsetMatch(CharsetDetector det, CharsetRecognizer rec, int conf, String csName, String lang) { + fConfidence = conf; + + // The references to the original application input data must be copied out + // of the charset recognizer to here, in case the application resets the + // recognizer before using this CharsetMatch. + if (det.fInputStream == null) { + // We only want the existing input byte data if it came straight from the user, + // not if is just the head of a stream. + fRawInput = det.fRawInput; + fRawLength = det.fRawLength; + } + fInputStream = det.fInputStream; + fCharsetName = csName; + fLang = lang; + } + + + // + // Private Data + // + private final int fConfidence; + private byte[] fRawInput = null; // Original, untouched input bytes. + // If user gave us a byte array, this is it. + private int fRawLength; // Length of data in fRawInput array. + + private final InputStream fInputStream; // User's input stream, or null if the user + // gave us a byte array. + + private final String fCharsetName; // The name of the charset this CharsetMatch + // represents. Filled in by the recognizer. + private final String fLang; // The language, if one was determined by + // the recognizer during the detect operation. +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_2022.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_2022.java new file mode 100644 index 000000000..40cf53ea0 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_2022.java @@ -0,0 +1,170 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* + ******************************************************************************* + * Copyright (C) 2005 - 2012, International Business Machines Corporation and * + * others. All Rights Reserved. * + ******************************************************************************* + */ +package io.legado.app.lib.icu4j; + +/** + * class CharsetRecog_2022 part of the ICU charset detection imlementation. + * This is a superclass for the individual detectors for + * each of the detectable members of the ISO 2022 family + * of encodings. + *

    + * The separate classes are nested within this class. + */ +abstract class CharsetRecog_2022 extends CharsetRecognizer { + + + /** + * Matching function shared among the 2022 detectors JP, CN and KR + * Counts up the number of legal an unrecognized escape sequences in + * the sample of text, and computes a score based on the total number & + * the proportion that fit the encoding. + * + * @param text the byte buffer containing text to analyse + * @param textLen the size of the text in the byte. + * @param escapeSequences the byte escape sequences to test for. + * @return match quality, in the range of 0-100. + */ + int match(byte[] text, int textLen, byte[][] escapeSequences) { + int i, j; + int escN; + int hits = 0; + int misses = 0; + int shifts = 0; + int quality; + scanInput: + for (i = 0; i < textLen; i++) { + if (text[i] == 0x1b) { + checkEscapes: + for (escN = 0; escN < escapeSequences.length; escN++) { + byte[] seq = escapeSequences[escN]; + + if ((textLen - i) < seq.length) { + continue; + } + + for (j = 1; j < seq.length; j++) { + if (seq[j] != text[i + j]) { + continue checkEscapes; + } + } + + hits++; + i += seq.length - 1; + continue scanInput; + } + + misses++; + } + + if (text[i] == 0x0e || text[i] == 0x0f) { + // Shift in/out + shifts++; + } + } + + if (hits == 0) { + return 0; + } + + // + // Initial quality is based on relative proportion of recongized vs. + // unrecognized escape sequences. + // All good: quality = 100; + // half or less good: quality = 0; + // linear inbetween. + quality = (100 * hits - 100 * misses) / (hits + misses); + + // Back off quality if there were too few escape sequences seen. + // Include shifts in this computation, so that KR does not get penalized + // for having only a single Escape sequence, but many shifts. + if (hits + shifts < 5) { + quality -= (5 - (hits + shifts)) * 10; + } + + if (quality < 0) { + quality = 0; + } + return quality; + } + + + static class CharsetRecog_2022JP extends CharsetRecog_2022 { + private final byte[][] escapeSequences = { + {0x1b, 0x24, 0x28, 0x43}, // KS X 1001:1992 + {0x1b, 0x24, 0x28, 0x44}, // JIS X 212-1990 + {0x1b, 0x24, 0x40}, // JIS C 6226-1978 + {0x1b, 0x24, 0x41}, // GB 2312-80 + {0x1b, 0x24, 0x42}, // JIS X 208-1983 + {0x1b, 0x26, 0x40}, // JIS X 208 1990, 1997 + {0x1b, 0x28, 0x42}, // ASCII + {0x1b, 0x28, 0x48}, // JIS-Roman + {0x1b, 0x28, 0x49}, // Half-width katakana + {0x1b, 0x28, 0x4a}, // JIS-Roman + {0x1b, 0x2e, 0x41}, // ISO 8859-1 + {0x1b, 0x2e, 0x46} // ISO 8859-7 + }; + + @Override + String getName() { + return "ISO-2022-JP"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + static class CharsetRecog_2022KR extends CharsetRecog_2022 { + private final byte[][] escapeSequences = { + {0x1b, 0x24, 0x29, 0x43} + }; + + @Override + String getName() { + return "ISO-2022-KR"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + static class CharsetRecog_2022CN extends CharsetRecog_2022 { + private final byte[][] escapeSequences = { + {0x1b, 0x24, 0x29, 0x41}, // GB 2312-80 + {0x1b, 0x24, 0x29, 0x47}, // CNS 11643-1992 Plane 1 + {0x1b, 0x24, 0x2A, 0x48}, // CNS 11643-1992 Plane 2 + {0x1b, 0x24, 0x29, 0x45}, // ISO-IR-165 + {0x1b, 0x24, 0x2B, 0x49}, // CNS 11643-1992 Plane 3 + {0x1b, 0x24, 0x2B, 0x4A}, // CNS 11643-1992 Plane 4 + {0x1b, 0x24, 0x2B, 0x4B}, // CNS 11643-1992 Plane 5 + {0x1b, 0x24, 0x2B, 0x4C}, // CNS 11643-1992 Plane 6 + {0x1b, 0x24, 0x2B, 0x4D}, // CNS 11643-1992 Plane 7 + {0x1b, 0x4e}, // SS2 + {0x1b, 0x4f}, // SS3 + }; + + @Override + String getName() { + return "ISO-2022-CN"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + +} + diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_UTF8.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_UTF8.java new file mode 100644 index 000000000..60ff6d746 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_UTF8.java @@ -0,0 +1,99 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/** + * ****************************************************************************** + * Copyright (C) 2005 - 2014, International Business Machines Corporation and * + * others. All Rights Reserved. * + * ****************************************************************************** + */ +package io.legado.app.lib.icu4j; + +/** + * Charset recognizer for UTF-8 + */ +class CharsetRecog_UTF8 extends CharsetRecognizer { + + @Override + String getName() { + return "UTF-8"; + } + + /* (non-Javadoc) + * @see com.ibm.icu.text.CharsetRecognizer#match(com.ibm.icu.text.CharsetDetector) + */ + @Override + CharsetMatch match(CharsetDetector det) { + boolean hasBOM = false; + int numValid = 0; + int numInvalid = 0; + byte[] input = det.fRawInput; + int i; + int trailBytes = 0; + int confidence; + + if (det.fRawLength >= 3 && + (input[0] & 0xFF) == 0xef && (input[1] & 0xFF) == 0xbb && (input[2] & 0xFF) == 0xbf) { + hasBOM = true; + } + + // Scan for multi-byte sequences + for (i = 0; i < det.fRawLength; i++) { + int b = input[i]; + if ((b & 0x80) == 0) { + continue; // ASCII + } + + // Hi bit on char found. Figure out how long the sequence should be + if ((b & 0x0e0) == 0x0c0) { + trailBytes = 1; + } else if ((b & 0x0f0) == 0x0e0) { + trailBytes = 2; + } else if ((b & 0x0f8) == 0xf0) { + trailBytes = 3; + } else { + numInvalid++; + continue; + } + + // Verify that we've got the right number of trail bytes in the sequence + for (; ; ) { + i++; + if (i >= det.fRawLength) { + break; + } + b = input[i]; + if ((b & 0xc0) != 0x080) { + numInvalid++; + break; + } + if (--trailBytes == 0) { + numValid++; + break; + } + } + } + + // Cook up some sort of confidence score, based on presense of a BOM + // and the existence of valid and/or invalid multi-byte sequences. + confidence = 0; + if (hasBOM && numInvalid == 0) { + confidence = 100; + } else if (hasBOM && numValid > numInvalid * 10) { + confidence = 80; + } else if (numValid > 3 && numInvalid == 0) { + confidence = 100; + } else if (numValid > 0 && numInvalid == 0) { + confidence = 80; + } else if (numValid == 0 && numInvalid == 0) { + // Plain ASCII. Confidence must be > 10, it's more likely than UTF-16, which + // accepts ASCII with confidence = 10. + // TODO: add plain ASCII as an explicitly detected type. + confidence = 15; + } else if (numValid > numInvalid * 10) { + // Probably corruput utf-8 data. Valid sequences aren't likely by chance. + confidence = 25; + } + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_Unicode.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_Unicode.java new file mode 100644 index 000000000..82380e654 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_Unicode.java @@ -0,0 +1,198 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* + ******************************************************************************* + * Copyright (C) 1996-2013, International Business Machines Corporation and * + * others. All Rights Reserved. * + ******************************************************************************* + * + */ + +package io.legado.app.lib.icu4j; + +/** + * This class matches UTF-16 and UTF-32, both big- and little-endian. The + * BOM will be used if it is present. + */ +abstract class CharsetRecog_Unicode extends CharsetRecognizer { + + /* (non-Javadoc) + * @see com.ibm.icu.text.CharsetRecognizer#getName() + */ + @Override + abstract String getName(); + + /* (non-Javadoc) + * @see com.ibm.icu.text.CharsetRecognizer#match(com.ibm.icu.text.CharsetDetector) + */ + @Override + abstract CharsetMatch match(CharsetDetector det); + + static int codeUnit16FromBytes(byte hi, byte lo) { + return ((hi & 0xff) << 8) | (lo & 0xff); + } + + // UTF-16 confidence calculation. Very simple minded, but better than nothing. + // Any 8 bit non-control characters bump the confidence up. These have a zero high byte, + // and are very likely to be UTF-16, although they could also be part of a UTF-32 code. + // NULs are a contra-indication, they will appear commonly if the actual encoding is UTF-32. + // NULs should be rare in actual text. + static int adjustConfidence(int codeUnit, int confidence) { + if (codeUnit == 0) { + confidence -= 10; + } else if ((codeUnit >= 0x20 && codeUnit <= 0xff) || codeUnit == 0x0a) { + confidence += 10; + } + if (confidence < 0) { + confidence = 0; + } else if (confidence > 100) { + confidence = 100; + } + return confidence; + } + + static class CharsetRecog_UTF_16_BE extends CharsetRecog_Unicode { + @Override + String getName() { + return "UTF-16BE"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + byte[] input = det.fRawInput; + int confidence = 10; + + int bytesToCheck = Math.min(input.length, 30); + for (int charIndex = 0; charIndex < bytesToCheck - 1; charIndex += 2) { + int codeUnit = codeUnit16FromBytes(input[charIndex], input[charIndex + 1]); + if (charIndex == 0 && codeUnit == 0xFEFF) { + confidence = 100; + break; + } + confidence = adjustConfidence(codeUnit, confidence); + if (confidence == 0 || confidence == 100) { + break; + } + } + if (bytesToCheck < 4 && confidence < 100) { + confidence = 0; + } + if (confidence > 0) { + return new CharsetMatch(det, this, confidence); + } + return null; + } + } + + static class CharsetRecog_UTF_16_LE extends CharsetRecog_Unicode { + @Override + String getName() { + return "UTF-16LE"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + byte[] input = det.fRawInput; + int confidence = 10; + + int bytesToCheck = Math.min(input.length, 30); + for (int charIndex = 0; charIndex < bytesToCheck - 1; charIndex += 2) { + int codeUnit = codeUnit16FromBytes(input[charIndex + 1], input[charIndex]); + if (charIndex == 0 && codeUnit == 0xFEFF) { + confidence = 100; + break; + } + confidence = adjustConfidence(codeUnit, confidence); + if (confidence == 0 || confidence == 100) { + break; + } + } + if (bytesToCheck < 4 && confidence < 100) { + confidence = 0; + } + if (confidence > 0) { + return new CharsetMatch(det, this, confidence); + } + return null; + } + } + + static abstract class CharsetRecog_UTF_32 extends CharsetRecog_Unicode { + abstract int getChar(byte[] input, int index); + + @Override + abstract String getName(); + + @Override + CharsetMatch match(CharsetDetector det) { + byte[] input = det.fRawInput; + int limit = (det.fRawLength / 4) * 4; + int numValid = 0; + int numInvalid = 0; + boolean hasBOM = false; + int confidence = 0; + + if (limit == 0) { + return null; + } + if (getChar(input, 0) == 0x0000FEFF) { + hasBOM = true; + } + + for (int i = 0; i < limit; i += 4) { + int ch = getChar(input, i); + + if (ch < 0 || ch >= 0x10FFFF || (ch >= 0xD800 && ch <= 0xDFFF)) { + numInvalid += 1; + } else { + numValid += 1; + } + } + + + // Cook up some sort of confidence score, based on presence of a BOM + // and the existence of valid and/or invalid multi-byte sequences. + if (hasBOM && numInvalid == 0) { + confidence = 100; + } else if (hasBOM && numValid > numInvalid * 10) { + confidence = 80; + } else if (numValid > 3 && numInvalid == 0) { + confidence = 100; + } else if (numValid > 0 && numInvalid == 0) { + confidence = 80; + } else if (numValid > numInvalid * 10) { + // Probably corrupt UTF-32BE data. Valid sequences aren't likely by chance. + confidence = 25; + } + + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + static class CharsetRecog_UTF_32_BE extends CharsetRecog_UTF_32 { + @Override + int getChar(byte[] input, int index) { + return (input[index + 0] & 0xFF) << 24 | (input[index + 1] & 0xFF) << 16 | + (input[index + 2] & 0xFF) << 8 | (input[index + 3] & 0xFF); + } + + @Override + String getName() { + return "UTF-32BE"; + } + } + + + static class CharsetRecog_UTF_32_LE extends CharsetRecog_UTF_32 { + @Override + int getChar(byte[] input, int index) { + return (input[index + 3] & 0xFF) << 24 | (input[index + 2] & 0xFF) << 16 | + (input[index + 1] & 0xFF) << 8 | (input[index + 0] & 0xFF); + } + + @Override + String getName() { + return "UTF-32LE"; + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_mbcs.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_mbcs.java new file mode 100644 index 000000000..0e211a7ab --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_mbcs.java @@ -0,0 +1,552 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* + **************************************************************************** + * Copyright (C) 2005-2012, International Business Machines Corporation and * + * others. All Rights Reserved. * + **************************************************************************** + * + */ +package io.legado.app.lib.icu4j; + +import java.util.Arrays; + +/** + * CharsetRecognizer implemenation for Asian - double or multi-byte - charsets. + * Match is determined mostly by the input data adhering to the + * encoding scheme for the charset, and, optionally, + * frequency-of-occurence of characters. + *

    + * Instances of this class are singletons, one per encoding + * being recognized. They are created in the main + * CharsetDetector class and kept in the global list of available + * encodings to be checked. The specific encoding being recognized + * is determined by subclass. + */ +abstract class CharsetRecog_mbcs extends CharsetRecognizer { + + /** + * Get the IANA name of this charset. + * + * @return the charset name. + */ + @Override + abstract String getName(); + + + /** + * Test the match of this charset with the input text data + * which is obtained via the CharsetDetector object. + * + * @param det The CharsetDetector, which contains the input text + * to be checked for being in this charset. + * @return Two values packed into one int (Damn java, anyhow) + *
    + * bits 0-7: the match confidence, ranging from 0-100 + *
    + * bits 8-15: The match reason, an enum-like value. + */ + int match(CharsetDetector det, int[] commonChars) { + @SuppressWarnings("unused") + int singleByteCharCount = 0; //TODO Do we really need this? + int doubleByteCharCount = 0; + int commonCharCount = 0; + int badCharCount = 0; + int totalCharCount = 0; + int confidence = 0; + iteratedChar iter = new iteratedChar(); + + detectBlock: + { + for (iter.reset(); nextChar(iter, det); ) { + totalCharCount++; + if (iter.error) { + badCharCount++; + } else { + long cv = iter.charValue & 0xFFFFFFFFL; + + if (cv <= 0xff) { + singleByteCharCount++; + } else { + doubleByteCharCount++; + if (commonChars != null) { + // NOTE: This assumes that there are no 4-byte common chars. + if (Arrays.binarySearch(commonChars, (int) cv) >= 0) { + commonCharCount++; + } + } + } + } + if (badCharCount >= 2 && badCharCount * 5 >= doubleByteCharCount) { + // Bail out early if the byte data is not matching the encoding scheme. + break detectBlock; + } + } + + if (doubleByteCharCount <= 10 && badCharCount == 0) { + // Not many multi-byte chars. + if (doubleByteCharCount == 0 && totalCharCount < 10) { + // There weren't any multibyte sequences, and there was a low density of non-ASCII single bytes. + // We don't have enough data to have any confidence. + // Statistical analysis of single byte non-ASCII charcters would probably help here. + confidence = 0; + } else { + // ASCII or ISO file? It's probably not our encoding, + // but is not incompatible with our encoding, so don't give it a zero. + confidence = 10; + } + + break detectBlock; + } + + // + // No match if there are too many characters that don't fit the encoding scheme. + // (should we have zero tolerance for these?) + // + if (doubleByteCharCount < 20 * badCharCount) { + confidence = 0; + break detectBlock; + } + + if (commonChars == null) { + // We have no statistics on frequently occuring characters. + // Assess confidence purely on having a reasonable number of + // multi-byte characters (the more the better + confidence = 30 + doubleByteCharCount - 20 * badCharCount; + if (confidence > 100) { + confidence = 100; + } + } else { + // + // Frequency of occurence statistics exist. + // + double maxVal = Math.log((float) doubleByteCharCount / 4); + double scaleFactor = 90.0 / maxVal; + confidence = (int) (Math.log(commonCharCount + 1) * scaleFactor + 10); + confidence = Math.min(confidence, 100); + } + } // end of detectBlock: + + return confidence; + } + + // "Character" iterated character class. + // Recognizers for specific mbcs encodings make their "characters" available + // by providing a nextChar() function that fills in an instance of iteratedChar + // with the next char from the input. + // The returned characters are not converted to Unicode, but remain as the raw + // bytes (concatenated into an int) from the codepage data. + // + // For Asian charsets, use the raw input rather than the input that has been + // stripped of markup. Detection only considers multi-byte chars, effectively + // stripping markup anyway, and double byte chars do occur in markup too. + // + static class iteratedChar { + int charValue = 0; // 1-4 bytes from the raw input data + int nextIndex = 0; + boolean error = false; + boolean done = false; + + void reset() { + charValue = 0; + nextIndex = 0; + error = false; + done = false; + } + + int nextByte(CharsetDetector det) { + if (nextIndex >= det.fRawLength) { + done = true; + return -1; + } + return det.fRawInput[nextIndex++] & 0x00ff; + } + } + + /** + * Get the next character (however many bytes it is) from the input data + * Subclasses for specific charset encodings must implement this function + * to get characters according to the rules of their encoding scheme. + *

    + * This function is not a method of class iteratedChar only because + * that would require a lot of extra derived classes, which is awkward. + * + * @param it The iteratedChar "struct" into which the returned char is placed. + * @param det The charset detector, which is needed to get at the input byte data + * being iterated over. + * @return True if a character was returned, false at end of input. + */ + abstract boolean nextChar(iteratedChar it, CharsetDetector det); + + + /** + * Shift-JIS charset recognizer. + */ + static class CharsetRecog_sjis extends CharsetRecog_mbcs { + static int[] commonChars = + // TODO: This set of data comes from the character frequency- + // of-occurence analysis tool. The data needs to be moved + // into a resource and loaded from there. + {0x8140, 0x8141, 0x8142, 0x8145, 0x815b, 0x8169, 0x816a, 0x8175, 0x8176, 0x82a0, + 0x82a2, 0x82a4, 0x82a9, 0x82aa, 0x82ab, 0x82ad, 0x82af, 0x82b1, 0x82b3, 0x82b5, + 0x82b7, 0x82bd, 0x82be, 0x82c1, 0x82c4, 0x82c5, 0x82c6, 0x82c8, 0x82c9, 0x82cc, + 0x82cd, 0x82dc, 0x82e0, 0x82e7, 0x82e8, 0x82e9, 0x82ea, 0x82f0, 0x82f1, 0x8341, + 0x8343, 0x834e, 0x834f, 0x8358, 0x835e, 0x8362, 0x8367, 0x8375, 0x8376, 0x8389, + 0x838a, 0x838b, 0x838d, 0x8393, 0x8e96, 0x93fa, 0x95aa}; + + @Override + boolean nextChar(iteratedChar it, CharsetDetector det) { + it.error = false; + int firstByte; + firstByte = it.charValue = it.nextByte(det); + if (firstByte < 0) { + return false; + } + + if (firstByte <= 0x7f || (firstByte > 0xa0 && firstByte <= 0xdf)) { + return true; + } + + int secondByte = it.nextByte(det); + if (secondByte < 0) { + return false; + } + it.charValue = (firstByte << 8) | secondByte; + if (!((secondByte >= 0x40 && secondByte <= 0x7f) || (secondByte >= 0x80 && secondByte <= 0xff))) { + // Illegal second byte value. + it.error = true; + } + return true; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det, commonChars); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + @Override + String getName() { + return "Shift_JIS"; + } + + @Override + public String getLanguage() { + return "ja"; + } + + + } + + + /** + * Big5 charset recognizer. + */ + static class CharsetRecog_big5 extends CharsetRecog_mbcs { + static int[] commonChars = + // TODO: This set of data comes from the character frequency- + // of-occurence analysis tool. The data needs to be moved + // into a resource and loaded from there. + {0xa140, 0xa141, 0xa142, 0xa143, 0xa147, 0xa149, 0xa175, 0xa176, 0xa440, 0xa446, + 0xa447, 0xa448, 0xa451, 0xa454, 0xa457, 0xa464, 0xa46a, 0xa46c, 0xa477, 0xa4a3, + 0xa4a4, 0xa4a7, 0xa4c1, 0xa4ce, 0xa4d1, 0xa4df, 0xa4e8, 0xa4fd, 0xa540, 0xa548, + 0xa558, 0xa569, 0xa5cd, 0xa5e7, 0xa657, 0xa661, 0xa662, 0xa668, 0xa670, 0xa6a8, + 0xa6b3, 0xa6b9, 0xa6d3, 0xa6db, 0xa6e6, 0xa6f2, 0xa740, 0xa751, 0xa759, 0xa7da, + 0xa8a3, 0xa8a5, 0xa8ad, 0xa8d1, 0xa8d3, 0xa8e4, 0xa8fc, 0xa9c0, 0xa9d2, 0xa9f3, + 0xaa6b, 0xaaba, 0xaabe, 0xaacc, 0xaafc, 0xac47, 0xac4f, 0xacb0, 0xacd2, 0xad59, + 0xaec9, 0xafe0, 0xb0ea, 0xb16f, 0xb2b3, 0xb2c4, 0xb36f, 0xb44c, 0xb44e, 0xb54c, + 0xb5a5, 0xb5bd, 0xb5d0, 0xb5d8, 0xb671, 0xb7ed, 0xb867, 0xb944, 0xbad8, 0xbb44, + 0xbba1, 0xbdd1, 0xc2c4, 0xc3b9, 0xc440, 0xc45f}; + + @Override + boolean nextChar(iteratedChar it, CharsetDetector det) { + it.error = false; + int firstByte; + firstByte = it.charValue = it.nextByte(det); + if (firstByte < 0) { + return false; + } + + if (firstByte <= 0x7f || firstByte == 0xff) { + // single byte character. + return true; + } + + int secondByte = it.nextByte(det); + if (secondByte < 0) { + return false; + } + it.charValue = (it.charValue << 8) | secondByte; + + if (secondByte < 0x40 || + secondByte == 0x7f || + secondByte == 0xff) { + it.error = true; + } + return true; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det, commonChars); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + @Override + String getName() { + return "Big5"; + } + + + @Override + public String getLanguage() { + return "zh"; + } + } + + + /** + * EUC charset recognizers. One abstract class that provides the common function + * for getting the next character according to the EUC encoding scheme, + * and nested derived classes for EUC_KR, EUC_JP, EUC_CN. + */ + abstract static class CharsetRecog_euc extends CharsetRecog_mbcs { + + /* + * (non-Javadoc) + * Get the next character value for EUC based encodings. + * Character "value" is simply the raw bytes that make up the character + * packed into an int. + */ + @Override + boolean nextChar(iteratedChar it, CharsetDetector det) { + it.error = false; + int firstByte; + int secondByte; + int thirdByte; + //int fourthByte = 0; + + buildChar: + { + firstByte = it.charValue = it.nextByte(det); + if (firstByte < 0) { + // Ran off the end of the input data + it.done = true; + break buildChar; + } + if (firstByte <= 0x8d) { + // single byte char + break buildChar; + } + + secondByte = it.nextByte(det); + it.charValue = (it.charValue << 8) | secondByte; + + if (firstByte >= 0xA1 && firstByte <= 0xfe) { + // Two byte Char + if (secondByte < 0xa1) { + it.error = true; + } + break buildChar; + } + if (firstByte == 0x8e) { + // Code Set 2. + // In EUC-JP, total char size is 2 bytes, only one byte of actual char value. + // In EUC-TW, total char size is 4 bytes, three bytes contribute to char value. + // We don't know which we've got. + // Treat it like EUC-JP. If the data really was EUC-TW, the following two + // bytes will look like a well formed 2 byte char. + if (secondByte < 0xa1) { + it.error = true; + } + break buildChar; + } + + if (firstByte == 0x8f) { + // Code set 3. + // Three byte total char size, two bytes of actual char value. + thirdByte = it.nextByte(det); + it.charValue = (it.charValue << 8) | thirdByte; + if (thirdByte < 0xa1) { + it.error = true; + } + } + } + + return (!it.done); + } + + /** + * The charset recognize for EUC-JP. A singleton instance of this class + * is created and kept by the public CharsetDetector class + */ + static class CharsetRecog_euc_jp extends CharsetRecog_euc { + static int[] commonChars = + // TODO: This set of data comes from the character frequency- + // of-occurence analysis tool. The data needs to be moved + // into a resource and loaded from there. + {0xa1a1, 0xa1a2, 0xa1a3, 0xa1a6, 0xa1bc, 0xa1ca, 0xa1cb, 0xa1d6, 0xa1d7, 0xa4a2, + 0xa4a4, 0xa4a6, 0xa4a8, 0xa4aa, 0xa4ab, 0xa4ac, 0xa4ad, 0xa4af, 0xa4b1, 0xa4b3, + 0xa4b5, 0xa4b7, 0xa4b9, 0xa4bb, 0xa4bd, 0xa4bf, 0xa4c0, 0xa4c1, 0xa4c3, 0xa4c4, + 0xa4c6, 0xa4c7, 0xa4c8, 0xa4c9, 0xa4ca, 0xa4cb, 0xa4ce, 0xa4cf, 0xa4d0, 0xa4de, + 0xa4df, 0xa4e1, 0xa4e2, 0xa4e4, 0xa4e8, 0xa4e9, 0xa4ea, 0xa4eb, 0xa4ec, 0xa4ef, + 0xa4f2, 0xa4f3, 0xa5a2, 0xa5a3, 0xa5a4, 0xa5a6, 0xa5a7, 0xa5aa, 0xa5ad, 0xa5af, + 0xa5b0, 0xa5b3, 0xa5b5, 0xa5b7, 0xa5b8, 0xa5b9, 0xa5bf, 0xa5c3, 0xa5c6, 0xa5c7, + 0xa5c8, 0xa5c9, 0xa5cb, 0xa5d0, 0xa5d5, 0xa5d6, 0xa5d7, 0xa5de, 0xa5e0, 0xa5e1, + 0xa5e5, 0xa5e9, 0xa5ea, 0xa5eb, 0xa5ec, 0xa5ed, 0xa5f3, 0xb8a9, 0xb9d4, 0xbaee, + 0xbbc8, 0xbef0, 0xbfb7, 0xc4ea, 0xc6fc, 0xc7bd, 0xcab8, 0xcaf3, 0xcbdc, 0xcdd1}; + + @Override + String getName() { + return "EUC-JP"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det, commonChars); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + @Override + public String getLanguage() { + return "ja"; + } + } + + /** + * The charset recognize for EUC-KR. A singleton instance of this class + * is created and kept by the public CharsetDetector class + */ + static class CharsetRecog_euc_kr extends CharsetRecog_euc { + static int[] commonChars = + // TODO: This set of data comes from the character frequency- + // of-occurence analysis tool. The data needs to be moved + // into a resource and loaded from there. + {0xb0a1, 0xb0b3, 0xb0c5, 0xb0cd, 0xb0d4, 0xb0e6, 0xb0ed, 0xb0f8, 0xb0fa, 0xb0fc, + 0xb1b8, 0xb1b9, 0xb1c7, 0xb1d7, 0xb1e2, 0xb3aa, 0xb3bb, 0xb4c2, 0xb4cf, 0xb4d9, + 0xb4eb, 0xb5a5, 0xb5b5, 0xb5bf, 0xb5c7, 0xb5e9, 0xb6f3, 0xb7af, 0xb7c2, 0xb7ce, + 0xb8a6, 0xb8ae, 0xb8b6, 0xb8b8, 0xb8bb, 0xb8e9, 0xb9ab, 0xb9ae, 0xb9cc, 0xb9ce, + 0xb9fd, 0xbab8, 0xbace, 0xbad0, 0xbaf1, 0xbbe7, 0xbbf3, 0xbbfd, 0xbcad, 0xbcba, + 0xbcd2, 0xbcf6, 0xbdba, 0xbdc0, 0xbdc3, 0xbdc5, 0xbec6, 0xbec8, 0xbedf, 0xbeee, + 0xbef8, 0xbefa, 0xbfa1, 0xbfa9, 0xbfc0, 0xbfe4, 0xbfeb, 0xbfec, 0xbff8, 0xc0a7, + 0xc0af, 0xc0b8, 0xc0ba, 0xc0bb, 0xc0bd, 0xc0c7, 0xc0cc, 0xc0ce, 0xc0cf, 0xc0d6, + 0xc0da, 0xc0e5, 0xc0fb, 0xc0fc, 0xc1a4, 0xc1a6, 0xc1b6, 0xc1d6, 0xc1df, 0xc1f6, + 0xc1f8, 0xc4a1, 0xc5cd, 0xc6ae, 0xc7cf, 0xc7d1, 0xc7d2, 0xc7d8, 0xc7e5, 0xc8ad}; + + @Override + String getName() { + return "EUC-KR"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det, commonChars); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + @Override + public String getLanguage() { + return "ko"; + } + } + } + + /** + * GB-18030 recognizer. Uses simplified Chinese statistics. + */ + static class CharsetRecog_gb_18030 extends CharsetRecog_mbcs { + + /* + * (non-Javadoc) + * Get the next character value for EUC based encodings. + * Character "value" is simply the raw bytes that make up the character + * packed into an int. + */ + @Override + boolean nextChar(iteratedChar it, CharsetDetector det) { + it.error = false; + int firstByte; + int secondByte; + int thirdByte; + int fourthByte; + + buildChar: + { + firstByte = it.charValue = it.nextByte(det); + + if (firstByte < 0) { + // Ran off the end of the input data + it.done = true; + break buildChar; + } + + if (firstByte <= 0x80) { + // single byte char + break buildChar; + } + + secondByte = it.nextByte(det); + it.charValue = (it.charValue << 8) | secondByte; + + if (firstByte >= 0x81 && firstByte <= 0xFE) { + // Two byte Char + if ((secondByte >= 0x40 && secondByte <= 0x7E) || (secondByte >= 80 && secondByte <= 0xFE)) { + break buildChar; + } + + // Four byte char + if (secondByte >= 0x30 && secondByte <= 0x39) { + thirdByte = it.nextByte(det); + + if (thirdByte >= 0x81 && thirdByte <= 0xFE) { + fourthByte = it.nextByte(det); + + if (fourthByte >= 0x30 && fourthByte <= 0x39) { + it.charValue = (it.charValue << 16) | (thirdByte << 8) | fourthByte; + break buildChar; + } + } + } + + it.error = true; + } + } + + return (!it.done); + } + + static int[] commonChars = + // TODO: This set of data comes from the character frequency- + // of-occurence analysis tool. The data needs to be moved + // into a resource and loaded from there. + {0xa1a1, 0xa1a2, 0xa1a3, 0xa1a4, 0xa1b0, 0xa1b1, 0xa1f1, 0xa1f3, 0xa3a1, 0xa3ac, + 0xa3ba, 0xb1a8, 0xb1b8, 0xb1be, 0xb2bb, 0xb3c9, 0xb3f6, 0xb4f3, 0xb5bd, 0xb5c4, + 0xb5e3, 0xb6af, 0xb6d4, 0xb6e0, 0xb7a2, 0xb7a8, 0xb7bd, 0xb7d6, 0xb7dd, 0xb8b4, + 0xb8df, 0xb8f6, 0xb9ab, 0xb9c9, 0xb9d8, 0xb9fa, 0xb9fd, 0xbacd, 0xbba7, 0xbbd6, + 0xbbe1, 0xbbfa, 0xbcbc, 0xbcdb, 0xbcfe, 0xbdcc, 0xbecd, 0xbedd, 0xbfb4, 0xbfc6, + 0xbfc9, 0xc0b4, 0xc0ed, 0xc1cb, 0xc2db, 0xc3c7, 0xc4dc, 0xc4ea, 0xc5cc, 0xc6f7, + 0xc7f8, 0xc8ab, 0xc8cb, 0xc8d5, 0xc8e7, 0xc9cf, 0xc9fa, 0xcab1, 0xcab5, 0xcac7, + 0xcad0, 0xcad6, 0xcaf5, 0xcafd, 0xccec, 0xcdf8, 0xceaa, 0xcec4, 0xced2, 0xcee5, + 0xcfb5, 0xcfc2, 0xcfd6, 0xd0c2, 0xd0c5, 0xd0d0, 0xd0d4, 0xd1a7, 0xd2aa, 0xd2b2, + 0xd2b5, 0xd2bb, 0xd2d4, 0xd3c3, 0xd3d0, 0xd3fd, 0xd4c2, 0xd4da, 0xd5e2, 0xd6d0}; + + + @Override + String getName() { + return "GB18030"; + } + + @Override + CharsetMatch match(CharsetDetector det) { + int confidence = match(det, commonChars); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + @Override + public String getLanguage() { + return "zh"; + } + } + + +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_sbcs.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_sbcs.java new file mode 100644 index 000000000..070b7754b --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecog_sbcs.java @@ -0,0 +1,1188 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* + **************************************************************************** + * Copyright (C) 2005-2013, International Business Machines Corporation and * + * others. All Rights Reserved. * + ************************************************************************** * + * + */ + +package io.legado.app.lib.icu4j; + +/** + * This class recognizes single-byte encodings. Because the encoding scheme is so + * simple, language statistics are used to do the matching. + */ +abstract class CharsetRecog_sbcs extends CharsetRecognizer { + + /* (non-Javadoc) + * @see com.ibm.icu.text.CharsetRecognizer#getName() + */ + @Override + abstract String getName(); + + static class NGramParser { + // private static final int N_GRAM_SIZE = 3; + private static final int N_GRAM_MASK = 0xFFFFFF; + + protected int byteIndex = 0; + private int ngram = 0; + + private final int[] ngramList; + protected byte[] byteMap; + + private int ngramCount; + private int hitCount; + + protected byte spaceChar; + + public NGramParser(int[] theNgramList, byte[] theByteMap) { + ngramList = theNgramList; + byteMap = theByteMap; + + ngram = 0; + + ngramCount = hitCount = 0; + } + + /* + * Binary search for value in table, which must have exactly 64 entries. + */ + private static int search(int[] table, int value) { + int index = 0; + + if (table[index + 32] <= value) { + index += 32; + } + + if (table[index + 16] <= value) { + index += 16; + } + + if (table[index + 8] <= value) { + index += 8; + } + + if (table[index + 4] <= value) { + index += 4; + } + + if (table[index + 2] <= value) { + index += 2; + } + + if (table[index + 1] <= value) { + index += 1; + } + + if (table[index] > value) { + index -= 1; + } + + if (index < 0 || table[index] != value) { + return -1; + } + + return index; + } + + private void lookup(int thisNgram) { + ngramCount += 1; + + if (search(ngramList, thisNgram) >= 0) { + hitCount += 1; + } + + } + + protected void addByte(int b) { + ngram = ((ngram << 8) + (b & 0xFF)) & N_GRAM_MASK; + lookup(ngram); + } + + private int nextByte(CharsetDetector det) { + if (byteIndex >= det.fInputLen) { + return -1; + } + + return det.fInputBytes[byteIndex++] & 0xFF; + } + + protected void parseCharacters(CharsetDetector det) { + int b; + boolean ignoreSpace = false; + + while ((b = nextByte(det)) >= 0) { + byte mb = byteMap[b]; + + // TODO: 0x20 might not be a space in all character sets... + if (mb != 0) { + if (!(mb == spaceChar && ignoreSpace)) { + addByte(mb); + } + + ignoreSpace = (mb == spaceChar); + } + } + + } + + public int parse(CharsetDetector det) { + return parse(det, (byte) 0x20); + } + + public int parse(CharsetDetector det, byte spaceCh) { + + this.spaceChar = spaceCh; + + parseCharacters(det); + + // TODO: Is this OK? The buffer could have ended in the middle of a word... + addByte(spaceChar); + + double rawPercent = (double) hitCount / (double) ngramCount; + +// if (rawPercent <= 2.0) { +// return 0; +// } + + // TODO - This is a bit of a hack to take care of a case + // were we were getting a confidence of 135... + if (rawPercent > 0.33) { + return 98; + } + + return (int) (rawPercent * 300.0); + } + } + + static class NGramParser_IBM420 extends NGramParser { + private byte alef = 0x00; + + protected static byte[] unshapeMap = { +/* -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -A -B -C -D -E -F */ +/* 0- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 1- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 2- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 3- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 4- */ (byte) 0x40, (byte) 0x40, (byte) 0x42, (byte) 0x42, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x47, (byte) 0x49, (byte) 0x4A, (byte) 0x4B, (byte) 0x4C, (byte) 0x4D, (byte) 0x4E, (byte) 0x4F, +/* 5- */ (byte) 0x50, (byte) 0x49, (byte) 0x52, (byte) 0x53, (byte) 0x54, (byte) 0x55, (byte) 0x56, (byte) 0x56, (byte) 0x58, (byte) 0x58, (byte) 0x5A, (byte) 0x5B, (byte) 0x5C, (byte) 0x5D, (byte) 0x5E, (byte) 0x5F, +/* 6- */ (byte) 0x60, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x63, (byte) 0x65, (byte) 0x65, (byte) 0x67, (byte) 0x67, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, +/* 7- */ (byte) 0x69, (byte) 0x71, (byte) 0x71, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x77, (byte) 0x79, (byte) 0x7A, (byte) 0x7B, (byte) 0x7C, (byte) 0x7D, (byte) 0x7E, (byte) 0x7F, +/* 8- */ (byte) 0x80, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x80, (byte) 0x8B, (byte) 0x8B, (byte) 0x8D, (byte) 0x8D, (byte) 0x8F, +/* 9- */ (byte) 0x90, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x9A, (byte) 0x9A, (byte) 0x9A, (byte) 0x9A, (byte) 0x9E, (byte) 0x9E, +/* A- */ (byte) 0x9E, (byte) 0xA1, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0x9E, (byte) 0xAB, (byte) 0xAB, (byte) 0xAD, (byte) 0xAD, (byte) 0xAF, +/* B- */ (byte) 0xAF, (byte) 0xB1, (byte) 0xB2, (byte) 0xB3, (byte) 0xB4, (byte) 0xB5, (byte) 0xB6, (byte) 0xB7, (byte) 0xB8, (byte) 0xB9, (byte) 0xB1, (byte) 0xBB, (byte) 0xBB, (byte) 0xBD, (byte) 0xBD, (byte) 0xBF, +/* C- */ (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xBF, (byte) 0xCC, (byte) 0xBF, (byte) 0xCE, (byte) 0xCF, +/* D- */ (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDA, (byte) 0xDC, (byte) 0xDC, (byte) 0xDC, (byte) 0xDF, +/* E- */ (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, +/* F- */ (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, + }; + + + public NGramParser_IBM420(int[] theNgramList, byte[] theByteMap) { + super(theNgramList, theByteMap); + } + + private byte isLamAlef(byte b) { + if (b == (byte) 0xb2 || b == (byte) 0xb3) { + return (byte) 0x47; + } else if (b == (byte) 0xb4 || b == (byte) 0xb5) { + return (byte) 0x49; + } else if (b == (byte) 0xb8 || b == (byte) 0xb9) { + return (byte) 0x56; + } else + return (byte) 0x00; + } + + /* + * Arabic shaping needs to be done manually. Cannot call ArabicShaping class + * because CharsetDetector is dealing with bytes not Unicode code points. We could + * convert the bytes to Unicode code points but that would leave us dependent + * on CharsetICU which we try to avoid. IBM420 converter amongst different versions + * of JDK can produce different results and therefore is also avoided. + */ + private int nextByte(CharsetDetector det) { + if (byteIndex >= det.fInputLen || det.fInputBytes[byteIndex] == 0) { + return -1; + } + int next; + + alef = isLamAlef(det.fInputBytes[byteIndex]); + if (alef != (byte) 0x00) + next = 0xB1 & 0xFF; + else + next = unshapeMap[det.fInputBytes[byteIndex] & 0xFF] & 0xFF; + + byteIndex++; + + return next; + } + + @Override + protected void parseCharacters(CharsetDetector det) { + int b; + boolean ignoreSpace = false; + + while ((b = nextByte(det)) >= 0) { + byte mb = byteMap[b]; + + // TODO: 0x20 might not be a space in all character sets... + if (mb != 0) { + if (!(mb == spaceChar && ignoreSpace)) { + addByte(mb); + } + + ignoreSpace = (mb == spaceChar); + } + if (alef != (byte) 0x00) { + mb = byteMap[alef & 0xFF]; + + // TODO: 0x20 might not be a space in all character sets... + if (mb != 0) { + if (!(mb == spaceChar && ignoreSpace)) { + addByte(mb); + } + + ignoreSpace = (mb == spaceChar); + } + + } + } + } + } + + + int match(CharsetDetector det, int[] ngrams, byte[] byteMap) { + return match(det, ngrams, byteMap, (byte) 0x20); + } + + int match(CharsetDetector det, int[] ngrams, byte[] byteMap, byte spaceChar) { + NGramParser parser = new NGramParser(ngrams, byteMap); + return parser.parse(det, spaceChar); + } + + @SuppressWarnings("SameParameterValue") + int matchIBM420(CharsetDetector det, int[] ngrams, byte[] byteMap, byte spaceChar) { + NGramParser_IBM420 parser = new NGramParser_IBM420(ngrams, byteMap); + return parser.parse(det, spaceChar); + } + + static class NGramsPlusLang { + int[] fNGrams; + String fLang; + + NGramsPlusLang(String la, int[] ng) { + fLang = la; + fNGrams = ng; + } + } + + static class CharsetRecog_8859_1 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0xAA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, + }; + + + private static final NGramsPlusLang[] ngrams_8859_1 = new NGramsPlusLang[]{ + new NGramsPlusLang( + "da", + new int[]{ + 0x206166, 0x206174, 0x206465, 0x20656E, 0x206572, 0x20666F, 0x206861, 0x206920, 0x206D65, 0x206F67, 0x2070E5, 0x207369, 0x207374, 0x207469, 0x207669, 0x616620, + 0x616E20, 0x616E64, 0x617220, 0x617420, 0x646520, 0x64656E, 0x646572, 0x646574, 0x652073, 0x656420, 0x656465, 0x656E20, 0x656E64, 0x657220, 0x657265, 0x657320, + 0x657420, 0x666F72, 0x676520, 0x67656E, 0x676572, 0x696765, 0x696C20, 0x696E67, 0x6B6520, 0x6B6B65, 0x6C6572, 0x6C6967, 0x6C6C65, 0x6D6564, 0x6E6465, 0x6E6520, + 0x6E6720, 0x6E6765, 0x6F6720, 0x6F6D20, 0x6F7220, 0x70E520, 0x722064, 0x722065, 0x722073, 0x726520, 0x737465, 0x742073, 0x746520, 0x746572, 0x74696C, 0x766572, + }), + new NGramsPlusLang( + "de", + new int[]{ + 0x20616E, 0x206175, 0x206265, 0x206461, 0x206465, 0x206469, 0x206569, 0x206765, 0x206861, 0x20696E, 0x206D69, 0x207363, 0x207365, 0x20756E, 0x207665, 0x20766F, + 0x207765, 0x207A75, 0x626572, 0x636820, 0x636865, 0x636874, 0x646173, 0x64656E, 0x646572, 0x646965, 0x652064, 0x652073, 0x65696E, 0x656974, 0x656E20, 0x657220, + 0x657320, 0x67656E, 0x68656E, 0x687420, 0x696368, 0x696520, 0x696E20, 0x696E65, 0x697420, 0x6C6963, 0x6C6C65, 0x6E2061, 0x6E2064, 0x6E2073, 0x6E6420, 0x6E6465, + 0x6E6520, 0x6E6720, 0x6E6765, 0x6E7465, 0x722064, 0x726465, 0x726569, 0x736368, 0x737465, 0x742064, 0x746520, 0x74656E, 0x746572, 0x756E64, 0x756E67, 0x766572, + }), + new NGramsPlusLang( + "en", + new int[]{ + 0x206120, 0x20616E, 0x206265, 0x20636F, 0x20666F, 0x206861, 0x206865, 0x20696E, 0x206D61, 0x206F66, 0x207072, 0x207265, 0x207361, 0x207374, 0x207468, 0x20746F, + 0x207768, 0x616964, 0x616C20, 0x616E20, 0x616E64, 0x617320, 0x617420, 0x617465, 0x617469, 0x642061, 0x642074, 0x652061, 0x652073, 0x652074, 0x656420, 0x656E74, + 0x657220, 0x657320, 0x666F72, 0x686174, 0x686520, 0x686572, 0x696420, 0x696E20, 0x696E67, 0x696F6E, 0x697320, 0x6E2061, 0x6E2074, 0x6E6420, 0x6E6720, 0x6E7420, + 0x6F6620, 0x6F6E20, 0x6F7220, 0x726520, 0x727320, 0x732061, 0x732074, 0x736169, 0x737420, 0x742074, 0x746572, 0x746861, 0x746865, 0x74696F, 0x746F20, 0x747320, + }), + + new NGramsPlusLang( + "es", + new int[]{ + 0x206120, 0x206361, 0x20636F, 0x206465, 0x20656C, 0x20656E, 0x206573, 0x20696E, 0x206C61, 0x206C6F, 0x207061, 0x20706F, 0x207072, 0x207175, 0x207265, 0x207365, + 0x20756E, 0x207920, 0x612063, 0x612064, 0x612065, 0x61206C, 0x612070, 0x616369, 0x61646F, 0x616C20, 0x617220, 0x617320, 0x6369F3, 0x636F6E, 0x646520, 0x64656C, + 0x646F20, 0x652064, 0x652065, 0x65206C, 0x656C20, 0x656E20, 0x656E74, 0x657320, 0x657374, 0x69656E, 0x69F36E, 0x6C6120, 0x6C6F73, 0x6E2065, 0x6E7465, 0x6F2064, + 0x6F2065, 0x6F6E20, 0x6F7220, 0x6F7320, 0x706172, 0x717565, 0x726120, 0x726573, 0x732064, 0x732065, 0x732070, 0x736520, 0x746520, 0x746F20, 0x756520, 0xF36E20, + }), + + new NGramsPlusLang( + "fr", + new int[]{ + 0x206175, 0x20636F, 0x206461, 0x206465, 0x206475, 0x20656E, 0x206574, 0x206C61, 0x206C65, 0x207061, 0x20706F, 0x207072, 0x207175, 0x207365, 0x20736F, 0x20756E, + 0x20E020, 0x616E74, 0x617469, 0x636520, 0x636F6E, 0x646520, 0x646573, 0x647520, 0x652061, 0x652063, 0x652064, 0x652065, 0x65206C, 0x652070, 0x652073, 0x656E20, + 0x656E74, 0x657220, 0x657320, 0x657420, 0x657572, 0x696F6E, 0x697320, 0x697420, 0x6C6120, 0x6C6520, 0x6C6573, 0x6D656E, 0x6E2064, 0x6E6520, 0x6E7320, 0x6E7420, + 0x6F6E20, 0x6F6E74, 0x6F7572, 0x717565, 0x72206C, 0x726520, 0x732061, 0x732064, 0x732065, 0x73206C, 0x732070, 0x742064, 0x746520, 0x74696F, 0x756520, 0x757220, + }), + + new NGramsPlusLang( + "it", + new int[]{ + 0x20616C, 0x206368, 0x20636F, 0x206465, 0x206469, 0x206520, 0x20696C, 0x20696E, 0x206C61, 0x207065, 0x207072, 0x20756E, 0x612063, 0x612064, 0x612070, 0x612073, + 0x61746F, 0x636865, 0x636F6E, 0x64656C, 0x646920, 0x652061, 0x652063, 0x652064, 0x652069, 0x65206C, 0x652070, 0x652073, 0x656C20, 0x656C6C, 0x656E74, 0x657220, + 0x686520, 0x692061, 0x692063, 0x692064, 0x692073, 0x696120, 0x696C20, 0x696E20, 0x696F6E, 0x6C6120, 0x6C6520, 0x6C6920, 0x6C6C61, 0x6E6520, 0x6E6920, 0x6E6F20, + 0x6E7465, 0x6F2061, 0x6F2064, 0x6F2069, 0x6F2073, 0x6F6E20, 0x6F6E65, 0x706572, 0x726120, 0x726520, 0x736920, 0x746120, 0x746520, 0x746920, 0x746F20, 0x7A696F, + }), + + new NGramsPlusLang( + "nl", + new int[]{ + 0x20616C, 0x206265, 0x206461, 0x206465, 0x206469, 0x206565, 0x20656E, 0x206765, 0x206865, 0x20696E, 0x206D61, 0x206D65, 0x206F70, 0x207465, 0x207661, 0x207665, + 0x20766F, 0x207765, 0x207A69, 0x61616E, 0x616172, 0x616E20, 0x616E64, 0x617220, 0x617420, 0x636874, 0x646520, 0x64656E, 0x646572, 0x652062, 0x652076, 0x65656E, + 0x656572, 0x656E20, 0x657220, 0x657273, 0x657420, 0x67656E, 0x686574, 0x696520, 0x696E20, 0x696E67, 0x697320, 0x6E2062, 0x6E2064, 0x6E2065, 0x6E2068, 0x6E206F, + 0x6E2076, 0x6E6465, 0x6E6720, 0x6F6E64, 0x6F6F72, 0x6F7020, 0x6F7220, 0x736368, 0x737465, 0x742064, 0x746520, 0x74656E, 0x746572, 0x76616E, 0x766572, 0x766F6F, + }), + + new NGramsPlusLang( + "no", + new int[]{ + 0x206174, 0x206176, 0x206465, 0x20656E, 0x206572, 0x20666F, 0x206861, 0x206920, 0x206D65, 0x206F67, 0x2070E5, 0x207365, 0x20736B, 0x20736F, 0x207374, 0x207469, + 0x207669, 0x20E520, 0x616E64, 0x617220, 0x617420, 0x646520, 0x64656E, 0x646574, 0x652073, 0x656420, 0x656E20, 0x656E65, 0x657220, 0x657265, 0x657420, 0x657474, + 0x666F72, 0x67656E, 0x696B6B, 0x696C20, 0x696E67, 0x6B6520, 0x6B6B65, 0x6C6520, 0x6C6C65, 0x6D6564, 0x6D656E, 0x6E2073, 0x6E6520, 0x6E6720, 0x6E6765, 0x6E6E65, + 0x6F6720, 0x6F6D20, 0x6F7220, 0x70E520, 0x722073, 0x726520, 0x736F6D, 0x737465, 0x742073, 0x746520, 0x74656E, 0x746572, 0x74696C, 0x747420, 0x747465, 0x766572, + }), + + new NGramsPlusLang( + "pt", + new int[]{ + 0x206120, 0x20636F, 0x206461, 0x206465, 0x20646F, 0x206520, 0x206573, 0x206D61, 0x206E6F, 0x206F20, 0x207061, 0x20706F, 0x207072, 0x207175, 0x207265, 0x207365, + 0x20756D, 0x612061, 0x612063, 0x612064, 0x612070, 0x616465, 0x61646F, 0x616C20, 0x617220, 0x617261, 0x617320, 0x636F6D, 0x636F6E, 0x646120, 0x646520, 0x646F20, + 0x646F73, 0x652061, 0x652064, 0x656D20, 0x656E74, 0x657320, 0x657374, 0x696120, 0x696361, 0x6D656E, 0x6E7465, 0x6E746F, 0x6F2061, 0x6F2063, 0x6F2064, 0x6F2065, + 0x6F2070, 0x6F7320, 0x706172, 0x717565, 0x726120, 0x726573, 0x732061, 0x732064, 0x732065, 0x732070, 0x737461, 0x746520, 0x746F20, 0x756520, 0xE36F20, 0xE7E36F, + + }), + + new NGramsPlusLang( + "sv", + new int[]{ + 0x206174, 0x206176, 0x206465, 0x20656E, 0x2066F6, 0x206861, 0x206920, 0x20696E, 0x206B6F, 0x206D65, 0x206F63, 0x2070E5, 0x20736B, 0x20736F, 0x207374, 0x207469, + 0x207661, 0x207669, 0x20E472, 0x616465, 0x616E20, 0x616E64, 0x617220, 0x617474, 0x636820, 0x646520, 0x64656E, 0x646572, 0x646574, 0x656420, 0x656E20, 0x657220, + 0x657420, 0x66F672, 0x67656E, 0x696C6C, 0x696E67, 0x6B6120, 0x6C6C20, 0x6D6564, 0x6E2073, 0x6E6120, 0x6E6465, 0x6E6720, 0x6E6765, 0x6E696E, 0x6F6368, 0x6F6D20, + 0x6F6E20, 0x70E520, 0x722061, 0x722073, 0x726120, 0x736B61, 0x736F6D, 0x742073, 0x746120, 0x746520, 0x746572, 0x74696C, 0x747420, 0x766172, 0xE47220, 0xF67220, + }), + + }; + + + @Override + public CharsetMatch match(CharsetDetector det) { + String name = det.fC1Bytes ? "windows-1252" : "ISO-8859-1"; + int bestConfidenceSoFar = -1; + String lang = null; + for (NGramsPlusLang ngl : ngrams_8859_1) { + int confidence = match(det, ngl.fNGrams, byteMap); + if (confidence > bestConfidenceSoFar) { + bestConfidenceSoFar = confidence; + lang = ngl.fLang; + } + } + return bestConfidenceSoFar <= 0 ? null : new CharsetMatch(det, this, bestConfidenceSoFar, name, lang); + } + + + @Override + public String getName() { + return "ISO-8859-1"; + } + } + + + static class CharsetRecog_8859_2 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0xB1, (byte) 0x20, (byte) 0xB3, (byte) 0x20, (byte) 0xB5, (byte) 0xB6, (byte) 0x20, + (byte) 0x20, (byte) 0xB9, (byte) 0xBA, (byte) 0xBB, (byte) 0xBC, (byte) 0x20, (byte) 0xBE, (byte) 0xBF, + (byte) 0x20, (byte) 0xB1, (byte) 0x20, (byte) 0xB3, (byte) 0x20, (byte) 0xB5, (byte) 0xB6, (byte) 0xB7, + (byte) 0x20, (byte) 0xB9, (byte) 0xBA, (byte) 0xBB, (byte) 0xBC, (byte) 0x20, (byte) 0xBE, (byte) 0xBF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0x20, + }; + + private static final NGramsPlusLang[] ngrams_8859_2 = new NGramsPlusLang[]{ + new NGramsPlusLang( + "cs", + new int[]{ + 0x206120, 0x206279, 0x20646F, 0x206A65, 0x206E61, 0x206E65, 0x206F20, 0x206F64, 0x20706F, 0x207072, 0x2070F8, 0x20726F, 0x207365, 0x20736F, 0x207374, 0x20746F, + 0x207620, 0x207679, 0x207A61, 0x612070, 0x636520, 0x636820, 0x652070, 0x652073, 0x652076, 0x656D20, 0x656EED, 0x686F20, 0x686F64, 0x697374, 0x6A6520, 0x6B7465, + 0x6C6520, 0x6C6920, 0x6E6120, 0x6EE920, 0x6EEC20, 0x6EED20, 0x6F2070, 0x6F646E, 0x6F6A69, 0x6F7374, 0x6F7520, 0x6F7661, 0x706F64, 0x706F6A, 0x70726F, 0x70F865, + 0x736520, 0x736F75, 0x737461, 0x737469, 0x73746E, 0x746572, 0x746EED, 0x746F20, 0x752070, 0xBE6520, 0xE16EED, 0xE9686F, 0xED2070, 0xED2073, 0xED6D20, 0xF86564, + }), + new NGramsPlusLang( + "hu", + new int[]{ + 0x206120, 0x20617A, 0x206265, 0x206567, 0x20656C, 0x206665, 0x206861, 0x20686F, 0x206973, 0x206B65, 0x206B69, 0x206BF6, 0x206C65, 0x206D61, 0x206D65, 0x206D69, + 0x206E65, 0x20737A, 0x207465, 0x20E973, 0x612061, 0x61206B, 0x61206D, 0x612073, 0x616B20, 0x616E20, 0x617A20, 0x62616E, 0x62656E, 0x656779, 0x656B20, 0x656C20, + 0x656C65, 0x656D20, 0x656E20, 0x657265, 0x657420, 0x657465, 0x657474, 0x677920, 0x686F67, 0x696E74, 0x697320, 0x6B2061, 0x6BF67A, 0x6D6567, 0x6D696E, 0x6E2061, + 0x6E616B, 0x6E656B, 0x6E656D, 0x6E7420, 0x6F6779, 0x732061, 0x737A65, 0x737A74, 0x737AE1, 0x73E967, 0x742061, 0x747420, 0x74E173, 0x7A6572, 0xE16E20, 0xE97320, + }), + new NGramsPlusLang( + "pl", + new int[]{ + 0x20637A, 0x20646F, 0x206920, 0x206A65, 0x206B6F, 0x206D61, 0x206D69, 0x206E61, 0x206E69, 0x206F64, 0x20706F, 0x207072, 0x207369, 0x207720, 0x207769, 0x207779, + 0x207A20, 0x207A61, 0x612070, 0x612077, 0x616E69, 0x636820, 0x637A65, 0x637A79, 0x646F20, 0x647A69, 0x652070, 0x652073, 0x652077, 0x65207A, 0x65676F, 0x656A20, + 0x656D20, 0x656E69, 0x676F20, 0x696120, 0x696520, 0x69656A, 0x6B6120, 0x6B6920, 0x6B6965, 0x6D6965, 0x6E6120, 0x6E6961, 0x6E6965, 0x6F2070, 0x6F7761, 0x6F7769, + 0x706F6C, 0x707261, 0x70726F, 0x70727A, 0x727A65, 0x727A79, 0x7369EA, 0x736B69, 0x737461, 0x776965, 0x796368, 0x796D20, 0x7A6520, 0x7A6965, 0x7A7920, 0xF37720, + }), + new NGramsPlusLang( + "ro", + new int[]{ + 0x206120, 0x206163, 0x206361, 0x206365, 0x20636F, 0x206375, 0x206465, 0x206469, 0x206C61, 0x206D61, 0x207065, 0x207072, 0x207365, 0x2073E3, 0x20756E, 0x20BA69, + 0x20EE6E, 0x612063, 0x612064, 0x617265, 0x617420, 0x617465, 0x617520, 0x636172, 0x636F6E, 0x637520, 0x63E320, 0x646520, 0x652061, 0x652063, 0x652064, 0x652070, + 0x652073, 0x656120, 0x656920, 0x656C65, 0x656E74, 0x657374, 0x692061, 0x692063, 0x692064, 0x692070, 0x696520, 0x696920, 0x696E20, 0x6C6120, 0x6C6520, 0x6C6F72, + 0x6C7569, 0x6E6520, 0x6E7472, 0x6F7220, 0x70656E, 0x726520, 0x726561, 0x727520, 0x73E320, 0x746520, 0x747275, 0x74E320, 0x756920, 0x756C20, 0xBA6920, 0xEE6E20, + }) + }; + + @Override + public CharsetMatch match(CharsetDetector det) { + String name = det.fC1Bytes ? "windows-1250" : "ISO-8859-2"; + int bestConfidenceSoFar = -1; + String lang = null; + for (NGramsPlusLang ngl : ngrams_8859_2) { + int confidence = match(det, ngl.fNGrams, byteMap); + if (confidence > bestConfidenceSoFar) { + bestConfidenceSoFar = confidence; + lang = ngl.fLang; + } + } + return bestConfidenceSoFar <= 0 ? null : new CharsetMatch(det, this, bestConfidenceSoFar, name, lang); + } + + @Override + public String getName() { + return "ISO-8859-2"; + } + + } + + + abstract static class CharsetRecog_8859_5 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0x20, (byte) 0xFE, (byte) 0xFF, + (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, + (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, + (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0x20, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0x20, (byte) 0xFE, (byte) 0xFF, + }; + + @Override + public String getName() { + return "ISO-8859-5"; + } + } + + static class CharsetRecog_8859_5_ru extends CharsetRecog_8859_5 { + private static final int[] ngrams = { + 0x20D220, 0x20D2DE, 0x20D4DE, 0x20D7D0, 0x20D820, 0x20DAD0, 0x20DADE, 0x20DDD0, 0x20DDD5, 0x20DED1, 0x20DFDE, 0x20DFE0, 0x20E0D0, 0x20E1DE, 0x20E1E2, 0x20E2DE, + 0x20E7E2, 0x20EDE2, 0xD0DDD8, 0xD0E2EC, 0xD3DE20, 0xD5DBEC, 0xD5DDD8, 0xD5E1E2, 0xD5E220, 0xD820DF, 0xD8D520, 0xD8D820, 0xD8EF20, 0xDBD5DD, 0xDBD820, 0xDBECDD, + 0xDDD020, 0xDDD520, 0xDDD8D5, 0xDDD8EF, 0xDDDE20, 0xDDDED2, 0xDE20D2, 0xDE20DF, 0xDE20E1, 0xDED220, 0xDED2D0, 0xDED3DE, 0xDED920, 0xDEDBEC, 0xDEDC20, 0xDEE1E2, + 0xDFDEDB, 0xDFE0D5, 0xDFE0D8, 0xDFE0DE, 0xE0D0D2, 0xE0D5D4, 0xE1E2D0, 0xE1E2D2, 0xE1E2D8, 0xE1EF20, 0xE2D5DB, 0xE2DE20, 0xE2DEE0, 0xE2EC20, 0xE7E2DE, 0xEBE520, + }; + + @Override + public String getLanguage() { + return "ru"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + abstract static class CharsetRecog_8859_6 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, + (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, + (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, + (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + }; + + @Override + public String getName() { + return "ISO-8859-6"; + } + } + + static class CharsetRecog_8859_6_ar extends CharsetRecog_8859_6 { + private static final int[] ngrams = { + 0x20C7E4, 0x20C7E6, 0x20C8C7, 0x20D9E4, 0x20E1EA, 0x20E4E4, 0x20E5E6, 0x20E8C7, 0xC720C7, 0xC7C120, 0xC7CA20, 0xC7D120, 0xC7E420, 0xC7E4C3, 0xC7E4C7, 0xC7E4C8, + 0xC7E4CA, 0xC7E4CC, 0xC7E4CD, 0xC7E4CF, 0xC7E4D3, 0xC7E4D9, 0xC7E4E2, 0xC7E4E5, 0xC7E4E8, 0xC7E4EA, 0xC7E520, 0xC7E620, 0xC7E6CA, 0xC820C7, 0xC920C7, 0xC920E1, + 0xC920E4, 0xC920E5, 0xC920E8, 0xCA20C7, 0xCF20C7, 0xCFC920, 0xD120C7, 0xD1C920, 0xD320C7, 0xD920C7, 0xD9E4E9, 0xE1EA20, 0xE420C7, 0xE4C920, 0xE4E920, 0xE4EA20, + 0xE520C7, 0xE5C720, 0xE5C920, 0xE5E620, 0xE620C7, 0xE720C7, 0xE7C720, 0xE8C7E4, 0xE8E620, 0xE920C7, 0xEA20C7, 0xEA20E5, 0xEA20E8, 0xEAC920, 0xEAD120, 0xEAE620, + }; + + @Override + public String getLanguage() { + return "ar"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + abstract static class CharsetRecog_8859_7 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0xA1, (byte) 0xA2, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xDC, (byte) 0x20, + (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, (byte) 0x20, (byte) 0xFC, (byte) 0x20, (byte) 0xFD, (byte) 0xFE, + (byte) 0xC0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0x20, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0x20, + }; + + @Override + public String getName() { + return "ISO-8859-7"; + } + } + + static class CharsetRecog_8859_7_el extends CharsetRecog_8859_7 { + private static final int[] ngrams = { + 0x20E1ED, 0x20E1F0, 0x20E3E9, 0x20E4E9, 0x20E5F0, 0x20E720, 0x20EAE1, 0x20ECE5, 0x20EDE1, 0x20EF20, 0x20F0E1, 0x20F0EF, 0x20F0F1, 0x20F3F4, 0x20F3F5, 0x20F4E7, + 0x20F4EF, 0xDFE120, 0xE120E1, 0xE120F4, 0xE1E920, 0xE1ED20, 0xE1F0FC, 0xE1F220, 0xE3E9E1, 0xE5E920, 0xE5F220, 0xE720F4, 0xE7ED20, 0xE7F220, 0xE920F4, 0xE9E120, + 0xE9EADE, 0xE9F220, 0xEAE1E9, 0xEAE1F4, 0xECE520, 0xED20E1, 0xED20E5, 0xED20F0, 0xEDE120, 0xEFF220, 0xEFF520, 0xF0EFF5, 0xF0F1EF, 0xF0FC20, 0xF220E1, 0xF220E5, + 0xF220EA, 0xF220F0, 0xF220F4, 0xF3E520, 0xF3E720, 0xF3F4EF, 0xF4E120, 0xF4E1E9, 0xF4E7ED, 0xF4E7F2, 0xF4E9EA, 0xF4EF20, 0xF4EFF5, 0xF4F9ED, 0xF9ED20, 0xFEED20, + }; + + @Override + public String getLanguage() { + return "el"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + String name = det.fC1Bytes ? "windows-1253" : "ISO-8859-7"; + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "el"); + } + } + + abstract static class CharsetRecog_8859_8 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + }; + + @Override + public String getName() { + return "ISO-8859-8"; + } + } + + static class CharsetRecog_8859_8_I_he extends CharsetRecog_8859_8 { + private static final int[] ngrams = { + 0x20E0E5, 0x20E0E7, 0x20E0E9, 0x20E0FA, 0x20E1E9, 0x20E1EE, 0x20E4E0, 0x20E4E5, 0x20E4E9, 0x20E4EE, 0x20E4F2, 0x20E4F9, 0x20E4FA, 0x20ECE0, 0x20ECE4, 0x20EEE0, + 0x20F2EC, 0x20F9EC, 0xE0FA20, 0xE420E0, 0xE420E1, 0xE420E4, 0xE420EC, 0xE420EE, 0xE420F9, 0xE4E5E0, 0xE5E020, 0xE5ED20, 0xE5EF20, 0xE5F820, 0xE5FA20, 0xE920E4, + 0xE9E420, 0xE9E5FA, 0xE9E9ED, 0xE9ED20, 0xE9EF20, 0xE9F820, 0xE9FA20, 0xEC20E0, 0xEC20E4, 0xECE020, 0xECE420, 0xED20E0, 0xED20E1, 0xED20E4, 0xED20EC, 0xED20EE, + 0xED20F9, 0xEEE420, 0xEF20E4, 0xF0E420, 0xF0E920, 0xF0E9ED, 0xF2EC20, 0xF820E4, 0xF8E9ED, 0xF9EC20, 0xFA20E0, 0xFA20E1, 0xFA20E4, 0xFA20EC, 0xFA20EE, 0xFA20F9, + }; + + @Override + public String getName() { + return "ISO-8859-8-I"; + } + + @Override + public String getLanguage() { + return "he"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + String name = det.fC1Bytes ? "windows-1255" : "ISO-8859-8-I"; + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "he"); + } + } + + static class CharsetRecog_8859_8_he extends CharsetRecog_8859_8 { + private static final int[] ngrams = { + 0x20E0E5, 0x20E0EC, 0x20E4E9, 0x20E4EC, 0x20E4EE, 0x20E4F0, 0x20E9F0, 0x20ECF2, 0x20ECF9, 0x20EDE5, 0x20EDE9, 0x20EFE5, 0x20EFE9, 0x20F8E5, 0x20F8E9, 0x20FAE0, + 0x20FAE5, 0x20FAE9, 0xE020E4, 0xE020EC, 0xE020ED, 0xE020FA, 0xE0E420, 0xE0E5E4, 0xE0EC20, 0xE0EE20, 0xE120E4, 0xE120ED, 0xE120FA, 0xE420E4, 0xE420E9, 0xE420EC, + 0xE420ED, 0xE420EF, 0xE420F8, 0xE420FA, 0xE4EC20, 0xE5E020, 0xE5E420, 0xE7E020, 0xE9E020, 0xE9E120, 0xE9E420, 0xEC20E4, 0xEC20ED, 0xEC20FA, 0xECF220, 0xECF920, + 0xEDE9E9, 0xEDE9F0, 0xEDE9F8, 0xEE20E4, 0xEE20ED, 0xEE20FA, 0xEEE120, 0xEEE420, 0xF2E420, 0xF920E4, 0xF920ED, 0xF920FA, 0xF9E420, 0xFAE020, 0xFAE420, 0xFAE5E9, + }; + + @Override + public String getLanguage() { + return "he"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + String name = det.fC1Bytes ? "windows-1255" : "ISO-8859-8"; + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "he"); + + } + } + + abstract static class CharsetRecog_8859_9 extends CharsetRecog_sbcs { + protected static byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0xAA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0x69, (byte) 0xFE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0x20, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, + }; + + @Override + public String getName() { + return "ISO-8859-9"; + } + } + + static class CharsetRecog_8859_9_tr extends CharsetRecog_8859_9 { + private static final int[] ngrams = { + 0x206261, 0x206269, 0x206275, 0x206461, 0x206465, 0x206765, 0x206861, 0x20696C, 0x206B61, 0x206B6F, 0x206D61, 0x206F6C, 0x207361, 0x207461, 0x207665, 0x207961, + 0x612062, 0x616B20, 0x616C61, 0x616D61, 0x616E20, 0x616EFD, 0x617220, 0x617261, 0x6172FD, 0x6173FD, 0x617961, 0x626972, 0x646120, 0x646520, 0x646920, 0x652062, + 0x65206B, 0x656469, 0x656E20, 0x657220, 0x657269, 0x657369, 0x696C65, 0x696E20, 0x696E69, 0x697220, 0x6C616E, 0x6C6172, 0x6C6520, 0x6C6572, 0x6E2061, 0x6E2062, + 0x6E206B, 0x6E6461, 0x6E6465, 0x6E6520, 0x6E6920, 0x6E696E, 0x6EFD20, 0x72696E, 0x72FD6E, 0x766520, 0x796120, 0x796F72, 0xFD6E20, 0xFD6E64, 0xFD6EFD, 0xFDF0FD, + }; + + @Override + public String getLanguage() { + return "tr"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + String name = det.fC1Bytes ? "windows-1254" : "ISO-8859-9"; + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence, name, "tr"); + } + } + + static class CharsetRecog_windows_1251 extends CharsetRecog_sbcs { + private static final int[] ngrams = { + 0x20E220, 0x20E2EE, 0x20E4EE, 0x20E7E0, 0x20E820, 0x20EAE0, 0x20EAEE, 0x20EDE0, 0x20EDE5, 0x20EEE1, 0x20EFEE, 0x20EFF0, 0x20F0E0, 0x20F1EE, 0x20F1F2, 0x20F2EE, + 0x20F7F2, 0x20FDF2, 0xE0EDE8, 0xE0F2FC, 0xE3EE20, 0xE5EBFC, 0xE5EDE8, 0xE5F1F2, 0xE5F220, 0xE820EF, 0xE8E520, 0xE8E820, 0xE8FF20, 0xEBE5ED, 0xEBE820, 0xEBFCED, + 0xEDE020, 0xEDE520, 0xEDE8E5, 0xEDE8FF, 0xEDEE20, 0xEDEEE2, 0xEE20E2, 0xEE20EF, 0xEE20F1, 0xEEE220, 0xEEE2E0, 0xEEE3EE, 0xEEE920, 0xEEEBFC, 0xEEEC20, 0xEEF1F2, + 0xEFEEEB, 0xEFF0E5, 0xEFF0E8, 0xEFF0EE, 0xF0E0E2, 0xF0E5E4, 0xF1F2E0, 0xF1F2E2, 0xF1F2E8, 0xF1FF20, 0xF2E5EB, 0xF2EE20, 0xF2EEF0, 0xF2FC20, 0xF7F2EE, 0xFBF520, + }; + + private static final byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x90, (byte) 0x83, (byte) 0x20, (byte) 0x83, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x9A, (byte) 0x20, (byte) 0x9C, (byte) 0x9D, (byte) 0x9E, (byte) 0x9F, + (byte) 0x90, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x9A, (byte) 0x20, (byte) 0x9C, (byte) 0x9D, (byte) 0x9E, (byte) 0x9F, + (byte) 0x20, (byte) 0xA2, (byte) 0xA2, (byte) 0xBC, (byte) 0x20, (byte) 0xB4, (byte) 0x20, (byte) 0x20, + (byte) 0xB8, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xBF, + (byte) 0x20, (byte) 0x20, (byte) 0xB3, (byte) 0xB3, (byte) 0xB4, (byte) 0xB5, (byte) 0x20, (byte) 0x20, + (byte) 0xB8, (byte) 0x20, (byte) 0xBA, (byte) 0x20, (byte) 0xBC, (byte) 0xBE, (byte) 0xBE, (byte) 0xBF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0xF0, (byte) 0xF1, (byte) 0xF2, (byte) 0xF3, (byte) 0xF4, (byte) 0xF5, (byte) 0xF6, (byte) 0xF7, + (byte) 0xF8, (byte) 0xF9, (byte) 0xFA, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, + }; + + @Override + public String getName() { + return "windows-1251"; + } + + @Override + public String getLanguage() { + return "ru"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + static class CharsetRecog_windows_1256 extends CharsetRecog_sbcs { + private static final int[] ngrams = { + 0x20C7E1, 0x20C7E4, 0x20C8C7, 0x20DAE1, 0x20DDED, 0x20E1E1, 0x20E3E4, 0x20E6C7, 0xC720C7, 0xC7C120, 0xC7CA20, 0xC7D120, 0xC7E120, 0xC7E1C3, 0xC7E1C7, 0xC7E1C8, + 0xC7E1CA, 0xC7E1CC, 0xC7E1CD, 0xC7E1CF, 0xC7E1D3, 0xC7E1DA, 0xC7E1DE, 0xC7E1E3, 0xC7E1E6, 0xC7E1ED, 0xC7E320, 0xC7E420, 0xC7E4CA, 0xC820C7, 0xC920C7, 0xC920DD, + 0xC920E1, 0xC920E3, 0xC920E6, 0xCA20C7, 0xCF20C7, 0xCFC920, 0xD120C7, 0xD1C920, 0xD320C7, 0xDA20C7, 0xDAE1EC, 0xDDED20, 0xE120C7, 0xE1C920, 0xE1EC20, 0xE1ED20, + 0xE320C7, 0xE3C720, 0xE3C920, 0xE3E420, 0xE420C7, 0xE520C7, 0xE5C720, 0xE6C7E1, 0xE6E420, 0xEC20C7, 0xED20C7, 0xED20E3, 0xED20E6, 0xEDC920, 0xEDD120, 0xEDE420, + }; + + private static final byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x81, (byte) 0x20, (byte) 0x83, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x88, (byte) 0x20, (byte) 0x8A, (byte) 0x20, (byte) 0x9C, (byte) 0x8D, (byte) 0x8E, (byte) 0x8F, + (byte) 0x90, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x98, (byte) 0x20, (byte) 0x9A, (byte) 0x20, (byte) 0x9C, (byte) 0x20, (byte) 0x20, (byte) 0x9F, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0xAA, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xB5, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, + (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, + (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0x20, + (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, + (byte) 0xE0, (byte) 0xE1, (byte) 0xE2, (byte) 0xE3, (byte) 0xE4, (byte) 0xE5, (byte) 0xE6, (byte) 0xE7, + (byte) 0xE8, (byte) 0xE9, (byte) 0xEA, (byte) 0xEB, (byte) 0xEC, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xF4, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0xF9, (byte) 0x20, (byte) 0xFB, (byte) 0xFC, (byte) 0x20, (byte) 0x20, (byte) 0xFF, + }; + + @Override + public String getName() { + return "windows-1256"; + } + + @Override + public String getLanguage() { + return "ar"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + static class CharsetRecog_KOI8_R extends CharsetRecog_sbcs { + private static final int[] ngrams = { + 0x20C4CF, 0x20C920, 0x20CBC1, 0x20CBCF, 0x20CEC1, 0x20CEC5, 0x20CFC2, 0x20D0CF, 0x20D0D2, 0x20D2C1, 0x20D3CF, 0x20D3D4, 0x20D4CF, 0x20D720, 0x20D7CF, 0x20DAC1, + 0x20DCD4, 0x20DED4, 0xC1CEC9, 0xC1D4D8, 0xC5CCD8, 0xC5CEC9, 0xC5D3D4, 0xC5D420, 0xC7CF20, 0xC920D0, 0xC9C520, 0xC9C920, 0xC9D120, 0xCCC5CE, 0xCCC920, 0xCCD8CE, + 0xCEC120, 0xCEC520, 0xCEC9C5, 0xCEC9D1, 0xCECF20, 0xCECFD7, 0xCF20D0, 0xCF20D3, 0xCF20D7, 0xCFC7CF, 0xCFCA20, 0xCFCCD8, 0xCFCD20, 0xCFD3D4, 0xCFD720, 0xCFD7C1, + 0xD0CFCC, 0xD0D2C5, 0xD0D2C9, 0xD0D2CF, 0xD2C1D7, 0xD2C5C4, 0xD3D120, 0xD3D4C1, 0xD3D4C9, 0xD3D4D7, 0xD4C5CC, 0xD4CF20, 0xD4CFD2, 0xD4D820, 0xD9C820, 0xDED4CF, + }; + + private static final byte[] byteMap = { + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x00, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x61, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, + (byte) 0x68, (byte) 0x69, (byte) 0x6A, (byte) 0x6B, (byte) 0x6C, (byte) 0x6D, (byte) 0x6E, (byte) 0x6F, + (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, + (byte) 0x78, (byte) 0x79, (byte) 0x7A, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xA3, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0xA3, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, (byte) 0x20, + (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, + (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, + (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, + (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, + (byte) 0xC0, (byte) 0xC1, (byte) 0xC2, (byte) 0xC3, (byte) 0xC4, (byte) 0xC5, (byte) 0xC6, (byte) 0xC7, + (byte) 0xC8, (byte) 0xC9, (byte) 0xCA, (byte) 0xCB, (byte) 0xCC, (byte) 0xCD, (byte) 0xCE, (byte) 0xCF, + (byte) 0xD0, (byte) 0xD1, (byte) 0xD2, (byte) 0xD3, (byte) 0xD4, (byte) 0xD5, (byte) 0xD6, (byte) 0xD7, + (byte) 0xD8, (byte) 0xD9, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, + }; + + @Override + public String getName() { + return "KOI8-R"; + } + + @Override + public String getLanguage() { + return "ru"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + abstract static class CharsetRecog_IBM424_he extends CharsetRecog_sbcs { + protected static byte[] byteMap = { +/* -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -A -B -C -D -E -F */ +/* 0- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 1- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 2- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 3- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 4- */ (byte) 0x40, (byte) 0x41, (byte) 0x42, (byte) 0x43, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x48, (byte) 0x49, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 5- */ (byte) 0x40, (byte) 0x51, (byte) 0x52, (byte) 0x53, (byte) 0x54, (byte) 0x55, (byte) 0x56, (byte) 0x57, (byte) 0x58, (byte) 0x59, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 6- */ (byte) 0x40, (byte) 0x40, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 7- */ (byte) 0x40, (byte) 0x71, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x00, (byte) 0x40, (byte) 0x40, +/* 8- */ (byte) 0x40, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 9- */ (byte) 0x40, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* A- */ (byte) 0xA0, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* B- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* C- */ (byte) 0x40, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* D- */ (byte) 0x40, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* E- */ (byte) 0x40, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* F- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, + }; + + @Override + public String getLanguage() { + return "he"; + } + } + + static class CharsetRecog_IBM424_he_rtl extends CharsetRecog_IBM424_he { + @Override + public String getName() { + return "IBM424_rtl"; + } + + private static final int[] ngrams = { + 0x404146, 0x404148, 0x404151, 0x404171, 0x404251, 0x404256, 0x404541, 0x404546, 0x404551, 0x404556, 0x404562, 0x404569, 0x404571, 0x405441, 0x405445, 0x405641, + 0x406254, 0x406954, 0x417140, 0x454041, 0x454042, 0x454045, 0x454054, 0x454056, 0x454069, 0x454641, 0x464140, 0x465540, 0x465740, 0x466840, 0x467140, 0x514045, + 0x514540, 0x514671, 0x515155, 0x515540, 0x515740, 0x516840, 0x517140, 0x544041, 0x544045, 0x544140, 0x544540, 0x554041, 0x554042, 0x554045, 0x554054, 0x554056, + 0x554069, 0x564540, 0x574045, 0x584540, 0x585140, 0x585155, 0x625440, 0x684045, 0x685155, 0x695440, 0x714041, 0x714042, 0x714045, 0x714054, 0x714056, 0x714069, + }; + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap, (byte) 0x40); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + static class CharsetRecog_IBM424_he_ltr extends CharsetRecog_IBM424_he { + @Override + public String getName() { + return "IBM424_ltr"; + } + + private static final int[] ngrams = { + 0x404146, 0x404154, 0x404551, 0x404554, 0x404556, 0x404558, 0x405158, 0x405462, 0x405469, 0x405546, 0x405551, 0x405746, 0x405751, 0x406846, 0x406851, 0x407141, + 0x407146, 0x407151, 0x414045, 0x414054, 0x414055, 0x414071, 0x414540, 0x414645, 0x415440, 0x415640, 0x424045, 0x424055, 0x424071, 0x454045, 0x454051, 0x454054, + 0x454055, 0x454057, 0x454068, 0x454071, 0x455440, 0x464140, 0x464540, 0x484140, 0x514140, 0x514240, 0x514540, 0x544045, 0x544055, 0x544071, 0x546240, 0x546940, + 0x555151, 0x555158, 0x555168, 0x564045, 0x564055, 0x564071, 0x564240, 0x564540, 0x624540, 0x694045, 0x694055, 0x694071, 0x694540, 0x714140, 0x714540, 0x714651 + + }; + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = match(det, ngrams, byteMap, (byte) 0x40); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + } + + abstract static class CharsetRecog_IBM420_ar extends CharsetRecog_sbcs { + + protected static byte[] byteMap = { +/* -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -A -B -C -D -E -F */ +/* 0- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 1- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 2- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 3- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 4- */ (byte) 0x40, (byte) 0x40, (byte) 0x42, (byte) 0x43, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x48, (byte) 0x49, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 5- */ (byte) 0x40, (byte) 0x51, (byte) 0x52, (byte) 0x40, (byte) 0x40, (byte) 0x55, (byte) 0x56, (byte) 0x57, (byte) 0x58, (byte) 0x59, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 6- */ (byte) 0x40, (byte) 0x40, (byte) 0x62, (byte) 0x63, (byte) 0x64, (byte) 0x65, (byte) 0x66, (byte) 0x67, (byte) 0x68, (byte) 0x69, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 7- */ (byte) 0x70, (byte) 0x71, (byte) 0x72, (byte) 0x73, (byte) 0x74, (byte) 0x75, (byte) 0x76, (byte) 0x77, (byte) 0x78, (byte) 0x79, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, +/* 8- */ (byte) 0x80, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x8A, (byte) 0x8B, (byte) 0x8C, (byte) 0x8D, (byte) 0x8E, (byte) 0x8F, +/* 9- */ (byte) 0x90, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0x9A, (byte) 0x9B, (byte) 0x9C, (byte) 0x9D, (byte) 0x9E, (byte) 0x9F, +/* A- */ (byte) 0xA0, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0xAA, (byte) 0xAB, (byte) 0xAC, (byte) 0xAD, (byte) 0xAE, (byte) 0xAF, +/* B- */ (byte) 0xB0, (byte) 0xB1, (byte) 0xB2, (byte) 0xB3, (byte) 0xB4, (byte) 0xB5, (byte) 0x40, (byte) 0x40, (byte) 0xB8, (byte) 0xB9, (byte) 0xBA, (byte) 0xBB, (byte) 0xBC, (byte) 0xBD, (byte) 0xBE, (byte) 0xBF, +/* C- */ (byte) 0x40, (byte) 0x81, (byte) 0x82, (byte) 0x83, (byte) 0x84, (byte) 0x85, (byte) 0x86, (byte) 0x87, (byte) 0x88, (byte) 0x89, (byte) 0x40, (byte) 0xCB, (byte) 0x40, (byte) 0xCD, (byte) 0x40, (byte) 0xCF, +/* D- */ (byte) 0x40, (byte) 0x91, (byte) 0x92, (byte) 0x93, (byte) 0x94, (byte) 0x95, (byte) 0x96, (byte) 0x97, (byte) 0x98, (byte) 0x99, (byte) 0xDA, (byte) 0xDB, (byte) 0xDC, (byte) 0xDD, (byte) 0xDE, (byte) 0xDF, +/* E- */ (byte) 0x40, (byte) 0x40, (byte) 0xA2, (byte) 0xA3, (byte) 0xA4, (byte) 0xA5, (byte) 0xA6, (byte) 0xA7, (byte) 0xA8, (byte) 0xA9, (byte) 0xEA, (byte) 0xEB, (byte) 0x40, (byte) 0xED, (byte) 0xEE, (byte) 0xEF, +/* F- */ (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0x40, (byte) 0xFB, (byte) 0xFC, (byte) 0xFD, (byte) 0xFE, (byte) 0x40, + }; + + + @Override + public String getLanguage() { + return "ar"; + } + + } + + static class CharsetRecog_IBM420_ar_rtl extends CharsetRecog_IBM420_ar { + private static final int[] ngrams = { + 0x4056B1, 0x4056BD, 0x405856, 0x409AB1, 0x40ABDC, 0x40B1B1, 0x40BBBD, 0x40CF56, 0x564056, 0x564640, 0x566340, 0x567540, 0x56B140, 0x56B149, 0x56B156, 0x56B158, + 0x56B163, 0x56B167, 0x56B169, 0x56B173, 0x56B178, 0x56B19A, 0x56B1AD, 0x56B1BB, 0x56B1CF, 0x56B1DC, 0x56BB40, 0x56BD40, 0x56BD63, 0x584056, 0x624056, 0x6240AB, + 0x6240B1, 0x6240BB, 0x6240CF, 0x634056, 0x734056, 0x736240, 0x754056, 0x756240, 0x784056, 0x9A4056, 0x9AB1DA, 0xABDC40, 0xB14056, 0xB16240, 0xB1DA40, 0xB1DC40, + 0xBB4056, 0xBB5640, 0xBB6240, 0xBBBD40, 0xBD4056, 0xBF4056, 0xBF5640, 0xCF56B1, 0xCFBD40, 0xDA4056, 0xDC4056, 0xDC40BB, 0xDC40CF, 0xDC6240, 0xDC7540, 0xDCBD40, + }; + + @Override + public String getName() { + return "IBM420_rtl"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = matchIBM420(det, ngrams, byteMap, (byte) 0x40); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + } + + static class CharsetRecog_IBM420_ar_ltr extends CharsetRecog_IBM420_ar { + private static final int[] ngrams = { + 0x404656, 0x4056BB, 0x4056BF, 0x406273, 0x406275, 0x4062B1, 0x4062BB, 0x4062DC, 0x406356, 0x407556, 0x4075DC, 0x40B156, 0x40BB56, 0x40BD56, 0x40BDBB, 0x40BDCF, + 0x40BDDC, 0x40DAB1, 0x40DCAB, 0x40DCB1, 0x49B156, 0x564056, 0x564058, 0x564062, 0x564063, 0x564073, 0x564075, 0x564078, 0x56409A, 0x5640B1, 0x5640BB, 0x5640BD, + 0x5640BF, 0x5640DA, 0x5640DC, 0x565840, 0x56B156, 0x56CF40, 0x58B156, 0x63B156, 0x63BD56, 0x67B156, 0x69B156, 0x73B156, 0x78B156, 0x9AB156, 0xAB4062, 0xADB156, + 0xB14062, 0xB15640, 0xB156CF, 0xB19A40, 0xB1B140, 0xBB4062, 0xBB40DC, 0xBBB156, 0xBD5640, 0xBDBB40, 0xCF4062, 0xCF40DC, 0xCFB156, 0xDAB19A, 0xDCAB40, 0xDCB156 + }; + + @Override + public String getName() { + return "IBM420_ltr"; + } + + @Override + public CharsetMatch match(CharsetDetector det) { + int confidence = matchIBM420(det, ngrams, byteMap, (byte) 0x40); + return confidence == 0 ? null : new CharsetMatch(det, this, confidence); + } + + } +} diff --git a/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecognizer.java b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecognizer.java new file mode 100644 index 000000000..1adabd330 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/icu4j/CharsetRecognizer.java @@ -0,0 +1,53 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/** + * ****************************************************************************** + * Copyright (C) 2005-2012, International Business Machines Corporation and * + * others. All Rights Reserved. * + * ****************************************************************************** + */ +package io.legado.app.lib.icu4j; + +/** + * Abstract class for recognizing a single charset. + * Part of the implementation of ICU's CharsetDetector. + *

    + * Each specific charset that can be recognized will have an instance + * of some subclass of this class. All interaction between the overall + * CharsetDetector and the stuff specific to an individual charset happens + * via the interface provided here. + *

    + * Instances of CharsetDetector DO NOT have or maintain + * state pertaining to a specific match or detect operation. + * The WILL be shared by multiple instances of CharsetDetector. + * They encapsulate const charset-specific information. + */ +abstract class CharsetRecognizer { + /** + * Get the IANA name of this charset. + * + * @return the charset name. + */ + abstract String getName(); + + /** + * Get the ISO language code for this charset. + * + * @return the language code, or null if the language cannot be determined. + */ + public String getLanguage() { + return null; + } + + /** + * Test the match of this charset with the input text data + * which is obtained via the CharsetDetector object. + * + * @param det The CharsetDetector, which contains the input text + * to be checked for being in this charset. + * @return A CharsetMatch object containing details of match + * with this charset, or null if there was no match. + */ + abstract CharsetMatch match(CharsetDetector det); + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/ActivitySource.kt b/app/src/main/java/io/legado/app/lib/permission/ActivitySource.kt new file mode 100644 index 000000000..7db53385e --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/ActivitySource.kt @@ -0,0 +1,20 @@ +package io.legado.app.lib.permission + +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity + +import java.lang.ref.WeakReference + +internal class ActivitySource(activity: AppCompatActivity) : RequestSource { + + private val actRef: WeakReference = WeakReference(activity) + + override val context: Context? + get() = actRef.get() + + override fun startActivity(intent: Intent) { + actRef.get()?.startActivity(intent) + } + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/FragmentSource.kt b/app/src/main/java/io/legado/app/lib/permission/FragmentSource.kt new file mode 100644 index 000000000..3b6bd8830 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/FragmentSource.kt @@ -0,0 +1,19 @@ +package io.legado.app.lib.permission + +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment + +import java.lang.ref.WeakReference + +internal class FragmentSource(fragment: Fragment) : RequestSource { + + private val fragRef: WeakReference = WeakReference(fragment) + + override val context: Context? + get() = fragRef.get()?.requireContext() + + override fun startActivity(intent: Intent) { + fragRef.get()?.startActivity(intent) + } +} diff --git a/app/src/main/java/io/legado/app/lib/permission/OnPermissionsDeniedCallback.kt b/app/src/main/java/io/legado/app/lib/permission/OnPermissionsDeniedCallback.kt new file mode 100644 index 000000000..0cc6b715b --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/OnPermissionsDeniedCallback.kt @@ -0,0 +1,7 @@ +package io.legado.app.lib.permission + +interface OnPermissionsDeniedCallback { + + fun onPermissionsDenied(deniedPermissions: Array) + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/OnPermissionsGrantedCallback.kt b/app/src/main/java/io/legado/app/lib/permission/OnPermissionsGrantedCallback.kt new file mode 100644 index 000000000..fe43e947e --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/OnPermissionsGrantedCallback.kt @@ -0,0 +1,7 @@ +package io.legado.app.lib.permission + +interface OnPermissionsGrantedCallback { + + fun onPermissionsGranted() + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/OnPermissionsResultCallback.kt b/app/src/main/java/io/legado/app/lib/permission/OnPermissionsResultCallback.kt new file mode 100644 index 000000000..41ae2f5fc --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/OnPermissionsResultCallback.kt @@ -0,0 +1,9 @@ +package io.legado.app.lib.permission + +interface OnPermissionsResultCallback { + + fun onPermissionsGranted() + + fun onPermissionsDenied(deniedPermissions: Array) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/permission/OnRequestPermissionsResultCallback.kt b/app/src/main/java/io/legado/app/lib/permission/OnRequestPermissionsResultCallback.kt new file mode 100644 index 000000000..2934e4c77 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/OnRequestPermissionsResultCallback.kt @@ -0,0 +1,8 @@ +package io.legado.app.lib.permission + +interface OnRequestPermissionsResultCallback { + + fun onRequestPermissionsResult(permissions: Array, grantResults: IntArray) + + fun onSettingActivityResult() +} diff --git a/app/src/main/java/io/legado/app/lib/permission/PermissionActivity.kt b/app/src/main/java/io/legado/app/lib/permission/PermissionActivity.kt new file mode 100644 index 000000000..887ca158a --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/PermissionActivity.kt @@ -0,0 +1,84 @@ +package io.legado.app.lib.permission + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.KeyEvent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import io.legado.app.R +import io.legado.app.utils.toastOnUi + +class PermissionActivity : AppCompatActivity() { + + private val settingActivityResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + RequestPlugins.sRequestCallback?.onSettingActivityResult() + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + when (intent.getIntExtra(KEY_INPUT_REQUEST_TYPE, Request.TYPE_REQUEST_PERMISSION)) { + Request.TYPE_REQUEST_PERMISSION//权限请求 + -> { + val requestCode = intent.getIntExtra(KEY_INPUT_PERMISSIONS_CODE, 1000) + val permissions = intent.getStringArrayExtra(KEY_INPUT_PERMISSIONS) + if (permissions != null) { + ActivityCompat.requestPermissions(this, permissions, requestCode) + } else { + finish() + } + } + Request.TYPE_REQUEST_SETTING//跳转到设置界面 + -> try { + val settingIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + settingIntent.data = Uri.fromParts("package", packageName, null) + settingActivityResult.launch(settingIntent) + } catch (e: Exception) { + toastOnUi(R.string.tip_cannot_jump_setting_page) + finish() + } + + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + RequestPlugins.sRequestCallback?.onRequestPermissionsResult( + permissions, + grantResults + ) + finish() + } + + override fun startActivity(intent: Intent) { + super.startActivity(intent) + overridePendingTransition(0, 0) + } + + override fun finish() { + super.finish() + overridePendingTransition(0, 0) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + return if (keyCode == KeyEvent.KEYCODE_BACK) { + true + } else super.onKeyDown(keyCode, event) + } + + companion object { + + const val KEY_INPUT_REQUEST_TYPE = "KEY_INPUT_REQUEST_TYPE" + const val KEY_INPUT_PERMISSIONS_CODE = "KEY_INPUT_PERMISSIONS_CODE" + const val KEY_INPUT_PERMISSIONS = "KEY_INPUT_PERMISSIONS" + } +} diff --git a/app/src/main/java/io/legado/app/lib/permission/Permissions.kt b/app/src/main/java/io/legado/app/lib/permission/Permissions.kt new file mode 100644 index 000000000..5dee84603 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/Permissions.kt @@ -0,0 +1,74 @@ +package io.legado.app.lib.permission + +@Suppress("unused") +object Permissions { + + const val READ_CALENDAR = "android.permission.READ_CALENDAR" + const val WRITE_CALENDAR = "android.permission.WRITE_CALENDAR" + + const val CAMERA = "android.permission.CAMERA" + + const val READ_CONTACTS = "android.permission.READ_CONTACTS" + const val WRITE_CONTACTS = "android.permission.WRITE_CONTACTS" + const val GET_ACCOUNTS = "android.permission.GET_ACCOUNTS" + + const val ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION" + const val ACCESS_COARSE_LOCATION = "android.permission.ACCESS_COARSE_LOCATION" + + const val RECORD_AUDIO = "android.permission.RECORD_AUDIO" + + const val READ_PHONE_STATE = "android.permission.READ_PHONE_STATE" + const val CALL_PHONE = "android.permission.CALL_PHONE" + const val READ_CALL_LOG = "android.permission.READ_CALL_LOG" + const val WRITE_CALL_LOG = "android.permission.WRITE_CALL_LOG" + const val ADD_VOICEMAIL = "com.android.voicemail.permission.ADD_VOICEMAIL" + const val USE_SIP = "android.permission.USE_SIP" + const val PROCESS_OUTGOING_CALLS = "android.permission.PROCESS_OUTGOING_CALLS" + + const val BODY_SENSORS = "android.permission.BODY_SENSORS" + + const val SEND_SMS = "android.permission.SEND_SMS" + const val RECEIVE_SMS = "android.permission.RECEIVE_SMS" + const val READ_SMS = "android.permission.READ_SMS" + const val RECEIVE_WAP_PUSH = "android.permission.RECEIVE_WAP_PUSH" + const val RECEIVE_MMS = "android.permission.RECEIVE_MMS" + + const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE" + const val WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE" + + const val ACCESS_MEDIA_LOCATION = "android.permission.ACCESS_MEDIA_LOCATION" + + object Group { + val STORAGE = arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE) + + val CAMERA = arrayOf(Permissions.CAMERA) + + val CALENDAR = arrayOf(READ_CALENDAR, WRITE_CALENDAR) + + val CONTACTS = arrayOf(READ_CONTACTS, WRITE_CONTACTS, GET_ACCOUNTS) + + val LOCATION = arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) + + val MICROPHONE = arrayOf(RECORD_AUDIO) + + val PHONE = arrayOf( + READ_PHONE_STATE, + CALL_PHONE, + READ_CALL_LOG, + WRITE_CALL_LOG, + ADD_VOICEMAIL, + USE_SIP, + PROCESS_OUTGOING_CALLS + ) + + val SENSORS = arrayOf(BODY_SENSORS) + + val SMS = arrayOf( + SEND_SMS, + RECEIVE_SMS, + READ_SMS, + RECEIVE_WAP_PUSH, + RECEIVE_MMS + ) + } +} diff --git a/app/src/main/java/io/legado/app/lib/permission/PermissionsCompat.kt b/app/src/main/java/io/legado/app/lib/permission/PermissionsCompat.kt new file mode 100644 index 000000000..f127c77d6 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/PermissionsCompat.kt @@ -0,0 +1,74 @@ +package io.legado.app.lib.permission + +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment + +@Suppress("unused") +class PermissionsCompat private constructor() { + + private var request: Request? = null + + fun request() { + RequestManager.pushRequest(request) + } + + class Builder { + private val request: Request + + constructor(activity: AppCompatActivity) { + request = Request(activity) + } + + constructor(fragment: Fragment) { + request = Request(fragment) + } + + fun addPermissions(vararg permissions: String): Builder { + request.addPermissions(*permissions) + return this + } + + fun onGranted(callback: () -> Unit): Builder { + request.setOnGrantedCallback(object : OnPermissionsGrantedCallback { + override fun onPermissionsGranted() { + callback() + } + }) + return this + } + + fun onDenied(callback: (deniedPermissions: Array) -> Unit): Builder { + request.setOnDeniedCallback(object : OnPermissionsDeniedCallback { + override fun onPermissionsDenied(deniedPermissions: Array) { + callback(deniedPermissions) + } + }) + return this + } + + fun rationale(rationale: CharSequence): Builder { + request.setRationale(rationale) + return this + } + + fun rationale(@StringRes resId: Int): Builder { + request.setRationale(resId) + return this + } + + fun build(): PermissionsCompat { + val compat = PermissionsCompat() + compat.request = request + return compat + } + + fun request(): PermissionsCompat { + val compat = build() + compat.request = request + compat.request() + return compat + } + } + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/Request.kt b/app/src/main/java/io/legado/app/lib/permission/Request.kt new file mode 100644 index 000000000..a5f3acacd --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/Request.kt @@ -0,0 +1,196 @@ +package io.legado.app.lib.permission + +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import io.legado.app.R +import io.legado.app.utils.startActivity +import java.util.* + +@Suppress("MemberVisibilityCanBePrivate") +internal class Request : OnRequestPermissionsResultCallback { + + internal val requestTime: Long + private var requestCode: Int = TYPE_REQUEST_PERMISSION + private var source: RequestSource? = null + private var permissions: ArrayList? = null + private var grantedCallback: OnPermissionsGrantedCallback? = null + private var deniedCallback: OnPermissionsDeniedCallback? = null + private var rationaleResId: Int = 0 + private var rationale: CharSequence? = null + + private var rationaleDialog: AlertDialog? = null + + private val deniedPermissions: Array? + get() { + return getDeniedPermissions(this.permissions?.toTypedArray()) + } + + constructor(activity: AppCompatActivity) { + source = ActivitySource(activity) + permissions = ArrayList() + requestTime = System.currentTimeMillis() + } + + constructor(fragment: Fragment) { + source = FragmentSource(fragment) + permissions = ArrayList() + requestTime = System.currentTimeMillis() + } + + fun addPermissions(vararg permissions: String) { + this.permissions?.addAll(listOf(*permissions)) + } + + fun setOnGrantedCallback(callback: OnPermissionsGrantedCallback) { + grantedCallback = callback + } + + fun setOnDeniedCallback(callback: OnPermissionsDeniedCallback) { + deniedCallback = callback + } + + fun setRationale(@StringRes resId: Int) { + rationaleResId = resId + rationale = null + } + + fun setRationale(rationale: CharSequence) { + this.rationale = rationale + rationaleResId = 0 + } + + fun start() { + RequestPlugins.setOnRequestPermissionsCallback(this) + + val deniedPermissions = deniedPermissions + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (deniedPermissions == null) { + onPermissionsGranted() + } else { + val rationale = + if (rationaleResId != 0) source?.context?.getText(rationaleResId) else rationale + if (rationale != null) { + showSettingDialog(rationale) { + onPermissionsDenied(deniedPermissions) + } + } else { + onPermissionsDenied(deniedPermissions) + } + } + } else { + if (deniedPermissions != null) { + source?.context?.startActivity { + putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_PERMISSION) + putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode) + putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions) + } + } else { + onPermissionsGranted() + } + } + } + + fun clear() { + grantedCallback = null + deniedCallback = null + } + + fun getDeniedPermissions(permissions: Array?): Array? { + if (permissions != null) { + val deniedPermissionList = ArrayList() + for (permission in permissions) { + if (source?.context?.let { + ContextCompat.checkSelfPermission( + it, + permission + ) + } != PackageManager.PERMISSION_GRANTED + ) { + deniedPermissionList.add(permission) + } + } + val size = deniedPermissionList.size + if (size > 0) { + return deniedPermissionList.toTypedArray() + } + } + return null + } + + private fun showSettingDialog(rationale: CharSequence, cancel: () -> Unit) { + rationaleDialog?.dismiss() + source?.context?.let { + runCatching { + rationaleDialog = AlertDialog.Builder(it) + .setTitle(R.string.dialog_title) + .setMessage(rationale) + .setPositiveButton(R.string.dialog_setting) { _, _ -> + it.startActivity { + putExtra( + PermissionActivity.KEY_INPUT_REQUEST_TYPE, + TYPE_REQUEST_SETTING + ) + } + } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cancel() } + .show() + } + } + } + + private fun onPermissionsGranted() { + try { + grantedCallback?.onPermissionsGranted() + } catch (ignore: Exception) { + } + + RequestPlugins.sResultCallback?.onPermissionsGranted() + } + + private fun onPermissionsDenied(deniedPermissions: Array) { + try { + deniedCallback?.onPermissionsDenied(deniedPermissions) + } catch (ignore: Exception) { + } + + RequestPlugins.sResultCallback?.onPermissionsDenied(deniedPermissions) + } + + override fun onRequestPermissionsResult( + permissions: Array, + grantResults: IntArray + ) { + val deniedPermissions = getDeniedPermissions(permissions) + if (deniedPermissions != null) { + val rationale = + if (rationaleResId != 0) source?.context?.getText(rationaleResId) else rationale + if (rationale != null) { + showSettingDialog(rationale) { onPermissionsDenied(deniedPermissions) } + } else { + onPermissionsDenied(deniedPermissions) + } + } else { + onPermissionsGranted() + } + } + + override fun onSettingActivityResult() { + val deniedPermissions = deniedPermissions + if (deniedPermissions == null) { + onPermissionsGranted() + } else { + onPermissionsDenied(deniedPermissions) + } + } + + companion object { + const val TYPE_REQUEST_PERMISSION = 1 + const val TYPE_REQUEST_SETTING = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/permission/RequestManager.kt b/app/src/main/java/io/legado/app/lib/permission/RequestManager.kt new file mode 100644 index 000000000..2bc6832c7 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/RequestManager.kt @@ -0,0 +1,69 @@ +package io.legado.app.lib.permission + +import android.os.Handler +import android.os.Looper +import java.util.* + +internal object RequestManager : OnPermissionsResultCallback { + + private var requests: Stack? = null + private var request: Request? = null + + private val handler = Handler(Looper.getMainLooper()) + + private val requestRunnable = Runnable { + request?.start() + } + + private val isCurrentRequestInvalid: Boolean + get() = request?.let { System.currentTimeMillis() - it.requestTime > 5 * 1000L } ?: true + + init { + RequestPlugins.setOnPermissionsResultCallback(this) + } + + fun pushRequest(request: Request?) { + if (request == null) return + + if (requests == null) { + requests = Stack() + } + + requests?.let { + val index = it.indexOf(request) + if (index >= 0) { + val to = it.size - 1 + if (index != to) { + @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") + Collections.swap(requests, index, to) + } + } else { + it.push(request) + } + + if (!it.empty() && isCurrentRequestInvalid) { + this.request = it.pop() + handler.post(requestRunnable) + } + } + } + + private fun startNextRequest() { + request?.clear() + request = null + + requests?.let { + request = if (it.empty()) null else it.pop() + request?.let { handler.post(requestRunnable) } + } + } + + override fun onPermissionsGranted() { + startNextRequest() + } + + override fun onPermissionsDenied(deniedPermissions: Array) { + startNextRequest() + } + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/RequestPlugins.kt b/app/src/main/java/io/legado/app/lib/permission/RequestPlugins.kt new file mode 100644 index 000000000..59bf0f0eb --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/RequestPlugins.kt @@ -0,0 +1,20 @@ +package io.legado.app.lib.permission + +internal object RequestPlugins { + + @Volatile + var sRequestCallback: OnRequestPermissionsResultCallback? = null + + @Volatile + var sResultCallback: OnPermissionsResultCallback? = null + + fun setOnRequestPermissionsCallback(callback: OnRequestPermissionsResultCallback) { + sRequestCallback = callback + } + + fun setOnPermissionsResultCallback(callback: OnPermissionsResultCallback) { + sResultCallback = callback + } + + +} diff --git a/app/src/main/java/io/legado/app/lib/permission/RequestSource.kt b/app/src/main/java/io/legado/app/lib/permission/RequestSource.kt new file mode 100644 index 000000000..3e029805f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/permission/RequestSource.kt @@ -0,0 +1,12 @@ +package io.legado.app.lib.permission + +import android.content.Context +import android.content.Intent + +interface RequestSource { + + val context: Context? + + fun startActivity(intent: Intent) + +} diff --git a/app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt b/app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt new file mode 100644 index 000000000..6b7f6f304 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt @@ -0,0 +1,127 @@ +@file:Suppress("unused") + +package io.legado.app.lib.theme + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import io.legado.app.R +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.dp + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +@ColorInt +fun Context.getPrimaryTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.primary_text_default_material_light) + } else ContextCompat.getColor(this, R.color.primary_text_default_material_dark) +} + +@ColorInt +fun Context.getSecondaryTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.secondary_text_default_material_light) + } else { + ContextCompat.getColor(this, R.color.secondary_text_default_material_dark) + } +} + +@ColorInt +fun Context.getPrimaryDisabledTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.primary_text_disabled_material_light) + } else { + ContextCompat.getColor(this, R.color.primary_text_disabled_material_dark) + } +} + +@ColorInt +fun Context.getSecondaryDisabledTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.secondary_text_disabled_material_light) + } else { + ContextCompat.getColor(this, R.color.secondary_text_disabled_material_dark) + } +} + +val Context.primaryColor: Int + get() = ThemeStore.primaryColor(this) + +val Context.primaryColorDark: Int + get() = ThemeStore.primaryColorDark(this) + +val Context.accentColor: Int + get() = ThemeStore.accentColor(this) + +val Context.backgroundColor: Int + get() = ThemeStore.backgroundColor(this) + +val Context.bottomBackground: Int + get() = ThemeStore.bottomBackground(this) + +val Context.primaryTextColor: Int + get() = getPrimaryTextColor(isDarkTheme) + +val Context.secondaryTextColor: Int + get() = getSecondaryTextColor(isDarkTheme) + +val Context.primaryDisabledTextColor: Int + get() = getPrimaryDisabledTextColor(isDarkTheme) + +val Context.secondaryDisabledTextColor: Int + get() = getSecondaryDisabledTextColor(isDarkTheme) + +val Fragment.primaryColor: Int + get() = ThemeStore.primaryColor(requireContext()) + +val Fragment.primaryColorDark: Int + get() = ThemeStore.primaryColorDark(requireContext()) + +val Fragment.accentColor: Int + get() = ThemeStore.accentColor(requireContext()) + +val Fragment.backgroundColor: Int + get() = ThemeStore.backgroundColor(requireContext()) + +val Fragment.bottomBackground: Int + get() = ThemeStore.bottomBackground(requireContext()) + +val Fragment.primaryTextColor: Int + get() = requireContext().getPrimaryTextColor(isDarkTheme) + +val Fragment.secondaryTextColor: Int + get() = requireContext().getSecondaryTextColor(isDarkTheme) + +val Fragment.primaryDisabledTextColor: Int + get() = requireContext().getPrimaryDisabledTextColor(isDarkTheme) + +val Fragment.secondaryDisabledTextColor: Int + get() = requireContext().getSecondaryDisabledTextColor(isDarkTheme) + +val Context.buttonDisabledColor: Int + get() = if (isDarkTheme) { + ContextCompat.getColor(this, R.color.md_dark_disabled) + } else { + ContextCompat.getColor(this, R.color.md_light_disabled) + } + +val Context.isDarkTheme: Boolean + get() = ColorUtils.isColorLight(ThemeStore.primaryColor(this)) + +val Fragment.isDarkTheme: Boolean + get() = requireContext().isDarkTheme + +val Context.elevation: Float + get() = ThemeStore.elevation(this) + +val Context.filletBackground: GradientDrawable + get() { + val background = GradientDrawable() + background.cornerRadius = 3F.dp + background.setColor(backgroundColor) + return background + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/Selector.kt b/app/src/main/java/io/legado/app/lib/theme/Selector.kt new file mode 100644 index 000000000..f9985528f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/Selector.kt @@ -0,0 +1,450 @@ +package io.legado.app.lib.theme + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import androidx.annotation.DrawableRes +import androidx.annotation.IntDef +import androidx.core.content.ContextCompat + +@Suppress("unused") +object Selector { + fun shapeBuild(): ShapeSelector { + return ShapeSelector() + } + + fun colorBuild(): ColorSelector { + return ColorSelector() + } + + fun drawableBuild(): DrawableSelector { + return DrawableSelector() + } + + /** + * 形状ShapeSelector + * + * @author hjy + * created at 2017/12/11 22:26 + */ + class ShapeSelector { + + private var mShape: Int = 0 //the shape of background + private var mDefaultBgColor: Int = 0 //default background color + private var mDisabledBgColor: Int = 0 //state_enabled = false + private var mPressedBgColor: Int = 0 //state_pressed = true + private var mSelectedBgColor: Int = 0 //state_selected = true + private var mFocusedBgColor: Int = 0 //state_focused = true + private var mCheckedBgColor: Int = 0 //state_checked = true + private var mStrokeWidth: Int = 0 //stroke width in pixel + private var mDefaultStrokeColor: Int = 0 //default stroke color + private var mDisabledStrokeColor: Int = 0 //state_enabled = false + private var mPressedStrokeColor: Int = 0 //state_pressed = true + private var mSelectedStrokeColor: Int = 0 //state_selected = true + private var mFocusedStrokeColor: Int = 0 //state_focused = true + private var mCheckedStrokeColor: Int = 0 //state_checked = true + private var mCornerRadius: Int = 0 //corner radius + + private var hasSetDisabledBgColor = false + private var hasSetPressedBgColor = false + private var hasSetSelectedBgColor = false + private val hasSetFocusedBgColor = false + private var hasSetCheckedBgColor = false + + private var hasSetDisabledStrokeColor = false + private var hasSetPressedStrokeColor = false + private var hasSetSelectedStrokeColor = false + private var hasSetFocusedStrokeColor = false + private var hasSetCheckedStrokeColor = false + + @IntDef( + GradientDrawable.RECTANGLE, + GradientDrawable.OVAL, + GradientDrawable.LINE, + GradientDrawable.RING + ) + private annotation class Shape + + init { + //initialize default values + mShape = GradientDrawable.RECTANGLE + mDefaultBgColor = Color.TRANSPARENT + mDisabledBgColor = Color.TRANSPARENT + mPressedBgColor = Color.TRANSPARENT + mSelectedBgColor = Color.TRANSPARENT + mFocusedBgColor = Color.TRANSPARENT + mStrokeWidth = 0 + mDefaultStrokeColor = Color.TRANSPARENT + mDisabledStrokeColor = Color.TRANSPARENT + mPressedStrokeColor = Color.TRANSPARENT + mSelectedStrokeColor = Color.TRANSPARENT + mFocusedStrokeColor = Color.TRANSPARENT + mCornerRadius = 0 + } + + fun setShape(@Shape shape: Int): ShapeSelector { + mShape = shape + return this + } + + fun setDefaultBgColor(@ColorInt color: Int): ShapeSelector { + mDefaultBgColor = color + if (!hasSetDisabledBgColor) + mDisabledBgColor = color + if (!hasSetPressedBgColor) + mPressedBgColor = color + if (!hasSetSelectedBgColor) + mSelectedBgColor = color + if (!hasSetFocusedBgColor) + mFocusedBgColor = color + return this + } + + fun setDisabledBgColor(@ColorInt color: Int): ShapeSelector { + mDisabledBgColor = color + hasSetDisabledBgColor = true + return this + } + + fun setPressedBgColor(@ColorInt color: Int): ShapeSelector { + mPressedBgColor = color + hasSetPressedBgColor = true + return this + } + + fun setSelectedBgColor(@ColorInt color: Int): ShapeSelector { + mSelectedBgColor = color + hasSetSelectedBgColor = true + return this + } + + fun setFocusedBgColor(@ColorInt color: Int): ShapeSelector { + mFocusedBgColor = color + hasSetPressedBgColor = true + return this + } + + fun setCheckedBgColor(@ColorInt color: Int): ShapeSelector { + mCheckedBgColor = color + hasSetCheckedBgColor = true + return this + } + + fun setStrokeWidth(@Dimension width: Int): ShapeSelector { + mStrokeWidth = width + return this + } + + fun setDefaultStrokeColor(@ColorInt color: Int): ShapeSelector { + mDefaultStrokeColor = color + if (!hasSetDisabledStrokeColor) + mDisabledStrokeColor = color + if (!hasSetPressedStrokeColor) + mPressedStrokeColor = color + if (!hasSetSelectedStrokeColor) + mSelectedStrokeColor = color + if (!hasSetFocusedStrokeColor) + mFocusedStrokeColor = color + return this + } + + fun setDisabledStrokeColor(@ColorInt color: Int): ShapeSelector { + mDisabledStrokeColor = color + hasSetDisabledStrokeColor = true + return this + } + + fun setPressedStrokeColor(@ColorInt color: Int): ShapeSelector { + mPressedStrokeColor = color + hasSetPressedStrokeColor = true + return this + } + + fun setSelectedStrokeColor(@ColorInt color: Int): ShapeSelector { + mSelectedStrokeColor = color + hasSetSelectedStrokeColor = true + return this + } + + fun setCheckedStrokeColor(@ColorInt color: Int): ShapeSelector { + mCheckedStrokeColor = color + hasSetCheckedStrokeColor = true + return this + } + + fun setFocusedStrokeColor(@ColorInt color: Int): ShapeSelector { + mFocusedStrokeColor = color + hasSetFocusedStrokeColor = true + return this + } + + fun setCornerRadius(@Dimension radius: Int): ShapeSelector { + mCornerRadius = radius + return this + } + + fun create(): StateListDrawable { + val selector = StateListDrawable() + + //enabled = false + if (hasSetDisabledBgColor || hasSetDisabledStrokeColor) { + val disabledShape = getItemShape( + mShape, mCornerRadius, + mDisabledBgColor, mStrokeWidth, mDisabledStrokeColor + ) + selector.addState(intArrayOf(-android.R.attr.state_enabled), disabledShape) + } + + //pressed = true + if (hasSetPressedBgColor || hasSetPressedStrokeColor) { + val pressedShape = getItemShape( + mShape, mCornerRadius, + mPressedBgColor, mStrokeWidth, mPressedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_pressed), pressedShape) + } + + //selected = true + if (hasSetSelectedBgColor || hasSetSelectedStrokeColor) { + val selectedShape = getItemShape( + mShape, mCornerRadius, + mSelectedBgColor, mStrokeWidth, mSelectedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_selected), selectedShape) + } + + //focused = true + if (hasSetFocusedBgColor || hasSetFocusedStrokeColor) { + val focusedShape = getItemShape( + mShape, mCornerRadius, + mFocusedBgColor, mStrokeWidth, mFocusedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_focused), focusedShape) + } + + //checked = true + if (hasSetCheckedBgColor || hasSetCheckedStrokeColor) { + val checkedShape = getItemShape( + mShape, mCornerRadius, + mCheckedBgColor, mStrokeWidth, mCheckedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_checked), checkedShape) + } + + //default + val defaultShape = getItemShape( + mShape, mCornerRadius, + mDefaultBgColor, mStrokeWidth, mDefaultStrokeColor + ) + selector.addState(intArrayOf(), defaultShape) + + return selector + } + + private fun getItemShape( + shape: Int, cornerRadius: Int, + solidColor: Int, strokeWidth: Int, strokeColor: Int + ): GradientDrawable { + val drawable = GradientDrawable() + drawable.shape = shape + drawable.setStroke(strokeWidth, strokeColor) + drawable.cornerRadius = cornerRadius.toFloat() + drawable.setColor(solidColor) + return drawable + } + } + + /** + * 资源DrawableSelector + * + * @author hjy + * created at 2017/12/11 22:34 + */ + @Suppress("MemberVisibilityCanBePrivate") + class DrawableSelector { + + private var mDefaultDrawable: Drawable? = null + private var mDisabledDrawable: Drawable? = null + private var mPressedDrawable: Drawable? = null + private var mSelectedDrawable: Drawable? = null + private var mFocusedDrawable: Drawable? = null + + private var hasSetDisabledDrawable = false + private var hasSetPressedDrawable = false + private var hasSetSelectedDrawable = false + private var hasSetFocusedDrawable = false + + init { + mDefaultDrawable = ColorDrawable(Color.TRANSPARENT) + } + + fun setDefaultDrawable(drawable: Drawable?): DrawableSelector { + mDefaultDrawable = drawable + if (!hasSetDisabledDrawable) + mDisabledDrawable = drawable + if (!hasSetPressedDrawable) + mPressedDrawable = drawable + if (!hasSetSelectedDrawable) + mSelectedDrawable = drawable + if (!hasSetFocusedDrawable) + mFocusedDrawable = drawable + return this + } + + fun setDisabledDrawable(drawable: Drawable?): DrawableSelector { + mDisabledDrawable = drawable + hasSetDisabledDrawable = true + return this + } + + fun setPressedDrawable(drawable: Drawable?): DrawableSelector { + mPressedDrawable = drawable + hasSetPressedDrawable = true + return this + } + + fun setSelectedDrawable(drawable: Drawable?): DrawableSelector { + mSelectedDrawable = drawable + hasSetSelectedDrawable = true + return this + } + + fun setFocusedDrawable(drawable: Drawable?): DrawableSelector { + mFocusedDrawable = drawable + hasSetFocusedDrawable = true + return this + } + + fun create(): StateListDrawable { + val selector = StateListDrawable() + if (hasSetDisabledDrawable) + selector.addState(intArrayOf(-android.R.attr.state_enabled), mDisabledDrawable) + if (hasSetPressedDrawable) + selector.addState(intArrayOf(android.R.attr.state_pressed), mPressedDrawable) + if (hasSetSelectedDrawable) + selector.addState(intArrayOf(android.R.attr.state_selected), mSelectedDrawable) + if (hasSetFocusedDrawable) + selector.addState(intArrayOf(android.R.attr.state_focused), mFocusedDrawable) + selector.addState(intArrayOf(), mDefaultDrawable) + return selector + } + + fun setDefaultDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setDefaultDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setDisabledDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setDisabledDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setPressedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setPressedDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setSelectedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setSelectedDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setFocusedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setFocusedDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + } + + /** + * 颜色ColorSelector + * + * @author hjy + * created at 2017/12/11 22:26 + */ + class ColorSelector { + + private var mDefaultColor: Int = 0 + private var mDisabledColor: Int = 0 + private var mPressedColor: Int = 0 + private var mSelectedColor: Int = 0 + private var mFocusedColor: Int = 0 + private var mCheckedColor: Int = 0 + + private var hasSetDisabledColor = false + private var hasSetPressedColor = false + private var hasSetSelectedColor = false + private var hasSetFocusedColor = false + private var hasSetCheckedColor = false + + init { + mDefaultColor = Color.BLACK + mDisabledColor = Color.GRAY + mPressedColor = Color.BLACK + mSelectedColor = Color.BLACK + mFocusedColor = Color.BLACK + } + + fun setDefaultColor(@ColorInt color: Int): ColorSelector { + mDefaultColor = color + if (!hasSetDisabledColor) + mDisabledColor = color + if (!hasSetPressedColor) + mPressedColor = color + if (!hasSetSelectedColor) + mSelectedColor = color + if (!hasSetFocusedColor) + mFocusedColor = color + return this + } + + fun setDisabledColor(@ColorInt color: Int): ColorSelector { + mDisabledColor = color + hasSetDisabledColor = true + return this + } + + fun setPressedColor(@ColorInt color: Int): ColorSelector { + mPressedColor = color + hasSetPressedColor = true + return this + } + + fun setSelectedColor(@ColorInt color: Int): ColorSelector { + mSelectedColor = color + hasSetSelectedColor = true + return this + } + + fun setFocusedColor(@ColorInt color: Int): ColorSelector { + mFocusedColor = color + hasSetFocusedColor = true + return this + } + + fun setCheckedColor(@ColorInt color: Int): ColorSelector { + mCheckedColor = color + hasSetCheckedColor = true + return this + } + + fun create(): ColorStateList { + val colors = intArrayOf( + if (hasSetDisabledColor) mDisabledColor else mDefaultColor, + if (hasSetPressedColor) mPressedColor else mDefaultColor, + if (hasSetSelectedColor) mSelectedColor else mDefaultColor, + if (hasSetFocusedColor) mFocusedColor else mDefaultColor, + if (hasSetCheckedColor) mCheckedColor else mDefaultColor, + mDefaultColor + ) + val states = arrayOfNulls(6) + states[0] = intArrayOf(-android.R.attr.state_enabled) + states[1] = intArrayOf(android.R.attr.state_pressed) + states[2] = intArrayOf(android.R.attr.state_selected) + states[3] = intArrayOf(android.R.attr.state_focused) + states[4] = intArrayOf(android.R.attr.state_checked) + states[5] = intArrayOf() + return ColorStateList(states, colors) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt new file mode 100644 index 000000000..5c5c27aac --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt @@ -0,0 +1,344 @@ +package io.legado.app.lib.theme + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import androidx.annotation.AttrRes +import androidx.annotation.CheckResult +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import io.legado.app.R +import io.legado.app.utils.ColorUtils +import splitties.init.appCtx + +/** + * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) + */ +@Suppress("unused") +class ThemeStore @SuppressLint("CommitPrefEdits") +private constructor(private val mContext: Context) : ThemeStoreInterface { + + private val mEditor = prefs(mContext).edit() + + override fun primaryColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_PRIMARY_COLOR, color) + if (autoGeneratePrimaryDark(mContext)) + primaryColorDark(ColorUtils.darkenColor(color)) + return this + } + + override fun primaryColorRes(@ColorRes colorRes: Int): ThemeStore { + return primaryColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun primaryColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return primaryColor(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun primaryColorDark(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK, color) + return this + } + + override fun primaryColorDarkRes(@ColorRes colorRes: Int): ThemeStore { + return primaryColorDark(ContextCompat.getColor(mContext, colorRes)) + } + + override fun primaryColorDarkAttr(@AttrRes colorAttr: Int): ThemeStore { + return primaryColorDark(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun accentColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_ACCENT_COLOR, color) + return this + } + + override fun accentColorRes(@ColorRes colorRes: Int): ThemeStore { + return accentColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun accentColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return accentColor(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun statusBarColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, color) + return this + } + + override fun statusBarColorRes(@ColorRes colorRes: Int): ThemeStore { + return statusBarColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun statusBarColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return statusBarColor(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun navigationBarColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR, color) + return this + } + + override fun navigationBarColorRes(@ColorRes colorRes: Int): ThemeStore { + return navigationBarColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun navigationBarColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return navigationBarColor(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorPrimary(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY, color) + return this + } + + override fun textColorPrimaryRes(@ColorRes colorRes: Int): ThemeStore { + return textColorPrimary(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorPrimaryAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorPrimary(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorPrimaryInverse(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE, color) + return this + } + + override fun textColorPrimaryInverseRes(@ColorRes colorRes: Int): ThemeStore { + return textColorPrimaryInverse(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorPrimaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorPrimaryInverse(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorSecondary(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY, color) + return this + } + + override fun textColorSecondaryRes(@ColorRes colorRes: Int): ThemeStore { + return textColorSecondary(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorSecondaryAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorSecondary(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorSecondaryInverse(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE, color) + return this + } + + override fun textColorSecondaryInverseRes(@ColorRes colorRes: Int): ThemeStore { + return textColorSecondaryInverse(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorSecondaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorSecondaryInverse(ThemeUtils.resolveColor(mContext, colorAttr)) + } + + override fun backgroundColor(color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, color) + return this + } + + override fun bottomBackground(color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_BOTTOM_BACKGROUND, color) + return this + } + + override fun autoGeneratePrimaryDark(autoGenerate: Boolean): ThemeStore { + mEditor.putBoolean(ThemeStorePrefKeys.KEY_AUTO_GENERATE_PRIMARYDARK, autoGenerate) + return this + } + + // Commit method + + override fun apply() { + mEditor.putLong(ThemeStorePrefKeys.VALUES_CHANGED, System.currentTimeMillis()) + .putBoolean(ThemeStorePrefKeys.IS_CONFIGURED_KEY, true) + .apply() + } + + companion object { + + fun editTheme(context: Context): ThemeStore { + return ThemeStore(context) + } + + // Static getters + + @CheckResult + internal fun prefs(context: Context): SharedPreferences { + return context.getSharedPreferences( + ThemeStorePrefKeys.CONFIG_PREFS_KEY_DEFAULT, + Context.MODE_PRIVATE + ) + } + + fun markChanged(context: Context) { + ThemeStore(context).apply() + } + + @CheckResult + @ColorInt + fun primaryColor(context: Context = appCtx): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_PRIMARY_COLOR, + ThemeUtils.resolveColor(context, R.attr.colorPrimary, Color.parseColor("#455A64")) + ) + } + + @CheckResult + @ColorInt + fun primaryColorDark(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK, + ThemeUtils.resolveColor( + context, + R.attr.colorPrimaryDark, + Color.parseColor("#37474F") + ) + ) + } + + @CheckResult + @ColorInt + fun accentColor(context: Context = appCtx): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_ACCENT_COLOR, + ThemeUtils.resolveColor(context, R.attr.colorAccent, Color.parseColor("#263238")) + ) + } + + @CheckResult + @ColorInt + fun statusBarColor(context: Context, transparent: Boolean): Int { + return if (transparent) { + prefs(context).getInt( + ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, + primaryColor(context) + ) + } else { + prefs(context).getInt( + ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, + primaryColorDark(context) + ) + } + } + + @CheckResult + @ColorInt + fun navigationBarColor(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR, + bottomBackground(context) + ) + } + + @CheckResult + @ColorInt + fun textColorPrimary(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY, + ThemeUtils.resolveColor(context, android.R.attr.textColorPrimary) + ) + } + + @CheckResult + @ColorInt + fun textColorPrimaryInverse(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE, + ThemeUtils.resolveColor(context, android.R.attr.textColorPrimaryInverse) + ) + } + + @CheckResult + @ColorInt + fun textColorSecondary(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY, + ThemeUtils.resolveColor(context, android.R.attr.textColorSecondary) + ) + } + + @CheckResult + @ColorInt + fun textColorSecondaryInverse(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE, + ThemeUtils.resolveColor(context, android.R.attr.textColorSecondaryInverse) + ) + } + + @CheckResult + @ColorInt + fun backgroundColor(context: Context = appCtx): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, + ThemeUtils.resolveColor(context, android.R.attr.colorBackground) + ) + } + + @SuppressLint("PrivateResource") + @CheckResult + fun elevation(context: Context): Float { + return prefs(context).getFloat( + ThemeStorePrefKeys.KEY_ELEVATION, + ThemeUtils.resolveFloat( + context, + android.R.attr.elevation, + context.resources.getDimension(R.dimen.design_appbar_elevation) + ) + ) + } + + @CheckResult + @ColorInt + fun bottomBackground(context: Context = appCtx): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_BOTTOM_BACKGROUND, + ThemeUtils.resolveColor(context, android.R.attr.colorBackground) + ) + } + + @CheckResult + fun coloredStatusBar(context: Context): Boolean { + return prefs(context).getBoolean( + ThemeStorePrefKeys.KEY_APPLY_PRIMARYDARK_STATUSBAR, + true + ) + } + + @CheckResult + fun coloredNavigationBar(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_APPLY_PRIMARY_NAVBAR, false) + } + + @CheckResult + fun autoGeneratePrimaryDark(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_AUTO_GENERATE_PRIMARYDARK, true) + } + + @CheckResult + fun isConfigured(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.IS_CONFIGURED_KEY, false) + } + + @SuppressLint("CommitPrefEdits") + fun isConfigured(context: Context, version: Int): Boolean { + val prefs = prefs(context) + val lastVersion = prefs.getInt(ThemeStorePrefKeys.IS_CONFIGURED_VERSION_KEY, -1) + if (version > lastVersion) { + prefs.edit().putInt(ThemeStorePrefKeys.IS_CONFIGURED_VERSION_KEY, version).apply() + return false + } + return true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt new file mode 100644 index 000000000..f7b3ae500 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt @@ -0,0 +1,90 @@ +package io.legado.app.lib.theme + + +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes + +/** + * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) + */ +internal interface ThemeStoreInterface { + + // Primary colors + + fun primaryColor(@ColorInt color: Int): ThemeStore + + fun primaryColorRes(@ColorRes colorRes: Int): ThemeStore + + fun primaryColorAttr(@AttrRes colorAttr: Int): ThemeStore + + fun autoGeneratePrimaryDark(autoGenerate: Boolean): ThemeStore + + fun primaryColorDark(@ColorInt color: Int): ThemeStore + + fun primaryColorDarkRes(@ColorRes colorRes: Int): ThemeStore + + fun primaryColorDarkAttr(@AttrRes colorAttr: Int): ThemeStore + + // Accent colors + + fun accentColor(@ColorInt color: Int): ThemeStore + + fun accentColorRes(@ColorRes colorRes: Int): ThemeStore + + fun accentColorAttr(@AttrRes colorAttr: Int): ThemeStore + + // Status bar color + + fun statusBarColor(@ColorInt color: Int): ThemeStore + + fun statusBarColorRes(@ColorRes colorRes: Int): ThemeStore + + fun statusBarColorAttr(@AttrRes colorAttr: Int): ThemeStore + + // Navigation bar color + + fun navigationBarColor(@ColorInt color: Int): ThemeStore + + fun navigationBarColorRes(@ColorRes colorRes: Int): ThemeStore + + fun navigationBarColorAttr(@AttrRes colorAttr: Int): ThemeStore + + // Primary text color + + fun textColorPrimary(@ColorInt color: Int): ThemeStore + + fun textColorPrimaryRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorPrimaryAttr(@AttrRes colorAttr: Int): ThemeStore + + fun textColorPrimaryInverse(@ColorInt color: Int): ThemeStore + + fun textColorPrimaryInverseRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorPrimaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore + + // Secondary text color + + fun textColorSecondary(@ColorInt color: Int): ThemeStore + + fun textColorSecondaryRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorSecondaryAttr(@AttrRes colorAttr: Int): ThemeStore + + fun textColorSecondaryInverse(@ColorInt color: Int): ThemeStore + + fun textColorSecondaryInverseRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorSecondaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore + + // Background + + fun backgroundColor(@ColorInt color: Int): ThemeStore + + fun bottomBackground(@ColorInt color: Int): ThemeStore + + // Commit/apply + + fun apply() +} diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt new file mode 100644 index 000000000..fe31b1c23 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt @@ -0,0 +1,32 @@ +package io.legado.app.lib.theme + +/** + * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) + */ +object ThemeStorePrefKeys { + + const val CONFIG_PREFS_KEY_DEFAULT = "app_themes" + const val IS_CONFIGURED_KEY = "is_configured" + const val IS_CONFIGURED_VERSION_KEY = "is_configured_version" + const val VALUES_CHANGED = "values_changed" + + const val KEY_PRIMARY_COLOR = "primary_color" + const val KEY_PRIMARY_COLOR_DARK = "primary_color_dark" + const val KEY_ACCENT_COLOR = "accent_color" + const val KEY_STATUS_BAR_COLOR = "status_bar_color" + const val KEY_NAVIGATION_BAR_COLOR = "navigation_bar_color" + + const val KEY_TEXT_COLOR_PRIMARY = "text_color_primary" + const val KEY_TEXT_COLOR_PRIMARY_INVERSE = "text_color_primary_inverse" + const val KEY_TEXT_COLOR_SECONDARY = "text_color_secondary" + const val KEY_TEXT_COLOR_SECONDARY_INVERSE = "text_color_secondary_inverse" + + const val KEY_BACKGROUND_COLOR = "backgroundColor" + const val KEY_BOTTOM_BACKGROUND = "bottomBackground" + + const val KEY_APPLY_PRIMARYDARK_STATUSBAR = "apply_primarydark_statusbar" + const val KEY_APPLY_PRIMARY_NAVBAR = "apply_primary_navbar" + const val KEY_AUTO_GENERATE_PRIMARYDARK = "auto_generate_primarydark" + + const val KEY_ELEVATION = "elevation" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeUtils.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeUtils.kt new file mode 100644 index 000000000..fe8b3f988 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeUtils.kt @@ -0,0 +1,34 @@ +package io.legado.app.lib.theme + +import android.content.Context +import androidx.annotation.AttrRes + +/** + * @author Aidan Follestad (afollestad) + */ +object ThemeUtils { + + @JvmOverloads + fun resolveColor(context: Context, @AttrRes attr: Int, fallback: Int = 0): Int { + val a = context.theme.obtainStyledAttributes(intArrayOf(attr)) + return try { + a.getColor(0, fallback) + } catch (e: Exception) { + fallback + } finally { + a.recycle() + } + } + + @JvmOverloads + fun resolveFloat(context: Context, @AttrRes attr: Int, fallback: Float = 0.0f): Float { + val a = context.theme.obtainStyledAttributes(intArrayOf(attr)) + return try { + a.getFloat(0, fallback) + } catch (e: Exception) { + fallback + } finally { + a.recycle() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/TintHelper.kt b/app/src/main/java/io/legado/app/lib/theme/TintHelper.kt new file mode 100644 index 000000000..d1bfd0231 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/TintHelper.kt @@ -0,0 +1,453 @@ +package io.legado.app.lib.theme + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable +import android.view.View +import android.widget.* +import androidx.annotation.CheckResult +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.SwitchCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import io.legado.app.R +import io.legado.app.utils.ColorUtils + +/** + * @author afollestad, plusCubed + */ +@Suppress("MemberVisibilityCanBePrivate") +object TintHelper { + + @SuppressLint("PrivateResource") + @ColorInt + private fun getDefaultRippleColor(context: Context, useDarkRipple: Boolean): Int { + // Light ripple is actually translucent black, and vice versa + return ContextCompat.getColor( + context, if (useDarkRipple) + R.color.ripple_material_light + else + R.color.ripple_material_dark + ) + } + + private fun getDisabledColorStateList( + @ColorInt normal: Int, + @ColorInt disabled: Int + ): ColorStateList { + return ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled) + ), intArrayOf(disabled, normal) + ) + } + + fun setTintSelector(view: View, @ColorInt color: Int, darker: Boolean, useDarkTheme: Boolean) { + val isColorLight = ColorUtils.isColorLight(color) + val disabled = ContextCompat.getColor( + view.context, + if (useDarkTheme) R.color.ate_button_disabled_dark else R.color.ate_button_disabled_light + ) + val pressed = ColorUtils.shiftColor(color, if (darker) 0.9f else 1.1f) + val activated = ColorUtils.shiftColor(color, if (darker) 1.1f else 0.9f) + val rippleColor = getDefaultRippleColor(view.context, isColorLight) + val textColor = ContextCompat.getColor( + view.context, + if (isColorLight) R.color.ate_primary_text_light else R.color.ate_primary_text_dark + ) + + val sl: ColorStateList + when (view) { + is Button -> { + sl = getDisabledColorStateList(color, disabled) + if (view.getBackground() is RippleDrawable) { + val rd = view.getBackground() as RippleDrawable + rd.setColor(ColorStateList.valueOf(rippleColor)) + } + // Disabled text color state for buttons, may get overridden later by ATE tags + view.setTextColor( + getDisabledColorStateList( + textColor, + ContextCompat.getColor( + view.getContext(), + if (useDarkTheme) R.color.ate_button_text_disabled_dark else R.color.ate_button_text_disabled_light + ) + ) + ) + } + is FloatingActionButton -> { + // FloatingActionButton doesn't support disabled state? + sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_pressed), + intArrayOf(android.R.attr.state_pressed) + ), intArrayOf(color, pressed) + ) + + view.rippleColor = rippleColor + view.backgroundTintList = sl + if (view.drawable != null) + view.setImageDrawable(createTintedDrawable(view.drawable, textColor)) + return + } + else -> { + sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), + intArrayOf(disabled, color, pressed, activated, activated) + ) + } + } + + var drawable: Drawable? = view.background + if (drawable != null) { + drawable = createTintedDrawable(drawable, sl) + ViewUtils.setBackgroundCompat(view, drawable) + } + + if (view is TextView && view !is Button) { + view.setTextColor( + getDisabledColorStateList( + textColor, + ContextCompat.getColor( + view.getContext(), + if (isColorLight) R.color.ate_text_disabled_light else R.color.ate_text_disabled_dark + ) + ) + ) + } + } + + fun setTintAuto( + view: View, + @ColorInt color: Int, + isBackground: Boolean, + isDark: Boolean + ) { + var isBg = isBackground + if (!isBg) { + when (view) { + is RadioButton -> setTint(view, color, isDark) + is SeekBar -> setTint(view, color, isDark) + is ProgressBar -> setTint(view, color) + is AppCompatEditText -> setTint(view, color, isDark) + is CheckBox -> setTint(view, color, isDark) + is ImageView -> setTint(view, color) + is Switch -> setTint(view, color, isDark) + is SwitchCompat -> setTint(view, color, isDark) + is SearchView -> { + val iconIdS = + intArrayOf( + androidx.appcompat.R.id.search_button, + androidx.appcompat.R.id.search_close_btn, + androidx.appcompat.R.id.search_go_btn, + androidx.appcompat.R.id.search_voice_btn, + androidx.appcompat.R.id.search_mag_icon + ) + for (iconId in iconIdS) { + val icon = view.findViewById(iconId) + if (icon != null) { + setTint(icon, color) + } + } + } + else -> isBg = true + } + if (!isBg && view.background is RippleDrawable) { + // Ripples for the above views (e.g. when you tap and hold a switch or checkbox) + val rd = view.background as RippleDrawable + @SuppressLint("PrivateResource") val unchecked = ContextCompat.getColor( + view.context, + if (isDark) R.color.ripple_material_dark else R.color.ripple_material_light + ) + val checked = ColorUtils.adjustAlpha(color, 0.4f) + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_activated, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_activated), + intArrayOf(android.R.attr.state_checked) + ), + intArrayOf(unchecked, checked, checked) + ) + rd.setColor(sl) + } + } + if (isBg) { + // Need to tint the isBackground of a view + if (view is FloatingActionButton || view is Button) { + setTintSelector(view, color, false, isDark) + } else if (view.background != null) { + var drawable: Drawable? = view.background + if (drawable != null) { + drawable = createTintedDrawable(drawable, color) + ViewUtils.setBackgroundCompat(view, drawable) + } + } + } + } + + @SuppressLint("PrivateResource") + fun setTint(radioButton: RadioButton, @ColorInt color: Int, useDarker: Boolean) { + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), intArrayOf( + // Radio button includes own alpha for disabled state + ColorUtils.stripAlpha( + ContextCompat.getColor( + radioButton.context, + if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light + ) + ), + ContextCompat.getColor( + radioButton.context, + if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light + ), + color + ) + ) + radioButton.buttonTintList = sl + } + + fun setTint(seekBar: SeekBar, @ColorInt color: Int, useDarker: Boolean) { + val s1 = getDisabledColorStateList( + color, + ContextCompat.getColor( + seekBar.context, + if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light + ) + ) + seekBar.thumbTintList = s1 + seekBar.progressTintList = s1 + } + + @JvmOverloads + fun setTint( + progressBar: ProgressBar, @ColorInt color: Int, + skipIndeterminate: Boolean = false + ) { + val sl = ColorStateList.valueOf(color) + progressBar.progressTintList = sl + progressBar.secondaryProgressTintList = sl + if (!skipIndeterminate) + progressBar.indeterminateTintList = sl + } + + + @SuppressLint("RestrictedApi") + fun setTint(editText: AppCompatEditText, @ColorInt color: Int, useDarker: Boolean) { + val editTextColorStateList = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf( + android.R.attr.state_enabled, + -android.R.attr.state_pressed, + -android.R.attr.state_focused + ), + intArrayOf() + ), + intArrayOf( + ContextCompat.getColor( + editText.context, + if (useDarker) R.color.ate_text_disabled_dark else R.color.ate_text_disabled_light + ), + ContextCompat.getColor( + editText.context, + if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light + ), + color + ) + ) + editText.supportBackgroundTintList = editTextColorStateList + setCursorTint(editText, color) + } + + @SuppressLint("PrivateResource") + fun setTint(box: CheckBox, @ColorInt color: Int, useDarker: Boolean) { + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), + intArrayOf( + ContextCompat.getColor( + box.context, + if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light + ), + ContextCompat.getColor( + box.context, + if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light + ), + color + ) + ) + box.buttonTintList = sl + } + + fun setTint(image: ImageView, @ColorInt color: Int) { + image.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) + } + + private fun modifySwitchDrawable( + context: Context, + from: Drawable, + @ColorInt tint: Int, + thumb: Boolean, + compatSwitch: Boolean, + useDarker: Boolean + ): Drawable? { + var tint1 = tint + if (useDarker) { + tint1 = ColorUtils.shiftColor(tint1, 1.1f) + } + tint1 = ColorUtils.adjustAlpha(tint1, if (compatSwitch && !thumb) 0.5f else 1.0f) + val disabled: Int + var normal: Int + if (thumb) { + disabled = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_thumb_disabled_dark else R.color.ate_switch_thumb_disabled_light + ) + normal = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_thumb_normal_dark else R.color.ate_switch_thumb_normal_light + ) + } else { + disabled = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_track_disabled_dark else R.color.ate_switch_track_disabled_light + ) + normal = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_track_normal_dark else R.color.ate_switch_track_normal_light + ) + } + + // Stock switch includes its own alpha + if (!compatSwitch) { + normal = ColorUtils.stripAlpha(normal) + } + + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf( + android.R.attr.state_enabled, + -android.R.attr.state_activated, + -android.R.attr.state_checked + ), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), + intArrayOf(disabled, normal, tint1, tint1) + ) + return createTintedDrawable(from, sl) + } + + fun setTint( + @SuppressLint("UseSwitchCompatOrMaterialCode") switchView: Switch, + @ColorInt color: Int, + useDarker: Boolean + ) { + if (switchView.trackDrawable != null) { + switchView.trackDrawable = modifySwitchDrawable( + switchView.context, + switchView.trackDrawable, + color, + thumb = false, + compatSwitch = false, + useDarker = useDarker + ) + } + if (switchView.thumbDrawable != null) { + switchView.thumbDrawable = modifySwitchDrawable( + switchView.context, + switchView.thumbDrawable, + color, + thumb = true, + compatSwitch = false, + useDarker = useDarker + ) + } + } + + fun setTint(switchView: SwitchCompat, @ColorInt color: Int, useDarker: Boolean) { + if (switchView.trackDrawable != null) { + switchView.trackDrawable = modifySwitchDrawable( + switchView.context, + switchView.trackDrawable, + color, + thumb = false, + compatSwitch = true, + useDarker = useDarker + ) + } + if (switchView.thumbDrawable != null) { + switchView.thumbDrawable = modifySwitchDrawable( + switchView.context, + switchView.thumbDrawable, + color, + thumb = true, + compatSwitch = true, + useDarker = useDarker + ) + } + } + + // This returns a NEW Drawable because of the mutate() call. The mutate() call is necessary because Drawables with the same resource have shared states otherwise. + @CheckResult + fun createTintedDrawable(drawable: Drawable?, @ColorInt color: Int): Drawable? { + var drawable1: Drawable? = drawable ?: return null + drawable1 = DrawableCompat.wrap(drawable1!!.mutate()) + DrawableCompat.setTintMode(drawable1!!, PorterDuff.Mode.SRC_IN) + DrawableCompat.setTint(drawable1, color) + return drawable1 + } + + // This returns a NEW Drawable because of the mutate() call. The mutate() call is necessary because Drawables with the same resource have shared states otherwise. + @CheckResult + fun createTintedDrawable(drawable: Drawable?, sl: ColorStateList): Drawable? { + var drawable1: Drawable? = drawable ?: return null + drawable1 = DrawableCompat.wrap(drawable1!!.mutate()) + DrawableCompat.setTintList(drawable1!!, sl) + return drawable1 + } + + @SuppressLint("DiscouragedPrivateApi", "SoonBlockedPrivateApi") + fun setCursorTint(editText: EditText, @ColorInt color: Int) { + try { + val fCursorDrawableRes = TextView::class.java.getDeclaredField("mCursorDrawableRes") + fCursorDrawableRes.isAccessible = true + val mCursorDrawableRes = fCursorDrawableRes.getInt(editText) + val fEditor = TextView::class.java.getDeclaredField("mEditor") + fEditor.isAccessible = true + val editor = fEditor.get(editText) + val clazz = editor.javaClass + val fCursorDrawable = clazz.getDeclaredField("mCursorDrawable") + fCursorDrawable.isAccessible = true + val drawables = arrayOfNulls(2) + drawables[0] = ContextCompat.getDrawable(editText.context, mCursorDrawableRes) + drawables[0] = createTintedDrawable(drawables[0], color) + drawables[1] = ContextCompat.getDrawable(editText.context, mCursorDrawableRes) + drawables[1] = createTintedDrawable(drawables[1], color) + fCursorDrawable.set(editor, drawables) + } catch (ignored: Exception) { + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt b/app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt new file mode 100644 index 000000000..931676edd --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt @@ -0,0 +1,43 @@ +package io.legado.app.lib.theme + +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.TransitionDrawable +import android.view.View +import android.view.ViewTreeObserver +import androidx.annotation.ColorInt +import io.legado.app.utils.DrawableUtils + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +@Suppress("unused") +object ViewUtils { + + fun removeOnGlobalLayoutListener(v: View, listener: ViewTreeObserver.OnGlobalLayoutListener) { + v.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + + fun setBackgroundCompat(view: View, drawable: Drawable?) { + view.background = drawable + } + + fun setBackgroundTransition(view: View, newDrawable: Drawable): TransitionDrawable { + val transition = DrawableUtils.createTransitionDrawable(view.background, newDrawable) + setBackgroundCompat(view, transition) + return transition + } + + fun setBackgroundColorTransition(view: View, @ColorInt newColor: Int): TransitionDrawable { + val oldColor = view.background + + val start = oldColor ?: ColorDrawable(view.solidColor) + val end = ColorDrawable(newColor) + + val transition = DrawableUtils.createTransitionDrawable(start, end) + + setBackgroundCompat(view, transition) + + return transition + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeBottomNavigationVIew.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeBottomNavigationVIew.kt new file mode 100644 index 000000000..6e5c49c58 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeBottomNavigationVIew.kt @@ -0,0 +1,27 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import com.google.android.material.bottomnavigation.BottomNavigationView +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getSecondaryTextColor +import io.legado.app.utils.ColorUtils + +class ThemeBottomNavigationVIew(context: Context, attrs: AttributeSet) : + BottomNavigationView(context, attrs) { + + init { + val bgColor = context.bottomBackground + setBackgroundColor(bgColor) + val textIsDark = ColorUtils.isColorLight(bgColor) + val textColor = context.getSecondaryTextColor(textIsDark) + val colorStateList = Selector.colorBuild() + .setDefaultColor(textColor) + .setSelectedColor(ThemeStore.accentColor(context)).create() + itemIconTintList = colorStateList + itemTextColor = colorStateList + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeCheckBox.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeCheckBox.kt new file mode 100644 index 000000000..fa3daa68e --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeCheckBox.kt @@ -0,0 +1,19 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatCheckBox +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeCheckBox(context: Context, attrs: AttributeSet) : AppCompatCheckBox(context, attrs) { + + init { + if (!isInEditMode) { + applyTint(context.accentColor) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeEditText.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeEditText.kt new file mode 100644 index 000000000..997ddb871 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeEditText.kt @@ -0,0 +1,19 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatEditText +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeEditText(context: Context, attrs: AttributeSet) : AppCompatEditText(context, attrs) { + + init { + if (!isInEditMode) { + applyTint(context.accentColor) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeProgressBar.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeProgressBar.kt new file mode 100644 index 000000000..16772ed8a --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeProgressBar.kt @@ -0,0 +1,19 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.ProgressBar +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeProgressBar(context: Context, attrs: AttributeSet) : ProgressBar(context, attrs) { + + init { + if (!isInEditMode) { + applyTint(context.accentColor) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioButton.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioButton.kt new file mode 100644 index 000000000..3c19ba119 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioButton.kt @@ -0,0 +1,20 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatRadioButton +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeRadioButton(context: Context, attrs: AttributeSet) : + AppCompatRadioButton(context, attrs) { + + init { + if (!isInEditMode) { + applyTint(context.accentColor) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioNoButton.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioNoButton.kt new file mode 100644 index 000000000..9f46157d5 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeRadioNoButton.kt @@ -0,0 +1,71 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatRadioButton +import io.legado.app.R +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeRadioNoButton(context: Context, attrs: AttributeSet) : + AppCompatRadioButton(context, attrs) { + + private val isBottomBackground: Boolean + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ATERadioNoButton) + isBottomBackground = + typedArray.getBoolean(R.styleable.ATERadioNoButton_isBottomBackground, false) + typedArray.recycle() + initTheme() + } + + private fun initTheme() { + when { + isInEditMode -> Unit + isBottomBackground -> { + val isLight = ColorUtils.isColorLight(context.bottomBackground) + val textColor = context.getPrimaryTextColor(isLight) + background = Selector.shapeBuild() + .setCornerRadius(2.dp) + .setStrokeWidth(2.dp) + .setCheckedBgColor(context.accentColor) + .setCheckedStrokeColor(context.accentColor) + .setDefaultStrokeColor(textColor) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(textColor) + .setCheckedColor(context.getPrimaryTextColor(ColorUtils.isColorLight(context.accentColor))) + .create() + ) + } + else -> { + val textColor = context.getCompatColor(R.color.primaryText) + background = Selector.shapeBuild() + .setCornerRadius(2.dp) + .setStrokeWidth(2.dp) + .setCheckedBgColor(context.accentColor) + .setCheckedStrokeColor(context.accentColor) + .setDefaultStrokeColor(textColor) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(textColor) + .setCheckedColor(context.getPrimaryTextColor(ColorUtils.isColorLight(context.accentColor))) + .create() + ) + } + } + + } + +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeSeekBar.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeSeekBar.kt new file mode 100644 index 000000000..1c842a2e7 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeSeekBar.kt @@ -0,0 +1,19 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatSeekBar +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeSeekBar(context: Context, attrs: AttributeSet) : AppCompatSeekBar(context, attrs) { + + init { + if (!isInEditMode) { + applyTint(context.accentColor) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ThemeSwitch.kt b/app/src/main/java/io/legado/app/lib/theme/view/ThemeSwitch.kt new file mode 100644 index 000000000..94b18be5c --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ThemeSwitch.kt @@ -0,0 +1,21 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.SwitchCompat +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +/** + * @author Aidan Follestad (afollestad) + */ +class ThemeSwitch(context: Context, attrs: AttributeSet) : SwitchCompat(context, attrs) { + + init { + if (!isInEditMode) { + applyTint(context.accentColor) + } + + } + +} diff --git a/app/src/main/java/io/legado/app/lib/webdav/HttpAuth.kt b/app/src/main/java/io/legado/app/lib/webdav/HttpAuth.kt new file mode 100644 index 000000000..d2f604a80 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/webdav/HttpAuth.kt @@ -0,0 +1,9 @@ +package io.legado.app.lib.webdav + +object HttpAuth { + + var auth: Auth? = null + + class Auth internal constructor(val user: String, val pass: String) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/webdav/WebDav.kt b/app/src/main/java/io/legado/app/lib/webdav/WebDav.kt new file mode 100644 index 000000000..8ed1af146 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/webdav/WebDav.kt @@ -0,0 +1,242 @@ +package io.legado.app.lib.webdav + +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.intellij.lang.annotations.Language +import org.jsoup.Jsoup +import timber.log.Timber +import java.io.File +import java.io.InputStream +import java.net.MalformedURLException +import java.net.URL +import java.net.URLEncoder +import java.util.* + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class WebDav(urlStr: String) { + companion object { + // 指定返回哪些属性 + @Language("xml") + private const val DIR = + """ + + + + + + + + %s + + """ + } + + private val url: URL = URL(urlStr) + private val httpUrl: String? by lazy { + val raw = url.toString().replace("davs://", "https://").replace("dav://", "http://") + return@lazy kotlin.runCatching { + URLEncoder.encode(raw, "UTF-8") + .replace("\\+".toRegex(), "%20") + .replace("%3A".toRegex(), ":") + .replace("%2F".toRegex(), "/") + }.getOrNull() + } + val host: String? get() = url.host + val path get() = url.toString() + var displayName: String? = null + var size: Long = 0 + var exists = false + var parent = "" + var urlName = "" + var contentType = "" + + /** + * 填充文件信息。实例化WebDAVFile对象时,并没有将远程文件的信息填充到实例中。需要手动填充! + * @return 远程文件是否存在 + */ + suspend fun indexFileInfo(): Boolean { + return !propFindResponse(ArrayList()).isNullOrEmpty() + } + + /** + * 列出当前路径下的文件 + * + * @return 文件列表 + */ + suspend fun listFiles(): List { + propFindResponse()?.let { body -> + return parseDir(body) + } + return ArrayList() + } + + /** + * @param propsList 指定列出文件的哪些属性 + */ + private suspend fun propFindResponse(propsList: List = emptyList()): String? { + val requestProps = StringBuilder() + for (p in propsList) { + requestProps.append("\n") + } + val requestPropsStr: String = if (requestProps.toString().isEmpty()) { + DIR.replace("%s", "") + } else { + String.format(DIR, requestProps.toString() + "\n") + } + val url = httpUrl + val auth = HttpAuth.auth + if (url != null && auth != null) { + return kotlin.runCatching { + okHttpClient.newCallResponseBody { + url(url) + addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) + addHeader("Depth", "1") + // 添加RequestBody对象,可以只返回的属性。如果设为null,则会返回全部属性 + // 注意:尽量手动指定需要返回的属性。若返回全部属性,可能后由于Prop.java里没有该属性名,而崩溃。 + val requestBody = requestPropsStr.toRequestBody("text/plain".toMediaType()) + method("PROPFIND", requestBody) + }.text() + }.onFailure { e -> + Timber.e(e) + }.getOrNull() + } + return null + } + + private fun parseDir(s: String): List { + val list = ArrayList() + val document = Jsoup.parse(s) + val elements = document.getElementsByTag("d:response") + httpUrl?.let { urlStr -> + val baseUrl = if (urlStr.endsWith("/")) urlStr else "$urlStr/" + for (element in elements) { + val href = element.getElementsByTag("d:href")[0].text() + if (!href.endsWith("/")) { + val fileName = href.substring(href.lastIndexOf("/") + 1) + val webDavFile: WebDav + try { + webDavFile = WebDav(baseUrl + fileName) + webDavFile.displayName = fileName + webDavFile.contentType = element + .getElementsByTag("d:getcontenttype") + .getOrNull(0)?.text() ?: "" + if (href.isEmpty()) { + webDavFile.urlName = + if (parent.isEmpty()) url.file.replace("/", "") + else url.toString().replace(parent, "").replace("/", "") + } else { + webDavFile.urlName = href + } + list.add(webDavFile) + } catch (e: MalformedURLException) { + Timber.e(e) + } + } + } + } + return list + } + + /** + * 根据自己的URL,在远程处创建对应的文件夹 + * @return 是否创建成功 + */ + suspend fun makeAsDir(): Boolean { + val url = httpUrl + val auth = HttpAuth.auth + if (url != null && auth != null) { + //防止报错 + return kotlin.runCatching { + okHttpClient.newCallResponseBody { + url(url) + method("MKCOL", null) + addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) + }.close() + }.isSuccess + } + return false + } + + /** + * 下载到本地 + * + * @param savedPath 本地的完整路径,包括最后的文件名 + * @param replaceExisting 是否替换本地的同名文件 + * @return 下载是否成功 + */ + suspend fun downloadTo(savedPath: String, replaceExisting: Boolean): Boolean { + if (File(savedPath).exists()) { + if (!replaceExisting) return false + } + val inputS = getInputStream() ?: return false + File(savedPath).writeBytes(inputS.readBytes()) + return true + } + + suspend fun download(): ByteArray? { + val inputS = getInputStream() ?: return null + return inputS.readBytes() + } + + /** + * 上传文件 + */ + suspend fun upload( + localPath: String, + contentType: String = "application/octet-stream" + ): Boolean { + val file = File(localPath) + if (!file.exists()) return false + // 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息 + val fileBody = file.asRequestBody(contentType.toMediaType()) + val url = httpUrl + val auth = HttpAuth.auth + if (url != null && auth != null) { + return kotlin.runCatching { + okHttpClient.newCallResponseBody { + url(url) + put(fileBody) + addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) + }.close() + }.isSuccess + } + return false + } + + suspend fun upload(byteArray: ByteArray, contentType: String): Boolean { + // 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息 + val fileBody = byteArray.toRequestBody(contentType.toMediaType()) + val url = httpUrl + val auth = HttpAuth.auth + if (url != null && auth != null) { + return kotlin.runCatching { + okHttpClient.newCallResponseBody { + url(url) + put(fileBody) + addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) + }.close() + }.isSuccess + } + return false + } + + private suspend fun getInputStream(): InputStream? { + val url = httpUrl + val auth = HttpAuth.auth + if (url != null && auth != null) { + return kotlin.runCatching { + okHttpClient.newCallResponseBody { + url(url) + addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) + }.byteStream() + }.getOrNull() + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/AudioPlay.kt b/app/src/main/java/io/legado/app/model/AudioPlay.kt new file mode 100644 index 000000000..a850301fc --- /dev/null +++ b/app/src/main/java/io/legado/app/model/AudioPlay.kt @@ -0,0 +1,168 @@ +package io.legado.app.model + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.constant.EventBus +import io.legado.app.constant.IntentAction +import io.legado.app.constant.Status +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.service.AudioPlayService +import io.legado.app.utils.postEvent +import io.legado.app.utils.startService + +object AudioPlay { + var titleData = MutableLiveData() + var coverData = MutableLiveData() + var status = Status.STOP + var book: Book? = null + var durChapter: BookChapter? = null + var inBookshelf = false + var bookSource: BookSource? = null + val loadingChapters = arrayListOf() + + fun headers(hasLoginHeader: Boolean): Map? { + return bookSource?.getHeaderMap(hasLoginHeader) + } + + /** + * 播放当前章节 + */ + fun play(context: Context) { + book?.let { + if (durChapter == null) { + upDurChapter(it) + } + durChapter?.let { + context.startService { + action = IntentAction.play + } + } + } + } + + /** + * 更新当前章节 + */ + fun upDurChapter(book: Book) { + durChapter = appDb.bookChapterDao.getChapter(book.bookUrl, book.durChapterIndex) + postEvent(EventBus.AUDIO_SUB_TITLE, durChapter?.title ?: "") + postEvent(EventBus.AUDIO_SIZE, durChapter?.end?.toInt() ?: 0) + postEvent(EventBus.AUDIO_PROGRESS, book.durChapterPos) + } + + fun pause(context: Context) { + if (AudioPlayService.isRun) { + context.startService { + action = IntentAction.pause + } + } + } + + fun resume(context: Context) { + if (AudioPlayService.isRun) { + context.startService { + action = IntentAction.resume + } + } + } + + fun stop(context: Context) { + if (AudioPlayService.isRun) { + context.startService { + action = IntentAction.stop + } + } + } + + fun adjustSpeed(context: Context, adjust: Float) { + if (AudioPlayService.isRun) { + context.startService { + action = IntentAction.adjustSpeed + putExtra("adjust", adjust) + } + } + } + + fun adjustProgress(context: Context, position: Int) { + if (AudioPlayService.isRun) { + context.startService { + action = IntentAction.adjustProgress + putExtra("position", position) + } + } + } + + fun skipTo(context: Context, index: Int) { + Coroutine.async { + book?.let { book -> + book.durChapterIndex = index + book.durChapterPos = 0 + durChapter = null + saveRead(book) + play(context) + } + } + } + + fun prev(context: Context) { + Coroutine.async { + book?.let { book -> + if (book.durChapterIndex <= 0) { + return@let + } + book.durChapterIndex = book.durChapterIndex - 1 + book.durChapterPos = 0 + durChapter = null + saveRead(book) + play(context) + } + } + } + + fun next(context: Context) { + book?.let { book -> + if (book.durChapterIndex >= book.totalChapterNum) { + return@let + } + book.durChapterIndex = book.durChapterIndex + 1 + book.durChapterPos = 0 + durChapter = null + saveRead(book) + play(context) + } + } + + fun addTimer(context: Context) { + val intent = Intent(context, AudioPlayService::class.java) + intent.action = IntentAction.addTimer + context.startService(intent) + } + + fun saveRead(book: Book) { + book.lastCheckCount = 0 + book.durChapterTime = System.currentTimeMillis() + Coroutine.async { + appDb.bookChapterDao.getChapter(book.bookUrl, book.durChapterIndex)?.let { + book.durChapterTitle = it.title + } + book.save() + } + } + + /** + * 保存章节长度 + */ + fun saveDurChapter(audioSize: Long) { + Coroutine.async { + durChapter?.let { + it.end = audioSize + appDb.bookChapterDao.upDate(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/BookCover.kt b/app/src/main/java/io/legado/app/model/BookCover.kt new file mode 100644 index 000000000..996f4f88a --- /dev/null +++ b/app/src/main/java/io/legado/app/model/BookCover.kt @@ -0,0 +1,61 @@ +package io.legado.app.model + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.request.RequestOptions +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.help.AppConfig +import io.legado.app.help.BlurTransformation +import io.legado.app.help.glide.ImageLoader +import io.legado.app.utils.BitmapUtils +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefString +import splitties.init.appCtx + +object BookCover { + + var drawBookName = true + private set + var drawBookAuthor = true + private set + lateinit var defaultDrawable: Drawable + private set + + init { + upDefaultCover() + } + + @SuppressLint("UseCompatLoadingForDrawables") + fun upDefaultCover() { + val isNightTheme = AppConfig.isNightTheme + drawBookName = if (isNightTheme) { + appCtx.getPrefBoolean(PreferKey.coverShowNameN, true) + } else { + appCtx.getPrefBoolean(PreferKey.coverShowName, true) + } + drawBookAuthor = if (isNightTheme) { + appCtx.getPrefBoolean(PreferKey.coverShowAuthorN, true) + } else { + appCtx.getPrefBoolean(PreferKey.coverShowAuthor, true) + } + val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover + val path = appCtx.getPrefString(key) + if (path.isNullOrBlank()) { + defaultDrawable = appCtx.resources.getDrawable(R.drawable.image_cover_default, null) + return + } + defaultDrawable = kotlin.runCatching { + BitmapDrawable(appCtx.resources, BitmapUtils.decodeBitmap(path, 600, 900)) + }.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null)) + } + + fun getBlurDefaultCover(context: Context): RequestBuilder { + return ImageLoader.load(context, defaultDrawable) + .apply(RequestOptions.bitmapTransform(BlurTransformation(context, 25))) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt new file mode 100644 index 000000000..2174dbfd3 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -0,0 +1,289 @@ +package io.legado.app.model + +import android.content.Context +import io.legado.app.constant.AppLog +import io.legado.app.constant.EventBus +import io.legado.app.constant.IntentAction +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.BookHelp +import io.legado.app.model.webBook.WebBook +import io.legado.app.service.CacheBookService +import io.legado.app.utils.postEvent + +import io.legado.app.utils.startService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.CoroutineContext + +object CacheBook { + + val cacheBookMap = ConcurrentHashMap() + + @Synchronized + fun getOrCreate(bookUrl: String): CacheBookModel? { + val book = appDb.bookDao.getBook(bookUrl) ?: return null + val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null + var cacheBook = cacheBookMap[bookUrl] + if (cacheBook != null) { + //存在时更新,书源可能会变化,必须更新 + cacheBook.bookSource = bookSource + cacheBook.book = book + return cacheBook + } + cacheBook = CacheBookModel(bookSource, book) + cacheBookMap[bookUrl] = cacheBook + return cacheBook + } + + @Synchronized + fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel { + var cacheBook = cacheBookMap[book.bookUrl] + if (cacheBook != null) { + //存在时更新,书源可能会变化,必须更新 + cacheBook.bookSource = bookSource + cacheBook.book = book + return cacheBook + } + cacheBook = CacheBookModel(bookSource, book) + cacheBookMap[book.bookUrl] = cacheBook + return cacheBook + } + + fun start(context: Context, bookUrl: String, start: Int, end: Int) { + context.startService { + action = IntentAction.start + putExtra("bookUrl", bookUrl) + putExtra("start", start) + putExtra("end", end) + } + } + + fun remove(context: Context, bookUrl: String) { + context.startService { + action = IntentAction.remove + putExtra("bookUrl", bookUrl) + } + } + + fun stop(context: Context) { + context.startService { + action = IntentAction.stop + } + } + + val downloadSummary: String + get() { + return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorCount}|成功:${successCount}" + } + + val isRun: Boolean + get() { + var isRun = false + cacheBookMap.forEach { + isRun = isRun || it.value.isRun() + } + return isRun + } + + private val waitCount: Int + get() { + var count = 0 + cacheBookMap.forEach { + count += it.value.waitCount + } + return count + } + + private val successCount: Int + get() { + var count = 0 + cacheBookMap.forEach { + count += it.value.successCount + } + return count + } + + val onDownloadCount: Int + get() { + var count = 0 + cacheBookMap.forEach { + count += it.value.onDownloadCount + } + return count + } + + private val errorCount: Int + get() { + var count = 0 + cacheBookMap.forEach { + count += it.value.errorCount + } + return count + } + + class CacheBookModel(var bookSource: BookSource, var book: Book) { + + private val waitDownloadSet = hashSetOf() + private val onDownloadSet = hashSetOf() + private val successDownloadSet = hashSetOf() + private val errorDownloadMap = hashMapOf() + + val waitCount get() = waitDownloadSet.size + val onDownloadCount get() = onDownloadSet.size + val successCount get() = successDownloadSet.size + val errorCount get() = errorDownloadMap.size + + @Synchronized + fun isRun(): Boolean { + return waitDownloadSet.size > 0 || onDownloadSet.size > 0 + } + + @Synchronized + fun stop() { + waitDownloadSet.clear() + } + + @Synchronized + fun addDownload(start: Int, end: Int) { + for (i in start..end) { + if (!onDownloadSet.contains(i)) { + waitDownloadSet.add(i) + } + } + } + + @Synchronized + private fun onSuccess(index: Int) { + onDownloadSet.remove(index) + successDownloadSet.add(index) + errorDownloadMap.remove(index) + } + + @Synchronized + private fun onError(index: Int, error: Throwable, chapterTitle: String) { + if (error !is ConcurrentException) { + errorDownloadMap[index] = (errorDownloadMap[index] ?: 0) + 1 + } + onDownloadSet.remove(index) + //重试3次 + if (errorDownloadMap[index] ?: 0 < 3) { + waitDownloadSet.add(index) + } else { + AppLog.put( + "下载${book.name}-${chapterTitle}失败\n${error.localizedMessage}", + error + ) + Timber.e(error) + } + } + + @Synchronized + private fun onCancel(index: Int) { + onDownloadSet.remove(index) + waitDownloadSet.add(index) + } + + @Synchronized + private fun onFinally() { + if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { + postEvent(EventBus.UP_DOWNLOAD, "") + cacheBookMap.remove(book.bookUrl) + } + } + + /** + * 从待下载列表内取第一条下载 + */ + @Synchronized + fun download(scope: CoroutineScope, context: CoroutineContext): Boolean { + val chapterIndex = waitDownloadSet.firstOrNull() + if (chapterIndex == null) { + if (onDownloadSet.isEmpty()) { + cacheBookMap.remove(book.bookUrl) + } + return false + } + if (onDownloadSet.contains(chapterIndex)) { + waitDownloadSet.remove(chapterIndex) + return download(scope, context) + } + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let { + waitDownloadSet.remove(chapterIndex) + return download(scope, context) + } + if (BookHelp.hasContent(book, chapter)) { + waitDownloadSet.remove(chapterIndex) + return download(scope, context) + } + waitDownloadSet.remove(chapterIndex) + onDownloadSet.add(chapterIndex) + WebBook.getContent( + scope, + bookSource, + book, + chapter, + context = context + ).onSuccess { content -> + onSuccess(chapterIndex) + downloadFinish(chapter, content) + }.onError { + //出现错误等待一秒后重新加入待下载列表 + delay(1000) + onError(chapterIndex, it, chapter.title) + downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}") + }.onCancel { + onCancel(chapterIndex) + }.onFinally { + onFinally() + } + return true + } + + @Synchronized + fun download( + scope: CoroutineScope, + chapter: BookChapter, + resetPageOffset: Boolean = false + ) { + if (onDownloadSet.contains(chapter.index)) { + return + } + onDownloadSet.add(chapter.index) + waitDownloadSet.remove(chapter.index) + WebBook.getContent(scope, bookSource, book, chapter) + .onSuccess { content -> + onSuccess(chapter.index) + downloadFinish(chapter, content, resetPageOffset) + }.onError { + onError(chapter.index, it, chapter.title) + downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset) + }.onCancel { + onCancel(chapter.index) + }.onFinally { + if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { + postEvent(EventBus.UP_DOWNLOAD, "") + } + } + } + + private fun downloadFinish( + chapter: BookChapter, + content: String, + resetPageOffset: Boolean = false + ) { + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.contentLoadFinish( + book, chapter, content, + resetPageOffset = resetPageOffset + ) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/CheckSource.kt b/app/src/main/java/io/legado/app/model/CheckSource.kt new file mode 100644 index 000000000..a938c48d2 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/CheckSource.kt @@ -0,0 +1,34 @@ +package io.legado.app.model + +import android.content.Context +import io.legado.app.R +import io.legado.app.constant.IntentAction +import io.legado.app.data.entities.BookSource +import io.legado.app.service.CheckSourceService +import io.legado.app.utils.startService +import io.legado.app.utils.toastOnUi + +object CheckSource { + var keyword = "我的" + + fun start(context: Context, sources: List) { + if (sources.isEmpty()) { + context.toastOnUi(R.string.non_select) + return + } + val selectedIds: ArrayList = arrayListOf() + sources.map { + selectedIds.add(it.bookSourceUrl) + } + context.startService { + action = IntentAction.start + putExtra("selectIds", selectedIds) + } + } + + fun stop(context: Context) { + context.startService { + action = IntentAction.stop + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/Debug.kt b/app/src/main/java/io/legado/app/model/Debug.kt new file mode 100644 index 000000000..cc39171c0 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/Debug.kt @@ -0,0 +1,286 @@ +package io.legado.app.model + +import android.annotation.SuppressLint +import io.legado.app.data.entities.* +import io.legado.app.help.coroutine.CompositeCoroutine +import io.legado.app.model.rss.Rss +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.HtmlFormatter +import io.legado.app.utils.isAbsUrl +import io.legado.app.utils.msg +import kotlinx.coroutines.CoroutineScope +import java.text.SimpleDateFormat +import java.util.* +import kotlin.collections.HashMap + +object Debug { + var callback: Callback? = null + private var debugSource: String? = null + private val tasks: CompositeCoroutine = CompositeCoroutine() + val debugMessageMap = HashMap() + private val debugTimeMap = HashMap() + var isChecking: Boolean = false + + @SuppressLint("ConstantLocale") + private val debugTimeFormat = SimpleDateFormat("[mm:ss.SSS]", Locale.getDefault()) + private var startTime: Long = System.currentTimeMillis() + + @Synchronized + fun log( + sourceUrl: String?, + msg: String? = "", + print: Boolean = true, + isHtml: Boolean = false, + showTime: Boolean = true, + state: Int = 1 + ) { + //调试信息始终要执行 + callback?.let { + if ((debugSource != sourceUrl || !print)) return + var printMsg = msg ?: "" + if (isHtml) { + printMsg = HtmlFormatter.format(msg) + } + if (showTime) { + val time = debugTimeFormat.format(Date(System.currentTimeMillis() - startTime)) + printMsg = "$time $printMsg" + } + it.printLog(state, printMsg) + } + if (isChecking) { + if (sourceUrl != null && (msg ?: "").length < 30) { + var printMsg = msg ?: "" + if (isHtml) { + printMsg = HtmlFormatter.format(msg) + } + if (showTime && debugTimeMap[sourceUrl] != null) { + val time = + debugTimeFormat.format(Date(System.currentTimeMillis() - debugTimeMap[sourceUrl]!!)) + printMsg = "$time $printMsg" + debugMessageMap[sourceUrl] = printMsg + } + } + } + } + + @Synchronized + fun log(msg: String?) { + log(debugSource, msg, true) + } + + fun cancelDebug(destroy: Boolean = false) { + tasks.clear() + + if (destroy) { + debugSource = null + callback = null + } + } + + fun startChecking(source: BookSource) { + isChecking = true + debugTimeMap[source.bookSourceUrl] = System.currentTimeMillis() + debugMessageMap[source.bookSourceUrl] = "${debugTimeFormat.format(Date(0))} 开始校验" + } + + fun finishChecking() { + isChecking = false + } + + fun getRespondTime(sourceUrl: String): Long { + return debugTimeMap[sourceUrl] ?: 180000L + } + + fun updateFinalMessage(sourceUrl: String, state: String) { + if (debugTimeMap[sourceUrl] != null && debugMessageMap[sourceUrl] != null) { + val spendingTime = System.currentTimeMillis() - debugTimeMap[sourceUrl]!! + debugTimeMap[sourceUrl] = if(state == "成功") spendingTime else 180000L + val printTime = debugTimeFormat.format(Date(spendingTime)) + val originalMessage = debugMessageMap[sourceUrl]!!.substringAfter("] ") + debugMessageMap[sourceUrl] = "$printTime $originalMessage $state" + } + } + + fun startDebug(scope: CoroutineScope, rssSource: RssSource) { + cancelDebug() + debugSource = rssSource.sourceUrl + log(debugSource, "︾开始解析") + val sort = rssSource.sortUrls().first() + Rss.getArticles(scope, sort.first, sort.second, rssSource, 1) + .onSuccess { + if (it.first.isEmpty()) { + log(debugSource, "⇒列表页解析成功,为空") + log(debugSource, "︽解析完成", state = 1000) + } else { + val ruleContent = rssSource.ruleContent + if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) { + log(debugSource, "︽列表页解析完成") + log(debugSource, showTime = false) + if (ruleContent.isNullOrEmpty()) { + log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000) + } else { + rssContentDebug(scope, it.first[0], ruleContent, rssSource) + } + } else { + log(debugSource, "⇒存在描述规则,不解析内容页") + log(debugSource, "︽解析完成", state = 1000) + } + } + } + .onError { + log(debugSource, it.msg, state = -1) + } + } + + private fun rssContentDebug( + scope: CoroutineScope, + rssArticle: RssArticle, + ruleContent: String, + rssSource: RssSource + ) { + log(debugSource, "︾开始解析内容页") + Rss.getContent(scope, rssArticle, ruleContent, rssSource) + .onSuccess { + log(debugSource, it) + log(debugSource, "︽内容页解析完成", state = 1000) + } + .onError { + log(debugSource, it.msg, state = -1) + } + } + + fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String) { + cancelDebug() + debugSource = bookSource.bookSourceUrl + startTime = System.currentTimeMillis() + when { + key.isAbsUrl() -> { + val book = Book() + book.origin = bookSource.bookSourceUrl + book.bookUrl = key + log(bookSource.bookSourceUrl, "⇒开始访问详情页:$key") + infoDebug(scope, bookSource, book) + } + key.contains("::") -> { + val url = key.substringAfter("::") + log(bookSource.bookSourceUrl, "⇒开始访问发现页:$url") + exploreDebug(scope, bookSource, url) + } + key.startsWith("++") -> { + val url = key.substring(2) + val book = Book() + book.origin = bookSource.bookSourceUrl + book.tocUrl = url + log(bookSource.bookSourceUrl, "⇒开始访目录页:$url") + tocDebug(scope, bookSource, book) + } + key.startsWith("--") -> { + val url = key.substring(2) + val book = Book() + book.origin = bookSource.bookSourceUrl + log(bookSource.bookSourceUrl, "⇒开始访正文页:$url") + val chapter = BookChapter() + chapter.title = "调试" + chapter.url = url + contentDebug(scope, bookSource, book, chapter, null) + } + else -> { + log(bookSource.bookSourceUrl, "⇒开始搜索关键字:$key") + searchDebug(scope, bookSource, key) + } + } + } + + private fun exploreDebug(scope: CoroutineScope, bookSource: BookSource, url: String) { + log(debugSource, "︾开始解析发现页") + val explore = WebBook.exploreBook(scope, bookSource, url, 1) + .onSuccess { exploreBooks -> + if (exploreBooks.isNotEmpty()) { + log(debugSource, "︽发现页解析完成") + log(debugSource, showTime = false) + infoDebug(scope, bookSource, exploreBooks[0].toBook()) + } else { + log(debugSource, "︽未获取到书籍", state = -1) + } + } + .onError { + log(debugSource, it.msg, state = -1) + } + tasks.add(explore) + } + + private fun searchDebug(scope: CoroutineScope, bookSource: BookSource, key: String) { + log(debugSource, "︾开始解析搜索页") + val search = WebBook.searchBook(scope, bookSource, key, 1) + .onSuccess { searchBooks -> + if (searchBooks.isNotEmpty()) { + log(debugSource, "︽搜索页解析完成") + log(debugSource, showTime = false) + infoDebug(scope, bookSource, searchBooks[0].toBook()) + } else { + log(debugSource, "︽未获取到书籍", state = -1) + } + } + .onError { + log(debugSource, it.msg, state = -1) + } + tasks.add(search) + } + + private fun infoDebug(scope: CoroutineScope, bookSource: BookSource, book: Book) { + if (book.tocUrl.isNotBlank()) { + log(debugSource, "≡已获取目录链接,跳过详情页") + log(debugSource, showTime = false) + tocDebug(scope, bookSource, book) + return + } + log(debugSource, "︾开始解析详情页") + val info = WebBook.getBookInfo(scope, bookSource, book) + .onSuccess { + log(debugSource, "︽详情页解析完成") + log(debugSource, showTime = false) + tocDebug(scope, bookSource, book) + } + .onError { + log(debugSource, it.msg, state = -1) + } + tasks.add(info) + } + + private fun tocDebug(scope: CoroutineScope, bookSource: BookSource, book: Book) { + log(debugSource, "︾开始解析目录页") + val chapterList = WebBook.getChapterList(scope, bookSource, book) + .onSuccess { + log(debugSource, "︽目录页解析完成") + log(debugSource, showTime = false) + val nextChapterUrl = it.getOrNull(1)?.url + contentDebug(scope, bookSource, book, it[0], nextChapterUrl) + } + .onError { + log(debugSource, it.msg, state = -1) + } + tasks.add(chapterList) + } + + private fun contentDebug( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + bookChapter: BookChapter, + nextChapterUrl: String? + ) { + log(debugSource, "︾开始解析正文页") + val content = WebBook.getContent(scope, bookSource, book, bookChapter, nextChapterUrl) + .onSuccess { + log(debugSource, "︽正文页解析完成", state = 1000) + } + .onError { + log(debugSource, it.msg, state = -1) + } + tasks.add(content) + } + + interface Callback { + fun printLog(state: Int, msg: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/Download.kt b/app/src/main/java/io/legado/app/model/Download.kt new file mode 100644 index 000000000..5df091448 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/Download.kt @@ -0,0 +1,19 @@ +package io.legado.app.model + +import android.content.Context +import io.legado.app.constant.IntentAction +import io.legado.app.service.DownloadService +import io.legado.app.utils.startService + +object Download { + + + fun start(context: Context, url: String, fileName: String) { + context.startService { + action = IntentAction.start + putExtra("url", url) + putExtra("fileName", fileName) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/Exceptions.kt b/app/src/main/java/io/legado/app/model/Exceptions.kt new file mode 100644 index 000000000..01a0026df --- /dev/null +++ b/app/src/main/java/io/legado/app/model/Exceptions.kt @@ -0,0 +1,49 @@ +@file:Suppress("unused") + +package io.legado.app.model + +class AppException(msg: String) : Exception(msg) + +/** + * + */ +class NoStackTraceException(msg: String) : Exception(msg) { + + override fun fillInStackTrace(): Throwable { + return this + } + +} + +/** + * 目录为空 + */ +class TocEmptyException(msg: String) : Exception(msg) { + + override fun fillInStackTrace(): Throwable { + return this + } + +} + +/** + * 内容为空 + */ +class ContentEmptyException(msg: String) : Exception(msg) { + + override fun fillInStackTrace(): Throwable { + return this + } + +} + +/** + * 并发限制 + */ +class ConcurrentException(msg: String, val waitTime: Int) : Exception(msg) { + + override fun fillInStackTrace(): Throwable { + return this + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/README.md b/app/src/main/java/io/legado/app/model/README.md new file mode 100644 index 000000000..150cfb8bd --- /dev/null +++ b/app/src/main/java/io/legado/app/model/README.md @@ -0,0 +1,6 @@ +# 放置一些模块类 +* analyzeRule 书源规则解析 +* localBook 本地书籍解析 +* rss 订阅规则解析 +* webBook 获取网络书籍 + diff --git a/app/src/main/java/io/legado/app/model/ReadAloud.kt b/app/src/main/java/io/legado/app/model/ReadAloud.kt new file mode 100644 index 000000000..65f5ee6eb --- /dev/null +++ b/app/src/main/java/io/legado/app/model/ReadAloud.kt @@ -0,0 +1,105 @@ +package io.legado.app.model + +import android.content.Context +import android.content.Intent +import io.legado.app.constant.IntentAction +import io.legado.app.data.appDb +import io.legado.app.data.entities.HttpTTS +import io.legado.app.help.AppConfig +import io.legado.app.service.BaseReadAloudService +import io.legado.app.service.HttpReadAloudService +import io.legado.app.service.TTSReadAloudService +import io.legado.app.utils.StringUtils +import splitties.init.appCtx + +object ReadAloud { + private var aloudClass: Class<*> = getReadAloudClass() + var httpTTS: HttpTTS? = null + + private fun getReadAloudClass(): Class<*> { + val ttsEngine = AppConfig.ttsEngine + if (ttsEngine.isNullOrBlank()) { + return TTSReadAloudService::class.java + } + if (StringUtils.isNumeric(ttsEngine)) { + httpTTS = appDb.httpTTSDao.get(ttsEngine.toLong()) + if (httpTTS != null) { + return HttpReadAloudService::class.java + } + } + return TTSReadAloudService::class.java + } + + fun upReadAloudClass() { + stop(appCtx) + aloudClass = getReadAloudClass() + } + + fun play( + context: Context, + play: Boolean = true + ) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.play + intent.putExtra("play", play) + context.startService(intent) + } + + fun pause(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.pause + context.startService(intent) + } + } + + fun resume(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.resume + context.startService(intent) + } + } + + fun stop(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.stop + context.startService(intent) + } + } + + fun prevParagraph(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.prevParagraph + context.startService(intent) + } + } + + fun nextParagraph(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.nextParagraph + context.startService(intent) + } + } + + fun upTtsSpeechRate(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.upTtsSpeechRate + context.startService(intent) + } + } + + fun setTimer(context: Context, minute: Int) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = IntentAction.setTimer + intent.putExtra("minute", minute) + context.startService(intent) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt new file mode 100644 index 000000000..cbf85bf4f --- /dev/null +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -0,0 +1,472 @@ +package io.legado.app.model + +import io.legado.app.constant.AppLog +import io.legado.app.constant.BookType +import io.legado.app.data.appDb +import io.legado.app.data.entities.* +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.help.ContentProcessor +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.storage.AppWebDav +import io.legado.app.model.webBook.WebBook +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.ui.book.read.page.provider.ImageProvider +import io.legado.app.utils.msg + +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import splitties.init.appCtx +import timber.log.Timber + + +@Suppress("MemberVisibilityCanBePrivate") +object ReadBook : CoroutineScope by MainScope() { + var book: Book? = null + var callBack: CallBack? = null + var inBookshelf = false + var chapterSize = 0 + var durChapterIndex = 0 + var durChapterPos = 0 + var isLocalBook = true + var prevTextChapter: TextChapter? = null + var curTextChapter: TextChapter? = null + var nextTextChapter: TextChapter? = null + var bookSource: BookSource? = null + var msg: String? = null + private val loadingChapters = arrayListOf() + private val readRecord = ReadRecord() + var readStartTime: Long = System.currentTimeMillis() + + fun resetData(book: Book) { + ReadBook.book = book + readRecord.bookName = book.name + readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0 + chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) + durChapterIndex = book.durChapterIndex + durChapterPos = book.durChapterPos + isLocalBook = book.origin == BookType.local + clearTextChapter() + callBack?.upMenuView() + callBack?.upPageAnim() + upWebBook(book) + ImageProvider.clearAllCache() + synchronized(this) { + loadingChapters.clear() + } + } + + fun upData(book: Book) { + ReadBook.book = book + chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) + if (durChapterIndex != book.durChapterIndex) { + durChapterIndex = book.durChapterIndex + durChapterPos = book.durChapterPos + clearTextChapter() + } + callBack?.upMenuView() + upWebBook(book) + } + + fun upWebBook(book: Book) { + if (book.origin == BookType.local) { + bookSource = null + } else { + appDb.bookSourceDao.getBookSource(book.origin)?.let { + bookSource = it + if (book.getImageStyle().isNullOrBlank()) { + book.setImageStyle(it.getContentRule().imageStyle) + } + } ?: let { + bookSource = null + } + } + } + + fun setProgress(progress: BookProgress) { + if (durChapterIndex != progress.durChapterIndex + || durChapterPos != progress.durChapterPos + ) { + durChapterIndex = progress.durChapterIndex + durChapterPos = progress.durChapterPos + clearTextChapter() + loadContent(resetPageOffset = true) + } + } + + fun clearTextChapter() { + prevTextChapter = null + curTextChapter = null + nextTextChapter = null + } + + fun uploadProgress() { + book?.let { + AppWebDav.uploadBookProgress(it) + } + } + + fun upReadStartTime() { + Coroutine.async { + readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime + readStartTime = System.currentTimeMillis() + appDb.readRecordDao.insert(readRecord) + } + } + + fun upMsg(msg: String?) { + if (ReadBook.msg != msg) { + ReadBook.msg = msg + callBack?.upContent() + } + } + + fun moveToNextPage() { + durChapterPos = curTextChapter?.getNextPageLength(durChapterPos) ?: durChapterPos + callBack?.upContent() + saveRead() + } + + fun moveToNextChapter(upContent: Boolean): Boolean { + if (durChapterIndex < chapterSize - 1) { + durChapterPos = 0 + durChapterIndex++ + prevTextChapter = curTextChapter + curTextChapter = nextTextChapter + nextTextChapter = null + if (curTextChapter == null) { + loadContent(durChapterIndex, upContent, false) + } else if (upContent) { + callBack?.upContent() + } + loadContent(durChapterIndex.plus(1), upContent, false) + saveRead() + callBack?.upMenuView() + curPageChanged() + return true + } else { + return false + } + } + + fun moveToPrevChapter( + upContent: Boolean, + toLast: Boolean = true + ): Boolean { + if (durChapterIndex > 0) { + durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: 0 else 0 + durChapterIndex-- + nextTextChapter = curTextChapter + curTextChapter = prevTextChapter + prevTextChapter = null + if (curTextChapter == null) { + loadContent(durChapterIndex, upContent, false) + } else if (upContent) { + callBack?.upContent() + } + loadContent(durChapterIndex.minus(1), upContent, false) + saveRead() + callBack?.upMenuView() + curPageChanged() + return true + } else { + return false + } + } + + fun skipToPage(index: Int, success: (() -> Unit)? = null) { + durChapterPos = curTextChapter?.getReadLength(index) ?: index + callBack?.upContent { + success?.invoke() + } + curPageChanged() + saveRead() + } + + fun setPageIndex(index: Int) { + durChapterPos = curTextChapter?.getReadLength(index) ?: index + saveRead() + curPageChanged() + } + + /** + * 当前页面变化 + */ + private fun curPageChanged() { + callBack?.pageChanged() + if (BaseReadAloudService.isRun) { + readAloud(!BaseReadAloudService.pause) + } + upReadStartTime() + preDownload() + ImageProvider.clearOut(durChapterIndex) + } + + /** + * 朗读 + */ + fun readAloud(play: Boolean = true) { + book?.let { + ReadAloud.play(appCtx, play) + } + } + + /** + * 当前页数 + */ + fun durPageIndex(): Int { + curTextChapter?.let { + return it.getPageIndexByCharIndex(durChapterPos) + } + return durChapterPos + } + + /** + * chapterOnDur: 0为当前页,1为下一页,-1为上一页 + */ + fun textChapter(chapterOnDur: Int = 0): TextChapter? { + return when (chapterOnDur) { + 0 -> curTextChapter + 1 -> nextTextChapter + -1 -> prevTextChapter + else -> null + } + } + + /** + * 加载章节内容 + */ + fun loadContent(resetPageOffset: Boolean, success: (() -> Unit)? = null) { + loadContent(durChapterIndex, resetPageOffset = resetPageOffset) { + success?.invoke() + } + loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset) + loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset) + } + + fun loadContent( + index: Int, + upContent: Boolean = true, + resetPageOffset: Boolean = false, + success: (() -> Unit)? = null + ) { + if (addLoading(index)) { + Coroutine.async { + val book = book!! + appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { chapter -> + BookHelp.getContent(book, chapter)?.let { + contentLoadFinish(book, chapter, it, upContent, resetPageOffset) { + success?.invoke() + } + removeLoading(chapter.index) + } ?: download(this, chapter, resetPageOffset = resetPageOffset) + } ?: removeLoading(index) + }.onError { + removeLoading(index) + AppLog.put("加载正文出错\n${it.localizedMessage}") + } + } + } + + private fun download(index: Int) { + if (index < 0) return + if (index > chapterSize - 1) { + upToc() + return + } + book?.let { book -> + if (book.isLocalBook()) return + if (addLoading(index)) { + Coroutine.async { + appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { chapter -> + if (BookHelp.hasContent(book, chapter)) { + removeLoading(chapter.index) + } else { + download(this, chapter, false) + } + } ?: removeLoading(index) + }.onError { + removeLoading(index) + } + } + } + } + + private fun download( + scope: CoroutineScope, + chapter: BookChapter, + resetPageOffset: Boolean, + success: (() -> Unit)? = null + ) { + val book = book + val bookSource = bookSource + if (book != null && bookSource != null) { + CacheBook.getOrCreate(bookSource, book).download(scope, chapter) + } else if (book != null) { + contentLoadFinish( + book, chapter, "没有书源", resetPageOffset = resetPageOffset + ) { + success?.invoke() + } + removeLoading(chapter.index) + } else { + removeLoading(chapter.index) + } + } + + private fun addLoading(index: Int): Boolean { + synchronized(this) { + if (loadingChapters.contains(index)) return false + loadingChapters.add(index) + return true + } + } + + fun removeLoading(index: Int) { + synchronized(this) { + loadingChapters.remove(index) + } + } + + /** + * 内容加载完成 + */ + fun contentLoadFinish( + book: Book, + chapter: BookChapter, + content: String, + upContent: Boolean = true, + resetPageOffset: Boolean, + success: (() -> Unit)? = null + ) { + Coroutine.async { + removeLoading(chapter.index) + if (chapter.index in durChapterIndex - 1..durChapterIndex + 1) { + val contentProcessor = ContentProcessor.get(book.name, book.origin) + val displayTitle = chapter.getDisplayTitle( + contentProcessor.getReplaceRules(), + book.getUseReplaceRule() + ) + val contents = contentProcessor.getContent(book, chapter, content) + val textChapter = ChapterProvider + .getTextChapter(book, chapter, displayTitle, contents, chapterSize) + when (val offset = chapter.index - durChapterIndex) { + 0 -> { + curTextChapter = textChapter + if (upContent) callBack?.upContent(offset, resetPageOffset) + callBack?.upMenuView() + curPageChanged() + callBack?.contentLoadFinish() + } + -1 -> { + prevTextChapter = textChapter + if (upContent) callBack?.upContent(offset, resetPageOffset) + } + 1 -> { + nextTextChapter = textChapter + if (upContent) callBack?.upContent(offset, resetPageOffset) + } + } + } + }.onError { + Timber.e(it) + appCtx.toastOnUi("ChapterProvider ERROR:\n${it.msg}") + }.onSuccess { + success?.invoke() + } + } + + @Synchronized + fun upToc() { + val bookSource = bookSource ?: return + val book = book ?: return + if (System.currentTimeMillis() - book.lastCheckTime < 600000) return + book.lastCheckTime = System.currentTimeMillis() + WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList -> + if (book.bookUrl == ReadBook.book?.bookUrl + && cList.size > chapterSize + ) { + appDb.bookChapterDao.insert(*cList.toTypedArray()) + chapterSize = cList.size + nextTextChapter ?: loadContent(1) + } + } + } + + fun pageAnim(): Int { + book?.let { + return if (it.getPageAnim() < 0) + ReadBookConfig.pageAnim + else + it.getPageAnim() + } + return ReadBookConfig.pageAnim + } + + fun setCharset(charset: String) { + book?.let { + it.charset = charset + callBack?.loadChapterList(it) + } + saveRead() + } + + fun saveRead() { + Coroutine.async { + book?.let { book -> + book.lastCheckCount = 0 + book.durChapterTime = System.currentTimeMillis() + book.durChapterIndex = durChapterIndex + book.durChapterPos = durChapterPos + appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let { + book.durChapterTitle = it.title + } + appDb.bookDao.update(book) + } + } + } + + /** + * 预下载 + */ + private fun preDownload() { + Coroutine.async { + //预下载 + val maxChapterIndex = durChapterIndex + AppConfig.preDownloadNum + for (i in durChapterIndex.plus(2)..maxChapterIndex) { + delay(1000) + download(i) + } + val minChapterIndex = durChapterIndex - 5 + for (i in durChapterIndex.minus(2) downTo minChapterIndex) { + delay(1000) + download(i) + } + } + } + + interface CallBack { + fun upMenuView() + + fun loadChapterList(book: Book) + + fun upContent( + relativePosition: Int = 0, + resetPageOffset: Boolean = true, + success: (() -> Unit)? = null + ) + + fun pageChanged() + + fun contentLoadFinish() + + fun upPageAnim() + } + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt new file mode 100644 index 000000000..084624513 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt @@ -0,0 +1,173 @@ +package io.legado.app.model.analyzeRule + +import androidx.annotation.Keep +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.ReadContext + +import timber.log.Timber +import java.util.* + +@Suppress("RegExpRedundantEscape") +@Keep +class AnalyzeByJSonPath(json: Any) { + + companion object { + + fun parse(json: Any): ReadContext { + return when (json) { + is ReadContext -> json + is String -> JsonPath.parse(json) //JsonPath.parse(json) + else -> JsonPath.parse(json) //JsonPath.parse(json) + } + } + } + + private var ctx: ReadContext = parse(json) + + /** + * 改进解析方法 + * 解决阅读”&&“、”||“与jsonPath支持的”&&“、”||“之间的冲突 + * 解决{$.rule}形式规则可能匹配错误的问题,旧规则用正则解析内容含‘}’的json文本时,用规则中的字段去匹配这种内容会匹配错误.现改用平衡嵌套方法解决这个问题 + * */ + fun getString(rule: String): String? { + if (rule.isEmpty()) return null + var result: String + val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡 + val rules = ruleAnalyzes.splitRule("&&", "||") + + if (rules.size == 1) { + + ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器 + + result = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...} + + if (result.isEmpty()) { //st为空,表明无成功替换的内嵌规则 + try { + val ob = ctx.read(rule) + result = if (ob is List<*>) { + ob.joinToString("\n") + } else { + ob.toString() + } + } catch (e: Exception) { + Timber.e(e) + } + } + return result + } else { + val textList = arrayListOf() + for (rl in rules) { + val temp = getString(rl) + if (!temp.isNullOrEmpty()) { + textList.add(temp) + if (ruleAnalyzes.elementsType == "||") { + break + } + } + } + return textList.joinToString("\n") + } + } + + internal fun getStringList(rule: String): List { + val result = ArrayList() + if (rule.isEmpty()) return result + val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡 + val rules = ruleAnalyzes.splitRule("&&", "||", "%%") + + if (rules.size == 1) { + ruleAnalyzes.reSetPos() //将pos重置为0,复用解析器 + val st = ruleAnalyzes.innerRule("{$.") { getString(it) } //替换所有{$.rule...} + if (st.isEmpty()) { //st为空,表明无成功替换的内嵌规则 + try { + val obj = ctx.read(rule) + if (obj is List<*>) { + for (o in obj) result.add(o.toString()) + } else { + result.add(obj.toString()) + } + } catch (e: Exception) { + Timber.e(e) + } + } else { + result.add(st) + } + return result + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getStringList(rl) + if (temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { + break + } + } + } + if (results.size > 0) { + if ("%%" == ruleAnalyzes.elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + result.add(temp[i]) + } + } + } + } else { + for (temp in results) { + result.addAll(temp) + } + } + } + return result + } + } + + internal fun getObject(rule: String): Any { + return ctx.read(rule) + } + + internal fun getList(rule: String): ArrayList? { + val result = ArrayList() + if (rule.isEmpty()) return result + val ruleAnalyzes = RuleAnalyzer(rule, true) //设置平衡组为代码平衡 + val rules = ruleAnalyzes.splitRule("&&", "||", "%%") + if (rules.size == 1) { + ctx.let { + try { + return it.read>(rules[0]) + } catch (e: Exception) { + Timber.e(e) + } + } + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getList(rl) + if (temp != null && temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { + break + } + } + } + if (results.size > 0) { + if ("%%" == ruleAnalyzes.elementsType) { + for (i in 0 until results[0].size) { + for (temp in results) { + if (i < temp.size) { + temp[i]?.let { result.add(it) } + } + } + } + } else { + for (temp in results) { + result.addAll(temp) + } + } + } + } + return result + } + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt new file mode 100644 index 000000000..45ea15c8f --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt @@ -0,0 +1,492 @@ +package io.legado.app.model.analyzeRule + +import androidx.annotation.Keep +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.select.Collector +import org.jsoup.select.Elements +import org.jsoup.select.Evaluator +import org.seimicrawler.xpath.JXNode + +/** + * Created by GKF on 2018/1/25. + * 书源规则解析 + */ +@Keep +class AnalyzeByJSoup(doc: Any) { + companion object { + + fun parse(doc: Any): Element { + return when (doc) { + is Element -> doc + is JXNode -> if (doc.isElement) doc.asElement() else Jsoup.parse(doc.toString()) + else -> Jsoup.parse(doc.toString()) + } + } + + } + + private var element: Element = parse(doc) + + /** + * 获取列表 + */ + internal fun getElements(rule: String) = getElements(element, rule) + + /** + * 合并内容列表,得到内容 + */ + internal fun getString(ruleStr: String) = + if (ruleStr.isEmpty()) null + else getStringList(ruleStr).takeIf { it.isNotEmpty() }?.joinToString("\n") + + /** + * 获取一个字符串 + */ + internal fun getString0(ruleStr: String) = + getStringList(ruleStr).let { if (it.isEmpty()) "" else it[0] } + + /** + * 获取所有内容列表 + */ + internal fun getStringList(ruleStr: String): List { + + val textS = ArrayList() + + if (ruleStr.isEmpty()) return textS + + //拆分规则 + val sourceRule = SourceRule(ruleStr) + + if (sourceRule.elementsRule.isEmpty()) { + + textS.add(element.data() ?: "") + + } else { + + val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule) + val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%") + + val results = ArrayList>() + for (ruleStrX in ruleStrS) { + + val temp: ArrayList? = + if (sourceRule.isCss) { + val lastIndex = ruleStrX.lastIndexOf('@') + getResultLast( + element.select(ruleStrX.substring(0, lastIndex)), + ruleStrX.substring(lastIndex + 1) + ) + } else { + getResultList(ruleStrX) + } + + if (!temp.isNullOrEmpty()) { + results.add(temp) + if (ruleAnalyzes.elementsType == "||") break + } + } + if (results.size > 0) { + if ("%%" == ruleAnalyzes.elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + textS.add(temp[i]) + } + } + } + } else { + for (temp in results) { + textS.addAll(temp) + } + } + } + } + return textS + } + + /** + * 获取Elements + */ + private fun getElements(temp: Element?, rule: String): Elements { + + if (temp == null || rule.isEmpty()) return Elements() + + val elements = Elements() + + val sourceRule = SourceRule(rule) + val ruleAnalyzes = RuleAnalyzer(sourceRule.elementsRule) + val ruleStrS = ruleAnalyzes.splitRule("&&", "||", "%%") + + val elementsList = ArrayList() + if (sourceRule.isCss) { + for (ruleStr in ruleStrS) { + val tempS = temp.select(ruleStr) + elementsList.add(tempS) + if (tempS.size > 0 && ruleAnalyzes.elementsType == "||") { + break + } + } + } else { + for (ruleStr in ruleStrS) { + + val rsRule = RuleAnalyzer(ruleStr) + + rsRule.trim() // 修剪当前规则之前的"@"或者空白符 + + val rs = rsRule.splitRule("@") + + val el = if (rs.size > 1) { + val el = Elements() + el.add(temp) + for (rl in rs) { + val es = Elements() + for (et in el) { + es.addAll(getElements(et, rl)) + } + el.clear() + el.addAll(es) + } + el + } else ElementsSingle().getElementsSingle(temp, ruleStr) + + elementsList.add(el) + if (el.size > 0 && ruleAnalyzes.elementsType == "||") { + break + } + } + } + if (elementsList.size > 0) { + if ("%%" == ruleAnalyzes.elementsType) { + for (i in 0 until elementsList[0].size) { + for (es in elementsList) { + if (i < es.size) { + elements.add(es[i]) + } + } + } + } else { + for (es in elementsList) { + elements.addAll(es) + } + } + } + return elements + } + + /** + * 获取内容列表 + */ + private fun getResultList(ruleStr: String): ArrayList? { + + if (ruleStr.isEmpty()) return null + + var elements = Elements() + + elements.add(element) + + val rule = RuleAnalyzer(ruleStr) //创建解析 + + rule.trim() //修建前置赘余符号 + + val rules = rule.splitRule("@") // 切割成列表 + + val last = rules.size - 1 + for (i in 0 until last) { + val es = Elements() + for (elt in elements) { + es.addAll(ElementsSingle().getElementsSingle(elt, rules[i])) + } + elements.clear() + elements = es + } + return if (elements.isEmpty()) null else getResultLast(elements, rules[last]) + } + + /** + * 根据最后一个规则获取内容 + */ + private fun getResultLast(elements: Elements, lastRule: String): ArrayList { + val textS = ArrayList() + when (lastRule) { + "text" -> for (element in elements) { + val text = element.text() + if (text.isNotEmpty()) { + textS.add(text) + } + } + "textNodes" -> for (element in elements) { + val tn = arrayListOf() + val contentEs = element.textNodes() + for (item in contentEs) { + val text = item.text().trim { it <= ' ' } + if (text.isNotEmpty()) { + tn.add(text) + } + } + if (tn.isNotEmpty()) { + textS.add(tn.joinToString("\n")) + } + } + "ownText" -> for (element in elements) { + val text = element.ownText() + if (text.isNotEmpty()) { + textS.add(text) + } + } + "html" -> { + elements.select("script").remove() + elements.select("style").remove() + val html = elements.outerHtml() + if (html.isNotEmpty()) { + textS.add(html) + } + } + "all" -> textS.add(elements.outerHtml()) + else -> for (element in elements) { + + val url = element.attr(lastRule) + + if (url.isBlank() || textS.contains(url)) continue + + textS.add(url) + } + } + return textS + } + + /** + * 1.支持阅读原有写法,':'分隔索引,!或.表示筛选方式,索引可为负数 + * 例如 tag.div.-1:10:2 或 tag.div!0:3 + * + * 2. 支持与jsonPath类似的[]索引写法 + * 格式形如 [it,it,。。。] 或 [!it,it,。。。] 其中[!开头表示筛选方式为排除,it为单个索引或区间。 + * 区间格式为 start:end 或 start:end:step,其中start为0可省略,end为-1可省略。 + * 索引,区间两端及间隔都支持负数 + * 例如 tag.div[-1, 3:-2:-10, 2] + * 特殊用法 tag.div[-1:0] 可在任意地方让列表反向 + * */ + @Suppress("UNCHECKED_CAST") + data class ElementsSingle( + var split: Char = '.', + var beforeRule: String = "", + val indexDefault: MutableList = mutableListOf(), + val indexes: MutableList = mutableListOf() + ) { + /** + * 获取Elements按照一个规则 + */ + fun getElementsSingle(temp: Element, rule: String): Elements { + + findIndexSet(rule) //执行索引列表处理器 + + /** + * 获取所有元素 + * */ + var elements = + if (beforeRule.isEmpty()) temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同 + else { + val rules = beforeRule.split(".") + when (rules[0]) { + "children" -> temp.children() //允许索引直接作为根元素,此时前置规则为空,效果与children相同 + "class" -> temp.getElementsByClass(rules[1]) + "tag" -> temp.getElementsByTag(rules[1]) + "id" -> Collector.collect(Evaluator.Id(rules[1]), temp) + "text" -> temp.getElementsContainingOwnText(rules[1]) + else -> temp.select(beforeRule) + } + } + + val len = elements.size + val lastIndexes = (indexDefault.size - 1).takeIf { it != -1 } ?: indexes.size - 1 + val indexSet = mutableSetOf() + + /** + * 获取无重且不越界的索引集合 + * */ + if (indexes.isEmpty()) for (ix in lastIndexes downTo 0) { //indexes为空,表明是非[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序 + + val it = indexDefault[ix] + if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合 + else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合 + + } else for (ix in lastIndexes downTo 0) { //indexes不空,表明是[]式索引,集合是逆向遍历插入的,所以这里也逆向遍历,好还原顺序 + + if (indexes[ix] is Triple<*, *, *>) { //区间 + val (startX, endX, stepX) = indexes[ix] as Triple //还原储存时的类型 + + val start = if (startX == null) 0 //左端省略表示0 + else if (startX >= 0) if (startX < len) startX else len - 1 //右端越界,设置为最大索引 + else if (-startX <= len) len + startX /* 将负索引转正 */ else 0 //左端越界,设置为最小索引 + + val end = if (endX == null) len - 1 //右端省略表示 len - 1 + else if (endX >= 0) if (endX < len) endX else len - 1 //右端越界,设置为最大索引 + else if (-endX <= len) len + endX /* 将负索引转正 */ else 0 //左端越界,设置为最小索引 + + if (start == end || stepX >= len) { //两端相同,区间里只有一个数。或间隔过大,区间实际上仅有首位 + + indexSet.add(start) + continue + + } + + val step = + if (stepX > 0) stepX else if (-stepX < len) stepX + len else 1 //最小正数间隔为1 + + //将区间展开到集合中,允许列表反向。 + indexSet.addAll(if (end > start) start..end step step else start downTo end step step) + + } else {//单个索引 + + val it = indexes[ix] as Int //还原储存时的类型 + + if (it in 0 until len) indexSet.add(it) //将正数不越界的索引添加到集合 + else if (it < 0 && len >= -it) indexSet.add(it + len) //将负数不越界的索引添加到集合 + + } + + } + + /** + * 根据索引集合筛选元素 + * */ + if (split == '!') { //排除 + + for (pcInt in indexSet) elements[pcInt] = null + + elements.removeAll(listOf(null)) //测试过,这样就行 + + } else if (split == '.') { //选择 + + val es = Elements() + + for (pcInt in indexSet) es.add(elements[pcInt]) + + elements = es + + } + + return elements //返回筛选结果 + + } + + private fun findIndexSet(rule: String) { + + val rus = rule.trim { it <= ' ' } + + var len = rus.length + var curInt: Int? //当前数字 + var curMinus = false //当前数字是否为负 + val curList = mutableListOf() //当前数字区间 + var l = "" //暂存数字字符串 + + val head = rus.last() == ']' //是否为常规索引写法 + + if (head) { //常规索引写法[index...] + + len-- //跳过尾部']' + + while (len-- >= 0) { //逆向遍历,可以无前置规则 + + var rl = rus[len] + if (rl == ' ') continue //跳过空格 + + if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出 + else if (rl == '-') curMinus = true + else { + + curInt = + if (l.isEmpty()) null else if (curMinus) -l.toInt() else l.toInt() //当前数字 + + when (rl) { + + ':' -> curList.add(curInt) //区间右端或区间间隔 + + else -> { + + //为保证查找顺序,区间和单个索引都添加到同一集合 + if (curList.isEmpty()) { + + if (curInt == null) break //是jsoup选择器而非索引列表,跳出 + + indexes.add(curInt) + } else { + + //列表最后压入的是区间右端,若列表有两位则最先压入的是间隔 + indexes.add( + Triple( + curInt, + curList.last(), + if (curList.size == 2) curList.first() else 1 + ) + ) + + curList.clear() //重置临时列表,避免影响到下个区间的处理 + + } + + if (rl == '!') { + split = '!' + do { + rl = rus[--len] + } while (len > 0 && rl == ' ')//跳过所有空格 + } + + if (rl == '[') { + beforeRule = rus.substring(0, len) //遇到索引边界,返回结果 + return + } + + if (rl != ',') break //非索引结构,跳出 + + } + } + + l = "" //清空 + curMinus = false //重置 + } + } + } else while (len-- >= 0) { //阅读原本写法,逆向遍历,可以无前置规则 + + val rl = rus[len] + if (rl == ' ') continue //跳过空格 + + if (rl in '0'..'9') l = rl + l //将数值累接入临时字串中,遇到分界符才取出 + else if (rl == '-') curMinus = true + else { + + if (rl == '!' || rl == '.' || rl == ':') { //分隔符或起始符 + + indexDefault.add(if (curMinus) -l.toInt() else l.toInt()) // 当前数字追加到列表 + + if (rl != ':') { //rl == '!' || rl == '.' + split = rl + beforeRule = rus.substring(0, len) + return + } + + } else break //非索引结构,跳出循环 + + l = "" //清空 + curMinus = false //重置 + } + + } + + split = ' ' + beforeRule = rus + } + } + + + internal inner class SourceRule(ruleStr: String) { + var isCss = false + var elementsRule: String = if (ruleStr.startsWith("@CSS:", true)) { + isCss = true + ruleStr.substring(5).trim { it <= ' ' } + } else { + ruleStr + } + } + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt new file mode 100644 index 000000000..05851b423 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt @@ -0,0 +1,61 @@ +package io.legado.app.model.analyzeRule + +import androidx.annotation.Keep +import java.util.* +import java.util.regex.Pattern + +@Keep +object AnalyzeByRegex { + + fun getElement(res: String, regs: Array, index: Int = 0): List? { + var vIndex = index + val resM = Pattern.compile(regs[vIndex]).matcher(res) + if (!resM.find()) { + return null + } + // 判断索引的规则是最后一个规则 + return if (vIndex + 1 == regs.size) { + // 新建容器 + val info = arrayListOf() + for (groupIndex in 0..resM.groupCount()) { + info.add(resM.group(groupIndex)!!) + } + info + } else { + val result = StringBuilder() + do { + result.append(resM.group()) + } while (resM.find()) + getElement(result.toString(), regs, ++vIndex) + } + } + + fun getElements(res: String, regs: Array, index: Int = 0): List> { + var vIndex = index + val resM = Pattern.compile(regs[vIndex]).matcher(res) + if (!resM.find()) { + return arrayListOf() + } + // 判断索引的规则是最后一个规则 + if (vIndex + 1 == regs.size) { + // 创建书息缓存数组 + val books = ArrayList>() + // 提取列表 + do { + // 新建容器 + val info = arrayListOf() + for (groupIndex in 0..resM.groupCount()) { + info.add(resM.group(groupIndex)!!) + } + books.add(info) + } while (resM.find()) + return books + } else { + val result = StringBuilder() + do { + result.append(resM.group()) + } while (resM.find()) + return getElements(result.toString(), regs, ++vIndex) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt new file mode 100644 index 000000000..a67d934f2 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt @@ -0,0 +1,149 @@ +package io.legado.app.model.analyzeRule + +import android.text.TextUtils +import androidx.annotation.Keep +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import org.seimicrawler.xpath.JXDocument +import org.seimicrawler.xpath.JXNode +import java.util.* + +@Keep +class AnalyzeByXPath(doc: Any) { + private var jxNode: Any = parse(doc) + + private fun parse(doc: Any): Any { + return when (doc) { + is JXNode -> if (doc.isElement) doc else strToJXDocument(doc.toString()) + is Document -> JXDocument.create(doc) + is Element -> JXDocument.create(Elements(doc)) + is Elements -> JXDocument.create(doc) + else -> strToJXDocument(doc.toString()) + } + } + + private fun strToJXDocument(html: String): JXDocument { + var html1 = html + if (html1.endsWith("")) { + html1 = "${html1}" + } + if (html1.endsWith("") || html1.endsWith("")) { + html1 = "${html1}
    " + } + return JXDocument.create(html1) + } + + private fun getResult(xPath: String): List? { + val node = jxNode + return if (node is JXNode) { + node.sel(xPath) + } else { + (node as JXDocument).selN(xPath) + } + } + + internal fun getElements(xPath: String): List? { + + if (xPath.isEmpty()) return null + + val jxNodes = ArrayList() + val ruleAnalyzes = RuleAnalyzer(xPath) + val rules = ruleAnalyzes.splitRule("&&", "||", "%%") + + if (rules.size == 1) { + return getResult(rules[0]) + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getElements(rl) + if (temp != null && temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { + break + } + } + } + if (results.size > 0) { + if ("%%" == ruleAnalyzes.elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + jxNodes.add(temp[i]) + } + } + } + } else { + for (temp in results) { + jxNodes.addAll(temp) + } + } + } + } + return jxNodes + } + + internal fun getStringList(xPath: String): List { + + val result = ArrayList() + val ruleAnalyzes = RuleAnalyzer(xPath) + val rules = ruleAnalyzes.splitRule("&&", "||", "%%") + + if (rules.size == 1) { + getResult(xPath)?.map { + result.add(it.asString()) + } + return result + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getStringList(rl) + if (temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && ruleAnalyzes.elementsType == "||") { + break + } + } + } + if (results.size > 0) { + if ("%%" == ruleAnalyzes.elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + result.add(temp[i]) + } + } + } + } else { + for (temp in results) { + result.addAll(temp) + } + } + } + } + return result + } + + fun getString(rule: String): String? { + val ruleAnalyzes = RuleAnalyzer(rule) + val rules = ruleAnalyzes.splitRule("&&", "||") + if (rules.size == 1) { + getResult(rule)?.let { + return TextUtils.join("\n", it) + } + return null + } else { + val textList = arrayListOf() + for (rl in rules) { + val temp = getString(rl) + if (!temp.isNullOrEmpty()) { + textList.add(temp) + if (ruleAnalyzes.elementsType == "||") { + break + } + } + } + return textList.joinToString("\n") + } + } +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt new file mode 100644 index 000000000..1e1b87915 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt @@ -0,0 +1,695 @@ +package io.legado.app.model.analyzeRule + +import android.text.TextUtils +import androidx.annotation.Keep +import io.legado.app.constant.AppConst.SCRIPT_ENGINE +import io.legado.app.constant.AppPattern.JS_PATTERN +import io.legado.app.data.entities.BaseBook +import io.legado.app.data.entities.BaseSource +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.CacheManager +import io.legado.app.help.JsExtensions +import io.legado.app.help.http.CookieStore +import io.legado.app.utils.* +import kotlinx.coroutines.runBlocking +import org.jsoup.nodes.Entities +import org.mozilla.javascript.NativeObject +import timber.log.Timber +import java.net.URL +import java.util.* +import java.util.regex.Pattern +import javax.script.SimpleBindings +import kotlin.collections.HashMap + +/** + * 解析规则获取结果 + */ +@Keep +@Suppress("unused", "RegExpRedundantEscape", "MemberVisibilityCanBePrivate") +class AnalyzeRule( + val ruleData: RuleDataInterface, + private val source: BaseSource? = null +) : JsExtensions { + + var book = if (ruleData is BaseBook) ruleData else null + + var chapter: BookChapter? = null + var nextChapterUrl: String? = null + var content: Any? = null + private set + var baseUrl: String? = null + private set + var redirectUrl: URL? = null + private set + private var isJSON: Boolean = false + private var isRegex: Boolean = false + + private var analyzeByXPath: AnalyzeByXPath? = null + private var analyzeByJSoup: AnalyzeByJSoup? = null + private var analyzeByJSonPath: AnalyzeByJSonPath? = null + + private var objectChangedXP = false + private var objectChangedJS = false + private var objectChangedJP = false + + @JvmOverloads + fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule { + if (content == null) throw AssertionError("内容不可空(Content cannot be null)") + this.content = content + isJSON = content.toString().isJson() + setBaseUrl(baseUrl) + objectChangedXP = true + objectChangedJS = true + objectChangedJP = true + return this + } + + fun setBaseUrl(baseUrl: String?): AnalyzeRule { + baseUrl?.let { + this.baseUrl = baseUrl + } + return this + } + + fun setRedirectUrl(url: String): URL? { + try { + redirectUrl = URL(url) + } catch (e: Exception) { + log("URL($url) error\n${e.localizedMessage}") + } + return redirectUrl + } + + /** + * 获取XPath解析类 + */ + private fun getAnalyzeByXPath(o: Any): AnalyzeByXPath { + return if (o != content) { + AnalyzeByXPath(o) + } else { + if (analyzeByXPath == null || objectChangedXP) { + analyzeByXPath = AnalyzeByXPath(content!!) + objectChangedXP = false + } + analyzeByXPath!! + } + } + + /** + * 获取JSOUP解析类 + */ + private fun getAnalyzeByJSoup(o: Any): AnalyzeByJSoup { + return if (o != content) { + AnalyzeByJSoup(o) + } else { + if (analyzeByJSoup == null || objectChangedJS) { + analyzeByJSoup = AnalyzeByJSoup(content!!) + objectChangedJS = false + } + analyzeByJSoup!! + } + } + + /** + * 获取JSON解析类 + */ + private fun getAnalyzeByJSonPath(o: Any): AnalyzeByJSonPath { + return if (o != content) { + AnalyzeByJSonPath(o) + } else { + if (analyzeByJSonPath == null || objectChangedJP) { + analyzeByJSonPath = AnalyzeByJSonPath(content!!) + objectChangedJP = false + } + analyzeByJSonPath!! + } + } + + /** + * 获取文本列表 + */ + @JvmOverloads + fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List? { + if (rule.isNullOrEmpty()) return null + val ruleList = splitSourceRule(rule, false) + return getStringList(ruleList, mContent, isUrl) + } + + @JvmOverloads + fun getStringList( + ruleList: List, + mContent: Any? = null, + isUrl: Boolean = false + ): List? { + var result: Any? = null + val content = mContent ?: this.content + if (content != null && ruleList.isNotEmpty()) { + result = content + if (content is NativeObject) { + result = content[ruleList[0].rule]?.toString() + } else { + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + sourceRule.makeUpRule(result) + result?.let { + if (sourceRule.rule.isNotEmpty()) { + result = when (sourceRule.mode) { + Mode.Js -> evalJS(sourceRule.rule, result) + Mode.Json -> getAnalyzeByJSonPath(it).getStringList(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getStringList(sourceRule.rule) + Mode.Default -> getAnalyzeByJSoup(it).getStringList(sourceRule.rule) + else -> sourceRule.rule + } + } + if (sourceRule.replaceRegex.isNotEmpty() && result is List<*>) { + val newList = ArrayList() + for (item in result as List<*>) { + newList.add(replaceRegex(item.toString(), sourceRule)) + } + result = newList + } else if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + } + if (result == null) return null + if (result is String) { + result = (result as String).split("\n") + } + if (isUrl) { + val urlList = ArrayList() + if (result is List<*>) { + for (url in result as List<*>) { + val absoluteURL = NetworkUtils.getAbsoluteURL(redirectUrl, url.toString()) + if (absoluteURL.isNotEmpty() && !urlList.contains(absoluteURL)) { + urlList.add(absoluteURL) + } + } + } + return urlList + } + @Suppress("UNCHECKED_CAST") + return result as? List + } + + /** + * 获取文本 + */ + @JvmOverloads + fun getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false): String { + if (TextUtils.isEmpty(ruleStr)) return "" + val ruleList = splitSourceRule(ruleStr) + return getString(ruleList, mContent, isUrl) + } + + @JvmOverloads + fun getString( + ruleList: List, + mContent: Any? = null, + isUrl: Boolean = false + ): String { + var result: Any? = null + val content = mContent ?: this.content + if (content != null && ruleList.isNotEmpty()) { + result = content + if (result is NativeObject) { + result = result[ruleList[0].rule]?.toString() + } else { + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + sourceRule.makeUpRule(result) + result?.let { + if (sourceRule.rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) { + result = when (sourceRule.mode) { + Mode.Js -> evalJS(sourceRule.rule, it) + Mode.Json -> getAnalyzeByJSonPath(it).getString(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getString(sourceRule.rule) + Mode.Default -> if (isUrl) { + getAnalyzeByJSoup(it).getString0(sourceRule.rule) + } else { + getAnalyzeByJSoup(it).getString(sourceRule.rule) + } + else -> sourceRule.rule + } + } + if ((result != null) && sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + } + if (result == null) result = "" + val str = kotlin.runCatching { + Entities.unescape(result.toString()) + }.onFailure { + log("Entities.unescape() error\n${it.localizedMessage}") + }.getOrElse { + result.toString() + } + if (isUrl) { + return if (str.isBlank()) { + baseUrl ?: "" + } else { + NetworkUtils.getAbsoluteURL(redirectUrl, str) + } + } + return str + } + + /** + * 获取Element + */ + fun getElement(ruleStr: String): Any? { + if (TextUtils.isEmpty(ruleStr)) return null + var result: Any? = null + val content = this.content + val ruleList = splitSourceRule(ruleStr, true) + if (content != null && ruleList.isNotEmpty()) { + result = content + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + sourceRule.makeUpRule(result) + result?.let { + result = when (sourceRule.mode) { + Mode.Regex -> AnalyzeByRegex.getElement( + result.toString(), + sourceRule.rule.splitNotBlank("&&") + ) + Mode.Js -> evalJS(sourceRule.rule, it) + Mode.Json -> getAnalyzeByJSonPath(it).getObject(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getElements(sourceRule.rule) + else -> getAnalyzeByJSoup(it).getElements(sourceRule.rule) + } + if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + return result + } + + /** + * 获取列表 + */ + @Suppress("UNCHECKED_CAST") + fun getElements(ruleStr: String): List { + var result: Any? = null + val content = this.content + val ruleList = splitSourceRule(ruleStr, true) + if (content != null && ruleList.isNotEmpty()) { + result = content + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + result?.let { + result = when (sourceRule.mode) { + Mode.Regex -> AnalyzeByRegex.getElements( + result.toString(), + sourceRule.rule.splitNotBlank("&&") + ) + Mode.Js -> evalJS(sourceRule.rule, result) + Mode.Json -> getAnalyzeByJSonPath(it).getList(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getElements(sourceRule.rule) + else -> getAnalyzeByJSoup(it).getElements(sourceRule.rule) + } + if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + result?.let { + return it as List + } + return ArrayList() + } + + /** + * 保存变量 + */ + private fun putRule(map: Map) { + for ((key, value) in map) { + put(key, getString(value)) + } + } + + /** + * 分离put规则 + */ + private fun splitPutRule(ruleStr: String, putMap: HashMap): String { + var vRuleStr = ruleStr + val putMatcher = putPattern.matcher(vRuleStr) + while (putMatcher.find()) { + vRuleStr = vRuleStr.replace(putMatcher.group(), "") + val map = GSON.fromJsonObject>(putMatcher.group(1)) + map?.let { putMap.putAll(map) } + } + return vRuleStr + } + + /** + * 正则替换 + */ + private fun replaceRegex(result: String, rule: SourceRule): String { + if (rule.replaceRegex.isEmpty()) return result + var vResult = result + vResult = if (rule.replaceFirst) { + kotlin.runCatching { + val pattern = Pattern.compile(rule.replaceRegex) + val matcher = pattern.matcher(vResult) + if (matcher.find()) { + matcher.group(0)!!.replaceFirst(rule.replaceRegex.toRegex(), rule.replacement) + } else { + "" + } + }.getOrElse { + vResult.replaceFirst(rule.replaceRegex, rule.replacement) + } + } else { + kotlin.runCatching { + vResult.replace(rule.replaceRegex.toRegex(), rule.replacement) + }.getOrElse { + vResult.replace(rule.replaceRegex, rule.replacement) + } + } + return vResult + } + + /** + * 分解规则生成规则列表 + */ + fun splitSourceRule(ruleStr: String?, allInOne: Boolean = false): List { + if (ruleStr.isNullOrEmpty()) return ArrayList() + val ruleList = ArrayList() + var mMode: Mode = Mode.Default + var start = 0 + //仅首字符为:时为AllInOne,其实:与伪类选择器冲突,建议改成?更合理 + if (allInOne && ruleStr.startsWith(":")) { + mMode = Mode.Regex + isRegex = true + start = 1 + } else if (isRegex) { + mMode = Mode.Regex + } + var tmp: String + val jsMatcher = JS_PATTERN.matcher(ruleStr) + while (jsMatcher.find()) { + if (jsMatcher.start() > start) { + tmp = ruleStr.substring(start, jsMatcher.start()).trim { it <= ' ' } + if (tmp.isNotEmpty()) { + ruleList.add(SourceRule(tmp, mMode)) + } + } + ruleList.add(SourceRule(jsMatcher.group(2) ?: jsMatcher.group(1), Mode.Js)) + start = jsMatcher.end() + } + + if (ruleStr.length > start) { + tmp = ruleStr.substring(start).trim { it <= ' ' } + if (tmp.isNotEmpty()) { + ruleList.add(SourceRule(tmp, mMode)) + } + } + + return ruleList + } + + /** + * 规则类 + */ + inner class SourceRule internal constructor( + ruleStr: String, + internal var mode: Mode = Mode.Default + ) { + internal var rule: String + internal var replaceRegex = "" + internal var replacement = "" + internal var replaceFirst = false + internal val putMap = HashMap() + private val ruleParam = ArrayList() + private val ruleType = ArrayList() + private val getRuleType = -2 + private val jsRuleType = -1 + private val defaultRuleType = 0 + + init { + rule = when { + mode == Mode.Js || mode == Mode.Regex -> ruleStr + ruleStr.startsWith("@CSS:", true) -> { + mode = Mode.Default + ruleStr + } + ruleStr.startsWith("@@") -> { + mode = Mode.Default + ruleStr.substring(2) + } + ruleStr.startsWith("@XPath:", true) -> { + mode = Mode.XPath + ruleStr.substring(7) + } + ruleStr.startsWith("@Json:", true) -> { + mode = Mode.Json + ruleStr.substring(6) + } + isJSON || ruleStr.startsWith("$.") || ruleStr.startsWith("$[") -> { + mode = Mode.Json + ruleStr + } + ruleStr.startsWith("/") -> {//XPath特征很明显,无需配置单独的识别标头 + mode = Mode.XPath + ruleStr + } + else -> ruleStr + } + //分离put + rule = splitPutRule(rule, putMap) + //@get,{{ }}, 拆分 + var start = 0 + var tmp: String + val evalMatcher = evalPattern.matcher(rule) + + if (evalMatcher.find()) { + tmp = rule.substring(start, evalMatcher.start()) + if (mode != Mode.Js && mode != Mode.Regex && + (evalMatcher.start() == 0 || !tmp.contains("##")) + ) { + mode = Mode.Regex + } + do { + if (evalMatcher.start() > start) { + tmp = rule.substring(start, evalMatcher.start()) + splitRegex(tmp) + } + tmp = evalMatcher.group() + when { + tmp.startsWith("@get:", true) -> { + ruleType.add(getRuleType) + ruleParam.add(tmp.substring(6, tmp.lastIndex)) + } + tmp.startsWith("{{") -> { + ruleType.add(jsRuleType) + ruleParam.add(tmp.substring(2, tmp.length - 2)) + } + else -> { + splitRegex(tmp) + } + } + start = evalMatcher.end() + } while (evalMatcher.find()) + } + if (rule.length > start) { + tmp = rule.substring(start) + splitRegex(tmp) + } + } + + /** + * 拆分\$\d{1,2} + */ + private fun splitRegex(ruleStr: String) { + var start = 0 + var tmp: String + val ruleStrArray = ruleStr.split("##") + val regexMatcher = regexPattern.matcher(ruleStrArray[0]) + + if (regexMatcher.find()) { + if (mode != Mode.Js && mode != Mode.Regex) { + mode = Mode.Regex + } + do { + if (regexMatcher.start() > start) { + tmp = ruleStr.substring(start, regexMatcher.start()) + ruleType.add(defaultRuleType) + ruleParam.add(tmp) + } + tmp = regexMatcher.group() + ruleType.add(tmp.substring(1).toInt()) + ruleParam.add(tmp) + start = regexMatcher.end() + } while (regexMatcher.find()) + } + if (ruleStr.length > start) { + tmp = ruleStr.substring(start) + ruleType.add(defaultRuleType) + ruleParam.add(tmp) + } + } + + /** + * 替换@get,{{ }} + */ + fun makeUpRule(result: Any?) { + val infoVal = StringBuilder() + if (ruleParam.isNotEmpty()) { + var index = ruleParam.size + while (index-- > 0) { + val regType = ruleType[index] + when { + regType > defaultRuleType -> { + @Suppress("UNCHECKED_CAST") + (result as? List)?.run { + if (this.size > regType) { + this[regType]?.let { + infoVal.insert(0, it) + } + } + } ?: infoVal.insert(0, ruleParam[index]) + } + regType == jsRuleType -> { + if (isRule(ruleParam[index])) { + getString(arrayListOf(SourceRule(ruleParam[index]))).let { + infoVal.insert(0, it) + } + } else { + val jsEval: Any? = evalJS(ruleParam[index], result) + when { + jsEval == null -> Unit + jsEval is String -> infoVal.insert(0, jsEval) + jsEval is Double && jsEval % 1.0 == 0.0 -> infoVal.insert( + 0, + String.format("%.0f", jsEval) + ) + else -> infoVal.insert(0, jsEval.toString()) + } + } + } + regType == getRuleType -> { + infoVal.insert(0, get(ruleParam[index])) + } + else -> infoVal.insert(0, ruleParam[index]) + } + } + rule = infoVal.toString() + } + //分离正则表达式 + val ruleStrS = rule.split("##") + rule = ruleStrS[0].trim() + if (ruleStrS.size > 1) { + replaceRegex = ruleStrS[1] + } + if (ruleStrS.size > 2) { + replacement = ruleStrS[2] + } + if (ruleStrS.size > 3) { + replaceFirst = true + } + } + + private fun isRule(ruleStr: String): Boolean { + return ruleStr.startsWith('@') //js首个字符不可能是@,除非是装饰器,所以@开头规定为规则 + || ruleStr.startsWith("$.") + || ruleStr.startsWith("$[") + || ruleStr.startsWith("//") + } + } + + enum class Mode { + XPath, Json, Default, Js, Regex + } + + fun put(key: String, value: String): String { + chapter?.putVariable(key, value) + ?: book?.putVariable(key, value) + ?: ruleData.putVariable(key, value) + return value + } + + fun get(key: String): String { + when (key) { + "bookName" -> book?.let { + return it.name + } + "title" -> chapter?.let { + return it.title + } + } + return chapter?.variableMap?.get(key) + ?: book?.variableMap?.get(key) + ?: ruleData.variableMap[key] + ?: "" + } + + /** + * 执行JS + */ + fun evalJS(jsStr: String, result: Any?): Any? { + val bindings = SimpleBindings() + bindings["java"] = this + bindings["cookie"] = CookieStore + bindings["cache"] = CacheManager + bindings["source"] = source + bindings["book"] = book + bindings["result"] = result + bindings["baseUrl"] = baseUrl + bindings["chapter"] = chapter + bindings["title"] = chapter?.title + bindings["src"] = content + bindings["nextChapterUrl"] = nextChapterUrl + return SCRIPT_ENGINE.eval(jsStr, bindings) + } + + override fun getSource(): BaseSource? { + return source + } + + /** + * js实现跨域访问,不能删 + */ + override fun ajax(urlStr: String): String? { + return runBlocking { + kotlin.runCatching { + val analyzeUrl = AnalyzeUrl(urlStr, source = source, ruleData = book) + analyzeUrl.getStrResponseAwait().body + }.onFailure { + log("ajax(${urlStr}) error\n${it.stackTraceToString()}") + Timber.e(it) + }.getOrElse { + it.msg + } + } + } + + /** + * 章节数转数字 + */ + fun toNumChapter(s: String?): String? { + s ?: return null + val matcher = titleNumPattern.matcher(s) + if (matcher.find()) { + return "${matcher.group(1)}${StringUtils.stringToInt(matcher.group(2))}${matcher.group(3)}" + } + return s + } + + companion object { + private val putPattern = Pattern.compile("@put:(\\{[^}]+?\\})", Pattern.CASE_INSENSITIVE) + private val evalPattern = + Pattern.compile("@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}", Pattern.CASE_INSENSITIVE) + private val regexPattern = Pattern.compile("\\$\\d{1,2}") + private val titleNumPattern = Pattern.compile("(第)(.+?)(章)") + } + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt new file mode 100644 index 000000000..427e71292 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt @@ -0,0 +1,571 @@ +package io.legado.app.model.analyzeRule + +import android.annotation.SuppressLint +import androidx.annotation.Keep +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import io.legado.app.constant.AppConst.SCRIPT_ENGINE +import io.legado.app.constant.AppConst.UA_NAME +import io.legado.app.constant.AppPattern.JS_PATTERN +import io.legado.app.data.entities.BaseSource +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.AppConfig +import io.legado.app.help.CacheManager +import io.legado.app.help.JsExtensions +import io.legado.app.help.http.* +import io.legado.app.model.ConcurrentException +import io.legado.app.utils.* +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.net.URLEncoder +import java.util.* +import java.util.regex.Pattern +import javax.script.SimpleBindings +import kotlin.collections.HashMap + +/** + * Created by GKF on 2018/1/24. + * 搜索URL规则解析 + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +@Keep +@SuppressLint("DefaultLocale") +class AnalyzeUrl( + val mUrl: String, + val key: String? = null, + val page: Int? = null, + val speakText: String? = null, + val speakSpeed: Int? = null, + var baseUrl: String = "", + private val source: BaseSource? = null, + private val ruleData: RuleDataInterface? = null, + private val chapter: BookChapter? = null, + headerMapF: Map? = null, +) : JsExtensions { + companion object { + val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)") + private val pagePattern = Pattern.compile("<(.*?)>") + private val concurrentRecordMap = hashMapOf() + } + + var ruleUrl = "" + private set + var url: String = "" + private set + var body: String? = null + private set + var type: String? = null + private set + val headerMap = HashMap() + private var urlNoQuery: String = "" + private var queryStr: String? = null + private val fieldMap = LinkedHashMap() + private var charset: String? = null + private var method = RequestMethod.GET + private var proxy: String? = null + private var retry: Int = 0 + private var useWebView: Boolean = false + private var webJs: String? = null + + init { + val urlMatcher = paramPattern.matcher(baseUrl) + if (urlMatcher.find()) baseUrl = baseUrl.substring(0, urlMatcher.start()) + headerMapF?.let { + headerMap.putAll(it) + if (it.containsKey("proxy")) { + proxy = it["proxy"] + headerMap.remove("proxy") + } + } + initUrl() + } + + /** + * 处理url + */ + fun initUrl() { + ruleUrl = mUrl + //执行@js, + analyzeJs() + //替换参数 + replaceKeyPageJs() + //处理URL + analyzeUrl() + } + + /** + * 执行@js, + */ + private fun analyzeJs() { + var start = 0 + var tmp: String + val jsMatcher = JS_PATTERN.matcher(ruleUrl) + while (jsMatcher.find()) { + if (jsMatcher.start() > start) { + tmp = + ruleUrl.substring(start, jsMatcher.start()).trim { it <= ' ' } + if (tmp.isNotEmpty()) { + ruleUrl = tmp.replace("@result", ruleUrl) + } + } + ruleUrl = evalJS(jsMatcher.group(2) ?: jsMatcher.group(1), ruleUrl) as String + start = jsMatcher.end() + } + if (ruleUrl.length > start) { + tmp = ruleUrl.substring(start).trim { it <= ' ' } + if (tmp.isNotEmpty()) { + ruleUrl = tmp.replace("@result", ruleUrl) + } + } + } + + /** + * 替换关键字,页数,JS + */ + private fun replaceKeyPageJs() { //先替换内嵌规则再替换页数规则,避免内嵌规则中存在大于小于号时,规则被切错 + //js + if (ruleUrl.contains("{{") && ruleUrl.contains("}}")) { + val analyze = RuleAnalyzer(ruleUrl) //创建解析 + //替换所有内嵌{{js}} + val url = analyze.innerRule("{{", "}}") { + val jsEval = evalJS(it) ?: "" + when { + jsEval is String -> jsEval + jsEval is Double && jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval) + else -> jsEval.toString() + } + } + if (url.isNotEmpty()) ruleUrl = url + } + //page + page?.let { + val matcher = pagePattern.matcher(ruleUrl) + while (matcher.find()) { + val pages = matcher.group(1)!!.split(",") + ruleUrl = if (page < pages.size) { //pages[pages.size - 1]等同于pages.last() + ruleUrl.replace(matcher.group(), pages[page - 1].trim { it <= ' ' }) + } else { + ruleUrl.replace(matcher.group(), pages.last().trim { it <= ' ' }) + } + } + } + } + + /** + * 解析Url + */ + private fun analyzeUrl() { + //replaceKeyPageJs已经替换掉额外内容,此处url是基础形式,可以直接切首个‘,’之前字符串。 + val urlMatcher = paramPattern.matcher(ruleUrl) + val urlNoOption = + if (urlMatcher.find()) ruleUrl.substring(0, urlMatcher.start()) else ruleUrl + url = NetworkUtils.getAbsoluteURL(baseUrl, urlNoOption) + NetworkUtils.getBaseUrl(url)?.let { + baseUrl = it + } + if (urlNoOption.length != ruleUrl.length) { + GSON.fromJsonObject(ruleUrl.substring(urlMatcher.end()))?.let { option -> + option.method?.let { + if (it.equals("POST", true)) method = RequestMethod.POST + } + option.headers?.let { headers -> + if (headers is Map<*, *>) { + headers.forEach { entry -> + headerMap[entry.key.toString()] = entry.value.toString() + } + } else if (headers is String) { + GSON.fromJsonObject>(headers) + ?.let { headerMap.putAll(it) } + } + } + option.body?.let { + body = if (it is String) it else GSON.toJson(it) + } + type = option.type + charset = option.charset + retry = option.retry + useWebView = option.webView?.toString()?.isNotBlank() == true + webJs = option.webJs + option.js?.let { jsStr -> + evalJS(jsStr, url)?.toString()?.let { + url = it + } + } + } + } + headerMap[UA_NAME] ?: let { + headerMap[UA_NAME] = AppConfig.userAgent + } + urlNoQuery = url + when (method) { + RequestMethod.GET -> { + val pos = url.indexOf('?') + if (pos != -1) { + analyzeFields(url.substring(pos + 1)) + urlNoQuery = url.substring(0, pos) + } + } + RequestMethod.POST -> body?.let { + if (!it.isJson() && !it.isXml() && headerMap["Content-Type"].isNullOrEmpty()) { + analyzeFields(it) + } + } + } + } + + /** + * 解析QueryMap + */ + private fun analyzeFields(fieldsTxt: String) { + queryStr = fieldsTxt + val queryS = fieldsTxt.splitNotBlank("&") + for (query in queryS) { + val queryM = query.splitNotBlank("=") + val value = if (queryM.size > 1) queryM[1] else "" + if (charset.isNullOrEmpty()) { + if (NetworkUtils.hasUrlEncoded(value)) { + fieldMap[queryM[0]] = value + } else { + fieldMap[queryM[0]] = URLEncoder.encode(value, "UTF-8") + } + } else if (charset == "escape") { + fieldMap[queryM[0]] = EncoderUtils.escape(value) + } else { + fieldMap[queryM[0]] = URLEncoder.encode(value, charset) + } + } + } + + /** + * 执行JS + */ + fun evalJS(jsStr: String, result: Any? = null): Any? { + val bindings = SimpleBindings() + bindings["java"] = this + bindings["baseUrl"] = baseUrl + bindings["cookie"] = CookieStore + bindings["cache"] = CacheManager + bindings["page"] = page + bindings["key"] = key + bindings["speakText"] = speakText + bindings["speakSpeed"] = speakSpeed + bindings["book"] = ruleData as? Book + bindings["source"] = source + bindings["result"] = result + return SCRIPT_ENGINE.eval(jsStr, bindings) + } + + fun put(key: String, value: String): String { + chapter?.putVariable(key, value) + ?: ruleData?.putVariable(key, value) + return value + } + + fun get(key: String): String { + when (key) { + "bookName" -> (ruleData as? Book)?.let { + return it.name + } + "title" -> chapter?.let { + return it.title + } + } + return chapter?.variableMap?.get(key) + ?: ruleData?.variableMap?.get(key) + ?: "" + } + + /** + * 开始访问,并发判断 + */ + private fun fetchStart(): ConcurrentRecord? { + source ?: return null + val concurrentRate = source.concurrentRate + if (concurrentRate.isNullOrEmpty()) { + return null + } + val rateIndex = concurrentRate.indexOf("/") + var fetchRecord = concurrentRecordMap[source.getKey()] + if (fetchRecord == null) { + fetchRecord = ConcurrentRecord(rateIndex > 0, System.currentTimeMillis(), 1) + concurrentRecordMap[source.getKey()] = fetchRecord + return fetchRecord + } + val waitTime: Int = synchronized(fetchRecord) { + try { + if (rateIndex == -1) { + if (fetchRecord.frequency > 0) { + return@synchronized concurrentRate.toInt() + } + val nextTime = fetchRecord.time + concurrentRate.toInt() + if (System.currentTimeMillis() >= nextTime) { + fetchRecord.time = System.currentTimeMillis() + fetchRecord.frequency = 1 + return@synchronized 0 + } + return@synchronized (nextTime - System.currentTimeMillis()).toInt() + } else { + val sj = concurrentRate.substring(rateIndex + 1) + val nextTime = fetchRecord.time + sj.toInt() + if (System.currentTimeMillis() >= nextTime) { + fetchRecord.time = System.currentTimeMillis() + fetchRecord.frequency = 1 + return@synchronized 0 + } + val cs = concurrentRate.substring(0, rateIndex) + if (fetchRecord.frequency > cs.toInt()) { + return@synchronized (nextTime - System.currentTimeMillis()).toInt() + } else { + fetchRecord.frequency = fetchRecord.frequency + 1 + return@synchronized 0 + } + } + } catch (e: Exception) { + return@synchronized 0 + } + } + if (waitTime > 0) { + throw ConcurrentException("根据并发率还需等待${waitTime}毫秒才可以访问", waitTime = waitTime) + } + return fetchRecord + } + + /** + * 访问结束 + */ + private fun fetchEnd(concurrentRecord: ConcurrentRecord?) { + if (concurrentRecord != null && !concurrentRecord.concurrent) { + synchronized(concurrentRecord) { + concurrentRecord.frequency = concurrentRecord.frequency - 1 + } + } + } + + /** + * 访问网站,返回StrResponse + */ + suspend fun getStrResponseAwait( + jsStr: String? = null, + sourceRegex: String? = null, + useWebView: Boolean = true, + ): StrResponse { + if (type != null) { + return StrResponse(url, StringUtils.byteToHexString(getByteArrayAwait())) + } + val concurrentRecord = fetchStart() + setCookie(source?.getKey()) + val strResponse: StrResponse + if (this.useWebView && useWebView) { + strResponse = when (method) { + RequestMethod.POST -> { + val body = getProxyClient(proxy).newCallStrResponse(retry) { + addHeaders(headerMap) + url(urlNoQuery) + if (fieldMap.isNotEmpty() || body.isNullOrBlank()) { + postForm(fieldMap, true) + } else { + postJson(body) + } + }.body + BackstageWebView( + url = url, + html = body, + tag = source?.getKey(), + javaScript = webJs ?: jsStr, + sourceRegex = sourceRegex, + headerMap = headerMap + ).getStrResponse() + } + else -> BackstageWebView( + url = url, + tag = source?.getKey(), + javaScript = webJs ?: jsStr, + sourceRegex = sourceRegex, + headerMap = headerMap + ).getStrResponse() + } + } else { + strResponse = getProxyClient(proxy).newCallStrResponse(retry) { + addHeaders(headerMap) + when (method) { + RequestMethod.POST -> { + url(urlNoQuery) + val contentType = headerMap["Content-Type"] + val body = body + if (fieldMap.isNotEmpty() || body.isNullOrBlank()) { + postForm(fieldMap, true) + } else if (!contentType.isNullOrBlank()) { + val requestBody = body.toRequestBody(contentType.toMediaType()) + post(requestBody) + } else { + postJson(body) + } + } + else -> get(urlNoQuery, fieldMap, true) + } + } + } + fetchEnd(concurrentRecord) + return strResponse + } + + @JvmOverloads + fun getStrResponse( + jsStr: String? = null, + sourceRegex: String? = null, + useWebView: Boolean = true, + ): StrResponse { + return runBlocking { + getStrResponseAwait(jsStr, sourceRegex, useWebView) + } + } + + /** + * 访问网站,返回Response + */ + suspend fun getResponseAwait(): Response { + val concurrentRecord = fetchStart() + setCookie(source?.getKey()) + @Suppress("BlockingMethodInNonBlockingContext") + val response = getProxyClient(proxy).newCallResponse(retry) { + addHeaders(headerMap) + when (method) { + RequestMethod.POST -> { + url(urlNoQuery) + val contentType = headerMap["Content-Type"] + val body = body + if (fieldMap.isNotEmpty() || body.isNullOrBlank()) { + postForm(fieldMap, true) + } else if (!contentType.isNullOrBlank()) { + val requestBody = body.toRequestBody(contentType.toMediaType()) + post(requestBody) + } else { + postJson(body) + } + } + else -> get(urlNoQuery, fieldMap, true) + } + } + fetchEnd(concurrentRecord) + return response + } + + fun getResponse(): Response { + return runBlocking { + getResponseAwait() + } + } + + /** + * 访问网站,返回ByteArray + */ + suspend fun getByteArrayAwait(): ByteArray { + val concurrentRecord = fetchStart() + setCookie(source?.getKey()) + @Suppress("BlockingMethodInNonBlockingContext") + val byteArray = getProxyClient(proxy).newCallResponseBody(retry) { + addHeaders(headerMap) + when (method) { + RequestMethod.POST -> { + url(urlNoQuery) + val contentType = headerMap["Content-Type"] + val body = body + if (fieldMap.isNotEmpty() || body.isNullOrBlank()) { + postForm(fieldMap, true) + } else if (!contentType.isNullOrBlank()) { + val requestBody = body.toRequestBody(contentType.toMediaType()) + post(requestBody) + } else { + postJson(body) + } + } + else -> get(urlNoQuery, fieldMap, true) + } + }.bytes() + fetchEnd(concurrentRecord) + return byteArray + } + + fun getByteArray(): ByteArray { + return runBlocking { + getByteArrayAwait() + } + } + + /** + * 上传文件 + */ + suspend fun upload(fileName: String, file: Any, contentType: String): StrResponse { + return getProxyClient(proxy).newCallStrResponse(retry) { + url(urlNoQuery) + val bodyMap = GSON.fromJsonObject>(body)!! + bodyMap.forEach { entry -> + if (entry.value.toString() == "fileRequest") { + bodyMap[entry.key] = mapOf( + Pair("fileName", fileName), + Pair("file", file), + Pair("contentType", contentType) + ) + } + } + postMultipart(type, bodyMap) + } + } + + private fun setCookie(tag: String?) { + if (tag != null) { + val cookie = CookieStore.getCookie(tag) + if (cookie.isNotEmpty()) { + val cookieMap = CookieStore.cookieToMap(cookie) + val customCookieMap = CookieStore.cookieToMap(headerMap["Cookie"] ?: "") + cookieMap.putAll(customCookieMap) + val newCookie = CookieStore.mapToCookie(cookieMap) + newCookie?.let { + headerMap.put("Cookie", it) + } + } + } + } + + fun getGlideUrl(): GlideUrl { + val headers = LazyHeaders.Builder() + headerMap.forEach { (key, value) -> + headers.addHeader(key, value) + } + return GlideUrl(url, headers.build()) + } + + fun getUserAgent(): String { + return headerMap[UA_NAME] ?: AppConfig.userAgent + } + + fun isPost(): Boolean { + return method == RequestMethod.POST + } + + override fun getSource(): BaseSource? { + return source + } + + data class UrlOption( + val method: String?, + val charset: String?, + val headers: Any?, + val body: Any?, + val type: String?, + val js: String?, + val retry: Int = 0, + val webView: Any?, + val webJs: String?, + ) + + data class ConcurrentRecord( + val concurrent: Boolean, + var time: Long, + var frequency: Int + ) + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/QueryTTF.java b/app/src/main/java/io/legado/app/model/analyzeRule/QueryTTF.java new file mode 100644 index 000000000..dc35a78ae --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/QueryTTF.java @@ -0,0 +1,603 @@ +package io.legado.app.model.analyzeRule; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +@SuppressWarnings({"FieldCanBeLocal", "StatementWithEmptyBody", "unused"}) +public class QueryTTF { + private static class Header { + public int majorVersion; + public int minorVersion; + public int numOfTables; + public int searchRange; + public int entrySelector; + public int rangeShift; + } + + private static class Directory { + public String tag; // table name + public int checkSum; // Check sum + public int offset; // Offset from beginning of file + public int length; // length of the table in bytes + } + + private static class NameLayout { + public int format; + public int count; + public int stringOffset; + public List records = new LinkedList<>(); + } + + private static class NameRecord { + public int platformID; // 平台标识符<0:Unicode, 1:Mac, 2:ISO, 3:Windows, 4:Custom> + public int encodingID; // 编码标识符 + public int languageID; // 语言标识符 + public int nameID; // 名称标识符 + public int length; // 名称字符串的长度 + public int offset; // 名称字符串相对于stringOffset的字节偏移量 + } + + private static class HeadLayout { + public int majorVersion; + public int minorVersion; + public int fontRevision; + public int checkSumAdjustment; + public int magicNumber; + public int flags; + public int unitsPerEm; + public long created; + public long modified; + public short xMin; + public short yMin; + public short xMax; + public short yMax; + public int macStyle; + public int lowestRecPPEM; + public short fontDirectionHint; + public short indexToLocFormat; // <0:loca是2字节数组, 1:loca是4字节数组> + public short glyphDataFormat; + } + + private static class MaxpLayout { + public int majorVersion; + public int minorVersion; + public int numGlyphs; // 字体中的字形数量 + public int maxPoints; + public int maxContours; + public int maxCompositePoints; + public int maxCompositeContours; + public int maxZones; + public int maxTwilightPoints; + public int maxStorage; + public int maxFunctionDefs; + public int maxInstructionDefs; + public int maxStackElements; + public int maxSizeOfInstructions; + public int maxComponentElements; + public int maxComponentDepth; + } + + private static class CmapLayout { + public int version; + public int numTables; + public List records = new LinkedList<>(); + public Map tables = new HashMap<>(); + } + + private static class CmapRecord { + public int platformID; + public int encodingID; + public int offset; + } + + private static class CmapFormat { + public int format; + public int length; + public int language; + public byte[] glyphIdArray; + } + + private static class CmapFormat4 extends CmapFormat { + public int segCountX2; + public int searchRange; + public int entrySelector; + public int rangeShift; + public int[] endCode; + public int reservedPad; + public int[] startCode; + public short[] idDelta; + public int[] idRangeOffset; + public int[] glyphIdArray; + } + + private static class CmapFormat6 extends CmapFormat { + public int firstCode; + public int entryCount; + public int[] glyphIdArray; + } + + private static class CmapFormat12 extends CmapFormat { + public int reserved; + public int length; + public int language; + public int numGroups; + public List> groups; + } + + private static class GlyfLayout { + public short numberOfContours; // 非负值为简单字型,负值为符合字型 + public short xMin; + public short yMin; + public short xMax; + public short yMax; + public int[] endPtsOfContours; // length=numberOfContours + public int instructionLength; + public byte[] instructions; // length=instructionLength + public byte[] flags; + public short[] xCoordinates; // length = flags.length + public short[] yCoordinates; // length = flags.length + } + + private static class ByteArrayReader { + public int index; + public byte[] buffer; + + public ByteArrayReader(byte[] buffer, int index) { + this.buffer = buffer; + this.index = index; + } + + public long ReadUIntX(long len) { + long result = 0; + for (long i = 0; i < len; ++i) { + result <<= 8; + result |= buffer[index++] & 0xFF; + } + return result; + } + + public long ReadUInt64() { + return ReadUIntX(8); + } + + public int ReadUInt32() { + return (int) ReadUIntX(4); + } + + public int ReadUInt16() { + return (int) ReadUIntX(2); + } + + public short ReadInt16() { + return (short) ReadUIntX(2); + } + + public short ReadUInt8() { + return (short) ReadUIntX(1); + } + + + public String ReadStrings(int len, Charset charset) { + byte[] result = len > 0 ? new byte[len] : null; + for (int i = 0; i < len; ++i) result[i] = buffer[index++]; + return new String(result, charset); + } + + public byte GetByte() { + return buffer[index++]; + } + + public byte[] GetBytes(int len) { + byte[] result = len > 0 ? new byte[len] : null; + for (int i = 0; i < len; ++i) result[i] = buffer[index++]; + return result; + } + + public int[] GetUInt16Array(int len) { + int[] result = len > 0 ? new int[len] : null; + for (int i = 0; i < len; ++i) result[i] = ReadUInt16(); + return result; + } + + public short[] GetInt16Array(int len) { + short[] result = len > 0 ? new short[len] : null; + for (int i = 0; i < len; ++i) result[i] = ReadInt16(); + return result; + } + } + + private final ByteArrayReader fontReader; + private final Header fileHeader = new Header(); + private final List directorys = new LinkedList<>(); + private final NameLayout name = new NameLayout(); + private final HeadLayout head = new HeadLayout(); + private final MaxpLayout maxp = new MaxpLayout(); + private final List loca = new LinkedList<>(); + private final CmapLayout Cmap = new CmapLayout(); + private final List glyf = new LinkedList<>(); + @SuppressWarnings("unchecked") + private final Pair[] pps = new Pair[]{ + Pair.of(3, 10), + Pair.of(0, 4), + Pair.of(3, 1), + Pair.of(1, 0), + Pair.of(0, 3), + Pair.of(0, 1) + }; + + public final Map codeToGlyph = new HashMap<>(); + public final Map glyphToCode = new HashMap<>(); + private int limitMix = 0; + private int limitMax = 0; + + /** + * 构造函数 + * + * @param buffer 传入TTF字体二进制数组 + */ + public QueryTTF(byte[] buffer) { + fontReader = new ByteArrayReader(buffer, 0); + // 获取文件头 + fileHeader.majorVersion = fontReader.ReadUInt16(); + fileHeader.minorVersion = fontReader.ReadUInt16(); + fileHeader.numOfTables = fontReader.ReadUInt16(); + fileHeader.searchRange = fontReader.ReadUInt16(); + fileHeader.entrySelector = fontReader.ReadUInt16(); + fileHeader.rangeShift = fontReader.ReadUInt16(); + // 获取目录 + for (int i = 0; i < fileHeader.numOfTables; ++i) { + Directory d = new Directory(); + d.tag = fontReader.ReadStrings(4, StandardCharsets.US_ASCII); + d.checkSum = fontReader.ReadUInt32(); + d.offset = fontReader.ReadUInt32(); + d.length = fontReader.ReadUInt32(); + directorys.add(d); + } + // 解析表 name (字体信息,包含版权、名称、作者等...) + for (Directory Temp : directorys) { + if (Temp.tag.equals("name")) { + fontReader.index = Temp.offset; + name.format = fontReader.ReadUInt16(); + name.count = fontReader.ReadUInt16(); + name.stringOffset = fontReader.ReadUInt16(); + for (int i = 0; i < name.count; ++i) { + NameRecord record = new NameRecord(); + record.platformID = fontReader.ReadUInt16(); + record.encodingID = fontReader.ReadUInt16(); + record.languageID = fontReader.ReadUInt16(); + record.nameID = fontReader.ReadUInt16(); + record.length = fontReader.ReadUInt16(); + record.offset = fontReader.ReadUInt16(); + name.records.add(record); + } + } + } + // 解析表 head (获取 head.indexToLocFormat) + for (Directory Temp : directorys) { + if (Temp.tag.equals("head")) { + fontReader.index = Temp.offset; + head.majorVersion = fontReader.ReadUInt16(); + head.minorVersion = fontReader.ReadUInt16(); + head.fontRevision = fontReader.ReadUInt32(); + head.checkSumAdjustment = fontReader.ReadUInt32(); + head.magicNumber = fontReader.ReadUInt32(); + head.flags = fontReader.ReadUInt16(); + head.unitsPerEm = fontReader.ReadUInt16(); + head.created = fontReader.ReadUInt64(); + head.modified = fontReader.ReadUInt64(); + head.xMin = fontReader.ReadInt16(); + head.yMin = fontReader.ReadInt16(); + head.xMax = fontReader.ReadInt16(); + head.yMax = fontReader.ReadInt16(); + head.macStyle = fontReader.ReadUInt16(); + head.lowestRecPPEM = fontReader.ReadUInt16(); + head.fontDirectionHint = fontReader.ReadInt16(); + head.indexToLocFormat = fontReader.ReadInt16(); + head.glyphDataFormat = fontReader.ReadInt16(); + } + } + // 解析表 maxp (获取 maxp.numGlyphs) + for (Directory Temp : directorys) { + if (Temp.tag.equals("maxp")) { + fontReader.index = Temp.offset; + maxp.majorVersion = fontReader.ReadUInt16(); + maxp.minorVersion = fontReader.ReadUInt16(); + maxp.numGlyphs = fontReader.ReadUInt16(); + maxp.maxPoints = fontReader.ReadUInt16(); + maxp.maxContours = fontReader.ReadUInt16(); + maxp.maxCompositePoints = fontReader.ReadUInt16(); + maxp.maxCompositeContours = fontReader.ReadUInt16(); + maxp.maxZones = fontReader.ReadUInt16(); + maxp.maxTwilightPoints = fontReader.ReadUInt16(); + maxp.maxStorage = fontReader.ReadUInt16(); + maxp.maxFunctionDefs = fontReader.ReadUInt16(); + maxp.maxInstructionDefs = fontReader.ReadUInt16(); + maxp.maxStackElements = fontReader.ReadUInt16(); + maxp.maxSizeOfInstructions = fontReader.ReadUInt16(); + maxp.maxComponentElements = fontReader.ReadUInt16(); + maxp.maxComponentDepth = fontReader.ReadUInt16(); + } + } + // 解析表 loca (轮廓数据偏移地址表) + for (Directory Temp : directorys) { + if (Temp.tag.equals("loca")) { + fontReader.index = Temp.offset; + int offset = head.indexToLocFormat == 0 ? 2 : 4; + for (long i = 0; i < Temp.length; i += offset) { + loca.add(offset == 2 ? fontReader.ReadUInt16() << 1 : fontReader.ReadUInt32()); + } + } + } + // 解析表 cmap (Unicode编码轮廓索引对照表) + for (Directory Temp : directorys) { + if (Temp.tag.equals("cmap")) { + fontReader.index = Temp.offset; + Cmap.version = fontReader.ReadUInt16(); + Cmap.numTables = fontReader.ReadUInt16(); + + for (int i = 0; i < Cmap.numTables; ++i) { + CmapRecord record = new CmapRecord(); + record.platformID = fontReader.ReadUInt16(); + record.encodingID = fontReader.ReadUInt16(); + record.offset = fontReader.ReadUInt32(); + Cmap.records.add(record); + } + for (int i = 0; i < Cmap.numTables; ++i) { + int fmtOffset = Cmap.records.get(i).offset; + fontReader.index = Temp.offset + fmtOffset; + int EndIndex = fontReader.index; + + int format = fontReader.ReadUInt16(); + if (Cmap.tables.containsKey(fmtOffset)) continue; + if (format == 0) { + CmapFormat f = new CmapFormat(); + f.format = format; + f.length = fontReader.ReadUInt16(); + f.language = fontReader.ReadUInt16(); + f.glyphIdArray = fontReader.GetBytes(f.length - 6); + Cmap.tables.put(fmtOffset, f); + } else if (format == 4) { + CmapFormat4 f = new CmapFormat4(); + f.format = format; + f.length = fontReader.ReadUInt16(); + f.language = fontReader.ReadUInt16(); + f.segCountX2 = fontReader.ReadUInt16(); + int segCount = f.segCountX2 >> 1; + f.searchRange = fontReader.ReadUInt16(); + f.entrySelector = fontReader.ReadUInt16(); + f.rangeShift = fontReader.ReadUInt16(); + f.endCode = fontReader.GetUInt16Array(segCount); + f.reservedPad = fontReader.ReadUInt16(); + f.startCode = fontReader.GetUInt16Array(segCount); + f.idDelta = fontReader.GetInt16Array(segCount); + f.idRangeOffset = fontReader.GetUInt16Array(segCount); + f.glyphIdArray = fontReader.GetUInt16Array((EndIndex + f.length - fontReader.index) >> 1); + Cmap.tables.put(fmtOffset, f); + } else if (format == 6) { + CmapFormat6 f = new CmapFormat6(); + f.format = format; + f.length = fontReader.ReadUInt16(); + f.language = fontReader.ReadUInt16(); + f.firstCode = fontReader.ReadUInt16(); + f.entryCount = fontReader.ReadUInt16(); + f.glyphIdArray = fontReader.GetUInt16Array(f.entryCount); + Cmap.tables.put(fmtOffset, f); + } else if (format == 12) { + CmapFormat12 f = new CmapFormat12(); + f.format = format; + f.reserved = fontReader.ReadUInt16(); + f.length = fontReader.ReadUInt32(); + f.language = fontReader.ReadUInt32(); + f.numGroups = fontReader.ReadUInt32(); + f.groups = new ArrayList<>(f.numGroups); + for (int n = 0; n < f.numGroups; ++n) { + f.groups.add(Triple.of(fontReader.ReadUInt32(), fontReader.ReadUInt32(), fontReader.ReadUInt32())); + } + Cmap.tables.put(fmtOffset, f); + } + } + } + } + // 解析表 glyf (字体轮廓数据表) + for (Directory Temp : directorys) { + if (Temp.tag.equals("glyf")) { + fontReader.index = Temp.offset; + for (int i = 0; i < maxp.numGlyphs; ++i) { + fontReader.index = Temp.offset + loca.get(i); + + short numberOfContours = fontReader.ReadInt16(); + if (numberOfContours > 0) { + GlyfLayout g = new GlyfLayout(); + g.numberOfContours = numberOfContours; + g.xMin = fontReader.ReadInt16(); + g.yMin = fontReader.ReadInt16(); + g.xMax = fontReader.ReadInt16(); + g.yMax = fontReader.ReadInt16(); + g.endPtsOfContours = fontReader.GetUInt16Array(numberOfContours); + g.instructionLength = fontReader.ReadUInt16(); + g.instructions = fontReader.GetBytes(g.instructionLength); + int flagLength = g.endPtsOfContours[g.endPtsOfContours.length - 1] + 1; + // 获取轮廓点描述标志 + g.flags = new byte[flagLength]; + for (int n = 0; n < flagLength; ++n) { + g.flags[n] = fontReader.GetByte(); + if ((g.flags[n] & 0x08) != 0x00) { + for (int m = fontReader.ReadUInt8(); m > 0; --m) { + g.flags[++n] = g.flags[n - 1]; + } + } + } + // 获取轮廓点描述x轴相对值 + g.xCoordinates = new short[flagLength]; + for (int n = 0; n < flagLength; ++n) { + short same = (short) ((g.flags[n] & 0x10) != 0 ? 1 : -1); + if ((g.flags[n] & 0x02) != 0) { + g.xCoordinates[n] = (short) (same * fontReader.ReadUInt8()); + } else { + g.xCoordinates[n] = same == 1 ? (short) 0 : fontReader.ReadInt16(); + } + } + // 获取轮廓点描述y轴相对值 + g.yCoordinates = new short[flagLength]; + for (int n = 0; n < flagLength; ++n) { + short same = (short) ((g.flags[n] & 0x20) != 0 ? 1 : -1); + if ((g.flags[n] & 0x04) != 0) { + g.yCoordinates[n] = (short) (same * fontReader.ReadUInt8()); + } else { + g.yCoordinates[n] = same == 1 ? (short) 0 : fontReader.ReadInt16(); + } + } + /* 相对坐标转绝对坐标 + for (int n = 1; n < flagLength; ++n) { + xCoordinates[n] += xCoordinates[n - 1]; + yCoordinates[n] += yCoordinates[n - 1]; + }*/ + glyf.add(g); + } else { + // 复合字体暂未使用 + } + } + } + } + + // 建立Unicode&Glyph双向表 + for (int key = 0; key < 130000; ++key) { + if (key == 0xFF) key = 0x3400; + int gid = getGlyfIndex(key); + if (gid == 0) continue; + StringBuilder sb = new StringBuilder(); + // 字型数据转String,方便存HashMap + for (short b : glyf.get(gid).xCoordinates) sb.append(b); + for (short b : glyf.get(gid).yCoordinates) sb.append(b); + String val = sb.toString(); + if (limitMix == 0) limitMix = key; + limitMax = key; + codeToGlyph.put(key, val); + if (glyphToCode.containsKey(val)) continue; + glyphToCode.put(val, key); + } + } + + /** + * 获取字体信息 (1=字体名称) + * + * @param nameId 传入十进制字体信息索引 + * @return 返回查询结果字符串 + */ + public String getNameById(int nameId) { + for (Directory Temp : directorys) { + if (!Temp.tag.equals("name")) continue; + fontReader.index = Temp.offset; + break; + } + for (NameRecord record : name.records) { + if (record.nameID != nameId) continue; + fontReader.index += name.stringOffset + record.offset; + return fontReader.ReadStrings(record.length, record.platformID == 1 ? StandardCharsets.UTF_8 : StandardCharsets.UTF_16BE); + } + return "error"; + } + + /** + * 使用Unicode值查找轮廓索引 + * + * @param code 传入Unicode十进制值 + * @return 返回十进制轮廓索引 + */ + private int getGlyfIndex(int code) { + if (code == 0) return 0; + int fmtKey = 0; + for (Pair item : pps) { + for (CmapRecord record : Cmap.records) { + if ((item.getLeft() == record.platformID) && (item.getRight() == record.encodingID)) { + fmtKey = record.offset; + break; + } + } + if (fmtKey > 0) break; + } + if (fmtKey == 0) return 0; + + int glyfID = 0; + CmapFormat table = Cmap.tables.get(fmtKey); + assert table != null; + int fmt = table.format; + if (fmt == 0) { + if (code < table.glyphIdArray.length) glyfID = table.glyphIdArray[code] & 0xFF; + } else if (fmt == 4) { + CmapFormat4 tab = (CmapFormat4) table; + if (code > tab.endCode[tab.endCode.length - 1]) return 0; + // 二分法查找数值索引 + int start = 0, middle, end = tab.endCode.length - 1; + while (start + 1 < end) { + middle = (start + end) / 2; + if (tab.endCode[middle] <= code) start = middle; + else end = middle; + } + if (tab.endCode[start] < code) ++start; + if (code < tab.startCode[start]) return 0; + if (tab.idRangeOffset[start] != 0) { + glyfID = tab.glyphIdArray[code - tab.startCode[start] + (tab.idRangeOffset[start] >> 1) - (tab.idRangeOffset.length - start)]; + } else glyfID = code + tab.idDelta[start]; + glyfID &= 0xFFFF; + } else if (fmt == 6) { + CmapFormat6 tab = (CmapFormat6) table; + int index = code - tab.firstCode; + if (index < 0 || index >= tab.glyphIdArray.length) glyfID = 0; + else glyfID = tab.glyphIdArray[index]; + } else if (fmt == 12) { + CmapFormat12 tab = (CmapFormat12) table; + if (code > tab.groups.get(tab.numGroups - 1).getMiddle()) return 0; + // 二分法查找数值索引 + int start = 0, middle, end = tab.numGroups - 1; + while (start + 1 < end) { + middle = (start + end) / 2; + if (tab.groups.get(middle).getLeft() <= code) start = middle; + else end = middle; + } + if (tab.groups.get(start).getLeft() <= code && code <= tab.groups.get(start).getMiddle()) { + glyfID = tab.groups.get(start).getRight() + code - tab.groups.get(start).getLeft(); + } + } + return glyfID; + } + + /** + * 判断Unicode值是否在字体范围内 + * + * @param code 传入Unicode十进制值 + * @return 返回bool查询结果 + */ + public boolean inLimit(char code) { + return (limitMix <= code) && (code < limitMax); + } + + /** + * 使用Unicode值获取轮廓数据 + * + * @param key 传入Unicode十进制值 + * @return 返回轮廓数组的String值 + */ + public String getGlyfByCode(int key) { + return codeToGlyph.getOrDefault(key, ""); + } + + /** + * 使用轮廓数据获取Unicode值 + * + * @param val 传入轮廓数组的String值 + * @return 返回Unicode十进制值 + */ + public int getCodeByGlyf(String val) { + //noinspection ConstantConditions + return glyphToCode.getOrDefault(val, 0); + } +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/RuleAnalyzer.kt b/app/src/main/java/io/legado/app/model/analyzeRule/RuleAnalyzer.kt new file mode 100644 index 000000000..5fd91e15d --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/RuleAnalyzer.kt @@ -0,0 +1,378 @@ +package io.legado.app.model.analyzeRule + +//通用的规则切分处理 +class RuleAnalyzer(data: String, code: Boolean = false) { + + private var queue: String = data //被处理字符串 + private var pos = 0 //当前处理到的位置 + private var start = 0 //当前处理字段的开始 + private var startX = 0 //当前规则的开始 + + private var rule = ArrayList() //分割出的规则列表 + private var step: Int = 0 //分割字符的长度 + var elementsType = "" //当前分割字符串 + + fun trim() { // 修剪当前规则之前的"@"或者空白符 + if (queue[pos] == '@' || queue[pos] < '!') { //在while里重复设置start和startX会拖慢执行速度,所以先来个判断是否存在需要修剪的字段,最后再一次性设置start和startX + pos++ + while (queue[pos] == '@' || queue[pos] < '!') pos++ + start = pos //开始点推移 + startX = pos //规则起始点推移 + } + } + + //将pos重置为0,方便复用 + fun reSetPos() { + pos = 0 + startX = 0 + } + + /** + * 从剩余字串中拉出一个字符串,直到但不包括匹配序列 + * @param seq 查找的字符串 **区分大小写** + * @return 是否找到相应字段。 + */ + private fun consumeTo(seq: String): Boolean { + start = pos //将处理到的位置设置为规则起点 + val offset = queue.indexOf(seq, pos) + return if (offset != -1) { + pos = offset + true + } else false + } + + /** + * 从剩余字串中拉出一个字符串,直到但不包括匹配序列(匹配参数列表中一项即为匹配),或剩余字串用完。 + * @param seq 匹配字符串序列 + * @return 成功返回true并设置间隔,失败则直接返回fasle + */ + private fun consumeToAny(vararg seq: String): Boolean { + + var pos = pos //声明新变量记录匹配位置,不更改类本身的位置 + + while (pos != queue.length) { + + for (s in seq) { + if (queue.regionMatches(pos, s, 0, s.length)) { + step = s.length //间隔数 + this.pos = pos //匹配成功, 同步处理位置到类 + return true //匹配就返回 true + } + } + + pos++ //逐个试探 + } + return false + } + + /** + * 从剩余字串中拉出一个字符串,直到但不包括匹配序列(匹配参数列表中一项即为匹配),或剩余字串用完。 + * @param seq 匹配字符序列 + * @return 返回匹配位置 + */ + private fun findToAny(vararg seq: Char): Int { + + var pos = pos //声明新变量记录匹配位置,不更改类本身的位置 + + while (pos != queue.length) { + + for (s in seq) if (queue[pos] == s) return pos //匹配则返回位置 + + pos++ //逐个试探 + + } + + return -1 + } + + /** + * 拉出一个非内嵌代码平衡组,存在转义文本 + */ + private fun chompCodeBalanced(open: Char, close: Char): Boolean { + + var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos + + var depth = 0 //嵌套深度 + var otherDepth = 0 //其他对称符合嵌套深度 + + var inSingleQuote = false //单引号 + var inDoubleQuote = false //双引号 + + do { + if (pos == queue.length) break + val c = queue[pos++] + if (c != ESC) { //非转义字符 + if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号 + else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号 + + if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环 + + if (c == '[') depth++ //开始嵌套一层 + else if (c == ']') depth-- //闭合一层嵌套 + else if (depth == 0) { + //处于默认嵌套中的非默认字符不需要平衡,仅depth为0时默认嵌套全部闭合,此字符才进行嵌套 + if (c == open) otherDepth++ + else if (c == close) otherDepth-- + } + + } else pos++ + + } while (depth > 0 || otherDepth > 0) //拉出一个平衡字串 + + return if (depth > 0 || otherDepth > 0) false else { + this.pos = pos //同步位置 + true + } + } + + /** + * 拉出一个规则平衡组,经过仔细测试xpath和jsoup中,引号内转义字符无效。 + */ + private fun chompRuleBalanced(open: Char, close: Char): Boolean { + + var pos = pos //声明临时变量记录匹配位置,匹配成功后才同步到类的pos + var depth = 0 //嵌套深度 + var inSingleQuote = false //单引号 + var inDoubleQuote = false //双引号 + + do { + if (pos == queue.length) break + val c = queue[pos++] + if (c == '\'' && !inDoubleQuote) inSingleQuote = !inSingleQuote //匹配具有语法功能的单引号 + else if (c == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote //匹配具有语法功能的双引号 + + if (inSingleQuote || inDoubleQuote) continue //语法单元未匹配结束,直接进入下个循环 + else if (c == '\\') { //不在引号中的转义字符才将下个字符转义 + pos++ + continue + } + + if (c == open) depth++ //开始嵌套一层 + else if (c == close) depth-- //闭合一层嵌套 + + } while (depth > 0) //拉出一个平衡字串 + + return if (depth > 0) false else { + this.pos = pos //同步位置 + true + } + } + + /** + * 不用正则,不到最后不切片也不用中间变量存储,只在序列中标记当前查找字段的开头结尾,到返回时才切片,高效快速准确切割规则 + * 解决jsonPath自带的"&&"和"||"与阅读的规则冲突,以及规则正则或字符串中包含"&&"、"||"、"%%"、"@"导致的冲突 + */ + tailrec fun splitRule(vararg split: String): ArrayList { //首段匹配,elementsType为空 + + if (split.size == 1) { + elementsType = split[0] //设置分割字串 + return if (!consumeTo(elementsType)) { + rule += queue.substring(startX) + rule + } else { + step = elementsType.length //设置分隔符长度 + splitRule() + } //递归匹配 + } else if (!consumeToAny(* split)) { //未找到分隔符 + rule += queue.substring(startX) + return rule + } + + val end = pos //记录分隔位置 + pos = start //重回开始,启动另一种查找 + + do { + val st = findToAny('[', '(') //查找筛选器位置 + + if (st == -1) { + + rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 + + elementsType = queue.substring(end, end + step) //设置组合类型 + pos = end + step //跳过分隔符 + + while (consumeTo(elementsType)) { //循环切分规则压入数组 + rule += queue.substring(start, pos) + pos += step //跳过分隔符 + } + + rule += queue.substring(pos) //将剩余字段压入数组末尾 + + return rule + } + + if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组 + + rule = arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 + + elementsType = queue.substring(end, end + step) //设置组合类型 + pos = end + step //跳过分隔符 + + while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组 + rule += queue.substring(start, pos) + pos += step //跳过分隔符 + } + + return if (pos > st) { + startX = start + splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配 + } else { //执行到此,证明后面再无分隔字符 + rule += queue.substring(pos) //将剩余字段压入数组末尾 + rule + } + } + + pos = st //位置推移到筛选器处 + val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符 + + if (!chompBalanced(queue[pos], next)) throw Error( + queue.substring(0, start) + "后未平衡" + ) //拉出一个筛选器,不平衡则报错 + + } while (end > pos) + + start = pos //设置开始查找筛选器位置的起始位置 + + return splitRule(* split) //递归调用首段匹配 + } + + @JvmName("splitRuleNext") + private tailrec fun splitRule(): ArrayList { //二段匹配被调用,elementsType非空(已在首段赋值),直接按elementsType查找,比首段采用的方式更快 + + val end = pos //记录分隔位置 + pos = start //重回开始,启动另一种查找 + + do { + val st = findToAny('[', '(') //查找筛选器位置 + + if (st == -1) { + + rule += arrayOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 + pos = end + step //跳过分隔符 + + while (consumeTo(elementsType)) { //循环切分规则压入数组 + rule += queue.substring(start, pos) + pos += step //跳过分隔符 + } + + rule += queue.substring(pos) //将剩余字段压入数组末尾 + + return rule + } + + if (st > end) { //先匹配到st1pos,表明分隔字串不在选择器中,将选择器前分隔字串分隔的字段依次压入数组 + + rule += arrayListOf(queue.substring(startX, end)) //压入分隔的首段规则到数组 + pos = end + step //跳过分隔符 + + while (consumeTo(elementsType) && pos < st) { //循环切分规则压入数组 + rule += queue.substring(start, pos) + pos += step //跳过分隔符 + } + + return if (pos > st) { + startX = start + splitRule() //首段已匹配,但当前段匹配未完成,调用二段匹配 + } else { //执行到此,证明后面再无分隔字符 + rule += queue.substring(pos) //将剩余字段压入数组末尾 + rule + } + } + + pos = st //位置推移到筛选器处 + val next = if (queue[pos] == '[') ']' else ')' //平衡组末尾字符 + + if (!chompBalanced(queue[pos], next)) throw Error( + queue.substring(0, start) + "后未平衡" + ) //拉出一个筛选器,不平衡则报错 + + } while (end > pos) + + start = pos //设置开始查找筛选器位置的起始位置 + + return if (!consumeTo(elementsType)) { + rule += queue.substring(startX) + rule + } else splitRule() //递归匹配 + + } + + /** + * 替换内嵌规则 + * @param inner 起始标志,如{$. + * @param startStep 不属于规则部分的前置字符长度,如{$.中{不属于规则的组成部分,故startStep为1 + * @param endStep 不属于规则部分的后置字符长度 + * @param fr 查找到内嵌规则时,用于解析的函数 + * + * */ + fun innerRule( + inner: String, + startStep: Int = 1, + endStep: Int = 1, + fr: (String) -> String? + ): String { + val st = StringBuilder() + + while (consumeTo(inner)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true + val posPre = pos //记录consumeTo匹配位置 + if (chompCodeBalanced('{', '}')) { + val frv = fr(queue.substring(posPre + startStep, pos - endStep)) + if (!frv.isNullOrEmpty()) { + st.append(queue.substring(startX, posPre) + frv) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串 + startX = pos //记录下次规则起点 + continue //获取内容成功,继续选择下个内嵌规则 + } + } + pos += inner.length //拉出字段不平衡,inner只是个普通字串,跳到此inner后继续匹配 + } + + return if (startX == 0) "" else st.apply { + append(queue.substring(startX)) + }.toString() + } + + /** + * 替换内嵌规则 + * @param fr 查找到内嵌规则时,用于解析的函数 + * + * */ + fun innerRule( + startStr: String, + endStr: String, + fr: (String) -> String? + ): String { + + val st = StringBuilder() + while (consumeTo(startStr)) { //拉取成功返回true,ruleAnalyzes里的字符序列索引变量pos后移相应位置,否则返回false,且isEmpty为true + pos += startStr.length //跳过开始字符串 + val posPre = pos //记录consumeTo匹配位置 + if (consumeTo(endStr)) { + val frv = fr(queue.substring(posPre, pos)) + st.append( + queue.substring( + startX, + posPre - startStr.length + ) + frv + ) //压入内嵌规则前的内容,及内嵌规则解析得到的字符串 + pos += endStr.length //跳过结束字符串 + startX = pos //记录下次规则起点 + } + } + + return if (startX == 0) queue else st.apply { + append(queue.substring(startX)) + }.toString() + } + + //设置平衡组函数,json或JavaScript时设置成chompCodeBalanced,否则为chompRuleBalanced + val chompBalanced = if (code) ::chompCodeBalanced else ::chompRuleBalanced + + companion object { + + /** + * 转义字符 + */ + private const val ESC = '\\' + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/RuleData.kt b/app/src/main/java/io/legado/app/model/analyzeRule/RuleData.kt new file mode 100644 index 000000000..3752ac113 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/RuleData.kt @@ -0,0 +1,17 @@ +package io.legado.app.model.analyzeRule + +class RuleData : RuleDataInterface { + + override val variableMap by lazy { + hashMapOf() + } + + override fun putVariable(key: String, value: String?) { + if (value != null) { + variableMap[key] = value + } else { + variableMap.remove(key) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/RuleDataInterface.kt b/app/src/main/java/io/legado/app/model/analyzeRule/RuleDataInterface.kt new file mode 100644 index 000000000..8b87f949b --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/RuleDataInterface.kt @@ -0,0 +1,13 @@ +package io.legado.app.model.analyzeRule + +interface RuleDataInterface { + + val variableMap: HashMap + + fun putVariable(key: String, value: String?) + + fun getVariable(key: String): String? { + return variableMap[key] + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/localBook/EpubFile.kt b/app/src/main/java/io/legado/app/model/localBook/EpubFile.kt new file mode 100644 index 000000000..873bb4470 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/localBook/EpubFile.kt @@ -0,0 +1,315 @@ +package io.legado.app.model.localBook + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.text.TextUtils +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.utils.FileUtils +import io.legado.app.utils.HtmlFormatter +import io.legado.app.utils.MD5Utils +import io.legado.app.utils.externalFiles +import me.ag2s.epublib.domain.EpubBook +import me.ag2s.epublib.domain.Resource +import me.ag2s.epublib.domain.TOCReference +import me.ag2s.epublib.epub.EpubReader +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import splitties.init.appCtx +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.charset.Charset +import java.util.* + +class EpubFile(var book: Book) { + + companion object { + private var eFile: EpubFile? = null + + @Synchronized + private fun getEFile(book: Book): EpubFile { + if (eFile == null || eFile?.book?.bookUrl != book.bookUrl) { + eFile = EpubFile(book) + //对于Epub文件默认不启用替换 + book.setUseReplaceRule(false) + return eFile!! + } + eFile?.book = book + return eFile!! + } + + @Synchronized + fun getChapterList(book: Book): ArrayList { + return getEFile(book).getChapterList() + } + + @Synchronized + fun getContent(book: Book, chapter: BookChapter): String? { + return getEFile(book).getContent(chapter) + } + + @Synchronized + fun getImage( + book: Book, + href: String + ): InputStream? { + return getEFile(book).getImage(href) + } + + @Synchronized + fun upBookInfo(book: Book) { + return getEFile(book).upBookInfo() + } + } + + private var mCharset: Charset = Charset.defaultCharset() + private var epubBook: EpubBook? = null + get() { + if (field != null) { + return field + } + field = readEpub() + return field + } + + init { + try { + epubBook?.let { + if (book.coverUrl.isNullOrEmpty()) { + book.coverUrl = FileUtils.getPath( + appCtx.externalFiles, + "covers", + "${MD5Utils.md5Encode16(book.bookUrl)}.jpg" + ) + } + if (!File(book.coverUrl!!).exists()) { + /*部分书籍DRM处理后,封面获取异常,待优化*/ + it.coverImage?.inputStream?.use { input -> + val cover = BitmapFactory.decodeStream(input) + val out = FileOutputStream(FileUtils.createFileIfNotExist(book.coverUrl!!)) + cover.compress(Bitmap.CompressFormat.JPEG, 90, out) + out.flush() + out.close() + } + } + } + } catch (e: Exception) { + Timber.e(e) + } + } + + /*重写epub文件解析代码,直接读出压缩包文件生成Resources给epublib,这样的好处是可以逐一修改某些文件的格式错误*/ + private fun readEpub(): EpubBook? { + try { + val bis = LocalBook.getBookInputStream(book) + //通过懒加载读取epub + return EpubReader().readEpub(bis, "utf-8") + } catch (e: Exception) { + Timber.e(e) + } + return null + } + + private fun getContent(chapter: BookChapter): String? { + /*获取当前章节文本*/ + epubBook?.let { epubBook -> + val nextUrl = chapter.getVariable("nextUrl") + val startFragmentId = chapter.startFragmentId + val endFragmentId = chapter.endFragmentId + val elements = Elements() + var isChapter = false + /*一些书籍依靠href索引的resource会包含多个章节,需要依靠fragmentId来截取到当前章节的内容*/ + /*注:这里较大增加了内容加载的时间,所以首次获取内容后可存储到本地cache,减少重复加载*/ + for (res in epubBook.contents) { + if (chapter.url.substringBeforeLast("#") == res.href) { + elements.add(getBody(res, startFragmentId, endFragmentId)) + isChapter = true + } else if (isChapter) { + if (nextUrl.isNullOrBlank() || res.href == nextUrl.substringBeforeLast("#")) { + break + } + elements.add(getBody(res, startFragmentId, endFragmentId)) + } + } + var html = elements.outerHtml() + val tag = Book.rubyTag + if (book.getDelTag(tag)) { + html = html.replace("\\s?([\\u4e00-\\u9fa5])\\s?.*?".toRegex(), "$1") + } + return HtmlFormatter.formatKeepImg(html) + } + return null + } + + private fun getBody(res: Resource, startFragmentId: String?, endFragmentId: String?): Element { + val body = Jsoup.parse(String(res.data, mCharset)).body() + if (!startFragmentId.isNullOrBlank()) { + body.getElementById(startFragmentId)?.previousElementSiblings()?.remove() + } + if (!endFragmentId.isNullOrBlank() && endFragmentId != startFragmentId) { + body.getElementById(endFragmentId)?.nextElementSiblings()?.remove() + } + /*选择去除正文中的H标签,部分书籍标题与阅读标题重复待优化*/ + val tag = Book.hTag + if (book.getDelTag(tag)) { + body.getElementsByTag("h1").remove() + body.getElementsByTag("h2").remove() + body.getElementsByTag("h3").remove() + body.getElementsByTag("h4").remove() + body.getElementsByTag("h5").remove() + body.getElementsByTag("h6").remove() + //body.getElementsMatchingOwnText(chapter.title)?.remove() + } + + val children = body.children() + children.select("script").remove() + children.select("style").remove() + return body + } + + private fun getImage(href: String): InputStream? { + val abHref = href.replace("../", "") + return epubBook?.resources?.getByHref(abHref)?.inputStream + } + + private fun upBookInfo() { + if (epubBook == null) { + eFile = null + book.intro = "书籍导入异常" + } else { + val metadata = epubBook!!.metadata + book.name = metadata.firstTitle + if (book.name.isEmpty()) { + book.name = book.originName.replace(".epub", "") + } + + if (metadata.authors.size > 0) { + val author = + metadata.authors[0].toString().replace("^, |, $".toRegex(), "") + book.author = author + } + if (metadata.descriptions.size > 0) { + book.intro = Jsoup.parse(metadata.descriptions[0]).text() + } + } + } + + private fun getChapterList(): ArrayList { + val chapterList = ArrayList() + epubBook?.let { eBook -> + val refs = eBook.tableOfContents.tocReferences + if (refs == null || refs.isEmpty()) { + val spineReferences = eBook.spine.spineReferences + var i = 0 + val size = spineReferences.size + while (i < size) { + val resource = spineReferences[i].resource + var title = resource.title + if (TextUtils.isEmpty(title)) { + try { + val doc = + Jsoup.parse(String(resource.data, mCharset)) + val elements = doc.getElementsByTag("title") + if (elements.size > 0) { + title = elements[0].text() + } + } catch (e: IOException) { + e.printStackTrace() + } + } + val chapter = BookChapter() + chapter.index = i + chapter.bookUrl = book.bookUrl + chapter.url = resource.href + if (i == 0 && title.isEmpty()) { + chapter.title = "封面" + } else { + chapter.title = title + } + chapterList.add(chapter) + i++ + } + } else { + parseFirstPage(chapterList, refs) + parseMenu(chapterList, refs, 0) + for (i in chapterList.indices) { + chapterList[i].index = i + } + } + } + book.latestChapterTitle = chapterList.lastOrNull()?.title + book.totalChapterNum = chapterList.size + return chapterList + } + + /*获取书籍起始页内容。部分书籍第一章之前存在封面,引言,扉页等内容*/ + /*tile获取不同书籍风格杂乱,格式化处理待优化*/ + private var durIndex = 0 + private fun parseFirstPage( + chapterList: ArrayList, + refs: List? + ) { + val contents = epubBook?.contents + if (epubBook == null || contents == null || refs == null) return + var i = 0 + durIndex = 0 + while (i < contents.size) { + val content = contents[i] + if (!content.mediaType.toString().contains("htm")) continue + /*检索到第一章href停止*/ + if (refs[0].completeHref == content.href) break + val chapter = BookChapter() + var title = content.title + if (TextUtils.isEmpty(title)) { + val elements = Jsoup.parse( + String(epubBook!!.resources.getByHref(content.href).data, mCharset) + ).getElementsByTag("title") + title = + if (elements.size > 0 && elements[0].text().isNotBlank()) + elements[0].text() + else + "--卷首--" + } + chapter.bookUrl = book.bookUrl + chapter.title = title + chapter.url = content.href + chapter.startFragmentId = + if (content.href.substringAfter("#") == content.href) null + else content.href.substringAfter("#") + + chapterList.lastOrNull()?.endFragmentId = chapter.startFragmentId + chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url) + chapterList.add(chapter) + durIndex++ + i++ + } + } + + private fun parseMenu( + chapterList: ArrayList, + refs: List?, + level: Int + ) { + refs?.forEach { ref -> + if (ref.resource != null) { + val chapter = BookChapter() + chapter.bookUrl = book.bookUrl + chapter.title = ref.title + chapter.url = ref.completeHref + chapter.startFragmentId = ref.fragmentId + chapterList.lastOrNull()?.endFragmentId = chapter.startFragmentId + chapterList.lastOrNull()?.putVariable("nextUrl", chapter.url) + chapterList.add(chapter) + durIndex++ + } + if (ref.children != null && ref.children.isNotEmpty()) { + parseMenu(chapterList, ref.children, level + 1) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt b/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt new file mode 100644 index 000000000..62ef5dcea --- /dev/null +++ b/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt @@ -0,0 +1,183 @@ +package io.legado.app.model.localBook + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.model.TocEmptyException +import io.legado.app.utils.* +import splitties.init.appCtx +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.InputStream +import java.util.regex.Matcher +import java.util.regex.Pattern +import javax.script.SimpleBindings + +object LocalBook { + + private const val folderName = "bookTxt" + val cacheFolder: File by lazy { + FileUtils.createFolderIfNotExist(appCtx.externalFiles, folderName) + } + + @Throws(FileNotFoundException::class, SecurityException::class) + fun getBookInputStream(book: Book): InputStream { + if (book.bookUrl.isContentScheme()) { + val uri = Uri.parse(book.bookUrl) + return appCtx.contentResolver.openInputStream(uri)!! + } + return FileInputStream(File(book.bookUrl)) + } + + @Throws(Exception::class) + fun getChapterList(book: Book): ArrayList { + val chapters = when { + book.isEpub() -> { + EpubFile.getChapterList(book) + } + book.isUmd() -> { + UmdFile.getChapterList(book) + } + else -> { + TextFile.getChapterList(book) + } + } + if (chapters.isEmpty()) { + throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty)) + } + return chapters + } + + fun getContent(book: Book, chapter: BookChapter): String? { + return try { + when { + book.isEpub() -> { + EpubFile.getContent(book, chapter) + } + book.isUmd() -> { + UmdFile.getContent(book, chapter) + } + else -> { + TextFile.getContent(book, chapter) + } + } + } catch (e: Exception) { + Timber.e(e) + e.localizedMessage + } + } + + fun importFile(uri: Uri): Book { + val path: String + val updateTime: Long + //这个变量不要修改,否则会导致读取不到缓存 + val fileName = (if (uri.isContentScheme()) { + path = uri.toString() + val doc = DocumentFile.fromSingleUri(appCtx, uri)!! + updateTime = doc.lastModified() + doc.name!! + } else { + path = uri.path!! + val file = File(path) + updateTime = file.lastModified() + file.name + }) + var book = appDb.bookDao.getBook(path) + if (book == null) { + val nameAuthor = analyzeNameAuthor(fileName) + book = Book( + bookUrl = path, + name = nameAuthor.first, + author = nameAuthor.second, + originName = fileName, + coverUrl = FileUtils.getPath( + appCtx.externalFiles, + "covers", + "${MD5Utils.md5Encode16(path)}.jpg" + ), + latestChapterTime = updateTime + ) + if (book.isEpub()) EpubFile.upBookInfo(book) + if (book.isUmd()) UmdFile.upBookInfo(book) + appDb.bookDao.insert(book) + } + return book + } + + fun analyzeNameAuthor(fileName: String): Pair { + val tempFileName = fileName.substringBeforeLast(".") + var name: String + var author: String + //匹配(知轩藏书常用格式) 《书名》其它信息作者:作者名.txt + val m1 = Pattern + .compile("(.*?)《([^《》]+)》(.*)") + .matcher(tempFileName) + //匹配 书名 by 作者名.txt + val m2 = Pattern + .compile("(^)(.+) by (.+)$") + .matcher(tempFileName) + + (m1.takeIf { m1.find() } ?: m2.takeIf { m2.find() }).run { + if (this is Matcher) { + //按默认格式将文件名分解成书名、作者名 + name = group(2)!! + author = BookHelp.formatBookAuthor((group(1) ?: "") + (group(3) ?: "")) + } else if (!AppConfig.bookImportFileName.isNullOrBlank()) { + try { + //在脚本中定义如何分解文件名成书名、作者名 + val jsonStr = AppConst.SCRIPT_ENGINE.eval( + //在用户脚本后添加捕获author、name的代码,只要脚本中author、name有值就会被捕获 + AppConfig.bookImportFileName + "\nJSON.stringify({author:author,name:name})", + //将文件名注入到脚步的src变量中 + SimpleBindings().also { it["src"] = tempFileName } + ).toString() + val bookMess = + GSON.fromJsonObject>(jsonStr) ?: HashMap() + name = bookMess["name"] ?: tempFileName + author = bookMess["author"]?.takeIf { it.length != tempFileName.length } ?: "" + } catch (e: Exception) { + name = tempFileName.replace(AppPattern.nameRegex, "") + author = tempFileName.replace(AppPattern.authorRegex, "") + .takeIf { it.length != tempFileName.length } ?: "" + } + } else { + name = tempFileName.replace(AppPattern.nameRegex, "") + author = tempFileName.replace(AppPattern.authorRegex, "") + .takeIf { it.length != tempFileName.length } ?: "" + } + + } + return Pair(name, author) + } + + fun deleteBook(book: Book, deleteOriginal: Boolean) { + kotlin.runCatching { + if (book.isLocalTxt() || book.isUmd()) { + cacheFolder.getFile(book.originName).delete() + } + if (book.isEpub()) { + FileUtils.delete( + cacheFolder.getFile(book.getFolderName()) + ) + } + + if (deleteOriginal) { + if (book.bookUrl.isContentScheme()) { + val uri = Uri.parse(book.bookUrl) + DocumentFile.fromSingleUri(appCtx, uri)?.delete() + } else { + FileUtils.deleteFile(book.bookUrl) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/model/localBook/TextFile.kt b/app/src/main/java/io/legado/app/model/localBook/TextFile.kt new file mode 100644 index 000000000..8d1d3feaa --- /dev/null +++ b/app/src/main/java/io/legado/app/model/localBook/TextFile.kt @@ -0,0 +1,299 @@ +package io.legado.app.model.localBook + +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.help.DefaultData +import io.legado.app.utils.EncodingDetect +import io.legado.app.utils.MD5Utils +import io.legado.app.utils.StringUtils +import java.io.FileNotFoundException +import java.nio.charset.Charset +import java.util.regex.Matcher +import java.util.regex.Pattern + +class TextFile(private val book: Book) { + + private val tocRules = arrayListOf() + private var charset: Charset = book.fileCharset() + + @Throws(FileNotFoundException::class) + fun getChapterList(): ArrayList { + var rulePattern: Pattern? = null + if (book.charset == null || book.tocUrl.isNotEmpty()) { + LocalBook.getBookInputStream(book).use { bis -> + val buffer = ByteArray(BUFFER_SIZE) + var blockContent: String + bis.read(buffer) + book.charset = EncodingDetect.getEncode(buffer) + charset = book.fileCharset() + blockContent = String(buffer, charset) + rulePattern = if (book.tocUrl.isNotEmpty()) { + Pattern.compile(book.tocUrl, Pattern.MULTILINE) + } else { + tocRules.addAll(getTocRules()) + if (blockContent.isEmpty()) { + bis.read(buffer) + book.charset = EncodingDetect.getEncode(buffer) + blockContent = String(buffer, charset) + } + getTocRule(blockContent)?.let { + Pattern.compile(it.rule, Pattern.MULTILINE) + } + } + } + } + return analyze(rulePattern) + } + + private fun analyze(pattern: Pattern?): ArrayList { + val toc = arrayListOf() + LocalBook.getBookInputStream(book).use { bis -> + var tocRule: TxtTocRule? = null + val buffer = ByteArray(BUFFER_SIZE) + var blockContent: String + val rulePattern = pattern ?: let { + val length = bis.read(buffer) + bis.skip(-length.toLong()) + blockContent = String(buffer, charset) + tocRule = getTocRule(blockContent) + tocRule?.let { + Pattern.compile(it.rule, Pattern.MULTILINE) + } + } + //加载章节 + var curOffset: Long = 0 + //block的个数 + var blockPos = 0 + //读取的长度 + var length: Int + //获取文件中的数据到buffer,直到没有数据为止 + while (bis.read(buffer).also { length = it } > 0) { + blockPos++ + //如果存在Chapter + if (rulePattern != null) { + //将数据转换成String, 不能超过length + blockContent = String(buffer, 0, length, charset) + val lastN = blockContent.lastIndexOf("\n") + if (lastN > 0) { + blockContent = blockContent.substring(0, lastN) + val blockContentSize = blockContent.toByteArray(charset).size + bis.skip(-(length - blockContentSize).toLong()) + length = blockContentSize + } + //当前Block下使过的String的指针 + var seekPos = 0 + //进行正则匹配 + val matcher: Matcher = rulePattern.matcher(blockContent) + //如果存在相应章节 + while (matcher.find()) { //获取匹配到的字符在字符串中的起始位置 + val chapterStart = matcher.start() + //获取章节内容 + val chapterContent = blockContent.substring(seekPos, chapterStart) + val chapterLength = chapterContent.toByteArray(charset).size + val lastStart = toc.lastOrNull()?.start ?: 0 + if (curOffset + chapterLength - lastStart > 50000 && pattern == null) { + //移除不匹配的规则 + tocRules.remove(tocRule) + bis.close() + return analyze(null) + } + //如果 seekPos == 0 && nextChapterPos != 0 表示当前block处前面有一段内容 + //第一种情况一定是序章 第二种情况是上一个章节的内容 + if (seekPos == 0 && chapterStart != 0) { //获取当前章节的内容 + if (toc.isEmpty()) { //如果当前没有章节,那么就是序章 + //加入简介 + if (StringUtils.trim(chapterContent).isNotEmpty()) { + val qyChapter = BookChapter() + qyChapter.title = "前言" + qyChapter.start = 0 + qyChapter.end = chapterLength.toLong() + toc.add(qyChapter) + } + //创建当前章节 + val curChapter = BookChapter() + curChapter.title = matcher.group() + curChapter.start = chapterLength.toLong() + toc.add(curChapter) + } else { //否则就block分割之后,上一个章节的剩余内容 + //获取上一章节 + val lastChapter = toc.last() + //将当前段落添加上一章去 + lastChapter.end = + lastChapter.end!! + chapterLength.toLong() + //创建当前章节 + val curChapter = BookChapter() + curChapter.title = matcher.group() + curChapter.start = lastChapter.end + toc.add(curChapter) + } + } else { + if (toc.isNotEmpty()) { //获取章节内容 + //获取上一章节 + val lastChapter = toc.last() + lastChapter.end = + lastChapter.start!! + chapterContent.toByteArray(charset).size.toLong() + //创建当前章节 + val curChapter = BookChapter() + curChapter.title = matcher.group() + curChapter.start = lastChapter.end + toc.add(curChapter) + } else { //如果章节不存在则创建章节 + val curChapter = BookChapter() + curChapter.title = matcher.group() + curChapter.start = 0 + curChapter.end = 0 + toc.add(curChapter) + } + } + //设置指针偏移 + seekPos += chapterContent.length + } + if (seekPos == 0 && length > 50000 && pattern == null) { + //移除不匹配的规则 + tocRules.remove(tocRule) + bis.close() + return analyze(null) + } + } else { //进行本地虚拟分章 + //章节在buffer的偏移量 + var chapterOffset = 0 + //当前剩余可分配的长度 + var strLength = length + //分章的位置 + var chapterPos = 0 + while (strLength > 0) { + ++chapterPos + //是否长度超过一章 + if (strLength > MAX_LENGTH_WITH_NO_CHAPTER) { //在buffer中一章的终止点 + var end = length + //寻找换行符作为终止点 + for (i in chapterOffset + MAX_LENGTH_WITH_NO_CHAPTER until length) { + if (buffer[i] == BLANK) { + end = i + break + } + } + val chapter = BookChapter() + chapter.title = "第${blockPos}章($chapterPos)" + chapter.start = curOffset + chapterOffset + 1 + chapter.end = curOffset + end + toc.add(chapter) + //减去已经被分配的长度 + strLength -= (end - chapterOffset) + //设置偏移的位置 + chapterOffset = end + } else { + val chapter = BookChapter() + chapter.title = "第" + blockPos + "章" + "(" + chapterPos + ")" + chapter.start = curOffset + chapterOffset + 1 + chapter.end = curOffset + length + toc.add(chapter) + strLength = 0 + } + } + } + + //block的偏移点 + curOffset += length.toLong() + + if (rulePattern != null) { + //设置上一章的结尾 + val lastChapter = toc.last() + lastChapter.end = curOffset + } + + //当添加的block太多的时候,执行GC + if (blockPos % 15 == 0) { + System.gc() + System.runFinalization() + } + } + tocRule?.let { + book.tocUrl = it.rule + } + } + for (i in toc.indices) { + val bean = toc[i] + bean.index = i + bean.bookUrl = book.bookUrl + bean.url = (MD5Utils.md5Encode16(book.originName + i + bean.title)) + } + book.latestChapterTitle = toc.last().title + book.totalChapterNum = toc.size + + System.gc() + System.runFinalization() + + return toc + } + + /** + * 获取匹配次数最多的目录规则 + */ + private fun getTocRule(content: String): TxtTocRule? { + var txtTocRule: TxtTocRule? = null + var maxCs = 0 + val removeRules = hashSetOf() + tocRules.forEach { tocRule -> + val pattern = Pattern.compile(tocRule.rule, Pattern.MULTILINE) + val matcher = pattern.matcher(content) + var cs = 0 + while (matcher.find()) { + cs++ + } + if (cs == 0) { + removeRules.add(tocRule) + } else if (cs > maxCs) { + maxCs = cs + txtTocRule = tocRule + } + } + tocRules.removeAll(removeRules) + return txtTocRule + } + + companion object { + + private const val BLANK: Byte = 0x0a + + //默认从文件中获取数据的长度 + private const val BUFFER_SIZE = 512 * 1024 + + //没有标题的时候,每个章节的最大长度 + private const val MAX_LENGTH_WITH_NO_CHAPTER = 10 * 1024 + + @Throws(FileNotFoundException::class) + fun getChapterList(book: Book): ArrayList { + return TextFile(book).getChapterList() + } + + @Throws(FileNotFoundException::class) + fun getContent(book: Book, bookChapter: BookChapter): String { + val count = (bookChapter.end!! - bookChapter.start!!).toInt() + val buffer = ByteArray(count) + LocalBook.getBookInputStream(book).use { bis -> + bis.skip(bookChapter.start!!) + bis.read(buffer) + } + return String(buffer, book.fileCharset()) + .substringAfter(bookChapter.title) + .replace("^[\\n\\s]+".toRegex(), "  ") + } + + private fun getTocRules(): List { + var rules = appDb.txtTocRuleDao.enabled + if (rules.isEmpty()) { + rules = DefaultData.txtTocRules.apply { + appDb.txtTocRuleDao.insert(*this.toTypedArray()) + }.filter { + it.enable + } + } + return rules + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/localBook/UmdFile.kt b/app/src/main/java/io/legado/app/model/localBook/UmdFile.kt new file mode 100644 index 000000000..95023372b --- /dev/null +++ b/app/src/main/java/io/legado/app/model/localBook/UmdFile.kt @@ -0,0 +1,127 @@ +package io.legado.app.model.localBook + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.utils.FileUtils +import io.legado.app.utils.MD5Utils +import io.legado.app.utils.externalFiles +import me.ag2s.umdlib.domain.UmdBook +import me.ag2s.umdlib.umd.UmdReader +import splitties.init.appCtx +import timber.log.Timber +import java.io.File +import java.io.InputStream +import java.util.* + +class UmdFile(var book: Book) { + companion object { + private var uFile: UmdFile? = null + + @Synchronized + private fun getUFile(book: Book): UmdFile { + if (uFile == null || uFile?.book?.bookUrl != book.bookUrl) { + uFile = UmdFile(book) + return uFile!! + } + uFile?.book = book + return uFile!! + } + + @Synchronized + fun getChapterList(book: Book): ArrayList { + return getUFile(book).getChapterList() + } + + @Synchronized + fun getContent(book: Book, chapter: BookChapter): String? { + return getUFile(book).getContent(chapter) + } + + @Synchronized + fun getImage( + book: Book, + href: String + ): InputStream? { + return getUFile(book).getImage(href) + } + + + @Synchronized + fun upBookInfo(book: Book) { + return getUFile(book).upBookInfo() + } + } + + + private var umdBook: UmdBook? = null + get() { + if (field != null) { + return field + } + field = readUmd() + return field + } + + + init { + try { + umdBook?.let { + if (book.coverUrl.isNullOrEmpty()) { + book.coverUrl = FileUtils.getPath( + appCtx.externalFiles, + "covers", + "${MD5Utils.md5Encode16(book.bookUrl)}.jpg" + ) + } + if (!File(book.coverUrl!!).exists()) { + FileUtils.writeBytes(book.coverUrl!!, it.cover.coverData) + } + } + } catch (e: Exception) { + Timber.e(e) + } + } + + private fun readUmd(): UmdBook? { + val input = LocalBook.getBookInputStream(book) + return UmdReader().read(input) + } + + private fun upBookInfo() { + if (umdBook == null) { + uFile = null + book.intro = "书籍导入异常" + } else { + val hd = umdBook!!.header + book.name = hd.title + book.author = hd.author + book.kind = hd.bookType + } + } + + private fun getContent(chapter: BookChapter): String? { + return umdBook?.chapters?.getContentString(chapter.index) + } + + private fun getChapterList(): ArrayList { + val chapterList = ArrayList() + umdBook?.chapters?.titles?.forEachIndexed { index, _ -> + val title = umdBook!!.chapters.getTitle(index) + val chapter = BookChapter() + chapter.title = title + chapter.index = index + chapter.bookUrl = book.bookUrl + chapter.url = index.toString() + Timber.d(chapter.url) + chapterList.add(chapter) + } + book.latestChapterTitle = chapterList.lastOrNull()?.title + book.totalChapterNum = chapterList.size + return chapterList + } + + private fun getImage(@Suppress("UNUSED_PARAMETER") href: String): InputStream? { + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/rss/Rss.kt b/app/src/main/java/io/legado/app/model/rss/Rss.kt new file mode 100644 index 000000000..25a989cd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/rss/Rss.kt @@ -0,0 +1,81 @@ +package io.legado.app.model.rss + +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.Debug +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.analyzeRule.RuleData +import io.legado.app.utils.NetworkUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +@Suppress("MemberVisibilityCanBePrivate") +object Rss { + + fun getArticles( + scope: CoroutineScope, + sortName: String, + sortUrl: String, + rssSource: RssSource, + page: Int, + context: CoroutineContext = Dispatchers.IO + ): Coroutine, String?>> { + return Coroutine.async(scope, context) { + getArticlesAwait(sortName, sortUrl, rssSource, page) + } + } + + suspend fun getArticlesAwait( + sortName: String, + sortUrl: String, + rssSource: RssSource, + page: Int, + ): Pair, String?> { + val ruleData = RuleData() + val analyzeUrl = AnalyzeUrl( + sortUrl, + page = page, + source = rssSource, + ruleData = ruleData, + headerMapF = rssSource.getHeaderMap() + ) + val body = analyzeUrl.getStrResponseAwait().body + return RssParserByRule.parseXML(sortName, sortUrl, body, rssSource, ruleData) + } + + fun getContent( + scope: CoroutineScope, + rssArticle: RssArticle, + ruleContent: String, + rssSource: RssSource, + context: CoroutineContext = Dispatchers.IO + ): Coroutine { + return Coroutine.async(scope, context) { + getContentAwait(rssArticle, ruleContent, rssSource) + } + } + + suspend fun getContentAwait( + rssArticle: RssArticle, + ruleContent: String, + rssSource: RssSource, + ): String { + val analyzeUrl = AnalyzeUrl( + rssArticle.link, + baseUrl = rssArticle.origin, + source = rssSource, + ruleData = rssArticle, + headerMapF = rssSource.getHeaderMap() + ) + val body = analyzeUrl.getStrResponseAwait().body + Debug.log(rssSource.sourceUrl, "≡获取成功:${rssSource.sourceUrl}") + Debug.log(rssSource.sourceUrl, body, state = 20) + val analyzeRule = AnalyzeRule(rssArticle, rssSource) + analyzeRule.setContent(body) + .setBaseUrl(NetworkUtils.getAbsoluteURL(rssArticle.origin, rssArticle.link)) + return analyzeRule.getString(ruleContent) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt b/app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt new file mode 100644 index 000000000..aabe26f98 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt @@ -0,0 +1,126 @@ +package io.legado.app.model.rss + +import androidx.annotation.Keep +import io.legado.app.R +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.RuleDataInterface +import io.legado.app.utils.GSON +import io.legado.app.utils.NetworkUtils +import splitties.init.appCtx +import java.util.* + +@Keep +object RssParserByRule { + + @Throws(Exception::class) + fun parseXML( + sortName: String, + sortUrl: String, + body: String?, + rssSource: RssSource, + ruleData: RuleDataInterface + ): Pair, String?> { + val sourceUrl = rssSource.sourceUrl + var nextUrl: String? = null + if (body.isNullOrBlank()) { + throw NoStackTraceException( + appCtx.getString(R.string.error_get_web_content, rssSource.sourceUrl) + ) + } + Debug.log(sourceUrl, "≡获取成功:$sourceUrl") + Debug.log(sourceUrl, body, state = 10) + var ruleArticles = rssSource.ruleArticles + if (ruleArticles.isNullOrBlank()) { + Debug.log(sourceUrl, "⇒列表规则为空, 使用默认规则解析") + return RssParserDefault.parseXML(sortName, body, sourceUrl) + } else { + val articleList = mutableListOf() + val analyzeRule = AnalyzeRule(ruleData, rssSource) + analyzeRule.setContent(body).setBaseUrl(sortUrl) + analyzeRule.setRedirectUrl(sortUrl) + var reverse = false + if (ruleArticles.startsWith("-")) { + reverse = true + ruleArticles = ruleArticles.substring(1) + } + Debug.log(sourceUrl, "┌获取列表") + val collections = analyzeRule.getElements(ruleArticles) + Debug.log(sourceUrl, "└列表大小:${collections.size}") + if (!rssSource.ruleNextPage.isNullOrEmpty()) { + Debug.log(sourceUrl, "┌获取下一页链接") + if (rssSource.ruleNextPage!!.uppercase(Locale.getDefault()) == "PAGE") { + nextUrl = sortUrl + } else { + nextUrl = analyzeRule.getString(rssSource.ruleNextPage) + if (nextUrl.isNotEmpty()) { + nextUrl = NetworkUtils.getAbsoluteURL(sortUrl, nextUrl) + } + } + Debug.log(sourceUrl, "└$nextUrl") + } + val ruleTitle = analyzeRule.splitSourceRule(rssSource.ruleTitle) + val rulePubDate = analyzeRule.splitSourceRule(rssSource.rulePubDate) + val ruleDescription = analyzeRule.splitSourceRule(rssSource.ruleDescription) + val ruleImage = analyzeRule.splitSourceRule(rssSource.ruleImage) + val ruleLink = analyzeRule.splitSourceRule(rssSource.ruleLink) + for ((index, item) in collections.withIndex()) { + getItem( + sourceUrl, item, analyzeRule, index == 0, + ruleTitle, rulePubDate, ruleDescription, ruleImage, ruleLink + )?.let { + it.sort = sortName + it.origin = sourceUrl + articleList.add(it) + } + } + if (reverse) { + articleList.reverse() + } + return Pair(articleList, nextUrl) + } + } + + private fun getItem( + sourceUrl: String, + item: Any, + analyzeRule: AnalyzeRule, + log: Boolean, + ruleTitle: List, + rulePubDate: List, + ruleDescription: List, + ruleImage: List, + ruleLink: List + ): RssArticle? { + val rssArticle = RssArticle() + analyzeRule.setContent(item) + Debug.log(sourceUrl, "┌获取标题", log) + rssArticle.title = analyzeRule.getString(ruleTitle) + Debug.log(sourceUrl, "└${rssArticle.title}", log) + Debug.log(sourceUrl, "┌获取时间", log) + rssArticle.pubDate = analyzeRule.getString(rulePubDate) + Debug.log(sourceUrl, "└${rssArticle.pubDate}", log) + Debug.log(sourceUrl, "┌获取描述", log) + if (ruleDescription.isNullOrEmpty()) { + rssArticle.description = null + Debug.log(sourceUrl, "└描述规则为空,将会解析内容页", log) + } else { + rssArticle.description = analyzeRule.getString(ruleDescription) + Debug.log(sourceUrl, "└${rssArticle.description}", log) + } + Debug.log(sourceUrl, "┌获取图片url", log) + rssArticle.image = analyzeRule.getString(ruleImage, isUrl = true) + Debug.log(sourceUrl, "└${rssArticle.image}", log) + Debug.log(sourceUrl, "┌获取文章链接", log) + rssArticle.link = NetworkUtils.getAbsoluteURL(sourceUrl, analyzeRule.getString(ruleLink)) + Debug.log(sourceUrl, "└${rssArticle.link}", log) + rssArticle.variable = GSON.toJson(analyzeRule.ruleData.variableMap) + if (rssArticle.title.isBlank()) { + return null + } + return rssArticle + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/rss/RssParserDefault.kt b/app/src/main/java/io/legado/app/model/rss/RssParserDefault.kt new file mode 100644 index 000000000..6568283c3 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/rss/RssParserDefault.kt @@ -0,0 +1,150 @@ +package io.legado.app.model.rss + +import io.legado.app.data.entities.RssArticle +import io.legado.app.model.Debug +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import org.xmlpull.v1.XmlPullParserFactory +import java.io.IOException +import java.io.StringReader + +@Suppress("unused") +object RssParserDefault { + + @Throws(XmlPullParserException::class, IOException::class) + fun parseXML( + sortName: String, + xml: String, + sourceUrl: String + ): Pair, String?> { + + val articleList = mutableListOf() + var currentArticle = RssArticle() + + val factory = XmlPullParserFactory.newInstance() + factory.isNamespaceAware = false + + val xmlPullParser = factory.newPullParser() + xmlPullParser.setInput(StringReader(xml)) + + // A flag just to be sure of the correct parsing + var insideItem = false + + var eventType = xmlPullParser.eventType + + // Start parsing the xml + loop@ while (eventType != XmlPullParser.END_DOCUMENT) { + + // Start parsing the item + if (eventType == XmlPullParser.START_TAG) { + when { + xmlPullParser.name.equals(RSS_ITEM, true) -> + insideItem = true + xmlPullParser.name.equals(RSS_ITEM_TITLE, true) -> + if (insideItem) currentArticle.title = xmlPullParser.nextText().trim() + xmlPullParser.name.equals(RSS_ITEM_LINK, true) -> + if (insideItem) currentArticle.link = xmlPullParser.nextText().trim() + xmlPullParser.name.equals(RSS_ITEM_THUMBNAIL, true) -> + if (insideItem) currentArticle.image = + xmlPullParser.getAttributeValue(null, RSS_ITEM_URL) + xmlPullParser.name.equals(RSS_ITEM_ENCLOSURE, true) -> + if (insideItem) { + val type = + xmlPullParser.getAttributeValue(null, RSS_ITEM_TYPE) + if (type != null && type.contains("image/")) { + currentArticle.image = + xmlPullParser.getAttributeValue(null, RSS_ITEM_URL) + } + } + xmlPullParser.name + .equals(RSS_ITEM_DESCRIPTION, true) -> + if (insideItem) { + val description = xmlPullParser.nextText() + currentArticle.description = description.trim() + if (currentArticle.image == null) { + currentArticle.image = getImageUrl(description) + } + } + xmlPullParser.name.equals(RSS_ITEM_CONTENT, true) -> + if (insideItem) { + val content = xmlPullParser.nextText().trim() + currentArticle.content = content + if (currentArticle.image == null) { + currentArticle.image = getImageUrl(content) + } + } + xmlPullParser.name + .equals(RSS_ITEM_PUB_DATE, true) -> + if (insideItem) { + val nextTokenType = xmlPullParser.next() + if (nextTokenType == XmlPullParser.TEXT) { + currentArticle.pubDate = xmlPullParser.text.trim() + } + // Skip to be able to find date inside 'tag' tag + continue@loop + } + xmlPullParser.name.equals(RSS_ITEM_TIME, true) -> + if (insideItem) currentArticle.pubDate = xmlPullParser.nextText() + } + } else if (eventType == XmlPullParser.END_TAG + && xmlPullParser.name.equals("item", true) + ) { + // The item is correctly parsed + insideItem = false + currentArticle.origin = sourceUrl + currentArticle.sort = sortName + articleList.add(currentArticle) + currentArticle = RssArticle() + } + eventType = xmlPullParser.next() + } + articleList.firstOrNull()?.let { + Debug.log(sourceUrl, "┌获取标题") + Debug.log(sourceUrl, "└${it.title}") + Debug.log(sourceUrl, "┌获取时间") + Debug.log(sourceUrl, "└${it.pubDate}") + Debug.log(sourceUrl, "┌获取描述") + Debug.log(sourceUrl, "└${it.description}") + Debug.log(sourceUrl, "┌获取图片url") + Debug.log(sourceUrl, "└${it.image}") + Debug.log(sourceUrl, "┌获取文章链接") + Debug.log(sourceUrl, "└${it.link}") + } + return Pair(articleList, null) + } + + /** + * Finds the first img tag and get the src as featured image + * + * @param input The content in which to search for the tag + * @return The url, if there is one + */ + private fun getImageUrl(input: String): String? { + + var url: String? = null + val patternImg = "(]*>)".toPattern() + val matcherImg = patternImg.matcher(input) + if (matcherImg.find()) { + val imgTag = matcherImg.group(1) + val patternLink = "src\\s*=\\s*\"([^\"]+)\"".toPattern() + val matcherLink = patternLink.matcher(imgTag!!) + if (matcherLink.find()) { + url = matcherLink.group(1)!!.trim() + } + } + return url + } + + private const val RSS_ITEM = "item" + private const val RSS_ITEM_TITLE = "title" + private const val RSS_ITEM_LINK = "link" + private const val RSS_ITEM_CATEGORY = "category" + private const val RSS_ITEM_THUMBNAIL = "media:thumbnail" + private const val RSS_ITEM_ENCLOSURE = "enclosure" + private const val RSS_ITEM_DESCRIPTION = "description" + private const val RSS_ITEM_CONTENT = "content:encoded" + private const val RSS_ITEM_PUB_DATE = "pubDate" + private const val RSS_ITEM_TIME = "time" + private const val RSS_ITEM_URL = "url" + private const val RSS_ITEM_TYPE = "type" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt b/app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt new file mode 100644 index 000000000..db8a7c775 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt @@ -0,0 +1,215 @@ +package io.legado.app.model.webBook + +import android.text.TextUtils +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.TocRule +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.TocEmptyException +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import splitties.init.appCtx + +/** + * 获取目录 + */ +object BookChapterList { + + private val falseRegex = "\\s*(?i)(null|false|0)\\s*".toRegex() + + suspend fun analyzeChapterList( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + redirectUrl: String, + baseUrl: String, + body: String? + ): List { + body ?: throw NoStackTraceException( + appCtx.getString(R.string.error_get_web_content, baseUrl) + ) + val chapterList = ArrayList() + Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") + Debug.log(bookSource.bookSourceUrl, body, state = 30) + val tocRule = bookSource.getTocRule() + val nextUrlList = arrayListOf(baseUrl) + var reverse = false + var listRule = tocRule.chapterList ?: "" + if (listRule.startsWith("-")) { + reverse = true + listRule = listRule.substring(1) + } + if (listRule.startsWith("+")) { + listRule = listRule.substring(1) + } + var chapterData = + analyzeChapterList( + scope, book, baseUrl, redirectUrl, body, + tocRule, listRule, bookSource, log = true + ) + chapterList.addAll(chapterData.first) + when (chapterData.second.size) { + 0 -> Unit + 1 -> { + var nextUrl = chapterData.second[0] + while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { + nextUrlList.add(nextUrl) + AnalyzeUrl( + mUrl = nextUrl, + source = bookSource, + ruleData = book, + headerMapF = bookSource.getHeaderMap() + ).getStrResponseAwait().body?.let { nextBody -> + chapterData = analyzeChapterList( + scope, book, nextUrl, nextUrl, + nextBody, tocRule, listRule, bookSource + ) + nextUrl = chapterData.second.firstOrNull() ?: "" + chapterList.addAll(chapterData.first) + } + } + Debug.log(bookSource.bookSourceUrl, "◇目录总页数:${nextUrlList.size}") + } + else -> { + Debug.log(bookSource.bookSourceUrl, "◇并发解析目录,总页数:${chapterData.second.size}") + withContext(IO) { + val asyncArray = Array(chapterData.second.size) { + async(IO) { + val urlStr = chapterData.second[it] + val analyzeUrl = AnalyzeUrl( + mUrl = urlStr, + source = bookSource, + ruleData = book, + headerMapF = bookSource.getHeaderMap() + ) + val res = analyzeUrl.getStrResponseAwait() + analyzeChapterList( + this, book, urlStr, res.url, + res.body!!, tocRule, listRule, bookSource, false + ).first + } + } + asyncArray.forEach { coroutine -> + chapterList.addAll(coroutine.await()) + } + } + } + } + if (chapterList.isEmpty()) { + throw TocEmptyException(appCtx.getString(R.string.chapter_list_empty)) + } + //去重 + if (!reverse) { + chapterList.reverse() + } + val lh = LinkedHashSet(chapterList) + val list = ArrayList(lh) + if (!book.getReverseToc()) { + list.reverse() + } + Debug.log(book.origin, "◇目录总数:${list.size}") + list.forEachIndexed { index, bookChapter -> + bookChapter.index = index + } + book.latestChapterTitle = list.last().title + book.durChapterTitle = + list.getOrNull(book.durChapterIndex)?.title ?: book.latestChapterTitle + if (book.totalChapterNum < list.size) { + book.lastCheckCount = list.size - book.totalChapterNum + book.latestChapterTime = System.currentTimeMillis() + } + book.lastCheckTime = System.currentTimeMillis() + book.totalChapterNum = list.size + return list + } + + private fun analyzeChapterList( + scope: CoroutineScope, + book: Book, + baseUrl: String, + redirectUrl: String, + body: String, + tocRule: TocRule, + listRule: String, + bookSource: BookSource, + getNextUrl: Boolean = true, + log: Boolean = false + ): Pair, List> { + val analyzeRule = AnalyzeRule(book, bookSource) + analyzeRule.setContent(body).setBaseUrl(baseUrl) + analyzeRule.setRedirectUrl(redirectUrl) + //获取目录列表 + val chapterList = arrayListOf() + Debug.log(bookSource.bookSourceUrl, "┌获取目录列表", log) + val elements = analyzeRule.getElements(listRule) + Debug.log(bookSource.bookSourceUrl, "└列表大小:${elements.size}", log) + //获取下一页链接 + val nextUrlList = arrayListOf() + val nextTocRule = tocRule.nextTocUrl + if (getNextUrl && !nextTocRule.isNullOrEmpty()) { + Debug.log(bookSource.bookSourceUrl, "┌获取目录下一页列表", log) + analyzeRule.getStringList(nextTocRule, isUrl = true)?.let { + for (item in it) { + if (item != baseUrl) { + nextUrlList.add(item) + } + } + } + Debug.log( + bookSource.bookSourceUrl, + "└" + TextUtils.join(",\n", nextUrlList), + log + ) + } + scope.ensureActive() + if (elements.isNotEmpty()) { + Debug.log(bookSource.bookSourceUrl, "┌解析目录列表", log) + val nameRule = analyzeRule.splitSourceRule(tocRule.chapterName) + val urlRule = analyzeRule.splitSourceRule(tocRule.chapterUrl) + val vipRule = analyzeRule.splitSourceRule(tocRule.isVip) + val payRule = analyzeRule.splitSourceRule(tocRule.isPay) + val upTimeRule = analyzeRule.splitSourceRule(tocRule.updateTime) + elements.forEachIndexed { index, item -> + scope.ensureActive() + analyzeRule.setContent(item) + val bookChapter = BookChapter(bookUrl = book.bookUrl, baseUrl = baseUrl) + analyzeRule.chapter = bookChapter + bookChapter.title = analyzeRule.getString(nameRule) + bookChapter.url = analyzeRule.getString(urlRule) + bookChapter.tag = analyzeRule.getString(upTimeRule) + if (bookChapter.url.isEmpty()) { + bookChapter.url = baseUrl + Debug.log(bookSource.bookSourceUrl, "目录${index}未获取到url,使用baseUrl替代") + } + if (bookChapter.title.isNotEmpty()) { + val isVip = analyzeRule.getString(vipRule) + val isPay = analyzeRule.getString(payRule) + if (isVip.isNotEmpty() && !isVip.matches(falseRegex)) { + bookChapter.isVip = true + } + if (isPay.isNotEmpty() && !isPay.matches(falseRegex)) { + bookChapter.isPay = true + } + chapterList.add(bookChapter) + } + } + Debug.log(bookSource.bookSourceUrl, "└目录列表解析完成", log) + Debug.log(bookSource.bookSourceUrl, "┌获取首章名称", log) + Debug.log(bookSource.bookSourceUrl, "└${chapterList[0].title}", log) + Debug.log(bookSource.bookSourceUrl, "┌获取首章链接", log) + Debug.log(bookSource.bookSourceUrl, "└${chapterList[0].url}", log) + Debug.log(bookSource.bookSourceUrl, "┌获取首章信息", log) + Debug.log(bookSource.bookSourceUrl, "└${chapterList[0].tag}", log) + } + return Pair(chapterList, nextUrlList) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt new file mode 100644 index 000000000..d7b3116c6 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt @@ -0,0 +1,160 @@ +package io.legado.app.model.webBook + +import io.legado.app.R +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.ContentRule +import io.legado.app.help.BookHelp +import io.legado.app.model.ContentEmptyException +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.HtmlFormatter +import io.legado.app.utils.NetworkUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import splitties.init.appCtx + +/** + * 获取正文 + */ +object BookContent { + + @Throws(Exception::class) + suspend fun analyzeContent( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + bookChapter: BookChapter, + redirectUrl: String, + baseUrl: String, + body: String?, + nextChapterUrl: String? = null + ): String { + body ?: throw NoStackTraceException( + appCtx.getString(R.string.error_get_web_content, baseUrl) + ) + Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") + Debug.log(bookSource.bookSourceUrl, body, state = 40) + val mNextChapterUrl = if (!nextChapterUrl.isNullOrEmpty()) { + nextChapterUrl + } else { + appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url + } + val content = StringBuilder() + val nextUrlList = arrayListOf(baseUrl) + val contentRule = bookSource.getContentRule() + val analyzeRule = AnalyzeRule(book, bookSource).setContent(body, baseUrl) + analyzeRule.setRedirectUrl(baseUrl) + analyzeRule.nextChapterUrl = mNextChapterUrl + scope.ensureActive() + var contentData = analyzeContent( + book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl + ) + content.append(contentData.first) + if (contentData.second.size == 1) { + var nextUrl = contentData.second[0] + while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { + if (!mNextChapterUrl.isNullOrEmpty() + && NetworkUtils.getAbsoluteURL(baseUrl, nextUrl) + == NetworkUtils.getAbsoluteURL(baseUrl, mNextChapterUrl) + ) break + nextUrlList.add(nextUrl) + scope.ensureActive() + val res = AnalyzeUrl( + mUrl = nextUrl, + source = bookSource, + ruleData = book, + headerMapF = bookSource.getHeaderMap() + ).getStrResponseAwait() + res.body?.let { nextBody -> + contentData = analyzeContent( + book, nextUrl, res.url, nextBody, contentRule, + bookChapter, bookSource, mNextChapterUrl, false + ) + nextUrl = + if (contentData.second.isNotEmpty()) contentData.second[0] else "" + content.append("\n").append(contentData.first) + } + } + Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}") + } else if (contentData.second.size > 1) { + Debug.log(bookSource.bookSourceUrl, "◇并发解析目录,总页数:${contentData.second.size}") + withContext(IO) { + val asyncArray = Array(contentData.second.size) { + async(IO) { + val urlStr = contentData.second[it] + val analyzeUrl = AnalyzeUrl( + mUrl = urlStr, + source = bookSource, + ruleData = book, + headerMapF = bookSource.getHeaderMap() + ) + val res = analyzeUrl.getStrResponseAwait() + analyzeContent( + book, urlStr, res.url, res.body!!, contentRule, + bookChapter, bookSource, mNextChapterUrl, false + ).first + } + } + asyncArray.forEach { coroutine -> + scope.ensureActive() + content.append("\n").append(coroutine.await()) + } + } + } + var contentStr = content.toString() + val replaceRegex = contentRule.replaceRegex + if (!replaceRegex.isNullOrEmpty()) { + contentStr = analyzeRule.getString(replaceRegex, contentStr) + } + Debug.log(bookSource.bookSourceUrl, "┌获取章节名称") + Debug.log(bookSource.bookSourceUrl, "└${bookChapter.title}") + Debug.log(bookSource.bookSourceUrl, "┌获取正文内容") + Debug.log(bookSource.bookSourceUrl, "└\n$contentStr") + if (contentStr.isBlank()) { + throw ContentEmptyException("内容为空") + } + BookHelp.saveContent(scope, bookSource, book, bookChapter, contentStr) + return contentStr + } + + @Throws(Exception::class) + private fun analyzeContent( + book: Book, + baseUrl: String, + redirectUrl: String, + body: String, + contentRule: ContentRule, + chapter: BookChapter, + bookSource: BookSource, + nextChapterUrl: String?, + printLog: Boolean = true + ): Pair> { + val analyzeRule = AnalyzeRule(book, bookSource) + analyzeRule.setContent(body, baseUrl) + val rUrl = analyzeRule.setRedirectUrl(redirectUrl) + analyzeRule.nextChapterUrl = nextChapterUrl + val nextUrlList = arrayListOf() + analyzeRule.chapter = chapter + //获取正文 + var content = analyzeRule.getString(contentRule.content) + content = HtmlFormatter.formatKeepImg(content, rUrl) + //获取下一页链接 + val nextUrlRule = contentRule.nextContentUrl + if (!nextUrlRule.isNullOrEmpty()) { + Debug.log(bookSource.bookSourceUrl, "┌获取正文下一页链接", printLog) + analyzeRule.getStringList(nextUrlRule, isUrl = true)?.let { + nextUrlList.addAll(it) + } + Debug.log(bookSource.bookSourceUrl, "└" + nextUrlList.joinToString(","), printLog) + } + return Pair(content, nextUrlList) + } +} diff --git a/app/src/main/java/io/legado/app/model/webBook/BookInfo.kt b/app/src/main/java/io/legado/app/model/webBook/BookInfo.kt new file mode 100644 index 000000000..ceb1b3b75 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webBook/BookInfo.kt @@ -0,0 +1,140 @@ +package io.legado.app.model.webBook + +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.help.BookHelp +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.utils.HtmlFormatter +import io.legado.app.utils.NetworkUtils +import io.legado.app.utils.StringUtils.wordCountFormat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ensureActive +import splitties.init.appCtx + +/** + * 获取详情 + */ +object BookInfo { + + @Throws(Exception::class) + fun analyzeBookInfo( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + redirectUrl: String, + baseUrl: String, + body: String?, + canReName: Boolean, + ) { + body ?: throw NoStackTraceException( + appCtx.getString(R.string.error_get_web_content, baseUrl) + ) + Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") + Debug.log(bookSource.bookSourceUrl, body, state = 20) + val analyzeRule = AnalyzeRule(book, bookSource) + analyzeRule.setContent(body).setBaseUrl(baseUrl) + analyzeRule.setRedirectUrl(redirectUrl) + analyzeBookInfo(scope, book, body, analyzeRule, bookSource, baseUrl, redirectUrl, canReName) + } + + fun analyzeBookInfo( + scope: CoroutineScope, + book: Book, + body: String, + analyzeRule: AnalyzeRule, + bookSource: BookSource, + baseUrl: String, + redirectUrl: String, + canReName: Boolean, + ) { + val infoRule = bookSource.getBookInfoRule() + infoRule.init?.let { + if (it.isNotBlank()) { + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "≡执行详情页初始化规则") + analyzeRule.setContent(analyzeRule.getElement(it)) + } + } + val mCanReName = canReName && !infoRule.canReName.isNullOrBlank() + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取书名") + BookHelp.formatBookName(analyzeRule.getString(infoRule.name)).let { + if (it.isNotEmpty() && (mCanReName || book.name.isEmpty())) { + book.name = it + } + Debug.log(bookSource.bookSourceUrl, "└${it}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取作者") + BookHelp.formatBookAuthor(analyzeRule.getString(infoRule.author)).let { + if (it.isNotEmpty() && (mCanReName || book.author.isEmpty())) { + book.author = it + } + Debug.log(bookSource.bookSourceUrl, "└${it}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取分类") + try { + analyzeRule.getStringList(infoRule.kind) + ?.joinToString(",") + ?.let { + if (it.isNotEmpty()) book.kind = it + } + Debug.log(bookSource.bookSourceUrl, "└${book.kind}") + } catch (e: Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取字数") + try { + wordCountFormat(analyzeRule.getString(infoRule.wordCount)).let { + if (it.isNotEmpty()) book.wordCount = it + } + Debug.log(bookSource.bookSourceUrl, "└${book.wordCount}") + } catch (e: Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取最新章节") + try { + analyzeRule.getString(infoRule.lastChapter).let { + if (it.isNotEmpty()) book.latestChapterTitle = it + } + Debug.log(bookSource.bookSourceUrl, "└${book.latestChapterTitle}") + } catch (e: Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取简介") + try { + analyzeRule.getString(infoRule.intro).let { + if (it.isNotEmpty()) book.intro = HtmlFormatter.format(it) + } + Debug.log(bookSource.bookSourceUrl, "└${book.intro}") + } catch (e: Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取封面链接") + try { + analyzeRule.getString(infoRule.coverUrl).let { + if (it.isNotEmpty()) book.coverUrl = NetworkUtils.getAbsoluteURL(baseUrl, it) + } + Debug.log(bookSource.bookSourceUrl, "└${book.coverUrl}") + } catch (e: Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}") + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取目录链接") + book.tocUrl = analyzeRule.getString(infoRule.tocUrl, isUrl = true) + if (book.tocUrl.isEmpty()) book.tocUrl = redirectUrl + if (book.tocUrl == redirectUrl) { + book.tocHtml = body + } + Debug.log(bookSource.bookSourceUrl, "└${book.tocUrl}") + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webBook/BookList.kt b/app/src/main/java/io/legado/app/model/webBook/BookList.kt new file mode 100644 index 000000000..8bc9dacad --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webBook/BookList.kt @@ -0,0 +1,244 @@ +package io.legado.app.model.webBook + +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.rule.BookListRule +import io.legado.app.help.BookHelp +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.HtmlFormatter +import io.legado.app.utils.NetworkUtils +import io.legado.app.utils.StringUtils.wordCountFormat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ensureActive +import splitties.init.appCtx + +/** + * 获取书籍列表 + */ +object BookList { + + @Throws(Exception::class) + fun analyzeBookList( + scope: CoroutineScope, + bookSource: BookSource, + variableBook: SearchBook, + analyzeUrl: AnalyzeUrl, + baseUrl: String, + body: String?, + isSearch: Boolean = true, + ): ArrayList { + body ?: throw NoStackTraceException( + appCtx.getString( + R.string.error_get_web_content, + analyzeUrl.ruleUrl + ) + ) + val bookList = ArrayList() + Debug.log(bookSource.bookSourceUrl, "≡获取成功:${analyzeUrl.ruleUrl}") + Debug.log(bookSource.bookSourceUrl, body, state = 10) + val analyzeRule = AnalyzeRule(variableBook, bookSource) + analyzeRule.setContent(body).setBaseUrl(baseUrl) + analyzeRule.setRedirectUrl(baseUrl) + bookSource.bookUrlPattern?.let { + scope.ensureActive() + if (baseUrl.matches(it.toRegex())) { + Debug.log(bookSource.bookSourceUrl, "≡链接为详情页") + getInfoItem( + scope, bookSource, analyzeRule, analyzeUrl, body, baseUrl, variableBook.variable + )?.let { searchBook -> + searchBook.infoHtml = body + bookList.add(searchBook) + } + return bookList + } + } + val collections: List + var reverse = false + val bookListRule: BookListRule = when { + isSearch -> bookSource.getSearchRule() + bookSource.getExploreRule().bookList.isNullOrBlank() -> bookSource.getSearchRule() + else -> bookSource.getExploreRule() + } + var ruleList: String = bookListRule.bookList ?: "" + if (ruleList.startsWith("-")) { + reverse = true + ruleList = ruleList.substring(1) + } + if (ruleList.startsWith("+")) { + ruleList = ruleList.substring(1) + } + Debug.log(bookSource.bookSourceUrl, "┌获取书籍列表") + collections = analyzeRule.getElements(ruleList) + scope.ensureActive() + if (collections.isEmpty() && bookSource.bookUrlPattern.isNullOrEmpty()) { + Debug.log(bookSource.bookSourceUrl, "└列表为空,按详情页解析") + getInfoItem( + scope, bookSource, analyzeRule, analyzeUrl, body, baseUrl, variableBook.variable + )?.let { searchBook -> + searchBook.infoHtml = body + bookList.add(searchBook) + } + } else { + val ruleName = analyzeRule.splitSourceRule(bookListRule.name) + val ruleBookUrl = analyzeRule.splitSourceRule(bookListRule.bookUrl) + val ruleAuthor = analyzeRule.splitSourceRule(bookListRule.author) + val ruleCoverUrl = analyzeRule.splitSourceRule(bookListRule.coverUrl) + val ruleIntro = analyzeRule.splitSourceRule(bookListRule.intro) + val ruleKind = analyzeRule.splitSourceRule(bookListRule.kind) + val ruleLastChapter = analyzeRule.splitSourceRule(bookListRule.lastChapter) + val ruleWordCount = analyzeRule.splitSourceRule(bookListRule.wordCount) + Debug.log(bookSource.bookSourceUrl, "└列表大小:${collections.size}") + for ((index, item) in collections.withIndex()) { + getSearchItem( + scope, bookSource, analyzeRule, item, baseUrl, variableBook.variable, + index == 0, + ruleName = ruleName, + ruleBookUrl = ruleBookUrl, + ruleAuthor = ruleAuthor, + ruleCoverUrl = ruleCoverUrl, + ruleIntro = ruleIntro, + ruleKind = ruleKind, + ruleLastChapter = ruleLastChapter, + ruleWordCount = ruleWordCount + )?.let { searchBook -> + if (baseUrl == searchBook.bookUrl) { + searchBook.infoHtml = body + } + bookList.add(searchBook) + } + } + if (reverse) { + bookList.reverse() + } + } + return bookList + } + + @Throws(Exception::class) + private fun getInfoItem( + scope: CoroutineScope, + bookSource: BookSource, + analyzeRule: AnalyzeRule, + analyzeUrl: AnalyzeUrl, + body: String, + baseUrl: String, + variable: String? + ): SearchBook? { + val book = Book(variable = variable) + book.bookUrl = analyzeUrl.ruleUrl + book.origin = bookSource.bookSourceUrl + book.originName = bookSource.bookSourceName + book.originOrder = bookSource.customOrder + book.type = bookSource.bookSourceType + analyzeRule.book = book + BookInfo.analyzeBookInfo( + scope, + book, + body, + analyzeRule, + bookSource, + baseUrl, + baseUrl, + false + ) + if (book.name.isNotBlank()) { + return book.toSearchBook() + } + return null + } + + @Throws(Exception::class) + private fun getSearchItem( + scope: CoroutineScope, + bookSource: BookSource, + analyzeRule: AnalyzeRule, + item: Any, + baseUrl: String, + variable: String?, + log: Boolean, + ruleName: List, + ruleBookUrl: List, + ruleAuthor: List, + ruleKind: List, + ruleCoverUrl: List, + ruleWordCount: List, + ruleIntro: List, + ruleLastChapter: List + ): SearchBook? { + val searchBook = SearchBook(variable = variable) + searchBook.origin = bookSource.bookSourceUrl + searchBook.originName = bookSource.bookSourceName + searchBook.type = bookSource.bookSourceType + searchBook.originOrder = bookSource.customOrder + analyzeRule.book = searchBook + analyzeRule.setContent(item) + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取书名", log) + searchBook.name = BookHelp.formatBookName(analyzeRule.getString(ruleName)) + Debug.log(bookSource.bookSourceUrl, "└${searchBook.name}", log) + if (searchBook.name.isNotEmpty()) { + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取作者", log) + searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor)) + Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log) + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取分类", log) + try { + searchBook.kind = analyzeRule.getStringList(ruleKind)?.joinToString(",") + Debug.log(bookSource.bookSourceUrl, "└${searchBook.kind}", log) + } catch (e: Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取字数", log) + try { + searchBook.wordCount = wordCountFormat(analyzeRule.getString(ruleWordCount)) + Debug.log(bookSource.bookSourceUrl, "└${searchBook.wordCount}", log) + } catch (e: java.lang.Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取最新章节", log) + try { + searchBook.latestChapterTitle = analyzeRule.getString(ruleLastChapter) + Debug.log(bookSource.bookSourceUrl, "└${searchBook.latestChapterTitle}", log) + } catch (e: java.lang.Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取简介", log) + try { + searchBook.intro = HtmlFormatter.format(analyzeRule.getString(ruleIntro)) + Debug.log(bookSource.bookSourceUrl, "└${searchBook.intro}", log) + } catch (e: java.lang.Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取封面链接", log) + try { + analyzeRule.getString(ruleCoverUrl).let { + if (it.isNotEmpty()) searchBook.coverUrl = + NetworkUtils.getAbsoluteURL(baseUrl, it) + } + Debug.log(bookSource.bookSourceUrl, "└${searchBook.coverUrl}", log) + } catch (e: java.lang.Exception) { + Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) + } + scope.ensureActive() + Debug.log(bookSource.bookSourceUrl, "┌获取详情页链接", log) + searchBook.bookUrl = analyzeRule.getString(ruleBookUrl, isUrl = true) + if (searchBook.bookUrl.isEmpty()) { + searchBook.bookUrl = baseUrl + } + Debug.log(bookSource.bookSourceUrl, "└${searchBook.bookUrl}", log) + return searchBook + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt new file mode 100644 index 000000000..97d897759 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt @@ -0,0 +1,209 @@ +package io.legado.app.model.webBook + +import io.legado.app.constant.AppConst +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.CompositeCoroutine +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.isActive +import splitties.init.appCtx +import java.util.concurrent.Executors +import kotlin.math.min + +class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) { + val threadCount = AppConfig.threadCount + private var searchPool: ExecutorCoroutineDispatcher? = null + private var mSearchId = 0L + private var searchPage = 1 + private var searchKey: String = "" + private var tasks = CompositeCoroutine() + private var bookSourceList = arrayListOf() + private var searchBooks = arrayListOf() + + @Volatile + private var searchIndex = -1 + + private fun initSearchPool() { + searchPool?.close() + searchPool = Executors + .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + } + + fun search(searchId: Long, key: String) { + callBack.onSearchStart() + if (searchId != mSearchId) { + if (key.isEmpty()) { + callBack.onSearchCancel() + return + } else { + this.searchKey = key + } + if (mSearchId != 0L) { + close() + } + initSearchPool() + mSearchId = searchId + searchPage = 1 + val searchGroup = appCtx.getPrefString("searchGroup") ?: "" + bookSourceList.clear() + if (searchGroup.isBlank()) { + bookSourceList.addAll(appDb.bookSourceDao.allEnabled) + } else { + val sources = appDb.bookSourceDao.getEnabledByGroup(searchGroup) + if (sources.isEmpty()) { + bookSourceList.addAll(appDb.bookSourceDao.allEnabled) + } else { + bookSourceList.addAll(sources) + } + } + } else { + searchPage++ + } + searchIndex = -1 + for (i in 0 until threadCount) { + search(searchId) + } + } + + @Synchronized + private fun search(searchId: Long) { + if (searchIndex >= bookSourceList.lastIndex) { + return + } + searchIndex++ + val source = bookSourceList[searchIndex] + searchPool?.let { searchPool -> + val task = WebBook.searchBook( + scope, + source, + searchKey, + searchPage, + context = searchPool + ).timeout(30000L) + .onSuccess(searchPool) { + onSuccess(searchId, it) + } + .onFinally(searchPool) { + onFinally(searchId) + } + tasks.add(task) + } + } + + @Synchronized + private fun onSuccess(searchId: Long, items: ArrayList) { + if (searchId == mSearchId) { + appDb.searchBookDao.insert(*items.toTypedArray()) + val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch) + mergeItems(scope, items, precision) + callBack.onSearchSuccess(searchBooks) + } + } + + @Synchronized + private fun onFinally(searchId: Long) { + if (searchIndex < bookSourceList.lastIndex) { + search(searchId) + } else { + searchIndex++ + } + if (searchIndex >= bookSourceList.lastIndex + + min(bookSourceList.size, threadCount) + ) { + callBack.onSearchFinish() + } + } + + private fun mergeItems(scope: CoroutineScope, newDataS: List, precision: Boolean) { + if (newDataS.isNotEmpty()) { + val copyData = ArrayList(searchBooks) + val equalData = arrayListOf() + val containsData = arrayListOf() + val otherData = arrayListOf() + copyData.forEach { + if (!scope.isActive) return + if (it.name == searchKey || it.author == searchKey) { + equalData.add(it) + } else if (it.name.contains(searchKey) || it.author.contains(searchKey)) { + containsData.add(it) + } else { + otherData.add(it) + } + } + newDataS.forEach { nBook -> + if (!scope.isActive) return + if (nBook.name == searchKey || nBook.author == searchKey) { + var hasSame = false + equalData.forEach { pBook -> + if (!scope.isActive) return + if (pBook.name == nBook.name && pBook.author == nBook.author) { + pBook.addOrigin(nBook.origin) + hasSame = true + } + } + if (!hasSame) { + equalData.add(nBook) + } + } else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) { + var hasSame = false + containsData.forEach { pBook -> + if (!scope.isActive) return + if (pBook.name == nBook.name && pBook.author == nBook.author) { + pBook.addOrigin(nBook.origin) + hasSame = true + } + } + if (!hasSame) { + containsData.add(nBook) + } + } else if (!precision) { + var hasSame = false + otherData.forEach { pBook -> + if (!scope.isActive) return + if (pBook.name == nBook.name && pBook.author == nBook.author) { + pBook.addOrigin(nBook.origin) + hasSame = true + } + } + if (!hasSame) { + otherData.add(nBook) + } + } + } + if (!scope.isActive) return + equalData.sortByDescending { it.origins.size } + equalData.addAll(containsData.sortedByDescending { it.origins.size }) + if (!precision) { + equalData.addAll(otherData) + } + searchBooks = equalData + } + } + + fun cancelSearch() { + close() + callBack.onSearchCancel() + } + + fun close() { + tasks.clear() + searchPool?.close() + searchPool = null + mSearchId = 0L + } + + interface CallBack { + fun onSearchStart() + fun onSearchSuccess(searchBooks: ArrayList) + fun onSearchFinish() + fun onSearchCancel() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt new file mode 100644 index 000000000..373014f91 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt @@ -0,0 +1,345 @@ +package io.legado.app.model.webBook + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.http.StrResponse +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlin.coroutines.CoroutineContext + +@Suppress("MemberVisibilityCanBePrivate") +object WebBook { + + /** + * 搜索 + */ + fun searchBook( + scope: CoroutineScope, + bookSource: BookSource, + key: String, + page: Int? = 1, + context: CoroutineContext = Dispatchers.IO, + ): Coroutine> { + return Coroutine.async(scope, context) { + searchBookAwait(scope, bookSource, key, page) + } + } + + suspend fun searchBookAwait( + scope: CoroutineScope, + bookSource: BookSource, + key: String, + page: Int? = 1, + ): ArrayList { + val variableBook = SearchBook() + bookSource.searchUrl?.let { searchUrl -> + val analyzeUrl = AnalyzeUrl( + mUrl = searchUrl, + key = key, + page = page, + baseUrl = bookSource.bookSourceUrl, + headerMapF = bookSource.getHeaderMap(true), + source = bookSource, + ruleData = variableBook, + ) + var res = analyzeUrl.getStrResponseAwait() + //检测书源是否已登录 + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, res) as StrResponse + } + } + return BookList.analyzeBookList( + scope, + bookSource, + variableBook, + analyzeUrl, + res.url, + res.body, + true + ) + } + return arrayListOf() + } + + /** + * 发现 + */ + fun exploreBook( + scope: CoroutineScope, + bookSource: BookSource, + url: String, + page: Int? = 1, + context: CoroutineContext = Dispatchers.IO, + ): Coroutine> { + return Coroutine.async(scope, context) { + exploreBookAwait(scope, bookSource, url, page) + } + } + + suspend fun exploreBookAwait( + scope: CoroutineScope, + bookSource: BookSource, + url: String, + page: Int? = 1, + ): ArrayList { + val variableBook = SearchBook() + val analyzeUrl = AnalyzeUrl( + mUrl = url, + page = page, + baseUrl = bookSource.bookSourceUrl, + source = bookSource, + ruleData = variableBook, + headerMapF = bookSource.getHeaderMap(true) + ) + var res = analyzeUrl.getStrResponseAwait() + //检测书源是否已登录 + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse + } + } + return BookList.analyzeBookList( + scope, + bookSource, + variableBook, + analyzeUrl, + res.url, + res.body, + false + ) + } + + /** + * 书籍信息 + */ + fun getBookInfo( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + context: CoroutineContext = Dispatchers.IO, + canReName: Boolean = true, + ): Coroutine { + return Coroutine.async(scope, context) { + getBookInfoAwait(scope, bookSource, book, canReName) + } + } + + suspend fun getBookInfoAwait( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + canReName: Boolean = true, + ): Book { + book.type = bookSource.bookSourceType + if (!book.infoHtml.isNullOrEmpty()) { + BookInfo.analyzeBookInfo( + scope, + bookSource, + book, + book.bookUrl, + book.bookUrl, + book.infoHtml, + canReName + ) + } else { + val analyzeUrl = AnalyzeUrl( + mUrl = book.bookUrl, + baseUrl = bookSource.bookSourceUrl, + source = bookSource, + ruleData = book, + headerMapF = bookSource.getHeaderMap(true) + ) + var res = analyzeUrl.getStrResponseAwait() + //检测书源是否已登录 + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse + } + } + BookInfo.analyzeBookInfo( + scope, + bookSource, + book, + book.bookUrl, + res.url, + res.body, + canReName + ) + } + return book + } + + /** + * 目录 + */ + fun getChapterList( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + context: CoroutineContext = Dispatchers.IO + ): Coroutine> { + return Coroutine.async(scope, context) { + getChapterListAwait(scope, bookSource, book) + } + } + + suspend fun getChapterListAwait( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + ): List { + book.type = bookSource.bookSourceType + return if (book.bookUrl == book.tocUrl && !book.tocHtml.isNullOrEmpty()) { + BookChapterList.analyzeChapterList( + scope, + bookSource, + book, + book.tocUrl, + book.tocUrl, + book.tocHtml + ) + } else { + val analyzeUrl = AnalyzeUrl( + mUrl = book.tocUrl, + baseUrl = book.bookUrl, + source = bookSource, + ruleData = book, + headerMapF = bookSource.getHeaderMap(true) + ) + var res = analyzeUrl.getStrResponseAwait() + //检测书源是否已登录 + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse + } + } + BookChapterList.analyzeChapterList( + scope, + bookSource, + book, + book.tocUrl, + res.url, + res.body + ) + } + } + + /** + * 章节内容 + */ + fun getContent( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + bookChapter: BookChapter, + nextChapterUrl: String? = null, + context: CoroutineContext = Dispatchers.IO + ): Coroutine { + return Coroutine.async(scope, context) { + getContentAwait(scope, bookSource, book, bookChapter, nextChapterUrl) + } + } + + suspend fun getContentAwait( + scope: CoroutineScope, + bookSource: BookSource, + book: Book, + bookChapter: BookChapter, + nextChapterUrl: String? = null + ): String { + if (bookSource.getContentRule().content.isNullOrEmpty()) { + Debug.log(bookSource.bookSourceUrl, "⇒正文规则为空,使用章节链接:${bookChapter.url}") + return bookChapter.url + } + return if (bookChapter.url == book.bookUrl && !book.tocHtml.isNullOrEmpty()) { + BookContent.analyzeContent( + scope, + bookSource, + book, + bookChapter, + bookChapter.getAbsoluteURL(), + bookChapter.getAbsoluteURL(), + book.tocHtml, + nextChapterUrl + ) + } else { + val analyzeUrl = AnalyzeUrl( + mUrl = bookChapter.getAbsoluteURL(), + baseUrl = book.tocUrl, + source = bookSource, + ruleData = book, + chapter = bookChapter, + headerMapF = bookSource.getHeaderMap(true) + ) + var res = analyzeUrl.getStrResponseAwait( + jsStr = bookSource.getContentRule().webJs, + sourceRegex = bookSource.getContentRule().sourceRegex + ) + //检测书源是否已登录 + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse + } + } + BookContent.analyzeContent( + scope, + bookSource, + book, + bookChapter, + bookChapter.getAbsoluteURL(), + res.url, + res.body, + nextChapterUrl + ) + } + } + + /** + * 精准搜索 + */ + fun preciseSearch( + scope: CoroutineScope, + bookSources: List, + name: String, + author: String, + context: CoroutineContext = Dispatchers.IO, + ): Coroutine> { + return Coroutine.async(scope, context) { + preciseSearchAwait(scope, bookSources, name, author) + ?: throw NoStackTraceException("没有搜索到<$name>$author") + } + } + + suspend fun preciseSearchAwait( + scope: CoroutineScope, + bookSources: List, + name: String, + author: String + ): Pair? { + bookSources.forEach { source -> + kotlin.runCatching { + if (!scope.isActive) return null + searchBookAwait(scope, source, name).firstOrNull { + it.name == name && it.author == author + }?.let { searchBook -> + if (!scope.isActive) return null + var book = searchBook.toBook() + if (book.tocUrl.isBlank()) { + book = getBookInfoAwait(scope, source, book) + } + return Pair(source, book) + } + } + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt b/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt new file mode 100644 index 000000000..39f3df781 --- /dev/null +++ b/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt @@ -0,0 +1,106 @@ +package io.legado.app.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.view.KeyEvent +import io.legado.app.constant.AppLog +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.help.AppConfig +import io.legado.app.help.LifecycleHelp +import io.legado.app.model.AudioPlay +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.service.AudioPlayService +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.audio.AudioPlayActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.postEvent + + +/** + * Created by GKF on 2018/1/6. + * 监听耳机键 + */ +class MediaButtonReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (handleIntent(context, intent) && isOrderedBroadcast) { + abortBroadcast() + } + } + + companion object { + + fun handleIntent(context: Context, intent: Intent): Boolean { + val intentAction = intent.action + if (Intent.ACTION_MEDIA_BUTTON == intentAction) { + val keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + ?: return false + val keycode: Int = keyEvent.keyCode + val action: Int = keyEvent.action + if (action == KeyEvent.ACTION_DOWN) { + AppLog.put("mediaButton $action") + when (keycode) { + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + if (context.getPrefBoolean("mediaButtonPerNext", false)) { + ReadBook.moveToPrevChapter(true) + } else { + ReadAloud.prevParagraph(context) + } + } + KeyEvent.KEYCODE_MEDIA_NEXT -> { + if (context.getPrefBoolean("mediaButtonPerNext", false)) { + ReadBook.moveToNextChapter(true) + } else { + ReadAloud.nextParagraph(context) + } + } + else -> readAloud(context) + } + } + } + return true + } + + fun readAloud(context: Context, isMediaKey: Boolean = true) { + when { + BaseReadAloudService.isRun -> { + if (BaseReadAloudService.isPlay()) { + ReadAloud.pause(context) + AudioPlay.pause(context) + } else { + ReadAloud.resume(context) + AudioPlay.resume(context) + } + } + AudioPlayService.isRun -> { + if (AudioPlayService.pause) { + AudioPlay.resume(context) + } else { + AudioPlay.pause(context) + } + } + LifecycleHelp.isExistActivity(ReadBookActivity::class.java) -> + postEvent(EventBus.MEDIA_BUTTON, true) + LifecycleHelp.isExistActivity(AudioPlayActivity::class.java) -> + postEvent(EventBus.MEDIA_BUTTON, true) + else -> if (AppConfig.mediaButtonOnExit || LifecycleHelp.activitySize() > 0 || !isMediaKey) { + AppLog.put("readAloud start Service") + if (ReadBook.book != null) { + ReadBook.readAloud() + } else { + appDb.bookDao.lastReadBook?.let { + ReadBook.resetData(it) + ReadBook.curTextChapter ?: ReadBook.loadContent(false) + ReadBook.readAloud() + } + } + } + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt b/app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt new file mode 100644 index 000000000..c5867c899 --- /dev/null +++ b/app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt @@ -0,0 +1,58 @@ +package io.legado.app.receiver + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.ui.main.MainActivity +import io.legado.app.utils.startActivity +import splitties.init.appCtx + +class SharedReceiverActivity : AppCompatActivity() { + + private val receivingType = "text/plain" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initIntent() + finish() + } + + private fun initIntent() { + when { + intent.action == Intent.ACTION_SEND && intent.type == receivingType -> { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { + dispose(it) + } + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && intent.action == Intent.ACTION_PROCESS_TEXT + && intent.type == receivingType -> { + intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT)?.let { + dispose(it) + } + } + intent.getStringExtra("action") == "readAloud" -> { + MediaButtonReceiver.readAloud(appCtx, false) + } + } + } + + private fun dispose(text: String) { + if (text.isBlank()) { + return + } + val urls = text.split("\\s".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val result = StringBuilder() + for (url in urls) { + if (url.matches("http.+".toRegex())) + result.append("\n").append(url.trim { it <= ' ' }) + } + if (result.length > 1) { + startActivity() + } else { + SearchActivity.start(this, text) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/receiver/TimeBatteryReceiver.kt b/app/src/main/java/io/legado/app/receiver/TimeBatteryReceiver.kt new file mode 100644 index 000000000..73737aa79 --- /dev/null +++ b/app/src/main/java/io/legado/app/receiver/TimeBatteryReceiver.kt @@ -0,0 +1,39 @@ +package io.legado.app.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import io.legado.app.constant.EventBus +import io.legado.app.utils.postEvent + + +class TimeBatteryReceiver : BroadcastReceiver() { + + companion object { + + fun register(context: Context): TimeBatteryReceiver { + val receiver = TimeBatteryReceiver() + val filter = IntentFilter() + filter.addAction(Intent.ACTION_TIME_TICK) + filter.addAction(Intent.ACTION_BATTERY_CHANGED) + context.registerReceiver(receiver, filter) + return receiver + } + + } + + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_TIME_TICK -> { + postEvent(EventBus.TIME_CHANGED, "") + } + Intent.ACTION_BATTERY_CHANGED -> { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + postEvent(EventBus.BATTERY_CHANGED, level) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/AudioPlayService.kt b/app/src/main/java/io/legado/app/service/AudioPlayService.kt new file mode 100644 index 000000000..a6fdcbb97 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/AudioPlayService.kt @@ -0,0 +1,510 @@ +package io.legado.app.service + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.BitmapFactory +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.app.NotificationCompat +import androidx.media.AudioFocusRequestCompat +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.* +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.MediaHelp +import io.legado.app.help.exoplayer.ExoPlayerHelper +import io.legado.app.model.AudioPlay +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.webBook.WebBook +import io.legado.app.receiver.MediaButtonReceiver +import io.legado.app.ui.book.audio.AudioPlayActivity +import io.legado.app.utils.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.Main +import timber.log.Timber + + +class AudioPlayService : BaseService(), + AudioManager.OnAudioFocusChangeListener, + Player.Listener { + + companion object { + var isRun = false + private set + var pause = false + private set + var timeMinute: Int = 0 + private set + var url: String = "" + private set + } + + private val audioManager: AudioManager by lazy { + getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + private val mFocusRequest: AudioFocusRequestCompat by lazy { + MediaHelp.getFocusRequest(this) + } + private val exoPlayer: ExoPlayer by lazy { + ExoPlayer.Builder(this).build() + } + private var title: String = "" + private var subtitle: String = "" + private var mediaSessionCompat: MediaSessionCompat? = null + private var broadcastReceiver: BroadcastReceiver? = null + private var position = 0 + private var dsJob: Job? = null + private var upPlayProgressJob: Job? = null + private var playSpeed: Float = 1f + + override fun onCreate() { + super.onCreate() + isRun = true + upNotification() + exoPlayer.addListener(this) + initMediaSession() + initBroadcastReceiver() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.action?.let { action -> + when (action) { + IntentAction.play -> { + AudioPlay.book?.let { + title = it.name + subtitle = AudioPlay.durChapter?.title ?: "" + position = it.durChapterPos + loadContent() + } + } + IntentAction.pause -> pause(true) + IntentAction.resume -> resume() + IntentAction.prev -> AudioPlay.prev(this) + IntentAction.next -> AudioPlay.next(this) + IntentAction.adjustSpeed -> upSpeed(intent.getFloatExtra("adjust", 1f)) + IntentAction.addTimer -> addTimer() + IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0)) + IntentAction.adjustProgress -> { + adjustProgress(intent.getIntExtra("position", position)) + } + else -> stopSelf() + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + super.onDestroy() + isRun = false + exoPlayer.release() + mediaSessionCompat?.release() + unregisterReceiver(broadcastReceiver) + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED) + AudioPlay.status = Status.STOP + postEvent(EventBus.AUDIO_STATE, Status.STOP) + } + + /** + * 播放音频 + */ + private fun play() { + upNotification() + if (requestFocus()) { + kotlin.runCatching { + AudioPlay.status = Status.STOP + postEvent(EventBus.AUDIO_STATE, Status.STOP) + upPlayProgressJob?.cancel() + val analyzeUrl = AnalyzeUrl( + url, + source = AudioPlay.bookSource, + ruleData = AudioPlay.book, + chapter = AudioPlay.durChapter, + headerMapF = AudioPlay.headers(true), + ) + val uri = Uri.parse(analyzeUrl.url) + val mediaSource = ExoPlayerHelper + .createMediaSource(uri, analyzeUrl.headerMap) + exoPlayer.setMediaSource(mediaSource) + exoPlayer.playWhenReady = true + exoPlayer.prepare() + }.onFailure { + Timber.e(it) + toastOnUi("$url ${it.localizedMessage}") + stopSelf() + } + } + } + + /** + * 暂停播放 + */ + private fun pause(pause: Boolean) { + try { + AudioPlayService.pause = pause + upPlayProgressJob?.cancel() + position = exoPlayer.currentPosition.toInt() + if (exoPlayer.isPlaying) exoPlayer.pause() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED) + AudioPlay.status = Status.PAUSE + postEvent(EventBus.AUDIO_STATE, Status.PAUSE) + upNotification() + } catch (e: Exception) { + Timber.e(e) + } + } + + /** + * 恢复播放 + */ + private fun resume() { + try { + pause = false + if (!exoPlayer.isPlaying) { + exoPlayer.play() + } + upPlayProgress() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) + AudioPlay.status = Status.PLAY + postEvent(EventBus.AUDIO_STATE, Status.PLAY) + upNotification() + } catch (e: Exception) { + Timber.e(e) + stopSelf() + } + } + + /** + * 调节进度 + */ + private fun adjustProgress(position: Int) { + this.position = position + exoPlayer.seekTo(position.toLong()) + } + + /** + * 调节速度 + */ + private fun upSpeed(adjust: Float) { + kotlin.runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + playSpeed += adjust + exoPlayer.setPlaybackSpeed(playSpeed) + postEvent(EventBus.AUDIO_SPEED, playSpeed) + } + } + } + + /** + * 播放状态监控 + */ + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + when (playbackState) { + Player.STATE_IDLE -> { + // 空闲 + } + Player.STATE_BUFFERING -> { + // 缓冲中 + } + Player.STATE_READY -> { + // 准备好 + if (exoPlayer.currentPosition != position.toLong()) { + exoPlayer.seekTo(position.toLong()) + } + if (exoPlayer.playWhenReady) { + AudioPlay.status = Status.PLAY + postEvent(EventBus.AUDIO_STATE, Status.PLAY) + } else { + AudioPlay.status = Status.PAUSE + postEvent(EventBus.AUDIO_STATE, Status.PAUSE) + } + postEvent(EventBus.AUDIO_SIZE, exoPlayer.duration) + upPlayProgress() + AudioPlay.saveDurChapter(exoPlayer.duration) + } + Player.STATE_ENDED -> { + // 结束 + upPlayProgressJob?.cancel() + AudioPlay.next(this) + } + } + } + + /** + * 播放错误事件 + */ + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + AudioPlay.status = Status.STOP + postEvent(EventBus.AUDIO_STATE, Status.STOP) + val errorMsg = "音频播放出错\n${error.errorCodeName} ${error.errorCode}" + AppLog.put(errorMsg, error) + toastOnUi(errorMsg) + Timber.e(error) + } + + private fun setTimer(minute: Int) { + timeMinute = minute + doDs() + } + + private fun addTimer() { + if (timeMinute == 60) { + timeMinute = 0 + } else { + timeMinute += 10 + if (timeMinute > 60) timeMinute = 60 + } + doDs() + } + + /** + * 定时 + */ + private fun doDs() { + postEvent(EventBus.TTS_DS, timeMinute) + upNotification() + dsJob?.cancel() + dsJob = launch { + while (isActive) { + delay(60000) + if (!pause) { + if (timeMinute >= 0) { + timeMinute-- + } + if (timeMinute == 0) { + AudioPlay.stop(this@AudioPlayService) + } + } + postEvent(EventBus.TTS_DS, timeMinute) + upNotification() + } + } + } + + /** + * 每隔1秒发送播放进度 + */ + private fun upPlayProgress() { + upPlayProgressJob?.cancel() + upPlayProgressJob = launch { + while (isActive) { + AudioPlay.book?.let { + it.durChapterPos = exoPlayer.currentPosition.toInt() + postEvent(EventBus.AUDIO_PROGRESS, it.durChapterPos) + saveProgress(it) + } + delay(1000) + } + } + } + + /** + * 加载播放URL + */ + private fun loadContent() = with(AudioPlay) { + durChapter?.let { chapter -> + if (addLoading(chapter.index)) { + val book = AudioPlay.book + val bookSource = AudioPlay.bookSource + if (book != null && bookSource != null) { + WebBook.getContent(this@AudioPlayService, bookSource, book, chapter) + .onSuccess { content -> + if (content.isEmpty()) { + withContext(Main) { + toastOnUi("未获取到资源链接") + } + } else { + contentLoadFinish(chapter, content) + } + }.onError { + contentLoadFinish(chapter, it.localizedMessage ?: toString()) + }.onFinally { + removeLoading(chapter.index) + } + } else { + removeLoading(chapter.index) + toastOnUi("book or source is null") + } + } + } + } + + private fun addLoading(index: Int): Boolean { + synchronized(this) { + if (AudioPlay.loadingChapters.contains(index)) return false + AudioPlay.loadingChapters.add(index) + return true + } + } + + private fun removeLoading(index: Int) { + synchronized(this) { + AudioPlay.loadingChapters.remove(index) + } + } + + /** + * 加载完成 + */ + private fun contentLoadFinish(chapter: BookChapter, content: String) { + if (chapter.index == AudioPlay.book?.durChapterIndex) { + subtitle = chapter.title + url = content + play() + } + } + + /** + * 保存播放进度 + */ + private fun saveProgress(book: Book) { + execute { + appDb.bookDao.upProgress(book.bookUrl, book.durChapterPos) + } + } + + /** + * 更新媒体状态 + */ + private fun upMediaSessionPlaybackState(state: Int) { + mediaSessionCompat?.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions(MediaHelp.MEDIA_SESSION_ACTIONS) + .setState(state, position.toLong(), 1f) + .build() + ) + } + + /** + * 初始化MediaSession, 注册多媒体按钮 + */ + @SuppressLint("UnspecifiedImmutableFlag") + private fun initMediaSession() { + mediaSessionCompat = MediaSessionCompat(this, "readAloud") + mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() { + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + return MediaButtonReceiver.handleIntent(this@AudioPlayService, mediaButtonEvent) + } + }) + mediaSessionCompat?.setMediaButtonReceiver( + broadcastPendingIntent(Intent.ACTION_MEDIA_BUTTON) + ) + mediaSessionCompat?.isActive = true + } + + /** + * 断开耳机监听 + */ + private fun initBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) { + pause(true) + } + } + } + val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + registerReceiver(broadcastReceiver, intentFilter) + } + + /** + * 音频焦点变化 + */ + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + // 重新获得焦点, 可做恢复播放,恢复后台音量的操作 + if (!pause) resume() + } + AudioManager.AUDIOFOCUS_LOSS -> { + // 永久丢失焦点除非重新主动获取,这种情况是被其他播放器抢去了焦点, 为避免与其他播放器混音,可将音乐暂停 + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + // 暂时丢失焦点,这种情况是被其他应用申请了短暂的焦点,可压低后台音量 + if (!pause) pause(false) + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音), + } + } + } + + /** + * 更新通知 + */ + private fun upNotification() { + var nTitle: String = when { + pause -> getString(R.string.audio_pause) + timeMinute in 1..60 -> getString( + R.string.playing_timer, + timeMinute + ) + else -> getString(R.string.audio_play_t) + } + nTitle += ": $title" + var nSubtitle = subtitle + if (subtitle.isEmpty()) { + nSubtitle = getString(R.string.audio_play_s) + } + val builder = NotificationCompat.Builder(this, AppConst.channelIdReadAloud) + .setSmallIcon(R.drawable.ic_volume_up) + .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.icon_read_book)) + .setOngoing(true) + .setContentTitle(nTitle) + .setContentText(nSubtitle) + .setContentIntent( + activityPendingIntent("activity") + ) + if (pause) { + builder.addAction( + R.drawable.ic_play_24dp, + getString(R.string.resume), + servicePendingIntent(IntentAction.resume) + ) + } else { + builder.addAction( + R.drawable.ic_pause_24dp, + getString(R.string.pause), + servicePendingIntent(IntentAction.pause) + ) + } + builder.addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.stop), + servicePendingIntent(IntentAction.stop) + ) + builder.addAction( + R.drawable.ic_time_add_24dp, + getString(R.string.set_timer), + servicePendingIntent(IntentAction.addTimer) + ) + builder.setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1, 2) + ) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + val notification = builder.build() + startForeground(AppConst.notificationIdAudio, notification) + } + + /** + * @return 音频焦点 + */ + private fun requestFocus(): Boolean { + return MediaHelp.requestFocus(audioManager, mFocusRequest) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt new file mode 100644 index 000000000..49e2a907e --- /dev/null +++ b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt @@ -0,0 +1,369 @@ +package io.legado.app.service + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.BitmapFactory +import android.media.AudioManager +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.annotation.CallSuper +import androidx.core.app.NotificationCompat +import androidx.media.AudioFocusRequestCompat +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.* +import io.legado.app.help.MediaHelp +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.receiver.MediaButtonReceiver +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.utils.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +abstract class BaseReadAloudService : BaseService(), + AudioManager.OnAudioFocusChangeListener { + + companion object { + var isRun = false + private set + var timeMinute: Int = 0 + private set + var pause = true + private set + + fun isPlay(): Boolean { + return isRun && !pause + } + } + + private val audioManager: AudioManager by lazy { + getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + private val mFocusRequest: AudioFocusRequestCompat by lazy { + MediaHelp.getFocusRequest(this) + } + private val mediaSessionCompat: MediaSessionCompat by lazy { + MediaSessionCompat(this, "readAloud") + } + private var audioFocusLossTransient = false + internal val contentList = arrayListOf() + internal var nowSpeak: Int = 0 + internal var readAloudNumber: Int = 0 + internal var textChapter: TextChapter? = null + internal var pageIndex = 0 + private var dsJob: Job? = null + + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) { + pauseReadAloud(true) + } + } + } + + override fun onCreate() { + super.onCreate() + isRun = true + pause = false + initMediaSession() + initBroadcastReceiver() + upNotification() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) + doDs() + } + + override fun onDestroy() { + super.onDestroy() + isRun = false + pause = true + unregisterReceiver(broadcastReceiver) + postEvent(EventBus.ALOUD_STATE, Status.STOP) + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED) + mediaSessionCompat.release() + ReadBook.uploadProgress() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.action?.let { action -> + when (action) { + IntentAction.play -> { + textChapter = ReadBook.curTextChapter + pageIndex = ReadBook.durPageIndex() + newReadAloud( + intent.getBooleanExtra("play", true) + ) + } + IntentAction.pause -> pauseReadAloud(true) + IntentAction.resume -> resumeReadAloud() + IntentAction.upTtsSpeechRate -> upSpeechRate(true) + IntentAction.prevParagraph -> prevP() + IntentAction.nextParagraph -> nextP() + IntentAction.addTimer -> addTimer() + IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0)) + else -> stopSelf() + } + } + return super.onStartCommand(intent, flags, startId) + } + + @CallSuper + open fun newReadAloud(play: Boolean) { + textChapter?.let { textChapter -> + nowSpeak = 0 + readAloudNumber = textChapter.getReadLength(pageIndex) + contentList.clear() + if (getPrefBoolean(PreferKey.readAloudByPage)) { + for (index in pageIndex..textChapter.lastIndex) { + textChapter.page(index)?.text?.split("\n")?.let { + contentList.addAll(it) + } + } + } else { + textChapter.getUnRead(pageIndex).split("\n").forEach { + if (it.isNotEmpty()) { + contentList.add(it) + } + } + } + if (play) play() + } + } + + open fun play() { + pause = false + upNotification() + postEvent(EventBus.ALOUD_STATE, Status.PLAY) + } + + abstract fun playStop() + + @CallSuper + open fun pauseReadAloud(pause: Boolean) { + BaseReadAloudService.pause = pause + upNotification() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED) + postEvent(EventBus.ALOUD_STATE, Status.PAUSE) + ReadBook.uploadProgress() + doDs() + } + + @CallSuper + open fun resumeReadAloud() { + pause = false + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) + postEvent(EventBus.ALOUD_STATE, Status.PLAY) + } + + abstract fun upSpeechRate(reset: Boolean = false) + + private fun prevP() { + if (nowSpeak > 0) { + playStop() + nowSpeak-- + readAloudNumber -= contentList[nowSpeak].length.minus(1) + play() + } else { + ReadBook.moveToPrevChapter(true) + } + } + + private fun nextP() { + if (nowSpeak < contentList.size - 1) { + playStop() + readAloudNumber += contentList[nowSpeak].length.plus(1) + nowSpeak++ + play() + } else { + nextChapter() + } + } + + private fun setTimer(minute: Int) { + timeMinute = minute + doDs() + } + + private fun addTimer() { + if (timeMinute == 180) { + timeMinute = 0 + } else { + timeMinute += 10 + if (timeMinute > 180) timeMinute = 180 + } + doDs() + } + + /** + * 定时 + */ + @Synchronized + private fun doDs() { + postEvent(EventBus.TTS_DS, timeMinute) + upNotification() + dsJob?.cancel() + dsJob = launch { + while (isActive) { + delay(60000) + if (!pause) { + if (timeMinute >= 0) { + timeMinute-- + } + if (timeMinute == 0) { + ReadAloud.stop(this@BaseReadAloudService) + } + } + postEvent(EventBus.TTS_DS, timeMinute) + upNotification() + } + } + } + + /** + * @return 音频焦点 + */ + fun requestFocus(): Boolean { + val requestFocus = MediaHelp.requestFocus(audioManager, mFocusRequest) + if (!requestFocus) { + toastOnUi("未获取到音频焦点") + } + return requestFocus + } + + /** + * 更新媒体状态 + */ + private fun upMediaSessionPlaybackState(state: Int) { + mediaSessionCompat.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions(MediaHelp.MEDIA_SESSION_ACTIONS) + .setState(state, nowSpeak.toLong(), 1f) + .build() + ) + } + + /** + * 初始化MediaSession, 注册多媒体按钮 + */ + @SuppressLint("UnspecifiedImmutableFlag") + private fun initMediaSession() { + mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() { + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + return MediaButtonReceiver.handleIntent(this@BaseReadAloudService, mediaButtonEvent) + } + }) + mediaSessionCompat.setMediaButtonReceiver( + broadcastPendingIntent(Intent.ACTION_MEDIA_BUTTON) + ) + mediaSessionCompat.isActive = true + } + + /** + * 断开耳机监听 + */ + private fun initBroadcastReceiver() { + val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + registerReceiver(broadcastReceiver, intentFilter) + } + + /** + * 音频焦点变化 + */ + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + AppLog.put("重新获得焦点, 恢复播放") + audioFocusLossTransient = false + if (!pause) resumeReadAloud() + } + AudioManager.AUDIOFOCUS_LOSS -> { + AppLog.put("永久丢失焦点") + if (audioFocusLossTransient) { + pauseReadAloud(true) + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + AppLog.put("暂时丢失焦点, 暂停播放") + audioFocusLossTransient = true + if (!pause) pauseReadAloud(false) + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音), + } + } + } + + /** + * 更新通知 + */ + private fun upNotification() { + var nTitle: String = when { + pause -> getString(R.string.read_aloud_pause) + timeMinute > 0 -> getString( + R.string.read_aloud_timer, + timeMinute + ) + else -> getString(R.string.read_aloud_t) + } + nTitle += ": ${ReadBook.book?.name}" + var nSubtitle = ReadBook.curTextChapter?.title + if (nSubtitle.isNullOrBlank()) + nSubtitle = getString(R.string.read_aloud_s) + val builder = NotificationCompat.Builder(this, AppConst.channelIdReadAloud) + .setSmallIcon(R.drawable.ic_volume_up) + .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.icon_read_book)) + .setOngoing(true) + .setContentTitle(nTitle) + .setContentText(nSubtitle) + .setContentIntent( + activityPendingIntent("activity") + ) + if (pause) { + builder.addAction( + R.drawable.ic_play_24dp, + getString(R.string.resume), + aloudServicePendingIntent(IntentAction.resume) + ) + } else { + builder.addAction( + R.drawable.ic_pause_24dp, + getString(R.string.pause), + aloudServicePendingIntent(IntentAction.pause) + ) + } + builder.addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.stop), + aloudServicePendingIntent(IntentAction.stop) + ) + builder.addAction( + R.drawable.ic_time_add_24dp, + getString(R.string.set_timer), + aloudServicePendingIntent(IntentAction.addTimer) + ) + builder.setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1, 2) + ) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + val notification = builder.build() + startForeground(AppConst.notificationIdRead, notification) + } + + abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent? + + open fun nextChapter() { + ReadBook.upReadStartTime() + if (!ReadBook.moveToNextChapter(true)) { + stopSelf() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt new file mode 100644 index 000000000..67f0f1f3c --- /dev/null +++ b/app/src/main/java/io/legado/app/service/CacheBookService.kt @@ -0,0 +1,128 @@ +package io.legado.app.service + +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.AppConst +import io.legado.app.constant.EventBus +import io.legado.app.constant.IntentAction +import io.legado.app.help.AppConfig +import io.legado.app.model.CacheBook +import io.legado.app.ui.book.cache.CacheActivity +import io.legado.app.utils.activityPendingIntent +import io.legado.app.utils.postEvent +import io.legado.app.utils.servicePendingIntent +import kotlinx.coroutines.* +import java.util.concurrent.Executors +import kotlin.math.min + +class CacheBookService : BaseService() { + + companion object { + var isRun = false + private set + } + + private val threadCount = AppConfig.threadCount + private var cachePool = + Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + private var downloadJob: Job? = null + + private val notificationBuilder by lazy { + val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload) + .setSmallIcon(R.drawable.ic_download) + .setOngoing(true) + .setContentTitle(getString(R.string.offline_cache)) + .setContentIntent(activityPendingIntent("cacheActivity")) + builder.addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.cancel), + servicePendingIntent(IntentAction.stop) + ) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + override fun onCreate() { + super.onCreate() + isRun = true + upNotification(getString(R.string.starting_download)) + launch { + while (isActive) { + delay(1000) + upNotification(CacheBook.downloadSummary) + postEvent(EventBus.UP_DOWNLOAD, "") + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.action?.let { action -> + when (action) { + IntentAction.start -> addDownloadData( + intent.getStringExtra("bookUrl"), + intent.getIntExtra("start", 0), + intent.getIntExtra("end", 0) + ) + IntentAction.remove -> removeDownload(intent.getStringExtra("bookUrl")) + IntentAction.stop -> stopSelf() + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + isRun = false + cachePool.close() + CacheBook.cacheBookMap.clear() + super.onDestroy() + postEvent(EventBus.UP_DOWNLOAD, "") + } + + private fun addDownloadData(bookUrl: String?, start: Int, end: Int) { + bookUrl ?: return + execute { + val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute + cacheBook.addDownload(start, end) + upNotification(CacheBook.downloadSummary) + if (downloadJob == null) { + download() + } + } + } + + private fun removeDownload(bookUrl: String?) { + CacheBook.cacheBookMap[bookUrl]?.stop() + if (CacheBook.cacheBookMap.isEmpty()) { + stopSelf() + } + } + + private fun download() { + downloadJob?.cancel() + downloadJob = launch(cachePool) { + while (isActive) { + if (!CacheBook.isRun) { + CacheBook.stop(this@CacheBookService) + return@launch + } + CacheBook.cacheBookMap.forEach { + while (CacheBook.onDownloadCount > threadCount) { + delay(100) + } + it.value.download(this, cachePool) + } + } + } + } + + /** + * 更新通知 + */ + private fun upNotification(notificationContent: String) { + notificationBuilder.setContentText(notificationContent) + val notification = notificationBuilder.build() + startForeground(AppConst.notificationIdCache, notification) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/CheckSourceService.kt b/app/src/main/java/io/legado/app/service/CheckSourceService.kt new file mode 100644 index 000000000..916a4f283 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/CheckSourceService.kt @@ -0,0 +1,190 @@ +package io.legado.app.service + +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.AppConst +import io.legado.app.constant.EventBus +import io.legado.app.constant.IntentAction +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.CompositeCoroutine +import io.legado.app.model.CheckSource +import io.legado.app.model.Debug +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.webBook.WebBook +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.utils.activityPendingIntent +import io.legado.app.utils.postEvent +import io.legado.app.utils.servicePendingIntent +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors +import kotlin.math.min + +class CheckSourceService : BaseService() { + private var threadCount = AppConfig.threadCount + private var searchCoroutine = + Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + private var tasks = CompositeCoroutine() + private val allIds = ArrayList() + private val checkedIds = ArrayList() + private var processIndex = 0 + private var notificationMsg = "" + private val notificationBuilder by lazy { + NotificationCompat.Builder(this, AppConst.channelIdReadAloud) + .setSmallIcon(R.drawable.ic_network_check) + .setOngoing(true) + .setContentTitle(getString(R.string.check_book_source)) + .setContentIntent( + activityPendingIntent("activity") + ) + .addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.cancel), + servicePendingIntent(IntentAction.stop) + ) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + override fun onCreate() { + super.onCreate() + notificationMsg = getString(R.string.start) + upNotification() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + IntentAction.start -> intent.getStringArrayListExtra("selectIds")?.let { + check(it) + } + else -> stopSelf() + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + super.onDestroy() + Debug.finishChecking() + tasks.clear() + searchCoroutine.close() + postEvent(EventBus.CHECK_SOURCE_DONE, 0) + } + + private fun check(ids: List) { + if (allIds.isNotEmpty()) { + toastOnUi("已有书源在校验,等完成后再试") + return + } + tasks.clear() + allIds.clear() + checkedIds.clear() + allIds.addAll(ids) + processIndex = 0 + threadCount = min(allIds.size, threadCount) + notificationMsg = getString(R.string.progress_show, "", 0, allIds.size) + upNotification() + for (i in 0 until threadCount) { + check() + } + } + + /** + * 检测 + */ + private fun check() { + val index = processIndex + synchronized(this) { + processIndex++ + } + execute(context = searchCoroutine) { + if (index < allIds.size) { + val sourceUrl = allIds[index] + appDb.bookSourceDao.getBookSource(sourceUrl)?.let { source -> + check(source) + } ?: onNext(sourceUrl, "") + } + } + } + + fun check(source: BookSource) { + execute(context = searchCoroutine) { + Debug.startChecking(source) + var searchWord = CheckSource.keyword + source.ruleSearch?.checkKeyWord?.let { + if (it.isNotBlank()) { + searchWord = it + } + } + var books = WebBook.searchBookAwait(this, source, searchWord) + if (books.isEmpty()) { + val exs = source.exploreKinds + var url: String? = null + for (ex in exs) { + url = ex.url + if (!url.isNullOrBlank()) { + break + } + } + if (url.isNullOrBlank()) { + throw NoStackTraceException("搜索内容为空并且没有发现") + } + books = WebBook.exploreBookAwait(this, source, url) + } + val book = WebBook.getBookInfoAwait(this, source, books.first().toBook()) + val toc = WebBook.getChapterListAwait(this, source, book) + val content = + WebBook.getContentAwait(this, source, book, toc.first(), toc.getOrNull(1)?.url) + if (content.isBlank()) { + throw NoStackTraceException("正文内容为空") + } + }.timeout(180000L) + .onError(searchCoroutine) { + source.addGroup("失效") + if (source.bookSourceComment?.contains("Error: ") == false) { + source.bookSourceComment = "Error: ${it.localizedMessage} \n\n" + "${source.bookSourceComment}" + } + Debug.updateFinalMessage(source.bookSourceUrl, "失败:${it.localizedMessage}") + source.respondTime = Debug.getRespondTime(source.bookSourceUrl) + appDb.bookSourceDao.update(source) + }.onSuccess(searchCoroutine) { + source.removeGroup("失效") + source.bookSourceComment = source.bookSourceComment + ?.split("\n\n") + ?.filterNot { + it.startsWith("Error: ") + }?.joinToString("\n") + Debug.updateFinalMessage(source.bookSourceUrl, "成功") + source.respondTime = Debug.getRespondTime(source.bookSourceUrl) + appDb.bookSourceDao.update(source) + }.onFinally(searchCoroutine) { + onNext(source.bookSourceUrl, source.bookSourceName) + } + } + + private fun onNext(sourceUrl: String, sourceName: String) { + synchronized(this) { + check() + checkedIds.add(sourceUrl) + notificationMsg = + getString(R.string.progress_show, sourceName, checkedIds.size, allIds.size) + upNotification() + if (processIndex > allIds.size + threadCount - 1) { + stopSelf() + } + } + } + + /** + * 更新通知 + */ + private fun upNotification() { + notificationBuilder.setContentText(notificationMsg) + notificationBuilder.setProgress(allIds.size, checkedIds.size, false) + postEvent(EventBus.CHECK_SOURCE, notificationMsg) + startForeground(AppConst.notificationIdCheckSource, notificationBuilder.build()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/DownloadService.kt b/app/src/main/java/io/legado/app/service/DownloadService.kt new file mode 100644 index 000000000..ebe68c7fa --- /dev/null +++ b/app/src/main/java/io/legado/app/service/DownloadService.kt @@ -0,0 +1,215 @@ +package io.legado.app.service + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Environment +import androidx.core.app.NotificationCompat +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.AppConst +import io.legado.app.constant.IntentAction +import io.legado.app.utils.IntentType +import io.legado.app.utils.openFileUri +import io.legado.app.utils.servicePendingIntent +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import splitties.init.appCtx +import splitties.systemservices.downloadManager +import splitties.systemservices.notificationManager + +/** + * 下载文件 + */ +class DownloadService : BaseService() { + private val groupKey = "${appCtx.packageName}.download" + private val downloads = hashMapOf>() + private val completeDownloads = hashSetOf() + private var upStateJob: Job? = null + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + queryState() + } + } + + override fun onCreate() { + super.onCreate() + upSummaryNotification() + registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(downloadReceiver) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + IntentAction.start -> startDownload( + intent.getStringExtra("url"), + intent.getStringExtra("fileName") + ) + IntentAction.play -> { + val id = intent.getLongExtra("downloadId", 0) + if (completeDownloads.contains(id)) { + openDownload(id, downloads[id]?.second) + } else { + toastOnUi("未完成,下载的文件夹Download") + } + } + IntentAction.stop -> { + val downloadId = intent.getLongExtra("downloadId", 0) + removeDownload(downloadId) + } + } + return super.onStartCommand(intent, flags, startId) + } + + @Synchronized + private fun startDownload(url: String?, fileName: String?) { + if (url == null || fileName == null) { + if (downloads.isEmpty()) { + stopSelf() + } + return + } + if (downloads.values.any { it.first == url }) { + toastOnUi("已在下载列表") + return + } + // 指定下载地址 + val request = DownloadManager.Request(Uri.parse(url)) + // 设置通知 + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) + // 设置下载文件保存的路径和文件名 + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) + // 添加一个下载任务 + val downloadId = downloadManager.enqueue(request) + downloads[downloadId] = Pair(url, fileName) + queryState() + if (upStateJob == null) { + checkDownloadState() + } + } + + @Synchronized + private fun removeDownload(downloadId: Long) { + if (!completeDownloads.contains(downloadId)) { + downloadManager.remove(downloadId) + } + downloads.remove(downloadId) + completeDownloads.remove(downloadId) + notificationManager.cancel(downloadId.toInt()) + } + + @Synchronized + private fun successDownload(downloadId: Long) { + if (!completeDownloads.contains(downloadId)) { + completeDownloads.add(downloadId) + val fileName = downloads[downloadId]?.second + if (fileName?.endsWith(".apk") == true) { + openDownload(downloadId, fileName) + } else { + toastOnUi("$fileName ${getString(R.string.download_success)}") + } + } + } + + private fun checkDownloadState() { + upStateJob?.cancel() + upStateJob = launch { + while (isActive) { + queryState() + delay(1000) + } + } + } + + //查询下载进度 + @Synchronized + private fun queryState() { + if (downloads.isEmpty()) { + stopSelf() + return + } + val ids = downloads.keys + val query = DownloadManager.Query() + query.setFilterById(*ids.toLongArray()) + downloadManager.query(query).use { cursor -> + if (cursor.moveToFirst()) { + val idIndex = cursor.getColumnIndex(DownloadManager.COLUMN_ID) + val progressIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val fileSizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + do { + val id = cursor.getLong(idIndex) + val progress = cursor.getInt(progressIndex) + val max = cursor.getInt(fileSizeIndex) + val status = when (cursor.getInt(statusIndex)) { + DownloadManager.STATUS_PAUSED -> getString(R.string.pause) + DownloadManager.STATUS_PENDING -> getString(R.string.wait_download) + DownloadManager.STATUS_RUNNING -> getString(R.string.downloading) + DownloadManager.STATUS_SUCCESSFUL -> { + successDownload(id) + getString(R.string.download_success) + } + DownloadManager.STATUS_FAILED -> getString(R.string.download_error) + else -> getString(R.string.unknown_state) + } + upDownloadNotification(id, "${downloads[id]?.second} $status", max, progress) + } while (cursor.moveToNext()) + } + } + } + + private fun openDownload(downloadId: Long, fileName: String?) { + downloadManager.getUriForDownloadedFile(downloadId)?.let { uri -> + val type = IntentType.from(fileName) + openFileUri(uri, type) + } + } + + private fun upSummaryNotification() { + val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.action_download)) + .setGroup(groupKey) + .setGroupSummary(true) + .setOngoing(true) + .build() + startForeground(AppConst.notificationIdDownload, notification) + } + + /** + * 更新通知 + */ + private fun upDownloadNotification(downloadId: Long, content: String, max: Int, progress: Int) { + val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.action_download)) + .setContentIntent( + servicePendingIntent(IntentAction.play) { + putExtra("downloadId", downloadId) + } + ) + .setDeleteIntent( + servicePendingIntent(IntentAction.stop) { + putExtra("downloadId", downloadId) + } + ) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentText(content) + .setProgress(max, progress, false) + .setGroup(groupKey) + .build() + notificationManager.notify(downloadId.toInt(), notification) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt b/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt new file mode 100644 index 000000000..b14f43d98 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt @@ -0,0 +1,357 @@ +package io.legado.app.service + +import android.app.PendingIntent +import android.media.MediaPlayer +import io.legado.app.R +import io.legado.app.constant.AppLog +import io.legado.app.constant.AppPattern +import io.legado.app.constant.EventBus +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.ConcurrentException +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.* +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Response +import org.mozilla.javascript.WrappedException +import timber.log.Timber +import java.io.File +import java.io.FileDescriptor +import java.io.FileInputStream +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.util.* +import javax.script.ScriptException + +class HttpReadAloudService : BaseReadAloudService(), + MediaPlayer.OnPreparedListener, + MediaPlayer.OnErrorListener, + MediaPlayer.OnCompletionListener { + + private val mediaPlayer = MediaPlayer() + private val ttsFolderPath: String by lazy { + externalCacheDir!!.absolutePath + File.separator + "httpTTS" + File.separator + } + private val cacheFiles = hashSetOf() + private var task: Coroutine<*>? = null + private var playingIndex = -1 + private var playIndexJob: Job? = null + private val mutex = Mutex() + + override fun onCreate() { + super.onCreate() + mediaPlayer.setOnErrorListener(this) + mediaPlayer.setOnPreparedListener(this) + mediaPlayer.setOnCompletionListener(this) + } + + override fun onDestroy() { + super.onDestroy() + task?.cancel() + mediaPlayer.release() + } + + override fun newReadAloud(play: Boolean) { + mediaPlayer.reset() + playingIndex = -1 + super.newReadAloud(play) + } + + override fun play() { + if (contentList.isEmpty()) return + ReadAloud.httpTTS?.let { + val fileName = + md5SpeakFileName(it.url, AppConfig.ttsSpeechRate.toString(), contentList[nowSpeak]) + if (nowSpeak == 0) { + downloadAudio() + } else { + val file = getSpeakFileAsMd5(fileName) + if (file.exists()) { + playAudio(FileInputStream(file).fd) + } else { + downloadAudio() + } + } + } + } + + override fun playStop() { + mediaPlayer.stop() + } + + private fun playNext() { + readAloudNumber += contentList[nowSpeak].length + 1 + if (nowSpeak < contentList.lastIndex) { + nowSpeak++ + play() + } else { + nextChapter() + } + } + + private var downloadErrorNo: Int = 0 + + private fun downloadAudio() { + task?.cancel() + task = execute { + clearSpeakCache() + removeCacheFile() + val httpTts = ReadAloud.httpTTS ?: return@execute + contentList.forEachIndexed { index, item -> + ensureActive() + val speakText = item.replace(AppPattern.notReadAloudRegex, "") + val fileName = + md5SpeakFileName( + httpTts.url, + AppConfig.ttsSpeechRate.toString(), + speakText + ) + if (hasSpeakFile(fileName)) { //已经下载好的语音缓存 + if (index == nowSpeak) { + val file = getSpeakFileAsMd5(fileName) + val fis = FileInputStream(file) + playAudio(fis.fd) + } + } else if (hasSpeakCache(fileName)) { //缓存文件还在,可能还没下载完 + return@forEachIndexed + } else if (speakText.isEmpty()) { + createSilentSound(fileName) + return@forEachIndexed + } else { + runCatching { + createSpeakCache(fileName) + val analyzeUrl = AnalyzeUrl( + httpTts.url, + speakText = speakText, + speakSpeed = AppConfig.ttsSpeechRate, + source = httpTts, + headerMapF = httpTts.getHeaderMap(true) + ) + var response = mutex.withLock { + analyzeUrl.getResponseAwait() + } + ensureActive() + httpTts.loginCheckJs?.takeIf { checkJs -> + checkJs.isNotBlank() + }?.let { checkJs -> + response = analyzeUrl.evalJS(checkJs, response) as Response + } + httpTts.contentType?.takeIf { ct -> + ct.isNotBlank() + }?.let { ct -> + response.headers["Content-Type"]?.let { contentType -> + if (!contentType.matches(ct.toRegex())) { + throw NoStackTraceException(response.body!!.string()) + } + } + } + ensureActive() + response.body!!.bytes().let { bytes -> + ensureActive() + val file = createSpeakFileAsMd5IfNotExist(fileName) + file.writeBytes(bytes) + removeSpeakCache(fileName) + val fis = FileInputStream(file) + if (index == nowSpeak) { + playAudio(fis.fd) + } + } + downloadErrorNo = 0 + }.onFailure { + when (it) { + is CancellationException -> removeSpeakCache(fileName) + is ConcurrentException -> { + removeSpeakCache(fileName) + delay(it.waitTime.toLong()) + downloadAudio() + } + is ScriptException, is WrappedException -> { + AppLog.put("js错误\n${it.localizedMessage}", it) + toastOnUi("js错误\n${it.localizedMessage}") + Timber.e(it) + cancel() + pauseReadAloud(true) + } + is SocketTimeoutException, is ConnectException -> { + removeSpeakCache(fileName) + downloadErrorNo++ + if (playErrorNo > 5) { + downloadErrorNo = 0 + createSilentSound(fileName) + val msg = "tts超时或连接错误超过5次\n${it.localizedMessage}" + AppLog.put(msg, it) + toastOnUi(msg) + } else { + downloadAudio() + } + } + else -> { + removeSpeakCache(fileName) + createSilentSound(fileName) + val msg = "tts下载错误\n${it.localizedMessage}" + AppLog.put(msg, it) + Timber.e(it) + } + } + } + } + } + } + } + + @Synchronized + private fun playAudio(fd: FileDescriptor) { + if (playingIndex != nowSpeak && requestFocus()) { + try { + mediaPlayer.reset() + mediaPlayer.setDataSource(fd) + mediaPlayer.prepareAsync() + playingIndex = nowSpeak + postEvent(EventBus.TTS_PROGRESS, readAloudNumber + 1) + } catch (e: Exception) { + Timber.e(e) + } + } + } + + private fun md5SpeakFileName(url: String, ttsConfig: String, content: String): String { + return MD5Utils.md5Encode16(textChapter!!.title) + "_" + MD5Utils.md5Encode16("$url-|-$ttsConfig-|-$content") + } + + private fun createSilentSound(fileName: String) { + val file = createSpeakFileAsMd5IfNotExist(fileName) + file.writeBytes(resources.openRawResource(R.raw.silent_sound).readBytes()) + } + + @Synchronized + private fun clearSpeakCache() = cacheFiles.clear() + + @Synchronized + private fun hasSpeakCache(name: String) = cacheFiles.contains(name) + + @Synchronized + private fun createSpeakCache(name: String) = cacheFiles.add(name) + + @Synchronized + private fun removeSpeakCache(name: String) = cacheFiles.remove(name) + + private fun hasSpeakFile(name: String) = + FileUtils.exist("${ttsFolderPath}$name.mp3") + + private fun getSpeakFileAsMd5(name: String): File = + File("${ttsFolderPath}$name.mp3") + + private fun createSpeakFileAsMd5IfNotExist(name: String): File = + FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3") + + private fun removeCacheFile() { + val cacheRegex = Regex(""".+\.mp3$""") + val reg = """^${MD5Utils.md5Encode16(textChapter!!.title)}_[a-z0-9]{16}\.mp3$""".toRegex() + FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach { + if (cacheRegex.matches(it.name)) { //mp3缓存文件 + if (!reg.matches(it.name)) { + FileUtils.deleteFile(it.absolutePath) + } + } else { + if (Date().time - it.lastModified() > 30000) { + FileUtils.deleteFile(it.absolutePath) + } + } + } + } + + + override fun pauseReadAloud(pause: Boolean) { + super.pauseReadAloud(pause) + kotlin.runCatching { + playIndexJob?.cancel() + mediaPlayer.pause() + } + } + + override fun resumeReadAloud() { + super.resumeReadAloud() + kotlin.runCatching { + if (playingIndex == -1) { + play() + } else { + mediaPlayer.start() + upPlayPos() + } + } + } + + private fun upPlayPos() { + playIndexJob?.cancel() + val textChapter = textChapter ?: return + playIndexJob = launch { + postEvent(EventBus.TTS_PROGRESS, readAloudNumber + 1) + if (mediaPlayer.duration <= 0) { + return@launch + } + val speakTextLength = contentList[nowSpeak].length + if (speakTextLength <= 0) { + return@launch + } + val sleep = mediaPlayer.duration / speakTextLength + val start = speakTextLength * mediaPlayer.currentPosition / mediaPlayer.duration + for (i in start..contentList[nowSpeak].length) { + if (readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)) { + pageIndex++ + ReadBook.moveToNextPage() + postEvent(EventBus.TTS_PROGRESS, readAloudNumber + i) + } + delay(sleep.toLong()) + } + } + } + + /** + * 更新朗读速度 + */ + override fun upSpeechRate(reset: Boolean) { + task?.cancel() + mediaPlayer.stop() + playingIndex = -1 + downloadAudio() + } + + override fun onPrepared(mp: MediaPlayer?) { + super.play() + if (pause) return + mediaPlayer.start() + upPlayPos() + } + + private var playErrorNo = 0 + + override fun onError(mp: MediaPlayer?, what: Int, extra: Int): Boolean { + if (what == -38 && extra == 0) { + play() + return true + } + AppLog.put("朗读错误($what, $extra)\n${contentList[nowSpeak]}") + playErrorNo++ + if (playErrorNo >= 5) { + toastOnUi("朗读连续5次错误, 最后一次错误代码($what, $extra)") + ReadAloud.pause(this) + } else { + playNext() + } + return true + } + + override fun onCompletion(mp: MediaPlayer?) { + playErrorNo = 0 + playNext() + } + + override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { + return servicePendingIntent(actionStr) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/README.md b/app/src/main/java/io/legado/app/service/README.md new file mode 100644 index 000000000..2d40d9375 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/README.md @@ -0,0 +1,7 @@ +# android服务 +* AudioPlayService 音频播放服务 +* CheckSourceService 书源检测服务 +* DownloadService 缓存服务 +* HttpReadAloudService 在线朗读服务 +* TTSReadAloudService tts朗读服务 +* WebService web服务 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/TTSReadAloudService.kt b/app/src/main/java/io/legado/app/service/TTSReadAloudService.kt new file mode 100644 index 000000000..24db5db4d --- /dev/null +++ b/app/src/main/java/io/legado/app/service/TTSReadAloudService.kt @@ -0,0 +1,162 @@ +package io.legado.app.service + +import android.app.PendingIntent +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppPattern +import io.legado.app.constant.EventBus +import io.legado.app.help.AppConfig +import io.legado.app.help.MediaHelp +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.model.ReadBook +import io.legado.app.utils.* +import java.util.* + +class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener { + + private var textToSpeech: TextToSpeech? = null + private var ttsInitFinish = false + private val ttsUtteranceListener = TTSUtteranceListener() + + override fun onCreate() { + super.onCreate() + initTts() + upSpeechRate() + } + + override fun onDestroy() { + super.onDestroy() + clearTTS() + } + + @Synchronized + private fun initTts() { + ttsInitFinish = false + val engine = GSON.fromJsonObject>(AppConfig.ttsEngine)?.value + textToSpeech = if (engine.isNullOrBlank()) { + TextToSpeech(this, this) + } else { + TextToSpeech(this, this, engine) + } + } + + @Synchronized + fun clearTTS() { + textToSpeech?.stop() + textToSpeech?.shutdown() + textToSpeech = null + ttsInitFinish = false + } + + override fun onInit(status: Int) { + if (status == TextToSpeech.SUCCESS) { + textToSpeech?.let { + it.setOnUtteranceProgressListener(ttsUtteranceListener) + it.language = Locale.CHINA + ttsInitFinish = true + play() + } + } else { + toastOnUi(R.string.tts_init_failed) + } + } + + @Synchronized + override fun play() { + if (contentList.isNotEmpty() && ttsInitFinish && requestFocus()) { + super.play() + execute { + MediaHelp.playSilentSound(this@TTSReadAloudService) + }.onFinally { + textToSpeech?.let { + it.speak("", TextToSpeech.QUEUE_FLUSH, null, null) + for (i in nowSpeak until contentList.size) { + val text = contentList[i].replace(AppPattern.notReadAloudRegex, "") + it.speak(text, TextToSpeech.QUEUE_ADD, null, AppConst.APP_TAG + i) + } + } + } + } + } + + override fun playStop() { + textToSpeech?.stop() + } + + /** + * 更新朗读速度 + */ + override fun upSpeechRate(reset: Boolean) { + if (this.getPrefBoolean("ttsFollowSys", true)) { + if (reset) { + clearTTS() + initTts() + } + } else { + textToSpeech?.setSpeechRate((AppConfig.ttsSpeechRate + 5) / 10f) + } + } + + /** + * 暂停朗读 + */ + override fun pauseReadAloud(pause: Boolean) { + super.pauseReadAloud(pause) + textToSpeech?.stop() + } + + /** + * 恢复朗读 + */ + override fun resumeReadAloud() { + super.resumeReadAloud() + play() + } + + /** + * 朗读监听 + */ + private inner class TTSUtteranceListener : UtteranceProgressListener() { + + override fun onStart(s: String) { + textChapter?.let { + if (readAloudNumber + 1 > it.getReadLength(pageIndex + 1)) { + pageIndex++ + ReadBook.moveToNextPage() + } + postEvent(EventBus.TTS_PROGRESS, readAloudNumber + 1) + } + } + + override fun onDone(s: String) { + readAloudNumber += contentList[nowSpeak].length + 1 + nowSpeak++ + if (nowSpeak >= contentList.size) { + nextChapter() + } + } + + override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { + super.onRangeStart(utteranceId, start, end, frame) + textChapter?.let { + if (readAloudNumber + start > it.getReadLength(pageIndex + 1)) { + pageIndex++ + ReadBook.moveToNextPage() + postEvent(EventBus.TTS_PROGRESS, readAloudNumber + start) + } + } + } + + override fun onError(s: String) { + //nothing + } + + } + + override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { + return servicePendingIntent(actionStr) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/WebService.kt b/app/src/main/java/io/legado/app/service/WebService.kt new file mode 100644 index 000000000..25fc5298a --- /dev/null +++ b/app/src/main/java/io/legado/app/service/WebService.kt @@ -0,0 +1,140 @@ +package io.legado.app.service + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.AppConst +import io.legado.app.constant.EventBus +import io.legado.app.constant.IntentAction +import io.legado.app.constant.PreferKey +import io.legado.app.ui.main.MainActivity +import io.legado.app.utils.* +import io.legado.app.web.HttpServer +import io.legado.app.web.WebSocketServer +import timber.log.Timber +import java.io.IOException + +class WebService : BaseService() { + + companion object { + var isRun = false + var hostAddress = "" + + fun start(context: Context) { + context.startService() + } + + fun stop(context: Context) { + context.stopService() + } + + } + + private var httpServer: HttpServer? = null + private var webSocketServer: WebSocketServer? = null + private var notificationContent = "" + + override fun onCreate() { + super.onCreate() + isRun = true + notificationContent = getString(R.string.service_starting) + upNotification() + upTile(true) + } + + override fun onDestroy() { + super.onDestroy() + isRun = false + if (httpServer?.isAlive == true) { + httpServer?.stop() + } + if (webSocketServer?.isAlive == true) { + webSocketServer?.stop() + } + postEvent(EventBus.WEB_SERVICE, "") + upTile(false) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + IntentAction.stop -> stopSelf() + else -> upWebServer() + } + return super.onStartCommand(intent, flags, startId) + } + + private fun upWebServer() { + if (httpServer?.isAlive == true) { + httpServer?.stop() + } + if (webSocketServer?.isAlive == true) { + webSocketServer?.stop() + } + val address = NetworkUtils.getLocalIPAddress() + if (address != null) { + val port = getPort() + httpServer = HttpServer(port) + webSocketServer = WebSocketServer(port + 1) + try { + httpServer?.start() + webSocketServer?.start(1000 * 30) // 通信超时设置 + hostAddress = getString(R.string.http_ip, address.hostAddress, port) + isRun = true + postEvent(EventBus.WEB_SERVICE, hostAddress) + notificationContent = hostAddress + upNotification() + } catch (e: IOException) { + toastOnUi(e.localizedMessage ?: "") + Timber.e(e) + stopSelf() + } + } else { + stopSelf() + } + } + + private fun getPort(): Int { + var port = getPrefInt(PreferKey.webPort, 1122) + if (port > 65530 || port < 1024) { + port = 1122 + } + return port + } + + /** + * 更新通知 + */ + private fun upNotification() { + val builder = NotificationCompat.Builder(this, AppConst.channelIdWeb) + .setSmallIcon(R.drawable.ic_web_service_noti) + .setOngoing(true) + .setContentTitle(getString(R.string.web_service)) + .setContentText(notificationContent) + .setContentIntent( + activityPendingIntent("webService") + ) + builder.addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.cancel), + servicePendingIntent(IntentAction.stop) + ) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + val notification = builder.build() + startForeground(AppConst.notificationIdWeb, notification) + } + + private fun upTile(active: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + startService { + action = if (active) { + IntentAction.start + } else { + IntentAction.stop + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/service/WebTileService.kt b/app/src/main/java/io/legado/app/service/WebTileService.kt new file mode 100644 index 000000000..83bb09cff --- /dev/null +++ b/app/src/main/java/io/legado/app/service/WebTileService.kt @@ -0,0 +1,56 @@ +package io.legado.app.service + +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import io.legado.app.constant.IntentAction +import timber.log.Timber + + +/** + * web服务快捷开关 + */ +@RequiresApi(Build.VERSION_CODES.N) +class WebTileService : TileService() { + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + try { + when (intent?.action) { + IntentAction.start -> qsTile?.run { + state = Tile.STATE_ACTIVE + updateTile() + } + IntentAction.stop -> qsTile?.run { + state = Tile.STATE_INACTIVE + updateTile() + } + } + } catch (e: Exception) { + Timber.e(e) + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onStartListening() { + super.onStartListening() + if (WebService.isRun) { + qsTile.state = Tile.STATE_ACTIVE + qsTile.updateTile() + } else { + qsTile.state = Tile.STATE_INACTIVE + qsTile.updateTile() + } + } + + override fun onClick() { + super.onClick() + if (WebService.isRun) { + WebService.stop(this) + } else { + WebService.start(this) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/README.md b/app/src/main/java/io/legado/app/ui/README.md new file mode 100644 index 000000000..7c5ab259a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/README.md @@ -0,0 +1,26 @@ +# 放置与界面有关的类 + +* about 关于界面 +* association 导入书源界面 +* book\audio 音频播放界面 +* book\arrange 书架整理界面 +* book\info 书籍信息查看 +* book\read 书籍阅读界面 +* book\search 搜索书籍界面 +* book\source 书源界面 +* book\changeCover 封面换源界面 +* book\changeSource 换源界面 +* book\toc 目录界面 +* book\download 下载界面 +* book\explore 发现界面 +* book\local 书籍导入界面 +* document 文件选择界面 +* config 配置界面 +* main 主界面 +* qrCode 二维码扫描界面 +* replaceRule 替换净化界面 +* rss\article 订阅条目界面 +* rss\read 订阅阅读界面 +* rss\source 订阅源界面 +* welcome 欢迎界面 +* widget 自定义插件 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt b/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt new file mode 100644 index 000000000..abc30b731 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt @@ -0,0 +1,62 @@ +package io.legado.app.ui.about + +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.Menu +import android.view.MenuItem +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.databinding.ActivityAboutBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.filletBackground +import io.legado.app.utils.openUrl +import io.legado.app.utils.share +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class AboutActivity : BaseActivity() { + + override val binding by viewBinding(ActivityAboutBinding::inflate) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.llAbout.background = filletBackground + val fTag = "aboutFragment" + var aboutFragment = supportFragmentManager.findFragmentByTag(fTag) + if (aboutFragment == null) aboutFragment = AboutFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.fl_fragment, aboutFragment, fTag) + .commit() + binding.tvAppSummary.post { + kotlin.runCatching { + val span = ForegroundColorSpan(accentColor) + val spannableString = SpannableString(binding.tvAppSummary.text) + val gzh = getString(R.string.legado_gzh) + val start = spannableString.indexOf(gzh) + spannableString.setSpan( + span, start, start + gzh.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + binding.tvAppSummary.text = spannableString + } + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.about, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_scoring -> openUrl("market://details?id=$packageName") + R.id.menu_share_it -> share( + getString(R.string.app_share_description), + getString(R.string.app_name) + ) + } + return super.onCompatOptionsItemSelected(item) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/about/AboutFragment.kt b/app/src/main/java/io/legado/app/ui/about/AboutFragment.kt new file mode 100644 index 000000000..ab158dca6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/AboutFragment.kt @@ -0,0 +1,142 @@ +package io.legado.app.ui.about + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.annotation.StringRes +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.R +import io.legado.app.constant.AppConst.appInfo +import io.legado.app.help.AppConfig +import io.legado.app.help.AppUpdate +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* + +class AboutFragment : PreferenceFragmentCompat() { + + private val licenseUrl = "https://github.com/gedoor/legado/blob/master/LICENSE" + private val disclaimerUrl = "https://gedoor.github.io/MyBookshelf/disclaimer.html" + private val qqGroups = linkedMapOf( + Pair("(QQ群VIP中转)1017837876", "0d9-zpmqbYfK3i_wt8uCvQoB2lmXadrg"), + Pair("(QQ群VIP1)701903217", "-iolizL4cbJSutKRpeImHlXlpLDZnzeF"), + Pair("(QQ群VIP2)263949160", "xwfh7_csb2Gf3Aw2qexEcEtviLfLfd4L"), + Pair("(QQ群VIP3)680280282", "_N0i7yZObjKSeZQvzoe2ej7j02kLnOOK"), + Pair("(QQ群VIP4)682555679", "VF2UwvUCuaqlo6pddWTe_kw__a1_Fr8O"), + Pair("(QQ群VIP5)161622578", "S81xdnhJ5EBC389LTUvoyiyM-wr71pvJ"), + Pair("(QQ群1)805192012", "6GlFKjLeIk5RhQnR3PNVDaKB6j10royo"), + Pair("(QQ群2)773736122", "5Bm5w6OgLupXnICbYvbgzpPUgf0UlsJF"), + Pair("(QQ群3)981838750", "g_Sgmp2nQPKqcZQ5qPcKLHziwX_mpps9"), + Pair("(QQ群4)256929088", "czEJPLDnT4Pd9SKQ6RoRVzKhDxLchZrO"), + Pair("(QQ群5)811843556", "zKZ2UYGZ7o5CzcA6ylxzlqi21si_iqaX"), + Pair("(QQ群6)870270970", "FeCF8iSxfQbe90HPvGsvcqs5P5oSeY5n"), + Pair("(QQ群7)15987187", "S2g2TMD0LGd3sefUADd1AbyPEW2o2XfC"), + Pair("(QQ群8)1079926194", "gg2qFH8q9IPFaCHV3H7CqCN-YljvazE1"), + Pair("(QQ群9)892108780", "Ci_O3aysKjEBfplOWeCud-rxl71TjU2Q"), + Pair("(QQ群10)812720266", "oW9ksY0sAWUEq0hfM5irN5aOdvKVgMEE") + ) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.about) + findPreference("update_log")?.summary = + "${getString(R.string.version)} ${appInfo.versionName}" + if (AppConfig.isGooglePlay) { + preferenceScreen.removePreferenceRecursively("check_update") + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.overScrollMode = View.OVER_SCROLL_NEVER + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "contributors" -> openUrl(R.string.contributors_url) + "update_log" -> showUpdateLog() + "check_update" -> checkUpdate() + "mail" -> requireContext().sendMail("kunfei.ge@gmail.com") + "sourceRuleSummary" -> openUrl(R.string.source_rule_url) + "git" -> openUrl(R.string.this_github_url) + "home_page" -> openUrl(R.string.home_page_url) + "license" -> requireContext().openUrl(licenseUrl) + "disclaimer" -> requireContext().openUrl(disclaimerUrl) + "qq" -> showQqGroups() + "gzGzh" -> requireContext().sendToClip(getString(R.string.legado_gzh)) + "crashLog" -> showCrashLogs() + "tg" -> openUrl(R.string.tg_url) + "discord" -> openUrl(R.string.discord_url) + } + return super.onPreferenceTreeClick(preference) + } + + @Suppress("SameParameterValue") + private fun openUrl(@StringRes addressID: Int) { + requireContext().openUrl(getString(addressID)) + } + + private fun showUpdateLog() { + val log = String(requireContext().assets.open("updateLog.md").readBytes()) + showDialogFragment(TextDialog(log, TextDialog.Mode.MD)) + } + + private fun checkUpdate() { + AppUpdate.checkFromGitHub(lifecycleScope) { newVersion, updateBody, url, name -> + showDialogFragment( + UpdateDialog(newVersion, updateBody, url, name) + ) + } + } + + private fun showQqGroups() { + alert(titleResource = R.string.join_qq_group) { + val names = arrayListOf() + qqGroups.forEach { + names.add(it.key) + } + items(names) { _, index -> + qqGroups[names[index]]?.let { + if (!joinQQGroup(it)) { + requireContext().sendToClip(it) + } + } + } + } + } + + private fun joinQQGroup(key: String): Boolean { + val intent = Intent() + intent.data = + Uri.parse("mqqopensdkapi://bizAgent/qm/qr?url=http%3A%2F%2Fqm.qq.com%2Fcgi-bin%2Fqm%2Fqr%3Ffrom%3Dapp%26p%3Dandroid%26k%3D$key") + // 此Flag可根据具体产品需要自定义,如设置,则在加群界面按返回,返回手Q主界面,不设置,按返回会返回到呼起产品界面 + // intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + kotlin.runCatching { + startActivity(intent) + return true + }.onFailure { + toastOnUi("添加失败,请手动添加") + } + return false + } + + private fun showCrashLogs() { + context?.externalCacheDir?.let { exCacheDir -> + val crashDir = exCacheDir.getFile("crash") + val crashLogs = crashDir.listFiles() + val crashLogNames = arrayListOf() + crashLogs?.forEach { + crashLogNames.add(it.name) + } + context?.selector(R.string.crash_log, crashLogNames) { _, select -> + crashLogs?.getOrNull(select)?.let { logFile -> + showDialogFragment(TextDialog(logFile.readText())) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/AppLogDialog.kt b/app/src/main/java/io/legado/app/ui/about/AppLogDialog.kt new file mode 100644 index 000000000..258d9cc9a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/AppLogDialog.kt @@ -0,0 +1,87 @@ +package io.legado.app.ui.about + +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppLog +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemAppLogBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.LogUtils +import io.legado.app.utils.setLayout +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onClick +import java.util.* + +class AppLogDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { + LogAdapter(requireContext()) + } + + override fun onStart() { + super.onStart() + setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.run { + toolBar.setBackgroundColor(primaryColor) + toolBar.setTitle(R.string.log) + toolBar.inflateMenu(R.menu.app_log) + toolBar.setOnMenuItemClickListener(this@AppLogDialog) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.adapter = adapter + } + adapter.setItems(AppLog.logs) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_clear -> AppLog.clear() + } + return true + } + + inner class LogAdapter(context: Context) : + RecyclerAdapter, ItemAppLogBinding>(context) { + + override fun getViewBinding(parent: ViewGroup): ItemAppLogBinding { + return ItemAppLogBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemAppLogBinding, + item: Triple, + payloads: MutableList + ) { + binding.textTime.text = LogUtils.logTimeFormat.format(Date(item.first)) + binding.textMessage.text = item.second + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemAppLogBinding) { + binding.root.onClick { + getItem(holder.layoutPosition)?.let { item -> + item.third?.let { + showDialogFragment(TextDialog(it.stackTraceToString())) + } + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/DonateActivity.kt b/app/src/main/java/io/legado/app/ui/about/DonateActivity.kt new file mode 100644 index 000000000..831d25535 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/DonateActivity.kt @@ -0,0 +1,28 @@ +package io.legado.app.ui.about + + +import android.os.Bundle +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.databinding.ActivityDonateBinding +import io.legado.app.utils.viewbindingdelegate.viewBinding + +/** + * Created by GKF on 2018/1/13. + * 捐赠页面 + */ + +class DonateActivity : BaseActivity() { + + override val binding by viewBinding(ActivityDonateBinding::inflate) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + val fTag = "donateFragment" + var donateFragment = supportFragmentManager.findFragmentByTag(fTag) + if (donateFragment == null) donateFragment = DonateFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.fl_fragment, donateFragment, fTag) + .commit() + } + +} diff --git a/app/src/main/java/io/legado/app/ui/about/DonateFragment.kt b/app/src/main/java/io/legado/app/ui/about/DonateFragment.kt new file mode 100644 index 000000000..94c7a7baf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/DonateFragment.kt @@ -0,0 +1,60 @@ +package io.legado.app.ui.about + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.R +import io.legado.app.utils.ACache +import io.legado.app.utils.longToastOnUi +import io.legado.app.utils.openUrl +import io.legado.app.utils.sendToClip +import timber.log.Timber + +class DonateFragment : PreferenceFragmentCompat() { + + private val zfbHbRwmUrl = "https://gitee.com/gekunfei/Donate/raw/master/zfbhbrwm.png" + private val zfbSkRwmUrl = "https://gitee.com/gekunfei/Donate/raw/master/zfbskrwm.jpg" + private val wxZsRwmUrl = "https://gitee.com/gekunfei/Donate/raw/master/wxskrwm.jpg" + private val qqSkRwmUrl = "https://gitee.com/gekunfei/Donate/raw/master/qqskrwm.jpg" + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.donate) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.overScrollMode = View.OVER_SCROLL_NEVER + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "wxZsm" -> requireContext().openUrl(wxZsRwmUrl) + "zfbHbRwm" -> requireContext().openUrl(zfbHbRwmUrl) + "zfbSkRwm" -> requireContext().openUrl(zfbSkRwmUrl) + "qqSkRwm" -> requireContext().openUrl(qqSkRwmUrl) + "zfbHbSsm" -> getZfbHb(requireContext()) + "gzGzh" -> requireContext().sendToClip("开源阅读") + } + return super.onPreferenceTreeClick(preference) + } + + private fun getZfbHb(context: Context) { + requireContext().sendToClip("537954522") + context.longToastOnUi("高级功能已开启\n红包码已复制\n支付宝首页搜索“537954522” 立即领红包") + try { + val packageManager = context.applicationContext.packageManager + val intent = packageManager.getLaunchIntentForPackage("com.eg.android.AlipayGphone")!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (e: Exception) { + Timber.e(e) + } finally { + ACache.get(requireContext(), cacheDir = false) + .put("proTime", System.currentTimeMillis()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt new file mode 100644 index 000000000..31a35de9d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt @@ -0,0 +1,167 @@ +package io.legado.app.ui.about + +import android.content.Context +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.appDb +import io.legado.app.data.entities.ReadRecordShow +import io.legado.app.databinding.ActivityReadRecordBinding +import io.legado.app.databinding.ItemReadRecordBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.utils.cnCompare +import io.legado.app.utils.startActivity +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.* + +class ReadRecordActivity : BaseActivity() { + + private val adapter by lazy { RecordAdapter(this) } + private var sortMode = 0 + + override val binding by viewBinding(ActivityReadRecordBinding::inflate) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + initData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_read_record, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_sort_name -> { + sortMode = 0 + initData() + } + R.id.menu_sort_time -> { + sortMode = 1 + initData() + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() = binding.run { + readRecord.tvBookName.setText(R.string.all_read_time) + recyclerView.adapter = adapter + readRecord.tvRemove.setOnClickListener { + alert(R.string.delete, R.string.sure_del) { + okButton { + appDb.readRecordDao.clear() + initData() + } + noButton() + } + } + } + + private fun initData() { + launch(IO) { + val allTime = appDb.readRecordDao.allTime + withContext(Main) { + binding.readRecord.tvReadTime.text = formatDuring(allTime) + } + var readRecords = appDb.readRecordDao.allShow + readRecords = when (sortMode) { + 1 -> readRecords.sortedBy { it.readTime } + else -> { + readRecords.sortedWith { o1, o2 -> + o1.bookName.cnCompare(o2.bookName) + } + } + } + withContext(Main) { + adapter.setItems(readRecords) + } + } + } + + inner class RecordAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemReadRecordBinding { + return ItemReadRecordBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemReadRecordBinding, + item: ReadRecordShow, + payloads: MutableList + ) { + binding.apply { + tvBookName.text = item.bookName + tvReadTime.text = formatDuring(item.readTime) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemReadRecordBinding) { + binding.apply { + root.setOnClickListener { + val item = getItem(holder.layoutPosition) ?: return@setOnClickListener + launch { + val book = withContext(IO) { + appDb.bookDao.findByName(item.bookName).firstOrNull() + } + if (book == null) { + SearchActivity.start(this@ReadRecordActivity, item.bookName) + } else { + startActivity { + putExtra("bookUrl", book.bookUrl) + } + } + } + } + tvRemove.setOnClickListener { + getItem(holder.layoutPosition)?.let { item -> + sureDelAlert(item) + } + } + } + } + + private fun sureDelAlert(item: ReadRecordShow) { + alert(R.string.delete) { + setMessage(getString(R.string.sure_del_any, item.bookName)) + okButton { + appDb.readRecordDao.deleteByName(item.bookName) + initData() + } + noButton() + } + } + + } + + fun formatDuring(mss: Long): String { + val days = mss / (1000 * 60 * 60 * 24) + val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60) + val minutes = mss % (1000 * 60 * 60) / (1000 * 60) + val seconds = mss % (1000 * 60) / 1000 + val d = if (days > 0) "${days}天" else "" + val h = if (hours > 0) "${hours}小时" else "" + val m = if (minutes > 0) "${minutes}分钟" else "" + val s = if (seconds > 0) "${seconds}秒" else "" + var time = "$d$h$m$s" + if (time.isBlank()) { + time = "0秒" + } + return time + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/UpdateDialog.kt b/app/src/main/java/io/legado/app/ui/about/UpdateDialog.kt new file mode 100644 index 000000000..56cf1776f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/UpdateDialog.kt @@ -0,0 +1,72 @@ +package io.legado.app.ui.about + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogUpdateBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.primaryColor +import io.legado.app.model.Download +import io.legado.app.utils.setLayout +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import io.noties.markwon.Markwon +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.glide.GlideImagesPlugin + +class UpdateDialog() : BaseDialogFragment(R.layout.dialog_update) { + + constructor(newVersion: String, updateBody: String, url: String, name: String) : this() { + arguments = Bundle().apply { + putString("newVersion", newVersion) + putString("updateBody", updateBody) + putString("url", url) + putString("name", name) + } + } + + val binding by viewBinding(DialogUpdateBinding::bind) + + override fun onStart() { + super.onStart() + setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.title = arguments?.getString("newVersion") + val updateBody = arguments?.getString("updateBody") + if (updateBody == null) { + toastOnUi("没有数据") + dismiss() + return + } + binding.textView.post { + Markwon.builder(requireContext()) + .usePlugin(GlideImagesPlugin.create(requireContext())) + .usePlugin(HtmlPlugin.create()) + .usePlugin(TablePlugin.create(requireContext())) + .build() + .setMarkdown(binding.textView, updateBody) + } + if (!AppConfig.isGooglePlay) { + binding.toolBar.inflateMenu(R.menu.app_update) + binding.toolBar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_download -> { + val url = arguments?.getString("url") + val name = arguments?.getString("name") + if (url != null && name != null) { + Download.start(requireContext(), url, name) + } + } + } + return@setOnMenuItemClickListener true + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/BaseAssociationViewModel.kt b/app/src/main/java/io/legado/app/ui/association/BaseAssociationViewModel.kt new file mode 100644 index 000000000..f0e252a3e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/BaseAssociationViewModel.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.association + +import android.app.Application +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.HttpTTS +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.help.ThemeConfig +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.isJsonArray + +abstract class BaseAssociationViewModel(application: Application) : BaseViewModel(application) { + + + fun importTextTocRule(json: String, finally: (title: String, msg: String) -> Unit) { + execute { + if (json.isJsonArray()) { + GSON.fromJsonArray(json)?.let { + appDb.txtTocRuleDao.insert(*it.toTypedArray()) + } ?: throw NoStackTraceException("格式不对") + } else { + GSON.fromJsonObject(json)?.let { + appDb.txtTocRuleDao.insert(it) + } ?: throw NoStackTraceException("格式不对") + } + }.onSuccess { + finally.invoke(context.getString(R.string.success), "导入Txt规则成功") + }.onError { + finally.invoke( + context.getString(R.string.error), + it.localizedMessage ?: context.getString(R.string.unknown_error) + ) + } + } + + fun importHttpTTS(json: String, finally: (title: String, msg: String) -> Unit) { + execute { + if (json.isJsonArray()) { + HttpTTS.fromJsonArray(json).let { + appDb.httpTTSDao.insert(*it.toTypedArray()) + return@execute it.size + } + } else { + HttpTTS.fromJson(json)?.let { + appDb.httpTTSDao.insert(it) + return@execute 1 + } ?: throw NoStackTraceException("格式不对") + } + }.onSuccess { + finally.invoke(context.getString(R.string.success), "导入${it}朗读引擎") + }.onError { + finally.invoke( + context.getString(R.string.error), + it.localizedMessage ?: context.getString(R.string.unknown_error) + ) + } + } + + fun importTheme(json: String, finally: (title: String, msg: String) -> Unit) { + execute { + if (json.isJsonArray()) { + GSON.fromJsonArray(json)?.forEach { + ThemeConfig.addConfig(it) + } ?: throw NoStackTraceException("格式不对") + } else { + GSON.fromJsonObject(json)?.let { + ThemeConfig.addConfig(it) + } ?: throw NoStackTraceException("格式不对") + } + }.onSuccess { + finally.invoke(context.getString(R.string.success), "导入主题成功") + }.onError { + finally.invoke( + context.getString(R.string.error), + it.localizedMessage ?: context.getString(R.string.unknown_error) + ) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt b/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt new file mode 100644 index 000000000..0cdb6ba84 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt @@ -0,0 +1,153 @@ +package io.legado.app.ui.association + +import android.net.Uri +import android.os.Bundle +import androidx.activity.viewModels +import androidx.documentfile.provider.DocumentFile +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivityTranslucenceBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.permission.Permissions +import io.legado.app.lib.permission.PermissionsCompat +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +class FileAssociationActivity : + VMBaseActivity() { + + private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) { + intent.data?.let { uri -> + it.uri?.let { treeUri -> + AppConfig.defaultBookTreeUri = treeUri.toString() + importBook(treeUri, uri) + } ?: let { + toastOnUi("不选择文件夹重启应用后可能没有权限访问") + viewModel.importBook(uri) + } + } + } + + override val binding by viewBinding(ActivityTranslucenceBinding::inflate) + + override val viewModel by viewModels() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.rotateLoading.show() + viewModel.importBookLiveData.observe(this) { uri -> + if (uri.isContentScheme()) { + val treeUriStr = AppConfig.defaultBookTreeUri + if (treeUriStr.isNullOrEmpty()) { + localBookTreeSelect.launch { + title = "选择保存书籍的文件夹" + } + } else { + importBook(Uri.parse(treeUriStr), uri) + } + } else { + PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { + viewModel.importBook(uri) + }.request() + } + } + viewModel.onLineImportLive.observe(this) { + startActivity { + data = it + } + finish() + } + viewModel.importBookSourceLive.observe(this) { + binding.rotateLoading.hide() + showDialogFragment(ImportBookSourceDialog(it, true)) + } + viewModel.importRssSourceLive.observe(this) { + binding.rotateLoading.hide() + showDialogFragment(ImportRssSourceDialog(it, true)) + } + viewModel.importReplaceRuleLive.observe(this) { + binding.rotateLoading.hide() + showDialogFragment(ImportReplaceRuleDialog(it, true)) + } + viewModel.errorLiveData.observe(this) { + binding.rotateLoading.hide() + toastOnUi(it) + finish() + } + viewModel.openBookLiveData.observe(this) { + binding.rotateLoading.hide() + startActivity { + putExtra("bookUrl", it) + } + finish() + } + intent.data?.let { data -> + viewModel.dispatchIndent(data, this::finallyDialog) + } + } + + private fun importBook(treeUri: Uri, uri: Uri) { + launch { + runCatching { + if (treeUri.isContentScheme()) { + val treeDoc = DocumentFile.fromTreeUri(this@FileAssociationActivity, treeUri) + val bookDoc = DocumentFile.fromSingleUri(this@FileAssociationActivity, uri) + withContext(IO) { + val name = bookDoc?.name!! + val doc = treeDoc!!.findFile(name) + if (doc != null) { + viewModel.importBook(doc.uri) + } else { + val nDoc = treeDoc.createFile(FileUtils.getMimeType(name), name)!! + contentResolver.openOutputStream(nDoc.uri)!!.use { oStream -> + contentResolver.openInputStream(bookDoc.uri)!!.use { iStream -> + iStream.copyTo(oStream) + oStream.flush() + } + } + viewModel.importBook(nDoc.uri) + } + } + } else { + val treeFile = File(treeUri.path!!) + val bookDoc = DocumentFile.fromSingleUri(this@FileAssociationActivity, uri) + withContext(IO) { + val name = bookDoc?.name!! + val file = treeFile.getFile(name) + if (!file.exists() || file.lastModified() < bookDoc.lastModified()) { + FileOutputStream(file).use { oStream -> + contentResolver.openInputStream(bookDoc.uri)!!.use { iStream -> + iStream.copyTo(oStream) + oStream.flush() + } + } + } + viewModel.importBook(Uri.fromFile(file)) + } + } + }.onFailure { + toastOnUi(it.localizedMessage) + } + } + } + + private fun finallyDialog(title: String, msg: String) { + alert(title, msg) { + okButton() + onDismiss { + finish() + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt b/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt new file mode 100644 index 000000000..04d73f717 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt @@ -0,0 +1,66 @@ +package io.legado.app.ui.association + +import android.app.Application +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.MutableLiveData +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.localBook.LocalBook +import io.legado.app.utils.isJson +import io.legado.app.utils.readText +import timber.log.Timber +import java.io.File + +class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) { + val importBookLiveData = MutableLiveData() + val onLineImportLive = MutableLiveData() + val importBookSourceLive = MutableLiveData() + val importRssSourceLive = MutableLiveData() + val importReplaceRuleLive = MutableLiveData() + val openBookLiveData = MutableLiveData() + val errorLiveData = MutableLiveData() + + @Suppress("BlockingMethodInNonBlockingContext") + fun dispatchIndent(uri: Uri, finally: (title: String, msg: String) -> Unit) { + execute { + //如果是普通的url,需要根据返回的内容判断是什么 + if (uri.scheme == "file" || uri.scheme == "content") { + val content = if (uri.scheme == "file") { + File(uri.path.toString()).readText() + } else { + DocumentFile.fromSingleUri(context, uri)?.readText(context) + } ?: throw NoStackTraceException("文件不存在") + if (content.isJson()) { + //暂时根据文件内容判断属于什么 + when { + content.contains("bookSourceUrl") -> + importBookSourceLive.postValue(content) + content.contains("sourceUrl") -> + importRssSourceLive.postValue(content) + content.contains("pattern") -> + importReplaceRuleLive.postValue(content) + content.contains("themeName") -> + importTheme(content, finally) + content.contains("name") && content.contains("rule") -> + importTextTocRule(content, finally) + content.contains("name") && content.contains("url") -> + importHttpTTS(content, finally) + else -> errorLiveData.postValue("格式不对") + } + } else { + importBookLiveData.postValue(uri) + } + } else { + onLineImportLive.postValue(uri) + } + }.onError { + Timber.e(it) + errorLiveData.postValue(it.localizedMessage) + } + } + + fun importBook(uri: Uri) { + val book = LocalBook.importFile(uri) + openBookLiveData.postValue(book.bookUrl) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt b/app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt new file mode 100644 index 000000000..178818d94 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt @@ -0,0 +1,255 @@ +package io.legado.app.ui.association + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppPattern +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.databinding.DialogCustomGroupBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemSourceImportBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.dialog.CodeDialog +import io.legado.app.ui.widget.dialog.WaitDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onClick + + +/** + * 导入书源弹出窗口 + */ +class ImportBookSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener, + CodeDialog.Callback { + + constructor(source: String, finishOnDismiss: Boolean = false) : this() { + arguments = Bundle().apply { + putString("source", source) + putBoolean("finishOnDismiss", finishOnDismiss) + } + } + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val viewModel by viewModels() + private val adapter by lazy { SourcesAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + if (arguments?.getBoolean("finishOnDismiss") == true) { + activity?.finish() + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.import_book_source) + binding.rotateLoading.show() + initMenu() + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + binding.tvCancel.visible() + binding.tvCancel.setOnClickListener { + dismissAllowingStateLoss() + } + binding.tvOk.visible() + binding.tvOk.setOnClickListener { + val waitDialog = WaitDialog(requireContext()) + waitDialog.show() + viewModel.importSelect { + waitDialog.dismiss() + dismissAllowingStateLoss() + } + } + binding.tvFooterLeft.visible() + binding.tvFooterLeft.setOnClickListener { + val selectAll = viewModel.isSelectAll + viewModel.selectStatus.forEachIndexed { index, b -> + if (b != !selectAll) { + viewModel.selectStatus[index] = !selectAll + } + } + adapter.notifyDataSetChanged() + upSelectText() + } + viewModel.errorLiveData.observe(this, { + binding.rotateLoading.hide() + binding.tvMsg.apply { + text = it + visible() + } + }) + viewModel.successLiveData.observe(this, { + binding.rotateLoading.hide() + if (it > 0) { + adapter.setItems(viewModel.allSources) + upSelectText() + } else { + binding.tvMsg.apply { + setText(R.string.wrong_format) + visible() + } + } + }) + val source = arguments?.getString("source") + if (source.isNullOrEmpty()) { + dismiss() + return + } + viewModel.importSource(source) + } + + private fun upSelectText() { + if (viewModel.isSelectAll) { + binding.tvFooterLeft.text = getString( + R.string.select_cancel_count, + viewModel.selectCount, + viewModel.allSources.size + ) + } else { + binding.tvFooterLeft.text = getString( + R.string.select_all_count, + viewModel.selectCount, + viewModel.allSources.size + ) + } + } + + private fun initMenu() { + binding.toolBar.setOnMenuItemClickListener(this) + binding.toolBar.inflateMenu(R.menu.import_source) + binding.toolBar.menu.findItem(R.id.menu_Keep_original_name) + ?.isChecked = AppConfig.importKeepName + } + + @SuppressLint("InflateParams") + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_new_group -> alertCustomGroup(item) + R.id.menu_Keep_original_name -> { + item.isChecked = !item.isChecked + putPrefBoolean(PreferKey.importKeepName, item.isChecked) + } + } + return false + } + + private fun alertCustomGroup(item: MenuItem) { + alert(R.string.diy_edit_source_group) { + val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply { + val groups = linkedSetOf() + appDb.bookSourceDao.allGroup.forEach { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + textInputLayout.setHint(R.string.group_name) + editView.setFilterValues(groups.toList()) + editView.dropDownHeight = 180.dp + } + customView { + alertBinding.root + } + okButton { + viewModel.isAddGroup = alertBinding.swAddGroup.isChecked + viewModel.groupName = alertBinding.editView.text?.toString() + if (viewModel.groupName.isNullOrBlank()) { + item.title = getString(R.string.diy_source_group) + } else { + val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName) + if (viewModel.isAddGroup) { + item.title = "+$group" + } else { + item.title = group + } + } + } + noButton() + } + } + + override fun onCodeSave(code: String, requestId: String?) { + requestId?.toInt()?.let { + BookSource.fromJson(code)?.let { source -> + viewModel.allSources[it] = source + adapter.setItem(it, source) + } + } + } + + inner class SourcesAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { + return ItemSourceImportBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemSourceImportBinding, + item: BookSource, + payloads: MutableList + ) { + binding.apply { + cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] + cbSourceName.text = item.bookSourceName + val localSource = viewModel.checkSources[holder.layoutPosition] + tvSourceState.text = when { + localSource == null -> "新增" + item.lastUpdateTime > localSource.lastUpdateTime -> "更新" + else -> "已有" + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { + binding.apply { + cbSourceName.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + viewModel.selectStatus[holder.layoutPosition] = isChecked + upSelectText() + } + } + root.onClick { + cbSourceName.isChecked = !cbSourceName.isChecked + viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked + upSelectText() + } + tvOpen.setOnClickListener { + val source = viewModel.allSources[holder.layoutPosition] + showDialogFragment( + CodeDialog( + GSON.toJson(source), + disableEdit = false, + requestId = holder.layoutPosition.toString() + ) + ) + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt new file mode 100644 index 000000000..e11eae675 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt @@ -0,0 +1,160 @@ +package io.legado.app.ui.association + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import com.jayway.jsonpath.JsonPath +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.help.AppConfig +import io.legado.app.help.ContentProcessor +import io.legado.app.help.SourceHelp +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.* +import timber.log.Timber + +class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) { + var isAddGroup = false + var groupName: String? = null + val errorLiveData = MutableLiveData() + val successLiveData = MutableLiveData() + + val allSources = arrayListOf() + val checkSources = arrayListOf() + val selectStatus = arrayListOf() + + val isSelectAll: Boolean + get() { + selectStatus.forEach { + if (!it) { + return false + } + } + return true + } + + val selectCount: Int + get() { + var count = 0 + selectStatus.forEach { + if (it) { + count++ + } + } + return count + } + + fun importSelect(finally: () -> Unit) { + execute { + val group = groupName?.trim() + val keepName = AppConfig.importKeepName + val selectSource = arrayListOf() + selectStatus.forEachIndexed { index, b -> + if (b) { + val source = allSources[index] + if (keepName) { + checkSources[index]?.let { + source.bookSourceName = it.bookSourceName + source.bookSourceGroup = it.bookSourceGroup + source.customOrder = it.customOrder + } + } + if (!group.isNullOrEmpty()) { + if (isAddGroup) { + val groups = linkedSetOf() + source.bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.let { + groups.addAll(it) + } + groups.add(group) + source.bookSourceGroup = groups.joinToString(",") + } else { + source.bookSourceGroup = group + } + } + selectSource.add(source) + } + } + SourceHelp.insertBookSource(*selectSource.toTypedArray()) + ContentProcessor.upReplaceRules() + }.onFinally { + finally.invoke() + } + } + + fun importSource(text: String) { + execute { + val mText = text.trim() + when { + mText.isJsonObject() -> { + val json = JsonPath.parse(mText) + val urls = json.read>("$.sourceUrls") + if (!urls.isNullOrEmpty()) { + urls.forEach { + importSourceUrl(it) + } + } else { + BookSource.fromJson(mText)?.let { + allSources.add(it) + } + } + } + mText.isJsonArray() -> { + val items = BookSource.fromJsonArray(mText) + allSources.addAll(items) + } + mText.isAbsUrl() -> { + importSourceUrl(mText) + } + else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) + } + }.onError { + Timber.e(it) + errorLiveData.postValue(it.localizedMessage ?: "") + }.onSuccess { + comparisonSource() + } + } + + private suspend fun importSourceUrl(url: String) { + okHttpClient.newCallResponseBody { + url(url) + }.text("utf-8").let { body -> + when { + body.isJsonArray() -> { + val items: List> = jsonPath.parse(body).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + BookSource.fromJson(jsonItem.jsonString())?.let { source -> + allSources.add(source) + } + } + } + body.isJsonObject() -> { + BookSource.fromJson(body)?.let { + allSources.add(it) + } + } + else -> { + throw NoStackTraceException(context.getString(R.string.wrong_format)) + } + } + } + } + + private fun comparisonSource() { + execute { + allSources.forEach { + val source = appDb.bookSourceDao.getBookSource(it.bookSourceUrl) + checkSources.add(source) + selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime) + } + successLiveData.postValue(allSources.size) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt b/app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt new file mode 100644 index 000000000..5ef354cbd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt @@ -0,0 +1,255 @@ +package io.legado.app.ui.association + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppPattern +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.databinding.DialogCustomGroupBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemSourceImportBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.dialog.CodeDialog +import io.legado.app.ui.widget.dialog.WaitDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onClick + +class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener, + CodeDialog.Callback { + + constructor(source: String, finishOnDismiss: Boolean = false) : this() { + arguments = Bundle().apply { + putString("source", source) + putBoolean("finishOnDismiss", finishOnDismiss) + } + } + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val viewModel by viewModels() + private val adapter by lazy { SourcesAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + if (arguments?.getBoolean("finishOnDismiss") == true) { + activity?.finish() + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.import_replace_rule) + binding.rotateLoading.show() + initMenu() + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + binding.tvCancel.visible() + binding.tvCancel.setOnClickListener { + dismissAllowingStateLoss() + } + binding.tvOk.visible() + binding.tvOk.setOnClickListener { + val waitDialog = WaitDialog(requireContext()) + waitDialog.show() + viewModel.importSelect { + waitDialog.dismiss() + dismissAllowingStateLoss() + } + } + binding.tvFooterLeft.visible() + binding.tvFooterLeft.setOnClickListener { + val selectAll = viewModel.isSelectAll + viewModel.selectStatus.forEachIndexed { index, b -> + if (b != !selectAll) { + viewModel.selectStatus[index] = !selectAll + } + } + adapter.notifyDataSetChanged() + upSelectText() + } + viewModel.errorLiveData.observe(this, { + binding.rotateLoading.hide() + binding.tvMsg.apply { + text = it + visible() + } + }) + viewModel.successLiveData.observe(this, { + binding.rotateLoading.hide() + if (it > 0) { + adapter.setItems(viewModel.allRules) + upSelectText() + } else { + binding.tvMsg.apply { + setText(R.string.wrong_format) + visible() + } + } + }) + val source = arguments?.getString("source") + if (source.isNullOrEmpty()) { + dismiss() + return + } + viewModel.import(source) + } + + private fun initMenu() { + binding.toolBar.setOnMenuItemClickListener(this) + binding.toolBar.inflateMenu(R.menu.import_replace) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_new_group -> alertCustomGroup(item) + R.id.menu_Keep_original_name -> { + item.isChecked = !item.isChecked + putPrefBoolean(PreferKey.importKeepName, item.isChecked) + } + } + return true + } + + private fun alertCustomGroup(item: MenuItem) { + alert(R.string.diy_edit_source_group) { + val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply { + val groups = linkedSetOf() + appDb.replaceRuleDao.allGroup.forEach { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + textInputLayout.setHint(R.string.group_name) + editView.setFilterValues(groups.toList()) + editView.dropDownHeight = 180.dp + } + customView { + alertBinding.root + } + okButton { + viewModel.isAddGroup = alertBinding.swAddGroup.isChecked + viewModel.groupName = alertBinding.editView.text?.toString() + if (viewModel.groupName.isNullOrBlank()) { + item.title = getString(R.string.diy_source_group) + } else { + val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName) + if (viewModel.isAddGroup) { + item.title = "+$group" + } else { + item.title = group + } + } + } + noButton() + } + } + + private fun upSelectText() { + if (viewModel.isSelectAll) { + binding.tvFooterLeft.text = getString( + R.string.select_cancel_count, + viewModel.selectCount, + viewModel.allRules.size + ) + } else { + binding.tvFooterLeft.text = getString( + R.string.select_all_count, + viewModel.selectCount, + viewModel.allRules.size + ) + } + } + + override fun onCodeSave(code: String, requestId: String?) { + requestId?.toInt()?.let { + GSON.fromJsonObject(code)?.let { rule -> + viewModel.allRules[it] = rule + adapter.setItem(it, rule) + } + } + } + + inner class SourcesAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { + return ItemSourceImportBinding.inflate(inflater, parent, false) + } + + @SuppressLint("SetTextI18n") + override fun convert( + holder: ItemViewHolder, + binding: ItemSourceImportBinding, + item: ReplaceRule, + payloads: MutableList + ) { + binding.run { + cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] + cbSourceName.text = if (item.group.isNullOrBlank()) { + item.name + } else { + "${item.name}(${item.group})" + } + val localRule = viewModel.checkRules[holder.layoutPosition] + tvSourceState.text = when { + localRule == null -> "新增" + item.pattern != localRule.pattern + || item.replacement != localRule.replacement + || item.isRegex != localRule.isRegex + || item.scope != localRule.scope -> "更新" + else -> "已有" + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { + binding.run { + cbSourceName.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + viewModel.selectStatus[holder.layoutPosition] = isChecked + upSelectText() + } + } + root.onClick { + cbSourceName.isChecked = !cbSourceName.isChecked + viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked + upSelectText() + } + tvOpen.setOnClickListener { + val source = viewModel.allRules[holder.layoutPosition] + showDialogFragment( + CodeDialog( + GSON.toJson(source), + disableEdit = false, + requestId = holder.layoutPosition.toString() + ) + ) + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt new file mode 100644 index 000000000..7f8534743 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt @@ -0,0 +1,115 @@ +package io.legado.app.ui.association + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.help.AppConfig +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.help.storage.OldReplace +import io.legado.app.utils.isAbsUrl +import io.legado.app.utils.splitNotBlank + +class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) { + var isAddGroup = false + var groupName: String? = null + val errorLiveData = MutableLiveData() + val successLiveData = MutableLiveData() + + val allRules = arrayListOf() + val checkRules = arrayListOf() + val selectStatus = arrayListOf() + + val isSelectAll: Boolean + get() { + selectStatus.forEach { + if (!it) { + return false + } + } + return true + } + + val selectCount: Int + get() { + var count = 0 + selectStatus.forEach { + if (it) { + count++ + } + } + return count + } + + fun importSelect(finally: () -> Unit) { + execute { + val group = groupName?.trim() + val keepName = AppConfig.importKeepName + val selectRules = arrayListOf() + selectStatus.forEachIndexed { index, b -> + if (b) { + val rule = allRules[index] + if (keepName) { + checkRules[index]?.let { + rule.name = it.name + rule.group = it.group + rule.order = it.order + } + } + if (!group.isNullOrEmpty()) { + if (isAddGroup) { + val groups = linkedSetOf() + rule.group?.splitNotBlank(AppPattern.splitGroupRegex)?.let { + groups.addAll(it) + } + groups.add(group) + rule.group = groups.joinToString(",") + } else { + rule.group = group + } + } + selectRules.add(rule) + } + } + appDb.replaceRuleDao.insert(*selectRules.toTypedArray()) + }.onFinally { + finally.invoke() + } + } + + fun import(text: String) { + execute { + if (text.isAbsUrl()) { + okHttpClient.newCallResponseBody { + url(text) + }.text("utf-8").let { + val rules = OldReplace.jsonToReplaceRules(it) + allRules.addAll(rules) + } + } else { + val rules = OldReplace.jsonToReplaceRules(text) + allRules.addAll(rules) + } + }.onError { + errorLiveData.postValue(it.localizedMessage ?: "ERROR") + }.onSuccess { + comparisonSource() + } + } + + private fun comparisonSource() { + execute { + allRules.forEach { + val rule = appDb.replaceRuleDao.findById(it.id) + checkRules.add(rule) + selectStatus.add(rule == null) + } + }.onSuccess { + successLiveData.postValue(allRules.size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt b/app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt new file mode 100644 index 000000000..e9c18979f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt @@ -0,0 +1,252 @@ +package io.legado.app.ui.association + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppPattern +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.databinding.DialogCustomGroupBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemSourceImportBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.dialog.CodeDialog +import io.legado.app.ui.widget.dialog.WaitDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onClick + +/** + * 导入rss源弹出窗口 + */ +class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener, + CodeDialog.Callback { + + constructor(source: String, finishOnDismiss: Boolean = false) : this() { + arguments = Bundle().apply { + putString("source", source) + putBoolean("finishOnDismiss", finishOnDismiss) + } + } + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val viewModel by viewModels() + private val adapter by lazy { SourcesAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + if (arguments?.getBoolean("finishOnDismiss") == true) { + activity?.finish() + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.import_rss_source) + binding.rotateLoading.show() + initMenu() + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + binding.tvCancel.visible() + binding.tvCancel.setOnClickListener { + dismissAllowingStateLoss() + } + binding.tvOk.visible() + binding.tvOk.setOnClickListener { + val waitDialog = WaitDialog(requireContext()) + waitDialog.show() + viewModel.importSelect { + waitDialog.dismiss() + dismissAllowingStateLoss() + } + } + binding.tvFooterLeft.visible() + binding.tvFooterLeft.setOnClickListener { + val selectAll = viewModel.isSelectAll + viewModel.selectStatus.forEachIndexed { index, b -> + if (b != !selectAll) { + viewModel.selectStatus[index] = !selectAll + } + } + adapter.notifyDataSetChanged() + upSelectText() + } + viewModel.errorLiveData.observe(this, { + binding.rotateLoading.hide() + binding.tvMsg.apply { + text = it + visible() + } + }) + viewModel.successLiveData.observe(this, { + binding.rotateLoading.hide() + if (it > 0) { + adapter.setItems(viewModel.allSources) + upSelectText() + } else { + binding.tvMsg.apply { + setText(R.string.wrong_format) + visible() + } + } + }) + val source = arguments?.getString("source") + if (source.isNullOrEmpty()) { + dismiss() + return + } + viewModel.importSource(source) + } + + private fun upSelectText() { + if (viewModel.isSelectAll) { + binding.tvFooterLeft.text = getString( + R.string.select_cancel_count, + viewModel.selectCount, + viewModel.allSources.size + ) + } else { + binding.tvFooterLeft.text = getString( + R.string.select_all_count, + viewModel.selectCount, + viewModel.allSources.size + ) + } + } + + private fun initMenu() { + binding.toolBar.setOnMenuItemClickListener(this) + binding.toolBar.inflateMenu(R.menu.import_source) + binding.toolBar.menu.findItem(R.id.menu_Keep_original_name)?.isChecked = + AppConfig.importKeepName + } + + @SuppressLint("InflateParams") + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_new_group -> alertCustomGroup(item) + R.id.menu_Keep_original_name -> { + item.isChecked = !item.isChecked + putPrefBoolean(PreferKey.importKeepName, item.isChecked) + } + } + return false + } + + private fun alertCustomGroup(item: MenuItem) { + alert(R.string.diy_edit_source_group) { + val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply { + val groups = linkedSetOf() + appDb.rssSourceDao.allGroup.forEach { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + textInputLayout.setHint(R.string.group_name) + editView.setFilterValues(groups.toList()) + editView.dropDownHeight = 180.dp + } + customView { + alertBinding.root + } + okButton { + viewModel.isAddGroup = alertBinding.swAddGroup.isChecked + viewModel.groupName = alertBinding.editView.text?.toString() + if (viewModel.groupName.isNullOrBlank()) { + item.title = getString(R.string.diy_source_group) + } else { + val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName) + if (viewModel.isAddGroup) { + item.title = "+$group" + } else { + item.title = group + } + } + } + noButton() + } + } + + override fun onCodeSave(code: String, requestId: String?) { + requestId?.toInt()?.let { + RssSource.fromJson(code)?.let { source -> + viewModel.allSources[it] = source + adapter.setItem(it, source) + } + } + } + + inner class SourcesAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { + return ItemSourceImportBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemSourceImportBinding, + item: RssSource, + payloads: MutableList + ) { + binding.apply { + cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition] + cbSourceName.text = item.sourceName + tvSourceState.text = if (viewModel.checkSources[holder.layoutPosition] != null) { + "已有" + } else { + "新增" + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) { + binding.apply { + cbSourceName.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + viewModel.selectStatus[holder.layoutPosition] = isChecked + upSelectText() + } + } + root.onClick { + cbSourceName.isChecked = !cbSourceName.isChecked + viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked + upSelectText() + } + tvOpen.setOnClickListener { + val source = viewModel.allSources[holder.layoutPosition] + showDialogFragment( + CodeDialog( + GSON.toJson(source), + disableEdit = false, + requestId = holder.layoutPosition.toString() + ) + ) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt new file mode 100644 index 000000000..ac83bc3db --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt @@ -0,0 +1,149 @@ +package io.legado.app.ui.association + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import com.jayway.jsonpath.JsonPath +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.help.AppConfig +import io.legado.app.help.SourceHelp +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.* + +class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) { + var isAddGroup = false + var groupName: String? = null + val errorLiveData = MutableLiveData() + val successLiveData = MutableLiveData() + + val allSources = arrayListOf() + val checkSources = arrayListOf() + val selectStatus = arrayListOf() + + val isSelectAll: Boolean + get() { + selectStatus.forEach { + if (!it) { + return false + } + } + return true + } + + val selectCount: Int + get() { + var count = 0 + selectStatus.forEach { + if (it) { + count++ + } + } + return count + } + + fun importSelect(finally: () -> Unit) { + execute { + val group = groupName?.trim() + val keepName = AppConfig.importKeepName + val selectSource = arrayListOf() + selectStatus.forEachIndexed { index, b -> + if (b) { + val source = allSources[index] + if (keepName) { + checkSources[index]?.let { + source.sourceName = it.sourceName + source.sourceGroup = it.sourceGroup + source.customOrder = it.customOrder + } + } + if (!group.isNullOrEmpty()) { + if (isAddGroup) { + val groups = linkedSetOf() + source.sourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.let { + groups.addAll(it) + } + groups.add(group) + source.sourceGroup = groups.joinToString(",") + } else { + source.sourceGroup = group + } + } + selectSource.add(source) + } + } + SourceHelp.insertRssSource(*selectSource.toTypedArray()) + }.onFinally { + finally.invoke() + } + } + + fun importSource(text: String) { + execute { + val mText = text.trim() + when { + mText.isJsonObject() -> { + val json = JsonPath.parse(mText) + val urls = json.read>("$.sourceUrls") + if (!urls.isNullOrEmpty()) { + urls.forEach { + importSourceUrl(it) + } + } else { + RssSource.fromJsonArray(mText).let { + allSources.addAll(it) + } + } + } + mText.isJsonArray() -> { + val items: List> = jsonPath.parse(mText).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + RssSource.fromJsonDoc(jsonItem)?.let { + allSources.add(it) + } + } + } + mText.isAbsUrl() -> { + importSourceUrl(mText) + } + else -> throw NoStackTraceException(context.getString(R.string.wrong_format)) + } + }.onError { + errorLiveData.postValue("ImportError:${it.localizedMessage}") + }.onSuccess { + comparisonSource() + } + } + + private suspend fun importSourceUrl(url: String) { + okHttpClient.newCallResponseBody { + url(url) + }.text("utf-8").let { body -> + val items: List> = jsonPath.parse(body).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + RssSource.fromJson(jsonItem.jsonString())?.let { source -> + allSources.add(source) + } + } + } + } + + private fun comparisonSource() { + execute { + allSources.forEach { + val has = appDb.rssSourceDao.getByKey(it.sourceUrl) + checkSources.add(has) + selectStatus.add(has == null) + } + successLiveData.postValue(allSources.size) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/OnLineImportActivity.kt b/app/src/main/java/io/legado/app/ui/association/OnLineImportActivity.kt new file mode 100644 index 000000000..103b74654 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/OnLineImportActivity.kt @@ -0,0 +1,95 @@ +package io.legado.app.ui.association + +import android.os.Bundle +import androidx.activity.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivityTranslucenceBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding + +/** + * 网络一键导入 + * 格式: legado://import/{path}?src={url} + */ +class OnLineImportActivity : + VMBaseActivity() { + + override val binding by viewBinding(ActivityTranslucenceBinding::inflate) + override val viewModel by viewModels() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + viewModel.successLive.observe(this) { + when (it.first) { + "bookSource" -> showDialogFragment( + ImportBookSourceDialog(it.second, true) + ) + "rssSource" -> showDialogFragment( + ImportRssSourceDialog(it.second, true) + ) + "replaceRule" -> showDialogFragment( + ImportReplaceRuleDialog(it.second, true) + ) + } + } + viewModel.errorLive.observe(this) { + finallyDialog(getString(R.string.error), it) + } + intent.data?.let { + val url = it.getQueryParameter("src") + if (url.isNullOrBlank()) { + finish() + return + } + when (it.path) { + "/bookSource" -> showDialogFragment( + ImportBookSourceDialog(url, true) + ) + "/rssSource" -> showDialogFragment( + ImportRssSourceDialog(url, true) + ) + "/replaceRule" -> showDialogFragment( + ImportReplaceRuleDialog(url, true) + ) + "/textTocRule" -> viewModel.getText(url) { json -> + viewModel.importTextTocRule(json, this::finallyDialog) + } + "/httpTTS" -> viewModel.getText(url) { json -> + viewModel.importHttpTTS(json, this::finallyDialog) + } + "/theme" -> viewModel.getText(url) { json -> + viewModel.importTheme(json, this::finallyDialog) + } + "/readConfig" -> viewModel.getBytes(url) { bytes -> + viewModel.importReadConfig(bytes, this::finallyDialog) + } + "/importonline" -> when (it.host) { + "booksource" -> showDialogFragment( + ImportBookSourceDialog(url, true) + ) + "rsssource" -> showDialogFragment( + ImportRssSourceDialog(url, true) + ) + "replace" -> showDialogFragment( + ImportReplaceRuleDialog(url, true) + ) + else -> { + viewModel.determineType(url, this::finallyDialog) + } + } + else -> viewModel.determineType(url, this::finallyDialog) + } + } + } + + private fun finallyDialog(title: String, msg: String) { + alert(title, msg) { + okButton() + onDismiss { + finish() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/association/OnLineImportViewModel.kt b/app/src/main/java/io/legado/app/ui/association/OnLineImportViewModel.kt new file mode 100644 index 000000000..245179b8a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/association/OnLineImportViewModel.kt @@ -0,0 +1,99 @@ +package io.legado.app.ui.association + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.R +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import okhttp3.MediaType.Companion.toMediaType + +class OnLineImportViewModel(app: Application) : BaseAssociationViewModel(app) { + val successLive = MutableLiveData>() + val errorLive = MutableLiveData() + + fun getText(url: String, success: (text: String) -> Unit) { + execute { + okHttpClient.newCallResponseBody { + url(url) + }.text("utf-8") + }.onSuccess { + success.invoke(it) + }.onError { + errorLive.postValue( + it.localizedMessage ?: context.getString(R.string.unknown_error) + ) + } + } + + fun getBytes(url: String, success: (bytes: ByteArray) -> Unit) { + execute { + @Suppress("BlockingMethodInNonBlockingContext") + okHttpClient.newCallResponseBody { + url(url) + }.bytes() + }.onSuccess { + success.invoke(it) + }.onError { + errorLive.postValue( + it.localizedMessage ?: context.getString(R.string.unknown_error) + ) + } + } + + fun importReadConfig(bytes: ByteArray, finally: (title: String, msg: String) -> Unit) { + execute { + val config = ReadBookConfig.import(bytes) + ReadBookConfig.configList.forEachIndexed { index, c -> + if (c.name == config.name) { + ReadBookConfig.configList[index] = config + return@execute config.name + } + ReadBookConfig.configList.add(config) + return@execute config.name + } + }.onSuccess { + finally.invoke(context.getString(R.string.success), "导入排版成功") + }.onError { + finally.invoke( + context.getString(R.string.error), + it.localizedMessage ?: context.getString(R.string.unknown_error) + ) + } + } + + fun determineType(url: String, finally: (title: String, msg: String) -> Unit) { + execute { + val rs = okHttpClient.newCallResponseBody { + url(url) + } + when (rs.contentType()) { + "application/zip".toMediaType(), + "application/octet-stream".toMediaType() -> { + @Suppress("BlockingMethodInNonBlockingContext") + importReadConfig(rs.bytes(), finally) + } + else -> { + val json = rs.text("utf-8") + when { + json.contains("bookSourceUrl") -> + successLive.postValue(Pair("bookSource", json)) + json.contains("sourceUrl") -> + successLive.postValue(Pair("rssSource", json)) + json.contains("replacement") -> + successLive.postValue(Pair("replaceRule", json)) + json.contains("themeName") -> + importTheme(json, finally) + json.contains("name") && json.contains("rule") -> + importTextTocRule(json, finally) + json.contains("name") && json.contains("url") -> + importHttpTTS(json, finally) + else -> errorLive.postValue("格式不对") + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookActivity.kt new file mode 100644 index 000000000..77f9f313a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookActivity.kt @@ -0,0 +1,229 @@ +package io.legado.app.ui.book.arrange + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.ActivityArrangeBookBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.group.GroupManageDialog +import io.legado.app.ui.book.group.GroupSelectDialog +import io.legado.app.ui.widget.SelectActionBar +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.cnCompare +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +class ArrangeBookActivity : VMBaseActivity(), + PopupMenu.OnMenuItemClickListener, + SelectActionBar.CallBack, + ArrangeBookAdapter.CallBack, + GroupSelectDialog.CallBack { + + override val binding by viewBinding(ActivityArrangeBookBinding::inflate) + override val viewModel by viewModels() + override val groupList: ArrayList = arrayListOf() + private val groupRequestCode = 22 + private val addToGroupRequestCode = 34 + private val adapter by lazy { ArrangeBookAdapter(this, this) } + private var booksFlowJob: Job? = null + private var menu: Menu? = null + private var groupId: Long = -1 + + override fun onActivityCreated(savedInstanceState: Bundle?) { + groupId = intent.getLongExtra("groupId", -1) + launch { + binding.titleBar.subtitle = withContext(IO) { + appDb.bookGroupDao.getByID(groupId)?.groupName + ?: getString(R.string.no_group) + } + } + initView() + initGroupData() + initBookData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.arrange_book, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + this.menu = menu + upMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun selectAll(selectAll: Boolean) { + adapter.selectAll(selectAll) + } + + override fun revertSelection() { + adapter.revertSelection() + } + + override fun onClickSelectBarMainAction() { + selectGroup(groupRequestCode, 0) + } + + private fun initView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.addItemDecoration(VerticalDivider(this)) + binding.recyclerView.adapter = adapter + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = getPrefInt(PreferKey.bookshelfSort) == 3 + val dragSelectTouchHelper: DragSelectTouchHelper = + DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) + dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) + // When this page is opened, it is in selection mode + dragSelectTouchHelper.activeSlideSelect() + // Note: need judge selection first, so add ItemTouchHelper after it. + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + binding.selectActionBar.setMainActionText(R.string.move_to_group) + binding.selectActionBar.inflateMenu(R.menu.arrange_book_sel) + binding.selectActionBar.setOnMenuItemClickListener(this) + binding.selectActionBar.setCallBack(this) + } + + @SuppressLint("NotifyDataSetChanged") + private fun initGroupData() { + launch { + appDb.bookGroupDao.flowAll().collect { + groupList.clear() + groupList.addAll(it) + adapter.notifyDataSetChanged() + upMenu() + } + } + } + + private fun initBookData() { + booksFlowJob?.cancel() + booksFlowJob = launch { + when (groupId) { + AppConst.bookGroupAllId -> appDb.bookDao.flowAll() + AppConst.bookGroupLocalId -> appDb.bookDao.flowLocal() + AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio() + AppConst.bookGroupNoneId -> appDb.bookDao.flowNoGroup() + else -> appDb.bookDao.flowByGroup(groupId) + }.collect { list -> + val books = when (getPrefInt(PreferKey.bookshelfSort)) { + 1 -> list.sortedByDescending { it.latestChapterTime } + 2 -> list.sortedWith { o1, o2 -> + o1.name.cnCompare(o2.name) + } + 3 -> list.sortedBy { it.order } + else -> list.sortedByDescending { it.durChapterTime } + } + adapter.setItems(books) + } + } + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_group_manage -> showDialogFragment() + else -> if (item.groupId == R.id.menu_group) { + binding.titleBar.subtitle = item.title + groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0 + initBookData() + } + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_del_selection -> + alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { + okButton { viewModel.deleteBook(*adapter.selectedBooks()) } + noButton() + } + R.id.menu_update_enable -> + viewModel.upCanUpdate(adapter.selectedBooks(), true) + R.id.menu_update_disable -> + viewModel.upCanUpdate(adapter.selectedBooks(), false) + R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0) + } + return false + } + + private fun upMenu() { + menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu -> + subMenu.removeGroup(R.id.menu_group) + groupList.forEach { bookGroup -> + subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName) + } + } + } + + override fun selectGroup(requestCode: Int, groupId: Long) { + showDialogFragment( + GroupSelectDialog(groupId, requestCode) + ) + } + + override fun upGroup(requestCode: Int, groupId: Long) { + when (requestCode) { + groupRequestCode -> { + val books = arrayListOf() + adapter.selectedBooks().forEach { + books.add(it.copy(group = groupId)) + } + viewModel.updateBook(*books.toTypedArray()) + } + adapter.groupRequestCode -> { + adapter.actionItem?.let { + viewModel.updateBook(it.copy(group = groupId)) + } + } + addToGroupRequestCode -> { + val books = arrayListOf() + adapter.selectedBooks().forEach { + books.add(it.copy(group = it.group or groupId)) + } + viewModel.updateBook(*books.toTypedArray()) + } + } + } + + override fun upSelectCount() { + binding.selectActionBar.upCountView(adapter.selectedBooks().size, adapter.getItems().size) + } + + override fun updateBook(vararg book: Book) { + viewModel.updateBook(*book) + } + + override fun deleteBook(book: Book) { + alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { + okButton { + viewModel.deleteBook(book) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookAdapter.kt new file mode 100644 index 000000000..d35d23542 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookAdapter.kt @@ -0,0 +1,206 @@ +package io.legado.app.ui.book.arrange + +import android.annotation.SuppressLint +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.ItemArrangeBookBinding +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import java.util.* + +class ArrangeBookAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context), + + ItemTouchCallback.Callback { + val groupRequestCode = 12 + private val selectedBooks: HashSet = hashSetOf() + var actionItem: Book? = null + + override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding { + return ItemArrangeBookBinding.inflate(inflater, parent, false) + } + + override fun onCurrentListChanged() { + callBack.upSelectCount() + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemArrangeBookBinding, + item: Book, + payloads: MutableList + ) { + binding.apply { + root.setBackgroundColor(context.backgroundColor) + tvName.text = item.name + tvAuthor.text = item.author + tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE + tvGroupS.text = getGroupName(item.group) + checkbox.isChecked = selectedBooks.contains(item) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) { + binding.apply { + checkbox.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + getItem(holder.layoutPosition)?.let { + if (buttonView.isPressed) { + if (isChecked) { + selectedBooks.add(it) + } else { + selectedBooks.remove(it) + } + callBack.upSelectCount() + } + } + } + } + root.setOnClickListener { + getItem(holder.layoutPosition)?.let { + checkbox.isChecked = !checkbox.isChecked + if (checkbox.isChecked) { + selectedBooks.add(it) + } else { + selectedBooks.remove(it) + } + callBack.upSelectCount() + } + } + tvDelete.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.deleteBook(it) + } + } + tvGroup.setOnClickListener { + getItem(holder.layoutPosition)?.let { + actionItem = it + callBack.selectGroup(groupRequestCode, it.group) + } + } + } + } + + @SuppressLint("NotifyDataSetChanged") + fun selectAll(selectAll: Boolean) { + if (selectAll) { + getItems().forEach { + selectedBooks.add(it) + } + } else { + selectedBooks.clear() + } + notifyDataSetChanged() + callBack.upSelectCount() + } + + @SuppressLint("NotifyDataSetChanged") + fun revertSelection() { + getItems().forEach { + if (selectedBooks.contains(it)) { + selectedBooks.remove(it) + } else { + selectedBooks.add(it) + } + } + notifyDataSetChanged() + callBack.upSelectCount() + } + + fun selectedBooks(): Array { + val books = arrayListOf() + selectedBooks.forEach { + if (getItems().contains(it)) { + books.add(it) + } + } + return books.toTypedArray() + } + + private fun getGroupList(groupId: Long): List { + val groupNames = arrayListOf() + callBack.groupList.forEach { + if (it.groupId > 0 && it.groupId and groupId > 0) { + groupNames.add(it.groupName) + } + } + return groupNames + } + + private fun getGroupName(groupId: Long): String { + val groupNames = getGroupList(groupId) + if (groupNames.isEmpty()) { + return "" + } + return groupNames.joinToString(",") + } + + private var isMoved = false + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.order == targetItem.order) { + for ((index, item) in getItems().withIndex()) { + item.order = index + 1 + } + } else { + val pos = srcItem.order + srcItem.order = targetItem.order + targetItem.order = pos + } + } + swapItem(srcPosition, targetPosition) + isMoved = true + return true + } + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (isMoved) { + callBack.updateBook(*getItems().toTypedArray()) + } + isMoved = false + } + + val dragSelectCallback: DragSelectTouchHelper.Callback = + object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { + override fun currentSelectedId(): MutableSet { + return selectedBooks + } + + override fun getItemId(position: Int): Book { + return getItem(position)!! + } + + override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { + getItem(position)?.let { + if (isSelected) { + selectedBooks.add(it) + } else { + selectedBooks.remove(it) + } + notifyItemChanged(position, bundleOf(Pair("selected", null))) + callBack.upSelectCount() + return true + } + return false + } + } + + interface CallBack { + val groupList: List + fun upSelectCount() + fun updateBook(vararg book: Book) + fun deleteBook(book: Book) + fun selectGroup(requestCode: Int, groupId: Long) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookViewModel.kt new file mode 100644 index 000000000..c6d389997 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/arrange/ArrangeBookViewModel.kt @@ -0,0 +1,32 @@ +package io.legado.app.ui.book.arrange + +import android.app.Application +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book + + +class ArrangeBookViewModel(application: Application) : BaseViewModel(application) { + + fun upCanUpdate(books: Array, canUpdate: Boolean) { + execute { + books.forEach { + it.canUpdate = canUpdate + } + appDb.bookDao.update(*books) + } + } + + fun updateBook(vararg book: Book) { + execute { + appDb.bookDao.update(*book) + } + } + + fun deleteBook(vararg book: Book) { + execute { + appDb.bookDao.delete(*book) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt new file mode 100644 index 000000000..4b2303df0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt @@ -0,0 +1,251 @@ +package io.legado.app.ui.book.audio + +import android.app.Activity +import android.icu.text.SimpleDateFormat +import android.os.Build +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.SeekBar +import androidx.activity.viewModels +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestOptions.bitmapTransform +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.EventBus +import io.legado.app.constant.Status +import io.legado.app.constant.Theme +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.databinding.ActivityAudioPlayBinding +import io.legado.app.help.BlurTransformation +import io.legado.app.help.glide.ImageLoader +import io.legado.app.lib.dialogs.alert +import io.legado.app.model.AudioPlay +import io.legado.app.model.BookCover +import io.legado.app.service.AudioPlayService +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.ui.book.changesource.ChangeSourceDialog +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.toc.TocActivityResult +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.widget.seekbar.SeekBarChangeListener +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onLongClick +import java.util.* + +/** + * 音频播放 + */ +class AudioPlayActivity : + VMBaseActivity(toolBarTheme = Theme.Dark), + ChangeSourceDialog.CallBack { + + override val binding by viewBinding(ActivityAudioPlayBinding::inflate) + override val viewModel by viewModels() + private var menu: Menu? = null + private var adjustProgress = false + private val progressTimeFormat by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + SimpleDateFormat("mm:ss", Locale.getDefault()) + } else { + java.text.SimpleDateFormat("mm:ss", Locale.getDefault()) + } + } + private val tocActivityResult = registerForActivityResult(TocActivityResult()) { + it?.let { + if (it.first != AudioPlay.book?.durChapterIndex) { + AudioPlay.skipTo(this, it.first) + } + } + } + private val sourceEditResult = + registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { + if (it.resultCode == RESULT_OK) { + viewModel.upSource() + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.titleBar.transparent() + AudioPlay.titleData.observe(this) { + binding.titleBar.title = it + } + AudioPlay.coverData.observe(this) { + upCover(it) + } + viewModel.initData(intent) + initView() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.audio_play, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + this.menu = menu + upMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_change_source -> AudioPlay.book?.let { + showDialogFragment(ChangeSourceDialog(it.name, it.author)) + } + R.id.menu_login -> AudioPlay.bookSource?.let { + startActivity { + putExtra("type", "bookSource") + putExtra("key", it.bookSourceUrl) + } + } + R.id.menu_copy_audio_url -> sendToClip(AudioPlayService.url) + R.id.menu_edit_source -> AudioPlay.bookSource?.let { + sourceEditResult.launch { + putExtra("sourceUrl", it.bookSourceUrl) + } + } + R.id.menu_log -> showDialogFragment() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + binding.fabPlayStop.setOnClickListener { + playButton() + } + binding.fabPlayStop.onLongClick { + AudioPlay.stop(this@AudioPlayActivity) + } + binding.ivSkipNext.setOnClickListener { + AudioPlay.next(this@AudioPlayActivity) + } + binding.ivSkipPrevious.setOnClickListener { + AudioPlay.prev(this@AudioPlayActivity) + } + binding.playerProgress.setOnSeekBarChangeListener(object : SeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + binding.tvDurTime.text = progressTimeFormat.format(progress.toLong()) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + adjustProgress = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + adjustProgress = false + AudioPlay.adjustProgress(this@AudioPlayActivity, seekBar.progress) + } + }) + binding.ivChapter.setOnClickListener { + AudioPlay.book?.let { + tocActivityResult.launch(it.bookUrl) + } + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + binding.ivFastRewind.invisible() + binding.ivFastForward.invisible() + } + binding.ivFastForward.setOnClickListener { + AudioPlay.adjustSpeed(this@AudioPlayActivity, 0.1f) + } + binding.ivFastRewind.setOnClickListener { + AudioPlay.adjustSpeed(this@AudioPlayActivity, -0.1f) + } + binding.ivTimer.setOnClickListener { + AudioPlay.addTimer(this@AudioPlayActivity) + } + } + + private fun upMenu() { + menu?.let { menu -> + menu.findItem(R.id.menu_login)?.isVisible = + !AudioPlay.bookSource?.loginUrl.isNullOrBlank() + } + } + + private fun upCover(path: String?) { + ImageLoader.load(this, path) + .placeholder(BookCover.defaultDrawable) + .error(BookCover.defaultDrawable) + .into(binding.ivCover) + ImageLoader.load(this, path) + .transition(DrawableTransitionOptions.withCrossFade(1500)) + .thumbnail(BookCover.getBlurDefaultCover(this)) + .apply(bitmapTransform(BlurTransformation(this, 25))) + .into(binding.ivBg) + } + + private fun playButton() { + when (AudioPlay.status) { + Status.PLAY -> AudioPlay.pause(this) + Status.PAUSE -> AudioPlay.resume(this) + else -> AudioPlay.play(this) + } + } + + override val oldBook: Book? + get() = AudioPlay.book + + override fun changeTo(source: BookSource, book: Book) { + viewModel.changeTo(source, book) + } + + override fun finish() { + AudioPlay.book?.let { + if (!AudioPlay.inBookshelf) { + alert(title = getString(R.string.add_to_shelf)) { + setMessage(getString(R.string.check_add_bookshelf, it.name)) + okButton { + AudioPlay.inBookshelf = true + setResult(Activity.RESULT_OK) + } + noButton { viewModel.removeFromBookshelf { super.finish() } } + } + } else { + super.finish() + } + } ?: super.finish() + } + + override fun onDestroy() { + super.onDestroy() + if (AudioPlay.status != Status.PLAY) { + AudioPlay.stop(this) + } + } + + override fun observeLiveBus() { + observeEvent(EventBus.MEDIA_BUTTON) { + if (it) { + playButton() + } + } + observeEventSticky(EventBus.AUDIO_STATE) { + AudioPlay.status = it + if (it == Status.PLAY) { + binding.fabPlayStop.setImageResource(R.drawable.ic_pause_24dp) + } else { + binding.fabPlayStop.setImageResource(R.drawable.ic_play_24dp) + } + } + observeEventSticky(EventBus.AUDIO_SUB_TITLE) { + binding.tvSubTitle.text = it + } + observeEventSticky(EventBus.AUDIO_SIZE) { + binding.playerProgress.max = it + binding.tvAllTime.text = progressTimeFormat.format(it.toLong()) + } + observeEventSticky(EventBus.AUDIO_PROGRESS) { + if (!adjustProgress) binding.playerProgress.progress = it + binding.tvDurTime.text = progressTimeFormat.format(it.toLong()) + } + observeEventSticky(EventBus.AUDIO_SPEED) { + binding.tvSpeed.text = String.format("%.1fX", it) + binding.tvSpeed.visible() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt new file mode 100644 index 000000000..a21cb3364 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt @@ -0,0 +1,139 @@ +package io.legado.app.ui.book.audio + +import android.app.Application +import android.content.Intent +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.BookHelp +import io.legado.app.model.AudioPlay +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.postEvent +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers + +class AudioPlayViewModel(application: Application) : BaseViewModel(application) { + + fun initData(intent: Intent) = AudioPlay.apply { + execute { + val bookUrl = intent.getStringExtra("bookUrl") + if (bookUrl != null && bookUrl != book?.bookUrl) { + stop(context) + inBookshelf = intent.getBooleanExtra("inBookshelf", true) + book = appDb.bookDao.getBook(bookUrl) + book?.let { book -> + titleData.postValue(book.name) + coverData.postValue(book.getDisplayCover()) + durChapter = appDb.bookChapterDao.getChapter(book.bookUrl, book.durChapterIndex) + upDurChapter(book) + bookSource = appDb.bookSourceDao.getBookSource(book.origin) + if (durChapter == null) { + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) + } else { + loadChapterList(book) + } + } + saveRead(book) + } + } + } + } + + private fun loadBookInfo( + book: Book, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null + ) { + execute { + AudioPlay.bookSource?.let { + WebBook.getBookInfo(this, it, book) + .onSuccess { + loadChapterList(book, changeDruChapterIndex) + } + } + } + } + + private fun loadChapterList( + book: Book, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null + ) { + execute { + AudioPlay.bookSource?.let { + WebBook.getChapterList(this, it, book) + .onSuccess(Dispatchers.IO) { cList -> + if (changeDruChapterIndex == null) { + appDb.bookChapterDao.insert(*cList.toTypedArray()) + } else { + changeDruChapterIndex(cList) + } + AudioPlay.upDurChapter(book) + }.onError { + context.toastOnUi(R.string.error_load_toc) + } + } + } + } + + fun upSource() { + execute { + AudioPlay.book?.let { book -> + AudioPlay.bookSource = appDb.bookSourceDao.getBookSource(book.origin) + } + } + } + + fun changeTo(source: BookSource, book: Book) { + execute { + var oldTocSize: Int = book.totalChapterNum + AudioPlay.book?.let { + oldTocSize = it.totalChapterNum + book.order = it.order + appDb.bookDao.delete(it) + } + appDb.bookDao.insert(book) + AudioPlay.book = book + AudioPlay.bookSource = source + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) { upChangeDurChapterIndex(book, oldTocSize, it) } + } else { + loadChapterList(book) { upChangeDurChapterIndex(book, oldTocSize, it) } + } + }.onFinally { + postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) + } + } + + private fun upChangeDurChapterIndex( + book: Book, + oldTocSize: Int, + chapters: List + ) { + execute { + book.durChapterIndex = BookHelp.getDurChapter( + book.durChapterIndex, + oldTocSize, + book.durChapterTitle, + chapters + ) + book.durChapterTitle = chapters[book.durChapterIndex].title + appDb.bookDao.update(book) + appDb.bookChapterDao.insert(*chapters.toTypedArray()) + } + } + + fun removeFromBookshelf(success: (() -> Unit)?) { + execute { + AudioPlay.book?.let { + appDb.bookDao.delete(it) + } + }.onSuccess { + success?.invoke() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt new file mode 100644 index 000000000..ae0bdcd5e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt @@ -0,0 +1,340 @@ +package io.legado.app.ui.book.cache + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppConst.charsets +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.ActivityCacheBookBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.model.CacheBook +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class CacheActivity : VMBaseActivity(), + CacheAdapter.CallBack { + + override val binding by viewBinding(ActivityCacheBookBinding::inflate) + override val viewModel by viewModels() + + private val exportBookPathKey = "exportBookPath" + private val exportTypes = arrayListOf("txt", "epub") + private val adapter by lazy { CacheAdapter(this, this) } + private var booksFlowJob: Job? = null + private var menu: Menu? = null + private val groupList: ArrayList = arrayListOf() + private var groupId: Long = -1 + + private val exportDir = registerForActivityResult(HandleFileContract()) { result -> + result.uri?.let { uri -> + if (uri.isContentScheme()) { + ACache.get(this@CacheActivity).put(exportBookPathKey, uri.toString()) + startExport(uri.toString(), result.requestCode) + } else { + uri.path?.let { path -> + ACache.get(this@CacheActivity).put(exportBookPathKey, path) + startExport(path, result.requestCode) + } + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + groupId = intent.getLongExtra("groupId", -1) + launch { + binding.titleBar.subtitle = withContext(IO) { + appDb.bookGroupDao.getByID(groupId)?.groupName + ?: getString(R.string.no_group) + } + } + initRecyclerView() + initGroupData() + initBookData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_cache, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + this.menu = menu + upMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.findItem(R.id.menu_enable_replace)?.isChecked = AppConfig.exportUseReplace + menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName + menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav + menu.findItem(R.id.menu_export_type)?.title = + "${getString(R.string.export_type)}(${getTypeName()})" + menu.findItem(R.id.menu_export_charset)?.title = + "${getString(R.string.export_charset)}(${AppConfig.exportCharset})" + return super.onMenuOpened(featureId, menu) + } + + private fun upMenu() { + menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu -> + subMenu.removeGroup(R.id.menu_group) + groupList.forEach { bookGroup -> + subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName) + } + } + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_download -> { + if (!CacheBook.isRun) { + adapter.getItems().forEach { book -> + CacheBook.start( + this@CacheActivity, + book.bookUrl, + book.durChapterIndex, + book.totalChapterNum + ) + } + } else { + CacheBook.stop(this@CacheActivity) + } + } + R.id.menu_export_all -> exportAll() + R.id.menu_enable_replace -> AppConfig.exportUseReplace = !item.isChecked + R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked + R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked + R.id.menu_export_folder -> { + selectExportFolder(-1) + } + R.id.menu_export_file_name -> alertExportFileName() + R.id.menu_export_type -> showExportTypeConfig() + R.id.menu_export_charset -> showCharsetConfig() + R.id.menu_log -> showDialogFragment() + else -> if (item.groupId == R.id.menu_group) { + binding.titleBar.subtitle = item.title + groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0 + initBookData() + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initRecyclerView() { + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = adapter + } + + private fun initBookData() { + booksFlowJob?.cancel() + booksFlowJob = launch { + when (groupId) { + AppConst.bookGroupAllId -> appDb.bookDao.flowAll() + AppConst.bookGroupLocalId -> appDb.bookDao.flowLocal() + AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio() + AppConst.bookGroupNoneId -> appDb.bookDao.flowNoGroup() + else -> appDb.bookDao.flowByGroup(groupId) + }.collect { list -> + val booksDownload = list.filter { + it.isOnLineTxt() + } + val books = when (getPrefInt(PreferKey.bookshelfSort)) { + 1 -> booksDownload.sortedByDescending { it.latestChapterTime } + 2 -> booksDownload.sortedWith { o1, o2 -> + o1.name.cnCompare(o2.name) + } + 3 -> booksDownload.sortedBy { it.order } + else -> booksDownload.sortedByDescending { it.durChapterTime } + } + adapter.setItems(books) + initCacheSize(books) + } + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun initGroupData() { + launch { + appDb.bookGroupDao.flowAll().collect { + groupList.clear() + groupList.addAll(it) + adapter.notifyDataSetChanged() + upMenu() + } + } + } + + private fun initCacheSize(books: List) { + launch(IO) { + books.forEach { book -> + val chapterCaches = hashSetOf() + val cacheNames = BookHelp.getChapterFiles(book) + appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter -> + if (cacheNames.contains(chapter.getFileName())) { + chapterCaches.add(chapter.url) + } + } + adapter.cacheChapters[book.bookUrl] = chapterCaches + withContext(Dispatchers.Main) { + adapter.notifyItemRangeChanged(0, adapter.itemCount, true) + } + } + } + } + + override fun observeLiveBus() { + viewModel.upAdapterLiveData.observe(this) { + adapter.getItems().forEachIndexed { index, book -> + if (book.bookUrl == it) { + adapter.notifyItemChanged(index, true) + } + } + } + observeEvent(EventBus.UP_DOWNLOAD) { + if (!CacheBook.isRun) { + menu?.findItem(R.id.menu_download)?.let { item -> + item.setIcon(R.drawable.ic_play_24dp) + item.setTitle(R.string.download_start) + } + menu?.applyTint(this) + } else { + menu?.findItem(R.id.menu_download)?.let { item -> + item.setIcon(R.drawable.ic_stop_black_24dp) + item.setTitle(R.string.stop) + } + menu?.applyTint(this) + } + adapter.notifyItemRangeChanged(0, adapter.itemCount, true) + } + observeEvent(EventBus.SAVE_CONTENT) { + adapter.cacheChapters[it.bookUrl]?.add(it.url) + } + } + + override fun export(position: Int) { + val path = ACache.get(this@CacheActivity).getAsString(exportBookPathKey) + if (path.isNullOrEmpty()) { + selectExportFolder(position) + } else { + startExport(path, position) + } + } + + private fun exportAll() { + val path = ACache.get(this@CacheActivity).getAsString(exportBookPathKey) + if (path.isNullOrEmpty()) { + selectExportFolder(-10) + } else { + startExport(path, -10) + } + } + + private fun selectExportFolder(exportPosition: Int) { + val default = arrayListOf>() + val path = ACache.get(this@CacheActivity).getAsString(exportBookPathKey) + if (!path.isNullOrEmpty()) { + default.add(SelectItem(path, -1)) + } + exportDir.launch { + otherActions = default + requestCode = exportPosition + } + } + + private fun startExport(path: String, exportPosition: Int) { + if (exportPosition == -10) { + if (adapter.getItems().isNotEmpty()) { + adapter.getItems().forEach { book -> + when (AppConfig.exportType) { + 1 -> viewModel.exportEPUB(path, book) + else -> viewModel.export(path, book) + } + } + } else { + toastOnUi(R.string.no_book) + } + } else if (exportPosition >= 0) { + adapter.getItem(exportPosition)?.let { book -> + when (AppConfig.exportType) { + 1 -> viewModel.exportEPUB(path, book) + else -> viewModel.export(path, book) + } + } + } + } + + @SuppressLint("SetTextI18n") + private fun alertExportFileName() { + alert(R.string.export_file_name) { + setMessage("js内有name和author变量,返回书名") + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "file name js" + editView.setText(AppConfig.bookExportFileName) + } + customView { alertBinding.root } + okButton { + AppConfig.bookExportFileName = alertBinding.editView.text?.toString() + } + cancelButton() + } + } + + private fun getTypeName(): String { + return exportTypes.getOrElse(AppConfig.exportType) { + exportTypes[0] + } + } + + private fun showExportTypeConfig() { + selector(R.string.export_type, exportTypes) { _, i -> + AppConfig.exportType = i + } + } + + private fun showCharsetConfig() { + alert(R.string.set_charset) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "charset name" + editView.setFilterValues(charsets) + editView.setText(AppConfig.exportCharset) + } + customView { alertBinding.root } + okButton { + AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8" + } + cancelButton() + } + } + + override fun exportProgress(bookUrl: String): Int? { + return viewModel.exportProgress[bookUrl] + } + + override fun exportMsg(bookUrl: String): String? { + return viewModel.exportMsg[bookUrl] + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt new file mode 100644 index 000000000..45439f31b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt @@ -0,0 +1,110 @@ +package io.legado.app.ui.book.cache + +import android.content.Context +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.Book +import io.legado.app.databinding.ItemDownloadBinding +import io.legado.app.model.CacheBook +import io.legado.app.utils.gone +import io.legado.app.utils.visible + +class CacheAdapter(context: Context, private val callBack: CallBack) : + RecyclerAdapter(context) { + + val cacheChapters = hashMapOf>() + + override fun getViewBinding(parent: ViewGroup): ItemDownloadBinding { + return ItemDownloadBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemDownloadBinding, + item: Book, + payloads: MutableList + ) { + binding.run { + if (payloads.isEmpty()) { + tvName.text = item.name + tvAuthor.text = context.getString(R.string.author_show, item.getRealAuthor()) + val cs = cacheChapters[item.bookUrl] + if (cs == null) { + tvDownload.setText(R.string.loading) + } else { + tvDownload.text = + context.getString(R.string.download_count, cs.size, item.totalChapterNum) + } + } else { + val cacheSize = cacheChapters[item.bookUrl]?.size ?: 0 + tvDownload.text = + context.getString(R.string.download_count, cacheSize, item.totalChapterNum) + } + upDownloadIv(ivDownload, item) + upExportInfo(tvMsg, progressExport, item) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemDownloadBinding) { + binding.run { + ivDownload.setOnClickListener { + getItem(holder.layoutPosition)?.let { book -> + CacheBook.cacheBookMap[book.bookUrl]?.let { + if (it.isRun()) { + CacheBook.remove(context, book.bookUrl) + } else { + CacheBook.start(context, book.bookUrl, 0, book.totalChapterNum) + } + } ?: let { + CacheBook.start(context, book.bookUrl, 0, book.totalChapterNum) + } + } + } + tvExport.setOnClickListener { + callBack.export(holder.layoutPosition) + } + } + } + + private fun upDownloadIv(iv: ImageView, book: Book) { + CacheBook.cacheBookMap[book.bookUrl]?.let { + if (it.isRun()) { + iv.setImageResource(R.drawable.ic_stop_black_24dp) + } else { + iv.setImageResource(R.drawable.ic_play_24dp) + } + } ?: let { + iv.setImageResource(R.drawable.ic_play_24dp) + } + } + + private fun upExportInfo(msgView: TextView, progressView: ProgressBar, book: Book) { + val msg = callBack.exportMsg(book.bookUrl) + if (msg != null) { + msgView.text = msg + msgView.visible() + progressView.gone() + return + } + msgView.gone() + val progress = callBack.exportProgress(book.bookUrl) + if (progress != null) { + progressView.max = book.totalChapterNum + progressView.progress = progress + progressView.visible() + return + } + progressView.gone() + } + + interface CallBack { + fun export(position: Int) + fun exportProgress(bookUrl: String): Int? + fun exportMsg(bookUrl: String): String? + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt new file mode 100644 index 000000000..e21157ade --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt @@ -0,0 +1,489 @@ +package io.legado.app.ui.book.cache + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.MutableLiveData +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.help.ContentProcessor +import io.legado.app.help.storage.AppWebDav +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ensureActive +import me.ag2s.epublib.domain.* +import me.ag2s.epublib.epub.EpubWriter +import me.ag2s.epublib.util.ResourceUtil +import splitties.init.appCtx +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.Charset +import java.util.concurrent.ConcurrentHashMap +import javax.script.SimpleBindings + + +class CacheViewModel(application: Application) : BaseViewModel(application) { + val upAdapterLiveData = MutableLiveData() + val exportProgress = ConcurrentHashMap() + val exportMsg = ConcurrentHashMap() + + private fun getExportFileName(book: Book): String { + val jsStr = AppConfig.bookExportFileName + if (jsStr.isNullOrBlank()) { + return "${book.name} 作者:${book.getRealAuthor()}" + } + val bindings = SimpleBindings() + bindings["name"] = book.name + bindings["author"] = book.getRealAuthor() + return AppConst.SCRIPT_ENGINE.eval(jsStr, bindings).toString() + } + + fun export(path: String, book: Book) { + if (exportProgress.contains(book.bookUrl)) return + exportProgress[book.bookUrl] = 0 + exportMsg.remove(book.bookUrl) + upAdapterLiveData.postValue(book.bookUrl) + execute { + if (path.isContentScheme()) { + val uri = Uri.parse(path) + val doc = DocumentFile.fromTreeUri(context, uri) + ?: throw NoStackTraceException("获取导出文档失败") + export(this, doc, book) + } else { + export(this, FileUtils.createFolderIfNotExist(path), book) + } + }.onError { + exportProgress.remove(book.bookUrl) + exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR" + upAdapterLiveData.postValue(book.bookUrl) + Timber.e(it) + }.onSuccess { + exportProgress.remove(book.bookUrl) + exportMsg[book.bookUrl] = context.getString(R.string.export_success) + upAdapterLiveData.postValue(book.bookUrl) + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun export(scope: CoroutineScope, doc: DocumentFile, book: Book) { + val filename = "${getExportFileName(book)}.txt" + DocumentUtils.delete(doc, filename) + val bookDoc = DocumentUtils.createFileIfNotExist(doc, filename) + ?: throw NoStackTraceException("创建文档失败") + val stringBuilder = StringBuilder() + context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs -> + getAllContents(scope, book) { text, srcList -> + bookOs.write(text.toByteArray(Charset.forName(AppConfig.exportCharset))) + stringBuilder.append(text) + srcList?.forEach { + val vFile = BookHelp.getImage(book, it.third) + if (vFile.exists()) { + DocumentUtils.createFileIfNotExist( + doc, + "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg", + subDirs = arrayOf("${book.name}_${book.author}", "images", it.first) + )?.writeBytes(context, vFile.readBytes()) + } + } + } + } + if (AppConfig.exportToWebDav) { + // 导出到webdav + val byteArray = + stringBuilder.toString().toByteArray(Charset.forName(AppConfig.exportCharset)) + AppWebDav.exportWebDav(byteArray, filename) + } + } + + private suspend fun export(scope: CoroutineScope, file: File, book: Book) { + val filename = "${getExportFileName(book)}.txt" + val bookPath = FileUtils.getPath(file, filename) + val bookFile = FileUtils.createFileWithReplace(bookPath) + val stringBuilder = StringBuilder() + getAllContents(scope, book) { text, srcList -> + bookFile.appendText(text, Charset.forName(AppConfig.exportCharset)) + stringBuilder.append(text) + srcList?.forEach { + val vFile = BookHelp.getImage(book, it.third) + if (vFile.exists()) { + FileUtils.createFileIfNotExist( + file, + "${book.name}_${book.author}", + "images", + it.first, + "${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg" + ).writeBytes(vFile.readBytes()) + } + } + } + if (AppConfig.exportToWebDav) { + val byteArray = + stringBuilder.toString().toByteArray(Charset.forName(AppConfig.exportCharset)) + AppWebDav.exportWebDav(byteArray, filename) // 导出到webdav + } + } + + private fun getAllContents( + scope: CoroutineScope, + book: Book, + append: (text: String, srcList: ArrayList>?) -> Unit + ) { + val useReplace = AppConfig.exportUseReplace + val contentProcessor = ContentProcessor.get(book.name, book.origin) + val qy = "${book.name}\n${ + context.getString(R.string.author_show, book.getRealAuthor()) + }\n${ + context.getString( + R.string.intro_show, + "\n" + HtmlFormatter.format(book.getDisplayIntro()) + ) + }" + append(qy, null) + appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter -> + scope.ensureActive() + upAdapterLiveData.postValue(book.bookUrl) + exportProgress[book.bookUrl] = index + BookHelp.getContent(book, chapter).let { content -> + val content1 = contentProcessor + .getContent( + book, + chapter, + content ?: "null", + includeTitle = !AppConfig.exportNoChapterName, + useReplace = useReplace, + chineseConvert = false, + reSegment = false + ).joinToString("\n") + val srcList = arrayListOf>() + content?.split("\n")?.forEachIndexed { index, text -> + val matcher = AppPattern.imgPattern.matcher(text) + while (matcher.find()) { + matcher.group(1)?.let { + val src = NetworkUtils.getAbsoluteURL(chapter.url, it) + srcList.add(Triple(chapter.title, index, src)) + } + } + } + append.invoke("\n\n$content1", srcList) + } + } + } + + //////////////////Start EPUB + /** + * 导出Epub + */ + fun exportEPUB(path: String, book: Book) { + if (exportProgress.contains(book.bookUrl)) return + exportProgress[book.bookUrl] = 0 + exportMsg.remove(book.bookUrl) + upAdapterLiveData.postValue(book.bookUrl) + execute { + if (path.isContentScheme()) { + val uri = Uri.parse(path) + val doc = DocumentFile.fromTreeUri(context, uri) + ?: throw NoStackTraceException("获取导出文档失败") + exportEpub(this, doc, book) + } else { + exportEpub(this, FileUtils.createFolderIfNotExist(path), book) + } + }.onError { + exportProgress.remove(book.bookUrl) + exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR" + upAdapterLiveData.postValue(book.bookUrl) + Timber.e(it) + }.onSuccess { + exportProgress.remove(book.bookUrl) + exportMsg[book.bookUrl] = context.getString(R.string.export_success) + upAdapterLiveData.postValue(book.bookUrl) + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private fun exportEpub(scope: CoroutineScope, doc: DocumentFile, book: Book) { + val filename = "${getExportFileName(book)}.epub" + DocumentUtils.delete(doc, filename) + val epubBook = EpubBook() + epubBook.version = "2.0" + //set metadata + setEpubMetadata(book, epubBook) + //set cover + setCover(book, epubBook) + //set css + val contentModel = setAssets(doc, book, epubBook) + + //设置正文 + setEpubContent(scope, contentModel, book, epubBook) + DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc -> + context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs -> + EpubWriter().write(epubBook, bookOs) + } + + } + } + + + private fun exportEpub(scope: CoroutineScope, file: File, book: Book) { + val filename = "${getExportFileName(book)}.epub" + val epubBook = EpubBook() + epubBook.version = "2.0" + //set metadata + setEpubMetadata(book, epubBook) + //set cover + setCover(book, epubBook) + //set css + val contentModel = setAssets(book, epubBook) + + val bookPath = FileUtils.getPath(file, filename) + val bookFile = FileUtils.createFileWithReplace(bookPath) + //设置正文 + setEpubContent(scope, contentModel, book, epubBook) + EpubWriter().write(epubBook, FileOutputStream(bookFile)) + } + + private fun setAssets(doc: DocumentFile, book: Book, epubBook: EpubBook): String { + if (!(AppConfig.isGooglePlay || appCtx.packageName.contains( + "debug", + true + )) + ) return setAssets(book, epubBook) + + var contentModel = "" + DocumentUtils.getDirDocument(doc, "Asset").let { customPath -> + if (customPath == null) {//使用内置模板 + contentModel = setAssets(book, epubBook) + } else {//外部模板 + customPath.listFiles().forEach { folder -> + if (folder.isDirectory && folder.name == "Text") { + folder.listFiles().sortedWith { o1, o2 -> + val name1 = o1.name ?: "" + val name2 = o2.name ?: "" + name1.cnCompare(name2) + }.forEach { file -> + if (file.isFile) { + when { + //正文模板 + file.name.equals("chapter.html", true) + || file.name.equals("chapter.xhtml", true) -> { + contentModel = file.readText(context) + } + //封面等其他模板 + true == file.name?.endsWith("html", true) -> { + epubBook.addSection( + FileUtils.getNameExcludeExtension( + file.name ?: "Cover.html" + ), + ResourceUtil.createPublicResource( + book.name, + book.getRealAuthor(), + book.getDisplayIntro(), + book.kind, + book.wordCount, + file.readText(context), + "${folder.name}/${file.name}" + ) + ) + } + else -> { + //其他格式文件当做资源文件 + folder.listFiles().forEach { + if (it.isFile) + epubBook.resources.add( + Resource( + it.readBytes(context), + "${folder.name}/${it.name}" + ) + ) + } + } + } + } + } + } else if (folder.isDirectory) { + //资源文件 + folder.listFiles().forEach { + if (it.isFile) + epubBook.resources.add( + Resource( + it.readBytes(context), + "${folder.name}/${it.name}" + ) + ) + } + } else {//Asset下面的资源文件 + epubBook.resources.add( + Resource( + folder.readBytes(context), + "${folder.name}" + ) + ) + } + } + } + } + + return contentModel + } + + private fun setAssets(book: Book, epubBook: EpubBook): String { + epubBook.resources.add( + Resource( + appCtx.assets.open("epub/fonts.css").readBytes(), + "Styles/fonts.css" + ) + ) + epubBook.resources.add( + Resource( + appCtx.assets.open("epub/main.css").readBytes(), + "Styles/main.css" + ) + ) + epubBook.resources.add( + Resource( + appCtx.assets.open("epub/logo.png").readBytes(), + "Images/logo.png" + ) + ) + epubBook.addSection( + context.getString(R.string.img_cover), + ResourceUtil.createPublicResource( + book.name, + book.getRealAuthor(), + book.getDisplayIntro(), + book.kind, + book.wordCount, + String(appCtx.assets.open("epub/cover.html").readBytes()), + "Text/cover.html" + ) + ) + epubBook.addSection( + context.getString(R.string.book_intro), + ResourceUtil.createPublicResource( + book.name, + book.getRealAuthor(), + book.getDisplayIntro(), + book.kind, + book.wordCount, + String(appCtx.assets.open("epub/intro.html").readBytes()), + "Text/intro.html" + ) + ) + return String(appCtx.assets.open("epub/chapter.html").readBytes()) + } + + private fun setCover(book: Book, epubBook: EpubBook) { + Glide.with(context) + .asBitmap() + .load(book.getDisplayCover()) + .into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + val stream = ByteArrayOutputStream() + resource.compress(Bitmap.CompressFormat.JPEG, 100, stream) + val byteArray: ByteArray = stream.toByteArray() + resource.recycle() + stream.close() + epubBook.coverImage = Resource(byteArray, "Images/cover.jpg") + } + + override fun onLoadCleared(placeholder: Drawable?) { + } + }) + } + + private fun setEpubContent( + scope: CoroutineScope, + contentModel: String, + book: Book, + epubBook: EpubBook + ) { + //正文 + val useReplace = AppConfig.exportUseReplace + val contentProcessor = ContentProcessor.get(book.name, book.origin) + appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter -> + scope.ensureActive() + upAdapterLiveData.postValue(book.bookUrl) + exportProgress[book.bookUrl] = index + BookHelp.getContent(book, chapter).let { content -> + var content1 = fixPic(epubBook, book, content ?: "null", chapter) + content1 = contentProcessor + .getContent( + book, + chapter, + content1, + includeTitle = false, + useReplace = useReplace, + chineseConvert = false, + reSegment = false + ) + .joinToString("\n") + epubBook.addSection( + chapter.title, + ResourceUtil.createChapterResource( + chapter.title.replace("\uD83D\uDD12", ""), + content1, + contentModel, + "Text/chapter_${index}.html" + ) + ) + } + } + } + + private fun fixPic( + epubBook: EpubBook, + book: Book, + content: String, + chapter: BookChapter + ): String { + val data = StringBuilder("") + content.split("\n").forEach { text -> + var text1 = text + val matcher = AppPattern.imgPattern.matcher(text) + while (matcher.find()) { + matcher.group(1)?.let { + val src = NetworkUtils.getAbsoluteURL(chapter.url, it) + val originalHref = "${MD5Utils.md5Encode16(src)}${BookHelp.getImageSuffix(src)}" + val href = "Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}" + val vFile = BookHelp.getImage(book, src) + val fp = FileResourceProvider(vFile.parent) + if (vFile.exists()) { + val img = LazyResource(fp, href, originalHref) + epubBook.resources.add(img) + } + text1 = text1.replace(src, "../${href}") + } + } + data.append(text1).append("\n") + } + return data.toString() + } + + private fun setEpubMetadata(book: Book, epubBook: EpubBook) { + val metadata = Metadata() + metadata.titles.add(book.name)//书籍的名称 + metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者 + metadata.language = "zh"//数据的语言 + metadata.dates.add(Date())//数据的创建日期 + metadata.publishers.add("Legado")//数据的创建者 + metadata.descriptions.add(book.getDisplayIntro())//书籍的简介 + //metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍 + epubBook.metadata = metadata + } + + //////end of EPUB +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt new file mode 100644 index 000000000..204adc35a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverDialog.kt @@ -0,0 +1,99 @@ +package io.legado.app.ui.book.changecover + +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.GridLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogChangeCoverBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.applyTint +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class ChangeCoverDialog() : BaseDialogFragment(R.layout.dialog_change_cover), + Toolbar.OnMenuItemClickListener, + CoverAdapter.CallBack { + + constructor(name: String, author: String) : this() { + arguments = Bundle().apply { + putString("name", name) + putString("author", author) + } + } + + private val binding by viewBinding(DialogChangeCoverBinding::bind) + private val callBack: CallBack? get() = activity as? CallBack + private val viewModel: ChangeCoverViewModel by viewModels() + private val adapter by lazy { CoverAdapter(requireContext(), this) } + + private val startStopMenuItem: MenuItem? + get() = binding.toolBar.menu.findItem(R.id.menu_start_stop) + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.change_cover_source) + viewModel.initData(arguments) + initMenu() + initView() + } + + private fun initMenu() { + binding.toolBar.inflateMenu(R.menu.change_cover) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + } + + private fun initView() { + binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 3) + binding.recyclerView.adapter = adapter + viewModel.loadDbSearchBook() + } + + override fun observeLiveBus() { + super.observeLiveBus() + viewModel.searchStateData.observe(viewLifecycleOwner, { + binding.refreshProgressBar.isAutoLoading = it + if (it) { + startStopMenuItem?.let { item -> + item.setIcon(R.drawable.ic_stop_black_24dp) + item.setTitle(R.string.stop) + } + } else { + startStopMenuItem?.let { item -> + item.setIcon(R.drawable.ic_refresh_black_24dp) + item.setTitle(R.string.refresh) + } + } + binding.toolBar.menu.applyTint(requireContext()) + }) + viewModel.searchBooksLiveData.observe(viewLifecycleOwner, { + adapter.setItems(it) + }) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_start_stop -> viewModel.startOrStopSearch() + } + return false + } + + override fun changeTo(coverUrl: String) { + callBack?.coverChangeTo(coverUrl) + dismissAllowingStateLoss() + } + + interface CallBack { + fun coverChangeTo(coverUrl: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt new file mode 100644 index 000000000..9c39f7375 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt @@ -0,0 +1,165 @@ +package io.legado.app.ui.book.changecover + +import android.app.Application +import android.os.Bundle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.CompositeCoroutine +import io.legado.app.model.webBook.WebBook +import kotlinx.coroutines.* +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import kotlin.math.min + +class ChangeCoverViewModel(application: Application) : BaseViewModel(application) { + private val threadCount = AppConfig.threadCount + private var searchPool: ExecutorCoroutineDispatcher? = null + private var upAdapterJob: Job? = null + var name: String = "" + var author: String = "" + private var tasks = CompositeCoroutine() + private var bookSourceList = arrayListOf() + val searchStateData = MutableLiveData() + val searchBooksLiveData = MutableLiveData>() + private val searchBooks = CopyOnWriteArraySet() + private var postTime = 0L + + @Volatile + private var searchIndex = -1 + + fun initData(arguments: Bundle?) { + arguments?.let { bundle -> + bundle.getString("name")?.let { + name = it + } + bundle.getString("author")?.let { + author = it.replace(AppPattern.authorRegex, "") + } + } + } + + private fun initSearchPool() { + searchPool = Executors + .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + searchIndex = -1 + } + + fun loadDbSearchBook() { + execute { + appDb.searchBookDao.getEnableHasCover(name, author).let { + searchBooks.addAll(it) + searchBooksLiveData.postValue(searchBooks.toList()) + if (it.size <= 1) { + startSearch() + } + } + } + } + + @Synchronized + private fun upAdapter() { + if (System.currentTimeMillis() >= postTime + 500) { + upAdapterJob?.cancel() + postTime = System.currentTimeMillis() + val books = searchBooks.toList() + searchBooksLiveData.postValue(books.sortedBy { it.originOrder }) + } else { + upAdapterJob?.cancel() + upAdapterJob = viewModelScope.launch { + delay(500) + upAdapter() + } + } + } + + private fun startSearch() { + execute { + stopSearch() + bookSourceList.clear() + bookSourceList.addAll(appDb.bookSourceDao.allEnabled) + searchStateData.postValue(true) + initSearchPool() + for (i in 0 until threadCount) { + search() + } + } + } + + @Synchronized + private fun search() { + if (searchIndex >= bookSourceList.lastIndex) { + return + } + searchIndex++ + val source = bookSourceList[searchIndex] + if (source.getSearchRule().coverUrl.isNullOrBlank()) { + searchNext() + return + } + val task = WebBook + .searchBook(viewModelScope, source, name, context = searchPool!!) + .timeout(60000L) + .onSuccess(searchPool) { + if (it.isNotEmpty()) { + val searchBook = it[0] + if (searchBook.name == name && searchBook.author == author + && !searchBook.coverUrl.isNullOrEmpty() + ) { + appDb.searchBookDao.insert(searchBook) + if (!searchBooks.contains(searchBook)) { + searchBooks.add(searchBook) + upAdapter() + } + } + } + } + .onFinally(searchPool) { + searchNext() + } + tasks.add(task) + } + + @Synchronized + private fun searchNext() { + if (searchIndex < bookSourceList.lastIndex) { + search() + } else { + searchIndex++ + } + if (searchIndex >= bookSourceList.lastIndex + min( + bookSourceList.size, + threadCount + ) + ) { + searchStateData.postValue(false) + tasks.clear() + } + } + + fun startOrStopSearch() { + if (tasks.isEmpty) { + startSearch() + } else { + stopSearch() + } + } + + fun stopSearch() { + tasks.clear() + searchPool?.close() + searchStateData.postValue(false) + } + + override fun onCleared() { + super.onCleared() + searchPool?.close() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changecover/CoverAdapter.kt b/app/src/main/java/io/legado/app/ui/book/changecover/CoverAdapter.kt new file mode 100644 index 000000000..323dbfc05 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changecover/CoverAdapter.kt @@ -0,0 +1,55 @@ +package io.legado.app.ui.book.changecover + +import android.content.Context +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.base.adapter.DiffRecyclerAdapter +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.data.entities.SearchBook +import io.legado.app.databinding.ItemCoverBinding + + +class CoverAdapter(context: Context, val callBack: CallBack) : + DiffRecyclerAdapter(context) { + + override val diffItemCallback: DiffUtil.ItemCallback + get() = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { + return oldItem.bookUrl == newItem.bookUrl + } + + override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { + return oldItem.originName == newItem.originName + && oldItem.coverUrl == newItem.coverUrl + } + + } + + override fun getViewBinding(parent: ViewGroup): ItemCoverBinding { + return ItemCoverBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemCoverBinding, + item: SearchBook, + payloads: MutableList + ) = binding.run { + ivCover.load(item.coverUrl, item.name, item.author) + tvSource.text = item.originName + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemCoverBinding) { + holder.itemView.apply { + setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.changeTo(it.coverUrl ?: "") + } + } + } + } + + interface CallBack { + fun changeTo(coverUrl: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceAdapter.kt new file mode 100644 index 000000000..0da75c27f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceAdapter.kt @@ -0,0 +1,122 @@ +package io.legado.app.ui.book.changesource + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.R +import io.legado.app.base.adapter.DiffRecyclerAdapter +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.data.entities.SearchBook +import io.legado.app.databinding.ItemChangeSourceBinding +import io.legado.app.utils.invisible +import io.legado.app.utils.visible +import splitties.views.onLongClick + + +class ChangeSourceAdapter( + context: Context, + val viewModel: ChangeSourceViewModel, + val callBack: CallBack +) : DiffRecyclerAdapter(context) { + + override val diffItemCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { + return oldItem.bookUrl == newItem.bookUrl + } + + override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { + return oldItem.originName == newItem.originName + && oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle() + } + + } + + override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding { + return ItemChangeSourceBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemChangeSourceBinding, + item: SearchBook, + payloads: MutableList + ) { + val bundle = payloads.getOrNull(0) as? Bundle + binding.apply { + if (bundle == null) { + tvOrigin.text = item.originName + tvAuthor.text = item.author + tvLast.text = item.getDisplayLastChapterTitle() + if (callBack.bookUrl == item.bookUrl) { + ivChecked.visible() + } else { + ivChecked.invisible() + } + } else { + bundle.keySet().map { + when (it) { + "name" -> tvOrigin.text = item.originName + "latest" -> tvLast.text = item.getDisplayLastChapterTitle() + "upCurSource" -> if (callBack.bookUrl == item.bookUrl) { + ivChecked.visible() + } else { + ivChecked.invisible() + } + } + } + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.changeTo(it) + } + } + holder.itemView.onLongClick { + showMenu(holder.itemView, getItem(holder.layoutPosition)) + } + } + + private fun showMenu(view: View, searchBook: SearchBook?) { + searchBook ?: return + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.change_source_item) + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_top_source -> { + callBack.topSource(searchBook) + } + R.id.menu_bottom_source -> { + callBack.bottomSource(searchBook) + } + R.id.menu_edit_source -> { + callBack.editSource(searchBook) + } + R.id.menu_disable_source -> { + callBack.disableSource(searchBook) + } + R.id.menu_delete_source -> { + callBack.deleteSource(searchBook) + updateItems(0, itemCount, listOf()) + } + } + true + } + popupMenu.show() + } + + interface CallBack { + val bookUrl: String? + fun changeTo(searchBook: SearchBook) + fun topSource(searchBook: SearchBook) + fun bottomSource(searchBook: SearchBook) + fun editSource(searchBook: SearchBook) + fun disableSource(searchBook: SearchBook) + fun deleteSource(searchBook: SearchBook) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceDialog.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceDialog.kt new file mode 100644 index 000000000..918c8b4f5 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceDialog.kt @@ -0,0 +1,285 @@ +package io.legado.app.ui.book.changesource + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.AppPattern +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.databinding.DialogChangeSourceBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class ChangeSourceDialog() : BaseDialogFragment(R.layout.dialog_change_source), + Toolbar.OnMenuItemClickListener, + ChangeSourceAdapter.CallBack { + + constructor(name: String, author: String) : this() { + arguments = Bundle().apply { + putString("name", name) + putString("author", author) + } + } + + private val binding by viewBinding(DialogChangeSourceBinding::bind) + private val groups = linkedSetOf() + private val callBack: CallBack? get() = activity as? CallBack + private val viewModel: ChangeSourceViewModel by viewModels() + private val adapter by lazy { ChangeSourceAdapter(requireContext(), viewModel, this) } + private val editSourceResult = + registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { + viewModel.startSearch() + } + + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + viewModel.initData(arguments) + showTitle() + initMenu() + initRecyclerView() + initSearchView() + initLiveData() + viewModel.loadDbSearchBook() + } + + private fun showTitle() { + binding.toolBar.title = viewModel.name + binding.toolBar.subtitle = viewModel.author + } + + private fun initMenu() { + binding.toolBar.inflateMenu(R.menu.change_source) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + binding.toolBar.menu.findItem(R.id.menu_check_author) + ?.isChecked = AppConfig.changeSourceCheckAuthor + binding.toolBar.menu.findItem(R.id.menu_load_info) + ?.isChecked = AppConfig.changeSourceLoadInfo + binding.toolBar.menu.findItem(R.id.menu_load_toc) + ?.isChecked = AppConfig.changeSourceLoadToc + } + + private fun initRecyclerView() { + binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) + binding.recyclerView.adapter = adapter + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0) { + binding.recyclerView.scrollToPosition(0) + } + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + if (toPosition == 0) { + binding.recyclerView.scrollToPosition(0) + } + } + }) + } + + private fun initSearchView() { + val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView + searchView.setOnCloseListener { + showTitle() + false + } + searchView.setOnSearchClickListener { + binding.toolBar.title = "" + binding.toolBar.subtitle = "" + } + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.screen(newText) + return false + } + + }) + } + + private fun initLiveData() { + viewModel.searchStateData.observe(viewLifecycleOwner, { + binding.refreshProgressBar.isAutoLoading = it + if (it) { + startStopMenuItem?.let { item -> + item.setIcon(R.drawable.ic_stop_black_24dp) + item.setTitle(R.string.stop) + } + } else { + startStopMenuItem?.let { item -> + item.setIcon(R.drawable.ic_refresh_black_24dp) + item.setTitle(R.string.refresh) + } + } + binding.toolBar.menu.applyTint(requireContext()) + }) + viewModel.searchBooksLiveData.observe(viewLifecycleOwner, { + adapter.setItems(it) + }) + launch { + appDb.bookSourceDao.flowGroupEnabled().collect { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupMenu() + } + } + } + + private val startStopMenuItem: MenuItem? + get() = binding.toolBar.menu.findItem(R.id.menu_start_stop) + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_check_author -> { + AppConfig.changeSourceCheckAuthor = !item.isChecked + item.isChecked = !item.isChecked + viewModel.loadDbSearchBook() + } + R.id.menu_load_toc -> { + putPrefBoolean(PreferKey.changeSourceLoadToc, !item.isChecked) + item.isChecked = !item.isChecked + } + R.id.menu_load_info -> { + putPrefBoolean(PreferKey.changeSourceLoadInfo, !item.isChecked) + item.isChecked = !item.isChecked + } + R.id.menu_start_stop -> viewModel.startOrStopSearch() + R.id.menu_source_manage -> startActivity() + else -> if (item?.groupId == R.id.source_group) { + if (!item.isChecked) { + item.isChecked = true + if (item.title.toString() == getString(R.string.all_source)) { + putPrefString("searchGroup", "") + } else { + putPrefString("searchGroup", item.title.toString()) + } + viewModel.startOrStopSearch() + viewModel.loadDbSearchBook() + } + } + } + return false + } + + override fun changeTo(searchBook: SearchBook) { + changeSource(searchBook) + dismissAllowingStateLoss() + } + + override val bookUrl: String? + get() = callBack?.oldBook?.bookUrl + + override fun topSource(searchBook: SearchBook) { + viewModel.topSource(searchBook) + } + + override fun bottomSource(searchBook: SearchBook) { + viewModel.bottomSource(searchBook) + } + + override fun editSource(searchBook: SearchBook) { + editSourceResult.launch { + putExtra("sourceUrl", searchBook.origin) + } + } + + override fun disableSource(searchBook: SearchBook) { + viewModel.disableSource(searchBook) + } + + override fun deleteSource(searchBook: SearchBook) { + viewModel.del(searchBook) + if (bookUrl == searchBook.bookUrl) { + viewModel.firstSourceOrNull(searchBook)?.let { + changeSource(it) + } + } + } + + private fun changeSource(searchBook: SearchBook) { + try { + val book = searchBook.toBook() + book.upInfoFromOld(callBack?.oldBook) + val source = appDb.bookSourceDao.getBookSource(book.origin) + callBack?.changeTo(source!!, book) + searchBook.time = System.currentTimeMillis() + viewModel.updateSource(searchBook) + } catch (e: Exception) { + toastOnUi("换源失败\n${e.localizedMessage}") + } + } + + /** + * 更新分组菜单 + */ + private fun upGroupMenu() { + val menu: Menu = binding.toolBar.menu + val selectedGroup = getPrefString("searchGroup") + menu.removeGroup(R.id.source_group) + val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source) + var hasSelectedGroup = false + groups.sortedWith { o1, o2 -> + o1.cnCompare(o2) + }.forEach { group -> + menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let { + if (group == selectedGroup) { + it.isChecked = true + hasSelectedGroup = true + } + } + } + menu.setGroupCheckable(R.id.source_group, true, true) + if (!hasSelectedGroup) { + allItem.isChecked = true + } + } + + override fun observeLiveBus() { + observeEvent(EventBus.SOURCE_CHANGED) { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + bundleOf(Pair("upCurSource", bookUrl)) + ) + } + } + + interface CallBack { + val oldBook: Book? + fun changeTo(source: BookSource, book: Book) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceViewModel.kt new file mode 100644 index 000000000..9249edc92 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceViewModel.kt @@ -0,0 +1,304 @@ +package io.legado.app.ui.book.changesource + +import android.app.Application +import android.os.Bundle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppPattern +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.CompositeCoroutine +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefString + +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import splitties.init.appCtx +import timber.log.Timber +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import kotlin.math.min + +@Suppress("MemberVisibilityCanBePrivate") +class ChangeSourceViewModel(application: Application) : BaseViewModel(application) { + private val threadCount = AppConfig.threadCount + private var searchPool: ExecutorCoroutineDispatcher? = null + private var upAdapterJob: Job? = null + val searchStateData = MutableLiveData() + val searchBooksLiveData = MutableLiveData>() + var name: String = "" + var author: String = "" + private var tasks = CompositeCoroutine() + private var screenKey: String = "" + private var bookSourceList = arrayListOf() + private val searchBooks = CopyOnWriteArraySet() + private var postTime = 0L + private val searchGroup get() = appCtx.getPrefString("searchGroup") ?: "" + + @Volatile + private var searchIndex = -1 + + fun initData(arguments: Bundle?) { + arguments?.let { bundle -> + bundle.getString("name")?.let { + name = it + } + bundle.getString("author")?.let { + author = it.replace(AppPattern.authorRegex, "") + } + } + } + + private fun initSearchPool() { + searchPool = Executors + .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + searchIndex = -1 + } + + fun loadDbSearchBook() { + execute { + searchBooks.clear() + upAdapter() + val sbs = if (AppConfig.changeSourceCheckAuthor) { + appDb.searchBookDao.getChangeSourceSearch(name, author, searchGroup) + } else { + appDb.searchBookDao.getChangeSourceSearch(name, "", searchGroup) + } + searchBooks.addAll(sbs) + searchBooksLiveData.postValue(searchBooks.toList()) + if (sbs.size <= 1) { + startSearch() + } + } + } + + @Synchronized + private fun upAdapter() { + if (System.currentTimeMillis() >= postTime + 500) { + upAdapterJob?.cancel() + postTime = System.currentTimeMillis() + val books = searchBooks.toList() + searchBooksLiveData.postValue(books.sortedBy { it.originOrder }) + } else { + upAdapterJob?.cancel() + upAdapterJob = viewModelScope.launch { + delay(500) + upAdapter() + } + } + } + + private fun searchFinish(searchBook: SearchBook) { + if (searchBooks.contains(searchBook)) return + appDb.searchBookDao.insert(searchBook) + if (screenKey.isEmpty()) { + searchBooks.add(searchBook) + } else if (searchBook.name.contains(screenKey)) { + searchBooks.add(searchBook) + } + upAdapter() + } + + fun startSearch() { + execute { + stopSearch() + appDb.searchBookDao.clear(name, author) + searchBooks.clear() + upAdapter() + bookSourceList.clear() + if (searchGroup.isBlank()) { + bookSourceList.addAll(appDb.bookSourceDao.allEnabled) + } else { + val sources = appDb.bookSourceDao.getEnabledByGroup(searchGroup) + if (sources.isEmpty()) { + bookSourceList.addAll(appDb.bookSourceDao.allEnabled) + } else { + bookSourceList.addAll(sources) + } + } + searchStateData.postValue(true) + initSearchPool() + for (i in 0 until threadCount) { + search() + } + } + } + + private fun search() { + synchronized(this) { + if (searchIndex >= bookSourceList.lastIndex) { + return + } + searchIndex++ + } + val source = bookSourceList[searchIndex] + val task = WebBook + .searchBook(viewModelScope, source, name, context = searchPool!!) + .timeout(60000L) + .onSuccess(searchPool) { + it.forEach { searchBook -> + if (searchBook.name == name) { + if ((AppConfig.changeSourceCheckAuthor && searchBook.author.contains(author)) + || !AppConfig.changeSourceCheckAuthor + ) { + if (searchBook.latestChapterTitle.isNullOrEmpty()) { + if (AppConfig.changeSourceLoadInfo || AppConfig.changeSourceLoadToc) { + loadBookInfo(source, searchBook.toBook()) + } else { + searchFinish(searchBook) + } + } else { + searchFinish(searchBook) + } + } + } + } + } + .onFinally(searchPool) { + synchronized(this) { + if (searchIndex < bookSourceList.lastIndex) { + search() + } else { + searchIndex++ + } + if (searchIndex >= bookSourceList.lastIndex + bookSourceList.size + || searchIndex >= bookSourceList.lastIndex + threadCount + ) { + searchStateData.postValue(false) + tasks.clear() + } + } + + } + tasks.add(task) + + } + + private fun loadBookInfo(source: BookSource, book: Book) { + WebBook.getBookInfo(viewModelScope, source, book) + .onSuccess { + if (context.getPrefBoolean(PreferKey.changeSourceLoadToc)) { + loadBookToc(source, book) + } else { + //从详情页里获取最新章节 + book.latestChapterTitle = it.latestChapterTitle + val searchBook = book.toSearchBook() + searchFinish(searchBook) + } + }.onError { + Timber.e(it) + } + } + + private fun loadBookToc(source: BookSource, book: Book) { + WebBook.getChapterList(viewModelScope, source, book) + .onSuccess(IO) { chapters -> + book.latestChapterTitle = chapters.last().title + val searchBook: SearchBook = book.toSearchBook() + searchFinish(searchBook) + }.onError { + Timber.e(it) + } + } + + /** + * 筛选 + */ + fun screen(key: String?) { + execute { + screenKey = key ?: "" + if (key.isNullOrEmpty()) { + loadDbSearchBook() + } else { + val items = + appDb.searchBookDao.getChangeSourceSearch(name, author, screenKey, searchGroup) + searchBooks.clear() + searchBooks.addAll(items) + upAdapter() + } + } + } + + fun startOrStopSearch() { + if (tasks.isEmpty) { + startSearch() + } else { + stopSearch() + } + } + + fun stopSearch() { + tasks.clear() + searchPool?.close() + searchStateData.postValue(false) + } + + override fun onCleared() { + super.onCleared() + searchPool?.close() + } + + fun disableSource(searchBook: SearchBook) { + execute { + appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> + source.enabled = false + appDb.bookSourceDao.update(source) + } + searchBooks.remove(searchBook) + upAdapter() + } + } + + fun topSource(searchBook: SearchBook) { + execute { + appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> + val minOrder = appDb.bookSourceDao.minOrder - 1 + source.customOrder = minOrder + searchBook.originOrder = source.customOrder + appDb.bookSourceDao.update(source) + updateSource(searchBook) + } + upAdapter() + } + } + + fun bottomSource(searchBook: SearchBook) { + execute { + appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> + val maxOrder = appDb.bookSourceDao.maxOrder + 1 + source.customOrder = maxOrder + searchBook.originOrder = source.customOrder + appDb.bookSourceDao.update(source) + updateSource(searchBook) + } + upAdapter() + } + } + + fun updateSource(searchBook: SearchBook) { + appDb.searchBookDao.update(searchBook) + } + + fun del(searchBook: SearchBook) { + execute { + appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> + appDb.bookSourceDao.delete(source) + appDb.searchBookDao.delete(searchBook) + } + } + searchBooks.remove(searchBook) + upAdapter() + } + + fun firstSourceOrNull(searchBook: SearchBook): SearchBook? { + return searchBooks.firstOrNull { it.bookUrl != searchBook.bookUrl } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/DiffCallBack.kt b/app/src/main/java/io/legado/app/ui/book/changesource/DiffCallBack.kt new file mode 100644 index 000000000..142ab24c2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/DiffCallBack.kt @@ -0,0 +1,50 @@ +package io.legado.app.ui.book.changesource + +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.SearchBook + +class DiffCallBack(private val oldItems: List, private val newItems: List) : + DiffUtil.Callback() { + + override fun getOldListSize(): Int { + return oldItems.size + } + + override fun getNewListSize(): Int { + return newItems.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.bookUrl == newItem.bookUrl + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return when { + oldItem.originName != newItem.originName -> false + oldItem.latestChapterTitle != newItem.latestChapterTitle -> false + else -> true + } + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + val payload = Bundle() + if (oldItem.originName != newItem.originName) { + payload.putString("name", newItem.originName) + } + if (oldItem.latestChapterTitle != newItem.latestChapterTitle) { + payload.putString("latest", newItem.latestChapterTitle) + } + if (payload.isEmpty) { + return null + } + return payload + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt new file mode 100644 index 000000000..0e9e2d06b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt @@ -0,0 +1,91 @@ +package io.legado.app.ui.book.explore + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchBook +import io.legado.app.databinding.ActivityExploreShowBinding +import io.legado.app.databinding.ViewLoadMoreBinding +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.widget.recycler.LoadMoreView +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.startActivity +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class ExploreShowActivity : VMBaseActivity(), + ExploreShowAdapter.CallBack { + override val binding by viewBinding(ActivityExploreShowBinding::inflate) + override val viewModel by viewModels() + + private val adapter by lazy { ExploreShowAdapter(this, this) } + private val loadMoreView by lazy { LoadMoreView(this) } + private var isLoading = true + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.titleBar.title = intent.getStringExtra("exploreName") + initRecyclerView() + viewModel.booksData.observe(this) { upData(it) } + viewModel.initData(intent) + viewModel.errorLiveData.observe(this) { + loadMoreView.error(it) + } + } + + private fun initRecyclerView() { + binding.recyclerView.addItemDecoration(VerticalDivider(this)) + binding.recyclerView.adapter = adapter + adapter.addFooterView { + ViewLoadMoreBinding.bind(loadMoreView) + } + loadMoreView.startLoad() + loadMoreView.setOnClickListener { + if (!isLoading) { + loadMoreView.hasMore() + scrollToBottom() + isLoading = true + } + } + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!recyclerView.canScrollVertically(1)) { + scrollToBottom() + } + } + }) + } + + private fun scrollToBottom() { + adapter.let { + if (loadMoreView.hasMore && !isLoading) { + viewModel.explore() + } + } + } + + private fun upData(books: List) { + isLoading = false + if (books.isEmpty() && adapter.isEmpty()) { + loadMoreView.noMore(getString(R.string.empty)) + } else if (books.isEmpty()) { + loadMoreView.noMore() + } else if (adapter.getItems().contains(books.first()) && adapter.getItems() + .contains(books.last()) + ) { + loadMoreView.noMore() + } else { + adapter.addItems(books) + } + } + + override fun showBookInfo(book: Book) { + startActivity { + putExtra("name", book.name) + putExtra("author", book.author) + putExtra("bookUrl", book.bookUrl) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt new file mode 100644 index 000000000..99d6b9285 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt @@ -0,0 +1,60 @@ +package io.legado.app.ui.book.explore + +import android.content.Context +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchBook +import io.legado.app.databinding.ItemSearchBinding +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +class ExploreShowAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemSearchBinding { + return ItemSearchBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemSearchBinding, + item: SearchBook, + payloads: MutableList + ) { + binding.apply { + tvName.text = item.name + tvAuthor.text = context.getString(R.string.author_show, item.author) + if (item.latestChapterTitle.isNullOrEmpty()) { + tvLasted.gone() + } else { + tvLasted.text = context.getString(R.string.lasted_show, item.latestChapterTitle) + tvLasted.visible() + } + tvIntroduce.text = item.trimIntro(context) + val kinds = item.getKindList() + if (kinds.isEmpty()) { + llKind.gone() + } else { + llKind.visible() + llKind.setLabels(kinds) + } + ivCover.load(item.coverUrl, item.name, item.author) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.showBookInfo(it.toBook()) + } + } + } + + interface CallBack { + fun showBookInfo(book: Book) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt new file mode 100644 index 000000000..acfbf2fcf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -0,0 +1,53 @@ +package io.legado.app.ui.book.explore + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.msg + +import kotlinx.coroutines.Dispatchers.IO +import timber.log.Timber + +class ExploreShowViewModel(application: Application) : BaseViewModel(application) { + + val booksData = MutableLiveData>() + val errorLiveData = MutableLiveData() + private var bookSource: BookSource? = null + private var exploreUrl: String? = null + private var page = 1 + + fun initData(intent: Intent) { + execute { + val sourceUrl = intent.getStringExtra("sourceUrl") + exploreUrl = intent.getStringExtra("exploreUrl") + if (bookSource == null && sourceUrl != null) { + bookSource = appDb.bookSourceDao.getBookSource(sourceUrl) + } + explore() + } + } + + fun explore() { + val source = bookSource + val url = exploreUrl + if (source != null && url != null) { + WebBook.exploreBook(viewModelScope, source, url, page) + .timeout(30000L) + .onSuccess(IO) { searchBooks -> + booksData.postValue(searchBooks) + appDb.searchBookDao.insert(*searchBooks.toTypedArray()) + page++ + }.onError { + Timber.e(it) + errorLiveData.postValue(it.msg) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/group/GroupEditDialog.kt b/app/src/main/java/io/legado/app/ui/book/group/GroupEditDialog.kt new file mode 100644 index 000000000..50df3f2a1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/group/GroupEditDialog.kt @@ -0,0 +1,103 @@ +package io.legado.app.ui.book.group + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.DialogBookGroupEditBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onClick + +class GroupEditDialog() : BaseDialogFragment(R.layout.dialog_book_group_edit) { + + constructor(bookGroup: BookGroup? = null) : this() { + arguments = Bundle().apply { + putParcelable("group", bookGroup) + } + } + + private val binding by viewBinding(DialogBookGroupEditBinding::bind) + private val viewModel by viewModels() + private var bookGroup: BookGroup? = null + val selectImage = registerForActivityResult(SelectImageContract()) { + readUri(it?.uri) { name, bytes -> + var file = requireContext().externalFiles + file = FileUtils.createFileIfNotExist(file, "covers", name) + file.writeBytes(bytes) + binding.ivCover.load(file.absolutePath) + } + } + + override fun onStart() { + super.onStart() + setLayout( + 0.9f, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + bookGroup = arguments?.getParcelable("group") + bookGroup?.let { + binding.tieGroupName.setText(it.groupName) + binding.ivCover.load(it.cover) + } ?: let { + binding.toolBar.title = getString(R.string.add_group) + binding.btnDelete.gone() + binding.ivCover.load() + } + binding.run { + ivCover.onClick { + selectImage.launch() + } + btnCancel.onClick { + dismiss() + } + btnOk.onClick { + val groupName = tieGroupName.text?.toString() + if (groupName.isNullOrEmpty()) { + toastOnUi("分组名称不能为空") + } else { + bookGroup?.let { + it.groupName = groupName + it.cover = binding.ivCover.bitmapPath + viewModel.upGroup(it) { + dismiss() + } + } ?: let { + viewModel.addGroup(groupName, binding.ivCover.bitmapPath) { + dismiss() + } + } + } + + } + btnDelete.onClick { + deleteGroup { + bookGroup?.let { + viewModel.delGroup(it) { + dismiss() + } + } + } + } + } + } + + private fun deleteGroup(ok: () -> Unit) { + alert(R.string.delete, R.string.sure_del) { + okButton { + ok.invoke() + } + noButton() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/group/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/book/group/GroupManageDialog.kt new file mode 100644 index 000000000..d76979d02 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/group/GroupManageDialog.kt @@ -0,0 +1,149 @@ +package io.legado.app.ui.book.group + +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemBookGroupManageBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.applyTint +import io.legado.app.utils.setLayout +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding +import io.legado.app.utils.visible +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val viewModel: GroupViewModel by viewModels() + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { GroupAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.title = getString(R.string.group_manage) + initView() + initData() + initMenu() + } + + private fun initView() { + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) + binding.recyclerView.adapter = adapter + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + binding.tvOk.setTextColor(requireContext().accentColor) + binding.tvOk.visible() + binding.tvOk.setOnClickListener { + dismissAllowingStateLoss() + } + } + + private fun initData() { + launch { + appDb.bookGroupDao.flowAll().collect { + adapter.setItems(it) + } + } + } + + private fun initMenu() { + binding.toolBar.setOnMenuItemClickListener(this) + binding.toolBar.inflateMenu(R.menu.book_group_manage) + binding.toolBar.menu.applyTint(requireContext()) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> showDialogFragment(GroupEditDialog()) + } + return true + } + + private inner class GroupAdapter(context: Context) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private var isMoved = false + + override fun getViewBinding(parent: ViewGroup): ItemBookGroupManageBinding { + return ItemBookGroupManageBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemBookGroupManageBinding, + item: BookGroup, + payloads: MutableList + ) { + binding.run { + root.setBackgroundColor(context.backgroundColor) + tvGroup.text = item.getManageName(context) + swShow.isChecked = item.show + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemBookGroupManageBinding) { + binding.run { + tvEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { bookGroup -> + showDialogFragment( + GroupEditDialog(bookGroup) + ) + } + } + swShow.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + getItem(holder.layoutPosition)?.let { + viewModel.upGroup(it.copy(show = isChecked)) + } + } + } + } + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + swapItem(srcPosition, targetPosition) + isMoved = true + return true + } + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (isMoved) { + for ((index, item) in getItems().withIndex()) { + item.order = index + 1 + } + viewModel.upGroup(*getItems().toTypedArray()) + } + isMoved = false + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/group/GroupSelectDialog.kt b/app/src/main/java/io/legado/app/ui/book/group/GroupSelectDialog.kt new file mode 100644 index 000000000..b1a4715f0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/group/GroupSelectDialog.kt @@ -0,0 +1,169 @@ +package io.legado.app.ui.book.group + +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.DialogBookGroupPickerBinding +import io.legado.app.databinding.ItemGroupSelectBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.applyTint +import io.legado.app.utils.setLayout +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class GroupSelectDialog() : BaseDialogFragment(R.layout.dialog_book_group_picker), + Toolbar.OnMenuItemClickListener { + + constructor(groupId: Long, requestCode: Int = -1) : this() { + arguments = Bundle().apply { + putLong("groupId", groupId) + putInt("requestCode", requestCode) + } + } + + private val binding by viewBinding(DialogBookGroupPickerBinding::bind) + private var requestCode: Int = -1 + private val viewModel: GroupViewModel by viewModels() + private val adapter by lazy { GroupAdapter(requireContext()) } + private var callBack: CallBack? = null + private var groupId: Long = 0 + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + callBack = activity as? CallBack + arguments?.let { + groupId = it.getLong("groupId") + requestCode = it.getInt("requestCode", -1) + } + initView() + initData() + } + + private fun initView() { + binding.toolBar.title = getString(R.string.group_select) + binding.toolBar.inflateMenu(R.menu.book_group_manage) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) + binding.recyclerView.adapter = adapter + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + binding.tvCancel.setOnClickListener { + dismissAllowingStateLoss() + } + binding.tvOk.setTextColor(requireContext().accentColor) + binding.tvOk.setOnClickListener { + callBack?.upGroup(requestCode, groupId) + dismissAllowingStateLoss() + } + } + + private fun initData() { + launch { + appDb.bookGroupDao.flowSelect().collect { + adapter.setItems(it) + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> showDialogFragment( + GroupEditDialog() + ) + } + return true + } + + private inner class GroupAdapter(context: Context) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private var isMoved: Boolean = false + + override fun getViewBinding(parent: ViewGroup): ItemGroupSelectBinding { + return ItemGroupSelectBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemGroupSelectBinding, + item: BookGroup, + payloads: MutableList + ) { + binding.run { + root.setBackgroundColor(context.backgroundColor) + cbGroup.text = item.groupName + cbGroup.isChecked = (groupId and item.groupId) > 0 + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemGroupSelectBinding) { + binding.run { + cbGroup.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + getItem(holder.layoutPosition)?.let { + groupId = if (isChecked) { + groupId + it.groupId + } else { + groupId - it.groupId + } + } + } + } + tvEdit.setOnClickListener { + showDialogFragment( + GroupEditDialog(getItem(holder.layoutPosition)) + ) + } + } + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + swapItem(srcPosition, targetPosition) + isMoved = true + return true + } + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (isMoved) { + for ((index, item) in getItems().withIndex()) { + item.order = index + 1 + } + viewModel.upGroup(*getItems().toTypedArray()) + } + isMoved = false + } + } + + interface CallBack { + fun upGroup(requestCode: Int, groupId: Long) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/group/GroupViewModel.kt b/app/src/main/java/io/legado/app/ui/book/group/GroupViewModel.kt new file mode 100644 index 000000000..5c71236ef --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/group/GroupViewModel.kt @@ -0,0 +1,48 @@ +package io.legado.app.ui.book.group + +import android.app.Application +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookGroup + +class GroupViewModel(application: Application) : BaseViewModel(application) { + + fun upGroup(vararg bookGroup: BookGroup, finally: (() -> Unit)? = null) { + execute { + appDb.bookGroupDao.update(*bookGroup) + }.onFinally { + finally?.invoke() + } + } + + fun addGroup(groupName: String, cover: String?, finally: () -> Unit) { + execute { + val bookGroup = BookGroup( + groupId = appDb.bookGroupDao.getUnusedId(), + groupName = groupName, + cover = cover, + order = appDb.bookGroupDao.maxOrder.plus(1) + ) + appDb.bookGroupDao.insert(bookGroup) + }.onFinally { + finally() + } + } + + fun delGroup(vararg bookGroup: BookGroup, finally: () -> Unit) { + execute { + appDb.bookGroupDao.delete(*bookGroup) + bookGroup.forEach { group -> + val books = appDb.bookDao.getBooksByGroup(group.groupId) + books.forEach { + it.group = it.group - group.groupId + } + appDb.bookDao.update(*books.toTypedArray()) + } + }.onFinally { + finally() + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt new file mode 100644 index 000000000..12e8d132f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -0,0 +1,464 @@ +package io.legado.app.ui.book.info + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.CheckBox +import android.widget.LinearLayout +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestOptions.bitmapTransform +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.BookType +import io.legado.app.constant.Theme +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.databinding.ActivityBookInfoBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.BlurTransformation +import io.legado.app.help.glide.ImageLoader +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.model.BookCover +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.ui.book.audio.AudioPlayActivity +import io.legado.app.ui.book.changecover.ChangeCoverDialog +import io.legado.app.ui.book.changesource.ChangeSourceDialog +import io.legado.app.ui.book.group.GroupSelectDialog +import io.legado.app.ui.book.info.edit.BookInfoEditActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.toc.TocActivityResult +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +class BookInfoActivity : + VMBaseActivity(toolBarTheme = Theme.Dark), + GroupSelectDialog.CallBack, + ChangeSourceDialog.CallBack, + ChangeCoverDialog.CallBack { + + private val tocActivityResult = registerForActivityResult(TocActivityResult()) { + it?.let { + viewModel.bookData.value?.let { book -> + launch { + withContext(IO) { + viewModel.durChapterIndex = it.first + book.durChapterIndex = it.first + book.durChapterPos = it.second + appDb.bookDao.update(book) + } + viewModel.chapterListData.value?.let { chapterList -> + binding.tvToc.text = + getString(R.string.toc_s, chapterList[book.durChapterIndex].title) + } + startReadActivity(book) + } + } + } ?: let { + if (!viewModel.inBookshelf) { + viewModel.delBook() + } + } + } + private val readBookResult = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + viewModel.refreshData(intent) + if (it.resultCode == RESULT_OK) { + viewModel.inBookshelf = true + upTvBookshelf() + } + } + private val infoEditResult = registerForActivityResult( + StartActivityContract(BookInfoEditActivity::class.java) + ) { + if (it.resultCode == RESULT_OK) { + viewModel.upEditBook() + } + } + + override val binding by viewBinding(ActivityBookInfoBinding::inflate) + override val viewModel by viewModels() + + @SuppressLint("PrivateResource") + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.titleBar.transparent() + binding.arcView.setBgColor(backgroundColor) + binding.llInfo.setBackgroundColor(backgroundColor) + binding.scrollView.setBackgroundColor(backgroundColor) + binding.flAction.setBackgroundColor(bottomBackground) + binding.tvShelf.setTextColor(getPrimaryTextColor(ColorUtils.isColorLight(bottomBackground))) + binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading)) + viewModel.bookData.observe(this, { showBook(it) }) + viewModel.chapterListData.observe(this, { upLoading(false, it) }) + viewModel.initData(intent) + initOnClick() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_info, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.findItem(R.id.menu_can_update)?.isChecked = + viewModel.bookData.value?.canUpdate ?: true + menu.findItem(R.id.menu_login)?.isVisible = + !viewModel.bookSource?.loginUrl.isNullOrBlank() + menu.findItem(R.id.menu_set_source_variable)?.isVisible = + viewModel.bookSource != null + menu.findItem(R.id.menu_set_book_variable)?.isVisible = + viewModel.bookSource != null + return super.onMenuOpened(featureId, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_edit -> { + if (viewModel.inBookshelf) { + viewModel.bookData.value?.let { + infoEditResult.launch { + putExtra("bookUrl", it.bookUrl) + } + } + } else { + toastOnUi(R.string.after_add_bookshelf) + } + } + R.id.menu_share_it -> { + viewModel.bookData.value?.let { + val bookJson = GSON.toJson(it) + val shareStr = "${it.bookUrl}#$bookJson" + shareWithQr(shareStr, it.name) + } + } + R.id.menu_refresh -> { + upLoading(true) + viewModel.bookData.value?.let { + if (it.isLocalBook()) { + it.tocUrl = "" + } + viewModel.loadBookInfo(it, false) + } + } + R.id.menu_login -> viewModel.bookSource?.let { + startActivity { + putExtra("type", "bookSource") + putExtra("key", it.bookSourceUrl) + } + } + R.id.menu_top -> viewModel.topBook() + R.id.menu_set_source_variable -> setSourceVariable() + R.id.menu_set_book_variable -> setBookVariable() + R.id.menu_copy_book_url -> viewModel.bookData.value?.bookUrl?.let { + sendToClip(it) + } ?: toastOnUi(R.string.no_book) + R.id.menu_copy_toc_url -> viewModel.bookData.value?.tocUrl?.let { + sendToClip(it) + } ?: toastOnUi(R.string.no_book) + R.id.menu_can_update -> { + if (viewModel.inBookshelf) { + viewModel.bookData.value?.let { + it.canUpdate = !it.canUpdate + viewModel.saveBook() + } + } else { + toastOnUi(R.string.after_add_bookshelf) + } + } + R.id.menu_clear_cache -> viewModel.clearCache() + R.id.menu_log -> showDialogFragment() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun showBook(book: Book) = binding.run { + showCover(book) + tvName.text = book.name + tvAuthor.text = getString(R.string.author_show, book.getRealAuthor()) + tvOrigin.text = getString(R.string.origin_show, book.originName) + tvLasted.text = getString(R.string.lasted_show, book.latestChapterTitle) + tvIntro.text = book.getDisplayIntro() + upTvBookshelf() + val kinds = book.getKindList() + if (kinds.isEmpty()) { + lbKind.gone() + } else { + lbKind.visible() + lbKind.setLabels(kinds) + } + upGroup(book.group) + } + + private fun showCover(book: Book) { + binding.ivCover.load(book.getDisplayCover(), book.name, book.author) + ImageLoader.load(this, book.getDisplayCover()) + .transition(DrawableTransitionOptions.withCrossFade(1500)) + .thumbnail(BookCover.getBlurDefaultCover(this)) + .apply(bitmapTransform(BlurTransformation(this, 25))) + .into(binding.bgBook) //模糊、渐变、缩小效果 + } + + private fun upLoading(isLoading: Boolean, chapterList: List? = null) { + when { + isLoading -> { + binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading)) + } + chapterList.isNullOrEmpty() -> { + binding.tvToc.text = getString(R.string.toc_s, getString(R.string.error_load_toc)) + } + else -> { + viewModel.bookData.value?.let { + if (it.durChapterIndex < chapterList.size) { + binding.tvToc.text = + getString(R.string.toc_s, chapterList[it.durChapterIndex].title) + } else { + binding.tvToc.text = getString(R.string.toc_s, chapterList.last().title) + } + } + } + } + } + + private fun upTvBookshelf() { + if (viewModel.inBookshelf) { + binding.tvShelf.text = getString(R.string.remove_from_bookshelf) + } else { + binding.tvShelf.text = getString(R.string.add_to_shelf) + } + } + + private fun upGroup(groupId: Long) { + viewModel.loadGroup(groupId) { + if (it.isNullOrEmpty()) { + binding.tvGroup.text = getString(R.string.group_s, getString(R.string.no_group)) + } else { + binding.tvGroup.text = getString(R.string.group_s, it) + } + } + } + + private fun initOnClick() = binding.run { + ivCover.setOnClickListener { + viewModel.bookData.value?.let { + showDialogFragment( + ChangeCoverDialog(it.name, it.author) + ) + } + } + tvRead.setOnClickListener { + viewModel.bookData.value?.let { + readBook(it) + } + } + tvShelf.setOnClickListener { + if (viewModel.inBookshelf) { + deleteBook() + } else { + viewModel.addToBookshelf { + upTvBookshelf() + } + } + } + tvOrigin.setOnClickListener { + viewModel.bookData.value?.let { + startActivity { + putExtra("sourceUrl", it.origin) + } + } + } + tvChangeSource.setOnClickListener { + viewModel.bookData.value?.let { + showDialogFragment(ChangeSourceDialog(it.name, it.author)) + } + } + tvTocView.setOnClickListener { + if (!viewModel.inBookshelf) { + viewModel.saveBook { + viewModel.saveChapterList { + openChapterList() + } + } + } else { + openChapterList() + } + } + tvChangeGroup.setOnClickListener { + viewModel.bookData.value?.let { + showDialogFragment( + GroupSelectDialog(it.group) + ) + } + } + tvAuthor.setOnClickListener { + startActivity { + putExtra("key", viewModel.bookData.value?.author) + } + } + tvName.setOnClickListener { + startActivity { + putExtra("key", viewModel.bookData.value?.name) + } + } + } + + private fun setSourceVariable() { + launch { + val variable = withContext(IO) { viewModel.bookSource?.getVariable() } + alert(R.string.set_source_variable) { + setMessage("源变量可在js中通过source.getVariable()获取") + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "source variable" + editView.setText(variable) + } + customView { alertBinding.root } + okButton { + viewModel.bookSource?.setVariable(alertBinding.editView.text?.toString()) + } + cancelButton() + neutralButton(R.string.delete) { + viewModel.bookSource?.setVariable(null) + } + } + } + } + + private fun setBookVariable() { + launch { + val variable = withContext(IO) { viewModel.bookData.value?.getVariable("custom") } + alert(R.string.set_source_variable) { + setMessage("""书籍变量可在js中通过book.getVariable("custom")获取""") + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "book variable" + editView.setText(variable) + } + customView { alertBinding.root } + okButton { + viewModel.bookData.value + ?.putVariable("custom", alertBinding.editView.text?.toString()) + viewModel.saveBook() + } + cancelButton() + neutralButton(R.string.delete) { + viewModel.bookData.value + ?.putVariable("custom", null) + viewModel.saveBook() + } + } + } + } + + @SuppressLint("InflateParams") + private fun deleteBook() { + viewModel.bookData.value?.let { + if (it.isLocalBook()) { + alert( + titleResource = R.string.sure, + messageResource = R.string.sure_del + ) { + val checkBox = CheckBox(this@BookInfoActivity).apply { + setText(R.string.delete_book_file) + } + val view = LinearLayout(this@BookInfoActivity).apply { + setPadding(16.dp, 0, 16.dp, 0) + addView(checkBox) + } + customView { view } + positiveButton(R.string.yes) { + viewModel.delBook(checkBox.isChecked) { + finish() + } + } + negativeButton(R.string.no) + } + } else { + viewModel.delBook { + upTvBookshelf() + } + } + } + } + + private fun openChapterList() { + if (viewModel.chapterListData.value.isNullOrEmpty()) { + toastOnUi(R.string.chapter_list_empty) + return + } + viewModel.bookData.value?.let { + tocActivityResult.launch(it.bookUrl) + } + } + + private fun readBook(book: Book) { + if (!viewModel.inBookshelf) { + viewModel.saveBook { + viewModel.saveChapterList { + startReadActivity(book) + } + } + } else { + viewModel.saveBook { + startReadActivity(book) + } + } + } + + private fun startReadActivity(book: Book) { + when (book.type) { + BookType.audio -> readBookResult.launch( + Intent(this, AudioPlayActivity::class.java) + .putExtra("bookUrl", book.bookUrl) + .putExtra("inBookshelf", viewModel.inBookshelf) + ) + else -> readBookResult.launch( + Intent(this, ReadBookActivity::class.java) + .putExtra("bookUrl", book.bookUrl) + .putExtra("inBookshelf", viewModel.inBookshelf) + ) + } + } + + override val oldBook: Book? + get() = viewModel.bookData.value + + override fun changeTo(source: BookSource, book: Book) { + upLoading(true) + viewModel.changeTo(source, book) + } + + override fun coverChangeTo(coverUrl: String) { + viewModel.bookData.value?.let { + it.coverUrl = coverUrl + viewModel.saveBook() + showCover(it) + } + } + + override fun upGroup(requestCode: Int, groupId: Long) { + upGroup(groupId) + viewModel.bookData.value?.group = groupId + if (viewModel.inBookshelf) { + viewModel.saveBook() + } else if (groupId > 0) { + viewModel.saveBook() + viewModel.inBookshelf = true + upTvBookshelf() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt new file mode 100644 index 000000000..56457a5d1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -0,0 +1,303 @@ +package io.legado.app.ui.book.info + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppLog +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.BookHelp +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.ReadBook +import io.legado.app.model.localBook.LocalBook +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.postEvent +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ensureActive + +class BookInfoViewModel(application: Application) : BaseViewModel(application) { + val bookData = MutableLiveData() + val chapterListData = MutableLiveData>() + var durChapterIndex = 0 + var inBookshelf = false + var bookSource: BookSource? = null + private var changeSourceCoroutine: Coroutine<*>? = null + + fun initData(intent: Intent) { + execute { + val name = intent.getStringExtra("name") ?: "" + val author = intent.getStringExtra("author") ?: "" + val bookUrl = intent.getStringExtra("bookUrl") ?: "" + appDb.bookDao.getBook(name, author)?.let { book -> + inBookshelf = true + setBook(book) + } ?: let { + val searchBook = appDb.searchBookDao.getSearchBook(bookUrl) + ?: appDb.searchBookDao.getFirstByNameAuthor(name, author) + searchBook?.toBook()?.let { book -> + setBook(book) + } + } + } + } + + fun refreshData(intent: Intent) { + execute { + val name = intent.getStringExtra("name") ?: "" + val author = intent.getStringExtra("author") ?: "" + appDb.bookDao.getBook(name, author)?.let { book -> + setBook(book) + } + } + } + + private fun setBook(book: Book) { + durChapterIndex = book.durChapterIndex + bookData.postValue(book) + bookSource = if (book.isLocalBook()) { + null + } else { + appDb.bookSourceDao.getBookSource(book.origin) + } + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) + } else { + val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl) + if (chapterList.isNotEmpty()) { + chapterListData.postValue(chapterList) + } else { + loadChapter(book) + } + } + } + + fun loadBookInfo( + book: Book, + canReName: Boolean = true, + scope: CoroutineScope = viewModelScope, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null, + ) { + execute(scope) { + if (book.isLocalBook()) { + loadChapter(book, scope, changeDruChapterIndex) + } else { + bookSource?.let { bookSource -> + WebBook.getBookInfo(this, bookSource, book, canReName = canReName) + .onSuccess(IO) { + bookData.postValue(book) + if (inBookshelf) { + appDb.bookDao.update(book) + } + loadChapter(it, scope, changeDruChapterIndex) + }.onError { + AppLog.put("获取数据信息失败\n${it.localizedMessage}", it) + context.toastOnUi(R.string.error_get_book_info) + } + } ?: let { + chapterListData.postValue(emptyList()) + context.toastOnUi(R.string.error_no_source) + } + } + } + } + + private fun loadChapter( + book: Book, + scope: CoroutineScope = viewModelScope, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null, + ) { + execute(scope) { + if (book.isLocalBook()) { + LocalBook.getChapterList(book).let { + appDb.bookDao.update(book) + appDb.bookChapterDao.insert(*it.toTypedArray()) + chapterListData.postValue(it) + } + } else { + bookSource?.let { bookSource -> + WebBook.getChapterList(this, bookSource, book) + .onSuccess(IO) { + if (inBookshelf) { + appDb.bookDao.update(book) + appDb.bookChapterDao.insert(*it.toTypedArray()) + } + if (changeDruChapterIndex == null) { + chapterListData.postValue(it) + } else { + changeDruChapterIndex(it) + } + }.onError { + chapterListData.postValue(emptyList()) + AppLog.put("获取目录失败\n${it.localizedMessage}", it) + context.toastOnUi(R.string.error_get_chapter_list) + } + } ?: let { + chapterListData.postValue(emptyList()) + context.toastOnUi(R.string.error_no_source) + } + } + }.onError { + context.toastOnUi("LoadTocError:${it.localizedMessage}") + } + } + + fun loadGroup(groupId: Long, success: ((groupNames: String?) -> Unit)) { + execute { + appDb.bookGroupDao.getGroupNames(groupId).joinToString(",") + }.onSuccess { + success.invoke(it) + } + } + + fun changeTo(source: BookSource, newBook: Book) { + changeSourceCoroutine?.cancel() + changeSourceCoroutine = execute { + var oldTocSize: Int = newBook.totalChapterNum + if (inBookshelf) { + bookData.value?.let { + oldTocSize = it.totalChapterNum + it.changeTo(newBook) + } + } + bookData.postValue(newBook) + bookSource = source + if (newBook.tocUrl.isEmpty()) { + loadBookInfo(newBook, false, this) { + ensureActive() + upChangeDurChapterIndex(newBook, oldTocSize, it) + } + } else { + loadChapter(newBook, this) { + ensureActive() + upChangeDurChapterIndex(newBook, oldTocSize, it) + } + } + }.onFinally { + postEvent(EventBus.SOURCE_CHANGED, newBook.bookUrl) + } + } + + private fun upChangeDurChapterIndex( + book: Book, + oldTocSize: Int, + chapters: List + ) { + execute { + book.durChapterIndex = BookHelp.getDurChapter( + book.durChapterIndex, + oldTocSize, + book.durChapterTitle, + chapters + ) + book.durChapterTitle = chapters[book.durChapterIndex].title + if (inBookshelf) { + appDb.bookDao.update(book) + appDb.bookChapterDao.insert(*chapters.toTypedArray()) + } + bookData.postValue(book) + chapterListData.postValue(chapters) + } + } + + fun topBook() { + execute { + bookData.value?.let { book -> + val minOrder = appDb.bookDao.minOrder + book.order = minOrder - 1 + book.durChapterTime = System.currentTimeMillis() + appDb.bookDao.update(book) + } + } + } + + fun saveBook(success: (() -> Unit)? = null) { + execute { + bookData.value?.let { book -> + if (book.order == 0) { + book.order = appDb.bookDao.maxOrder + 1 + } + appDb.bookDao.getBook(book.name, book.author)?.let { + book.durChapterPos = it.durChapterPos + book.durChapterTitle = it.durChapterTitle + } + book.save() + if (ReadBook.book?.name == book.name && ReadBook.book?.author == book.author) { + ReadBook.book = book + } + } + }.onSuccess { + success?.invoke() + } + } + + fun saveChapterList(success: (() -> Unit)?) { + execute { + chapterListData.value?.let { + appDb.bookChapterDao.insert(*it.toTypedArray()) + } + }.onSuccess { + success?.invoke() + } + } + + fun addToBookshelf(success: (() -> Unit)?) { + execute { + bookData.value?.let { book -> + if (book.order == 0) { + book.order = appDb.bookDao.maxOrder + 1 + } + appDb.bookDao.getBook(book.name, book.author)?.let { + book.durChapterPos = it.durChapterPos + book.durChapterTitle = it.durChapterTitle + } + book.save() + } + chapterListData.value?.let { + appDb.bookChapterDao.insert(*it.toTypedArray()) + } + inBookshelf = true + }.onSuccess { + success?.invoke() + } + } + + fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) { + execute { + bookData.value?.let { + Book.delete(it) + inBookshelf = false + if (it.isLocalBook()) { + LocalBook.deleteBook(it, deleteOriginal) + } + } + }.onSuccess { + success?.invoke() + } + } + + fun clearCache() { + execute { + BookHelp.clearCache(bookData.value!!) + }.onSuccess { + context.toastOnUi(R.string.clear_cache_success) + }.onError { + context.toastOnUi("清理缓存出错\n${it.localizedMessage}") + } + } + + fun upEditBook() { + bookData.value?.let { + appDb.bookDao.getBook(it.bookUrl)?.let { book -> + bookData.postValue(book) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt new file mode 100644 index 000000000..8f3725634 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt @@ -0,0 +1,112 @@ +package io.legado.app.ui.book.info.edit + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.Book +import io.legado.app.databinding.ActivityBookInfoEditBinding +import io.legado.app.ui.book.changecover.ChangeCoverDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class BookInfoEditActivity : + VMBaseActivity(), + ChangeCoverDialog.CallBack { + + private val selectCover = registerForActivityResult(SelectImageContract()) { + it.uri?.let { uri -> + coverChangeTo(uri) + } + } + + override val binding by viewBinding(ActivityBookInfoEditBinding::inflate) + override val viewModel by viewModels() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + viewModel.bookData.observe(this, { upView(it) }) + if (viewModel.bookData.value == null) { + intent.getStringExtra("bookUrl")?.let { + viewModel.loadBook(it) + } + } + initEvent() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_info_edit, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> saveData() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initEvent() = binding.run { + tvChangeCover.setOnClickListener { + viewModel.bookData.value?.let { + showDialogFragment( + ChangeCoverDialog(it.name, it.author) + ) + } + } + tvSelectCover.setOnClickListener { + selectCover.launch() + } + tvRefreshCover.setOnClickListener { + viewModel.book?.customCoverUrl = tieCoverUrl.text?.toString() + upCover() + } + } + + private fun upView(book: Book) = binding.run { + tieBookName.setText(book.name) + tieBookAuthor.setText(book.author) + tieCoverUrl.setText(book.getDisplayCover()) + tieBookIntro.setText(book.getDisplayIntro()) + upCover() + } + + private fun upCover() { + viewModel.book.let { + binding.ivCover.load(it?.getDisplayCover(), it?.name, it?.author) + } + } + + private fun saveData() = binding.run { + viewModel.book?.let { book -> + book.name = tieBookName.text?.toString() ?: "" + book.author = tieBookAuthor.text?.toString() ?: "" + val customCoverUrl = tieCoverUrl.text?.toString() + book.customCoverUrl = if (customCoverUrl == book.coverUrl) null else customCoverUrl + book.customIntro = tieBookIntro.text?.toString() + viewModel.saveBook(book) { + setResult(Activity.RESULT_OK) + finish() + } + } + } + + override fun coverChangeTo(coverUrl: String) { + viewModel.book?.customCoverUrl = coverUrl + binding.tieCoverUrl.setText(coverUrl) + upCover() + } + + private fun coverChangeTo(uri: Uri) { + readUri(uri) { name, bytes -> + var file = this.externalFiles + file = FileUtils.createFileIfNotExist(file, "covers", name) + file.writeBytes(bytes) + coverChangeTo(file.absolutePath) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt new file mode 100644 index 000000000..da9917acc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt @@ -0,0 +1,33 @@ +package io.legado.app.ui.book.info.edit + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.model.ReadBook + +class BookInfoEditViewModel(application: Application) : BaseViewModel(application) { + var book: Book? = null + val bookData = MutableLiveData() + + fun loadBook(bookUrl: String) { + execute { + book = appDb.bookDao.getBook(bookUrl) + book?.let { + bookData.postValue(it) + } + } + } + + fun saveBook(book: Book, success: (() -> Unit)?) { + execute { + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.book = book + } + appDb.bookDao.update(book) + }.onSuccess { + success?.invoke() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/local/ImportBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/local/ImportBookActivity.kt new file mode 100644 index 000000000..78686fe19 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/local/ImportBookActivity.kt @@ -0,0 +1,284 @@ +package io.legado.app.ui.book.local + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu +import androidx.documentfile.provider.DocumentFile +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.appDb +import io.legado.app.databinding.ActivityImportBookBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.permission.Permissions +import io.legado.app.lib.permission.PermissionsCompat +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.widget.SelectActionBar +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * 导入本地书籍界面 + */ +class ImportBookActivity : VMBaseActivity(), + PopupMenu.OnMenuItemClickListener, + ImportBookAdapter.CallBack, + SelectActionBar.CallBack { + + override val binding by viewBinding(ActivityImportBookBinding::inflate) + override val viewModel by viewModels() + private val bookFileRegex = Regex("(?i).*\\.(txt|epub|umd)") + private var rootDoc: FileDoc? = null + private val subDocs = arrayListOf() + private val adapter by lazy { ImportBookAdapter(this, this) } + + private val selectFolder = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + if (uri.isContentScheme()) { + AppConfig.importBookPath = uri.toString() + initRootDoc() + } else { + AppConfig.importBookPath = uri.path + initRootDoc() + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + initEvent() + initData() + initRootDoc() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.import_book, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_select_folder -> selectFolder.launch() + R.id.menu_scan_folder -> scanFolder() + R.id.menu_import_file_name -> alertImportFileName() + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_del_selection -> + viewModel.deleteDoc(adapter.selectedUris) { + adapter.removeSelection() + } + } + return false + } + + override fun selectAll(selectAll: Boolean) { + adapter.selectAll(selectAll) + } + + override fun revertSelection() { + adapter.revertSelection() + } + + @SuppressLint("NotifyDataSetChanged") + override fun onClickSelectBarMainAction() { + viewModel.addToBookshelf(adapter.selectedUris) { + adapter.notifyDataSetChanged() + } + } + + private fun initView() { + binding.layTop.setBackgroundColor(backgroundColor) + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = adapter + binding.selectActionBar.setMainActionText(R.string.add_to_shelf) + binding.selectActionBar.inflateMenu(R.menu.import_book_sel) + binding.selectActionBar.setOnMenuItemClickListener(this) + binding.selectActionBar.setCallBack(this) + } + + private fun initEvent() { + binding.tvGoBack.setOnClickListener { + goBackDir() + } + } + + private fun initData() { + launch { + appDb.bookDao.flowLocalUri().collect { + adapter.upBookHas(it) + } + } + } + + private fun initRootDoc() { + val lastPath = AppConfig.importBookPath + when { + lastPath.isNullOrEmpty() -> { + binding.tvEmptyMsg.visible() + selectFolder.launch() + } + lastPath.isContentScheme() -> { + val rootUri = Uri.parse(lastPath) + kotlin.runCatching { + val doc = DocumentFile.fromTreeUri(this, rootUri) + if (doc == null || doc.name.isNullOrEmpty()) { + binding.tvEmptyMsg.visible() + selectFolder.launch() + } else { + subDocs.clear() + rootDoc = FileDoc.fromDocumentFile(doc) + upDocs(rootDoc!!) + } + }.onFailure { + binding.tvEmptyMsg.visible() + selectFolder.launch() + } + } + Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> { + binding.tvEmptyMsg.visible() + selectFolder.launch() + } + else -> initRootPath(lastPath) + } + } + + private fun initRootPath(path: String) { + binding.tvEmptyMsg.visible() + PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { + kotlin.runCatching { + rootDoc = FileDoc.fromFile(File(path)) + subDocs.clear() + upPath() + }.onFailure { + binding.tvEmptyMsg.visible() + selectFolder.launch() + } + } + .request() + } + + @Synchronized + private fun upPath() { + rootDoc?.let { + upDocs(it) + } + } + + private fun upDocs(rootDoc: FileDoc) { + binding.tvEmptyMsg.gone() + var path = rootDoc.name + File.separator + var lastDoc = rootDoc + for (doc in subDocs) { + lastDoc = doc + path = path + doc.name + File.separator + } + binding.tvPath.text = path + adapter.selectedUris.clear() + adapter.clearItems() + launch(IO) { + runCatching { + val docList = DocumentUtils.listFiles(lastDoc.uri) { item -> + when { + item.name.startsWith(".") -> false + item.isDir -> true + else -> item.name.matches(bookFileRegex) + } + } + docList.sortWith(compareBy({ !it.isDir }, { it.name })) + withContext(Main) { + adapter.setItems(docList) + } + }.onFailure { + toastOnUi("获取文件列表出错\n${it.localizedMessage}") + } + } + } + + /** + * 扫描当前文件夹 + */ + private fun scanFolder() { + rootDoc?.let { doc -> + adapter.clearItems() + val lastDoc = subDocs.lastOrNull() ?: doc + binding.refreshProgressBar.isAutoLoading = true + launch(IO) { + viewModel.scanDoc(lastDoc, true, find) { + launch { + binding.refreshProgressBar.isAutoLoading = false + } + } + } + } + } + + private fun alertImportFileName() { + alert(R.string.import_file_name) { + setMessage("""使用js返回一个json结构,{"name":"xxx", "author":"yyy"}""") + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "js" + editView.setText(AppConfig.bookImportFileName) + } + customView { alertBinding.root } + okButton { + AppConfig.bookImportFileName = alertBinding.editView.text?.toString() + } + cancelButton() + } + } + + private val find: (docItem: FileDoc) -> Unit = { + launch { + adapter.addItem(it) + } + } + + @Synchronized + override fun nextDoc(fileDoc: FileDoc) { + subDocs.add(fileDoc) + upPath() + } + + @Synchronized + private fun goBackDir(): Boolean { + return if (subDocs.isNotEmpty()) { + subDocs.removeAt(subDocs.lastIndex) + upPath() + true + } else { + false + } + } + + override fun onBackPressed() { + if (!goBackDir()) { + super.onBackPressed() + } + } + + override fun upCountView() { + binding.selectActionBar.upCountView(adapter.selectedUris.size, adapter.checkableCount) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/local/ImportBookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/local/ImportBookAdapter.kt new file mode 100644 index 000000000..91d35d437 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/local/ImportBookAdapter.kt @@ -0,0 +1,145 @@ +package io.legado.app.ui.book.local + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppConst +import io.legado.app.databinding.ItemImportBookBinding +import io.legado.app.utils.* + + +class ImportBookAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + var selectedUris = hashSetOf() + var checkableCount = 0 + private var bookFileNames = arrayListOf() + + override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding { + return ItemImportBookBinding.inflate(inflater, parent, false) + } + + override fun onCurrentListChanged() { + upCheckableCount() + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemImportBookBinding, + item: FileDoc, + payloads: MutableList + ) { + binding.run { + if (payloads.isEmpty()) { + if (item.isDir) { + ivIcon.setImageResource(R.drawable.ic_folder) + ivIcon.visible() + cbSelect.invisible() + llBrief.gone() + cbSelect.isChecked = false + } else { + if (bookFileNames.contains(item.name)) { + ivIcon.setImageResource(R.drawable.ic_book_has) + ivIcon.visible() + cbSelect.invisible() + } else { + ivIcon.invisible() + cbSelect.visible() + } + llBrief.visible() + tvTag.text = item.name.substringAfterLast(".") + tvSize.text = ConvertUtils.formatFileSize(item.size) + tvDate.text = AppConst.dateFormat.format(item.date) + cbSelect.isChecked = selectedUris.contains(item.toString()) + } + tvName.text = item.name + } else { + cbSelect.isChecked = selectedUris.contains(item.toString()) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + if (it.isDir) { + callBack.nextDoc(it) + } else if (!bookFileNames.contains(it.name)) { + if (!selectedUris.contains(it.toString())) { + selectedUris.add(it.toString()) + } else { + selectedUris.remove(it.toString()) + } + notifyItemChanged(holder.layoutPosition, true) + callBack.upCountView() + } + } + } + } + + @SuppressLint("NotifyDataSetChanged") + fun upBookHas(bookUrls: List) { + bookFileNames.clear() + bookUrls.forEach { + val path = Uri.decode(it) + bookFileNames.add(FileUtils.getName(path)) + } + notifyDataSetChanged() + upCheckableCount() + } + + private fun upCheckableCount() { + checkableCount = 0 + getItems().forEach { + if (!it.isDir && !bookFileNames.contains(it.name)) { + checkableCount++ + } + } + callBack.upCountView() + } + + @SuppressLint("NotifyDataSetChanged") + fun selectAll(selectAll: Boolean) { + if (selectAll) { + getItems().forEach { + if (!it.isDir && !bookFileNames.contains(it.name)) { + selectedUris.add(it.uri.toString()) + } + } + } else { + selectedUris.clear() + } + notifyDataSetChanged() + callBack.upCountView() + } + + fun revertSelection() { + getItems().forEach { + if (!it.isDir) { + if (selectedUris.contains(it.uri.toString())) { + selectedUris.remove(it.uri.toString()) + } else { + selectedUris.add(it.uri.toString()) + } + } + } + callBack.upCountView() + } + + fun removeSelection() { + for (i in getItems().lastIndex downTo 0) { + if (getItem(i)?.uri.toString() in selectedUris) { + removeItem(i) + } + } + } + + interface CallBack { + fun nextDoc(fileDoc: FileDoc) + fun upCountView() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/local/ImportBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/local/ImportBookViewModel.kt new file mode 100644 index 000000000..dd684bf80 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/local/ImportBookViewModel.kt @@ -0,0 +1,69 @@ +package io.legado.app.ui.book.local + +import android.app.Application +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.legado.app.base.BaseViewModel +import io.legado.app.model.localBook.LocalBook +import io.legado.app.utils.DocumentUtils +import io.legado.app.utils.FileDoc +import io.legado.app.utils.isContentScheme +import io.legado.app.utils.toastOnUi +import java.io.File +import java.util.* + + +class ImportBookViewModel(application: Application) : BaseViewModel(application) { + + fun addToBookshelf(uriList: HashSet, finally: () -> Unit) { + execute { + uriList.forEach { + LocalBook.importFile(Uri.parse(it)) + } + }.onFinally { + finally.invoke() + } + } + + fun deleteDoc(uriList: HashSet, finally: () -> Unit) { + execute { + uriList.forEach { + val uri = Uri.parse(it) + if (uri.isContentScheme()) { + DocumentFile.fromSingleUri(context, uri)?.delete() + } else { + uri.path?.let { path -> + File(path).delete() + } + } + } + }.onFinally { + finally.invoke() + } + } + + fun scanDoc( + fileDoc: FileDoc, + isRoot: Boolean, + find: (docItem: FileDoc) -> Unit, + finally: (() -> Unit)? = null + ) { + kotlin.runCatching { + DocumentUtils.listFiles(fileDoc.uri).forEach { docItem -> + if (docItem.isDir) { + scanDoc(docItem, false, find) + } else if (docItem.name.endsWith(".txt", true) + || docItem.name.endsWith(".epub", true) + ) { + find(docItem) + } + } + }.onFailure { + context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}") + } + if (isRoot) { + finally?.invoke() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleActivity.kt b/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleActivity.kt new file mode 100644 index 000000000..fe95f1597 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleActivity.kt @@ -0,0 +1,198 @@ +package io.legado.app.ui.book.local.rule + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.recyclerview.widget.ItemTouchHelper +import com.google.android.material.snackbar.Snackbar +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.appDb +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.databinding.ActivityTxtTocRuleBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogTocRegexEditBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.SelectActionBar +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.ACache +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.snackbar +import io.legado.app.utils.splitNotBlank +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +class TxtTocRuleActivity : VMBaseActivity(), + TxtTocRuleAdapter.CallBack, + SelectActionBar.CallBack { + + override val viewModel: TxtTocRuleViewModel by viewModels() + override val binding: ActivityTxtTocRuleBinding by viewBinding(ActivityTxtTocRuleBinding::inflate) + private val adapter: TxtTocRuleAdapter by lazy { + TxtTocRuleAdapter(this, this) + } + private val importTocRuleKey = "tocRuleUrl" + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + initBottomActionBar() + initData() + } + + private fun initView() = binding.run { + recyclerView.setEdgeEffectColor(primaryColor) + recyclerView.addItemDecoration(VerticalDivider(this@TxtTocRuleActivity)) + recyclerView.adapter = adapter + // When this page is opened, it is in selection mode + val dragSelectTouchHelper = + DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) + dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) + dragSelectTouchHelper.activeSlideSelect() + // Note: need judge selection first, so add ItemTouchHelper after it. + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + } + + private fun initBottomActionBar() { + binding.selectActionBar.setMainActionText(R.string.delete) + binding.selectActionBar.setCallBack(this) + } + + private fun initData() { + launch { + appDb.txtTocRuleDao.observeAll().collect { tocRules -> + adapter.setItems(tocRules) + upCountView() + } + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.txt_toc_regex, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add -> edit(TxtTocRule()) + R.id.menu_default -> viewModel.importDefault() + R.id.menu_import -> showImportDialog() + } + return super.onCompatOptionsItemSelected(item) + } + + override fun del(source: TxtTocRule) { + viewModel.del(source) + } + + override fun edit(source: TxtTocRule) { + alert(titleResource = R.string.txt_toc_regex) { + val alertBinding = DialogTocRegexEditBinding.inflate(layoutInflater) + alertBinding.apply { + tvRuleName.setText(source.name) + tvRuleRegex.setText(source.rule) + } + customView { alertBinding.root } + okButton { + alertBinding.apply { + source.name = tvRuleName.text.toString() + source.rule = tvRuleRegex.text.toString() + viewModel.save(source) + } + } + cancelButton() + } + } + + override fun onClickSelectBarMainAction() { + delSourceDialog() + } + + override fun revertSelection() { + adapter.revertSelection() + } + + override fun selectAll(selectAll: Boolean) { + if (selectAll) { + adapter.selectAll() + } else { + adapter.revertSelection() + } + } + + override fun update(vararg source: TxtTocRule) { + viewModel.update(*source) + } + + override fun toTop(source: TxtTocRule) { + viewModel.toTop(source) + } + + override fun toBottom(source: TxtTocRule) { + viewModel.toBottom(source) + } + + override fun upOrder() { + viewModel.upOrder() + } + + override fun upCountView() { + binding.selectActionBar + .upCountView(adapter.selection.size, adapter.itemCount) + } + + private fun delSourceDialog() { + alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { + okButton { viewModel.del(*adapter.selection.toTypedArray()) } + noButton() + } + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(this, cacheDir = false) + val defaultUrl = "https://gitee.com/fisher52/YueDuJson/raw/master/myTxtChapterRule.json" + val cacheUrls: MutableList = aCache + .getAsString(importTocRuleKey) + ?.splitNotBlank(",") + ?.toMutableList() + ?: mutableListOf() + if (!cacheUrls.contains(defaultUrl)) { + cacheUrls.add(0, defaultUrl) + } + alert(titleResource = R.string.import_on_line) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + editView.setFilterValues(cacheUrls) + editView.delCallBack = { + cacheUrls.remove(it) + aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) + } + } + customView { alertBinding.root } + okButton { + val text = alertBinding.editView.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) + } + Snackbar.make(binding.root, R.string.importing, Snackbar.LENGTH_INDEFINITE) + .show() + viewModel.importOnLine(it) { msg -> + binding.root.snackbar(msg) + } + } + } + cancelButton() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleAdapter.kt b/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleAdapter.kt new file mode 100644 index 000000000..5fa02ca2a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleAdapter.kt @@ -0,0 +1,191 @@ +package io.legado.app.ui.book.local.rule + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.databinding.ItemTxtTocRuleBinding +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.utils.ColorUtils + +class TxtTocRuleAdapter(context: Context, private val callBack: CallBack) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private val selected = linkedSetOf() + + val selection: List + get() { + val selection = arrayListOf() + getItems().map { + if (selected.contains(it)) { + selection.add(it) + } + } + return selection.sortedBy { it.serialNumber } + } + + override fun getViewBinding(parent: ViewGroup): ItemTxtTocRuleBinding { + return ItemTxtTocRuleBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemTxtTocRuleBinding, + item: TxtTocRule, + payloads: MutableList + ) { + binding.run { + val bundle = payloads.getOrNull(0) as? Bundle + if (bundle == null) { + root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) + cbSource.text = item.name + swtEnabled.isChecked = item.enable + cbSource.isChecked = selected.contains(item) + } else { + bundle.keySet().map { + when (it) { + "selected" -> cbSource.isChecked = selected.contains(item) + } + } + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemTxtTocRuleBinding) { + binding.cbSource.setOnCheckedChangeListener { buttonView, isChecked -> + getItem(holder.layoutPosition)?.let { + if (buttonView.isPressed) { + if (isChecked) { + selected.add(it) + } else { + selected.remove(it) + } + callBack.upCountView() + } + } + } + binding.swtEnabled.setOnCheckedChangeListener { buttonView, isChecked -> + getItem(holder.layoutPosition)?.let { + if (buttonView.isPressed) { + it.enable = isChecked + } + } + } + binding.ivEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.edit(it) + } + } + binding.ivMenuMore.setOnClickListener { + showMenu(it, holder.layoutPosition) + } + } + + private fun showMenu(view: View, position: Int) { + val source = getItem(position) ?: return + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.txt_toc_rule_item) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(source) + R.id.menu_bottom -> callBack.toBottom(source) + R.id.menu_del -> callBack.del(source) + } + true + } + popupMenu.show() + } + + fun selectAll() { + getItems().forEach { + selected.add(it) + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + fun revertSelection() { + getItems().forEach { + if (selected.contains(it)) { + selected.remove(it) + } else { + selected.add(it) + } + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.serialNumber == targetItem.serialNumber) { + callBack.upOrder() + } else { + val srcOrder = srcItem.serialNumber + srcItem.serialNumber = targetItem.serialNumber + targetItem.serialNumber = srcOrder + movedItems.add(srcItem) + movedItems.add(targetItem) + } + } + swapItem(srcPosition, targetPosition) + return true + } + + private val movedItems = hashSetOf() + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (movedItems.isNotEmpty()) { + callBack.update(*movedItems.toTypedArray()) + movedItems.clear() + } + } + + val dragSelectCallback: DragSelectTouchHelper.Callback = + object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { + override fun currentSelectedId(): MutableSet { + return selected + } + + override fun getItemId(position: Int): TxtTocRule { + return getItem(position)!! + } + + override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { + getItem(position)?.let { + if (isSelected) { + selected.add(it) + } else { + selected.remove(it) + } + notifyItemChanged(position, bundleOf(Pair("selected", null))) + callBack.upCountView() + return true + } + return false + } + } + + interface CallBack { + fun del(source: TxtTocRule) + fun edit(source: TxtTocRule) + fun update(vararg source: TxtTocRule) + fun toTop(source: TxtTocRule) + fun toBottom(source: TxtTocRule) + fun upOrder() + fun upCountView() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleViewModel.kt new file mode 100644 index 000000000..47c261a16 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/local/rule/TxtTocRuleViewModel.kt @@ -0,0 +1,86 @@ +package io.legado.app.ui.book.local.rule + +import android.app.Application +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.help.DefaultData +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray + +class TxtTocRuleViewModel(app: Application) : BaseViewModel(app) { + + fun save(txtTocRule: TxtTocRule) { + execute { + appDb.txtTocRuleDao.insert(txtTocRule) + } + } + + fun del(vararg txtTocRule: TxtTocRule) { + execute { + appDb.txtTocRuleDao.delete(*txtTocRule) + } + } + + fun update(vararg txtTocRule: TxtTocRule) { + execute { + appDb.txtTocRuleDao.update(*txtTocRule) + } + } + + fun importDefault() { + execute { + DefaultData.importDefaultTocRules() + } + } + + fun importOnLine(url: String, finally: (msg: String) -> Unit) { + execute { + okHttpClient.newCallResponseBody { + url(url) + }.text("utf-8").let { json -> + GSON.fromJsonArray(json)?.let { + appDb.txtTocRuleDao.insert(*it.toTypedArray()) + } + } + }.onSuccess { + finally("导入成功") + }.onError { + finally("导入失败") + } + } + + fun toTop(vararg rules: TxtTocRule) { + execute { + val minOrder = appDb.txtTocRuleDao.minOrder - 1 + rules.forEachIndexed { index, source -> + source.serialNumber = minOrder - index + } + appDb.txtTocRuleDao.update(*rules) + } + } + + fun toBottom(vararg sources: TxtTocRule) { + execute { + val maxOrder = appDb.txtTocRuleDao.maxOrder + 1 + sources.forEachIndexed { index, source -> + source.serialNumber = maxOrder + index + } + appDb.txtTocRuleDao.update(*sources) + } + } + + fun upOrder() { + execute { + val sources = appDb.txtTocRuleDao.all + for ((index: Int, source: TxtTocRule) in sources.withIndex()) { + source.serialNumber = index + 1 + } + appDb.txtTocRuleDao.update(*sources.toTypedArray()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt new file mode 100644 index 000000000..ec77fbbd7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt @@ -0,0 +1,279 @@ +package io.legado.app.ui.book.read + +import android.annotation.SuppressLint +import android.content.pm.ActivityInfo +import android.os.Build +import android.os.Bundle +import android.view.* +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.activity.viewModels +import androidx.core.view.isVisible +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst.charsets +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.ActivityBookReadBinding +import io.legado.app.databinding.DialogDownloadChoiceBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.LocalConfig +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.model.CacheBook +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.config.BgTextConfigDialog +import io.legado.app.ui.book.read.config.ClickActionConfigDialog +import io.legado.app.ui.book.read.config.PaddingConfigDialog +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding + +/** + * 阅读界面 + */ +abstract class BaseReadBookActivity : + VMBaseActivity(imageBg = false) { + + override val binding by viewBinding(ActivityBookReadBinding::inflate) + override val viewModel by viewModels() + var bottomDialog = 0 + private val selectBookFolderResult = registerForActivityResult(HandleFileContract()){ + it.uri?.let { + ReadBook.book?.let { book -> + viewModel.loadChapterList(book) + } + } ?: ReadBook.upMsg("没有权限访问") + } + + override fun onCreate(savedInstanceState: Bundle?) { + ReadBook.msg = null + setOrientation() + upLayoutInDisplayCutoutMode() + super.onCreate(savedInstanceState) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.navigationBar.setBackgroundColor(bottomBackground) + viewModel.permissionDenialLiveData.observe(this) { + selectBookFolderResult.launch { + mode = HandleFileContract.SYS_DIR + title = "选择书籍所在文件夹" + } + } + if (!LocalConfig.readHelpVersionIsLast) { + showClickRegionalConfig() + } + } + + fun showPaddingConfig() { + showDialogFragment() + } + + fun showBgTextConfig() { + showDialogFragment() + } + + fun showClickRegionalConfig() { + showDialogFragment() + } + + /** + * 屏幕方向 + */ + @SuppressLint("SourceLockedOrientationActivity") + fun setOrientation() { + when (AppConfig.screenOrientation) { + "0" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + "1" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + "2" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + "3" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + } + } + + /** + * 更新状态栏,导航栏 + */ + fun upSystemUiVisibility( + isInMultiWindow: Boolean, + toolBarHide: Boolean = true + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.run { + if (toolBarHide && ReadBookConfig.hideNavigationBar) { + hide(WindowInsets.Type.navigationBars()) + } else { + show(WindowInsets.Type.navigationBars()) + } + if (toolBarHide && ReadBookConfig.hideStatusBar) { + hide(WindowInsets.Type.statusBars()) + } else { + show(WindowInsets.Type.statusBars()) + } + } + } + upSystemUiVisibilityO(isInMultiWindow, toolBarHide) + if (toolBarHide) { + setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark()) + } else { + val statusBarColor = ThemeStore.statusBarColor(this, AppConfig.isTransparentStatusBar) + setLightStatusBar(ColorUtils.isColorLight(statusBarColor)) + } + } + + @Suppress("DEPRECATION") + private fun upSystemUiVisibilityO( + isInMultiWindow: Boolean, + toolBarHide: Boolean = true + ) { + var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_IMMERSIVE + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + if (!isInMultiWindow) { + flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } + if (ReadBookConfig.hideNavigationBar) { + flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + if (toolBarHide) { + flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + } + } + if (ReadBookConfig.hideStatusBar && toolBarHide) { + flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN + } + window.decorView.systemUiVisibility = flag + } + + override fun upNavigationBarColor() { + upNavigationBar() + when { + binding.readMenu.isVisible -> super.upNavigationBarColor() + bottomDialog > 0 -> super.upNavigationBarColor() + !AppConfig.immNavigationBar -> super.upNavigationBarColor() + else -> setNavigationBarColorAuto(ReadBookConfig.bgMeanColor) + } + } + + @SuppressLint("RtlHardcoded") + private fun upNavigationBar() { + binding.navigationBar.run { + if (bottomDialog > 0 || binding.readMenu.isVisible) { + val navigationBarHeight = + if (ReadBookConfig.hideNavigationBar) navigationBarHeight else 0 + when (navigationBarGravity) { + Gravity.BOTTOM -> layoutParams = + (layoutParams as FrameLayout.LayoutParams).apply { + height = navigationBarHeight + width = MATCH_PARENT + gravity = Gravity.BOTTOM + } + Gravity.LEFT -> layoutParams = + (layoutParams as FrameLayout.LayoutParams).apply { + height = MATCH_PARENT + width = navigationBarHeight + gravity = Gravity.LEFT + } + Gravity.RIGHT -> layoutParams = + (layoutParams as FrameLayout.LayoutParams).apply { + height = MATCH_PARENT + width = navigationBarHeight + gravity = Gravity.RIGHT + } + } + visible() + } else { + gone() + } + } + } + + /** + * 保持亮屏 + */ + fun keepScreenOn(on: Boolean) { + if (on) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + /** + * 适配刘海 + */ + private fun upLayoutInDisplayCutoutMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ReadBookConfig.readBodyToLh) { + window.attributes = window.attributes.apply { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + } + + @SuppressLint("InflateParams") + fun showDownloadDialog() { + ReadBook.book?.let { book -> + alert(titleResource = R.string.offline_cache) { + val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply { + root.setBackgroundColor(root.context.backgroundColor) + editStart.setText((book.durChapterIndex + 1).toString()) + editEnd.setText(book.totalChapterNum.toString()) + } + customView { alertBinding.root } + yesButton { + alertBinding.run { + val start = editStart.text?.toString()?.toInt() ?: 0 + val end = editEnd.text?.toString()?.toInt() ?: book.totalChapterNum + CacheBook.start(this@BaseReadBookActivity, book.bookUrl, start - 1, end - 1) + } + } + noButton() + } + } + } + + fun showCharsetConfig() { + alert(R.string.set_charset) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "charset" + editView.setFilterValues(charsets) + editView.setText(ReadBook.book?.charset) + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + ReadBook.setCharset(it) + } + } + cancelButton() + } + } + + fun showPageAnimConfig(success: () -> Unit) { + val items = arrayListOf() + items.add(getString(R.string.btn_default_s)) + items.add(getString(R.string.page_anim_cover)) + items.add(getString(R.string.page_anim_slide)) + items.add(getString(R.string.page_anim_simulation)) + items.add(getString(R.string.page_anim_scroll)) + items.add(getString(R.string.page_anim_none)) + selector(R.string.page_anim, items) { _, i -> + ReadBook.book?.setPageAnim(i - 1) + success() + } + } + + fun isPrevKey(keyCode: Int): Boolean { + val prevKeysStr = getPrefString(PreferKey.prevKeys) + return prevKeysStr?.split(",")?.contains(keyCode.toString()) ?: false + } + + fun isNextKey(keyCode: Int): Boolean { + val nextKeysStr = getPrefString(PreferKey.nextKeys) + return nextKeysStr?.split(",")?.contains(keyCode.toString()) ?: false + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/PhotoDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/PhotoDialog.kt new file mode 100644 index 000000000..c3c8678e8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/PhotoDialog.kt @@ -0,0 +1,53 @@ +package io.legado.app.ui.book.read + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogPhotoViewBinding +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.page.provider.ImageProvider +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class PhotoDialog() : BaseDialogFragment(R.layout.dialog_photo_view) { + + constructor(chapterIndex: Int, src: String) : this() { + arguments = Bundle().apply { + putInt("chapterIndex", chapterIndex) + putString("src", src) + } + } + + private val binding by viewBinding(DialogPhotoViewBinding::bind) + + override fun onStart() { + super.onStart() + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + arguments?.let { + val chapterIndex = it.getInt("chapterIndex") + val src = it.getString("src") + ReadBook.book?.let { book -> + src?.let { + execute { + ImageProvider.getImage(book, chapterIndex, src, ReadBook.bookSource) + }.onSuccess { bitmap -> + if (bitmap != null) { + binding.photoView.setImageBitmap(bitmap) + } + } + } + } + } + + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt new file mode 100644 index 000000000..519388b52 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -0,0 +1,1071 @@ +package io.legado.app.ui.book.read + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.view.* +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.get +import androidx.core.view.isVisible +import androidx.core.view.size +import com.jaredrummler.android.colorpicker.ColorPickerDialogListener +import io.legado.app.BuildConfig +import io.legado.app.R +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.constant.Status +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookProgress +import io.legado.app.data.entities.BookSource +import io.legado.app.help.BookHelp +import io.legado.app.help.IntentData +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.ReadTipConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.storage.AppWebDav +import io.legado.app.help.storage.Backup +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.accentColor +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.receiver.TimeBatteryReceiver +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.ui.book.changesource.ChangeSourceDialog +import io.legado.app.ui.book.read.config.* +import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.BG_COLOR +import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.TEXT_COLOR +import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_COLOR +import io.legado.app.ui.book.read.page.ContentTextView +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection +import io.legado.app.ui.book.read.page.provider.TextPageFactory +import io.legado.app.ui.book.searchContent.SearchContentActivity +import io.legado.app.ui.book.searchContent.SearchResult +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.toc.BookmarkDialog +import io.legado.app.ui.book.toc.TocActivityResult +import io.legado.app.ui.browser.WebViewActivity +import io.legado.app.ui.dict.DictDialog +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.replace.ReplaceRuleActivity +import io.legado.app.ui.replace.edit.ReplaceEditActivity +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO + +class ReadBookActivity : BaseReadBookActivity(), + View.OnTouchListener, + ReadView.CallBack, + TextActionMenu.CallBack, + ContentTextView.CallBack, + ReadMenu.CallBack, + SearchMenu.CallBack, + ReadAloudDialog.CallBack, + ChangeSourceDialog.CallBack, + ReadBook.CallBack, + AutoReadDialog.CallBack, + TocRegexDialog.CallBack, + ColorPickerDialogListener { + + private val tocActivity = + registerForActivityResult(TocActivityResult()) { + it?.let { + viewModel.openChapter(it.first, it.second) + } + } + private val sourceEditActivity = + registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { + if (it.resultCode == RESULT_OK) { + viewModel.upBookSource { + upMenuView() + } + } + } + private val replaceActivity = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + it ?: return@registerForActivityResult + if (it.resultCode == RESULT_OK) { + viewModel.replaceRuleChanged() + } + } + private val searchContentActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + it ?: return@registerForActivityResult + it.data?.let { data -> + data.getIntExtra("chapterIndex", ReadBook.durChapterIndex).let { _ -> + viewModel.searchContentQuery = data.getStringExtra("query") ?: "" + val searchResultIndex = data.getIntExtra("searchResultIndex", 0) + isShowingSearchResult = true + binding.searchMenu.updateSearchResultIndex(searchResultIndex) + binding.searchMenu.selectedSearchResult?.let { currentResult -> + skipToSearch(currentResult) + showActionMenu() + } + } + } + } + private var menu: Menu? = null + val textActionMenu: TextActionMenu by lazy { + TextActionMenu(this, this) + } + + override val isInitFinish: Boolean get() = viewModel.isInitFinish + override val isScroll: Boolean get() = binding.readView.isScroll + private var keepScreenJon: Job? = null + private var autoPageJob: Job? = null + private var backupJob: Job? = null + override var autoPageProgress = 0 + override var isAutoPage = false + override var isShowingSearchResult = false + override var isSelectingSearchResult = false + set(value) { + field = value && isShowingSearchResult + } + private var screenTimeOut: Long = 0 + private var timeBatteryReceiver: TimeBatteryReceiver? = null + private var loadStates: Boolean = false + override val pageFactory: TextPageFactory get() = binding.readView.pageFactory + override val headerHeight: Int get() = binding.readView.curPage.headerHeight + private val menuLayoutIsVisible get() = bottomDialog > 0 || binding.readMenu.isVisible + + @SuppressLint("ClickableViewAccessibility") + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + binding.cursorLeft.setColorFilter(accentColor) + binding.cursorRight.setColorFilter(accentColor) + binding.cursorLeft.setOnTouchListener(this) + binding.cursorRight.setOnTouchListener(this) + upScreenTimeOut() + ReadBook.callBack = this + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + viewModel.initData(intent) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + upSystemUiVisibility() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + binding.readView.upStatusBar() + } + + override fun onResume() { + super.onResume() + ReadBook.readStartTime = System.currentTimeMillis() + upSystemUiVisibility() + timeBatteryReceiver = TimeBatteryReceiver.register(this) + binding.readView.upTime() + } + + override fun onPause() { + super.onPause() + autoPageStop() + backupJob?.cancel() + ReadBook.saveRead() + timeBatteryReceiver?.let { + unregisterReceiver(it) + timeBatteryReceiver = null + } + upSystemUiVisibility() + if (!BuildConfig.DEBUG) { + ReadBook.uploadProgress() + Backup.autoBack(this) + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_read, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + this.menu = menu + upMenu() + return super.onPrepareOptionsMenu(menu) + } + + /** + * 更新菜单 + */ + private fun upMenu() { + val menu = menu + val book = ReadBook.book + if (menu != null && book != null) { + val onLine = !book.isLocalBook() + for (i in 0 until menu.size) { + val item = menu[i] + when (item.groupId) { + R.id.menu_group_on_line -> item.isVisible = onLine + R.id.menu_group_local -> item.isVisible = !onLine + R.id.menu_group_text -> item.isVisible = book.isLocalTxt() + else -> when (item.itemId) { + R.id.menu_enable_replace -> item.isChecked = book.getUseReplaceRule() + R.id.menu_re_segment -> item.isChecked = book.getReSegment() + R.id.menu_reverse_content -> item.isVisible = onLine + } + } + } + launch { + menu.findItem(R.id.menu_get_progress)?.isVisible = + withContext(IO) { + runCatching { AppWebDav.initWebDav() } + .getOrElse { false } + } + } + } + } + + /** + * 菜单 + */ + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_change_source -> { + binding.readMenu.runMenuOut() + ReadBook.book?.let { + showDialogFragment(ChangeSourceDialog(it.name, it.author)) + } + } + R.id.menu_refresh -> { + if (ReadBook.bookSource == null) { + upContent() + } else { + ReadBook.book?.let { + ReadBook.curTextChapter = null + binding.readView.upContent() + viewModel.refreshContent(it) + } + } + } + R.id.menu_download -> showDownloadDialog() + R.id.menu_add_bookmark -> { + val book = ReadBook.book + val page = ReadBook.curTextChapter?.page(ReadBook.durPageIndex()) + if (book != null && page != null) { + val bookmark = book.createBookMark().apply { + chapterIndex = ReadBook.durChapterIndex + chapterPos = ReadBook.durChapterPos + chapterName = page.title + bookText = page.text.trim() + } + showDialogFragment(BookmarkDialog(bookmark)) + } + } + R.id.menu_copy_text -> showDialogFragment( + TextDialog(ReadBook.curTextChapter?.getContent()) + ) + R.id.menu_update_toc -> ReadBook.book?.let { + if (it.isEpub()) { + BookHelp.clearCache(it) + } + loadChapterList(it) + } + R.id.menu_enable_replace -> ReadBook.book?.let { + it.setUseReplaceRule(!it.getUseReplaceRule()) + ReadBook.saveRead() + menu?.findItem(R.id.menu_enable_replace)?.isChecked = it.getUseReplaceRule() + viewModel.replaceRuleChanged() + } + R.id.menu_re_segment -> ReadBook.book?.let { + it.setReSegment(!it.getReSegment()) + menu?.findItem(R.id.menu_re_segment)?.isChecked = it.getReSegment() + ReadBook.loadContent(false) + } + R.id.menu_page_anim -> showPageAnimConfig { + binding.readView.upPageAnim() + ReadBook.loadContent(false) + } + R.id.menu_log -> showDialogFragment() + R.id.menu_toc_regex -> showDialogFragment( + TocRegexDialog(ReadBook.book?.tocUrl) + ) + R.id.menu_reverse_content -> ReadBook.book?.let { + viewModel.reverseContent(it) + } + R.id.menu_set_charset -> showCharsetConfig() + R.id.menu_image_style -> { + val imgStyles = + arrayListOf(Book.imgStyleDefault, Book.imgStyleFull, Book.imgStyleText) + selector( + R.string.image_style, + imgStyles + ) { _, index -> + ReadBook.book?.setImageStyle(imgStyles[index]) + ReadBook.loadContent(false) + } + } + R.id.menu_get_progress -> ReadBook.book?.let { + viewModel.syncBookProgress(it) { progress -> + sureSyncProgress(progress) + } + } + R.id.menu_help -> showReadMenuHelp() + } + return super.onCompatOptionsItemSelected(item) + } + + /** + * 按键拦截,显示菜单 + */ + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + val keyCode = event?.keyCode + val action = event?.action + val isDown = action == 0 + + if (keyCode == KeyEvent.KEYCODE_MENU) { + if (isDown && !binding.readMenu.cnaShowMenu) { + binding.readMenu.runMenuIn() + return true + } + if (!isDown && !binding.readMenu.cnaShowMenu) { + binding.readMenu.cnaShowMenu = true + return true + } + } + return super.dispatchKeyEvent(event) + } + + /** + * 按键事件 + */ + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (menuLayoutIsVisible) { + return super.onKeyDown(keyCode, event) + } + when { + isPrevKey(keyCode) -> { + if (keyCode != KeyEvent.KEYCODE_UNKNOWN) { + binding.readView.pageDelegate?.keyTurnPage(PageDirection.PREV) + return true + } + } + isNextKey(keyCode) -> { + if (keyCode != KeyEvent.KEYCODE_UNKNOWN) { + binding.readView.pageDelegate?.keyTurnPage(PageDirection.NEXT) + return true + } + } + keyCode == KeyEvent.KEYCODE_VOLUME_UP -> { + if (volumeKeyPage(PageDirection.PREV)) { + return true + } + } + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (volumeKeyPage(PageDirection.NEXT)) { + return true + } + } + keyCode == KeyEvent.KEYCODE_PAGE_UP -> { + binding.readView.pageDelegate?.keyTurnPage(PageDirection.PREV) + return true + } + keyCode == KeyEvent.KEYCODE_PAGE_DOWN -> { + binding.readView.pageDelegate?.keyTurnPage(PageDirection.NEXT) + return true + } + keyCode == KeyEvent.KEYCODE_SPACE -> { + binding.readView.pageDelegate?.keyTurnPage(PageDirection.NEXT) + return true + } + } + return super.onKeyDown(keyCode, event) + } + + /** + * 长按事件 + */ + override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> { + finish() + return true + } + } + return super.onKeyLongPress(keyCode, event) + } + + /** + * 松开按键事件 + */ + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (volumeKeyPage(PageDirection.NONE)) { + return true + } + } + KeyEvent.KEYCODE_BACK -> { + event?.let { + if ((event.flags and KeyEvent.FLAG_CANCELED_LONG_PRESS == 0) + && event.isTracking + && !event.isCanceled + ) { + if (BaseReadAloudService.isPlay()) { + ReadAloud.pause(this) + toastOnUi(R.string.read_aloud_pause) + return true + } + if (isAutoPage) { + autoPageStop() + return true + } + if (getPrefBoolean("disableReturnKey")) { + if (menuLayoutIsVisible) { + finish() + } + return true + } + } + } + } + } + return super.onKeyUp(keyCode, event) + } + + /** + * view触摸,文字选择 + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean = binding.run { + when (event.action) { + MotionEvent.ACTION_DOWN -> textActionMenu.dismiss() + MotionEvent.ACTION_MOVE -> { + when (v.id) { + R.id.cursor_left -> readView.curPage.selectStartMove( + event.rawX + cursorLeft.width, + event.rawY - cursorLeft.height + ) + R.id.cursor_right -> readView.curPage.selectEndMove( + event.rawX - cursorRight.width, + event.rawY - cursorRight.height + ) + } + } + MotionEvent.ACTION_UP -> showTextActionMenu() + } + return true + } + + /** + * 更新文字选择开始位置 + */ + override fun upSelectedStart(x: Float, y: Float, top: Float) = binding.run { + cursorLeft.x = x - cursorLeft.width + cursorLeft.y = y + cursorLeft.visible(true) + textMenuPosition.x = x + textMenuPosition.y = top + } + + /** + * 更新文字选择结束位置 + */ + override fun upSelectedEnd(x: Float, y: Float) = binding.run { + cursorRight.x = x + cursorRight.y = y + cursorRight.visible(true) + } + + /** + * 取消文字选择 + */ + override fun onCancelSelect() = binding.run { + cursorLeft.invisible() + cursorRight.invisible() + textActionMenu.dismiss() + } + + /** + * 显示文本操作菜单 + */ + override fun showTextActionMenu() { + val navigationBarHeight = + if (!ReadBookConfig.hideNavigationBar && navigationBarGravity == Gravity.BOTTOM) + navigationBarHeight else 0 + textActionMenu.show( + binding.textMenuPosition, + binding.root.height + navigationBarHeight, + binding.textMenuPosition.x.toInt(), + binding.textMenuPosition.y.toInt(), + binding.cursorLeft.y.toInt() + binding.cursorLeft.height, + binding.cursorRight.x.toInt(), + binding.cursorRight.y.toInt() + binding.cursorRight.height + ) + } + + /** + * 当前选择的文本 + */ + override val selectedText: String get() = binding.readView.curPage.selectedText + + /** + * 文本选择菜单操作 + */ + override fun onMenuItemSelected(itemId: Int): Boolean { + when (itemId) { + R.id.menu_bookmark -> binding.readView.curPage.let { + val bookmark = it.createBookmark() + if (bookmark == null) { + toastOnUi(R.string.create_bookmark_error) + } else { + showDialogFragment(BookmarkDialog(bookmark)) + } + return true + } + R.id.menu_replace -> { + val scopes = arrayListOf() + ReadBook.book?.name?.let { + scopes.add(it) + } + ReadBook.bookSource?.bookSourceUrl?.let { + scopes.add(it) + } + replaceActivity.launch( + ReplaceEditActivity.startIntent( + this, + pattern = selectedText, + scope = scopes.joinToString(";") + ) + ) + return true + } + R.id.menu_search_content -> { + viewModel.searchContentQuery = selectedText + openSearchActivity(selectedText) + return true + } + R.id.menu_dict -> { + showDialogFragment(DictDialog(selectedText)) + return true + } + } + return false + } + + /** + * 文本选择菜单操作完成 + */ + override fun onMenuActionFinally() = binding.run { + textActionMenu.dismiss() + readView.curPage.cancelSelect() + readView.isTextSelected = false + } + + /** + * 音量键翻页 + */ + private fun volumeKeyPage(direction: PageDirection): Boolean { + if (!binding.readMenu.isVisible) { + if (getPrefBoolean("volumeKeyPage", true)) { + if (getPrefBoolean("volumeKeyPageOnPlay") + || BaseReadAloudService.pause + ) { + binding.readView.pageDelegate?.isCancel = false + binding.readView.pageDelegate?.keyTurnPage(direction) + return true + } + } + } + return false + } + + override fun upMenuView() { + launch { + upMenu() + binding.readMenu.upBookView() + } + } + + override fun loadChapterList(book: Book) { + ReadBook.upMsg(getString(R.string.toc_updateing)) + viewModel.loadChapterList(book) + } + + /** + * 内容加载完成 + */ + override fun contentLoadFinish() { + if (intent.getBooleanExtra("readAloud", false)) { + intent.removeExtra("readAloud") + ReadBook.readAloud() + } + loadStates = true + } + + /** + * 更新内容 + */ + override fun upContent( + relativePosition: Int, + resetPageOffset: Boolean, + success: (() -> Unit)? + ) { + launch { + autoPageProgress = 0 + binding.readView.upContent(relativePosition, resetPageOffset) + binding.readMenu.setSeekPage(ReadBook.durPageIndex()) + loadStates = false + success?.invoke() + } + } + + override fun upPageAnim() { + launch { + binding.readView.upPageAnim() + } + } + + /** + * 页面改变 + */ + override fun pageChanged() { + launch { + autoPageProgress = 0 + binding.readMenu.setSeekPage(ReadBook.durPageIndex()) + startBackupJob() + } + } + + /** + * 显示菜单 + */ + override fun showMenuBar() { + binding.readMenu.runMenuIn() + } + + override val oldBook: Book? + get() = ReadBook.book + + override fun changeTo(source: BookSource, book: Book) { + viewModel.changeTo(source, book) + } + + override fun showActionMenu() { + when { + BaseReadAloudService.isRun -> showReadAloudDialog() + isAutoPage -> showDialogFragment() + isShowingSearchResult -> binding.searchMenu.runMenuIn() + else -> binding.readMenu.runMenuIn() + } + } + + override fun showReadMenuHelp() { + val text = String(assets.open("help/readMenuHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + + /** + * 显示朗读菜单 + */ + override fun showReadAloudDialog() { + showDialogFragment() + } + + /** + * 自动翻页 + */ + override fun autoPage() { + ReadAloud.stop(this) + if (isAutoPage) { + autoPageStop() + } else { + isAutoPage = true + autoPagePlus() + binding.readMenu.setAutoPage(true) + screenTimeOut = -1L + screenOffTimerStart() + } + } + + override fun autoPageStop() { + if (isAutoPage) { + isAutoPage = false + autoPageJob?.cancel() + binding.readView.invalidate() + binding.readMenu.setAutoPage(false) + upScreenTimeOut() + } + } + + private fun autoPagePlus() { + autoPageJob?.cancel() + autoPageJob = launch { + while (isActive) { + var delayMillis = ReadBookConfig.autoReadSpeed * 1000L / binding.readView.height + var scrollOffset = 1 + if (delayMillis < 20) { + var delayInt = delayMillis.toInt() + if (delayInt == 0) delayInt = 1 + scrollOffset = 20 / delayInt + delayMillis = 20 + } + delay(delayMillis) + if (!menuLayoutIsVisible) { + if (binding.readView.isScroll) { + binding.readView.curPage.scroll(-scrollOffset) + } else { + autoPageProgress += scrollOffset + if (autoPageProgress >= binding.readView.height) { + autoPageProgress = 0 + if (!binding.readView.fillPage(PageDirection.NEXT)) { + autoPageStop() + } + } else { + binding.readView.invalidate() + } + } + } + } + } + } + + override fun openSourceEditActivity() { + ReadBook.bookSource?.let { + sourceEditActivity.launch { + putExtra("sourceUrl", it.bookSourceUrl) + } + } + } + + /** + * 替换 + */ + override fun openReplaceRule() { + replaceActivity.launch(Intent(this, ReplaceRuleActivity::class.java)) + } + + /** + * 打开目录 + */ + override fun openChapterList() { + ReadBook.book?.let { + tocActivity.launch(it.bookUrl) + } + } + + /** + * 打开搜索界面 + */ + override fun openSearchActivity(searchWord: String?) { + ReadBook.book?.let { + searchContentActivity.launch(Intent(this, SearchContentActivity::class.java).apply { + putExtra("bookUrl", it.bookUrl) + putExtra("searchWord", searchWord ?: viewModel.searchContentQuery) + }) + } + } + + /** + * 禁用书源 + */ + override fun disableSource() { + viewModel.disableSource() + } + + /** + * 显示阅读样式配置 + */ + override fun showReadStyle() { + showDialogFragment() + } + + /** + * 显示更多设置 + */ + override fun showMoreSetting() { + showDialogFragment() + } + + override fun showSearchSetting() { + showDialogFragment() + } + + /** + * 更新状态栏,导航栏 + */ + override fun upSystemUiVisibility() { + upSystemUiVisibility(isInMultiWindow, !binding.readMenu.isVisible) + upNavigationBarColor() + } + + override fun exitSearchMenu() { + if (isShowingSearchResult) { + isShowingSearchResult = false + binding.searchMenu.invalidate() + } + } + + override fun showLogin() { + ReadBook.bookSource?.let { + startActivity { + putExtra("type", "bookSource") + putExtra("key", it.bookSourceUrl) + } + } + } + + override fun payAction() { + Coroutine.async(this) { + val book = ReadBook.book ?: throw NoStackTraceException("no book") + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: throw NoStackTraceException("no chapter") + val source = ReadBook.bookSource ?: throw NoStackTraceException("no book source") + val payAction = source.getContentRule().payAction + if (payAction.isNullOrEmpty()) { + throw NoStackTraceException("no pay action") + } + if (payAction.isAbsUrl()) { + payAction + } else { + source.evalJS(payAction) { + put("book", book) + put("chapter", chapter) + }?.toString() + } + }.onSuccess { + it?.let { + startActivity { + putExtra("title", getString(R.string.chapter_pay)) + putExtra("url", it) + IntentData.put(it, ReadBook.bookSource) + } + } + }.onError { + toastOnUi(it.localizedMessage) + } + } + + /** + * 朗读按钮 + */ + override fun onClickReadAloud() { + autoPageStop() + when { + !BaseReadAloudService.isRun -> ReadBook.readAloud() + BaseReadAloudService.pause -> ReadAloud.resume(this) + else -> ReadAloud.pause(this) + } + } + + /** + * colorSelectDialog + */ + override fun onColorSelected(dialogId: Int, color: Int) = ReadBookConfig.durConfig.run { + when (dialogId) { + TEXT_COLOR -> { + setCurTextColor(color) + postEvent(EventBus.UP_CONFIG, false) + } + BG_COLOR -> { + setCurBg(0, "#${color.hexString}") + ReadBookConfig.upBg() + postEvent(EventBus.UP_CONFIG, false) + } + TIP_COLOR -> { + ReadTipConfig.tipColor = color + postEvent(EventBus.TIP_COLOR, "") + postEvent(EventBus.UP_CONFIG, true) + } + } + } + + /** + * colorSelectDialog + */ + override fun onDialogDismissed(dialogId: Int) = Unit + + override fun onTocRegexDialogResult(tocRegex: String) { + ReadBook.book?.let { + it.tocUrl = tocRegex + viewModel.loadChapterList(it) + } + } + + private fun sureSyncProgress(progress: BookProgress) { + alert(R.string.get_book_progress) { + setMessage(R.string.current_progress_exceeds_cloud) + okButton { + ReadBook.setProgress(progress) + } + noButton() + } + } + + override fun navigateToSearch(searchResult: SearchResult) { + skipToSearch(searchResult) + } + + private fun skipToSearch(searchResult: SearchResult) { + val previousResult = binding.searchMenu.previousSearchResult + + fun jumpToPosition(){ + ReadBook.curTextChapter?.let { + binding.searchMenu.updateSearchInfo() + val positions = viewModel.searchResultPositions(it, searchResult) + ReadBook.skipToPage(positions[0]) { + launch { + isSelectingSearchResult = true + binding.readView.curPage.selectStartMoveIndex(0, positions[1], positions[2]) + when (positions[3]) { + 0 -> binding.readView.curPage.selectEndMoveIndex( + 0, positions[1], positions[2] + viewModel.searchContentQuery.length - 1 + ) + 1 -> binding.readView.curPage.selectEndMoveIndex( + 0, positions[1] + 1, positions[4] + ) + //consider change page, jump to scroll position + -1 -> binding.readView.curPage.selectEndMoveIndex(1, 0, positions[4]) + } + binding.readView.isTextSelected = true + isSelectingSearchResult = false + } + } + } + } + + if (previousResult.chapterIndex != searchResult.chapterIndex) { + viewModel.openChapter(searchResult.chapterIndex) { + jumpToPosition() + } + } else { + jumpToPosition() + } + } + + private fun startBackupJob() { + backupJob?.cancel() + backupJob = launch { + delay(120000) + ReadBook.book?.let { + AppWebDav.uploadBookProgress(it) + Backup.autoBack(this@ReadBookActivity) + } + } + } + + override fun finish() { + ReadBook.book?.let { + if (!ReadBook.inBookshelf) { + alert(title = getString(R.string.add_to_shelf)) { + setMessage(getString(R.string.check_add_bookshelf, it.name)) + okButton { + ReadBook.inBookshelf = true + setResult(Activity.RESULT_OK) + } + noButton { viewModel.removeFromBookshelf { super.finish() } } + } + } else { + super.finish() + } + } ?: super.finish() + } + + override fun onDestroy() { + super.onDestroy() + textActionMenu.dismiss() + binding.readView.onDestroy() + ReadBook.msg = null + ReadBook.callBack = null + if (!BuildConfig.DEBUG) { + Backup.autoBack(this) + } + } + + override fun observeLiveBus() = binding.run { + super.observeLiveBus() + observeEvent(EventBus.TIME_CHANGED) { readView.upTime() } + observeEvent(EventBus.BATTERY_CHANGED) { readView.upBattery(it) } + observeEvent(EventBus.OPEN_CHAPTER) { + viewModel.openChapter(it.index, ReadBook.durChapterPos) + readView.upContent() + } + observeEvent(EventBus.MEDIA_BUTTON) { + if (it) { + onClickReadAloud() + } else { + ReadBook.readAloud(!BaseReadAloudService.pause) + } + } + observeEvent(EventBus.UP_CONFIG) { + upSystemUiVisibility() + readView.upBg() + readView.upStyle() + if (it) { + ReadBook.loadContent(resetPageOffset = false) + } else { + readView.upContent(resetPageOffset = false) + } + } + observeEvent(EventBus.ALOUD_STATE) { + if (it == Status.STOP || it == Status.PAUSE) { + ReadBook.curTextChapter?.let { textChapter -> + val page = textChapter.getPageByReadPos(ReadBook.durChapterPos) + if (page != null) { + page.removePageAloudSpan() + readView.upContent(resetPageOffset = false) + } + } + } + } + observeEventSticky(EventBus.TTS_PROGRESS) { chapterStart -> + launch(IO) { + if (BaseReadAloudService.isPlay()) { + ReadBook.curTextChapter?.let { textChapter -> + val aloudSpanStart = chapterStart - ReadBook.durChapterPos + textChapter.getPageByReadPos(ReadBook.durChapterPos) + ?.upPageAloudSpan(aloudSpanStart) + upContent() + } + } + } + } + observeEvent(PreferKey.keepLight) { + upScreenTimeOut() + } + observeEvent(PreferKey.textSelectAble) { + readView.curPage.upSelectAble(it) + } + observeEvent(PreferKey.showBrightnessView) { + readMenu.upBrightnessState() + } + } + + private fun upScreenTimeOut() { + val keepLightPrefer = getPrefString(PreferKey.keepLight)?.toInt() ?: 0 + screenTimeOut = keepLightPrefer * 1000L + screenOffTimerStart() + } + + /** + * 重置黑屏时间 + */ + override fun screenOffTimerStart() { + keepScreenJon?.cancel() + keepScreenJon = launch { + if (screenTimeOut < 0) { + keepScreenOn(true) + return@launch + } + val t = screenTimeOut - sysScreenOffTime + if (t > 0) { + keepScreenOn(true) + delay(screenTimeOut) + keepScreenOn(false) + } else { + keepScreenOn(false) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt new file mode 100644 index 000000000..af2b146fe --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt @@ -0,0 +1,362 @@ +package io.legado.app.ui.book.read + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppLog +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookProgress +import io.legado.app.data.entities.BookSource +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.help.ContentProcessor +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.storage.AppWebDav +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.model.localBook.LocalBook +import io.legado.app.model.webBook.WebBook +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.book.searchContent.SearchResult +import io.legado.app.utils.msg +import io.legado.app.utils.postEvent +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ensureActive + +class ReadBookViewModel(application: Application) : BaseViewModel(application) { + val permissionDenialLiveData = MutableLiveData() + var isInitFinish = false + var searchContentQuery = "" + private var changeSourceCoroutine: Coroutine<*>? = null + + fun initData(intent: Intent) { + execute { + ReadBook.inBookshelf = intent.getBooleanExtra("inBookshelf", true) + val bookUrl = intent.getStringExtra("bookUrl") + val book = when { + bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook + else -> appDb.bookDao.getBook(bookUrl) + } ?: ReadBook.book + when { + book != null -> initBook(book) + else -> ReadBook.upMsg(context.getString(R.string.no_book)) + } + }.onFinally { + ReadBook.saveRead() + } + } + + private fun initBook(book: Book) { + if (ReadBook.book?.bookUrl != book.bookUrl) { + ReadBook.resetData(book) + isInitFinish = true + if (ReadBook.chapterSize == 0) { + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) + } else { + loadChapterList(book) + } + } else { + if (ReadBook.durChapterIndex > ReadBook.chapterSize - 1) { + ReadBook.durChapterIndex = ReadBook.chapterSize - 1 + } + ReadBook.loadContent(resetPageOffset = true) + } + syncBookProgress(book) + } else { + ReadBook.upData(book) + isInitFinish = true + if (ReadBook.chapterSize == 0) { + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) + } else { + loadChapterList(book) + } + } else { + if (ReadBook.curTextChapter != null) { + ReadBook.callBack?.upContent(resetPageOffset = false) + } else { + ReadBook.loadContent(resetPageOffset = true) + } + } + if (!BaseReadAloudService.isRun) { + syncBookProgress(book) + } + } + if (!book.isLocalBook() && ReadBook.bookSource == null) { + autoChangeSource(book.name, book.author) + return + } + } + + private fun loadBookInfo(book: Book) { + if (book.isLocalBook()) { + loadChapterList(book) + } else { + ReadBook.bookSource?.let { source -> + WebBook.getBookInfo(viewModelScope, source, book, canReName = false) + .onSuccess { + loadChapterList(book) + }.onError { + ReadBook.upMsg("详情页出错: ${it.localizedMessage}") + } + } + } + } + + fun loadChapterList(book: Book) { + if (book.isLocalBook()) { + execute { + LocalBook.getChapterList(book).let { + appDb.bookChapterDao.delByBook(book.bookUrl) + appDb.bookChapterDao.insert(*it.toTypedArray()) + appDb.bookDao.update(book) + ReadBook.chapterSize = it.size + ReadBook.upMsg(null) + ReadBook.loadContent(resetPageOffset = true) + } + }.onError { + when (it) { + is SecurityException -> { + permissionDenialLiveData.postValue(1) + } + else -> { + AppLog.put("LoadTocError:${it.localizedMessage}", it) + ReadBook.upMsg("LoadTocError:${it.localizedMessage}") + } + } + } + } else { + ReadBook.bookSource?.let { + WebBook.getChapterList(viewModelScope, it, book) + .onSuccess(IO) { cList -> + appDb.bookChapterDao.insert(*cList.toTypedArray()) + appDb.bookDao.update(book) + ReadBook.chapterSize = cList.size + ReadBook.upMsg(null) + ReadBook.loadContent(resetPageOffset = true) + }.onError { + ReadBook.upMsg(context.getString(R.string.error_load_toc)) + } + } + } + } + + fun syncBookProgress( + book: Book, + alertSync: ((progress: BookProgress) -> Unit)? = null + ) { + if (AppConfig.syncBookProgress) + execute { + AppWebDav.getBookProgress(book) + }.onSuccess { + it?.let { progress -> + if (progress.durChapterIndex < book.durChapterIndex || + (progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos < book.durChapterPos) + ) { + alertSync?.invoke(progress) + } else { + ReadBook.setProgress(progress) + } + } + } + } + + fun changeTo(source: BookSource, book: Book) { + changeSourceCoroutine?.cancel() + changeSourceCoroutine = execute { + ReadBook.upMsg(context.getString(R.string.loading)) + if (book.tocUrl.isEmpty()) { + WebBook.getBookInfoAwait(this, source, book) + } + ensureActive() + val chapters = WebBook.getChapterListAwait(this, source, book) + ensureActive() + val oldBook = ReadBook.book!! + book.durChapterIndex = BookHelp.getDurChapter( + oldBook.durChapterIndex, + oldBook.totalChapterNum, + oldBook.durChapterTitle, + chapters + ) + book.durChapterTitle = chapters[book.durChapterIndex].title + oldBook.changeTo(book) + appDb.bookChapterDao.insert(*chapters.toTypedArray()) + ReadBook.resetData(book) + ReadBook.upMsg(null) + ReadBook.loadContent(resetPageOffset = true) + }.timeout(60000) + .onError { + context.toastOnUi("换源失败\n${it.localizedMessage}") + ReadBook.upMsg(null) + }.onFinally { + postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) + } + } + + private fun autoChangeSource(name: String, author: String) { + if (!AppConfig.autoChangeSource) return + execute { + val sources = appDb.bookSourceDao.allTextEnabled + WebBook.preciseSearchAwait(this, sources, name, author)?.let { + it.second.upInfoFromOld(ReadBook.book) + changeTo(it.first, it.second) + } ?: throw NoStackTraceException("自动换源失败") + }.onStart { + ReadBook.upMsg(context.getString(R.string.source_auto_changing)) + }.onError { + context.toastOnUi(it.msg) + }.onFinally { + ReadBook.upMsg(null) + } + } + + fun openChapter(index: Int, durChapterPos: Int = 0, success: (() -> Unit)? = null) { + ReadBook.clearTextChapter() + ReadBook.callBack?.upContent() + if (index != ReadBook.durChapterIndex) { + ReadBook.durChapterIndex = index + ReadBook.durChapterPos = durChapterPos + } + ReadBook.saveRead() + ReadBook.loadContent(resetPageOffset = true) { + success?.invoke() + } + } + + fun removeFromBookshelf(success: (() -> Unit)?) { + execute { + Book.delete(ReadBook.book) + }.onSuccess { + success?.invoke() + } + } + + fun upBookSource(success: (() -> Unit)?) { + execute { + ReadBook.book?.let { book -> + ReadBook.bookSource = appDb.bookSourceDao.getBookSource(book.origin) + } + }.onSuccess { + success?.invoke() + } + } + + fun refreshContent(book: Book) { + execute { + appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?.let { chapter -> + BookHelp.delContent(book, chapter) + ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) + } + } + } + + fun reverseContent(book: Book) { + execute { + appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?.let { chapter -> + BookHelp.reverseContent(book, chapter) + ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) + } + } + } + + /** + * 内容搜索跳转 + */ + fun searchResultPositions( + textChapter: TextChapter, + searchResult: SearchResult + ): Array { + // calculate search result's pageIndex + val pages = textChapter.pages + val content = textChapter.getContent() + + var count = 0 + var index = content.indexOf(searchContentQuery) + while (count != searchResult.resultCountWithinChapter) { + index = content.indexOf(searchContentQuery, index + 1) + count += 1 + } + val contentPosition = index + var pageIndex = 0 + var length = pages[pageIndex].text.length + while (length < contentPosition) { + pageIndex += 1 + if (pageIndex > pages.size) { + pageIndex = pages.size + break + } + length += pages[pageIndex].text.length + } + + // calculate search result's lineIndex + val currentPage = pages[pageIndex] + var lineIndex = 0 + length = length - currentPage.text.length + currentPage.textLines[lineIndex].text.length + while (length < contentPosition) { + lineIndex += 1 + if (lineIndex > currentPage.textLines.size) { + lineIndex = currentPage.textLines.size + break + } + length += currentPage.textLines[lineIndex].text.length + } + + // charIndex + val currentLine = currentPage.textLines[lineIndex] + length -= currentLine.text.length + val charIndex = contentPosition - length + var addLine = 0 + var charIndex2 = 0 + // change line + if ((charIndex + searchContentQuery.length) > currentLine.text.length) { + addLine = 1 + charIndex2 = charIndex + searchContentQuery.length - currentLine.text.length - 1 + } + // changePage + if ((lineIndex + addLine + 1) > currentPage.textLines.size) { + addLine = -1 + charIndex2 = charIndex + searchContentQuery.length - currentLine.text.length - 1 + } + return arrayOf(pageIndex, lineIndex, charIndex, addLine, charIndex2) + } + + /** + * 替换规则变化 + */ + fun replaceRuleChanged() { + execute { + ReadBook.book?.let { + ContentProcessor.get(it.name, it.origin).upReplaceRules() + ReadBook.loadContent(resetPageOffset = false) + } + } + } + + fun disableSource() { + execute { + ReadBook.bookSource?.let { + it.enabled = false + appDb.bookSourceDao.update(it) + } + } + } + + override fun onCleared() { + super.onCleared() + if (BaseReadAloudService.pause) { + ReadAloud.stop(context) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt new file mode 100644 index 000000000..219715aa7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt @@ -0,0 +1,420 @@ +package io.legado.app.ui.book.read + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View.OnClickListener +import android.view.View.OnLongClickListener +import android.view.WindowManager +import android.view.animation.Animation +import android.widget.FrameLayout +import android.widget.SeekBar +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.isGone +import androidx.core.view.isVisible +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.ViewReadMenuBinding +import io.legado.app.help.* +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.* +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.browser.WebViewActivity +import io.legado.app.ui.widget.seekbar.SeekBarChangeListener +import io.legado.app.utils.* +import splitties.views.* + +/** + * 阅读界面菜单 + */ +class ReadMenu @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + var cnaShowMenu: Boolean = false + private val callBack: CallBack get() = activity as CallBack + private val binding = ViewReadMenuBinding.inflate(LayoutInflater.from(context), this, true) + private lateinit var menuTopIn: Animation + private lateinit var menuTopOut: Animation + private lateinit var menuBottomIn: Animation + private lateinit var menuBottomOut: Animation + private val bgColor: Int = context.bottomBackground + private val textColor: Int = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) + private val bottomBackgroundList: ColorStateList = Selector.colorBuild() + .setDefaultColor(bgColor) + .setPressedColor(ColorUtils.darkenColor(bgColor)) + .create() + private var onMenuOutEnd: (() -> Unit)? = null + private val showBrightnessView + get() = context.getPrefBoolean( + PreferKey.showBrightnessView, + true + ) + private val sourceMenu by lazy { + PopupMenu(context, binding.tvSourceAction).apply { + inflate(R.menu.book_read_source) + setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_edit_source -> callBack.openSourceEditActivity() + R.id.menu_disable_source -> callBack.disableSource() + } + true + } + } + } + + init { + initView() + upBrightnessState() + bindEvent() + } + + private fun initView() = binding.run { + if (AppConfig.isNightTheme) { + fabNightTheme.setImageResource(R.drawable.ic_daytime) + } else { + fabNightTheme.setImageResource(R.drawable.ic_brightness) + } + initAnimation() + val brightnessBackground = GradientDrawable() + brightnessBackground.cornerRadius = 5F.dp + brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f)) + llBrightness.background = brightnessBackground + llBottomBg.setBackgroundColor(bgColor) + fabSearch.backgroundTintList = bottomBackgroundList + fabSearch.setColorFilter(textColor) + fabAutoPage.backgroundTintList = bottomBackgroundList + fabAutoPage.setColorFilter(textColor) + fabReplaceRule.backgroundTintList = bottomBackgroundList + fabReplaceRule.setColorFilter(textColor) + fabNightTheme.backgroundTintList = bottomBackgroundList + fabNightTheme.setColorFilter(textColor) + tvPre.setTextColor(textColor) + tvNext.setTextColor(textColor) + ivCatalog.setColorFilter(textColor) + tvCatalog.setTextColor(textColor) + ivReadAloud.setColorFilter(textColor) + tvReadAloud.setTextColor(textColor) + ivFont.setColorFilter(textColor) + tvFont.setTextColor(textColor) + ivSetting.setColorFilter(textColor) + tvSetting.setTextColor(textColor) + vwBg.setOnClickListener(null) + llBrightness.setOnClickListener(null) + seekBrightness.post { + seekBrightness.progress = AppConfig.readBrightness + } + } + + fun upBrightnessState() { + if (brightnessAuto()) { + binding.ivBrightnessAuto.setColorFilter(context.accentColor) + binding.seekBrightness.isEnabled = false + } else { + binding.ivBrightnessAuto.setColorFilter(context.buttonDisabledColor) + binding.seekBrightness.isEnabled = true + } + setScreenBrightness(AppConfig.readBrightness) + } + + /** + * 设置屏幕亮度 + */ + private fun setScreenBrightness(value: Int) { + var brightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (!brightnessAuto()) { + brightness = value.toFloat() + if (brightness < 1f) brightness = 1f + brightness /= 255f + } + val params = activity?.window?.attributes + params?.screenBrightness = brightness + activity?.window?.attributes = params + } + + fun runMenuIn() { + this.visible() + binding.titleBar.visible() + binding.bottomMenu.visible() + binding.titleBar.startAnimation(menuTopIn) + binding.bottomMenu.startAnimation(menuBottomIn) + } + + fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) { + this.onMenuOutEnd = onMenuOutEnd + if (this.isVisible) { + binding.titleBar.startAnimation(menuTopOut) + binding.bottomMenu.startAnimation(menuBottomOut) + } + } + + private fun brightnessAuto(): Boolean { + return context.getPrefBoolean("brightnessAuto", true) || !showBrightnessView + } + + private fun bindEvent() = binding.run { + titleBar.toolbar.setOnClickListener { + ReadBook.book?.let { + context.startActivity { + putExtra("name", it.name) + putExtra("author", it.author) + } + } + } + val chapterViewClickListener = OnClickListener { + if (ReadBook.isLocalBook) { + return@OnClickListener + } + if (AppConfig.readUrlInBrowser) { + context.openUrl(tvChapterUrl.text.toString().substringBefore(",{")) + } else { + context.startActivity { + val url = tvChapterUrl.text.toString() + putExtra("title", tvChapterName.text) + putExtra("url", url) + IntentData.put(url, ReadBook.bookSource?.getHeaderMap(true)) + } + } + } + val chapterViewLongClickListener = OnLongClickListener { + if (ReadBook.isLocalBook) { + return@OnLongClickListener true + } + context.alert(R.string.open_fun) { + setMessage(R.string.use_browser_open) + okButton { + AppConfig.readUrlInBrowser = true + } + noButton { + AppConfig.readUrlInBrowser = false + } + } + true + } + tvChapterName.setOnClickListener(chapterViewClickListener) + tvChapterName.setOnLongClickListener(chapterViewLongClickListener) + tvChapterUrl.setOnClickListener(chapterViewClickListener) + tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener) + //登录 + tvLogin.setOnClickListener { + callBack.showLogin() + } + //购买 + tvPay.setOnClickListener { + callBack.payAction() + } + //书源操作 + tvSourceAction.onClick { + sourceMenu.show() + } + //亮度跟随 + ivBrightnessAuto.setOnClickListener { + context.putPrefBoolean("brightnessAuto", !brightnessAuto()) + upBrightnessState() + } + //亮度调节 + seekBrightness.setOnSeekBarChangeListener(object : SeekBarChangeListener { + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + setScreenBrightness(progress) + } + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + AppConfig.readBrightness = seekBar.progress + } + + }) + + //阅读进度 + seekReadPage.setOnSeekBarChangeListener(object : SeekBarChangeListener { + + override fun onStopTrackingTouch(seekBar: SeekBar) { + ReadBook.skipToPage(seekBar.progress) + } + + }) + + //搜索 + fabSearch.setOnClickListener { + runMenuOut { + callBack.openSearchActivity(null) + } + } + + //自动翻页 + fabAutoPage.setOnClickListener { + runMenuOut { + callBack.autoPage() + } + } + + //替换 + fabReplaceRule.setOnClickListener { callBack.openReplaceRule() } + + //夜间模式 + fabNightTheme.setOnClickListener { + AppConfig.isNightTheme = !AppConfig.isNightTheme + ThemeConfig.applyDayNight(context) + } + + //上一章 + tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } + + //下一章 + tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) } + + //目录 + llCatalog.setOnClickListener { + runMenuOut { + callBack.openChapterList() + } + } + + //朗读 + llReadAloud.setOnClickListener { + runMenuOut { + callBack.onClickReadAloud() + } + } + llReadAloud.onLongClick { + runMenuOut { callBack.showReadAloudDialog() } + } + //界面 + llFont.setOnClickListener { + runMenuOut { + callBack.showReadStyle() + } + } + + //设置 + llSetting.setOnClickListener { + runMenuOut { + callBack.showMoreSetting() + } + } + } + + private fun initAnimation() { + //显示菜单 + menuTopIn = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_top_in) + menuBottomIn = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_bottom_in) + menuTopIn.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + binding.tvSourceAction.isGone = ReadBook.isLocalBook + binding.tvLogin.isGone = ReadBook.bookSource?.loginUrl.isNullOrEmpty() + binding.tvPay.isGone = ReadBook.bookSource?.loginUrl.isNullOrEmpty() + || ReadBook.curTextChapter?.isVip != true + || ReadBook.curTextChapter?.isPay == true + callBack.upSystemUiVisibility() + binding.llBrightness.visible(showBrightnessView) + } + + @SuppressLint("RtlHardcoded") + override fun onAnimationEnd(animation: Animation) { + val navigationBarHeight = + if (ReadBookConfig.hideNavigationBar) { + activity?.navigationBarHeight ?: 0 + } else { + 0 + } + binding.run { + vwMenuBg.setOnClickListener { runMenuOut() } + root.padding = 0 + when (activity?.navigationBarGravity) { + Gravity.BOTTOM -> root.bottomPadding = navigationBarHeight + Gravity.LEFT -> root.leftPadding = navigationBarHeight + Gravity.RIGHT -> root.rightPadding = navigationBarHeight + } + } + callBack.upSystemUiVisibility() + if (!LocalConfig.readMenuHelpVersionIsLast) { + callBack.showReadMenuHelp() + } + } + + override fun onAnimationRepeat(animation: Animation) = Unit + }) + + //隐藏菜单 + menuTopOut = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_top_out) + menuBottomOut = + AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_bottom_out) + menuTopOut.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + binding.vwMenuBg.setOnClickListener(null) + } + + override fun onAnimationEnd(animation: Animation) { + this@ReadMenu.invisible() + binding.titleBar.invisible() + binding.bottomMenu.invisible() + cnaShowMenu = false + onMenuOutEnd?.invoke() + callBack.upSystemUiVisibility() + } + + override fun onAnimationRepeat(animation: Animation) = Unit + }) + } + + fun upBookView() { + binding.titleBar.title = ReadBook.book?.name + ReadBook.curTextChapter?.let { + binding.tvChapterName.text = it.title + binding.tvChapterName.visible() + if (!ReadBook.isLocalBook) { + binding.tvChapterUrl.text = it.url + binding.tvChapterUrl.visible() + } else { + binding.tvChapterUrl.gone() + } + binding.seekReadPage.max = it.pageSize.minus(1) + binding.seekReadPage.progress = ReadBook.durPageIndex() + binding.tvPre.isEnabled = ReadBook.durChapterIndex != 0 + binding.tvNext.isEnabled = ReadBook.durChapterIndex != ReadBook.chapterSize - 1 + } ?: let { + binding.tvChapterName.gone() + binding.tvChapterUrl.gone() + } + } + + fun setSeekPage(seek: Int) { + binding.seekReadPage.progress = seek + } + + fun setAutoPage(autoPage: Boolean) = binding.run { + if (autoPage) { + fabAutoPage.setImageResource(R.drawable.ic_auto_page_stop) + fabAutoPage.contentDescription = context.getString(R.string.auto_next_page_stop) + } else { + fabAutoPage.setImageResource(R.drawable.ic_auto_page) + fabAutoPage.contentDescription = context.getString(R.string.auto_next_page) + } + fabAutoPage.setColorFilter(textColor) + } + + interface CallBack { + fun autoPage() + fun openReplaceRule() + fun openChapterList() + fun openSearchActivity(searchWord: String?) + fun openSourceEditActivity() + fun showReadStyle() + fun showMoreSetting() + fun showReadAloudDialog() + fun upSystemUiVisibility() + fun onClickReadAloud() + fun showReadMenuHelp() + fun showLogin() + fun payAction() + fun disableSource() + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt new file mode 100644 index 000000000..6e176c437 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt @@ -0,0 +1,235 @@ +package io.legado.app.ui.book.read + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.animation.Animation +import android.widget.FrameLayout +import androidx.core.view.isVisible +import io.legado.app.R +import io.legado.app.constant.EventBus +import io.legado.app.databinding.ViewSearchMenuBinding +import io.legado.app.help.* +import io.legado.app.lib.theme.* +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.searchContent.SearchResult +import io.legado.app.utils.* +import splitties.views.* + +/** + * 搜索界面菜单 + */ +class SearchMenu @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + private val callBack: CallBack get() = activity as CallBack + private val binding = ViewSearchMenuBinding.inflate(LayoutInflater.from(context), this, true) + + private val menuBottomIn: Animation = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_bottom_in) + private val menuBottomOut: Animation = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_bottom_out) + private val bgColor: Int = context.bottomBackground + private val textColor: Int = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) + private val bottomBackgroundList: ColorStateList = + Selector.colorBuild().setDefaultColor(bgColor).setPressedColor(ColorUtils.darkenColor(bgColor)).create() + private var onMenuOutEnd: (() -> Unit)? = null + + private val searchResultList: MutableList = mutableListOf() + private var currentSearchResultIndex: Int = 0 + private var lastSearchResultIndex: Int = 0 + private val hasSearchResult: Boolean + get() = searchResultList.isNotEmpty() + val selectedSearchResult: SearchResult? + get() = if (searchResultList.isNotEmpty()) searchResultList[currentSearchResultIndex] else null + val previousSearchResult: SearchResult + get() = searchResultList[lastSearchResultIndex] + + init { + initAnimation() + initView() + bindEvent() + updateSearchInfo() + observeSearchResultList() + } + + private fun observeSearchResultList() { + activity?.let { owner -> + eventObservable>(EventBus.SEARCH_RESULT).observe(owner, { + searchResultList.clear() + searchResultList.addAll(it) + updateSearchInfo() + }) + } + } + + private fun initView() = binding.run { + llSearchBaseInfo.setBackgroundColor(bgColor) + tvCurrentSearchInfo.setTextColor(bottomBackgroundList) + llBottomBg.setBackgroundColor(bgColor) + fabLeft.backgroundTintList = bottomBackgroundList + fabLeft.setColorFilter(textColor) + fabRight.backgroundTintList = bottomBackgroundList + fabRight.setColorFilter(textColor) + tvMainMenu.setTextColor(textColor) + tvSearchResults.setTextColor(textColor) + tvSearchExit.setTextColor(textColor) + //tvSetting.setTextColor(textColor) + ivMainMenu.setColorFilter(textColor) + ivSearchResults.setColorFilter(textColor) + ivSearchExit.setColorFilter(textColor) + //ivSetting.setColorFilter(textColor) + ivSearchContentUp.setColorFilter(textColor) + ivSearchContentDown.setColorFilter(textColor) + tvCurrentSearchInfo.setTextColor(textColor) + } + + + fun runMenuIn() { + this.visible() + binding.llSearchBaseInfo.visible() + binding.llBottomBg.visible() + binding.vwMenuBg.visible() + binding.llSearchBaseInfo.startAnimation(menuBottomIn) + binding.llBottomBg.startAnimation(menuBottomIn) + } + + fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) { + this.onMenuOutEnd = onMenuOutEnd + if (this.isVisible) { + binding.llSearchBaseInfo.startAnimation(menuBottomOut) + binding.llBottomBg.startAnimation(menuBottomOut) + } + } + + fun updateSearchInfo() { + ReadBook.curTextChapter?.let { + binding.tvCurrentSearchInfo.text = context.getString(R.string.search_content_size) + ": ${searchResultList.size} / 当前章节: ${it.title}" + } + } + + fun updateSearchResultIndex(updateIndex: Int) { + lastSearchResultIndex = currentSearchResultIndex + currentSearchResultIndex = when { + updateIndex < 0 -> 0 + updateIndex >= searchResultList.size -> searchResultList.size - 1 + else -> updateIndex + } + } + + private fun bindEvent() = binding.run { + + llSearchResults.setOnClickListener { + runMenuOut { + callBack.openSearchActivity(selectedSearchResult?.query) + } + } + + //主菜单 + llMainMenu.setOnClickListener { + runMenuOut { + callBack.showMenuBar() + this@SearchMenu.invisible() + } + } + + //目录 + llSearchExit.setOnClickListener { + runMenuOut { + callBack.exitSearchMenu() + this@SearchMenu.invisible() + } + } + + //设置 +// llSetting.setOnClickListener { +// runMenuOut { +// callBack.showSearchSetting() +// } +// } + + fabLeft.setOnClickListener { + updateSearchResultIndex(currentSearchResultIndex - 1) + callBack.navigateToSearch(searchResultList[currentSearchResultIndex]) + } + + ivSearchContentUp.setOnClickListener { + updateSearchResultIndex(currentSearchResultIndex - 1) + callBack.navigateToSearch(searchResultList[currentSearchResultIndex]) + } + + ivSearchContentDown.setOnClickListener { + updateSearchResultIndex(currentSearchResultIndex + 1) + callBack.navigateToSearch(searchResultList[currentSearchResultIndex]) + } + + fabRight.setOnClickListener { + updateSearchResultIndex(currentSearchResultIndex + 1) + callBack.navigateToSearch(searchResultList[currentSearchResultIndex]) + } + } + + private fun initAnimation() { + //显示菜单 + menuBottomIn.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + callBack.upSystemUiVisibility() + binding.fabLeft.visible(hasSearchResult) + binding.fabRight.visible(hasSearchResult) + } + + @SuppressLint("RtlHardcoded") + override fun onAnimationEnd(animation: Animation) { + val navigationBarHeight = if (ReadBookConfig.hideNavigationBar) { + activity?.navigationBarHeight ?: 0 + } else { + 0 + } + binding.run { + vwMenuBg.setOnClickListener { runMenuOut() } + root.padding = 0 + when (activity?.navigationBarGravity) { + Gravity.BOTTOM -> root.bottomPadding = navigationBarHeight + Gravity.LEFT -> root.leftPadding = navigationBarHeight + Gravity.RIGHT -> root.rightPadding = navigationBarHeight + } + } + callBack.upSystemUiVisibility() + } + + override fun onAnimationRepeat(animation: Animation) = Unit + }) + + //隐藏菜单 + menuBottomOut.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + binding.vwMenuBg.setOnClickListener(null) + } + + override fun onAnimationEnd(animation: Animation) { + binding.llSearchBaseInfo.invisible() + binding.llBottomBg.invisible() + binding.vwMenuBg.invisible() + binding.vwMenuBg.setOnClickListener { runMenuOut() } + + onMenuOutEnd?.invoke() + callBack.upSystemUiVisibility() + } + + override fun onAnimationRepeat(animation: Animation) = Unit + }) + } + + interface CallBack { + var isShowingSearchResult: Boolean + fun openSearchActivity(searchWord: String?) + fun showSearchSetting() + fun upSystemUiVisibility() + fun exitSearchMenu() + fun showMenuBar() + fun navigateToSearch(searchResult: SearchResult) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt new file mode 100644 index 000000000..9a0ec9460 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt @@ -0,0 +1,322 @@ +package io.legado.app.ui.book.read + +import android.annotation.SuppressLint +import android.app.SearchManager +import android.content.Context +import android.content.Intent +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.view.* +import android.widget.PopupWindow +import androidx.annotation.RequiresApi +import androidx.appcompat.view.SupportMenuInflater +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuItemImpl +import androidx.core.view.isVisible +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.ItemTextBinding +import io.legado.app.databinding.PopupActionMenuBinding +import io.legado.app.service.BaseReadAloudService +import io.legado.app.utils.* +import timber.log.Timber +import java.util.* + +@SuppressLint("RestrictedApi") +class TextActionMenu(private val context: Context, private val callBack: CallBack) : + PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT), + TextToSpeech.OnInitListener { + + private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context)) + private val adapter = Adapter(context) + private val menuItems: List + private val visibleMenuItems = arrayListOf() + private val moreMenuItems = arrayListOf() + private val ttsListener by lazy { + TTSUtteranceListener() + } + private val expandTextMenu get() = context.getPrefBoolean(PreferKey.expandTextMenu) + + init { + @SuppressLint("InflateParams") + contentView = binding.root + + isTouchable = true + isOutsideTouchable = false + isFocusable = false + + val myMenu = MenuBuilder(context) + val otherMenu = MenuBuilder(context) + SupportMenuInflater(context).inflate(R.menu.content_select_action, myMenu) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + onInitializeMenu(otherMenu) + } + menuItems = myMenu.visibleItems + otherMenu.visibleItems + visibleMenuItems.addAll(menuItems.subList(0, 5)) + moreMenuItems.addAll(menuItems.subList(5, menuItems.size)) + binding.recyclerView.adapter = adapter + binding.recyclerViewMore.adapter = adapter + setOnDismissListener { + if (!context.getPrefBoolean(PreferKey.expandTextMenu)) { + binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert) + binding.recyclerViewMore.gone() + adapter.setItems(visibleMenuItems) + binding.recyclerView.visible() + } + } + binding.ivMenuMore.setOnClickListener { + if (binding.recyclerView.isVisible) { + binding.ivMenuMore.setImageResource(R.drawable.ic_arrow_back) + adapter.setItems(moreMenuItems) + binding.recyclerView.gone() + binding.recyclerViewMore.visible() + } else { + binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert) + binding.recyclerViewMore.gone() + adapter.setItems(visibleMenuItems) + binding.recyclerView.visible() + } + } + upMenu() + } + + fun upMenu() { + if (expandTextMenu) { + adapter.setItems(menuItems) + binding.ivMenuMore.gone() + } else { + adapter.setItems(visibleMenuItems) + binding.ivMenuMore.visible() + } + } + + fun show( + view: View, + windowHeight: Int, + startX: Int, + startTopY: Int, + startBottomY: Int, + endX: Int, + endBottomY: Int + ) { + if (expandTextMenu) { + when { + startTopY > 500 -> { + showAtLocation( + view, + Gravity.BOTTOM or Gravity.START, + startX, + windowHeight - startTopY + ) + } + endBottomY - startBottomY > 500 -> { + showAtLocation(view, Gravity.TOP or Gravity.START, startX, startBottomY) + } + else -> { + showAtLocation(view, Gravity.TOP or Gravity.START, endX, endBottomY) + } + } + } else { + contentView.measure( + View.MeasureSpec.UNSPECIFIED, + View.MeasureSpec.UNSPECIFIED, + ) + val popupHeight = contentView.measuredHeight + when { + startBottomY > 500 -> { + showAtLocation( + view, + Gravity.TOP or Gravity.START, + startX, + startTopY - popupHeight + ) + } + endBottomY - startBottomY > 500 -> { + showAtLocation( + view, + Gravity.TOP or Gravity.START, + startX, + startBottomY + ) + } + else -> { + showAtLocation( + view, + Gravity.TOP or Gravity.START, + endX, + endBottomY + ) + } + } + } + } + + inner class Adapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemTextBinding { + return ItemTextBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemTextBinding, + item: MenuItemImpl, + payloads: MutableList + ) { + with(binding) { + textView.text = item.title + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + if (!callBack.onMenuItemSelected(it.itemId)) { + onMenuItemSelected(it) + } + } + callBack.onMenuActionFinally() + } + } + } + + private fun onMenuItemSelected(item: MenuItemImpl) { + when (item.itemId) { + R.id.menu_copy -> context.sendToClip(callBack.selectedText) + R.id.menu_share_str -> context.share(callBack.selectedText) + R.id.menu_aloud -> { + if (BaseReadAloudService.isRun) { + context.toastOnUi(R.string.alouding_disable) + return + } + readAloud(callBack.selectedText) + } + R.id.menu_browser -> { + kotlin.runCatching { + val intent = if (callBack.selectedText.isAbsUrl()) { + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(callBack.selectedText) + } + } else { + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, callBack.selectedText) + } + } + context.startActivity(intent) + }.onFailure { + Timber.e(it) + context.toastOnUi(it.localizedMessage ?: "ERROR") + } + } + else -> item.intent?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + it.putExtra(Intent.EXTRA_PROCESS_TEXT, callBack.selectedText) + context.startActivity(it) + } + } + } + } + + private var textToSpeech: TextToSpeech? = null + private var ttsInitFinish = false + private var lastText: String = "" + + @SuppressLint("SetJavaScriptEnabled") + private fun readAloud(text: String) { + lastText = text + if (textToSpeech == null) { + textToSpeech = TextToSpeech(context, this).apply { + setOnUtteranceProgressListener(ttsListener) + } + return + } + if (!ttsInitFinish) return + if (text == "") return + if (textToSpeech?.isSpeaking == true) { + textToSpeech?.stop() + } + textToSpeech?.speak(text, TextToSpeech.QUEUE_ADD, null, "select_text") + lastText = "" + } + + @Synchronized + override fun onInit(status: Int) { + if (status == TextToSpeech.SUCCESS) { + textToSpeech?.language = Locale.CHINA + ttsInitFinish = true + readAloud(lastText) + } else { + context.toastOnUi(R.string.tts_init_failed) + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun createProcessTextIntent(): Intent { + return Intent() + .setAction(Intent.ACTION_PROCESS_TEXT) + .setType("text/plain") + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getSupportedActivities(): List { + return context.packageManager + .queryIntentActivities(createProcessTextIntent(), 0) + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun createProcessTextIntentForResolveInfo(info: ResolveInfo): Intent { + return createProcessTextIntent() + .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false) + .setClassName(info.activityInfo.packageName, info.activityInfo.name) + } + + /** + * Start with a menu Item order value that is high enough + * so that your "PROCESS_TEXT" menu items appear after the + * standard selection menu items like Cut, Copy, Paste. + */ + @RequiresApi(Build.VERSION_CODES.M) + private fun onInitializeMenu(menu: Menu) { + kotlin.runCatching { + var menuItemOrder = 100 + for (resolveInfo in getSupportedActivities()) { + menu.add( + Menu.NONE, Menu.NONE, + menuItemOrder++, resolveInfo.loadLabel(context.packageManager) + ).intent = createProcessTextIntentForResolveInfo(resolveInfo) + } + }.onFailure { + context.toastOnUi("获取文字操作菜单出错:${it.localizedMessage}") + } + } + + private inner class TTSUtteranceListener : UtteranceProgressListener() { + + override fun onStart(utteranceId: String?) { + + } + + override fun onDone(utteranceId: String?) { + textToSpeech?.shutdown() + textToSpeech = null + } + + override fun onError(utteranceId: String?) { + + } + } + + interface CallBack { + val selectedText: String + + fun onMenuItemSelected(itemId: Int): Boolean + + fun onMenuActionFinally() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt new file mode 100644 index 000000000..13656f015 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt @@ -0,0 +1,123 @@ +package io.legado.app.ui.book.read.config + +import android.content.DialogInterface +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.SeekBar +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogAutoReadBinding +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.BaseReadBookActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.widget.seekbar.SeekBarChangeListener +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class AutoReadDialog : BaseDialogFragment(R.layout.dialog_auto_read) { + + private val binding by viewBinding(DialogAutoReadBinding::bind) + private val callBack: CallBack? get() = activity as? CallBack + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + it.setBackgroundDrawableResource(R.color.background) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + (activity as ReadBookActivity).bottomDialog-- + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { + (activity as ReadBookActivity).bottomDialog++ + val bg = requireContext().bottomBackground + val isLight = ColorUtils.isColorLight(bg) + val textColor = requireContext().getPrimaryTextColor(isLight) + root.setBackgroundColor(bg) + tvReadSpeedTitle.setTextColor(textColor) + tvReadSpeed.setTextColor(textColor) + ivCatalog.setColorFilter(textColor) + tvCatalog.setTextColor(textColor) + ivMainMenu.setColorFilter(textColor) + tvMainMenu.setTextColor(textColor) + ivAutoPageStop.setColorFilter(textColor) + tvAutoPageStop.setTextColor(textColor) + ivSetting.setColorFilter(textColor) + tvSetting.setTextColor(textColor) + initOnChange() + initData() + initEvent() + } + + private fun initData() { + val speed = if (ReadBookConfig.autoReadSpeed < 2) 2 else ReadBookConfig.autoReadSpeed + binding.tvReadSpeed.text = String.format("%ds", speed) + binding.seekAutoRead.progress = speed + } + + private fun initOnChange() { + binding.seekAutoRead.setOnSeekBarChangeListener(object : SeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val speed = if (progress < 2) 2 else progress + binding.tvReadSpeed.text = String.format("%ds", speed) + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + ReadBookConfig.autoReadSpeed = + if (binding.seekAutoRead.progress < 2) 2 else binding.seekAutoRead.progress + upTtsSpeechRate() + } + }) + } + + private fun initEvent() { + binding.llMainMenu.setOnClickListener { + callBack?.showMenuBar() + dismissAllowingStateLoss() + } + binding.llSetting.setOnClickListener { + (activity as BaseReadBookActivity).showPageAnimConfig { + (activity as ReadBookActivity).upPageAnim() + ReadBook.loadContent(false) + } + } + binding.llCatalog.setOnClickListener { callBack?.openChapterList() } + binding.llAutoPageStop.setOnClickListener { + callBack?.autoPageStop() + dismissAllowingStateLoss() + } + } + + private fun upTtsSpeechRate() { + ReadAloud.upTtsSpeechRate(requireContext()) + if (!BaseReadAloudService.pause) { + ReadAloud.pause(requireContext()) + ReadAloud.resume(requireContext()) + } + } + + interface CallBack { + fun showMenuBar() + fun openChapterList() + fun autoPageStop() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt b/app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt new file mode 100644 index 000000000..439f8d554 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt @@ -0,0 +1,50 @@ +package io.legado.app.ui.book.read.config + +import android.content.Context +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.EventBus +import io.legado.app.databinding.ItemBgImageBinding +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.glide.ImageLoader +import io.legado.app.utils.postEvent +import java.io.File + +class BgAdapter(context: Context, val textColor: Int) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemBgImageBinding { + return ItemBgImageBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemBgImageBinding, + item: String, + payloads: MutableList + ) { + binding.run { + ImageLoader.load( + context, + context.assets.open("bg${File.separator}$item").readBytes() + ) + .centerCrop() + .into(ivBg) + tvName.setTextColor(textColor) + tvName.text = item.substringBeforeLast(".") + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemBgImageBinding) { + holder.itemView.apply { + this.setOnClickListener { + getItemByLayoutPosition(holder.layoutPosition)?.let { + ReadBookConfig.durConfig.setCurBg(1, it) + ReadBookConfig.upBg() + postEvent(EventBus.UP_CONFIG, false) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt new file mode 100644 index 000000000..438839367 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt @@ -0,0 +1,348 @@ +package io.legado.app.ui.book.read.config + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.documentfile.provider.DocumentFile +import com.jaredrummler.android.colorpicker.ColorPickerDialog +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.EventBus +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogReadBgTextBinding +import io.legado.app.databinding.ItemBgImageBinding +import io.legado.app.help.DefaultData +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.lib.theme.getSecondaryTextColor +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import timber.log.Timber +import java.io.File + +class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) { + + companion object { + const val TEXT_COLOR = 121 + const val BG_COLOR = 122 + } + + private val binding by viewBinding(DialogReadBgTextBinding::bind) + private val configFileName = "readConfig.zip" + private val adapter by lazy { BgAdapter(requireContext(), secondaryTextColor) } + private var primaryTextColor = 0 + private var secondaryTextColor = 0 + private val importFormNet = "网络导入" + private val selectBgImage = registerForActivityResult(SelectImageContract()) { + it.uri?.let { uri -> + setBgFromUri(uri) + } + } + private val selectExportDir = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + exportConfig(uri) + } + } + private val selectImportDoc = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + if (uri.toString() == importFormNet) { + importNetConfigAlert() + } else { + importConfig(uri) + } + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + it.setBackgroundDrawableResource(R.color.background) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + (activity as ReadBookActivity).bottomDialog++ + initView() + initData() + initEvent() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + ReadBookConfig.save() + (activity as ReadBookActivity).bottomDialog-- + } + + private fun initView() = binding.run { + val bg = requireContext().bottomBackground + val isLight = ColorUtils.isColorLight(bg) + primaryTextColor = requireContext().getPrimaryTextColor(isLight) + secondaryTextColor = requireContext().getSecondaryTextColor(isLight) + rootView.setBackgroundColor(bg) + tvNameTitle.setTextColor(primaryTextColor) + tvName.setTextColor(secondaryTextColor) + ivEdit.setColorFilter(secondaryTextColor) + tvRestore.setTextColor(primaryTextColor) + swDarkStatusIcon.setTextColor(primaryTextColor) + ivImport.setColorFilter(primaryTextColor) + ivExport.setColorFilter(primaryTextColor) + ivDelete.setColorFilter(primaryTextColor) + tvBgImage.setTextColor(primaryTextColor) + recyclerView.adapter = adapter + adapter.addHeaderView { + ItemBgImageBinding.inflate(layoutInflater, it, false).apply { + tvName.setTextColor(secondaryTextColor) + tvName.text = getString(R.string.select_image) + ivBg.setImageResource(R.drawable.ic_image) + ivBg.setColorFilter(primaryTextColor) + root.setOnClickListener { + selectBgImage.launch() + } + } + } + requireContext().assets.list("bg")?.let { + adapter.setItems(it.toList()) + } + } + + @SuppressLint("InflateParams") + private fun initData() = with(ReadBookConfig.durConfig) { + binding.tvName.text = name.ifBlank { "文字" } + binding.swDarkStatusIcon.isChecked = curStatusIconDark() + } + + @SuppressLint("InflateParams") + private fun initEvent() = with(ReadBookConfig.durConfig) { + binding.ivEdit.setOnClickListener { + alert(R.string.style_name) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "name" + editView.setText(ReadBookConfig.durConfig.name) + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + binding.tvName.text = it + ReadBookConfig.durConfig.name = it + } + } + cancelButton() + } + } + binding.tvRestore.setOnClickListener { + val defaultConfigs = DefaultData.readConfigs + val layoutNames = defaultConfigs.map { it.name } + context?.selector("选择预设布局", layoutNames) { _, i -> + if (i >= 0) { + ReadBookConfig.durConfig = defaultConfigs[i] + initData() + postEvent(EventBus.UP_CONFIG, true) + } + } + } + binding.swDarkStatusIcon.setOnCheckedChangeListener { _, isChecked -> + setCurStatusIconDark(isChecked) + (activity as? ReadBookActivity)?.upSystemUiVisibility() + } + binding.tvTextColor.setOnClickListener { + ColorPickerDialog.newBuilder() + .setColor(curTextColor()) + .setShowAlphaSlider(false) + .setDialogType(ColorPickerDialog.TYPE_CUSTOM) + .setDialogId(TEXT_COLOR) + .show(requireActivity()) + } + binding.tvBgColor.setOnClickListener { + val bgColor = + if (curBgType() == 0) Color.parseColor(curBgStr()) + else Color.parseColor("#015A86") + ColorPickerDialog.newBuilder() + .setColor(bgColor) + .setShowAlphaSlider(false) + .setDialogType(ColorPickerDialog.TYPE_CUSTOM) + .setDialogId(BG_COLOR) + .show(requireActivity()) + } + binding.ivImport.setOnClickListener { + selectImportDoc.launch { + mode = HandleFileContract.FILE + title = getString(R.string.import_str) + allowExtensions = arrayOf("zip") + otherActions = arrayListOf(SelectItem(importFormNet, -1)) + } + } + binding.ivExport.setOnClickListener { + selectExportDir.launch { + title = getString(R.string.export_str) + } + } + binding.ivDelete.setOnClickListener { + if (ReadBookConfig.deleteDur()) { + postEvent(EventBus.UP_CONFIG, true) + dismissAllowingStateLoss() + } else { + toastOnUi("数量已是最少,不能删除.") + } + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private fun exportConfig(uri: Uri) { + val exportFileName = if (ReadBookConfig.config.name.isBlank()) { + configFileName + } else { + "${ReadBookConfig.config.name}.zip" + } + execute { + val exportFiles = arrayListOf() + val configDirPath = FileUtils.getPath(requireContext().externalCache, "readConfig") + FileUtils.deleteFile(configDirPath) + val configDir = FileUtils.createFolderIfNotExist(configDirPath) + val configExportPath = FileUtils.getPath(configDir, "readConfig.json") + FileUtils.deleteFile(configExportPath) + val configExportFile = FileUtils.createFileIfNotExist(configExportPath) + configExportFile.writeText(GSON.toJson(ReadBookConfig.getExportConfig())) + exportFiles.add(configExportFile) + val fontPath = ReadBookConfig.textFont + if (fontPath.isNotEmpty()) { + val fontName = FileUtils.getName(fontPath) + val fontBytes = fontPath.parseToUri().readBytes(requireContext()) + fontBytes.let { + val fontExportFile = FileUtils.createFileIfNotExist(configDir, fontName) + fontExportFile.writeBytes(it) + exportFiles.add(fontExportFile) + } + } + if (ReadBookConfig.durConfig.bgType == 2) { + val bgName = FileUtils.getName(ReadBookConfig.durConfig.bgStr) + val bgFile = File(ReadBookConfig.durConfig.bgStr) + if (bgFile.exists()) { + val bgExportFile = File(FileUtils.getPath(configDir, bgName)) + bgFile.copyTo(bgExportFile) + exportFiles.add(bgExportFile) + } + } + if (ReadBookConfig.durConfig.bgTypeNight == 2) { + val bgName = FileUtils.getName(ReadBookConfig.durConfig.bgStrNight) + val bgFile = File(ReadBookConfig.durConfig.bgStrNight) + if (bgFile.exists()) { + val bgExportFile = File(FileUtils.getPath(configDir, bgName)) + bgFile.copyTo(bgExportFile) + exportFiles.add(bgExportFile) + } + } + if (ReadBookConfig.durConfig.bgTypeEInk == 2) { + val bgName = FileUtils.getName(ReadBookConfig.durConfig.bgStrEInk) + val bgFile = File(ReadBookConfig.durConfig.bgStrEInk) + if (bgFile.exists()) { + val bgExportFile = File(FileUtils.getPath(configDir, bgName)) + bgFile.copyTo(bgExportFile) + exportFiles.add(bgExportFile) + } + } + val configZipPath = FileUtils.getPath(requireContext().externalCache, configFileName) + if (ZipUtils.zipFiles(exportFiles, File(configZipPath))) { + if (uri.isContentScheme()) { + DocumentFile.fromTreeUri(requireContext(), uri)?.let { treeDoc -> + treeDoc.findFile(exportFileName)?.delete() + treeDoc.createFile("", exportFileName) + ?.writeBytes(requireContext(), File(configZipPath).readBytes()) + } + } else { + val exportPath = FileUtils.getPath(File(uri.path!!), exportFileName) + FileUtils.deleteFile(exportPath) + FileUtils.createFileIfNotExist(exportPath) + .writeBytes(File(configZipPath).readBytes()) + } + } + }.onSuccess { + toastOnUi("导出成功, 文件名为 $exportFileName") + }.onError { + Timber.e(it) + longToast("导出失败:${it.localizedMessage}") + } + } + + @SuppressLint("InflateParams") + private fun importNetConfigAlert() { + alert("输入地址") { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater) + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { url -> + importNetConfig(url) + } + } + noButton() + } + } + + private fun importNetConfig(url: String) { + execute { + @Suppress("BlockingMethodInNonBlockingContext") + okHttpClient.newCallResponseBody { + url(url) + }.bytes().let { + importConfig(it) + } + }.onError { + longToast(it.msg) + } + } + + private fun importConfig(uri: Uri) { + execute { + @Suppress("BlockingMethodInNonBlockingContext") + importConfig(uri.readBytes(requireContext())) + }.onError { + Timber.e(it) + longToast("导入失败:${it.localizedMessage}") + } + } + + @Suppress("BlockingMethodInNonBlockingContext", "BlockingMethodInNonBlockingContext") + private fun importConfig(byteArray: ByteArray) { + execute { + ReadBookConfig.import(byteArray) + }.onSuccess { + ReadBookConfig.durConfig = it + postEvent(EventBus.UP_CONFIG, true) + toastOnUi("导入成功") + }.onError { + Timber.e(it) + longToast("导入失败:${it.localizedMessage}") + } + } + + private fun setBgFromUri(uri: Uri) { + readUri(uri) { name, bytes -> + var file = requireContext().externalFiles + file = FileUtils.createFileIfNotExist(file, "bg", name) + file.writeBytes(bytes) + ReadBookConfig.durConfig.setCurBg(2, file.absolutePath) + ReadBookConfig.upBg() + postEvent(EventBus.UP_CONFIG, false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ChineseConverter.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ChineseConverter.kt new file mode 100644 index 000000000..549f21729 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ChineseConverter.kt @@ -0,0 +1,53 @@ +package io.legado.app.ui.book.read.config + +import android.content.Context +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.util.AttributeSet +import io.legado.app.R +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.widget.text.StrokeTextView + + +class ChineseConverter(context: Context, attrs: AttributeSet?) : StrokeTextView(context, attrs) { + + private val spannableString = SpannableString("简/繁") + private var enabledSpan: ForegroundColorSpan = ForegroundColorSpan(context.accentColor) + private var onChanged: (() -> Unit)? = null + + init { + text = spannableString + if (!isInEditMode) { + upUi(AppConfig.chineseConverterType) + } + setOnClickListener { + selectType() + } + } + + private fun upUi(type: Int) { + spannableString.removeSpan(enabledSpan) + when (type) { + 1 -> spannableString.setSpan(enabledSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + 2 -> spannableString.setSpan(enabledSpan, 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + text = spannableString + } + + private fun selectType() { + context.alert(titleResource = R.string.chinese_converter) { + items(context.resources.getStringArray(R.array.chinese_mode).toList()) { _, i -> + AppConfig.chineseConverterType = i + upUi(i) + onChanged?.invoke() + } + } + } + + fun onChanged(unit: () -> Unit) { + onChanged = unit + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt new file mode 100644 index 000000000..fe492cac0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt @@ -0,0 +1,129 @@ +package io.legado.app.ui.book.read.config + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.DialogClickActionConfigBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.selector +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.putPrefInt +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class ClickActionConfigDialog : BaseDialogFragment(R.layout.dialog_click_action_config) { + private val binding by viewBinding(DialogClickActionConfigBinding::bind) + private val actions by lazy { + linkedMapOf().apply { + this[-1] = getString(R.string.non_action) + this[0] = getString(R.string.menu) + this[1] = getString(R.string.next_page) + this[2] = getString(R.string.prev_page) + this[3] = getString(R.string.next_chapter) + this[4] = getString(R.string.previous_chapter) + this[5] = getString(R.string.read_aloud_prev_paragraph) + this[6] = getString(R.string.read_aloud_next_paragraph) + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + (activity as ReadBookActivity).bottomDialog-- + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + (activity as ReadBookActivity).bottomDialog++ + view.setBackgroundColor(getCompatColor(R.color.translucent)) + initData() + initViewEvent() + } + + private fun initData() = binding.run { + tvTopLeft.text = actions[AppConfig.clickActionTL] + tvTopCenter.text = actions[AppConfig.clickActionTC] + tvTopRight.text = actions[AppConfig.clickActionTR] + tvMiddleLeft.text = actions[AppConfig.clickActionML] + tvMiddleRight.text = actions[AppConfig.clickActionMR] + tvBottomLeft.text = actions[AppConfig.clickActionBL] + tvBottomCenter.text = actions[AppConfig.clickActionBC] + tvBottomRight.text = actions[AppConfig.clickActionBR] + } + + private fun initViewEvent() { + binding.ivClose.setOnClickListener { + dismissAllowingStateLoss() + } + binding.tvTopLeft.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionTL, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvTopCenter.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionTC, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvTopRight.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionTR, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvMiddleLeft.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionML, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvMiddleRight.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionMR, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvBottomLeft.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionBL, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvBottomCenter.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionBC, action) + (it as? TextView)?.text = actions[action] + } + } + binding.tvBottomRight.setOnClickListener { + selectAction { action -> + putPrefInt(PreferKey.clickActionBR, action) + (it as? TextView)?.text = actions[action] + } + } + } + + private fun selectAction(success: (action: Int) -> Unit) { + context?.selector( + getString(R.string.select_action), + actions.values.toList() + ) { _, index -> + success.invoke(actions.keys.toList()[index]) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt new file mode 100644 index 000000000..f2c0420c9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt @@ -0,0 +1,138 @@ +package io.legado.app.ui.book.read.config + +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.data.entities.HttpTTS +import io.legado.app.databinding.DialogHttpTtsEditBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.widget.code.addJsPattern +import io.legado.app.ui.widget.code.addJsonPattern +import io.legado.app.ui.widget.code.addLegadoPattern +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class HttpTtsEditDialog() : BaseDialogFragment(R.layout.dialog_http_tts_edit), + Toolbar.OnMenuItemClickListener { + + constructor(id: Long) : this() { + arguments = Bundle().apply { + putLong("id", id) + } + } + + private val binding by viewBinding(DialogHttpTtsEditBinding::bind) + private val viewModel by viewModels() + + override fun onStart() { + super.onStart() + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.tvUrl.run { + addLegadoPattern() + addJsonPattern() + addJsPattern() + } + binding.tvLoginUrl.run { + addLegadoPattern() + addJsonPattern() + addJsPattern() + } + binding.tvLoginUi.addJsonPattern() + binding.tvLoginCheckJs.addJsPattern() + binding.tvHeaders.run { + addLegadoPattern() + addJsonPattern() + addJsPattern() + } + viewModel.initData(arguments) { + initView(httpTTS = it) + } + initMenu() + } + + fun initMenu() { + binding.toolBar.inflateMenu(R.menu.speak_engine_edit) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + } + + fun initView(httpTTS: HttpTTS) { + binding.tvName.setText(httpTTS.name) + binding.tvUrl.setText(httpTTS.url) + binding.tvContentType.setText(httpTTS.contentType) + binding.tvLoginUrl.setText(httpTTS.loginUrl) + binding.tvLoginUi.setText(httpTTS.loginUi) + binding.tvLoginCheckJs.setText(httpTTS.loginCheckJs) + binding.tvHeaders.setText(httpTTS.header) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_save -> viewModel.save(dataFromView()) { + toastOnUi("保存成功") + } + R.id.menu_login -> dataFromView().let { httpTts -> + if (httpTts.loginUrl.isNullOrBlank()) { + toastOnUi("登录url不能为空") + } else { + viewModel.save(httpTts) { + startActivity { + putExtra("type", "httpTts") + putExtra("key", httpTts.id.toString()) + } + } + } + } + R.id.menu_show_login_header -> alert { + setTitle(R.string.login_header) + dataFromView().getLoginHeader()?.let { loginHeader -> + setMessage(loginHeader) + } + } + R.id.menu_del_login_header -> dataFromView().removeLoginHeader() + R.id.menu_copy_source -> dataFromView().let { + context?.sendToClip(GSON.toJson(it)) + } + R.id.menu_paste_source -> viewModel.importFromClip { + initView(it) + } + R.id.menu_log -> showDialogFragment() + R.id.menu_help -> help() + } + return true + } + + private fun dataFromView(): HttpTTS { + return HttpTTS( + id = viewModel.id ?: System.currentTimeMillis(), + name = binding.tvName.text.toString(), + url = binding.tvUrl.text.toString(), + contentType = binding.tvContentType.text?.toString(), + loginUrl = binding.tvLoginUrl.text?.toString(), + loginUi = binding.tvLoginUi.text?.toString(), + loginCheckJs = binding.tvLoginCheckJs.text?.toString(), + header = binding.tvHeaders.text?.toString() + ) + } + + private fun help() { + val helpStr = String( + requireContext().assets.open("help/httpTTSHelp.md").readBytes() + ) + showDialogFragment(TextDialog(helpStr, TextDialog.Mode.MD)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt new file mode 100644 index 000000000..e9b008220 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt @@ -0,0 +1,75 @@ +package io.legado.app.ui.book.read.config + +import android.app.Application +import android.os.Bundle +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.HttpTTS +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.ReadAloud +import io.legado.app.utils.* + +class HttpTtsEditViewModel(app: Application) : BaseViewModel(app) { + + var id: Long? = null + + fun initData(arguments: Bundle?, success: (httpTTS: HttpTTS) -> Unit) { + execute { + if (id == null) { + val argumentId = arguments?.getLong("id") + if (argumentId != null && argumentId != 0L) { + id = argumentId + return@execute appDb.httpTTSDao.get(argumentId) + } + } + return@execute null + }.onSuccess { + it?.let { + success.invoke(it) + } + } + } + + fun save(httpTTS: HttpTTS, success: (() -> Unit)? = null) { + id = httpTTS.id + execute { + appDb.httpTTSDao.insert(httpTTS) + ReadAloud.upReadAloudClass() + }.onSuccess { + success?.invoke() + } + } + + fun importFromClip(onSuccess: (httpTTS: HttpTTS) -> Unit) { + val text = context.getClipText() + if (text.isNullOrBlank()) { + context.toastOnUi("剪贴板为空") + } else { + importSource(text, onSuccess) + } + } + + fun importSource(text: String, onSuccess: (httpTTS: HttpTTS) -> Unit) { + val text1 = text.trim() + execute { + when { + text1.isJsonObject() -> { + HttpTTS.fromJson(text1) + } + text1.isJsonArray() -> { + HttpTTS.fromJsonArray(text1).firstOrNull() + } + else -> { + throw NoStackTraceException("格式不对") + } + } + }.onSuccess { + it?.let { httpTts -> + onSuccess.invoke(httpTts) + } ?: context.toastOnUi("格式不对") + }.onError { + context.toastOnUi(it.localizedMessage) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt new file mode 100644 index 000000000..769bf9c1e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt @@ -0,0 +1,145 @@ +package io.legado.app.ui.book.read.config + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.content.SharedPreferences +import android.os.Bundle +import android.view.* +import android.widget.LinearLayout +import androidx.fragment.app.DialogFragment +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.primaryColor +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.utils.dp +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.postEvent +import io.legado.app.utils.setEdgeEffectColor + +class MoreConfigDialog : DialogFragment() { + private val readPreferTag = "readPreferenceFragment" + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + it.setBackgroundDrawableResource(R.color.background) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 360.dp) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + (activity as ReadBookActivity).bottomDialog++ + val view = LinearLayout(context) + view.setBackgroundColor(requireContext().bottomBackground) + view.id = R.id.tag1 + container?.addView(view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var preferenceFragment = childFragmentManager.findFragmentByTag(readPreferTag) + if (preferenceFragment == null) preferenceFragment = ReadPreferenceFragment() + childFragmentManager.beginTransaction() + .replace(view.id, preferenceFragment, readPreferTag) + .commit() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + (activity as ReadBookActivity).bottomDialog-- + } + + class ReadPreferenceFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + @SuppressLint("RestrictedApi") + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_read) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.setEdgeEffectColor(primaryColor) + } + + override fun onResume() { + super.onResume() + preferenceManager + .sharedPreferences + .registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager + .sharedPreferences + .unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + PreferKey.readBodyToLh -> activity?.recreate() + PreferKey.hideStatusBar -> { + ReadBookConfig.hideStatusBar = getPrefBoolean(PreferKey.hideStatusBar) + postEvent(EventBus.UP_CONFIG, true) + } + PreferKey.hideNavigationBar -> { + ReadBookConfig.hideNavigationBar = getPrefBoolean(PreferKey.hideNavigationBar) + postEvent(EventBus.UP_CONFIG, true) + } + PreferKey.keepLight -> postEvent(key, true) + PreferKey.textSelectAble -> postEvent(key, getPrefBoolean(key)) + PreferKey.screenOrientation -> { + (activity as? ReadBookActivity)?.setOrientation() + } + PreferKey.textFullJustify, + PreferKey.textBottomJustify, + PreferKey.useZhLayout -> { + postEvent(EventBus.UP_CONFIG, true) + } + PreferKey.showBrightnessView -> { + postEvent(PreferKey.showBrightnessView, "") + } + PreferKey.expandTextMenu -> { + (activity as? ReadBookActivity)?.textActionMenu?.upMenu() + } + PreferKey.doublePageHorizontal -> { + ChapterProvider.upLayout() + ReadBook.loadContent(false) + } + } + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "customPageKey" -> PageKeyDialog(requireContext()).show() + "clickRegionalConfig" -> { + (activity as? ReadBookActivity)?.showClickRegionalConfig() + } + } + return super.onPreferenceTreeClick(preference) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt new file mode 100644 index 000000000..5fd0ea2b4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt @@ -0,0 +1,124 @@ +package io.legado.app.ui.book.read.config + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.EventBus +import io.legado.app.databinding.DialogReadPaddingBinding +import io.legado.app.help.ReadBookConfig +import io.legado.app.utils.postEvent +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class PaddingConfigDialog : BaseDialogFragment(R.layout.dialog_read_padding) { + + private val binding by viewBinding(DialogReadPaddingBinding::bind) + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + val attr = it.attributes + attr.dimAmount = 0.0f + it.attributes = attr + } + setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + initData() + initView() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + ReadBookConfig.save() + } + + private fun initData() = binding.run { + //正文 + dsbPaddingTop.progress = ReadBookConfig.paddingTop + dsbPaddingBottom.progress = ReadBookConfig.paddingBottom + dsbPaddingLeft.progress = ReadBookConfig.paddingLeft + dsbPaddingRight.progress = ReadBookConfig.paddingRight + //页眉 + dsbHeaderPaddingTop.progress = ReadBookConfig.headerPaddingTop + dsbHeaderPaddingBottom.progress = ReadBookConfig.headerPaddingBottom + dsbHeaderPaddingLeft.progress = ReadBookConfig.headerPaddingLeft + dsbHeaderPaddingRight.progress = ReadBookConfig.headerPaddingRight + //页脚 + dsbFooterPaddingTop.progress = ReadBookConfig.footerPaddingTop + dsbFooterPaddingBottom.progress = ReadBookConfig.footerPaddingBottom + dsbFooterPaddingLeft.progress = ReadBookConfig.footerPaddingLeft + dsbFooterPaddingRight.progress = ReadBookConfig.footerPaddingRight + cbShowTopLine.isChecked = ReadBookConfig.showHeaderLine + cbShowBottomLine.isChecked = ReadBookConfig.showFooterLine + } + + private fun initView() = binding.run { + //正文 + dsbPaddingTop.onChanged = { + ReadBookConfig.paddingTop = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbPaddingBottom.onChanged = { + ReadBookConfig.paddingBottom = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbPaddingLeft.onChanged = { + ReadBookConfig.paddingLeft = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbPaddingRight.onChanged = { + ReadBookConfig.paddingRight = it + postEvent(EventBus.UP_CONFIG, true) + } + //页眉 + dsbHeaderPaddingTop.onChanged = { + ReadBookConfig.headerPaddingTop = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbHeaderPaddingBottom.onChanged = { + ReadBookConfig.headerPaddingBottom = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbHeaderPaddingLeft.onChanged = { + ReadBookConfig.headerPaddingLeft = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbHeaderPaddingRight.onChanged = { + ReadBookConfig.headerPaddingRight = it + postEvent(EventBus.UP_CONFIG, true) + } + //页脚 + dsbFooterPaddingTop.onChanged = { + ReadBookConfig.footerPaddingTop = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbFooterPaddingBottom.onChanged = { + ReadBookConfig.footerPaddingBottom = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbFooterPaddingLeft.onChanged = { + ReadBookConfig.footerPaddingLeft = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbFooterPaddingRight.onChanged = { + ReadBookConfig.footerPaddingRight = it + postEvent(EventBus.UP_CONFIG, true) + } + cbShowTopLine.onCheckedChangeListener = { _, isChecked -> + ReadBookConfig.showHeaderLine = isChecked + postEvent(EventBus.UP_CONFIG, true) + } + cbShowBottomLine.onCheckedChangeListener = { _, isChecked -> + ReadBookConfig.showFooterLine = isChecked + postEvent(EventBus.UP_CONFIG, true) + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt new file mode 100644 index 000000000..1d85a5963 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt @@ -0,0 +1,66 @@ +package io.legado.app.ui.book.read.config + +import android.app.Dialog +import android.content.Context +import android.view.KeyEvent +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.DialogPageKeyBinding +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.utils.getPrefString +import io.legado.app.utils.hideSoftInput +import io.legado.app.utils.putPrefString +import splitties.views.onClick + + +class PageKeyDialog(context: Context) : Dialog(context, R.style.AppTheme_AlertDialog) { + + private val binding = DialogPageKeyBinding.inflate(layoutInflater) + + init { + setContentView(binding.root) + binding.run { + contentView.setBackgroundColor(context.backgroundColor) + etPrev.setText(context.getPrefString(PreferKey.prevKeys)) + etNext.setText(context.getPrefString(PreferKey.nextKeys)) + tvReset.onClick { + etPrev.setText("") + etNext.setText("") + } + tvOk.setOnClickListener { + context.putPrefString(PreferKey.prevKeys, etPrev.text?.toString()) + context.putPrefString(PreferKey.nextKeys, etNext.text?.toString()) + dismiss() + } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) { + if (binding.etPrev.hasFocus()) { + val editableText = binding.etPrev.editableText + if (editableText.isEmpty() or editableText.endsWith(",")) { + editableText.append(keyCode.toString()) + } else { + editableText.append(",").append(keyCode.toString()) + } + return true + } else if (binding.etNext.hasFocus()) { + val editableText = binding.etNext.editableText + if (editableText.isEmpty() or editableText.endsWith(",")) { + editableText.append(keyCode.toString()) + } else { + editableText.append(",").append(keyCode.toString()) + } + return true + } + } + return super.onKeyDown(keyCode, event) + } + + override fun dismiss() { + super.dismiss() + currentFocus?.hideSoftInput() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt new file mode 100644 index 000000000..8f9191f31 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt @@ -0,0 +1,133 @@ +package io.legado.app.ui.book.read.config + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.DialogFragment +import androidx.preference.ListPreference +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.model.ReadAloud +import io.legado.app.service.BaseReadAloudService +import io.legado.app.utils.* + +class ReadAloudConfigDialog : DialogFragment() { + private val readAloudPreferTag = "readAloudPreferTag" + + override fun onStart() { + super.onStart() + val dm = requireActivity().windowSize + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.setLayout((dm.widthPixels * 0.9).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = LinearLayout(requireContext()) + view.setBackgroundColor(requireContext().backgroundColor) + view.id = R.id.tag1 + container?.addView(view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var preferenceFragment = childFragmentManager.findFragmentByTag(readAloudPreferTag) + if (preferenceFragment == null) preferenceFragment = ReadAloudPreferenceFragment() + childFragmentManager.beginTransaction() + .replace(view.id, preferenceFragment, readAloudPreferTag) + .commit() + } + + class ReadAloudPreferenceFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val speakEngineSummary: String + get() { + val ttsEngine = AppConfig.ttsEngine + ?: return getString(R.string.system_tts) + if (StringUtils.isNumeric(ttsEngine)) { + return appDb.httpTTSDao.getName(ttsEngine.toLong()) + ?: getString(R.string.system_tts) + } + return GSON.fromJsonObject>(ttsEngine)?.title + ?: getString(R.string.system_tts) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_aloud) + upPreferenceSummary( + findPreference(PreferKey.ttsEngine), + speakEngineSummary + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.setEdgeEffectColor(primaryColor) + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + PreferKey.ttsEngine -> showDialogFragment(SpeakEngineDialog()) + } + return super.onPreferenceTreeClick(preference) + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + PreferKey.readAloudByPage -> { + if (BaseReadAloudService.isRun) { + postEvent(EventBus.MEDIA_BUTTON, false) + } + } + PreferKey.ttsEngine -> { + upPreferenceSummary(findPreference(key), speakEngineSummary) + ReadAloud.upReadAloudClass() + } + } + } + + private fun upPreferenceSummary(preference: Preference?, value: String) { + when (preference) { + is ListPreference -> { + val index = preference.findIndexOfValue(value) + preference.summary = if (index >= 0) preference.entries[index] else null + } + else -> { + preference?.summary = value + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt new file mode 100644 index 000000000..39911df6b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt @@ -0,0 +1,197 @@ +package io.legado.app.ui.book.read.config + +import android.content.DialogInterface +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.SeekBar +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.EventBus +import io.legado.app.databinding.DialogReadAloudBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.widget.seekbar.SeekBarChangeListener +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.observeEvent +import io.legado.app.utils.putPrefBoolean +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class ReadAloudDialog : BaseDialogFragment(R.layout.dialog_read_aloud) { + private val callBack: CallBack? get() = activity as? CallBack + private val binding by viewBinding(DialogReadAloudBinding::bind) + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + it.setBackgroundDrawableResource(R.color.background) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + (activity as ReadBookActivity).bottomDialog-- + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + (activity as ReadBookActivity).bottomDialog++ + val bg = requireContext().bottomBackground + val isLight = ColorUtils.isColorLight(bg) + val textColor = requireContext().getPrimaryTextColor(isLight) + binding.run { + rootView.setBackgroundColor(bg) + tvPre.setTextColor(textColor) + tvNext.setTextColor(textColor) + ivPlayPrev.setColorFilter(textColor) + ivPlayPause.setColorFilter(textColor) + ivPlayNext.setColorFilter(textColor) + ivStop.setColorFilter(textColor) + ivTimer.setColorFilter(textColor) + tvTimer.setTextColor(textColor) + ivTtsSpeechReduce.setColorFilter(textColor) + tvTtsSpeed.setTextColor(textColor) + ivTtsSpeechAdd.setColorFilter(textColor) + ivCatalog.setColorFilter(textColor) + tvCatalog.setTextColor(textColor) + ivMainMenu.setColorFilter(textColor) + tvMainMenu.setTextColor(textColor) + ivToBackstage.setColorFilter(textColor) + tvToBackstage.setTextColor(textColor) + ivSetting.setColorFilter(textColor) + tvSetting.setTextColor(textColor) + cbTtsFollowSys.setTextColor(textColor) + } + initData() + initEvent() + } + + private fun initData() = binding.run { + upPlayState() + upTimerText(BaseReadAloudService.timeMinute) + seekTimer.progress = BaseReadAloudService.timeMinute + cbTtsFollowSys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true) + seekTtsSpeechRate.isEnabled = !cbTtsFollowSys.isChecked + upSeekTimer() + } + + private fun initEvent() = binding.run { + llMainMenu.setOnClickListener { + callBack?.showMenuBar() + dismissAllowingStateLoss() + } + llSetting.setOnClickListener { + ReadAloudConfigDialog().show(childFragmentManager, "readAloudConfigDialog") + } + tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } + tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) } + ivStop.setOnClickListener { + ReadAloud.stop(requireContext()) + dismissAllowingStateLoss() + } + ivPlayPause.setOnClickListener { callBack?.onClickReadAloud() } + ivPlayPrev.setOnClickListener { ReadAloud.prevParagraph(requireContext()) } + ivPlayNext.setOnClickListener { ReadAloud.nextParagraph(requireContext()) } + llCatalog.setOnClickListener { callBack?.openChapterList() } + llToBackstage.setOnClickListener { callBack?.finish() } + cbTtsFollowSys.setOnCheckedChangeListener { _, isChecked -> + requireContext().putPrefBoolean("ttsFollowSys", isChecked) + seekTtsSpeechRate.isEnabled = !isChecked + upTtsSpeechRate() + } + ivTtsSpeechReduce.setOnClickListener { + seekTtsSpeechRate.progress = AppConfig.ttsSpeechRate - 1 + AppConfig.ttsSpeechRate = AppConfig.ttsSpeechRate - 1 + upTtsSpeechRate() + } + ivTtsSpeechAdd.setOnClickListener { + seekTtsSpeechRate.progress = AppConfig.ttsSpeechRate + 1 + AppConfig.ttsSpeechRate = AppConfig.ttsSpeechRate + 1 + upTtsSpeechRate() + } + //设置保存的默认值 + seekTtsSpeechRate.progress = AppConfig.ttsSpeechRate + seekTtsSpeechRate.setOnSeekBarChangeListener(object : SeekBarChangeListener { + override fun onStopTrackingTouch(seekBar: SeekBar) { + AppConfig.ttsSpeechRate = seekBar.progress + upTtsSpeechRate() + } + }) + seekTimer.setOnSeekBarChangeListener(object : SeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + upTimerText(progress) + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + ReadAloud.setTimer(requireContext(), seekTimer.progress) + } + }) + } + + private fun upPlayState() { + if (!BaseReadAloudService.pause) { + binding.ivPlayPause.setImageResource(R.drawable.ic_pause_24dp) + binding.ivPlayPause.contentDescription = getString(R.string.pause) + } else { + binding.ivPlayPause.setImageResource(R.drawable.ic_play_24dp) + binding.ivPlayPause.contentDescription = getString(R.string.audio_play) + } + val bg = requireContext().bottomBackground + val isLight = ColorUtils.isColorLight(bg) + val textColor = requireContext().getPrimaryTextColor(isLight) + binding.ivPlayPause.setColorFilter(textColor) + } + + private fun upSeekTimer() { + binding.seekTimer.post { + if (BaseReadAloudService.timeMinute > 0) { + binding.seekTimer.progress = BaseReadAloudService.timeMinute + } else { + binding.seekTimer.progress = 0 + } + } + } + + private fun upTimerText(timeMinute: Int) { + if (timeMinute < 0) { + binding.tvTimer.text = requireContext().getString(R.string.timer_m, 0) + } else { + binding.tvTimer.text = requireContext().getString(R.string.timer_m, timeMinute) + } + } + + private fun upTtsSpeechRate() { + ReadAloud.upTtsSpeechRate(requireContext()) + if (!BaseReadAloudService.pause) { + ReadAloud.pause(requireContext()) + ReadAloud.resume(requireContext()) + } + } + + override fun observeLiveBus() { + observeEvent(EventBus.ALOUD_STATE) { upPlayState() } + observeEvent(EventBus.TTS_DS) { binding.seekTimer.progress = it } + } + + interface CallBack { + fun showMenuBar() + fun openChapterList() + fun onClickReadAloud() + fun finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt new file mode 100644 index 000000000..06f3119c8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt @@ -0,0 +1,244 @@ +package io.legado.app.ui.book.read.config + +import android.content.DialogInterface +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.core.view.get +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.EventBus +import io.legado.app.databinding.DialogReadBookStyleBinding +import io.legado.app.databinding.ItemReadStyleBinding +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.font.FontSelectDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onLongClick + +class ReadStyleDialog : BaseDialogFragment(R.layout.dialog_read_book_style), + FontSelectDialog.CallBack { + + private val binding by viewBinding(DialogReadBookStyleBinding::bind) + private val callBack get() = activity as? ReadBookActivity + private lateinit var styleAdapter: StyleAdapter + + override fun onStart() { + super.onStart() + dialog?.window?.let { + it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + it.setBackgroundDrawableResource(R.color.background) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + (activity as ReadBookActivity).bottomDialog++ + initView() + initData() + initViewEvent() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + ReadBookConfig.save() + (activity as ReadBookActivity).bottomDialog-- + } + + private fun initView() = binding.run { + val bg = requireContext().bottomBackground + val isLight = ColorUtils.isColorLight(bg) + val textColor = requireContext().getPrimaryTextColor(isLight) + rootView.setBackgroundColor(bg) + tvPageAnim.setTextColor(textColor) + tvBgTs.setTextColor(textColor) + tvShareLayout.setTextColor(textColor) + dsbTextSize.valueFormat = { + (it + 5).toString() + } + dsbTextLetterSpacing.valueFormat = { + ((it - 50) / 100f).toString() + } + dsbLineSize.valueFormat = { ((it - 10) / 10f).toString() } + dsbParagraphSpacing.valueFormat = { (it / 10f).toString() } + styleAdapter = StyleAdapter() + rvStyle.adapter = styleAdapter + styleAdapter.addFooterView { + ItemReadStyleBinding.inflate(layoutInflater, it, false).apply { + ivStyle.setPadding(6.dp, 6.dp, 6.dp, 6.dp) + ivStyle.setText(null) + ivStyle.setColorFilter(textColor) + ivStyle.borderColor = textColor + ivStyle.setImageResource(R.drawable.ic_add) + root.setOnClickListener { + ReadBookConfig.configList.add(ReadBookConfig.Config()) + showBgTextConfig(ReadBookConfig.configList.lastIndex) + } + } + } + } + + private fun initData() { + binding.cbShareLayout.isChecked = ReadBookConfig.shareLayout + upView() + styleAdapter.setItems(ReadBookConfig.configList) + } + + private fun initViewEvent() = binding.run { + chineseConverter.onChanged { + postEvent(EventBus.UP_CONFIG, true) + } + textFontWeightConverter.onChanged { + postEvent(EventBus.UP_CONFIG, true) + } + tvTextFont.setOnClickListener { + showDialogFragment() + } + tvTextIndent.setOnClickListener { + context?.selector( + title = getString(R.string.text_indent), + items = resources.getStringArray(R.array.indent).toList() + ) { _, index -> + ReadBookConfig.paragraphIndent = " ".repeat(index) + postEvent(EventBus.UP_CONFIG, true) + } + } + tvPadding.setOnClickListener { + dismissAllowingStateLoss() + callBack?.showPaddingConfig() + } + tvTip.setOnClickListener { + TipConfigDialog().show(childFragmentManager, "tipConfigDialog") + } + rgPageAnim.setOnCheckedChangeListener { _, checkedId -> + ReadBook.book?.setPageAnim(-1) + ReadBookConfig.pageAnim = binding.rgPageAnim.getIndexById(checkedId) + callBack?.upPageAnim() + ReadBook.loadContent(false) + } + cbShareLayout.onCheckedChangeListener = { _, isChecked -> + ReadBookConfig.shareLayout = isChecked + upView() + postEvent(EventBus.UP_CONFIG, true) + } + dsbTextSize.onChanged = { + ReadBookConfig.textSize = it + 5 + postEvent(EventBus.UP_CONFIG, true) + } + dsbTextLetterSpacing.onChanged = { + ReadBookConfig.letterSpacing = (it - 50) / 100f + postEvent(EventBus.UP_CONFIG, true) + } + dsbLineSize.onChanged = { + ReadBookConfig.lineSpacingExtra = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbParagraphSpacing.onChanged = { + ReadBookConfig.paragraphSpacing = it + postEvent(EventBus.UP_CONFIG, true) + } + } + + private fun changeBg(index: Int) { + val oldIndex = ReadBookConfig.styleSelect + if (index != oldIndex) { + ReadBookConfig.styleSelect = index + ReadBookConfig.upBg() + upView() + styleAdapter.notifyItemChanged(oldIndex) + styleAdapter.notifyItemChanged(index) + postEvent(EventBus.UP_CONFIG, true) + } + } + + private fun showBgTextConfig(index: Int): Boolean { + dismissAllowingStateLoss() + changeBg(index) + callBack?.showBgTextConfig() + return true + } + + private fun upView() = binding.run { + textFontWeightConverter.upUi(ReadBookConfig.textBold) + ReadBook.pageAnim().let { + if (it >= 0 && it < rgPageAnim.childCount) { + rgPageAnim.check(rgPageAnim[it].id) + } + } + ReadBookConfig.let { + dsbTextSize.progress = it.textSize - 5 + dsbTextLetterSpacing.progress = (it.letterSpacing * 100).toInt() + 50 + dsbLineSize.progress = it.lineSpacingExtra + dsbParagraphSpacing.progress = it.paragraphSpacing + } + } + + override val curFontPath: String + get() = ReadBookConfig.textFont + + override fun selectFont(path: String) { + if (path != ReadBookConfig.textFont) { + ReadBookConfig.textFont = path + postEvent(EventBus.UP_CONFIG, true) + } + } + + inner class StyleAdapter : + RecyclerAdapter(requireContext()) { + + override fun getViewBinding(parent: ViewGroup): ItemReadStyleBinding { + return ItemReadStyleBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemReadStyleBinding, + item: ReadBookConfig.Config, + payloads: MutableList + ) { + binding.apply { + ivStyle.setText(item.name.ifBlank { "文字" }) + ivStyle.setTextColor(item.curTextColor()) + ivStyle.setImageDrawable(item.curBgDrawable(100, 150)) + if (ReadBookConfig.styleSelect == holder.layoutPosition) { + ivStyle.borderColor = accentColor + ivStyle.setTextBold(true) + } else { + ivStyle.borderColor = item.curTextColor() + ivStyle.setTextBold(false) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemReadStyleBinding) { + binding.apply { + ivStyle.setOnClickListener { + if (ivStyle.isInView) { + changeBg(holder.layoutPosition) + } + } + ivStyle.onLongClick(ivStyle.isInView) { + if (ivStyle.isInView) { + showBgTextConfig(holder.layoutPosition) + } + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt new file mode 100644 index 000000000..26d52ba59 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt @@ -0,0 +1,215 @@ +package io.legado.app.ui.book.read.config + +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.appDb +import io.legado.app.data.entities.HttpTTS +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemHttpTtsBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.DirectLinkUpload +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class SpeakEngineDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val viewModel: SpeakEngineViewModel by viewModels() + private val ttsUrlKey = "ttsUrlKey" + private val adapter by lazy { Adapter(requireContext()) } + private var ttsEngine: String? = AppConfig.ttsEngine + private val importDocResult = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + viewModel.importLocal(uri) + } + } + private val exportDirResult = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + alert(R.string.export_success) { + if (uri.toString().isAbsUrl()) { + DirectLinkUpload.getSummary()?.let { summary -> + setMessage(summary) + } + } + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = getString(R.string.path) + editView.setText(uri.toString()) + } + customView { alertBinding.root } + okButton { + requireContext().sendToClip(uri.toString()) + } + } + } + } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + initView() + initMenu() + initData() + } + + private fun initView() = binding.run { + toolBar.setBackgroundColor(primaryColor) + toolBar.setTitle(R.string.speak_engine) + recyclerView.setEdgeEffectColor(primaryColor) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.adapter = adapter + tvFooterLeft.setText(R.string.system_tts) + tvFooterLeft.visible() + tvFooterLeft.setOnClickListener { + selectSysTts() + } + tvOk.visible() + tvOk.setOnClickListener { + AppConfig.ttsEngine = ttsEngine + dismissAllowingStateLoss() + } + tvCancel.visible() + tvCancel.setOnClickListener { + dismissAllowingStateLoss() + } + } + + private fun initMenu() = binding.run { + toolBar.inflateMenu(R.menu.speak_engine) + toolBar.menu.applyTint(requireContext()) + toolBar.setOnMenuItemClickListener(this@SpeakEngineDialog) + } + + private fun initData() { + launch { + appDb.httpTTSDao.flowAll().collect { + adapter.setItems(it) + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> showDialogFragment() + R.id.menu_default -> viewModel.importDefault() + R.id.menu_import_local -> importDocResult.launch { + mode = HandleFileContract.FILE + allowExtensions = arrayOf("txt", "json") + } + R.id.menu_import_onLine -> importAlert() + R.id.menu_export -> exportDirResult.launch { + mode = HandleFileContract.EXPORT + fileData = Triple( + "httpTts.json", + GSON.toJson(adapter.getItems()).toByteArray(), + "application/json" + ) + } + } + return true + } + + private fun selectSysTts() { + val ttsItems = viewModel.tts.engines.map { + SelectItem(it.label, it.name) + } + context?.selector(R.string.system_tts, ttsItems) { _, item, _ -> + AppConfig.ttsEngine = GSON.toJson(item) + ttsEngine = null + adapter.notifyItemRangeChanged(0, adapter.itemCount) + dismissAllowingStateLoss() + } + } + + private fun importAlert() { + val aCache = ACache.get(requireContext(), cacheDir = false) + val cacheUrls: MutableList = aCache + .getAsString(ttsUrlKey) + ?.splitNotBlank(",") + ?.toMutableList() ?: mutableListOf() + alert(R.string.import_on_line) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + editView.setFilterValues(cacheUrls) + editView.delCallBack = { + cacheUrls.remove(it) + aCache.put(ttsUrlKey, cacheUrls.joinToString(",")) + } + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { url -> + if (!cacheUrls.contains(url)) { + cacheUrls.add(0, url) + aCache.put(ttsUrlKey, cacheUrls.joinToString(",")) + } + viewModel.importOnLine(url) + } + } + } + } + + inner class Adapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemHttpTtsBinding { + return ItemHttpTtsBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemHttpTtsBinding, + item: HttpTTS, + payloads: MutableList + ) { + binding.apply { + cbName.text = item.name + cbName.isChecked = item.id.toString() == ttsEngine + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemHttpTtsBinding) { + binding.run { + cbName.setOnClickListener { + getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS -> + ttsEngine = httpTTS.id.toString() + notifyItemRangeChanged(getHeaderCount(), itemCount) + } + } + ivEdit.setOnClickListener { + val id = getItemByLayoutPosition(holder.layoutPosition)!!.id + showDialogFragment(HttpTtsEditDialog(id)) + } + ivMenuDelete.setOnClickListener { + getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS -> + appDb.httpTTSDao.delete(httpTTS) + } + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt new file mode 100644 index 000000000..d12f2e76c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt @@ -0,0 +1,71 @@ +package io.legado.app.ui.book.read.config + +import android.app.Application +import android.net.Uri +import android.speech.tts.TextToSpeech +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.HttpTTS +import io.legado.app.help.DefaultData +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.isJsonArray +import io.legado.app.utils.isJsonObject +import io.legado.app.utils.readText +import io.legado.app.utils.toastOnUi + +class SpeakEngineViewModel(application: Application) : BaseViewModel(application) { + + val tts = TextToSpeech(context, null) + + fun importDefault() { + execute { + DefaultData.importDefaultHttpTTS() + } + } + + fun importOnLine(url: String) { + execute { + okHttpClient.newCallResponseBody { + url(url) + }.text("utf-8").let { json -> + import(json) + } + }.onSuccess { + context.toastOnUi("导入成功") + }.onError { + context.toastOnUi("导入失败") + } + } + + fun importLocal(uri: Uri) { + execute { + import(uri.readText(context)) + }.onSuccess { + context.toastOnUi("导入成功") + }.onError { + context.toastOnUi("导入失败\n${it.localizedMessage}") + } + } + + fun import(text: String) { + when { + text.isJsonArray() -> { + HttpTTS.fromJsonArray(text).let { + appDb.httpTTSDao.insert(*it.toTypedArray()) + } + } + text.isJsonObject() -> { + HttpTTS.fromJson(text)?.let { + appDb.httpTTSDao.insert(it) + } + } + else -> { + throw NoStackTraceException("格式不对") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/TextFontWeightConverter.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TextFontWeightConverter.kt new file mode 100644 index 000000000..67e80f1d3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/TextFontWeightConverter.kt @@ -0,0 +1,56 @@ +package io.legado.app.ui.book.read.config + +import android.content.Context +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.util.AttributeSet +import io.legado.app.R +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.widget.text.StrokeTextView + + +class TextFontWeightConverter(context: Context, attrs: AttributeSet?) : + StrokeTextView(context, attrs) { + + private val spannableString = SpannableString("中/粗/细") + private var enabledSpan: ForegroundColorSpan = ForegroundColorSpan(context.accentColor) + private var onChanged: (() -> Unit)? = null + + init { + text = spannableString + if (!isInEditMode) { + upUi(ReadBookConfig.textBold) + } + setOnClickListener { + selectType() + } + } + + @Suppress("MemberVisibilityCanBePrivate") + fun upUi(type: Int) { + spannableString.removeSpan(enabledSpan) + when (type) { + 0 -> spannableString.setSpan(enabledSpan, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + 1 -> spannableString.setSpan(enabledSpan, 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + 2 -> spannableString.setSpan(enabledSpan, 4, 5, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + text = spannableString + } + + private fun selectType() { + context.alert(titleResource = R.string.text_font_weight_converter) { + items(context.resources.getStringArray(R.array.text_font_weight).toList()) { _, i -> + ReadBookConfig.textBold = i + upUi(i) + onChanged?.invoke() + } + } + } + + fun onChanged(unit: () -> Unit) { + onChanged = unit + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt new file mode 100644 index 000000000..607b80f7b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt @@ -0,0 +1,195 @@ +package io.legado.app.ui.book.read.config + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import com.jaredrummler.android.colorpicker.ColorPickerDialog +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.EventBus +import io.legado.app.databinding.DialogTipConfigBinding +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.ReadTipConfig +import io.legado.app.lib.dialogs.selector +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { + + companion object { + const val TIP_COLOR = 7897 + } + + private val binding by viewBinding(DialogTipConfigBinding::bind) + + override fun onStart() { + super.onStart() + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + initView() + initEvent() + observeEvent(EventBus.TIP_COLOR) { + upTvTipColor() + } + } + + private fun initView() = binding.run { + rgTitleMode.checkByIndex(ReadBookConfig.titleMode) + dsbTitleSize.progress = ReadBookConfig.titleSize + dsbTitleTop.progress = ReadBookConfig.titleTopSpacing + dsbTitleBottom.progress = ReadBookConfig.titleBottomSpacing + + tvHeaderShow.text = ReadTipConfig.getHeaderModes(requireContext())[ReadTipConfig.headerMode] + tvFooterShow.text = ReadTipConfig.getFooterModes(requireContext())[ReadTipConfig.footerMode] + + tvHeaderLeft.text = ReadTipConfig.tipHeaderLeftStr + tvHeaderMiddle.text = ReadTipConfig.tipHeaderMiddleStr + tvHeaderRight.text = ReadTipConfig.tipHeaderRightStr + tvFooterLeft.text = ReadTipConfig.tipFooterLeftStr + tvFooterMiddle.text = ReadTipConfig.tipFooterMiddleStr + tvFooterRight.text = ReadTipConfig.tipFooterRightStr + + upTvTipColor() + } + + private fun upTvTipColor() { + binding.tvTipColor.text = + if (ReadTipConfig.tipColor == 0) { + "跟随正文" + } else { + "#${ReadTipConfig.tipColor.hexString}" + } + } + + private fun initEvent() = binding.run { + rgTitleMode.setOnCheckedChangeListener { _, checkedId -> + ReadBookConfig.titleMode = rgTitleMode.getIndexById(checkedId) + postEvent(EventBus.UP_CONFIG, true) + } + dsbTitleSize.onChanged = { + ReadBookConfig.titleSize = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbTitleTop.onChanged = { + ReadBookConfig.titleTopSpacing = it + postEvent(EventBus.UP_CONFIG, true) + } + dsbTitleBottom.onChanged = { + ReadBookConfig.titleBottomSpacing = it + postEvent(EventBus.UP_CONFIG, true) + } + llHeaderShow.setOnClickListener { + val headerModes = ReadTipConfig.getHeaderModes(requireContext()) + context?.selector(items = headerModes.values.toList()) { _, i -> + ReadTipConfig.headerMode = headerModes.keys.toList()[i] + tvHeaderShow.text = headerModes[ReadTipConfig.headerMode] + postEvent(EventBus.UP_CONFIG, true) + } + } + llFooterShow.setOnClickListener { + val footerModes = ReadTipConfig.getFooterModes(requireContext()) + context?.selector(items = footerModes.values.toList()) { _, i -> + ReadTipConfig.footerMode = footerModes.keys.toList()[i] + tvFooterShow.text = footerModes[ReadTipConfig.footerMode] + postEvent(EventBus.UP_CONFIG, true) + } + } + llHeaderLeft.setOnClickListener { + context?.selector(items = ReadTipConfig.tips) { _, i -> + clearRepeat(i) + ReadTipConfig.tipHeaderLeft = i + tvHeaderLeft.text = ReadTipConfig.tips[i] + postEvent(EventBus.UP_CONFIG, true) + } + } + llHeaderMiddle.setOnClickListener { + context?.selector(items = ReadTipConfig.tips) { _, i -> + clearRepeat(i) + ReadTipConfig.tipHeaderMiddle = i + tvHeaderMiddle.text = ReadTipConfig.tips[i] + postEvent(EventBus.UP_CONFIG, true) + } + } + llHeaderRight.setOnClickListener { + context?.selector(items = ReadTipConfig.tips) { _, i -> + clearRepeat(i) + ReadTipConfig.tipHeaderRight = i + tvHeaderRight.text = ReadTipConfig.tips[i] + postEvent(EventBus.UP_CONFIG, true) + } + } + llFooterLeft.setOnClickListener { + context?.selector(items = ReadTipConfig.tips) { _, i -> + clearRepeat(i) + ReadTipConfig.tipFooterLeft = i + tvFooterLeft.text = ReadTipConfig.tips[i] + postEvent(EventBus.UP_CONFIG, true) + } + } + llFooterMiddle.setOnClickListener { + context?.selector(items = ReadTipConfig.tips) { _, i -> + clearRepeat(i) + ReadTipConfig.tipFooterMiddle = i + tvFooterMiddle.text = ReadTipConfig.tips[i] + postEvent(EventBus.UP_CONFIG, true) + } + } + llFooterRight.setOnClickListener { + context?.selector(items = ReadTipConfig.tips) { _, i -> + clearRepeat(i) + ReadTipConfig.tipFooterRight = i + tvFooterRight.text = ReadTipConfig.tips[i] + postEvent(EventBus.UP_CONFIG, true) + } + } + llTipColor.setOnClickListener { + context?.selector(items = arrayListOf("跟随正文", "自定义")) { _, i -> + when (i) { + 0 -> { + ReadTipConfig.tipColor = 0 + upTvTipColor() + postEvent(EventBus.UP_CONFIG, true) + } + 1 -> ColorPickerDialog.newBuilder() + .setShowAlphaSlider(false) + .setDialogType(ColorPickerDialog.TYPE_CUSTOM) + .setDialogId(TIP_COLOR) + .show(requireActivity()) + } + } + } + } + + private fun clearRepeat(repeat: Int) = ReadTipConfig.apply { + if (repeat != none) { + if (tipHeaderLeft == repeat) { + tipHeaderLeft = none + binding.tvHeaderLeft.text = tips[none] + } + if (tipHeaderMiddle == repeat) { + tipHeaderMiddle = none + binding.tvHeaderMiddle.text = tips[none] + } + if (tipHeaderRight == repeat) { + tipHeaderRight = none + binding.tvHeaderRight.text = tips[none] + } + if (tipFooterLeft == repeat) { + tipFooterLeft = none + binding.tvFooterLeft.text = tips[none] + } + if (tipFooterMiddle == repeat) { + tipFooterMiddle = none + binding.tvFooterMiddle.text = tips[none] + } + if (tipFooterRight == repeat) { + tipFooterRight = none + binding.tvFooterRight.text = tips[none] + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/TocRegexDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TocRegexDialog.kt new file mode 100644 index 000000000..cb29d093e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/TocRegexDialog.kt @@ -0,0 +1,265 @@ +package io.legado.app.ui.book.read.config + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.appDb +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogTocRegexBinding +import io.legado.app.databinding.DialogTocRegexEditBinding +import io.legado.app.databinding.ItemTocRegexBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.util.* + +class TocRegexDialog() : BaseDialogFragment(R.layout.dialog_toc_regex), + Toolbar.OnMenuItemClickListener { + + constructor(tocRegex: String?) : this() { + arguments = Bundle().apply { + putString("tocRegex", tocRegex) + } + } + + private val importTocRuleKey = "tocRuleUrl" + private val viewModel: TocRegexViewModel by viewModels() + private val binding by viewBinding(DialogTocRegexBinding::bind) + private val adapter by lazy { TocRegexAdapter(requireContext()) } + var selectedName: String? = null + private var durRegex: String? = null + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.8f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + durRegex = arguments?.getString("tocRegex") + binding.toolBar.setTitle(R.string.txt_toc_regex) + binding.toolBar.inflateMenu(R.menu.txt_toc_regex) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + initView() + initData() + } + + private fun initView() = binding.run { + recyclerView.addItemDecoration(VerticalDivider(requireContext())) + recyclerView.adapter = adapter + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recyclerView) + tvCancel.setOnClickListener { + dismissAllowingStateLoss() + } + tvOk.setOnClickListener { + adapter.getItems().forEach { tocRule -> + if (selectedName == tocRule.name) { + val callBack = activity as? CallBack + callBack?.onTocRegexDialogResult(tocRule.rule) + dismissAllowingStateLoss() + return@setOnClickListener + } + } + } + } + + private fun initData() { + launch { + appDb.txtTocRuleDao.observeAll().collect { tocRules -> + initSelectedName(tocRules) + adapter.setItems(tocRules) + } + } + } + + private fun initSelectedName(tocRules: List) { + if (selectedName == null && durRegex != null) { + tocRules.forEach { + if (durRegex == it.rule) { + selectedName = it.name + return@forEach + } + } + if (selectedName == null) { + selectedName = "" + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> editRule() + R.id.menu_default -> viewModel.importDefault() + R.id.menu_import -> showImportDialog() + } + return false + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(requireContext(), cacheDir = false) + val defaultUrl = "https://gitee.com/fisher52/YueDuJson/raw/master/myTxtChapterRule.json" + val cacheUrls: MutableList = aCache + .getAsString(importTocRuleKey) + ?.splitNotBlank(",") + ?.toMutableList() + ?: mutableListOf() + if (!cacheUrls.contains(defaultUrl)) { + cacheUrls.add(0, defaultUrl) + } + requireContext().alert(titleResource = R.string.import_on_line) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + editView.setFilterValues(cacheUrls) + editView.delCallBack = { + cacheUrls.remove(it) + aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) + } + } + customView { alertBinding.root } + okButton { + val text = alertBinding.editView.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put(importTocRuleKey, cacheUrls.joinToString(",")) + } + Snackbar.make(binding.toolBar, R.string.importing, Snackbar.LENGTH_INDEFINITE) + .show() + viewModel.importOnLine(it) { msg -> + binding.toolBar.snackbar(msg) + } + } + } + cancelButton() + } + } + + @SuppressLint("InflateParams") + private fun editRule(rule: TxtTocRule? = null) { + val tocRule = rule?.copy() ?: TxtTocRule() + requireContext().alert(titleResource = R.string.txt_toc_regex) { + val alertBinding = DialogTocRegexEditBinding.inflate(layoutInflater) + alertBinding.apply { + tvRuleName.setText(tocRule.name) + tvRuleRegex.setText(tocRule.rule) + } + customView { alertBinding.root } + okButton { + alertBinding.apply { + tocRule.name = tvRuleName.text.toString() + tocRule.rule = tvRuleRegex.text.toString() + viewModel.saveRule(tocRule) + } + } + cancelButton() + } + } + + inner class TocRegexAdapter(context: Context) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + override fun getViewBinding(parent: ViewGroup): ItemTocRegexBinding { + return ItemTocRegexBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemTocRegexBinding, + item: TxtTocRule, + payloads: MutableList + ) { + binding.apply { + if (payloads.isEmpty()) { + root.setBackgroundColor(context.backgroundColor) + rbRegexName.text = item.name + rbRegexName.isChecked = item.name == selectedName + swtEnabled.isChecked = item.enable + } else { + rbRegexName.isChecked = item.name == selectedName + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemTocRegexBinding) { + binding.apply { + rbRegexName.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed && isChecked) { + selectedName = getItem(holder.layoutPosition)?.name + updateItems(0, itemCount - 1, true) + } + } + swtEnabled.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + getItem(holder.layoutPosition)?.let { + it.enable = isChecked + launch(IO) { + appDb.txtTocRuleDao.update(it) + } + } + } + } + ivEdit.setOnClickListener { + editRule(getItem(holder.layoutPosition)) + } + ivDelete.setOnClickListener { + getItem(holder.layoutPosition)?.let { item -> + launch(IO) { + appDb.txtTocRuleDao.delete(item) + } + } + } + } + } + + private var isMoved = false + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + swapItem(srcPosition, targetPosition) + isMoved = true + return super.swap(srcPosition, targetPosition) + } + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.onClearView(recyclerView, viewHolder) + if (isMoved) { + for ((index, item) in getItems().withIndex()) { + item.serialNumber = index + 1 + } + launch(IO) { + appDb.txtTocRuleDao.update(*getItems().toTypedArray()) + } + } + isMoved = false + } + } + + interface CallBack { + fun onTocRegexDialogResult(tocRegex: String) {} + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/TocRegexViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TocRegexViewModel.kt new file mode 100644 index 000000000..011f4debe --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/TocRegexViewModel.kt @@ -0,0 +1,47 @@ +package io.legado.app.ui.book.read.config + +import android.app.Application +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.TxtTocRule +import io.legado.app.help.DefaultData +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray + +class TocRegexViewModel(application: Application) : BaseViewModel(application) { + + fun saveRule(rule: TxtTocRule) { + execute { + if (rule.serialNumber < 0) { + rule.serialNumber = appDb.txtTocRuleDao.maxOrder + 1 + } + appDb.txtTocRuleDao.insert(rule) + } + } + + fun importDefault() { + execute { + DefaultData.importDefaultTocRules() + } + } + + fun importOnLine(url: String, finally: (msg: String) -> Unit) { + execute { + okHttpClient.newCallResponseBody { + url(url) + }.text("utf-8").let { json -> + GSON.fromJsonArray(json)?.let { + appDb.txtTocRuleDao.insert(*it.toTypedArray()) + } + } + }.onSuccess { + finally("导入成功") + }.onError { + finally("导入失败") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt new file mode 100644 index 000000000..9c33b0f67 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt @@ -0,0 +1,548 @@ +package io.legado.app.ui.book.read.page + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.data.entities.Bookmark +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.theme.accentColor +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.PhotoDialog +import io.legado.app.ui.book.read.page.entities.TextChar +import io.legado.app.ui.book.read.page.entities.TextLine +import io.legado.app.ui.book.read.page.entities.TextPage +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.ui.book.read.page.provider.ImageProvider +import io.legado.app.ui.book.read.page.provider.TextPageFactory +import io.legado.app.utils.* +import kotlin.math.min + +/** + * 阅读内容界面 + */ +class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, attrs) { + var selectAble = context.getPrefBoolean(PreferKey.textSelectAble, true) + var upView: ((TextPage) -> Unit)? = null + private val selectedPaint by lazy { + Paint().apply { + color = context.getCompatColor(R.color.btn_bg_press_2) + style = Paint.Style.FILL + } + } + private var callBack: CallBack + private val visibleRect = RectF() + private val selectStart = arrayOf(0, 0, 0) + private val selectEnd = arrayOf(0, 0, 0) + var textPage: TextPage = TextPage() + private set + + //滚动参数 + private val pageFactory: TextPageFactory get() = callBack.pageFactory + private var pageOffset = 0 + + init { + callBack = activity as CallBack + } + + fun setContent(textPage: TextPage) { + this.textPage = textPage + invalidate() + } + + fun upVisibleRect() { + visibleRect.set( + ChapterProvider.paddingLeft.toFloat(), + ChapterProvider.paddingTop.toFloat(), + ChapterProvider.visibleRight.toFloat(), + ChapterProvider.visibleBottom.toFloat() + ) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + ChapterProvider.upViewSize(w, h) + upVisibleRect() + textPage.format() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.clipRect(visibleRect) + drawPage(canvas) + } + + /** + * 绘制页面 + */ + private fun drawPage(canvas: Canvas) { + var relativeOffset = relativeOffset(0) + textPage.textLines.forEach { textLine -> + draw(canvas, textLine, relativeOffset) + } + if (!callBack.isScroll) return + //滚动翻页 + if (!pageFactory.hasNext()) return + val nextPage = relativePage(1) + relativeOffset = relativeOffset(1) + nextPage.textLines.forEach { textLine -> + draw(canvas, textLine, relativeOffset) + } + if (!pageFactory.hasNextPlus()) return + relativeOffset = relativeOffset(2) + if (relativeOffset < ChapterProvider.visibleHeight) { + relativePage(2).textLines.forEach { textLine -> + draw(canvas, textLine, relativeOffset) + } + } + } + + private fun draw( + canvas: Canvas, + textLine: TextLine, + relativeOffset: Float, + ) { + val lineTop = textLine.lineTop + relativeOffset + val lineBase = textLine.lineBase + relativeOffset + val lineBottom = textLine.lineBottom + relativeOffset + drawChars( + canvas, + textLine.textChars, + lineTop, + lineBase, + lineBottom, + textLine.isTitle, + textLine.isReadAloud, + textLine.isImage + ) + } + + /** + * 绘制文字 + */ + private fun drawChars( + canvas: Canvas, + textChars: List, + lineTop: Float, + lineBase: Float, + lineBottom: Float, + isTitle: Boolean, + isReadAloud: Boolean, + isImageLine: Boolean + ) { + val textPaint = if (isTitle) { + ChapterProvider.titlePaint + } else { + ChapterProvider.contentPaint + } + val textColor = if (isReadAloud) context.accentColor else ReadBookConfig.textColor + textChars.forEach { + if (it.isImage) { + drawImage(canvas, it, lineTop, lineBottom, isImageLine) + } else { + textPaint.color = textColor + if(it.isSearchResult) { + textPaint.color = context.accentColor + } + canvas.drawText(it.charData, it.start, lineBase, textPaint) + } + if (it.selected) { + canvas.drawRect(it.start, lineTop, it.end, lineBottom, selectedPaint) + } + } + } + + /** + * 绘制图片 + */ + private fun drawImage( + canvas: Canvas, + textChar: TextChar, + lineTop: Float, + lineBottom: Float, + isImageLine: Boolean + ) { + val book = ReadBook.book ?: return + ImageProvider.getImage( + book, + textPage.chapterIndex, + textChar.charData, + ReadBook.bookSource, + true + )?.let { + val rectF = if (isImageLine) { + RectF(textChar.start, lineTop, textChar.end, lineBottom) + } else { + /*以宽度为基准保持图片的原始比例叠加,当div为负数时,允许高度比字符更高*/ + val h = (textChar.end - textChar.start) / it.width * it.height + val div = (lineBottom - lineTop - h) / 2 + RectF(textChar.start, lineTop + div, textChar.end, lineBottom - div) + } + kotlin.runCatching { + canvas.drawBitmap(it, null, rectF, null) + }.onFailure { e -> + context.toastOnUi(e.localizedMessage) + } + } + } + + /** + * 滚动事件 + */ + fun scroll(mOffset: Int) { + if (mOffset == 0) return + pageOffset += mOffset + if (!pageFactory.hasPrev() && pageOffset > 0) { + pageOffset = 0 + } else if (!pageFactory.hasNext() + && pageOffset < 0 + && pageOffset + textPage.height < ChapterProvider.visibleHeight + ) { + val offset = (ChapterProvider.visibleHeight - textPage.height).toInt() + pageOffset = min(0, offset) + } else if (pageOffset > 0) { + pageFactory.moveToPrev(false) + textPage = pageFactory.curPage + pageOffset -= textPage.height.toInt() + upView?.invoke(textPage) + contentDescription = textPage.text + } else if (pageOffset < -textPage.height) { + pageOffset += textPage.height.toInt() + pageFactory.moveToNext(false) + textPage = pageFactory.curPage + upView?.invoke(textPage) + contentDescription = textPage.text + } + invalidate() + } + + fun resetPageOffset() { + pageOffset = 0 + } + + /** + * 选择文字 + */ + fun selectText( + x: Float, + y: Float, + select: (relativePage: Int, lineIndex: Int, charIndex: Int) -> Unit, + ) { + if (!selectAble) return + touch(x, y) { relativePos, textPage, _, lineIndex, _, charIndex, textChar -> + if (textChar.isImage) { + activity?.showDialogFragment(PhotoDialog(textPage.chapterIndex, textChar.charData)) + } else { + textChar.selected = true + invalidate() + select(relativePos, lineIndex, charIndex) + } + } + } + + /** + * 开始选择符移动 + */ + fun selectStartMove(x: Float, y: Float) { + touch(x, y) { relativePos, _, relativeOffset, lineIndex, textLine, charIndex, textChar -> + if (selectStart[0] != relativePos || + selectStart[1] != lineIndex || + selectStart[2] != charIndex + ) { + if (selectToInt(relativePos, lineIndex, charIndex) + <= selectToInt(selectEnd) + ) { + selectStart[0] = relativePos + selectStart[1] = lineIndex + selectStart[2] = charIndex + upSelectedStart( + textChar.start, + textLine.lineBottom + relativeOffset, + textLine.lineTop + relativeOffset + ) + upSelectChars() + } + } + } + } + + /** + * 结束选择符移动 + */ + fun selectEndMove(x: Float, y: Float) { + touch(x, y) { relativePos, _, relativeOffset, lineIndex, textLine, charIndex, textChar -> + if (selectEnd[0] != relativePos + || selectEnd[1] != lineIndex + || selectEnd[2] != charIndex + ) { + if (selectToInt(relativePos, lineIndex, charIndex) + >= selectToInt(selectStart) + ) { + selectEnd[0] = relativePos + selectEnd[1] = lineIndex + selectEnd[2] = charIndex + upSelectedEnd(textChar.end, textLine.lineBottom + relativeOffset) + upSelectChars() + } + } + } + } + + private fun touch( + x: Float, + y: Float, + touched: ( + relativePos: Int, + textPage: TextPage, + relativeOffset: Float, + lineIndex: Int, + textLine: TextLine, + charIndex: Int, + textChar: TextChar + ) -> Unit + ) { + if (!visibleRect.contains(x, y)) return + var relativeOffset: Float + for (relativePos in 0..2) { + relativeOffset = relativeOffset(relativePos) + if (relativePos > 0) { + //滚动翻页 + if (!callBack.isScroll) return + if (relativeOffset >= ChapterProvider.visibleHeight) return + } + val textPage = relativePage(relativePos) + for ((lineIndex, textLine) in textPage.textLines.withIndex()) { + if (textLine.isTouch(x, y, relativeOffset)) { + for ((charIndex, textChar) in textLine.textChars.withIndex()) { + if (textChar.isTouch(x)) { + touched.invoke( + relativePos, textPage, + relativeOffset, + lineIndex, textLine, + charIndex, textChar + ) + return + } + } + return + } + } + } + } + + /** + * 选择开始文字 + */ + fun selectStartMoveIndex(relativePage: Int, lineIndex: Int, charIndex: Int) { + selectStart[0] = relativePage + selectStart[1] = lineIndex + selectStart[2] = charIndex + val textLine = relativePage(relativePage).getLine(lineIndex) + val textChar = textLine.getTextChar(charIndex) + upSelectedStart( + textChar.start, + textLine.lineBottom + relativeOffset(relativePage), + textLine.lineTop + relativeOffset(relativePage) + ) + upSelectChars() + } + + /** + * 选择结束文字 + */ + fun selectEndMoveIndex(relativePage: Int, lineIndex: Int, charIndex: Int) { + selectEnd[0] = relativePage + selectEnd[1] = lineIndex + selectEnd[2] = charIndex + val textLine = relativePage(relativePage).getLine(lineIndex) + val textChar = textLine.getTextChar(charIndex) + upSelectedEnd(textChar.end, textLine.lineBottom + relativeOffset(relativePage)) + upSelectChars() + } + + private fun upSelectChars() { + val last = if (callBack.isScroll) 2 else 0 + for (relativePos in 0..last) { + for ((lineIndex, textLine) in relativePage(relativePos).textLines.withIndex()) { + for ((charIndex, textChar) in textLine.textChars.withIndex()) { + textChar.selected = when { + relativePos == selectStart[0] + && relativePos == selectEnd[0] + && lineIndex == selectStart[1] + && lineIndex == selectEnd[1] -> { + charIndex in selectStart[2]..selectEnd[2] + } + relativePos == selectStart[0] && lineIndex == selectStart[1] -> { + charIndex >= selectStart[2] + } + relativePos == selectEnd[0] && lineIndex == selectEnd[1] -> { + charIndex <= selectEnd[2] + } + relativePos == selectStart[0] && relativePos == selectEnd[0] -> { + lineIndex in (selectStart[1] + 1) until selectEnd[1] + } + relativePos == selectStart[0] -> { + lineIndex > selectStart[1] + } + relativePos == selectEnd[0] -> { + lineIndex < selectEnd[1] + } + else -> { + relativePos in selectStart[0] + 1 until selectEnd[0] + } + } + textChar.isSearchResult = textChar.selected && callBack.isSelectingSearchResult + } + } + } + invalidate() + } + + private fun upSelectedStart(x: Float, y: Float, top: Float) = callBack.apply { + upSelectedStart(x, y + headerHeight, top + headerHeight) + } + + private fun upSelectedEnd(x: Float, y: Float) = callBack.apply { + upSelectedEnd(x, y + headerHeight) + } + + fun cancelSelect() { + val last = if (callBack.isScroll) 2 else 0 + for (relativePos in 0..last) { + relativePage(relativePos).textLines.forEach { textLine -> + textLine.textChars.forEach { + it.selected = false + } + } + } + invalidate() + callBack.onCancelSelect() + } + + val selectedText: String + get() { + val stringBuilder = StringBuilder() + for (relativePos in selectStart[0]..selectEnd[0]) { + val textPage = relativePage(relativePos) + when { + relativePos == selectStart[0] && relativePos == selectEnd[0] -> { + for (lineIndex in selectStart[1]..selectEnd[1]) { + when { + lineIndex == selectStart[1] && lineIndex == selectEnd[1] -> { + stringBuilder.append( + textPage.textLines[lineIndex].text + .substring(selectStart[2], selectEnd[2] + 1) + ) + } + lineIndex == selectStart[1] -> { + stringBuilder.append( + textPage.textLines[lineIndex].text + .substring(selectStart[2]) + ) + } + lineIndex == selectEnd[1] -> { + stringBuilder.append( + textPage.textLines[lineIndex].text + .substring(0, selectEnd[2] + 1) + ) + } + else -> { + stringBuilder.append(textPage.textLines[lineIndex].text) + } + } + } + } + relativePos == selectStart[0] -> { + for (lineIndex in selectStart[1] until textPage.textLines.size) { + when (lineIndex) { + selectStart[1] -> { + stringBuilder.append( + textPage.textLines[lineIndex].text + .substring(selectStart[2]) + ) + } + else -> { + stringBuilder.append(textPage.textLines[lineIndex].text) + } + } + } + } + relativePos == selectEnd[0] -> { + for (lineIndex in 0..selectEnd[1]) { + when (lineIndex) { + selectEnd[1] -> { + stringBuilder.append( + textPage.textLines[lineIndex].text + .substring(0, selectEnd[2] + 1) + ) + } + else -> { + stringBuilder.append(textPage.textLines[lineIndex].text) + } + } + } + } + relativePos in selectStart[0] + 1 until selectEnd[0] -> { + for (lineIndex in selectStart[1]..selectEnd[1]) { + stringBuilder.append(textPage.textLines[lineIndex].text) + } + } + } + } + return stringBuilder.toString() + } + + fun createBookmark(): Bookmark? { + val page = relativePage(selectStart[0]) + page.getTextChapter()?.let { chapter -> + ReadBook.book?.let { book -> + return book.createBookMark().apply { + chapterIndex = page.chapterIndex + chapterPos = chapter.getReadLength(page.index) + + page.getSelectStartLength(selectStart[1], selectStart[2]) + chapterName = chapter.title + bookText = selectedText + } + } + } + return null + } + + private fun selectToInt(page: Int, line: Int, char: Int): Int { + return page * 10000000 + line * 100000 + char + } + + private fun selectToInt(select: Array): Int { + return select[0] * 10000000 + select[1] * 100000 + select[2] + } + + private fun relativeOffset(relativePos: Int): Float { + return when (relativePos) { + 0 -> pageOffset.toFloat() + 1 -> pageOffset + textPage.height + else -> pageOffset + textPage.height + pageFactory.nextPage.height + } + } + + fun relativePage(relativePos: Int): TextPage { + return when (relativePos) { + 0 -> textPage + 1 -> pageFactory.nextPage + else -> pageFactory.nextPlusPage + } + } + + interface CallBack { + fun upSelectedStart(x: Float, y: Float, top: Float) + fun upSelectedEnd(x: Float, y: Float) + fun onCancelSelect() + val headerHeight: Int + val pageFactory: TextPageFactory + val isScroll: Boolean + var isSelectingSearchResult: Boolean + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt new file mode 100644 index 000000000..f838c3166 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt @@ -0,0 +1,281 @@ +package io.legado.app.ui.book.read.page + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import io.legado.app.R +import io.legado.app.constant.AppConst.timeFormat +import io.legado.app.data.entities.Bookmark +import io.legado.app.databinding.ViewBookPageBinding +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.ReadTipConfig +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.read.page.entities.TextPage +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.ui.widget.BatteryView +import io.legado.app.utils.* +import java.util.* + +/** + * 阅读界面 + */ +class PageView(context: Context) : FrameLayout(context) { + + private val binding = ViewBookPageBinding.inflate(LayoutInflater.from(context), this, true) + private val readBookActivity get() = activity as? ReadBookActivity + private var battery = 100 + private var tvTitle: BatteryView? = null + private var tvTime: BatteryView? = null + private var tvBattery: BatteryView? = null + private var tvPage: BatteryView? = null + private var tvTotalProgress: BatteryView? = null + private var tvPageAndTotal: BatteryView? = null + private var tvBookName: BatteryView? = null + private var tvTimeBattery: BatteryView? = null + + val headerHeight: Int + get() { + val h1 = if (ReadBookConfig.hideStatusBar) 0 else context.statusBarHeight + val h2 = if (binding.llHeader.isGone) 0 else binding.llHeader.height + return h1 + h2 + } + + init { + if (!isInEditMode) { + //设置背景防止切换背景时文字重叠 + setBackgroundColor(context.getCompatColor(R.color.background)) + upStyle() + } + binding.contentTextView.upView = { + setProgress(it) + } + } + + fun upStyle() = binding.run { + upTipStyle() + ReadBookConfig.let { + val tipColor = with(ReadTipConfig) { + if (tipColor == 0) it.textColor else tipColor + } + bvHeaderLeft.setColor(tipColor) + tvHeaderLeft.setColor(tipColor) + tvHeaderMiddle.setColor(tipColor) + tvHeaderRight.setColor(tipColor) + bvFooterLeft.setColor(tipColor) + tvFooterLeft.setColor(tipColor) + tvFooterMiddle.setColor(tipColor) + tvFooterRight.setColor(tipColor) + upStatusBar() + llHeader.setPadding( + it.headerPaddingLeft.dp, + it.headerPaddingTop.dp, + it.headerPaddingRight.dp, + it.headerPaddingBottom.dp + ) + llFooter.setPadding( + it.footerPaddingLeft.dp, + it.footerPaddingTop.dp, + it.footerPaddingRight.dp, + it.footerPaddingBottom.dp + ) + vwTopDivider.visible(it.showHeaderLine) + vwBottomDivider.visible(it.showFooterLine) + } + contentTextView.upVisibleRect() + upTime() + upBattery(battery) + } + + /** + * 显示状态栏时隐藏header + */ + fun upStatusBar() = with(binding.vwStatusBar) { + setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom) + isGone = ReadBookConfig.hideStatusBar || readBookActivity?.isInMultiWindow == true + } + + private fun upTipStyle() = binding.run { + ReadTipConfig.apply { + tvHeaderLeft.isInvisible = tipHeaderLeft != chapterTitle + bvHeaderLeft.isInvisible = + tipHeaderLeft == none || !tvHeaderLeft.isInvisible + tvHeaderRight.isGone = tipHeaderRight == none + tvHeaderMiddle.isGone = tipHeaderMiddle == none + tvFooterLeft.isInvisible = tipFooterLeft != chapterTitle + bvFooterLeft.isInvisible = + tipFooterLeft == none || !tvFooterLeft.isInvisible + tvFooterRight.isGone = tipFooterRight == none + tvFooterMiddle.isGone = tipFooterMiddle == none + llHeader.isGone = when (headerMode) { + 1 -> false + 2 -> true + else -> !ReadBookConfig.hideStatusBar + } + llFooter.isGone = when (footerMode) { + 1 -> true + else -> false + } + } + tvTitle = getTipView(ReadTipConfig.chapterTitle) + tvTitle?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvTime = getTipView(ReadTipConfig.time) + tvTime?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvBattery = getTipView(ReadTipConfig.battery) + tvBattery?.apply { + isBattery = true + textSize = 10f + } + tvPage = getTipView(ReadTipConfig.page) + tvPage?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvTotalProgress = getTipView(ReadTipConfig.totalProgress) + tvTotalProgress?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal) + tvPageAndTotal?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvBookName = getTipView(ReadTipConfig.bookName) + tvBookName?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvTimeBattery = getTipView(ReadTipConfig.timeBattery) + tvTimeBattery?.apply { + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + } + + private fun getTipView(tip: Int): BatteryView? = binding.run { + return when (tip) { + ReadTipConfig.tipHeaderLeft -> + if (tip == ReadTipConfig.chapterTitle) tvHeaderLeft else bvHeaderLeft + ReadTipConfig.tipHeaderMiddle -> tvHeaderMiddle + ReadTipConfig.tipHeaderRight -> tvHeaderRight + ReadTipConfig.tipFooterLeft -> + if (tip == ReadTipConfig.chapterTitle) tvFooterLeft else bvFooterLeft + ReadTipConfig.tipFooterMiddle -> tvFooterMiddle + ReadTipConfig.tipFooterRight -> tvFooterRight + else -> null + } + } + + fun setBg(bg: Drawable?) { + binding.pagePanel.background = bg + } + + fun upTime() { + tvTime?.text = timeFormat.format(Date(System.currentTimeMillis())) + upTimeBattery() + } + + fun upBattery(battery: Int) { + this.battery = battery + tvBattery?.setBattery(battery) + upTimeBattery() + } + + @SuppressLint("SetTextI18n") + private fun upTimeBattery() { + tvTimeBattery?.let { + val time = timeFormat.format(Date(System.currentTimeMillis())) + it.text = "$time $battery%" + } + } + + fun setContent(textPage: TextPage, resetPageOffset: Boolean = true) { + setProgress(textPage) + if (resetPageOffset) { + resetPageOffset() + } + binding.contentTextView.setContent(textPage) + } + + fun setContentDescription(content: String) { + binding.contentTextView.contentDescription = content + } + + fun resetPageOffset() { + binding.contentTextView.resetPageOffset() + } + + @SuppressLint("SetTextI18n") + fun setProgress(textPage: TextPage) = textPage.apply { + tvBookName?.text = ReadBook.book?.name + tvTitle?.text = textPage.title + tvPage?.text = "${index.plus(1)}/$pageSize" + tvTotalProgress?.text = readProgress + tvPageAndTotal?.text = "${index.plus(1)}/$pageSize $readProgress" + } + + fun scroll(offset: Int) { + binding.contentTextView.scroll(offset) + } + + fun upSelectAble(selectAble: Boolean) { + binding.contentTextView.selectAble = selectAble + } + + fun selectText( + x: Float, y: Float, + select: (relativePage: Int, lineIndex: Int, charIndex: Int) -> Unit, + ) { + return binding.contentTextView.selectText(x, y - headerHeight, select) + } + + fun selectStartMove(x: Float, y: Float) { + binding.contentTextView.selectStartMove(x, y - headerHeight) + } + + fun selectStartMoveIndex(relativePage: Int, lineIndex: Int, charIndex: Int) { + binding.contentTextView.selectStartMoveIndex(relativePage, lineIndex, charIndex) + } + + fun selectEndMove(x: Float, y: Float) { + binding.contentTextView.selectEndMove(x, y - headerHeight) + } + + fun selectEndMoveIndex(relativePage: Int, lineIndex: Int, charIndex: Int) { + binding.contentTextView.selectEndMoveIndex(relativePage, lineIndex, charIndex) + } + + fun cancelSelect() { + binding.contentTextView.cancelSelect() + } + + fun createBookmark(): Bookmark? { + return binding.contentTextView.createBookmark() + } + + fun relativePage(relativePos: Int): TextPage { + return binding.contentTextView.relativePage(relativePos) + } + + val selectedText: String get() = binding.contentTextView.selectedText + + val textPage get() = binding.contentTextView.textPage +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt new file mode 100644 index 000000000..1cb59cb1a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt @@ -0,0 +1,581 @@ +package io.legado.app.ui.book.read.page + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.os.Build +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.WindowInsets +import android.widget.FrameLayout +import io.legado.app.help.AppConfig +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.theme.accentColor +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.page.api.DataSource +import io.legado.app.ui.book.read.page.delegate.* +import io.legado.app.ui.book.read.page.entities.PageDirection +import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.ui.book.read.page.provider.TextPageFactory +import io.legado.app.utils.activity +import io.legado.app.utils.screenshot +import java.text.BreakIterator +import java.util.* +import kotlin.math.abs + + +class ReadView(context: Context, attrs: AttributeSet) : + FrameLayout(context, attrs), + DataSource { + + val callBack: CallBack get() = activity as CallBack + var pageFactory: TextPageFactory = TextPageFactory(this) + var pageDelegate: PageDelegate? = null + private set(value) { + field?.onDestroy() + field = null + field = value + upContent() + } + var isScroll = false + val prevPage by lazy { PageView(context) } + val curPage by lazy { PageView(context) } + val nextPage by lazy { PageView(context) } + val defaultAnimationSpeed = 300 + private var pressDown = false + private var isMove = false + + //起始点 + var startX: Float = 0f + var startY: Float = 0f + + //上一个触碰点 + var lastX: Float = 0f + var lastY: Float = 0f + + //触碰点 + var touchX: Float = 0f + var touchY: Float = 0f + + //是否停止动画动作 + var isAbortAnim = false + + //长按 + private var longPressed = false + private val longPressTimeout = 600L + private val longPressRunnable = Runnable { + longPressed = true + onLongPress() + } + var isTextSelected = false + private var pressOnTextSelected = false + private var firstRelativePage = 0 + private var firstLineIndex: Int = 0 + private var firstCharIndex: Int = 0 + + val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop } + private val tlRect = RectF() + private val tcRect = RectF() + private val trRect = RectF() + private val mlRect = RectF() + private val mcRect = RectF() + private val mrRect = RectF() + private val blRect = RectF() + private val bcRect = RectF() + private val brRect = RectF() + private val autoPageRect by lazy { Rect() } + private val autoPagePint by lazy { Paint().apply { color = context.accentColor } } + private val boundary by lazy { BreakIterator.getWordInstance(Locale.getDefault()) } + + init { + addView(nextPage) + addView(curPage) + addView(prevPage) + if (!isInEditMode) { + upBg() + setWillNotDraw(false) + upPageAnim() + } + } + + fun setRect9x() { + tlRect.set(0f, 0f, width * 0.33f, height * 0.33f) + tcRect.set(width * 0.33f, 0f, width * 0.66f, height * 0.33f) + trRect.set(width * 0.36f, 0f, width.toFloat(), height * 0.33f) + mlRect.set(0f, height * 0.33f, width * 0.33f, height * 0.66f) + mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f) + mrRect.set(width * 0.66f, height * 0.33f, width.toFloat(), height * 0.66f) + blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat()) + bcRect.set(width * 0.33f, height * 0.66f, width * 0.66f, height.toFloat()) + brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat()) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + setRect9x() + prevPage.x = -w.toFloat() + pageDelegate?.setViewSize(w, h) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + pageDelegate?.onDraw(canvas) + if (!isInEditMode && callBack.isAutoPage && !isScroll) { + // TODO 自动翻页 + nextPage.screenshot()?.let { + val bottom = callBack.autoPageProgress + autoPageRect.set(0, 0, width, bottom) + canvas.drawBitmap(it, autoPageRect, autoPageRect, null) + canvas.drawRect( + 0f, + bottom.toFloat() - 1, + width.toFloat(), + bottom.toFloat(), + autoPagePint + ) + it.recycle() + } + } + } + + override fun computeScroll() { + pageDelegate?.scroll() + } + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + return true + } + + /** + * 触摸事件 + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + callBack.screenOffTimerStart() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = + this.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.mandatorySystemGestures()) + val height = activity?.windowManager?.currentWindowMetrics?.bounds?.height() + if (height != null) { + if (event.y > height.minus(insets.bottom)) { + return true + } + } + } + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (isTextSelected) { + curPage.cancelSelect() + isTextSelected = false + pressOnTextSelected = true + } else { + pressOnTextSelected = false + } + longPressed = false + postDelayed(longPressRunnable, longPressTimeout) + pressDown = true + isMove = false + pageDelegate?.onTouch(event) + pageDelegate?.onDown() + setStartPoint(event.x, event.y) + } + MotionEvent.ACTION_MOVE -> { + if (!isMove) { + isMove = + abs(startX - event.x) > slopSquare || abs(startY - event.y) > slopSquare + } + if (isMove) { + longPressed = false + removeCallbacks(longPressRunnable) + if (isTextSelected) { + selectText(event.x, event.y) + } else { + pageDelegate?.onTouch(event) + } + } + } + MotionEvent.ACTION_UP -> { + removeCallbacks(longPressRunnable) + if (!pressDown) return true + pressDown = false + if (!isMove) { + if (!longPressed && !pressOnTextSelected) { + onSingleTapUp() + return true + } + } + if (isTextSelected) { + callBack.showTextActionMenu() + } else if (isMove) { + pageDelegate?.onTouch(event) + } + pressOnTextSelected = false + } + MotionEvent.ACTION_CANCEL -> { + removeCallbacks(longPressRunnable) + if (!pressDown) return true + pressDown = false + if (isTextSelected) { + callBack.showTextActionMenu() + } else if (isMove) { + pageDelegate?.onTouch(event) + } + pressOnTextSelected = false + } + } + return true + } + + fun upStatusBar() { + curPage.upStatusBar() + prevPage.upStatusBar() + nextPage.upStatusBar() + } + + /** + * 保存开始位置 + */ + fun setStartPoint(x: Float, y: Float, invalidate: Boolean = true) { + startX = x + startY = y + lastX = x + lastY = y + touchX = x + touchY = y + + if (invalidate) { + invalidate() + } + } + + /** + * 保存当前位置 + */ + fun setTouchPoint(x: Float, y: Float, invalidate: Boolean = true) { + lastX = touchX + lastY = touchY + touchX = x + touchY = y + if (invalidate) { + invalidate() + } + pageDelegate?.onScroll() + } + + /** + * 长按选择 + */ + private fun onLongPress() { + kotlin.runCatching { + curPage.selectText(startX, startY) { relativePage, lineIndex, charIndex -> + val page = if (isScroll) curPage.relativePage(relativePage) else curPage.textPage + with(page) { + isTextSelected = true + firstRelativePage = relativePage + firstLineIndex = lineIndex + firstCharIndex = charIndex + var lineStart = lineIndex + var lineEnd = lineIndex + var start: Int + var end: Int + if (lineIndex - 1 > 0 && lineIndex + 1 < lineSize) { + // 中间行 + val lineText = with(textLines) { + get(lineIndex - 1).text + get(lineIndex).text + get(lineIndex + 1).text + } + boundary.setText(lineText) + start = boundary.first() + end = boundary.next() + val cIndex = textLines[lineIndex - 1].text.length + charIndex + while (end != BreakIterator.DONE) { + if (cIndex in start until end) { + break + } + start = end + end = boundary.next() + } + if (start < textLines[lineIndex - 1].text.length) { + lineStart = lineIndex - 1 + } else { + start -= textLines[lineIndex - 1].text.length + } + if (end > textLines[lineIndex - 1].text.length + textLines[lineIndex].text.length) { + lineEnd = lineIndex + 1 + end = (end - textLines[lineIndex - 1].text.length + - textLines[lineIndex].text.length) + } else { + end = end - textLines[lineIndex - 1].text.length - 1 + } + } else if (lineIndex - 1 > 0) { + // 尾行 + val lineText = with(textLines) { + get(lineIndex - 1).text + get(lineIndex).text + } + boundary.setText(lineText) + start = boundary.first() + end = boundary.next() + val cIndex = textLines[lineIndex - 1].text.length + charIndex + while (end != BreakIterator.DONE) { + if (cIndex in start until end) { + break + } + start = end + end = boundary.next() + } + if (start < textLines[lineIndex - 1].text.length) { + lineStart = lineIndex - 1 + } else { + start -= textLines[lineIndex - 1].text.length + } + end = end - textLines[lineIndex - 1].text.length - 1 + } else if (lineIndex + 1 < lineSize) { + // 首行 + val lineText = with(textLines) { + get(lineIndex).text + get(lineIndex + 1).text + } + boundary.setText(lineText) + start = boundary.first() + end = boundary.next() + while (end != BreakIterator.DONE) { + if (charIndex in start until end) { + break + } + start = end + end = boundary.next() + } + if (end > textLines[lineIndex].text.length) { + lineEnd = lineIndex + 1 + end -= textLines[lineIndex].text.length + } else { + end -= 1 + } + } else { + // 单行 + val lineText = textLines[lineIndex].text + boundary.setText(lineText) + start = boundary.first() + end = boundary.next() + while (end != BreakIterator.DONE) { + if (charIndex in start until end) { + break + } + start = end + end = boundary.next() + } + end -= 1 + } + curPage.selectStartMoveIndex(firstRelativePage, lineStart, start) + curPage.selectEndMoveIndex(firstRelativePage, lineEnd, end) + } + } + } + } + + /** + * 单击 + */ + private fun onSingleTapUp() { + when { + isTextSelected -> isTextSelected = false + mcRect.contains(startX, startY) -> if (!isAbortAnim) { + click(AppConfig.clickActionMC) + } + bcRect.contains(startX, startY) -> { + click(AppConfig.clickActionBC) + } + blRect.contains(startX, startY) -> { + click(AppConfig.clickActionBL) + } + brRect.contains(startX, startY) -> { + click(AppConfig.clickActionBR) + } + mlRect.contains(startX, startY) -> { + click(AppConfig.clickActionML) + } + mrRect.contains(startX, startY) -> { + click(AppConfig.clickActionMR) + } + tlRect.contains(startX, startY) -> { + click(AppConfig.clickActionTL) + } + tcRect.contains(startX, startY) -> { + click(AppConfig.clickActionTC) + } + trRect.contains(startX, startY) -> { + click(AppConfig.clickActionTR) + } + } + } + + private fun click(action: Int) { + when (action) { + 0 -> callBack.showActionMenu() + 1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed) + 2 -> pageDelegate?.prevPageByAnim(defaultAnimationSpeed) + 3 -> ReadBook.moveToNextChapter(true) + 4 -> ReadBook.moveToPrevChapter(upContent = true, toLast = false) + 5 -> ReadAloud.prevParagraph(context) + 6 -> ReadAloud.nextParagraph(context) + } + } + + /** + * 选择文本 + */ + private fun selectText(x: Float, y: Float) { + curPage.selectText(x, y) { relativePage, lineIndex, charIndex -> + when { + relativePage > firstRelativePage -> { + curPage.selectStartMoveIndex(firstRelativePage, firstLineIndex, firstCharIndex) + curPage.selectEndMoveIndex(relativePage, lineIndex, charIndex) + } + relativePage < firstRelativePage -> { + curPage.selectEndMoveIndex(firstRelativePage, firstLineIndex, firstCharIndex) + curPage.selectStartMoveIndex(relativePage, lineIndex, charIndex) + } + lineIndex > firstLineIndex -> { + curPage.selectStartMoveIndex(firstRelativePage, firstLineIndex, firstCharIndex) + curPage.selectEndMoveIndex(relativePage, lineIndex, charIndex) + } + lineIndex < firstLineIndex -> { + curPage.selectEndMoveIndex(firstRelativePage, firstLineIndex, firstCharIndex) + curPage.selectStartMoveIndex(relativePage, lineIndex, charIndex) + } + charIndex > firstCharIndex -> { + curPage.selectStartMoveIndex(firstRelativePage, firstLineIndex, firstCharIndex) + curPage.selectEndMoveIndex(relativePage, lineIndex, charIndex) + } + else -> { + curPage.selectEndMoveIndex(firstRelativePage, firstLineIndex, firstCharIndex) + curPage.selectStartMoveIndex(relativePage, lineIndex, charIndex) + } + } + } + } + + fun onDestroy() { + pageDelegate?.onDestroy() + curPage.cancelSelect() + } + + fun fillPage(direction: PageDirection): Boolean { + return when (direction) { + PageDirection.PREV -> { + pageFactory.moveToPrev(true) + } + PageDirection.NEXT -> { + pageFactory.moveToNext(true) + } + else -> false + } + } + + fun upPageAnim() { + isScroll = ReadBook.pageAnim() == 3 + ChapterProvider.upLayout() + when (ReadBook.pageAnim()) { + 0 -> if (pageDelegate !is CoverPageDelegate) { + pageDelegate = CoverPageDelegate(this) + } + 1 -> if (pageDelegate !is SlidePageDelegate) { + pageDelegate = SlidePageDelegate(this) + } + 2 -> if (pageDelegate !is SimulationPageDelegate) { + pageDelegate = SimulationPageDelegate(this) + } + 3 -> if (pageDelegate !is ScrollPageDelegate) { + pageDelegate = ScrollPageDelegate(this) + } + else -> if (pageDelegate !is NoAnimPageDelegate) { + pageDelegate = NoAnimPageDelegate(this) + } + } + } + + override fun upContent(relativePosition: Int, resetPageOffset: Boolean) { + curPage.setContentDescription(pageFactory.curPage.text) + if (isScroll && !callBack.isAutoPage) { + curPage.setContent(pageFactory.curPage, resetPageOffset) + } else { + curPage.resetPageOffset() + when (relativePosition) { + -1 -> prevPage.setContent(pageFactory.prevPage) + 1 -> nextPage.setContent(pageFactory.nextPage) + else -> { + curPage.setContent(pageFactory.curPage) + nextPage.setContent(pageFactory.nextPage) + prevPage.setContent(pageFactory.prevPage) + } + } + } + callBack.screenOffTimerStart() + } + + fun upStyle() { + ChapterProvider.upStyle() + curPage.upStyle() + prevPage.upStyle() + nextPage.upStyle() + } + + fun upBg() { + ReadBookConfig.bg ?: let { + ReadBookConfig.upBg() + } + curPage.setBg(ReadBookConfig.bg) + prevPage.setBg(ReadBookConfig.bg) + nextPage.setBg(ReadBookConfig.bg) + } + + fun upTime() { + curPage.upTime() + prevPage.upTime() + nextPage.upTime() + } + + fun upBattery(battery: Int) { + curPage.upBattery(battery) + prevPage.upBattery(battery) + nextPage.upBattery(battery) + } + + override val currentChapter: TextChapter? + get() { + return if (callBack.isInitFinish) ReadBook.textChapter(0) else null + } + + override val nextChapter: TextChapter? + get() { + return if (callBack.isInitFinish) ReadBook.textChapter(1) else null + } + + override val prevChapter: TextChapter? + get() { + return if (callBack.isInitFinish) ReadBook.textChapter(-1) else null + } + + override fun hasNextChapter(): Boolean { + return ReadBook.durChapterIndex < ReadBook.chapterSize - 1 + } + + override fun hasPrevChapter(): Boolean { + return ReadBook.durChapterIndex > 0 + } + + interface CallBack { + val isInitFinish: Boolean + val isAutoPage: Boolean + val autoPageProgress: Int + fun showActionMenu() + fun screenOffTimerStart() + fun showTextActionMenu() + fun autoPageStop() + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt b/app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt new file mode 100644 index 000000000..236f737c1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt @@ -0,0 +1,21 @@ +package io.legado.app.ui.book.read.page.api + +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.page.entities.TextChapter + +interface DataSource { + + val pageIndex: Int get() = ReadBook.durPageIndex() + + val currentChapter: TextChapter? + + val nextChapter: TextChapter? + + val prevChapter: TextChapter? + + fun hasNextChapter(): Boolean + + fun hasPrevChapter(): Boolean + + fun upContent(relativePosition: Int = 0, resetPageOffset: Boolean = true) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/api/PageFactory.kt b/app/src/main/java/io/legado/app/ui/book/read/page/api/PageFactory.kt new file mode 100644 index 000000000..ecf0e56a7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/api/PageFactory.kt @@ -0,0 +1,26 @@ +package io.legado.app.ui.book.read.page.api + +abstract class PageFactory(protected val dataSource: DataSource) { + + abstract fun moveToFirst() + + abstract fun moveToLast() + + abstract fun moveToNext(upContent: Boolean): Boolean + + abstract fun moveToPrev(upContent: Boolean): Boolean + + abstract val nextPage: DATA + + abstract val prevPage: DATA + + abstract val curPage: DATA + + abstract val nextPlusPage: DATA + + abstract fun hasNext(): Boolean + + abstract fun hasPrev(): Boolean + + abstract fun hasNextPlus(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt new file mode 100644 index 000000000..c0024c81c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt @@ -0,0 +1,84 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.drawable.GradientDrawable +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection + +class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { + private val bitmapMatrix = Matrix() + private val shadowDrawableR: GradientDrawable + + init { + val shadowColors = intArrayOf(0x66111111, 0x00000000) + shadowDrawableR = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, shadowColors + ) + shadowDrawableR.gradientType = GradientDrawable.LINEAR_GRADIENT + } + + override fun onDraw(canvas: Canvas) { + if (!isRunning) return + val offsetX = touchX - startX + + if ((mDirection == PageDirection.NEXT && offsetX > 0) + || (mDirection == PageDirection.PREV && offsetX < 0) + ) { + return + } + + val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth + if (mDirection == PageDirection.PREV) { + bitmapMatrix.setTranslate(distanceX, 0.toFloat()) + curBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } + prevBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) } + addShadow(distanceX.toInt(), canvas) + } else if (mDirection == PageDirection.NEXT) { + bitmapMatrix.setTranslate(distanceX - viewWidth, 0.toFloat()) + nextBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } + curBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) } + addShadow(distanceX.toInt(), canvas) + } + } + + private fun addShadow(left: Int, canvas: Canvas) { + if (left < 0) { + shadowDrawableR.setBounds(left + viewWidth, 0, left + viewWidth + 30, viewHeight) + shadowDrawableR.draw(canvas) + } else if (left > 0) { + shadowDrawableR.setBounds(left, 0, left + 30, viewHeight) + shadowDrawableR.draw(canvas) + } + } + + override fun onAnimStop() { + if (!isCancel) { + readView.fillPage(mDirection) + } + } + + override fun onAnimStart(animationSpeed: Int) { + val distanceX: Float + when (mDirection) { + PageDirection.NEXT -> distanceX = + if (isCancel) { + var dis = viewWidth - startX + touchX + if (dis > viewWidth) { + dis = viewWidth.toFloat() + } + viewWidth - dis + } else { + -(touchX + (viewWidth - startX)) + } + else -> distanceX = + if (isCancel) { + -(touchX - startX) + } else { + viewWidth - (touchX - startX) + } + } + startScroll(touchX.toInt(), 0, distanceX.toInt(), 0, animationSpeed) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/HorizontalPageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/HorizontalPageDelegate.kt new file mode 100644 index 000000000..3f8c29ca3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/HorizontalPageDelegate.kt @@ -0,0 +1,144 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection +import io.legado.app.utils.screenshot + +abstract class HorizontalPageDelegate(readView: ReadView) : PageDelegate(readView) { + + protected var curBitmap: Bitmap? = null + protected var prevBitmap: Bitmap? = null + protected var nextBitmap: Bitmap? = null + + override fun setDirection(direction: PageDirection) { + super.setDirection(direction) + setBitmap() + } + + private fun setBitmap() { + when (mDirection) { + PageDirection.PREV -> { + prevBitmap?.recycle() + prevBitmap = prevPage.screenshot() + curBitmap?.recycle() + curBitmap = curPage.screenshot() + } + PageDirection.NEXT -> { + nextBitmap?.recycle() + nextBitmap = nextPage.screenshot() + curBitmap?.recycle() + curBitmap = curPage.screenshot() + } + else -> Unit + } + } + + override fun onTouch(event: MotionEvent) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + abortAnim() + } + MotionEvent.ACTION_MOVE -> { + onScroll(event) + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + onAnimStart(readView.defaultAnimationSpeed) + } + } + } + + private fun onScroll(event: MotionEvent) { + + val action: Int = event.action + val pointerUp = + action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_POINTER_UP + val skipIndex = if (pointerUp) event.actionIndex else -1 + // Determine focal point + var sumX = 0f + var sumY = 0f + val count: Int = event.pointerCount + for (i in 0 until count) { + if (skipIndex == i) continue + sumX += event.getX(i) + sumY += event.getY(i) + } + val div = if (pointerUp) count - 1 else count + val focusX = sumX / div + val focusY = sumY / div + //判断是否移动了 + if (!isMoved) { + val deltaX = (focusX - startX).toInt() + val deltaY = (focusY - startY).toInt() + val distance = deltaX * deltaX + deltaY * deltaY + isMoved = distance > readView.slopSquare + if (isMoved) { + if (sumX - startX > 0) { + //如果上一页不存在 + if (!hasPrev()) { + noNext = true + return + } + setDirection(PageDirection.PREV) + } else { + //如果不存在表示没有下一页了 + if (!hasNext()) { + noNext = true + return + } + setDirection(PageDirection.NEXT) + } + } + } + if (isMoved) { + isCancel = if (mDirection == PageDirection.NEXT) sumX > lastX else sumX < lastX + isRunning = true + //设置触摸点 + readView.setTouchPoint(sumX, sumY) + } + } + + override fun abortAnim() { + isStarted = false + isMoved = false + isRunning = false + if (!scroller.isFinished) { + readView.isAbortAnim = true + scroller.abortAnimation() + if (!isCancel) { + readView.fillPage(mDirection) + readView.invalidate() + } + } else { + readView.isAbortAnim = false + } + } + + override fun nextPageByAnim(animationSpeed: Int) { + abortAnim() + if (!hasNext()) return + setDirection(PageDirection.NEXT) + readView.setStartPoint(viewWidth.toFloat(), 0f, false) + onAnimStart(animationSpeed) + } + + override fun prevPageByAnim(animationSpeed: Int) { + abortAnim() + if (!hasPrev()) return + setDirection(PageDirection.PREV) + readView.setStartPoint(0f, 0f, false) + onAnimStart(animationSpeed) + } + + override fun onDestroy() { + super.onDestroy() + prevBitmap?.recycle() + prevBitmap = null + curBitmap?.recycle() + curBitmap = null + nextBitmap?.recycle() + nextBitmap = null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/NoAnimPageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/NoAnimPageDelegate.kt new file mode 100644 index 000000000..9809a4f71 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/NoAnimPageDelegate.kt @@ -0,0 +1,24 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.graphics.Canvas +import io.legado.app.ui.book.read.page.ReadView + +class NoAnimPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { + + override fun onAnimStart(animationSpeed: Int) { + if (!isCancel) { + readView.fillPage(mDirection) + } + stopScroll() + } + + override fun onDraw(canvas: Canvas) { + // nothing + } + + override fun onAnimStop() { + // nothing + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt new file mode 100644 index 000000000..2b417240c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt @@ -0,0 +1,189 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.content.Context +import android.graphics.Canvas +import android.view.MotionEvent +import android.view.animation.LinearInterpolator +import android.widget.Scroller +import androidx.annotation.CallSuper +import com.google.android.material.snackbar.Snackbar +import io.legado.app.R +import io.legado.app.ui.book.read.page.PageView +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection +import kotlin.math.abs + +abstract class PageDelegate(protected val readView: ReadView) { + + protected val context: Context = readView.context + + //起始点 + protected val startX: Float get() = readView.startX + protected val startY: Float get() = readView.startY + + //上一个触碰点 + protected val lastX: Float get() = readView.lastX + protected val lastY: Float get() = readView.lastY + + //触碰点 + protected val touchX: Float get() = readView.touchX + protected val touchY: Float get() = readView.touchY + + protected val nextPage: PageView get() = readView.nextPage + protected val curPage: PageView get() = readView.curPage + protected val prevPage: PageView get() = readView.prevPage + + protected var viewWidth: Int = readView.width + protected var viewHeight: Int = readView.height + + protected val scroller: Scroller by lazy { + Scroller(readView.context, LinearInterpolator()) + } + + private val snackBar: Snackbar by lazy { + Snackbar.make(readView, "", Snackbar.LENGTH_SHORT) + } + + var isMoved = false + var noNext = true + + //移动方向 + var mDirection = PageDirection.NONE + var isCancel = false + var isRunning = false + var isStarted = false + + private var selectedOnDown = false + + init { + curPage.resetPageOffset() + } + + open fun fling( + startX: Int, startY: Int, velocityX: Int, velocityY: Int, + minX: Int, maxX: Int, minY: Int, maxY: Int + ) { + scroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY) + isRunning = true + isStarted = true + readView.invalidate() + } + + protected fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, animationSpeed: Int) { + val duration = if (dx != 0) { + (animationSpeed * abs(dx)) / viewWidth + } else { + (animationSpeed * abs(dy)) / viewHeight + } + scroller.startScroll(startX, startY, dx, dy, duration) + isRunning = true + isStarted = true + readView.invalidate() + } + + protected fun stopScroll() { + isStarted = false + readView.post { + isMoved = false + isRunning = false + readView.invalidate() + } + } + + open fun setViewSize(width: Int, height: Int) { + viewWidth = width + viewHeight = height + } + + fun scroll() { + if (scroller.computeScrollOffset()) { + readView.setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat()) + } else if (isStarted) { + onAnimStop() + stopScroll() + } + } + + open fun onScroll() = Unit + + abstract fun abortAnim() + + abstract fun onAnimStart(animationSpeed: Int) //scroller start + + abstract fun onDraw(canvas: Canvas) //绘制 + + abstract fun onAnimStop() //scroller finish + + abstract fun nextPageByAnim(animationSpeed: Int) + + abstract fun prevPageByAnim(animationSpeed: Int) + + open fun keyTurnPage(direction: PageDirection) { + if (isRunning) return + when (direction) { + PageDirection.NEXT -> nextPageByAnim(100) + PageDirection.PREV -> prevPageByAnim(100) + else -> return + } + } + + @CallSuper + open fun setDirection(direction: PageDirection) { + mDirection = direction + } + + /** + * 触摸事件处理 + */ + abstract fun onTouch(event: MotionEvent) + + /** + * 按下 + */ + fun onDown() { + //是否移动 + isMoved = false + //是否存在下一章 + noNext = false + //是否正在执行动画 + isRunning = false + //取消 + isCancel = false + //是下一章还是前一章 + setDirection(PageDirection.NONE) + } + + /** + * 判断是否有上一页 + */ + fun hasPrev(): Boolean { + val hasPrev = readView.pageFactory.hasPrev() + if (!hasPrev) { + if (!snackBar.isShown) { + snackBar.setText(R.string.no_prev_page) + snackBar.show() + } + } + return hasPrev + } + + /** + * 判断是否有下一页 + */ + fun hasNext(): Boolean { + val hasNext = readView.pageFactory.hasNext() + if (!hasNext) { + readView.callBack.autoPageStop() + if (!snackBar.isShown) { + snackBar.setText(R.string.no_next_page) + snackBar.show() + } + } + return hasNext + } + + open fun onDestroy() { + + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt new file mode 100644 index 000000000..26e8f98b0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/ScrollPageDelegate.kt @@ -0,0 +1,115 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.graphics.Canvas +import android.view.MotionEvent +import android.view.VelocityTracker +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.provider.ChapterProvider + +class ScrollPageDelegate(readView: ReadView) : PageDelegate(readView) { + + // 滑动追踪的时间 + private val velocityDuration = 1000 + + //速度追踪器 + private val mVelocity: VelocityTracker = VelocityTracker.obtain() + + override fun onAnimStart(animationSpeed: Int) { + //惯性滚动 + fling( + 0, touchY.toInt(), 0, mVelocity.yVelocity.toInt(), + 0, 0, -10 * viewHeight, 10 * viewHeight + ) + } + + override fun onAnimStop() { + // nothing + } + + override fun onTouch(event: MotionEvent) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + abortAnim() + mVelocity.clear() + } + MotionEvent.ACTION_MOVE -> { + onScroll(event) + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + onAnimStart(readView.defaultAnimationSpeed) + } + } + } + + override fun onScroll() { + curPage.scroll((touchY - lastY).toInt()) + } + + override fun onDraw(canvas: Canvas) { + // nothing + } + + private fun onScroll(event: MotionEvent) { + mVelocity.addMovement(event) + mVelocity.computeCurrentVelocity(velocityDuration) + val action: Int = event.action + val pointerUp = + action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_POINTER_UP + val skipIndex = if (pointerUp) event.actionIndex else -1 + // Determine focal point + var sumX = 0f + var sumY = 0f + val count: Int = event.pointerCount + for (i in 0 until count) { + if (skipIndex == i) continue + sumX += event.getX(i) + sumY += event.getY(i) + } + val div = if (pointerUp) count - 1 else count + val focusX = sumX / div + val focusY = sumY / div + readView.setTouchPoint(sumX, sumY) + if (!isMoved) { + val deltaX = (focusX - startX).toInt() + val deltaY = (focusY - startY).toInt() + val distance = deltaX * deltaX + deltaY * deltaY + isMoved = distance > readView.slopSquare + } + if (isMoved) { + isRunning = true + } + } + + override fun onDestroy() { + super.onDestroy() + mVelocity.recycle() + } + + override fun abortAnim() { + isStarted = false + isMoved = false + isRunning = false + if (!scroller.isFinished) { + readView.isAbortAnim = true + scroller.abortAnimation() + } else { + readView.isAbortAnim = false + } + } + + override fun nextPageByAnim(animationSpeed: Int) { + if (readView.isAbortAnim) { + return + } + readView.setStartPoint(0f, 0f, false) + startScroll(0, 0, 0, -ChapterProvider.visibleHeight, animationSpeed) + } + + override fun prevPageByAnim(animationSpeed: Int) { + if (readView.isAbortAnim) { + return + } + readView.setStartPoint(0f, 0f, false) + startScroll(0, 0, 0, ChapterProvider.visibleHeight, animationSpeed) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/SimulationPageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/SimulationPageDelegate.kt new file mode 100644 index 000000000..bb3622fbc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/SimulationPageDelegate.kt @@ -0,0 +1,573 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.graphics.* +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.view.MotionEvent +import io.legado.app.help.ReadBookConfig +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection +import kotlin.math.* + +@Suppress("DEPRECATION") +class SimulationPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { + //不让x,y为0,否则在点计算时会有问题 + private var mTouchX = 0.1f + private var mTouchY = 0.1f + + // 拖拽点对应的页脚 + private var mCornerX = 1 + private var mCornerY = 1 + private val mPath0: Path = Path() + private val mPath1: Path = Path() + + // 贝塞尔曲线起始点 + private val mBezierStart1 = PointF() + + // 贝塞尔曲线控制点 + private val mBezierControl1 = PointF() + + // 贝塞尔曲线顶点 + private val mBezierVertex1 = PointF() + + // 贝塞尔曲线结束点 + private var mBezierEnd1 = PointF() + + // 另一条贝塞尔曲线 + // 贝塞尔曲线起始点 + private val mBezierStart2 = PointF() + + // 贝塞尔曲线控制点 + private val mBezierControl2 = PointF() + + // 贝塞尔曲线顶点 + private val mBezierVertex2 = PointF() + + // 贝塞尔曲线结束点 + private var mBezierEnd2 = PointF() + + private var mMiddleX = 0f + private var mMiddleY = 0f + private var mDegrees = 0f + private var mTouchToCornerDis = 0f + private var mColorMatrixFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ) + ) + ) + private val mMatrix: Matrix = Matrix() + private val mMatrixArray = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f) + + // 是否属于右上左下 + private var mIsRtOrLb = false + private var mMaxLength = hypot(viewWidth.toDouble(), viewHeight.toDouble()).toFloat() + + // 背面颜色组 + private var mBackShadowColors: IntArray + + // 前面颜色组 + private var mFrontShadowColors: IntArray + + // 有阴影的GradientDrawable + private var mBackShadowDrawableLR: GradientDrawable + private var mBackShadowDrawableRL: GradientDrawable + private var mFolderShadowDrawableLR: GradientDrawable + private var mFolderShadowDrawableRL: GradientDrawable + + private var mFrontShadowDrawableHBT: GradientDrawable + private var mFrontShadowDrawableHTB: GradientDrawable + private var mFrontShadowDrawableVLR: GradientDrawable + private var mFrontShadowDrawableVRL: GradientDrawable + + private val mPaint: Paint = Paint().apply { style = Paint.Style.FILL } + + init { + //设置颜色数组 + val color = intArrayOf(0x333333, -0x4fcccccd) + mFolderShadowDrawableRL = GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, color) + mFolderShadowDrawableRL.gradientType = GradientDrawable.LINEAR_GRADIENT + + mFolderShadowDrawableLR = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, color) + mFolderShadowDrawableLR.gradientType = GradientDrawable.LINEAR_GRADIENT + + mBackShadowColors = intArrayOf(-0xeeeeef, 0x111111) + mBackShadowDrawableRL = + GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, mBackShadowColors) + mBackShadowDrawableRL.gradientType = GradientDrawable.LINEAR_GRADIENT + + mBackShadowDrawableLR = + GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, mBackShadowColors) + mBackShadowDrawableLR.gradientType = GradientDrawable.LINEAR_GRADIENT + + mFrontShadowColors = intArrayOf(-0x7feeeeef, 0x111111) + mFrontShadowDrawableVLR = + GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, mFrontShadowColors) + mFrontShadowDrawableVLR.gradientType = GradientDrawable.LINEAR_GRADIENT + + mFrontShadowDrawableVRL = + GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, mFrontShadowColors) + mFrontShadowDrawableVRL.gradientType = GradientDrawable.LINEAR_GRADIENT + + mFrontShadowDrawableHTB = + GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, mFrontShadowColors) + mFrontShadowDrawableHTB.gradientType = GradientDrawable.LINEAR_GRADIENT + + mFrontShadowDrawableHBT = + GradientDrawable(GradientDrawable.Orientation.BOTTOM_TOP, mFrontShadowColors) + mFrontShadowDrawableHBT.gradientType = GradientDrawable.LINEAR_GRADIENT + } + + override fun setViewSize(width: Int, height: Int) { + super.setViewSize(width, height) + mMaxLength = hypot(viewWidth.toDouble(), viewHeight.toDouble()).toFloat() + } + + override fun onTouch(event: MotionEvent) { + super.onTouch(event) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + calcCornerXY(event.x, event.y) + } + MotionEvent.ACTION_MOVE -> { + if ((startY > viewHeight / 3 && startY < viewHeight * 2 / 3) + || mDirection == PageDirection.PREV + ) { + readView.touchY = viewHeight.toFloat() + } + + if (startY > viewHeight / 3 && startY < viewHeight / 2 + && mDirection == PageDirection.NEXT + ) { + readView.touchY = 1f + } + } + } + } + + override fun setDirection(direction: PageDirection) { + super.setDirection(direction) + when (direction) { + PageDirection.PREV -> + //上一页滑动不出现对角 + if (startX > viewWidth / 2) { + calcCornerXY(startX, viewHeight.toFloat()) + } else { + calcCornerXY(viewWidth - startX, viewHeight.toFloat()) + } + PageDirection.NEXT -> + if (viewWidth / 2 > startX) { + calcCornerXY(viewWidth - startX, startY) + } + else -> Unit + } + } + + override fun onAnimStart(animationSpeed: Int) { + var dx: Float + val dy: Float + // dy 垂直方向滑动的距离,负值会使滚动向上滚动 + if (isCancel) { + dx = if (mCornerX > 0 && mDirection == PageDirection.NEXT) { + (viewWidth - touchX) + } else { + -touchX + } + if (mDirection != PageDirection.NEXT) { + dx = -(viewWidth + touchX) + } + dy = if (mCornerY > 0) { + (viewHeight - touchY) + } else { + -touchY // 防止mTouchY最终变为0 + } + } else { + dx = if (mCornerX > 0 && mDirection == PageDirection.NEXT) { + -(viewWidth + touchX) + } else { + (viewWidth - touchX + viewWidth) + } + dy = if (mCornerY > 0) { + (viewHeight - touchY) + } else { + (1 - touchY) // 防止mTouchY最终变为0 + } + } + startScroll(touchX.toInt(), touchY.toInt(), dx.toInt(), dy.toInt(), animationSpeed) + } + + override fun onAnimStop() { + if (!isCancel) { + readView.fillPage(mDirection) + } + } + + override fun onDraw(canvas: Canvas) { + if (!isRunning) return + when (mDirection) { + PageDirection.NEXT -> { + calcPoints() + drawCurrentPageArea(canvas, curBitmap) + drawNextPageAreaAndShadow(canvas, nextBitmap) + drawCurrentPageShadow(canvas) + drawCurrentBackArea(canvas, curBitmap) + } + PageDirection.PREV -> { + calcPoints() + drawCurrentPageArea(canvas, prevBitmap) + drawNextPageAreaAndShadow(canvas, curBitmap) + drawCurrentPageShadow(canvas) + drawCurrentBackArea(canvas, prevBitmap) + } + else -> return + } + } + + /** + * 绘制翻起页背面 + */ + private fun drawCurrentBackArea( + canvas: Canvas, + bitmap: Bitmap? + ) { + bitmap ?: return + val i = ((mBezierStart1.x + mBezierControl1.x) / 2).toInt() + val f1 = abs(i - mBezierControl1.x) + val i1 = ((mBezierStart2.y + mBezierControl2.y) / 2).toInt() + val f2 = abs(i1 - mBezierControl2.y) + val f3 = min(f1, f2) + mPath1.reset() + mPath1.moveTo(mBezierVertex2.x, mBezierVertex2.y) + mPath1.lineTo(mBezierVertex1.x, mBezierVertex1.y) + mPath1.lineTo(mBezierEnd1.x, mBezierEnd1.y) + mPath1.lineTo(mTouchX, mTouchY) + mPath1.lineTo(mBezierEnd2.x, mBezierEnd2.y) + mPath1.close() + val mFolderShadowDrawable: GradientDrawable + val left: Int + val right: Int + if (mIsRtOrLb) { + left = (mBezierStart1.x - 1).toInt() + right = (mBezierStart1.x + f3 + 1).toInt() + mFolderShadowDrawable = mFolderShadowDrawableLR + } else { + left = (mBezierStart1.x - f3 - 1).toInt() + right = (mBezierStart1.x + 1).toInt() + mFolderShadowDrawable = mFolderShadowDrawableRL + } + canvas.save() + canvas.clipPath(mPath0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipPath(mPath1) + } else { + canvas.clipPath(mPath1, Region.Op.INTERSECT) + } + + mPaint.colorFilter = mColorMatrixFilter + val dis = hypot( + mCornerX - mBezierControl1.x.toDouble(), + mBezierControl2.y - mCornerY.toDouble() + ).toFloat() + val f8 = (mCornerX - mBezierControl1.x) / dis + val f9 = (mBezierControl2.y - mCornerY) / dis + mMatrixArray[0] = 1 - 2 * f9 * f9 + mMatrixArray[1] = 2 * f8 * f9 + mMatrixArray[3] = mMatrixArray[1] + mMatrixArray[4] = 1 - 2 * f8 * f8 + mMatrix.reset() + mMatrix.setValues(mMatrixArray) + mMatrix.preTranslate(-mBezierControl1.x, -mBezierControl1.y) + mMatrix.postTranslate(mBezierControl1.x, mBezierControl1.y) + canvas.drawColor(ReadBookConfig.bgMeanColor) + canvas.drawBitmap(bitmap, mMatrix, mPaint) + mPaint.colorFilter = null + canvas.rotate(mDegrees, mBezierStart1.x, mBezierStart1.y) + mFolderShadowDrawable.setBounds( + left, mBezierStart1.y.toInt(), + right, (mBezierStart1.y + mMaxLength).toInt() + ) + mFolderShadowDrawable.draw(canvas) + canvas.restore() + } + + /** + * 绘制翻起页的阴影 + */ + private fun drawCurrentPageShadow(canvas: Canvas) { + val degree: Double = if (mIsRtOrLb) { + Math.PI / 4 - atan2(mBezierControl1.y - mTouchY, mTouchX - mBezierControl1.x) + } else { + Math.PI / 4 - atan2(mTouchY - mBezierControl1.y, mTouchX - mBezierControl1.x) + } + // 翻起页阴影顶点与touch点的距离 + val d1 = 25.toFloat() * 1.414 * cos(degree) + val d2 = 25.toFloat() * 1.414 * sin(degree) + val x = (mTouchX + d1).toFloat() + val y: Float = if (mIsRtOrLb) { + (mTouchY + d2).toFloat() + } else { + (mTouchY - d2).toFloat() + } + mPath1.reset() + mPath1.moveTo(x, y) + mPath1.lineTo(mTouchX, mTouchY) + mPath1.lineTo(mBezierControl1.x, mBezierControl1.y) + mPath1.lineTo(mBezierStart1.x, mBezierStart1.y) + mPath1.close() + canvas.save() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipOutPath(mPath0) + } else { + canvas.clipPath(mPath0, Region.Op.XOR) + } + canvas.clipPath(mPath1, Region.Op.INTERSECT) + + var leftX: Int + var rightX: Int + var mCurrentPageShadow: GradientDrawable + if (mIsRtOrLb) { + leftX = mBezierControl1.x.toInt() + rightX = (mBezierControl1.x + 25).toInt() + mCurrentPageShadow = mFrontShadowDrawableVLR + } else { + leftX = (mBezierControl1.x - 25).toInt() + rightX = (mBezierControl1.x + 1).toInt() + mCurrentPageShadow = mFrontShadowDrawableVRL + } + var rotateDegrees = Math.toDegrees( + atan2(mTouchX - mBezierControl1.x, mBezierControl1.y - mTouchY).toDouble() + ).toFloat() + canvas.rotate(rotateDegrees, mBezierControl1.x, mBezierControl1.y) + mCurrentPageShadow.setBounds( + leftX, (mBezierControl1.y - mMaxLength).toInt(), + rightX, mBezierControl1.y.toInt() + ) + mCurrentPageShadow.draw(canvas) + canvas.restore() + + mPath1.reset() + mPath1.moveTo(x, y) + mPath1.lineTo(mTouchX, mTouchY) + mPath1.lineTo(mBezierControl2.x, mBezierControl2.y) + mPath1.lineTo(mBezierStart2.x, mBezierStart2.y) + mPath1.close() + canvas.save() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipOutPath(mPath0) + } else { + canvas.clipPath(mPath0, Region.Op.XOR) + } + canvas.clipPath(mPath1) + + if (mIsRtOrLb) { + leftX = mBezierControl2.y.toInt() + rightX = (mBezierControl2.y + 25).toInt() + mCurrentPageShadow = mFrontShadowDrawableHTB + } else { + leftX = (mBezierControl2.y - 25).toInt() + rightX = (mBezierControl2.y + 1).toInt() + mCurrentPageShadow = mFrontShadowDrawableHBT + } + rotateDegrees = Math.toDegrees( + atan2(mBezierControl2.y - mTouchY, mBezierControl2.x - mTouchX).toDouble() + ).toFloat() + canvas.rotate(rotateDegrees, mBezierControl2.x, mBezierControl2.y) + val temp = + if (mBezierControl2.y < 0) (mBezierControl2.y - viewHeight).toDouble() + else mBezierControl2.y.toDouble() + val hmg = hypot(mBezierControl2.x.toDouble(), temp) + if (hmg > mMaxLength) + mCurrentPageShadow.setBounds( + (mBezierControl2.x - 25 - hmg).toInt(), leftX, + (mBezierControl2.x + mMaxLength - hmg).toInt(), rightX + ) + else + mCurrentPageShadow.setBounds( + (mBezierControl2.x - mMaxLength).toInt(), leftX, + mBezierControl2.x.toInt(), rightX + ) + mCurrentPageShadow.draw(canvas) + canvas.restore() + } + + // + private fun drawNextPageAreaAndShadow( + canvas: Canvas, + bitmap: Bitmap? + ) { + bitmap ?: return + mPath1.reset() + mPath1.moveTo(mBezierStart1.x, mBezierStart1.y) + mPath1.lineTo(mBezierVertex1.x, mBezierVertex1.y) + mPath1.lineTo(mBezierVertex2.x, mBezierVertex2.y) + mPath1.lineTo(mBezierStart2.x, mBezierStart2.y) + mPath1.lineTo(mCornerX.toFloat(), mCornerY.toFloat()) + mPath1.close() + mDegrees = Math.toDegrees( + atan2( + (mBezierControl1.x - mCornerX).toDouble(), + mBezierControl2.y - mCornerY.toDouble() + ) + ).toFloat() + val leftX: Int + val rightX: Int + val mBackShadowDrawable: GradientDrawable + if (mIsRtOrLb) { //左下及右上 + leftX = mBezierStart1.x.toInt() + rightX = (mBezierStart1.x + mTouchToCornerDis / 4).toInt() + mBackShadowDrawable = mBackShadowDrawableLR + } else { + leftX = (mBezierStart1.x - mTouchToCornerDis / 4).toInt() + rightX = mBezierStart1.x.toInt() + mBackShadowDrawable = mBackShadowDrawableRL + } + canvas.save() + canvas.clipPath(mPath0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipPath(mPath1) + } else { + canvas.clipPath(mPath1, Region.Op.INTERSECT) + } + canvas.drawBitmap(bitmap, 0f, 0f, null) + canvas.rotate(mDegrees, mBezierStart1.x, mBezierStart1.y) + mBackShadowDrawable.setBounds( + leftX, mBezierStart1.y.toInt(), + rightX, (mMaxLength + mBezierStart1.y).toInt() + ) //左上及右下角的xy坐标值,构成一个矩形 + mBackShadowDrawable.draw(canvas) + canvas.restore() + } + + // + private fun drawCurrentPageArea( + canvas: Canvas, + bitmap: Bitmap? + ) { + bitmap ?: return + mPath0.reset() + mPath0.moveTo(mBezierStart1.x, mBezierStart1.y) + mPath0.quadTo(mBezierControl1.x, mBezierControl1.y, mBezierEnd1.x, mBezierEnd1.y) + mPath0.lineTo(mTouchX, mTouchY) + mPath0.lineTo(mBezierEnd2.x, mBezierEnd2.y) + mPath0.quadTo(mBezierControl2.x, mBezierControl2.y, mBezierStart2.x, mBezierStart2.y) + mPath0.lineTo(mCornerX.toFloat(), mCornerY.toFloat()) + mPath0.close() + + canvas.save() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipOutPath(mPath0) + } else { + canvas.clipPath(mPath0, Region.Op.XOR) + } + canvas.drawBitmap(bitmap, 0f, 0f, null) + canvas.restore() + } + + /** + * 计算拖拽点对应的拖拽脚 + */ + private fun calcCornerXY(x: Float, y: Float) { + mCornerX = if (x <= viewWidth / 2) 0 else viewWidth + mCornerY = if (y <= viewHeight / 2) 0 else viewHeight + mIsRtOrLb = (mCornerX == 0 && mCornerY == viewHeight) + || (mCornerY == 0 && mCornerX == viewWidth) + } + + private fun calcPoints() { + mTouchX = touchX + mTouchY = touchY + + mMiddleX = (mTouchX + mCornerX) / 2 + mMiddleY = (mTouchY + mCornerY) / 2 + mBezierControl1.x = + mMiddleX - (mCornerY - mMiddleY) * (mCornerY - mMiddleY) / (mCornerX - mMiddleX) + mBezierControl1.y = mCornerY.toFloat() + mBezierControl2.x = mCornerX.toFloat() + + val f4 = mCornerY - mMiddleY + if (f4 == 0f) { + mBezierControl2.y = mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / 0.1f + + } else { + mBezierControl2.y = + mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / (mCornerY - mMiddleY) + } + mBezierStart1.x = mBezierControl1.x - (mCornerX - mBezierControl1.x) / 2 + mBezierStart1.y = mCornerY.toFloat() + + // 固定左边上下两个点 + if (mTouchX > 0 && mTouchX < viewWidth) { + if (mBezierStart1.x < 0 || mBezierStart1.x > viewWidth) { + if (mBezierStart1.x < 0) + mBezierStart1.x = viewWidth - mBezierStart1.x + + val f1 = abs(mCornerX - mTouchX) + val f2 = viewWidth * f1 / mBezierStart1.x + mTouchX = abs(mCornerX - f2) + + val f3 = abs(mCornerX - mTouchX) * abs(mCornerY - mTouchY) / f1 + mTouchY = abs(mCornerY - f3) + + mMiddleX = (mTouchX + mCornerX) / 2 + mMiddleY = (mTouchY + mCornerY) / 2 + + mBezierControl1.x = + mMiddleX - (mCornerY - mMiddleY) * (mCornerY - mMiddleY) / (mCornerX - mMiddleX) + mBezierControl1.y = mCornerY.toFloat() + + mBezierControl2.x = mCornerX.toFloat() + + val f5 = mCornerY - mMiddleY + if (f5 == 0f) { + mBezierControl2.y = + mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / 0.1f + } else { + mBezierControl2.y = + mMiddleY - (mCornerX - mMiddleX) * (mCornerX - mMiddleX) / (mCornerY - mMiddleY) + } + + mBezierStart1.x = mBezierControl1.x - (mCornerX - mBezierControl1.x) / 2 + } + } + mBezierStart2.x = mCornerX.toFloat() + mBezierStart2.y = mBezierControl2.y - (mCornerY - mBezierControl2.y) / 2 + + mTouchToCornerDis = hypot( + (mTouchX - mCornerX).toDouble(), + (mTouchY - mCornerY).toDouble() + ).toFloat() + + mBezierEnd1 = getCross( + PointF(mTouchX, mTouchY), mBezierControl1, mBezierStart1, + mBezierStart2 + ) + mBezierEnd2 = getCross( + PointF(mTouchX, mTouchY), mBezierControl2, mBezierStart1, + mBezierStart2 + ) + + mBezierVertex1.x = (mBezierStart1.x + 2 * mBezierControl1.x + mBezierEnd1.x) / 4 + mBezierVertex1.y = (2 * mBezierControl1.y + mBezierStart1.y + mBezierEnd1.y) / 4 + mBezierVertex2.x = (mBezierStart2.x + 2 * mBezierControl2.x + mBezierEnd2.x) / 4 + mBezierVertex2.y = (2 * mBezierControl2.y + mBezierStart2.y + mBezierEnd2.y) / 4 + } + + /** + * 求解直线P1P2和直线P3P4的交点坐标 + */ + private fun getCross(P1: PointF, P2: PointF, P3: PointF, P4: PointF): PointF { + val crossP = PointF() + // 二元函数通式: y=ax+b + val a1 = (P2.y - P1.y) / (P2.x - P1.x) + val b1 = (P1.x * P2.y - P2.x * P1.y) / (P1.x - P2.x) + val a2 = (P4.y - P3.y) / (P4.x - P3.x) + val b2 = (P3.x * P4.y - P4.x * P3.y) / (P3.x - P4.x) + crossP.x = (b2 - b1) / (a1 - a2) + crossP.y = a1 * crossP.x + b1 + return crossP + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/SlidePageDelegate.kt b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/SlidePageDelegate.kt new file mode 100644 index 000000000..417ea746a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/SlidePageDelegate.kt @@ -0,0 +1,61 @@ +package io.legado.app.ui.book.read.page.delegate + +import android.graphics.Canvas +import android.graphics.Matrix +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection + +class SlidePageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) { + + private val bitmapMatrix = Matrix() + + override fun onAnimStart(animationSpeed: Int) { + val distanceX: Float + when (mDirection) { + PageDirection.NEXT -> distanceX = + if (isCancel) { + var dis = viewWidth - startX + touchX + if (dis > viewWidth) { + dis = viewWidth.toFloat() + } + viewWidth - dis + } else { + -(touchX + (viewWidth - startX)) + } + else -> distanceX = + if (isCancel) { + -(touchX - startX) + } else { + viewWidth - (touchX - startX) + } + } + startScroll(touchX.toInt(), 0, distanceX.toInt(), 0, animationSpeed) + } + + override fun onDraw(canvas: Canvas) { + val offsetX = touchX - startX + + if ((mDirection == PageDirection.NEXT && offsetX > 0) + || (mDirection == PageDirection.PREV && offsetX < 0) + ) return + val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth + if (!isRunning) return + if (mDirection == PageDirection.PREV) { + bitmapMatrix.setTranslate(distanceX + viewWidth, 0.toFloat()) + curBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) } + bitmapMatrix.setTranslate(distanceX, 0.toFloat()) + prevBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) } + } else if (mDirection == PageDirection.NEXT) { + bitmapMatrix.setTranslate(distanceX, 0.toFloat()) + nextBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) } + bitmapMatrix.setTranslate(distanceX - viewWidth, 0.toFloat()) + curBitmap?.let { canvas.drawBitmap(it, bitmapMatrix, null) } + } + } + + override fun onAnimStop() { + if (!isCancel) { + readView.fillPage(mDirection) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/curl/CurlMesh.java b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/curl/CurlMesh.java new file mode 100644 index 000000000..dda7bd174 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/curl/CurlMesh.java @@ -0,0 +1,957 @@ +package io.legado.app.ui.book.read.page.delegate.curl; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PointF; +import android.graphics.RectF; +import android.opengl.GLUtils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +import javax.microedition.khronos.opengles.GL10; + +/** + * Class implementing actual curl/page rendering. + * + * @author harism + */ +public class CurlMesh { + + // Flag for rendering some lines used for developing. Shows + // curl position and one for the direction from the + // position given. Comes handy once playing around with different + // ways for following pointer. + private static final boolean DRAW_CURL_POSITION = false; + // Flag for drawing polygon outlines. Using this flag crashes on emulator + // due to reason unknown to me. Leaving it here anyway as seeing polygon + // outlines gives good insight how original rectangle is divided. + private static final boolean DRAW_POLYGON_OUTLINES = false; + // Flag for enabling shadow rendering. + private static final boolean DRAW_SHADOW = true; + // Flag for texture rendering. While this is likely something you + // don't want to do it's been used for development purposes as texture + // rendering is rather slow on emulator. + private static final boolean DRAW_TEXTURE = true; + + // Colors for shadow. Inner one is the color drawn next to surface where + // shadowed area starts and outer one is color shadow ends to. + private static final float[] SHADOW_INNER_COLOR = {0f, 0f, 0f, .5f}; + private static final float[] SHADOW_OUTER_COLOR = {0f, 0f, 0f, .0f}; + + // Let's avoid using 'new' as much as possible. Meaning we introduce arrays + // once here and reuse them on runtime. Doesn't really have very much effect + // but avoids some garbage collections from happening. + private final Array mArrDropShadowVertices; + private final Array mArrIntersections; + private final Array mArrOutputVertices; + private final Array mArrRotatedVertices; + private final Array mArrScanLines; + private final Array mArrSelfShadowVertices; + private final Array mArrTempShadowVertices; + private final Array mArrTempVertices; + + // Buffers for feeding rasterizer. + private final FloatBuffer mBufColors; + private FloatBuffer mBufCurlPositionLines; + private final FloatBuffer mBufShadowColors; + private final FloatBuffer mBufShadowVertices; + private final FloatBuffer mBufTexCoords; + private final FloatBuffer mBufVertices; + + private int mCurlPositionLinesCount; + private int mDropShadowCount; + + // Boolean for 'flipping' texture sideways. + private boolean mFlipTexture = false; + // Maximum number of split lines used for creating a curl. + private final int mMaxCurlSplits; + + // Bounding rectangle for this mesh. mRectagle[0] = top-left corner, + // mRectangle[1] = bottom-left, mRectangle[2] = top-right and mRectangle[3] + // bottom-right. + private final Vertex[] mRectangle = new Vertex[4]; + private int mSelfShadowCount; + + private boolean mTextureBack = false; + // Texture ids and other variables. + private int[] mTextureIds = null; + private final CurlPage mTexturePage = new CurlPage(); + private final RectF mTextureRectBack = new RectF(); + private final RectF mTextureRectFront = new RectF(); + + private int mVerticesCountBack; + private int mVerticesCountFront; + + /** + * Constructor for mesh object. + * + * @param maxCurlSplits Maximum number curl can be divided into. The bigger the value + * the smoother curl will be. With the cost of having more + * polygons for drawing. + */ + public CurlMesh(int maxCurlSplits) { + // There really is no use for 0 splits. + mMaxCurlSplits = maxCurlSplits < 1 ? 1 : maxCurlSplits; + + mArrScanLines = new Array(maxCurlSplits + 2); + mArrOutputVertices = new Array(7); + mArrRotatedVertices = new Array(4); + mArrIntersections = new Array(2); + mArrTempVertices = new Array(7 + 4); + for (int i = 0; i < 7 + 4; ++i) { + mArrTempVertices.add(new Vertex()); + } + + if (DRAW_SHADOW) { + mArrSelfShadowVertices = new Array( + (mMaxCurlSplits + 2) * 2); + mArrDropShadowVertices = new Array( + (mMaxCurlSplits + 2) * 2); + mArrTempShadowVertices = new Array( + (mMaxCurlSplits + 2) * 2); + for (int i = 0; i < (mMaxCurlSplits + 2) * 2; ++i) { + mArrTempShadowVertices.add(new ShadowVertex()); + } + } + + // Rectangle consists of 4 vertices. Index 0 = top-left, index 1 = + // bottom-left, index 2 = top-right and index 3 = bottom-right. + for (int i = 0; i < 4; ++i) { + mRectangle[i] = new Vertex(); + } + // Set up shadow penumbra direction to each vertex. We do fake 'self + // shadow' calculations based on this information. + mRectangle[0].mPenumbraX = mRectangle[1].mPenumbraX = mRectangle[1].mPenumbraY = mRectangle[3].mPenumbraY = -1; + mRectangle[0].mPenumbraY = mRectangle[2].mPenumbraX = mRectangle[2].mPenumbraY = mRectangle[3].mPenumbraX = 1; + + if (DRAW_CURL_POSITION) { + mCurlPositionLinesCount = 3; + ByteBuffer hvbb = ByteBuffer + .allocateDirect(mCurlPositionLinesCount * 2 * 2 * 4); + hvbb.order(ByteOrder.nativeOrder()); + mBufCurlPositionLines = hvbb.asFloatBuffer(); + mBufCurlPositionLines.position(0); + } + + // There are 4 vertices from bounding rect, max 2 from adding split line + // to two corners and curl consists of max mMaxCurlSplits lines each + // outputting 2 vertices. + int maxVerticesCount = 4 + 2 + (2 * mMaxCurlSplits); + ByteBuffer vbb = ByteBuffer.allocateDirect(maxVerticesCount * 3 * 4); + vbb.order(ByteOrder.nativeOrder()); + mBufVertices = vbb.asFloatBuffer(); + mBufVertices.position(0); + + if (DRAW_TEXTURE) { + ByteBuffer tbb = ByteBuffer + .allocateDirect(maxVerticesCount * 2 * 4); + tbb.order(ByteOrder.nativeOrder()); + mBufTexCoords = tbb.asFloatBuffer(); + mBufTexCoords.position(0); + } + + ByteBuffer cbb = ByteBuffer.allocateDirect(maxVerticesCount * 4 * 4); + cbb.order(ByteOrder.nativeOrder()); + mBufColors = cbb.asFloatBuffer(); + mBufColors.position(0); + + if (DRAW_SHADOW) { + int maxShadowVerticesCount = (mMaxCurlSplits + 2) * 2 * 2; + ByteBuffer scbb = ByteBuffer + .allocateDirect(maxShadowVerticesCount * 4 * 4); + scbb.order(ByteOrder.nativeOrder()); + mBufShadowColors = scbb.asFloatBuffer(); + mBufShadowColors.position(0); + + ByteBuffer sibb = ByteBuffer + .allocateDirect(maxShadowVerticesCount * 3 * 4); + sibb.order(ByteOrder.nativeOrder()); + mBufShadowVertices = sibb.asFloatBuffer(); + mBufShadowVertices.position(0); + + mDropShadowCount = mSelfShadowCount = 0; + } + } + + /** + * Adds vertex to buffers. + */ + private void addVertex(Vertex vertex) { + mBufVertices.put((float) vertex.mPosX); + mBufVertices.put((float) vertex.mPosY); + mBufVertices.put((float) vertex.mPosZ); + mBufColors.put(vertex.mColorFactor * Color.red(vertex.mColor) / 255f); + mBufColors.put(vertex.mColorFactor * Color.green(vertex.mColor) / 255f); + mBufColors.put(vertex.mColorFactor * Color.blue(vertex.mColor) / 255f); + mBufColors.put(Color.alpha(vertex.mColor) / 255f); + if (DRAW_TEXTURE) { + mBufTexCoords.put((float) vertex.mTexX); + mBufTexCoords.put((float) vertex.mTexY); + } + } + + /** + * Sets curl for this mesh. + * + * @param curlPos Position for curl 'center'. Can be any point on line collinear + * to curl. + * @param curlDir Curl direction, should be normalized. + * @param radius Radius of curl. + */ + public synchronized void curl(PointF curlPos, PointF curlDir, double radius) { + + // First add some 'helper' lines used for development. + if (DRAW_CURL_POSITION) { + mBufCurlPositionLines.position(0); + + mBufCurlPositionLines.put(curlPos.x); + mBufCurlPositionLines.put(curlPos.y - 1.0f); + mBufCurlPositionLines.put(curlPos.x); + mBufCurlPositionLines.put(curlPos.y + 1.0f); + mBufCurlPositionLines.put(curlPos.x - 1.0f); + mBufCurlPositionLines.put(curlPos.y); + mBufCurlPositionLines.put(curlPos.x + 1.0f); + mBufCurlPositionLines.put(curlPos.y); + + mBufCurlPositionLines.put(curlPos.x); + mBufCurlPositionLines.put(curlPos.y); + mBufCurlPositionLines.put(curlPos.x + curlDir.x * 2); + mBufCurlPositionLines.put(curlPos.y + curlDir.y * 2); + + mBufCurlPositionLines.position(0); + } + + // Actual 'curl' implementation starts here. + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + + // Calculate curl angle from direction. + double curlAngle = Math.acos(curlDir.x); + curlAngle = curlDir.y > 0 ? -curlAngle : curlAngle; + + // Initiate rotated rectangle which's is translated to curlPos and + // rotated so that curl direction heads to right (1,0). Vertices are + // ordered in ascending order based on x -coordinate at the same time. + // And using y -coordinate in very rare case in which two vertices have + // same x -coordinate. + mArrTempVertices.addAll(mArrRotatedVertices); + mArrRotatedVertices.clear(); + for (int i = 0; i < 4; ++i) { + Vertex v = mArrTempVertices.remove(0); + v.set(mRectangle[i]); + v.translate(-curlPos.x, -curlPos.y); + v.rotateZ(-curlAngle); + int j = 0; + for (; j < mArrRotatedVertices.size(); ++j) { + Vertex v2 = mArrRotatedVertices.get(j); + if (v.mPosX > v2.mPosX) { + break; + } + if (v.mPosX == v2.mPosX && v.mPosY > v2.mPosY) { + break; + } + } + mArrRotatedVertices.add(j, v); + } + + // Rotated rectangle lines/vertex indices. We need to find bounding + // lines for rotated rectangle. After sorting vertices according to + // their x -coordinate we don't have to worry about vertices at indices + // 0 and 1. But due to inaccuracy it's possible vertex 3 is not the + // opposing corner from vertex 0. So we are calculating distance from + // vertex 0 to vertices 2 and 3 - and altering line indices if needed. + // Also vertices/lines are given in an order first one has x -coordinate + // at least the latter one. This property is used in getIntersections to + // see if there is an intersection. + int[][] lines = {{0, 1}, {0, 2}, {1, 3}, {2, 3}}; + { + // TODO: There really has to be more 'easier' way of doing this - + // not including extensive use of sqrt. + Vertex v0 = mArrRotatedVertices.get(0); + Vertex v2 = mArrRotatedVertices.get(2); + Vertex v3 = mArrRotatedVertices.get(3); + double dist2 = Math.sqrt((v0.mPosX - v2.mPosX) + * (v0.mPosX - v2.mPosX) + (v0.mPosY - v2.mPosY) + * (v0.mPosY - v2.mPosY)); + double dist3 = Math.sqrt((v0.mPosX - v3.mPosX) + * (v0.mPosX - v3.mPosX) + (v0.mPosY - v3.mPosY) + * (v0.mPosY - v3.mPosY)); + if (dist2 > dist3) { + lines[1][1] = 3; + lines[2][1] = 2; + } + } + + mVerticesCountFront = mVerticesCountBack = 0; + + if (DRAW_SHADOW) { + mArrTempShadowVertices.addAll(mArrDropShadowVertices); + mArrTempShadowVertices.addAll(mArrSelfShadowVertices); + mArrDropShadowVertices.clear(); + mArrSelfShadowVertices.clear(); + } + + // Length of 'curl' curve. + double curlLength = Math.PI * radius; + // Calculate scan lines. + // TODO: Revisit this code one day. There is room for optimization here. + mArrScanLines.clear(); + if (mMaxCurlSplits > 0) { + mArrScanLines.add((double) 0); + } + for (int i = 1; i < mMaxCurlSplits; ++i) { + mArrScanLines.add((-curlLength * i) / (mMaxCurlSplits - 1)); + } + // As mRotatedVertices is ordered regarding x -coordinate, adding + // this scan line produces scan area picking up vertices which are + // rotated completely. One could say 'until infinity'. + mArrScanLines.add(mArrRotatedVertices.get(3).mPosX - 1); + + // Start from right most vertex. Pretty much the same as first scan area + // is starting from 'infinity'. + double scanXmax = mArrRotatedVertices.get(0).mPosX + 1; + + for (int i = 0; i < mArrScanLines.size(); ++i) { + // Once we have scanXmin and scanXmax we have a scan area to start + // working with. + double scanXmin = mArrScanLines.get(i); + // First iterate 'original' rectangle vertices within scan area. + for (int j = 0; j < mArrRotatedVertices.size(); ++j) { + Vertex v = mArrRotatedVertices.get(j); + // Test if vertex lies within this scan area. + // TODO: Frankly speaking, can't remember why equality check was + // added to both ends. Guessing it was somehow related to case + // where radius=0f, which, given current implementation, could + // be handled much more effectively anyway. + if (v.mPosX >= scanXmin && v.mPosX <= scanXmax) { + // Pop out a vertex from temp vertices. + Vertex n = mArrTempVertices.remove(0); + n.set(v); + // This is done solely for triangulation reasons. Given a + // rotated rectangle it has max 2 vertices having + // intersection. + Array intersections = getIntersections( + mArrRotatedVertices, lines, n.mPosX); + // In a sense one could say we're adding vertices always in + // two, positioned at the ends of intersecting line. And for + // triangulation to work properly they are added based on y + // -coordinate. And this if-else is doing it for us. + if (intersections.size() == 1 + && intersections.get(0).mPosY > v.mPosY) { + // In case intersecting vertex is higher add it first. + mArrOutputVertices.addAll(intersections); + mArrOutputVertices.add(n); + } else if (intersections.size() <= 1) { + // Otherwise add original vertex first. + mArrOutputVertices.add(n); + mArrOutputVertices.addAll(intersections); + } else { + // There should never be more than 1 intersecting + // vertex. But if it happens as a fallback simply skip + // everything. + mArrTempVertices.add(n); + mArrTempVertices.addAll(intersections); + } + } + } + + // Search for scan line intersections. + Array intersections = getIntersections(mArrRotatedVertices, + lines, scanXmin); + + // We expect to get 0 or 2 vertices. In rare cases there's only one + // but in general given a scan line intersecting rectangle there + // should be 2 intersecting vertices. + if (intersections.size() == 2) { + // There were two intersections, add them based on y + // -coordinate, higher first, lower last. + Vertex v1 = intersections.get(0); + Vertex v2 = intersections.get(1); + if (v1.mPosY < v2.mPosY) { + mArrOutputVertices.add(v2); + mArrOutputVertices.add(v1); + } else { + mArrOutputVertices.addAll(intersections); + } + } else if (intersections.size() != 0) { + // This happens in a case in which there is a original vertex + // exactly at scan line or something went very much wrong if + // there are 3+ vertices. What ever the reason just return the + // vertices to temp vertices for later use. In former case it + // was handled already earlier once iterating through + // mRotatedVertices, in latter case it's better to avoid doing + // anything with them. + mArrTempVertices.addAll(intersections); + } + + // Add vertices found during this iteration to vertex etc buffers. + while (mArrOutputVertices.size() > 0) { + Vertex v = mArrOutputVertices.remove(0); + mArrTempVertices.add(v); + + // Local texture front-facing flag. + boolean textureFront; + + // Untouched vertices. + if (i == 0) { + textureFront = true; + mVerticesCountFront++; + } + // 'Completely' rotated vertices. + else if (i == mArrScanLines.size() - 1 || curlLength == 0) { + v.mPosX = -(curlLength + v.mPosX); + v.mPosZ = 2 * radius; + v.mPenumbraX = -v.mPenumbraX; + + textureFront = false; + mVerticesCountBack++; + } + // Vertex lies within 'curl'. + else { + // Even though it's not obvious from the if-else clause, + // here v.mPosX is between [-curlLength, 0]. And we can do + // calculations around a half cylinder. + double rotY = Math.PI * (v.mPosX / curlLength); + v.mPosX = radius * Math.sin(rotY); + v.mPosZ = radius - (radius * Math.cos(rotY)); + v.mPenumbraX *= Math.cos(rotY); + // Map color multiplier to [.1f, 1f] range. + v.mColorFactor = (float) (.1f + .9f * Math.sqrt(Math + .sin(rotY) + 1)); + + if (v.mPosZ >= radius) { + textureFront = false; + mVerticesCountBack++; + } else { + textureFront = true; + mVerticesCountFront++; + } + } + + // We use local textureFront for flipping backside texture + // locally. Plus additionally if mesh is in flip texture mode, + // we'll make the procedure "backwards". Also, until this point, + // texture coordinates are within [0, 1] range so we'll adjust + // them to final texture coordinates too. + if (textureFront != mFlipTexture) { + v.mTexX *= mTextureRectFront.right; + v.mTexY *= mTextureRectFront.bottom; + v.mColor = mTexturePage.getColor(CurlPage.SIDE_FRONT); + } else { + v.mTexX *= mTextureRectBack.right; + v.mTexY *= mTextureRectBack.bottom; + v.mColor = mTexturePage.getColor(CurlPage.SIDE_BACK); + } + + // Move vertex back to 'world' coordinates. + v.rotateZ(curlAngle); + v.translate(curlPos.x, curlPos.y); + addVertex(v); + + // Drop shadow is cast 'behind' the curl. + if (DRAW_SHADOW && v.mPosZ > 0 && v.mPosZ <= radius) { + ShadowVertex sv = mArrTempShadowVertices.remove(0); + sv.mPosX = v.mPosX; + sv.mPosY = v.mPosY; + sv.mPosZ = v.mPosZ; + sv.mPenumbraX = (v.mPosZ / 2) * -curlDir.x; + sv.mPenumbraY = (v.mPosZ / 2) * -curlDir.y; + sv.mPenumbraColor = v.mPosZ / radius; + int idx = (mArrDropShadowVertices.size() + 1) / 2; + mArrDropShadowVertices.add(idx, sv); + } + // Self shadow is cast partly over mesh. + if (DRAW_SHADOW && v.mPosZ > radius) { + ShadowVertex sv = mArrTempShadowVertices.remove(0); + sv.mPosX = v.mPosX; + sv.mPosY = v.mPosY; + sv.mPosZ = v.mPosZ; + sv.mPenumbraX = ((v.mPosZ - radius) / 3) * v.mPenumbraX; + sv.mPenumbraY = ((v.mPosZ - radius) / 3) * v.mPenumbraY; + sv.mPenumbraColor = (v.mPosZ - radius) / (2 * radius); + int idx = (mArrSelfShadowVertices.size() + 1) / 2; + mArrSelfShadowVertices.add(idx, sv); + } + } + + // Switch scanXmin as scanXmax for next iteration. + scanXmax = scanXmin; + } + + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + + // Add shadow Vertices. + if (DRAW_SHADOW) { + mBufShadowColors.position(0); + mBufShadowVertices.position(0); + mDropShadowCount = 0; + + for (int i = 0; i < mArrDropShadowVertices.size(); ++i) { + ShadowVertex sv = mArrDropShadowVertices.get(i); + mBufShadowVertices.put((float) sv.mPosX); + mBufShadowVertices.put((float) sv.mPosY); + mBufShadowVertices.put((float) sv.mPosZ); + mBufShadowVertices.put((float) (sv.mPosX + sv.mPenumbraX)); + mBufShadowVertices.put((float) (sv.mPosY + sv.mPenumbraY)); + mBufShadowVertices.put((float) sv.mPosZ); + for (int j = 0; j < 4; ++j) { + double color = SHADOW_OUTER_COLOR[j] + + (SHADOW_INNER_COLOR[j] - SHADOW_OUTER_COLOR[j]) + * sv.mPenumbraColor; + mBufShadowColors.put((float) color); + } + mBufShadowColors.put(SHADOW_OUTER_COLOR); + mDropShadowCount += 2; + } + mSelfShadowCount = 0; + for (int i = 0; i < mArrSelfShadowVertices.size(); ++i) { + ShadowVertex sv = mArrSelfShadowVertices.get(i); + mBufShadowVertices.put((float) sv.mPosX); + mBufShadowVertices.put((float) sv.mPosY); + mBufShadowVertices.put((float) sv.mPosZ); + mBufShadowVertices.put((float) (sv.mPosX + sv.mPenumbraX)); + mBufShadowVertices.put((float) (sv.mPosY + sv.mPenumbraY)); + mBufShadowVertices.put((float) sv.mPosZ); + for (int j = 0; j < 4; ++j) { + double color = SHADOW_OUTER_COLOR[j] + + (SHADOW_INNER_COLOR[j] - SHADOW_OUTER_COLOR[j]) + * sv.mPenumbraColor; + mBufShadowColors.put((float) color); + } + mBufShadowColors.put(SHADOW_OUTER_COLOR); + mSelfShadowCount += 2; + } + mBufShadowColors.position(0); + mBufShadowVertices.position(0); + } + } + + /** + * Calculates intersections for given scan line. + */ + private Array getIntersections(Array vertices, + int[][] lineIndices, double scanX) { + mArrIntersections.clear(); + // Iterate through rectangle lines each re-presented as a pair of + // vertices. + for (int j = 0; j < lineIndices.length; j++) { + Vertex v1 = vertices.get(lineIndices[j][0]); + Vertex v2 = vertices.get(lineIndices[j][1]); + // Here we expect that v1.mPosX >= v2.mPosX and wont do intersection + // test the opposite way. + if (v1.mPosX > scanX && v2.mPosX < scanX) { + // There is an intersection, calculate coefficient telling 'how + // far' scanX is from v2. + double c = (scanX - v2.mPosX) / (v1.mPosX - v2.mPosX); + Vertex n = mArrTempVertices.remove(0); + n.set(v2); + n.mPosX = scanX; + n.mPosY += (v1.mPosY - v2.mPosY) * c; + if (DRAW_TEXTURE) { + n.mTexX += (v1.mTexX - v2.mTexX) * c; + n.mTexY += (v1.mTexY - v2.mTexY) * c; + } + if (DRAW_SHADOW) { + n.mPenumbraX += (v1.mPenumbraX - v2.mPenumbraX) * c; + n.mPenumbraY += (v1.mPenumbraY - v2.mPenumbraY) * c; + } + mArrIntersections.add(n); + } + } + return mArrIntersections; + } + + /** + * Getter for textures page for this mesh. + */ + public synchronized CurlPage getTexturePage() { + return mTexturePage; + } + + /** + * Renders our page curl mesh. + */ + public synchronized void onDrawFrame(GL10 gl) { + // First allocate texture if there is not one yet. + if (DRAW_TEXTURE && mTextureIds == null) { + // Generate texture. + mTextureIds = new int[2]; + gl.glGenTextures(2, mTextureIds, 0); + for (int textureId : mTextureIds) { + // Set texture attributes. + gl.glBindTexture(GL10.GL_TEXTURE_2D, textureId); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, + GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, + GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_NEAREST); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, + GL10.GL_CLAMP_TO_EDGE); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, + GL10.GL_CLAMP_TO_EDGE); + } + } + + if (DRAW_TEXTURE && mTexturePage.getTexturesChanged()) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[0]); + Bitmap texture = mTexturePage.getTexture(mTextureRectFront, + CurlPage.SIDE_FRONT); + GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, texture, 0); + texture.recycle(); + + mTextureBack = mTexturePage.hasBackTexture(); + if (mTextureBack) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[1]); + texture = mTexturePage.getTexture(mTextureRectBack, + CurlPage.SIDE_BACK); + GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, texture, 0); + texture.recycle(); + } else { + mTextureRectBack.set(mTextureRectFront); + } + + mTexturePage.recycle(); + reset(); + } + + // Some 'global' settings. + gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); + + // TODO: Drop shadow drawing is done temporarily here to hide some + // problems with its calculation. + if (DRAW_SHADOW) { + gl.glDisable(GL10.GL_TEXTURE_2D); + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glEnableClientState(GL10.GL_COLOR_ARRAY); + gl.glColorPointer(4, GL10.GL_FLOAT, 0, mBufShadowColors); + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufShadowVertices); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, mDropShadowCount); + gl.glDisableClientState(GL10.GL_COLOR_ARRAY); + gl.glDisable(GL10.GL_BLEND); + } + + if (DRAW_TEXTURE) { + gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); + gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, mBufTexCoords); + } + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufVertices); + // Enable color array. + gl.glEnableClientState(GL10.GL_COLOR_ARRAY); + gl.glColorPointer(4, GL10.GL_FLOAT, 0, mBufColors); + + // Draw front facing blank vertices. + gl.glDisable(GL10.GL_TEXTURE_2D); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, mVerticesCountFront); + + // Draw front facing texture. + if (DRAW_TEXTURE) { + gl.glEnable(GL10.GL_BLEND); + gl.glEnable(GL10.GL_TEXTURE_2D); + + if (!mFlipTexture || !mTextureBack) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[0]); + } else { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[1]); + } + + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, mVerticesCountFront); + + gl.glDisable(GL10.GL_BLEND); + gl.glDisable(GL10.GL_TEXTURE_2D); + } + + int backStartIdx = Math.max(0, mVerticesCountFront - 2); + int backCount = mVerticesCountFront + mVerticesCountBack - backStartIdx; + + // Draw back facing blank vertices. + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, backStartIdx, backCount); + + // Draw back facing texture. + if (DRAW_TEXTURE) { + gl.glEnable(GL10.GL_BLEND); + gl.glEnable(GL10.GL_TEXTURE_2D); + + if (mFlipTexture || !mTextureBack) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[0]); + } else { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[1]); + } + + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, backStartIdx, backCount); + + gl.glDisable(GL10.GL_BLEND); + gl.glDisable(GL10.GL_TEXTURE_2D); + } + + // Disable textures and color array. + gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY); + gl.glDisableClientState(GL10.GL_COLOR_ARRAY); + + if (DRAW_POLYGON_OUTLINES) { + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glLineWidth(1.0f); + gl.glColor4f(0.5f, 0.5f, 1.0f, 1.0f); + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufVertices); + gl.glDrawArrays(GL10.GL_LINE_STRIP, 0, mVerticesCountFront); + gl.glDisable(GL10.GL_BLEND); + } + + if (DRAW_CURL_POSITION) { + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glLineWidth(1.0f); + gl.glColor4f(1.0f, 0.5f, 0.5f, 1.0f); + gl.glVertexPointer(2, GL10.GL_FLOAT, 0, mBufCurlPositionLines); + gl.glDrawArrays(GL10.GL_LINES, 0, mCurlPositionLinesCount * 2); + gl.glDisable(GL10.GL_BLEND); + } + + if (DRAW_SHADOW) { + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glEnableClientState(GL10.GL_COLOR_ARRAY); + gl.glColorPointer(4, GL10.GL_FLOAT, 0, mBufShadowColors); + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufShadowVertices); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, mDropShadowCount, + mSelfShadowCount); + gl.glDisableClientState(GL10.GL_COLOR_ARRAY); + gl.glDisable(GL10.GL_BLEND); + } + + gl.glDisableClientState(GL10.GL_VERTEX_ARRAY); + } + + /** + * Resets mesh to 'initial' state. Meaning this mesh will draw a plain + * textured rectangle after call to this method. + */ + public synchronized void reset() { + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + for (int i = 0; i < 4; ++i) { + Vertex tmp = mArrTempVertices.get(0); + tmp.set(mRectangle[i]); + + if (mFlipTexture) { + tmp.mTexX *= mTextureRectBack.right; + tmp.mTexY *= mTextureRectBack.bottom; + tmp.mColor = mTexturePage.getColor(CurlPage.SIDE_BACK); + } else { + tmp.mTexX *= mTextureRectFront.right; + tmp.mTexY *= mTextureRectFront.bottom; + tmp.mColor = mTexturePage.getColor(CurlPage.SIDE_FRONT); + } + + addVertex(tmp); + } + mVerticesCountFront = 4; + mVerticesCountBack = 0; + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + + mDropShadowCount = mSelfShadowCount = 0; + } + + /** + * Resets allocated texture id forcing creation of new one. After calling + * this method you most likely want to set bitmap too as it's lost. This + * method should be called only once e.g GL context is re-created as this + * method does not release previous texture id, only makes sure new one is + * requested on next render. + */ + public synchronized void resetTexture() { + mTextureIds = null; + } + + /** + * If true, flips texture sideways. + */ + public synchronized void setFlipTexture(boolean flipTexture) { + mFlipTexture = flipTexture; + if (flipTexture) { + setTexCoords(1f, 0f, 0f, 1f); + } else { + setTexCoords(0f, 0f, 1f, 1f); + } + } + + /** + * Update mesh bounds. + */ + public void setRect(RectF r) { + mRectangle[0].mPosX = r.left; + mRectangle[0].mPosY = r.top; + mRectangle[1].mPosX = r.left; + mRectangle[1].mPosY = r.bottom; + mRectangle[2].mPosX = r.right; + mRectangle[2].mPosY = r.top; + mRectangle[3].mPosX = r.right; + mRectangle[3].mPosY = r.bottom; + } + + /** + * Sets texture coordinates to mRectangle vertices. + */ + private synchronized void setTexCoords(float left, float top, float right, + float bottom) { + mRectangle[0].mTexX = left; + mRectangle[0].mTexY = top; + mRectangle[1].mTexX = left; + mRectangle[1].mTexY = bottom; + mRectangle[2].mTexX = right; + mRectangle[2].mTexY = top; + mRectangle[3].mTexX = right; + mRectangle[3].mTexY = bottom; + } + + /** + * Simple fixed size array implementation. + */ + private class Array { + private final Object[] mArray; + private final int mCapacity; + private int mSize; + + public Array(int capacity) { + mCapacity = capacity; + mArray = new Object[capacity]; + } + + public void add(int index, T item) { + if (index < 0 || index > mSize || mSize >= mCapacity) { + throw new IndexOutOfBoundsException(); + } + for (int i = mSize; i > index; --i) { + mArray[i] = mArray[i - 1]; + } + mArray[index] = item; + ++mSize; + } + + public void add(T item) { + if (mSize >= mCapacity) { + throw new IndexOutOfBoundsException(); + } + mArray[mSize++] = item; + } + + public void addAll(Array array) { + if (mSize + array.size() > mCapacity) { + throw new IndexOutOfBoundsException(); + } + for (int i = 0; i < array.size(); ++i) { + mArray[mSize++] = array.get(i); + } + } + + public void clear() { + mSize = 0; + } + + @SuppressWarnings("unchecked") + public T get(int index) { + if (index < 0 || index >= mSize) { + throw new IndexOutOfBoundsException(); + } + return (T) mArray[index]; + } + + @SuppressWarnings("unchecked") + public T remove(int index) { + if (index < 0 || index >= mSize) { + throw new IndexOutOfBoundsException(); + } + T item = (T) mArray[index]; + for (int i = index; i < mSize - 1; ++i) { + mArray[i] = mArray[i + 1]; + } + --mSize; + return item; + } + + public int size() { + return mSize; + } + + } + + /** + * Holder for shadow vertex information. + */ + private class ShadowVertex { + public double mPenumbraColor; + public double mPenumbraX; + public double mPenumbraY; + public double mPosX; + public double mPosY; + public double mPosZ; + } + + /** + * Holder for vertex information. + */ + private class Vertex { + public int mColor; + public float mColorFactor; + public double mPenumbraX; + public double mPenumbraY; + public double mPosX; + public double mPosY; + public double mPosZ; + public double mTexX; + public double mTexY; + + public Vertex() { + mPosX = mPosY = mPosZ = mTexX = mTexY = 0; + mColorFactor = 1.0f; + } + + public void rotateZ(double theta) { + double cos = Math.cos(theta); + double sin = Math.sin(theta); + double x = mPosX * cos + mPosY * sin; + double y = mPosX * -sin + mPosY * cos; + mPosX = x; + mPosY = y; + double px = mPenumbraX * cos + mPenumbraY * sin; + double py = mPenumbraX * -sin + mPenumbraY * cos; + mPenumbraX = px; + mPenumbraY = py; + } + + public void set(Vertex vertex) { + mPosX = vertex.mPosX; + mPosY = vertex.mPosY; + mPosZ = vertex.mPosZ; + mTexX = vertex.mTexX; + mTexY = vertex.mTexY; + mPenumbraX = vertex.mPenumbraX; + mPenumbraY = vertex.mPenumbraY; + mColor = vertex.mColor; + mColorFactor = vertex.mColorFactor; + } + + public void translate(double dx, double dy) { + mPosX += dx; + mPosY += dy; + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/delegate/curl/CurlPage.java b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/curl/CurlPage.java new file mode 100644 index 000000000..0caa17010 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/delegate/curl/CurlPage.java @@ -0,0 +1,195 @@ +package io.legado.app.ui.book.read.page.delegate.curl; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.RectF; + +/** + * Storage class for page textures, blend colors and possibly some other values + * in the future. + * + * @author harism + */ +public class CurlPage { + + public static final int SIDE_BACK = 2; + public static final int SIDE_BOTH = 3; + public static final int SIDE_FRONT = 1; + + private int mColorBack; + private int mColorFront; + private Bitmap mTextureBack; + private Bitmap mTextureFront; + private boolean mTexturesChanged; + + /** + * Default constructor. + */ + public CurlPage() { + reset(); + } + + /** + * Getter for color. + */ + public int getColor(int side) { + switch (side) { + case SIDE_FRONT: + return mColorFront; + default: + return mColorBack; + } + } + + /** + * Calculates the next highest power of two for a given integer. + */ + private int getNextHighestPO2(int n) { + n -= 1; + n = n | (n >> 1); + n = n | (n >> 2); + n = n | (n >> 4); + n = n | (n >> 8); + n = n | (n >> 16); + n = n | (n >> 32); + return n + 1; + } + + /** + * Generates nearest power of two sized Bitmap for give Bitmap. Returns this + * new Bitmap using default return statement + original texture coordinates + * are stored into RectF. + */ + private Bitmap getTexture(Bitmap bitmap, RectF textureRect) { + // Bitmap original size. + int w = bitmap.getWidth(); + int h = bitmap.getHeight(); + // Bitmap size expanded to next power of two. This is done due to + // the requirement on many devices, texture width and height should + // be power of two. + int newW = getNextHighestPO2(w); + int newH = getNextHighestPO2(h); + + // TODO: Is there another way to create a bigger Bitmap and copy + // original Bitmap to it more efficiently? Immutable bitmap anyone? + Bitmap bitmapTex = Bitmap.createBitmap(newW, newH, bitmap.getConfig()); + Canvas c = new Canvas(bitmapTex); + c.drawBitmap(bitmap, 0, 0, null); + + // Calculate final texture coordinates. + float texX = (float) w / newW; + float texY = (float) h / newH; + textureRect.set(0f, 0f, texX, texY); + + return bitmapTex; + } + + /** + * Getter for textures. Creates Bitmap sized to nearest power of two, copies + * original Bitmap into it and returns it. RectF given as parameter is + * filled with actual texture coordinates in this new upscaled texture + * Bitmap. + */ + public Bitmap getTexture(RectF textureRect, int side) { + switch (side) { + case SIDE_FRONT: + return getTexture(mTextureFront, textureRect); + default: + return getTexture(mTextureBack, textureRect); + } + } + + /** + * Returns true if textures have changed. + */ + public boolean getTexturesChanged() { + return mTexturesChanged; + } + + /** + * Returns true if back siding texture exists and it differs from front + * facing one. + */ + public boolean hasBackTexture() { + return !mTextureFront.equals(mTextureBack); + } + + /** + * Recycles and frees underlying Bitmaps. + */ + public void recycle() { + if (mTextureFront != null) { + mTextureFront.recycle(); + } + mTextureFront = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565); + mTextureFront.eraseColor(mColorFront); + if (mTextureBack != null) { + mTextureBack.recycle(); + } + mTextureBack = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565); + mTextureBack.eraseColor(mColorBack); + mTexturesChanged = false; + } + + /** + * Resets this CurlPage into its initial state. + */ + public void reset() { + mColorBack = Color.WHITE; + mColorFront = Color.WHITE; + recycle(); + } + + /** + * Setter blend color. + */ + public void setColor(int color, int side) { + switch (side) { + case SIDE_FRONT: + mColorFront = color; + break; + case SIDE_BACK: + mColorBack = color; + break; + default: + mColorFront = mColorBack = color; + break; + } + } + + /** + * Setter for textures. + */ + public void setTexture(Bitmap texture, int side) { + if (texture == null) { + texture = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565); + if (side == SIDE_BACK) { + texture.eraseColor(mColorBack); + } else { + texture.eraseColor(mColorFront); + } + } + switch (side) { + case SIDE_FRONT: + if (mTextureFront != null) + mTextureFront.recycle(); + mTextureFront = texture; + break; + case SIDE_BACK: + if (mTextureBack != null) + mTextureBack.recycle(); + mTextureBack = texture; + break; + case SIDE_BOTH: + if (mTextureFront != null) + mTextureFront.recycle(); + if (mTextureBack != null) + mTextureBack.recycle(); + mTextureFront = mTextureBack = texture; + break; + } + mTexturesChanged = true; + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/PageDirection.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/PageDirection.kt new file mode 100644 index 000000000..713c6bd67 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/PageDirection.kt @@ -0,0 +1,5 @@ +package io.legado.app.ui.book.read.page.entities + +enum class PageDirection { + NONE, PREV, NEXT +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt new file mode 100644 index 000000000..57df6ac3c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt @@ -0,0 +1,79 @@ +package io.legado.app.ui.book.read.page.entities + +import kotlin.math.min + +data class TextChapter( + val position: Int, + val title: String, + val url: String, + val pages: List, + val chaptersSize: Int, + val isVip: Boolean, + val isPay: Boolean, +) { + + fun page(index: Int): TextPage? { + return pages.getOrNull(index) + } + + fun getPageByReadPos(readPos: Int): TextPage? { + return page(getPageIndexByCharIndex(readPos)) + } + + val lastPage: TextPage? get() = pages.lastOrNull() + + val lastIndex: Int get() = pages.lastIndex + + val lastReadLength: Int get() = getReadLength(lastIndex) + + val pageSize: Int get() = pages.size + + fun isLastIndex(index: Int): Boolean { + return index >= pages.size - 1 + } + + fun getReadLength(pageIndex: Int): Int { + var length = 0 + val maxIndex = min(pageIndex, pages.size) + for (index in 0 until maxIndex) { + length += pages[index].charSize + } + return length + } + + fun getNextPageLength(length: Int): Int { + return getReadLength(getPageIndexByCharIndex(length) + 1) + } + + fun getUnRead(pageIndex: Int): String { + val stringBuilder = StringBuilder() + if (pages.isNotEmpty()) { + for (index in pageIndex..pages.lastIndex) { + stringBuilder.append(pages[index].text) + } + } + return stringBuilder.toString() + } + + fun getContent(): String { + val stringBuilder = StringBuilder() + pages.forEach { + stringBuilder.append(it.text) + } + return stringBuilder.toString() + } + + /** + * 根据索引位置获取所在页 + */ + fun getPageIndexByCharIndex(charIndex: Int): Int { + var length = 0 + pages.forEach { + length += it.charSize + if (length > charIndex) { + return it.index + } + } + return pages.lastIndex + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChar.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChar.kt new file mode 100644 index 000000000..518067182 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChar.kt @@ -0,0 +1,16 @@ +package io.legado.app.ui.book.read.page.entities + +data class TextChar( + val charData: String, + var start: Float, + var end: Float, + var selected: Boolean = false, + var isImage: Boolean = false, + var isSearchResult: Boolean = false +) { + + fun isTouch(x: Float): Boolean { + return x > start && x < end + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt new file mode 100644 index 000000000..bc05317b8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt @@ -0,0 +1,49 @@ +package io.legado.app.ui.book.read.page.entities + +import android.text.TextPaint +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.utils.textHeight + +@Suppress("unused") +data class TextLine( + var text: String = "", + val textChars: ArrayList = arrayListOf(), + var lineTop: Float = 0f, + var lineBase: Float = 0f, + var lineBottom: Float = 0f, + val isTitle: Boolean = false, + var isReadAloud: Boolean = false, + var isImage: Boolean = false +) { + + val charSize: Int get() = textChars.size + val lineStart: Float get() = textChars.firstOrNull()?.start ?: 0f + val lineEnd: Float get() = textChars.lastOrNull()?.end ?: 0f + + fun upTopBottom(durY: Float, textPaint: TextPaint) { + lineTop = ChapterProvider.paddingTop + durY + lineBottom = lineTop + textPaint.textHeight + lineBase = lineBottom - textPaint.fontMetrics.descent + } + + fun getTextChar(index: Int): TextChar { + return textChars.getOrElse(index) { + textChars.last() + } + } + + fun getTextCharReverseAt(index: Int): TextChar { + return textChars[textChars.lastIndex - index] + } + + fun getTextCharsCount(): Int { + return textChars.size + } + + fun isTouch(x: Float, y: Float, relativeOffset: Float): Boolean { + return y > lineTop + relativeOffset + && y < lineBottom + relativeOffset + && x >= lineStart + && x <= lineEnd + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt new file mode 100644 index 000000000..a9432cb94 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt @@ -0,0 +1,194 @@ +package io.legado.app.ui.book.read.page.entities + +import android.text.Layout +import android.text.StaticLayout +import io.legado.app.R +import io.legado.app.help.ReadBookConfig +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.utils.textHeight +import splitties.init.appCtx +import java.text.DecimalFormat +import kotlin.math.min + +@Suppress("unused", "MemberVisibilityCanBePrivate") +data class TextPage( + var index: Int = 0, + var text: String = appCtx.getString(R.string.data_loading), + var title: String = "", + val textLines: ArrayList = arrayListOf(), + var pageSize: Int = 0, + var chapterSize: Int = 0, + var chapterIndex: Int = 0, + var height: Float = 0f, + var leftLineSize: Int = 0 +) { + + val lineSize get() = textLines.size + val charSize get() = text.length + + fun getLine(index: Int): TextLine { + return textLines.getOrElse(index) { + textLines.last() + } + } + + fun upLinesPosition() { + if (!ReadBookConfig.textBottomJustify) return + if (textLines.size <= 1) return + if (leftLineSize == 0) { + leftLineSize = lineSize + } + ChapterProvider.run { + val lastLine = textLines[leftLineSize - 1] + if (lastLine.isImage) return@run + val lastLineHeight = with(lastLine) { lineBottom - lineTop } + val pageHeight = lastLine.lineBottom + contentPaint.textHeight * lineSpacingExtra + if (visibleHeight - pageHeight >= lastLineHeight) return@run + val surplus = (visibleBottom - lastLine.lineBottom) + if (surplus == 0f) return@run + height += surplus + val tj = surplus / (leftLineSize - 1) + for (i in 1 until leftLineSize) { + val line = textLines[i] + line.lineTop = line.lineTop + tj * i + line.lineBase = line.lineBase + tj * i + line.lineBottom = line.lineBottom + tj * i + } + } + if (leftLineSize == lineSize) return + ChapterProvider.run { + val lastLine = textLines.last() + if (lastLine.isImage) return@run + val lastLineHeight = with(lastLine) { lineBottom - lineTop } + val pageHeight = lastLine.lineBottom + contentPaint.textHeight * lineSpacingExtra + if (visibleHeight - pageHeight >= lastLineHeight) return@run + val surplus = (visibleBottom - lastLine.lineBottom) + if (surplus == 0f) return@run + val tj = surplus / (textLines.size - leftLineSize - 1) + for (i in leftLineSize + 1 until textLines.size) { + val line = textLines[i] + val surplusIndex = i - leftLineSize + line.lineTop = line.lineTop + tj * surplusIndex + line.lineBase = line.lineBase + tj * surplusIndex + line.lineBottom = line.lineBottom + tj * surplusIndex + } + } + } + + @Suppress("DEPRECATION") + fun format(): TextPage { + if (textLines.isEmpty() && ChapterProvider.viewWidth > 0) { + val visibleWidth = ChapterProvider.visibleRight - ChapterProvider.paddingLeft + val layout = StaticLayout( + text, ChapterProvider.contentPaint, visibleWidth, + Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false + ) + var y = (ChapterProvider.visibleHeight - layout.height) / 2f + if (y < 0) y = 0f + for (lineIndex in 0 until layout.lineCount) { + val textLine = TextLine() + textLine.lineTop = ChapterProvider.paddingTop + y + layout.getLineTop(lineIndex) + textLine.lineBase = + ChapterProvider.paddingTop + y + layout.getLineBaseline(lineIndex) + textLine.lineBottom = + ChapterProvider.paddingTop + y + layout.getLineBottom(lineIndex) + var x = ChapterProvider.paddingLeft + + (visibleWidth - layout.getLineMax(lineIndex)) / 2 + textLine.text = + text.substring(layout.getLineStart(lineIndex), layout.getLineEnd(lineIndex)) + for (i in textLine.text.indices) { + val char = textLine.text[i].toString() + val cw = StaticLayout.getDesiredWidth(char, ChapterProvider.contentPaint) + val x1 = x + cw + textLine.textChars.add( + TextChar( + char, start = x, end = x1 + ) + ) + x = x1 + } + textLines.add(textLine) + } + height = ChapterProvider.visibleHeight.toFloat() + } + return this + } + + fun removePageAloudSpan(): TextPage { + textLines.forEach { textLine -> + textLine.isReadAloud = false + } + return this + } + + fun upPageAloudSpan(aloudSpanStart: Int) { + removePageAloudSpan() + var lineStart = 0 + for ((index, textLine) in textLines.withIndex()) { + if (aloudSpanStart > lineStart && aloudSpanStart < lineStart + textLine.text.length) { + for (i in index - 1 downTo 0) { + if (textLines[i].text.endsWith("\n")) { + break + } else { + textLines[i].isReadAloud = true + } + } + for (i in index until textLines.size) { + if (textLines[i].text.endsWith("\n")) { + textLines[i].isReadAloud = true + break + } else { + textLines[i].isReadAloud = true + } + } + break + } + lineStart += textLine.text.length + } + } + + val readProgress: String + get() { + val df = DecimalFormat("0.0%") + if (chapterSize == 0 || pageSize == 0 && chapterIndex == 0) { + return "0.0%" + } else if (pageSize == 0) { + return df.format((chapterIndex + 1.0f) / chapterSize.toDouble()) + } + var percent = + df.format(chapterIndex * 1.0f / chapterSize + 1.0f / chapterSize * (index + 1) / pageSize.toDouble()) + if (percent == "100.0%" && (chapterIndex + 1 != chapterSize || index + 1 != pageSize)) { + percent = "99.9%" + } + return percent + } + + fun getSelectStartLength(lineIndex: Int, charIndex: Int): Int { + var length = 0 + val maxIndex = min(lineIndex, lineSize) + for (index in 0 until maxIndex) { + length += textLines[index].charSize + } + return length + charIndex + } + + fun getTextChapter(): TextChapter? { + ReadBook.curTextChapter?.let { + if (it.position == chapterIndex) { + return it + } + } + ReadBook.nextTextChapter?.let { + if (it.position == chapterIndex) { + return it + } + } + ReadBook.prevTextChapter?.let { + if (it.position == chapterIndex) { + return it + } + } + return null + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt new file mode 100644 index 000000000..556f26df1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt @@ -0,0 +1,613 @@ +package io.legado.app.ui.book.read.page.provider + +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import io.legado.app.constant.AppPattern +import io.legado.app.constant.EventBus +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.AppConfig +import io.legado.app.help.ReadBookConfig +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.book.read.page.entities.TextChar +import io.legado.app.ui.book.read.page.entities.TextLine +import io.legado.app.ui.book.read.page.entities.TextPage +import io.legado.app.utils.* +import splitties.init.appCtx +import java.util.* + +/** + * 解析内容生成章节和页面 + */ +@Suppress("DEPRECATION") +object ChapterProvider { + private const val srcReplaceChar = "▩" + + @JvmStatic + var viewWidth = 0 + private set + + @JvmStatic + var viewHeight = 0 + private set + + @JvmStatic + var paddingLeft = 0 + private set + + @JvmStatic + var paddingTop = 0 + private set + + @JvmStatic + var paddingRight = 0 + private set + + @JvmStatic + var paddingBottom = 0 + private set + + @JvmStatic + var visibleWidth = 0 + private set + + @JvmStatic + var visibleHeight = 0 + private set + + @JvmStatic + var visibleRight = 0 + private set + + @JvmStatic + var visibleBottom = 0 + private set + + @JvmStatic + var lineSpacingExtra = 0f + private set + + @JvmStatic + private var paragraphSpacing = 0 + + @JvmStatic + private var titleTopSpacing = 0 + + @JvmStatic + private var titleBottomSpacing = 0 + + @JvmStatic + var typeface: Typeface = Typeface.DEFAULT + private set + + @JvmStatic + var titlePaint: TextPaint = TextPaint() + + @JvmStatic + var contentPaint: TextPaint = TextPaint() + + var doublePage = false + private set + + init { + upStyle() + } + + /** + * 获取拆分完的章节数据 + */ + fun getTextChapter( + book: Book, + bookChapter: BookChapter, + displayTitle: String, + contents: List, + chapterSize: Int, + ): TextChapter { + val textPages = arrayListOf() + val stringBuilder = StringBuilder() + var absStartX = paddingLeft + var durY = 0f + textPages.add(TextPage()) + contents.forEachIndexed { index, content -> + if (book.getImageStyle() == Book.imgStyleText) { + var text = content.replace(srcReplaceChar, "▣") + val srcList = LinkedList() + val sb = StringBuffer() + val matcher = AppPattern.imgPattern.matcher(text) + while (matcher.find()) { + matcher.group(1)?.let { src -> + srcList.add(src) + ImageProvider.getImage(book, bookChapter.index, src, ReadBook.bookSource) + matcher.appendReplacement(sb, srcReplaceChar) + } + } + matcher.appendTail(sb) + text = sb.toString() + val isTitle = index == 0 + val textPaint = if (isTitle) titlePaint else contentPaint + if (!(isTitle && ReadBookConfig.titleMode == 2)) { + setTypeText( + absStartX, durY, text, textPages, stringBuilder, + isTitle, textPaint, srcList + ).let { + absStartX = it.first + durY = it.second + } + } + } else if (book.getImageStyle() != Book.imgStyleText) { + val matcher = AppPattern.imgPattern.matcher(content) + var start = 0 + while (matcher.find()) { + val text = content.substring(start, matcher.start()) + if (text.isNotBlank()) { + val isTitle = index == 0 + val textPaint = if (isTitle) titlePaint else contentPaint + if (!(isTitle && ReadBookConfig.titleMode == 2)) { + setTypeText( + absStartX, durY, text, textPages, + stringBuilder, isTitle, textPaint + ).let { + absStartX = it.first + durY = it.second + } + } + } + durY = setTypeImage( + book, bookChapter, matcher.group(1)!!, + durY, textPages, book.getImageStyle() + ) + start = matcher.end() + } + if (start < content.length) { + val text = content.substring(start, content.length) + if (text.isNotBlank()) { + val isTitle = index == 0 + val textPaint = if (isTitle) titlePaint else contentPaint + if (!(isTitle && ReadBookConfig.titleMode == 2)) { + setTypeText( + absStartX, durY, text, textPages, + stringBuilder, isTitle, textPaint + ).let { + absStartX = it.first + durY = it.second + } + } + } + } + } + } + textPages.last().height = durY + 20.dp + textPages.last().text = stringBuilder.toString() + textPages.forEachIndexed { index, item -> + item.index = index + item.pageSize = textPages.size + item.chapterIndex = bookChapter.index + item.chapterSize = chapterSize + item.title = displayTitle + item.upLinesPosition() + } + + return TextChapter( + bookChapter.index, displayTitle, + bookChapter.getAbsoluteURL(), + textPages, chapterSize, + bookChapter.isVip, bookChapter.isPay + ) + } + + private fun setTypeImage( + book: Book, + chapter: BookChapter, + src: String, + y: Float, + textPages: ArrayList, + imageStyle: String?, + ): Float { + var durY = y + ImageProvider.getImage(book, chapter.index, src, ReadBook.bookSource)?.let { + if (durY > visibleHeight) { + textPages.last().height = durY + textPages.add(TextPage()) + durY = 0f + } + var height = it.height + var width = it.width + when (imageStyle?.toUpperCase(Locale.ROOT)) { + Book.imgStyleFull -> { + width = visibleWidth + height = it.height * visibleWidth / it.width + } + else -> { + if (it.width > visibleWidth) { + height = it.height * visibleWidth / it.width + width = visibleWidth + } + if (height > visibleHeight) { + width = width * visibleHeight / height + height = visibleHeight + } + if (durY + height > visibleHeight) { + textPages.last().height = durY + textPages.add(TextPage()) + durY = 0f + } + } + } + val textLine = TextLine(isImage = true) + textLine.lineTop = durY + durY += height + textLine.lineBottom = durY + val (start, end) = if (visibleWidth > width) { + val adjustWidth = (visibleWidth - width) / 2f + Pair( + paddingLeft.toFloat() + adjustWidth, + paddingLeft.toFloat() + adjustWidth + width + ) + } else { + Pair(paddingLeft.toFloat(), (paddingLeft + width).toFloat()) + } + textLine.textChars.add( + TextChar( + charData = src, + start = start, + end = end, + isImage = true + ) + ) + textPages.last().textLines.add(textLine) + } + return durY + paragraphSpacing / 10f + } + + /** + * 排版文字 + */ + private fun setTypeText( + x: Int, + y: Float, + text: String, + textPages: ArrayList, + stringBuilder: StringBuilder, + isTitle: Boolean, + textPaint: TextPaint, + srcList: LinkedList? = null + ): Pair { + var absStartX = x + var durY = if (isTitle) y + titleTopSpacing else y + val layout = if (ReadBookConfig.useZhLayout) { + ZhLayout(text, textPaint, visibleWidth) + } else StaticLayout( + text, textPaint, visibleWidth, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true + ) + for (lineIndex in 0 until layout.lineCount) { + val textLine = TextLine(isTitle = isTitle) + if (durY + textPaint.textHeight > visibleHeight) { + val textPage = textPages.last() + if (doublePage && absStartX < viewWidth / 2) { + textPage.leftLineSize = textPage.lineSize + absStartX = viewWidth / 2 + paddingLeft + } else { + //当前页面结束,设置各种值 + if (textPage.leftLineSize == 0) { + textPage.leftLineSize = textPage.lineSize + } + textPage.text = stringBuilder.toString() + textPage.height = durY + //新建页面 + textPages.add(TextPage()) + stringBuilder.clear() + absStartX = paddingLeft + } + durY = 0f + } + val words = + text.substring(layout.getLineStart(lineIndex), layout.getLineEnd(lineIndex)) + val desiredWidth = layout.getLineWidth(lineIndex) + var isLastLine = false + if (lineIndex == 0 && layout.lineCount > 1 && !isTitle) { + //第一行 + textLine.text = words + addCharsToLineFirst( + absStartX, + textLine, + words.toStringArray(), + textPaint, + desiredWidth, + srcList + ) + } else if (lineIndex == layout.lineCount - 1) { + //最后一行 + textLine.text = "$words\n" + isLastLine = true + val startX = if (isTitle && ReadBookConfig.titleMode == 1) + (visibleWidth - layout.getLineWidth(lineIndex)) / 2 + else 0f + addCharsToLineLast( + absStartX, + textLine, + words.toStringArray(), + textPaint, + startX, + srcList + ) + } else { + //中间行 + textLine.text = words + addCharsToLineMiddle( + absStartX, + textLine, + words.toStringArray(), + textPaint, + desiredWidth, + 0f, + srcList + ) + } + stringBuilder.append(words) + if (isLastLine) stringBuilder.append("\n") + textPages.last().textLines.add(textLine) + textLine.upTopBottom(durY, textPaint) + durY += textPaint.textHeight * lineSpacingExtra + textPages.last().height = durY + } + if (isTitle) durY += titleBottomSpacing + durY += textPaint.textHeight * paragraphSpacing / 10f + return Pair(absStartX, durY) + } + + /** + * 有缩进,两端对齐 + */ + private fun addCharsToLineFirst( + absStartX: Int, + textLine: TextLine, + words: Array, + textPaint: TextPaint, + desiredWidth: Float, + srcList: LinkedList? + ) { + var x = 0f + if (!ReadBookConfig.textFullJustify) { + addCharsToLineLast(absStartX, textLine, words, textPaint, x, srcList) + return + } + val bodyIndent = ReadBookConfig.paragraphIndent + val icw = StaticLayout.getDesiredWidth(bodyIndent, textPaint) / bodyIndent.length + bodyIndent.toStringArray().forEach { char -> + val x1 = x + icw + textLine.textChars.add( + TextChar( + charData = char, + start = absStartX + x, + end = absStartX + x1 + ) + ) + x = x1 + } + if (words.size > bodyIndent.length) { + val words1 = words.copyOfRange(bodyIndent.length, words.size) + addCharsToLineMiddle(absStartX, textLine, words1, textPaint, desiredWidth, x, srcList) + } + } + + /** + * 无缩进,两端对齐 + */ + private fun addCharsToLineMiddle( + absStartX: Int, + textLine: TextLine, + words: Array, + textPaint: TextPaint, + desiredWidth: Float, + startX: Float, + srcList: LinkedList? + ) { + if (!ReadBookConfig.textFullJustify) { + addCharsToLineLast(absStartX, textLine, words, textPaint, startX, srcList) + return + } + val gapCount: Int = words.lastIndex + val d = (visibleWidth - desiredWidth) / gapCount + var x = startX + words.forEachIndexed { index, char -> + val cw = StaticLayout.getDesiredWidth(char, textPaint) + val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw) + if (srcList != null && char == srcReplaceChar) { + textLine.textChars.add( + TextChar( + charData = srcList.removeFirst(), + start = absStartX + x, + end = absStartX + x1, + isImage = true + ) + ) + } else { + textLine.textChars.add( + TextChar( + charData = char, + start = absStartX + x, + end = absStartX + x1 + ) + ) + } + x = x1 + } + exceed(absStartX, textLine, words) + } + + /** + * 最后一行,自然排列 + */ + private fun addCharsToLineLast( + absStartX: Int, + textLine: TextLine, + words: Array, + textPaint: TextPaint, + startX: Float, + srcList: LinkedList? + ) { + var x = startX + words.forEach { char -> + val cw = StaticLayout.getDesiredWidth(char, textPaint) + val x1 = x + cw + if (srcList != null && char == srcReplaceChar) { + textLine.textChars.add( + TextChar( + charData = srcList.removeFirst(), + start = absStartX + x, + end = absStartX + x1, + isImage = true + ) + ) + } else { + textLine.textChars.add( + TextChar( + charData = char, + start = absStartX + x, + end = absStartX + x1 + ) + ) + } + x = x1 + } + exceed(absStartX, textLine, words) + } + + /** + * 超出边界处理 + */ + private fun exceed(absStartX: Int, textLine: TextLine, words: Array) { + val visibleEnd = absStartX + visibleWidth + val endX = textLine.textChars.lastOrNull()?.end ?: return + if (endX > visibleEnd) { + val cc = (endX - visibleEnd) / words.size + for (i in 0..words.lastIndex) { + textLine.getTextCharReverseAt(i).let { + val py = cc * (words.size - i) + it.start = it.start - py + it.end = it.end - py + } + } + } + } + + /** + * 更新样式 + */ + fun upStyle() { + typeface = getTypeface(ReadBookConfig.textFont) + getPaints(typeface).let { + titlePaint = it.first + contentPaint = it.second + } + //间距 + lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f + paragraphSpacing = ReadBookConfig.paragraphSpacing + titleTopSpacing = ReadBookConfig.titleTopSpacing.dp + titleBottomSpacing = ReadBookConfig.titleBottomSpacing.dp + upLayout() + } + + private fun getTypeface(fontPath: String): Typeface { + return kotlin.runCatching { + when { + fontPath.isContentScheme() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + val fd = appCtx.contentResolver + .openFileDescriptor(Uri.parse(fontPath), "r")!! + .fileDescriptor + Typeface.Builder(fd).build() + } + fontPath.isContentScheme() -> { + Typeface.createFromFile(RealPathUtil.getPath(appCtx, Uri.parse(fontPath))) + } + fontPath.isNotEmpty() -> Typeface.createFromFile(fontPath) + else -> when (AppConfig.systemTypefaces) { + 1 -> Typeface.SERIF + 2 -> Typeface.MONOSPACE + else -> Typeface.SANS_SERIF + } + } + }.getOrElse { + ReadBookConfig.textFont = "" + ReadBookConfig.save() + Typeface.SANS_SERIF + } ?: Typeface.DEFAULT + } + + private fun getPaints(typeface: Typeface): Pair { + // 字体统一处理 + val bold = Typeface.create(typeface, Typeface.BOLD) + val normal = Typeface.create(typeface, Typeface.NORMAL) + val (titleFont, textFont) = when (ReadBookConfig.textBold) { + 1 -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + Pair(Typeface.create(typeface, 900, false), bold) + else + Pair(bold, bold) + } + 2 -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + Pair(normal, Typeface.create(typeface, 300, false)) + else + Pair(normal, normal) + } + else -> Pair(bold, normal) + } + + //标题 + val tPaint = TextPaint() + tPaint.color = ReadBookConfig.textColor + tPaint.letterSpacing = ReadBookConfig.letterSpacing + tPaint.typeface = titleFont + tPaint.textSize = with(ReadBookConfig) { textSize + titleSize }.sp.toFloat() + tPaint.isAntiAlias = true + val cPaint = TextPaint() + cPaint.color = ReadBookConfig.textColor + cPaint.letterSpacing = ReadBookConfig.letterSpacing + cPaint.typeface = textFont + cPaint.textSize = ReadBookConfig.textSize.sp.toFloat() + cPaint.isAntiAlias = true + return Pair(tPaint, cPaint) + } + + /** + * 更新View尺寸 + */ + fun upViewSize(width: Int, height: Int) { + if (width > 0 && height > 0 && (width != viewWidth || height != viewHeight)) { + viewWidth = width + viewHeight = height + upLayout() + postEvent(EventBus.UP_CONFIG, true) + } + } + + /** + * 更新绘制尺寸 + */ + fun upLayout() { + doublePage = (viewWidth > viewHeight || appCtx.isPad) + && ReadBook.pageAnim() != 3 + && AppConfig.doublePageHorizontal + if (viewWidth > 0 && viewHeight > 0) { + paddingLeft = ReadBookConfig.paddingLeft.dp + paddingTop = ReadBookConfig.paddingTop.dp + paddingRight = ReadBookConfig.paddingRight.dp + paddingBottom = ReadBookConfig.paddingBottom.dp + visibleWidth = if (doublePage) { + viewWidth / 2 - paddingLeft - paddingRight + } else { + viewWidth - paddingLeft - paddingRight + } + visibleHeight = viewHeight - paddingTop - paddingBottom + visibleRight = viewWidth - paddingRight + visibleBottom = paddingTop + visibleHeight + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/ImageProvider.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ImageProvider.kt new file mode 100644 index 000000000..386a37dbb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ImageProvider.kt @@ -0,0 +1,93 @@ +package io.legado.app.ui.book.read.page.provider + +import android.graphics.Bitmap +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.help.BookHelp +import io.legado.app.model.localBook.EpubFile +import io.legado.app.utils.BitmapUtils +import io.legado.app.utils.FileUtils +import kotlinx.coroutines.runBlocking +import java.io.FileOutputStream +import java.util.concurrent.ConcurrentHashMap + +object ImageProvider { + + private val cache = ConcurrentHashMap>() + + @Synchronized + fun getCache(chapterIndex: Int, src: String): Bitmap? { + return cache[chapterIndex]?.get(src) + } + + @Synchronized + fun setCache(chapterIndex: Int, src: String, bitmap: Bitmap) { + var indexCache = cache[chapterIndex] + if (indexCache == null) { + indexCache = ConcurrentHashMap() + cache[chapterIndex] = indexCache + } + indexCache[src] = bitmap + } + + fun getImage( + book: Book, + chapterIndex: Int, + src: String, + bookSource: BookSource?, + onUi: Boolean = false, + ): Bitmap? { + getCache(chapterIndex, src)?.let { + return it + } + val vFile = BookHelp.getImage(book, src) + if (!vFile.exists()) { + if (book.isEpub()) { + EpubFile.getImage(book, src)?.use { input -> + val newFile = FileUtils.createFileIfNotExist(vFile.absolutePath) + FileOutputStream(newFile).use { output -> + input.copyTo(output) + } + } + } else if (!onUi) { + runBlocking { + BookHelp.saveImage(bookSource, book, src) + } + } + } + return try { + val bitmap = BitmapUtils.decodeBitmap( + vFile.absolutePath, + ChapterProvider.visibleWidth, + ChapterProvider.visibleHeight + ) + setCache(chapterIndex, src, bitmap) + bitmap + } catch (e: Exception) { + null + } + } + + @Synchronized + fun clearAllCache() { + cache.forEach { indexCache -> + indexCache.value.forEach { + it.value.recycle() + } + } + cache.clear() + } + + @Synchronized + fun clearOut(chapterIndex: Int) { + cache.forEach { indexCache -> + if (indexCache.key !in chapterIndex - 1..chapterIndex + 1) { + indexCache.value.forEach { + it.value.recycle() + } + cache.remove(indexCache.key) + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt new file mode 100644 index 000000000..10d4b9ab4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt @@ -0,0 +1,131 @@ +package io.legado.app.ui.book.read.page.provider + +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.page.api.DataSource +import io.legado.app.ui.book.read.page.api.PageFactory +import io.legado.app.ui.book.read.page.entities.TextPage + +class TextPageFactory(dataSource: DataSource) : PageFactory(dataSource) { + + override fun hasPrev(): Boolean = with(dataSource) { + return hasPrevChapter() || pageIndex > 0 + } + + override fun hasNext(): Boolean = with(dataSource) { + return hasNextChapter() || currentChapter?.isLastIndex(pageIndex) != true + } + + override fun hasNextPlus(): Boolean = with(dataSource) { + return hasNextChapter() || pageIndex < (currentChapter?.pageSize ?: 1) - 2 + } + + override fun moveToFirst() { + ReadBook.setPageIndex(0) + } + + override fun moveToLast() = with(dataSource) { + currentChapter?.let { + if (it.pageSize == 0) { + ReadBook.setPageIndex(0) + } else { + ReadBook.setPageIndex(it.pageSize.minus(1)) + } + } ?: ReadBook.setPageIndex(0) + } + + override fun moveToNext(upContent: Boolean): Boolean = with(dataSource) { + return if (hasNext()) { + if (currentChapter?.isLastIndex(pageIndex) == true) { + ReadBook.moveToNextChapter(upContent) + } else { + ReadBook.setPageIndex(pageIndex.plus(1)) + } + if (upContent) upContent(resetPageOffset = false) + true + } else + false + } + + override fun moveToPrev(upContent: Boolean): Boolean = with(dataSource) { + return if (hasPrev()) { + if (pageIndex <= 0) { + ReadBook.moveToPrevChapter(upContent) + } else { + ReadBook.setPageIndex(pageIndex.minus(1)) + } + if (upContent) upContent(resetPageOffset = false) + true + } else + false + } + + override val curPage: TextPage + get() = with(dataSource) { + ReadBook.msg?.let { + return@with TextPage(text = it).format() + } + currentChapter?.let { + return@with it.page(pageIndex) ?: TextPage(title = it.title).format() + } + return TextPage().format() + } + + override val nextPage: TextPage + get() = with(dataSource) { + ReadBook.msg?.let { + return@with TextPage(text = it).format() + } + currentChapter?.let { + if (pageIndex < it.pageSize - 1) { + return@with it.page(pageIndex + 1)?.removePageAloudSpan() + ?: TextPage(title = it.title).format() + } + } + if (!hasNextChapter()) { + return@with TextPage(text = "") + } + nextChapter?.let { + return@with it.page(0)?.removePageAloudSpan() + ?: TextPage(title = it.title).format() + } + return TextPage().format() + } + + override val prevPage: TextPage + get() = with(dataSource) { + ReadBook.msg?.let { + return@with TextPage(text = it).format() + } + if (pageIndex > 0) { + currentChapter?.let { + return@with it.page(pageIndex - 1)?.removePageAloudSpan() + ?: TextPage(title = it.title).format() + } + } + prevChapter?.let { + return@with it.lastPage?.removePageAloudSpan() + ?: TextPage(title = it.title).format() + } + return TextPage().format() + } + + override val nextPlusPage: TextPage + get() = with(dataSource) { + currentChapter?.let { + if (pageIndex < it.pageSize - 2) { + return@with it.page(pageIndex + 2)?.removePageAloudSpan() + ?: TextPage(title = it.title).format() + } + nextChapter?.let { nc -> + if (pageIndex < it.pageSize - 1) { + return@with nc.page(0)?.removePageAloudSpan() + ?: TextPage(title = nc.title).format() + } + return@with nc.page(1)?.removePageAloudSpan() + ?: TextPage(title = nc.title).format() + } + + } + return TextPage().format() + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt new file mode 100644 index 000000000..648392e83 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ZhLayout.kt @@ -0,0 +1,261 @@ +package io.legado.app.ui.book.read.page.provider + +import android.graphics.Rect +import android.text.Layout +import android.text.TextPaint +import io.legado.app.utils.toStringArray +import kotlin.math.max + +/** + * 针对中文的断行排版处理-by hoodie13 + * 因为StaticLayout对标点处理不符合国人习惯,继承Layout + * */ +@Suppress("MemberVisibilityCanBePrivate", "unused") +class ZhLayout( + text: String, + textPaint: TextPaint, + width: Int +) : Layout(text, textPaint, width, Alignment.ALIGN_NORMAL, 0f, 0f) { + private val defaultCapacity = 10 + var lineStart = IntArray(defaultCapacity) + var lineWidth = FloatArray(defaultCapacity) + private var lineCount = 0 + private val curPaint = textPaint + private val cnCharWitch = getDesiredWidth("我", textPaint) + + enum class BreakMod { NORMAL, BREAK_ONE_CHAR, BREAK_MORE_CHAR, CPS_1, CPS_2, CPS_3, } + class Locate { + var start: Float = 0f + var end: Float = 0f + } + + class Interval { + var total: Float = 0f + var single: Float = 0f + } + + init { + var line = 0 + val words = text.toStringArray() + var lineW = 0f + var cwPre = 0f + var length = 0 + words.forEachIndexed { index, s -> + val cw = getDesiredWidth(s, curPaint) + var breakMod: BreakMod + var breakLine = false + lineW += cw + var offset = 0f + var breakCharCnt = 0 + + if (lineW > width) { + /*禁止在行尾的标点处理*/ + breakMod = if (index >= 1 && isPrePanc(words[index - 1])) { + if (index >= 2 && isPrePanc(words[index - 2])) BreakMod.CPS_2//如果后面还有一个禁首标点则异常 + else BreakMod.BREAK_ONE_CHAR //无异常场景 + } + /*禁止在行首的标点处理*/ + else if (isPostPanc(words[index])) { + if (index >= 1 && isPostPanc(words[index - 1])) BreakMod.CPS_1//如果后面还有一个禁首标点则异常,不过三个连续行尾标点的用法不通用 + else if (index >= 2 && isPrePanc(words[index - 2])) BreakMod.CPS_3//如果后面还有一个禁首标点则异常 + else BreakMod.BREAK_ONE_CHAR //无异常场景 + } else { + BreakMod.NORMAL //无异常场景 + } + + /*判断上述逻辑解决不了的特殊情况*/ + var reCheck = false + var breakIndex = 0 + if (breakMod == BreakMod.CPS_1 && + (inCompressible(words[index]) || inCompressible(words[index - 1])) + ) reCheck = true + if (breakMod == BreakMod.CPS_2 && + (inCompressible(words[index - 1]) || inCompressible(words[index - 2])) + ) reCheck = true + if (breakMod == BreakMod.CPS_3 && + (inCompressible(words[index]) || inCompressible(words[index - 2])) + ) reCheck = true + if (breakMod > BreakMod.BREAK_MORE_CHAR + && index < words.lastIndex && isPostPanc(words[index + 1]) + ) reCheck = true + + /*特殊标点使用难保证显示效果,所以不考虑间隔,直接查找到能满足条件的分割字*/ + var breakLength = 0 + if (reCheck && index > 2) { + breakMod = BreakMod.NORMAL + for (i in (index) downTo 1) { + if (i == index) { + breakIndex = 0 + cwPre = 0f + } else { + breakIndex++ + breakLength += words[i].length + cwPre += getDesiredWidth(words[i], textPaint) + } + if (!isPostPanc(words[i]) && !isPrePanc(words[i - 1])) { + breakMod = BreakMod.BREAK_MORE_CHAR + break + } + } + } + + when (breakMod) { + BreakMod.NORMAL -> {//模式0 正常断行 + offset = cw + lineStart[line + 1] = length + breakCharCnt = 1 + } + BreakMod.BREAK_ONE_CHAR -> {//模式1 当前行下移一个字 + offset = cw + cwPre + lineStart[line + 1] = length - words[index - 1].length + breakCharCnt = 2 + } + BreakMod.BREAK_MORE_CHAR -> {//模式2 当前行下移多个字 + offset = cw + cwPre + lineStart[line + 1] = length - breakLength + breakCharCnt = breakIndex + 1 + } + BreakMod.CPS_1 -> {//模式3 两个后置标点压缩 + offset = 0f + lineStart[line + 1] = length + s.length + breakCharCnt = 0 + } + BreakMod.CPS_2 -> { //模式4 前置标点压缩+前置标点压缩+字 + offset = 0f + lineStart[line + 1] = length + s.length + breakCharCnt = 0 + } + BreakMod.CPS_3 -> {//模式5 前置标点压缩+字+后置标点压缩 + offset = 0f + lineStart[line + 1] = length + s.length + breakCharCnt = 0 + } + } + breakLine = true + } + + /*当前行写满情况下的断行*/ + if (breakLine) { + lineWidth[line] = lineW - offset + lineW = offset + addLineArray(++line) + } + /*已到最后一个字符*/ + if ((words.lastIndex) == index) { + if (!breakLine) { + offset = 0f + lineStart[line + 1] = length + s.length + lineWidth[line] = lineW - offset + lineW = offset + addLineArray(++line) + } + /*写满断行、段落末尾、且需要下移字符,这种特殊情况下要额外多一行*/ + else if (breakCharCnt > 0) { + lineStart[line + 1] = lineStart[line] + breakCharCnt + lineWidth[line] = lineW + addLineArray(++line) + } + } + length += s.length + cwPre = cw + } + + lineCount = line + + } + + private fun addLineArray(line: Int) { + if (lineStart.size <= line + 1) { + lineStart = lineStart.copyOf(line + defaultCapacity) + lineWidth = lineWidth.copyOf(line + defaultCapacity) + } + } + + private fun isPostPanc(string: String): Boolean { + val panc = arrayOf( + ",", "。", ":", "?", "!", "、", "”", "’", ")", "》", "}", + "】", ")", ">", "]", "}", ",", ".", "?", "!", ":", "」", ";", ";" + ) + panc.forEach { + if (it == string) return true + } + return false + } + + private fun isPrePanc(string: String): Boolean { + val panc = arrayOf("“", "(", "《", "【", "‘", "‘", "(", "<", "[", "{", "「") + panc.forEach { + if (it == string) return true + } + return false + } + + private fun inCompressible(string: String): Boolean { + return getDesiredWidth(string, curPaint) < cnCharWitch + } + + private val gap = (cnCharWitch / 12.75).toFloat() + private fun getPostPancOffset(string: String): Float { + val textRect = Rect() + curPaint.getTextBounds(string, 0, 1, textRect) + return max(textRect.left.toFloat() - gap, 0f) + } + + private fun getPrePancOffset(string: String): Float { + val textRect = Rect() + curPaint.getTextBounds(string, 0, 1, textRect) + val d = max(cnCharWitch - textRect.right.toFloat() - gap, 0f) + return cnCharWitch / 2 - d + } + + fun getDesiredWidth(sting: String, paint: TextPaint) = paint.measureText(sting) + + override fun getLineCount(): Int { + return lineCount + } + + override fun getLineTop(line: Int): Int { + return 0 + } + + override fun getLineDescent(line: Int): Int { + return 0 + } + + override fun getLineStart(line: Int): Int { + return lineStart[line] + } + + override fun getParagraphDirection(line: Int): Int { + return 0 + } + + override fun getLineContainsTab(line: Int): Boolean { + return true + } + + override fun getLineDirections(line: Int): Directions? { + return null + } + + override fun getTopPadding(): Int { + return 0 + } + + override fun getBottomPadding(): Int { + return 0 + } + + override fun getLineWidth(line: Int): Float { + return lineWidth[line] + } + + override fun getEllipsisStart(line: Int): Int { + return 0 + } + + override fun getEllipsisCount(line: Int): Int { + return 0 + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt new file mode 100644 index 000000000..1aa9f0fff --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt @@ -0,0 +1,42 @@ +package io.legado.app.ui.book.search + +import android.content.Context +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.Book +import io.legado.app.databinding.ItemFilletTextBinding + + +class BookAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding { + return ItemFilletTextBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemFilletTextBinding, + item: Book, + payloads: MutableList + ) { + binding.run { + textView.text = item.name + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) { + holder.itemView.apply { + setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.showBookInfo(it) + } + } + } + } + + interface CallBack { + fun showBookInfo(book: Book) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt new file mode 100644 index 000000000..f7bbd8297 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt @@ -0,0 +1,51 @@ +package io.legado.app.ui.book.search + +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.databinding.ItemFilletTextBinding +import io.legado.app.ui.widget.anima.explosion_field.ExplosionField +import splitties.views.onLongClick + +class HistoryKeyAdapter(activity: SearchActivity, val callBack: CallBack) : + RecyclerAdapter(activity) { + + private val explosionField = ExplosionField.attach2Window(activity) + + override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding { + return ItemFilletTextBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemFilletTextBinding, + item: SearchKeyword, + payloads: MutableList + ) { + binding.run { + textView.text = item.word + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) { + holder.itemView.apply { + setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.searchHistory(it.word) + } + } + onLongClick { + explosionField.explode(this, true) + getItem(holder.layoutPosition)?.let { + callBack.deleteHistory(it) + } + } + } + } + + interface CallBack { + fun searchHistory(key: String) + fun deleteHistory(searchKeyword: SearchKeyword) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt new file mode 100644 index 000000000..39f6617da --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -0,0 +1,379 @@ +package io.legado.app.ui.book.search + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View.GONE +import android.view.View.VISIBLE +import android.widget.TextView +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.flexbox.FlexboxLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppPattern +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.databinding.ActivityBookSearchBinding +import io.legado.app.lib.theme.* +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.widget.recycler.LoadMoreView +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SearchActivity : VMBaseActivity(), + BookAdapter.CallBack, + HistoryKeyAdapter.CallBack, + SearchAdapter.CallBack { + + override val binding by viewBinding(ActivityBookSearchBinding::inflate) + override val viewModel by viewModels() + + private val adapter by lazy { SearchAdapter(this, this) } + private val bookAdapter by lazy { BookAdapter(this, this) } + private val historyKeyAdapter by lazy { HistoryKeyAdapter(this, this) } + private val loadMoreView by lazy { LoadMoreView(this) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private var historyFlowJob: Job? = null + private var booksFlowJob: Job? = null + private var menu: Menu? = null + private var precisionSearchMenuItem: MenuItem? = null + private var groups = linkedSetOf() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.llHistory.setBackgroundColor(backgroundColor) + initRecyclerView() + initSearchView() + initOtherView() + initData() + receiptIntent(intent) + } + + override fun onNewIntent(data: Intent?) { + super.onNewIntent(data) + receiptIntent(data) + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_search, menu) + precisionSearchMenuItem = menu.findItem(R.id.menu_precision_search) + precisionSearchMenuItem?.isChecked = getPrefBoolean(PreferKey.precisionSearch) + this.menu = menu + upGroupMenu() + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_precision_search -> { + putPrefBoolean( + PreferKey.precisionSearch, + !getPrefBoolean(PreferKey.precisionSearch) + ) + precisionSearchMenuItem?.isChecked = getPrefBoolean(PreferKey.precisionSearch) + searchView.query?.toString()?.trim()?.let { + searchView.setQuery(it, true) + } + } + R.id.menu_source_manage -> startActivity() + else -> if (item.groupId == R.id.source_group) { + item.isChecked = true + if (item.title.toString() == getString(R.string.all_source)) { + putPrefString("searchGroup", "") + } else { + putPrefString("searchGroup", item.title.toString()) + } + searchView.query?.toString()?.trim()?.let { + searchView.setQuery(it, true) + } + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initSearchView() { + searchView.applyTint(primaryTextColor) + searchView.onActionViewExpanded() + searchView.isSubmitButtonEnabled = true + searchView.queryHint = getString(R.string.search_book_key) + searchView.clearFocus() + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + searchView.clearFocus() + query?.let { + viewModel.saveSearchKey(query) + viewModel.search(it) + } + openOrCloseHistory(false) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (newText.isNullOrBlank()) viewModel.stop() + upHistory(newText) + return false + } + }) + searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus && searchView.query.toString().trim().isEmpty()) { + finish() + } else { + openOrCloseHistory(hasFocus) + } + } + openOrCloseHistory(true) + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.rvBookshelfSearch.setEdgeEffectColor(primaryColor) + binding.rvHistoryKey.setEdgeEffectColor(primaryColor) + binding.rvBookshelfSearch.layoutManager = FlexboxLayoutManager(this) + binding.rvBookshelfSearch.adapter = bookAdapter + binding.rvHistoryKey.layoutManager = FlexboxLayoutManager(this) + binding.rvHistoryKey.adapter = historyKeyAdapter + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = adapter + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + if (positionStart == 0) { + binding.recyclerView.scrollToPosition(0) + } + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + super.onItemRangeMoved(fromPosition, toPosition, itemCount) + if (toPosition == 0) { + binding.recyclerView.scrollToPosition(0) + } + } + }) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!recyclerView.canScrollVertically(1)) { + scrollToBottom() + } + } + }) + } + + private fun initOtherView() { + binding.fbStop.backgroundTintList = + Selector.colorBuild() + .setDefaultColor(accentColor) + .setPressedColor(ColorUtils.darkenColor(accentColor)) + .create() + binding.fbStop.setOnClickListener { + viewModel.stop() + binding.refreshProgressBar.isAutoLoading = false + } + binding.tvClearHistory.setOnClickListener { viewModel.clearHistory() } + } + + private fun initData() { + launch { + appDb.bookSourceDao.flowGroupEnabled().collect { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupMenu() + } + } + viewModel.searchBookLiveData.observe(this, { + upSearchItems(it) + }) + viewModel.isSearchLiveData.observe(this, { + if (it) { + startSearch() + } else { + searchFinally() + } + }) + } + + private fun receiptIntent(intent: Intent? = null) { + val key = intent?.getStringExtra("key") + if (key.isNullOrBlank()) { + searchView.findViewById(androidx.appcompat.R.id.search_src_text) + .requestFocus() + } else { + searchView.setQuery(key, true) + } + } + + /** + * 滚动到底部事件 + */ + private fun scrollToBottom() { + if (!viewModel.isLoading && viewModel.searchKey.isNotEmpty() && loadMoreView.hasMore) { + viewModel.search("") + } + } + + /** + * 打开关闭历史界面 + */ + private fun openOrCloseHistory(open: Boolean) { + if (open) { + upHistory(searchView.query.toString()) + binding.llHistory.visibility = VISIBLE + } else { + binding.llHistory.visibility = GONE + } + } + + /** + * 更新分组菜单 + */ + private fun upGroupMenu() = menu?.let { menu -> + val selectedGroup = getPrefString("searchGroup") + menu.removeGroup(R.id.source_group) + val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source) + var hasSelectedGroup = false + groups.sortedWith { o1, o2 -> + o1.cnCompare(o2) + }.forEach { group -> + menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let { + if (group == selectedGroup) { + it.isChecked = true + hasSelectedGroup = true + } + } + } + menu.setGroupCheckable(R.id.source_group, true, true) + if (!hasSelectedGroup) { + allItem.isChecked = true + } + } + + /** + * 更新搜索历史 + */ + private fun upHistory(key: String? = null) { + booksFlowJob?.cancel() + booksFlowJob = launch { + if (key.isNullOrBlank()) { + binding.tvBookShow.gone() + binding.rvBookshelfSearch.gone() + } else { + val bookFlow = appDb.bookDao.flowSearch(key) + bookFlow.collect { + if (it.isEmpty()) { + binding.tvBookShow.gone() + binding.rvBookshelfSearch.gone() + } else { + binding.tvBookShow.visible() + binding.rvBookshelfSearch.visible() + } + bookAdapter.setItems(it) + } + } + } + historyFlowJob?.cancel() + historyFlowJob = launch { + when { + key.isNullOrBlank() -> appDb.searchKeywordDao.flowByUsage() + else -> appDb.searchKeywordDao.flowSearch(key) + }.collect { + historyKeyAdapter.setItems(it) + if (it.isEmpty()) { + binding.tvClearHistory.invisible() + } else { + binding.tvClearHistory.visible() + } + } + } + } + + /** + * 更新搜索结果 + */ + private fun upSearchItems(items: List) { + adapter.setItems(items) + } + + /** + * 开始搜索 + */ + private fun startSearch() { + binding.refreshProgressBar.isAutoLoading = true + binding.fbStop.visible() + } + + /** + * 搜索结束 + */ + private fun searchFinally() { + binding.refreshProgressBar.isAutoLoading = false + loadMoreView.startLoad() + binding.fbStop.invisible() + } + + /** + * 显示书籍详情 + */ + override fun showBookInfo(name: String, author: String) { + startActivity { + putExtra("name", name) + putExtra("author", author) + } + } + + /** + * 显示书籍详情 + */ + override fun showBookInfo(book: Book) { + showBookInfo(book.name, book.author) + } + + /** + * 点击历史关键字 + */ + override fun searchHistory(key: String) { + launch { + when { + searchView.query.toString() == key -> { + searchView.setQuery(key, true) + } + withContext(IO) { appDb.bookDao.findByName(key).isEmpty() } -> { + searchView.setQuery(key, true) + } + else -> { + searchView.setQuery(key, false) + } + } + } + } + + override fun deleteHistory(searchKeyword: SearchKeyword) { + viewModel.deleteHistory(searchKeyword) + } + + companion object { + + fun start(context: Context, key: String?) { + context.startActivity { + putExtra("key", key) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt new file mode 100644 index 000000000..469b71ada --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt @@ -0,0 +1,127 @@ +package io.legado.app.ui.book.search + +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.R +import io.legado.app.base.adapter.DiffRecyclerAdapter +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.data.entities.SearchBook +import io.legado.app.databinding.ItemSearchBinding +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +class SearchAdapter(context: Context, val callBack: CallBack) : + DiffRecyclerAdapter(context) { + + override val diffItemCallback: DiffUtil.ItemCallback + get() = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { + return when { + oldItem.name != newItem.name -> false + oldItem.author != newItem.author -> false + else -> true + } + } + + override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { + return false + } + + override fun getChangePayload(oldItem: SearchBook, newItem: SearchBook): Any { + val payload = Bundle() + payload.putInt("origins", newItem.origins.size) + if (oldItem.coverUrl != newItem.coverUrl) + payload.putString("cover", newItem.coverUrl) + if (oldItem.kind != newItem.kind) + payload.putString("kind", newItem.kind) + if (oldItem.latestChapterTitle != newItem.latestChapterTitle) + payload.putString("last", newItem.latestChapterTitle) + if (oldItem.intro != newItem.intro) + payload.putString("intro", newItem.intro) + return payload + } + + } + + override fun getViewBinding(parent: ViewGroup): ItemSearchBinding { + return ItemSearchBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemSearchBinding, + item: SearchBook, + payloads: MutableList + ) { + val bundle = payloads.getOrNull(0) as? Bundle + if (bundle == null) { + bind(binding, item) + } else { + bindChange(binding, item, bundle) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) { + binding.root.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.showBookInfo(it.name, it.author) + } + } + } + + private fun bind(binding: ItemSearchBinding, searchBook: SearchBook) { + binding.run { + tvName.text = searchBook.name + tvAuthor.text = context.getString(R.string.author_show, searchBook.author) + bvOriginCount.setBadgeCount(searchBook.origins.size) + upLasted(binding, searchBook.latestChapterTitle) + tvIntroduce.text = searchBook.trimIntro(context) + upKind(binding, searchBook.getKindList()) + ivCover.load(searchBook.coverUrl, searchBook.name, searchBook.author) + + } + } + + private fun bindChange(binding: ItemSearchBinding, searchBook: SearchBook, bundle: Bundle) { + binding.run { + bundle.keySet().map { + when (it) { + "origins" -> bvOriginCount.setBadgeCount(searchBook.origins.size) + "last" -> upLasted(binding, searchBook.latestChapterTitle) + "intro" -> tvIntroduce.text = searchBook.trimIntro(context) + "kind" -> upKind(binding, searchBook.getKindList()) + "cover" -> ivCover.load(searchBook.coverUrl, searchBook.name, searchBook.author) + } + } + } + } + + private fun upLasted(binding: ItemSearchBinding, latestChapterTitle: String?) { + binding.run { + if (latestChapterTitle.isNullOrEmpty()) { + tvLasted.gone() + } else { + tvLasted.text = + context.getString(R.string.lasted_show, latestChapterTitle) + tvLasted.visible() + } + } + } + + private fun upKind(binding: ItemSearchBinding, kinds: List) = binding.run { + if (kinds.isEmpty()) { + llKind.gone() + } else { + llKind.visible() + llKind.setLabels(kinds) + } + } + + interface CallBack { + fun showBookInfo(name: String, author: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt new file mode 100644 index 000000000..0c706734a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -0,0 +1,116 @@ +package io.legado.app.ui.book.search + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.model.webBook.SearchModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SearchViewModel(application: Application) : BaseViewModel(application), SearchModel.CallBack { + private val searchModel = SearchModel(viewModelScope, this) + private var upAdapterJob: Job? = null + var isSearchLiveData = MutableLiveData() + var searchBookLiveData = MutableLiveData>() + var searchKey: String = "" + var isLoading = false + private var searchBooks = arrayListOf() + private var searchID = 0L + private var postTime = 0L + + /** + * 开始搜索 + */ + fun search(key: String) { + if ((searchKey == key) || key.isNotEmpty()) { + searchModel.cancelSearch() + searchBooks.clear() + searchBookLiveData.postValue(searchBooks) + searchID = System.currentTimeMillis() + searchKey = key + } + if (searchKey.isEmpty()) { + return + } + searchModel.search(searchID, searchKey) + } + + @Synchronized + private fun upAdapter() { + upAdapterJob?.cancel() + if (System.currentTimeMillis() >= postTime + 1000) { + postTime = System.currentTimeMillis() + searchBookLiveData.postValue(searchBooks) + } else { + upAdapterJob = viewModelScope.launch { + delay(1000) + upAdapter() + } + } + } + + override fun onSearchStart() { + isSearchLiveData.postValue(true) + isLoading = true + } + + @Synchronized + override fun onSearchSuccess(searchBooks: ArrayList) { + this.searchBooks = searchBooks + upAdapter() + } + + override fun onSearchFinish() { + isSearchLiveData.postValue(false) + isLoading = false + } + + override fun onSearchCancel() { + isSearchLiveData.postValue(false) + isLoading = false + } + + + /** + * 停止搜索 + */ + fun stop() { + searchModel.cancelSearch() + } + + /** + * 保存搜索关键字 + */ + fun saveSearchKey(key: String) { + execute { + appDb.searchKeywordDao.get(key)?.let { + it.usage = it.usage + 1 + appDb.searchKeywordDao.update(it) + } ?: appDb.searchKeywordDao.insert(SearchKeyword(key, 1)) + } + } + + /** + * 清楚搜索关键字 + */ + fun clearHistory() { + execute { + appDb.searchKeywordDao.deleteAll() + } + } + + fun deleteHistory(searchKeyword: SearchKeyword) { + appDb.searchKeywordDao.delete(searchKeyword) + } + + override fun onCleared() { + super.onCleared() + searchModel.close() + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt new file mode 100644 index 000000000..274cc4fd3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt @@ -0,0 +1,181 @@ +package io.legado.app.ui.book.searchContent + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.databinding.ActivitySearchContentBinding +import io.legado.app.help.BookHelp +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.widget.recycler.UpLinearLayoutManager +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.applyTint +import io.legado.app.utils.observeEvent +import io.legado.app.utils.postEvent +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +class SearchContentActivity : + VMBaseActivity(), + SearchContentAdapter.Callback { + + override val binding by viewBinding(ActivitySearchContentBinding::inflate) + override val viewModel by viewModels() + private val adapter by lazy { SearchContentAdapter(this, this) } + private val mLayoutManager by lazy { UpLinearLayoutManager(this) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private var durChapterIndex = 0 + + override fun onActivityCreated(savedInstanceState: Bundle?) { + val bbg = bottomBackground + val btc = getPrimaryTextColor(ColorUtils.isColorLight(bbg)) + binding.llSearchBaseInfo.setBackgroundColor(bbg) + binding.tvCurrentSearchInfo.setTextColor(btc) + binding.ivSearchContentTop.setColorFilter(btc) + binding.ivSearchContentBottom.setColorFilter(btc) + initSearchView() + initRecyclerView() + initView() + intent.getStringExtra("bookUrl")?.let { + viewModel.initBook(it) { + initBook() + } + } + } + + private fun initSearchView() { + searchView.applyTint(primaryTextColor) + searchView.onActionViewExpanded() + searchView.isSubmitButtonEnabled = true + searchView.queryHint = getString(R.string.search) + searchView.clearFocus() + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + if (viewModel.lastQuery != query) { + startContentSearch(query) + } + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + }) + } + + private fun initRecyclerView() { + binding.recyclerView.layoutManager = mLayoutManager + binding.recyclerView.addItemDecoration(VerticalDivider(this)) + binding.recyclerView.adapter = adapter + } + + private fun initView() { + binding.ivSearchContentTop.setOnClickListener { + mLayoutManager.scrollToPositionWithOffset( + 0, + 0 + ) + } + binding.ivSearchContentBottom.setOnClickListener { + if (adapter.itemCount > 0) { + mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) + } + } + } + + @SuppressLint("SetTextI18n") + private fun initBook() { + binding.tvCurrentSearchInfo.text = this.getString(R.string.search_content_size) +": ${viewModel.searchResultCounts}" + viewModel.book?.let { + initCacheFileNames(it) + durChapterIndex = it.durChapterIndex + intent.getStringExtra("searchWord")?.let { searchWord -> + searchView.setQuery(searchWord, true) + } + } + } + + private fun initCacheFileNames(book: Book) { + launch(Dispatchers.IO) { + viewModel.cacheChapterNames.addAll(BookHelp.getChapterFiles(book)) + withContext(Dispatchers.Main) { + adapter.notifyItemRangeChanged(0, adapter.itemCount, true) + } + } + } + + override fun observeLiveBus() { + observeEvent(EventBus.SAVE_CONTENT) { chapter -> + viewModel.book?.bookUrl?.let { bookUrl -> + if (chapter.bookUrl == bookUrl) { + viewModel.cacheChapterNames.add(chapter.getFileName()) + adapter.notifyItemChanged(chapter.index, true) + } + } + } + } + + @SuppressLint("SetTextI18n") + fun startContentSearch(query: String) { + // 按章节搜索内容 + if (query.isNotBlank()) { + adapter.clearItems() + viewModel.searchResultList.clear() + viewModel.searchResultCounts = 0 + viewModel.lastQuery = query + var searchResults = listOf() + launch(Dispatchers.Main) { + appDb.bookChapterDao.getChapterList(viewModel.bookUrl).map { bookChapter -> + binding.refreshProgressBar.isAutoLoading = true + withContext(Dispatchers.IO) { + if (isLocalBook || viewModel.cacheChapterNames.contains(bookChapter.getFileName())) { + searchResults = viewModel.searchChapter(query, bookChapter) + } + } + if (searchResults.isNotEmpty()) { + viewModel.searchResultList.addAll(searchResults) + binding.refreshProgressBar.isAutoLoading = false + binding.tvCurrentSearchInfo.text = this@SearchContentActivity.getString(R.string.search_content_size) +": ${viewModel.searchResultCounts}" + adapter.addItems(searchResults) + searchResults = listOf() + } + } + } + } + } + + val isLocalBook: Boolean + get() = viewModel.book?.isLocalBook() == true + + override fun openSearchResult(searchResult: SearchResult) { + postEvent(EventBus.SEARCH_RESULT, viewModel.searchResultList as List) + val searchData = Intent() + searchData.putExtra("searchResultIndex", viewModel.searchResultList.indexOf(searchResult)) + searchData.putExtra("chapterIndex", searchResult.chapterIndex) + searchData.putExtra("contentPosition", searchResult.queryIndexInChapter) + searchData.putExtra("query", searchResult.query) + searchData.putExtra("resultCountWithinChapter", searchResult.resultCountWithinChapter) + setResult(RESULT_OK, searchData) + finish() + } + + override fun durChapterIndex(): Int { + return durChapterIndex + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentAdapter.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentAdapter.kt new file mode 100644 index 000000000..364e7daaf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentAdapter.kt @@ -0,0 +1,52 @@ +package io.legado.app.ui.book.searchContent + +import android.content.Context +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemSearchListBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.hexString + + +class SearchContentAdapter(context: Context, val callback: Callback) : + RecyclerAdapter(context) { + + val cacheFileNames = hashSetOf() + val textColor = context.getCompatColor(R.color.primaryText).hexString.substring(2) + val accentColor = context.accentColor.hexString.substring(2) + + override fun getViewBinding(parent: ViewGroup): ItemSearchListBinding { + return ItemSearchListBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemSearchListBinding, + item: SearchResult, + payloads: MutableList + ) { + binding.run { + val isDur = callback.durChapterIndex() == item.chapterIndex + if (payloads.isEmpty()) { + tvSearchResult.text = item.getHtmlCompat(textColor, accentColor) + tvSearchResult.paint.isFakeBoldText = isDur + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemSearchListBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callback.openSearchResult(it) + } + } + } + + interface Callback { + fun openSearchResult(searchResult: SearchResult) + fun durChapterIndex(): Int + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt new file mode 100644 index 000000000..6489e2852 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt @@ -0,0 +1,106 @@ +package io.legado.app.ui.book.searchContent + + +import android.app.Application +import com.github.liuyueyi.quick.transfer.ChineseUtils +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.help.ContentProcessor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SearchContentViewModel(application: Application) : BaseViewModel(application) { + var bookUrl: String = "" + var book: Book? = null + private var contentProcessor: ContentProcessor? = null + var lastQuery: String = "" + var searchResultCounts = 0 + val cacheChapterNames = hashSetOf() + val searchResultList: MutableList = mutableListOf() + var selectedIndex = 0 + + fun initBook(bookUrl: String, success: () -> Unit) { + this.bookUrl = bookUrl + execute { + book = appDb.bookDao.getBook(bookUrl) + book?.let { + contentProcessor = ContentProcessor.get(it.name, it.origin) + } + }.onSuccess { + success.invoke() + } + } + + suspend fun searchChapter(query: String, chapter: BookChapter?): List { + val searchResultsWithinChapter: MutableList = mutableListOf() + if (chapter != null) { + book?.let { book -> + val chapterContent = BookHelp.getContent(book, chapter) + if (chapterContent != null) { + //搜索替换后的正文 + val replaceContent: String + withContext(Dispatchers.IO) { + chapter.title = when (AppConfig.chineseConverterType) { + 1 -> ChineseUtils.t2s(chapter.title) + 2 -> ChineseUtils.s2t(chapter.title) + else -> chapter.title + } + replaceContent = contentProcessor!!.getContent( + book, chapter, chapterContent, chineseConvert = false, reSegment = false + ).joinToString("") + } + val positions = searchPosition(replaceContent, query) + positions.forEachIndexed { index, position -> + val construct = getResultAndQueryIndex(replaceContent, position, query) + val result = SearchResult( + resultCountWithinChapter = index, + resultText = construct.second, + chapterTitle = chapter.title, + query = query, + chapterIndex = chapter.index, + queryIndexInResult = construct.first, + queryIndexInChapter = position + ) + searchResultsWithinChapter.add(result) + } + searchResultCounts += searchResultsWithinChapter.size + } + } + } + return searchResultsWithinChapter + } + + private fun searchPosition(chapterContent: String, pattern: String): List { + val position: MutableList = mutableListOf() + var index = chapterContent.indexOf(pattern) + while (index >= 0) { + position.add(index) + index = chapterContent.indexOf(pattern, index + 1) + } + return position + } + + private fun getResultAndQueryIndex(content: String, queryIndexInContent: Int, query: String): Pair { + // 左右移动20个字符,构建关键词周边文字,在搜索结果里显示 + // todo: 判断段落,只在关键词所在段落内分割 + // todo: 利用标点符号分割完整的句 + // todo: length和设置结合,自由调整周边文字长度 + val length = 20 + var po1 = queryIndexInContent - length + var po2 = queryIndexInContent + query.length + length + if (po1 < 0) { + po1 = 0 + } + if (po2 > content.length) { + po2 = content.length + } + val queryIndexInResult = queryIndexInContent - po1 + val newText = content.substring(po1, po2) + return queryIndexInResult to newText + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt new file mode 100644 index 000000000..a3b3631be --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt @@ -0,0 +1,32 @@ +package io.legado.app.ui.book.searchContent + +import android.text.Spanned +import androidx.core.text.HtmlCompat + +data class SearchResult( + val resultCount: Int = 0, + val resultCountWithinChapter: Int = 0, + val resultText: String = "", + val chapterTitle: String = "", + val query: String, + val pageSize: Int = 0, + val chapterIndex: Int = 0, + val pageIndex: Int = 0, + val queryIndexInResult: Int = 0, + val queryIndexInChapter: Int = 0 +) { + + fun getHtmlCompat(textColor: String, accentColor: String): Spanned { + val queryIndexInSurrounding = resultText.indexOf(query) + val leftString = resultText.substring(0, queryIndexInSurrounding) + val rightString = resultText.substring(queryIndexInSurrounding + query.length, resultText.length) + val html = leftString.colorTextForHtml(textColor) + + query.colorTextForHtml(accentColor) + + rightString.colorTextForHtml(textColor) + + chapterTitle.colorTextForHtml(accentColor) + return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + } + + private fun String.colorTextForHtml(textColor: String) = "$this" + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt new file mode 100644 index 000000000..dac04ed2d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt @@ -0,0 +1,156 @@ +package io.legado.app.ui.book.source.debug + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivitySourceDebugBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.qrcode.QrCodeResult +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.launch +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.launch +import splitties.views.onClick + +class BookSourceDebugActivity : VMBaseActivity() { + + override val binding by viewBinding(ActivitySourceDebugBinding::inflate) + override val viewModel by viewModels() + + private val adapter by lazy { BookSourceDebugAdapter(this) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private val qrCodeResult = registerForActivityResult(QrCodeResult()) { + it?.let { + startSearch(it) + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initRecyclerView() + initSearchView() + viewModel.init(intent.getStringExtra("key")) { + initHelpView() + } + viewModel.observe { state, msg -> + launch { + adapter.addItem(msg) + if (state == -1 || state == 1000) { + binding.rotateLoading.hide() + } + } + } + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.adapter = adapter + binding.rotateLoading.loadingColor = accentColor + } + + private fun initSearchView() { + searchView.onActionViewExpanded() + searchView.isSubmitButtonEnabled = true + searchView.queryHint = getString(R.string.search_book_key) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + searchView.clearFocus() + openOrCloseHelp(false) + startSearch(query ?: "我的") + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + }) + searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> + openOrCloseHelp(hasFocus) + } + openOrCloseHelp(true) + } + + @SuppressLint("SetTextI18n") + private fun initHelpView() { + viewModel.bookSource?.ruleSearch?.checkKeyWord?.let { + if (it.isNotBlank()) { + binding.textMy.text = it + } + } + viewModel.bookSource?.exploreKinds?.firstOrNull { + !it.url.isNullOrBlank() + }?.let { + binding.textFx.text = "${it.title}::${it.url}" + if (it.title.startsWith("ERROR:")) { + adapter.addItem("获取发现出错\n${it.url}") + openOrCloseHelp(false) + searchView.clearFocus() + } + } + binding.textMy.onClick { + searchView.setQuery(binding.textMy.text, true) + } + binding.textXt.onClick { + searchView.setQuery(binding.textXt.text, true) + } + binding.textFx.onClick { + if (!binding.textFx.text.startsWith("ERROR:")) { + searchView.setQuery(binding.textFx.text, true) + } + } + } + + /** + * 打开关闭历史界面 + */ + private fun openOrCloseHelp(open: Boolean) { + if (open) { + binding.help.visibility = View.VISIBLE + } else { + binding.help.visibility = View.GONE + } + } + + private fun startSearch(key: String) { + adapter.clearItems() + viewModel.startDebug(key, { + binding.rotateLoading.show() + }, { + toastOnUi("未获取到书源") + }) + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_source_debug, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_scan -> qrCodeResult.launch() + R.id.menu_search_src -> showDialogFragment(TextDialog(viewModel.searchSrc)) + R.id.menu_book_src -> showDialogFragment(TextDialog(viewModel.bookSrc)) + R.id.menu_toc_src -> showDialogFragment(TextDialog(viewModel.tocSrc)) + R.id.menu_content_src -> showDialogFragment(TextDialog(viewModel.contentSrc)) + R.id.menu_help -> showHelp() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun showHelp() { + val text = String(assets.open("help/debugHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt new file mode 100644 index 000000000..dd93b0a59 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt @@ -0,0 +1,44 @@ +package io.legado.app.ui.book.source.debug + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemLogBinding + +class BookSourceDebugAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemLogBinding { + return ItemLogBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemLogBinding, + item: String, + payloads: MutableList + ) { + binding.apply { + if (textView.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + textView.isCursorVisible = false + textView.isCursorVisible = true + } + + override fun onViewDetachedFromWindow(v: View) {} + } + textView.addOnAttachStateChangeListener(listener) + textView.setTag(R.id.tag1, listener) + } + textView.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) { + //nothing + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt new file mode 100644 index 000000000..b7efa3029 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt @@ -0,0 +1,60 @@ +package io.legado.app.ui.book.source.debug + +import android.app.Application +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.model.Debug + +class BookSourceDebugModel(application: Application) : BaseViewModel(application), + Debug.Callback { + + var bookSource: BookSource? = null + private var callback: ((Int, String) -> Unit)? = null + var searchSrc: String? = null + var bookSrc: String? = null + var tocSrc: String? = null + var contentSrc: String? = null + + fun init(sourceUrl: String?, finally: () -> Unit) { + sourceUrl?.let { + //优先使用这个,不会抛出异常 + execute { + bookSource = appDb.bookSourceDao.getBookSource(sourceUrl) + }.onFinally { + finally.invoke() + } + } + } + + fun observe(callback: (Int, String) -> Unit) { + this.callback = callback + } + + fun startDebug(key: String, start: (() -> Unit)? = null, error: (() -> Unit)? = null) { + execute { + Debug.callback = this@BookSourceDebugModel + Debug.startDebug(this, bookSource!!, key) + }.onStart { + start?.invoke() + }.onError { + error?.invoke() + } + } + + override fun printLog(state: Int, msg: String) { + when (state) { + 10 -> searchSrc = msg + 20 -> bookSrc = msg + 30 -> tocSrc = msg + 40 -> contentSrc = msg + else -> callback?.invoke(state, msg) + } + } + + override fun onCleared() { + super.onCleared() + Debug.cancelDebug(true) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt new file mode 100644 index 000000000..34f4cf915 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt @@ -0,0 +1,477 @@ +package io.legado.app.ui.book.source.edit + +import android.app.Activity +import android.graphics.Rect +import android.os.Bundle +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.ViewTreeObserver +import android.widget.EditText +import android.widget.PopupWindow +import androidx.activity.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.tabs.TabLayout +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.* +import io.legado.app.databinding.ActivityBookSourceEditBinding +import io.legado.app.help.LocalConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.source.debug.BookSourceDebugActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.qrcode.QrCodeResult +import io.legado.app.ui.widget.KeyboardToolPop +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlin.math.abs + +class BookSourceEditActivity : + VMBaseActivity(false), + KeyboardToolPop.CallBack { + + override val binding by viewBinding(ActivityBookSourceEditBinding::inflate) + override val viewModel by viewModels() + + private val adapter by lazy { BookSourceEditAdapter() } + private val sourceEntities: ArrayList = ArrayList() + private val searchEntities: ArrayList = ArrayList() + private val findEntities: ArrayList = ArrayList() + private val infoEntities: ArrayList = ArrayList() + private val tocEntities: ArrayList = ArrayList() + private val contentEntities: ArrayList = ArrayList() + private val qrCodeResult = registerForActivityResult(QrCodeResult()) { + it ?: return@registerForActivityResult + viewModel.importSource(it) { source -> + upRecyclerView(source) + } + } + private val selectDoc = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + if (uri.isContentScheme()) { + sendText(uri.toString()) + } else { + sendText(uri.path.toString()) + } + } + } + + private var mSoftKeyboardTool: PopupWindow? = null + private var mIsSoftKeyBoardShowing = false + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + viewModel.initData(intent) { + upRecyclerView() + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + if (!LocalConfig.ruleHelpVersionIsLast) { + showRuleHelp() + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.source_edit, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.findItem(R.id.menu_login)?.isVisible = !viewModel.bookSource?.loginUrl.isNullOrBlank() + return super.onMenuOpened(featureId, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> getSource().let { source -> + if (!source.equal(viewModel.bookSource ?: BookSource())) { + source.lastUpdateTime = System.currentTimeMillis() + } + if (checkSource(source)) { + viewModel.save(source) { setResult(Activity.RESULT_OK); finish() } + } + } + R.id.menu_debug_source -> getSource().let { source -> + if (checkSource(source)) { + viewModel.save(source) { + startActivity { + putExtra("key", source.bookSourceUrl) + } + } + } + } + R.id.menu_copy_source -> sendToClip(GSON.toJson(getSource())) + R.id.menu_paste_source -> viewModel.pasteSource { upRecyclerView(it) } + R.id.menu_qr_code_camera -> qrCodeResult.launch() + R.id.menu_share_str -> share(GSON.toJson(getSource())) + R.id.menu_share_qr -> shareWithQr( + GSON.toJson(getSource()), + getString(R.string.share_book_source), + ErrorCorrectionLevel.L + ) + R.id.menu_help -> showRuleHelp() + R.id.menu_login -> getSource().let { source -> + if (checkSource(source)) { + viewModel.save(source) { + startActivity { + putExtra("type", "bookSource") + putExtra("key", source.bookSourceUrl) + } + } + } + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + mSoftKeyboardTool = KeyboardToolPop(this, AppConst.keyboardToolChars, this) + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(KeyboardOnGlobalChangeListener()) + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = adapter + binding.tabLayout.setBackgroundColor(backgroundColor) + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) { + + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + + } + + override fun onTabSelected(tab: TabLayout.Tab?) { + setEditEntities(tab?.position) + } + }) + } + + override fun finish() { + val source = getSource() + if (!source.equal(viewModel.bookSource ?: BookSource())) { + alert(R.string.exit) { + setMessage(R.string.exit_no_save) + positiveButton(R.string.yes) + negativeButton(R.string.no) { + super.finish() + } + } + } else { + super.finish() + } + } + + override fun onDestroy() { + super.onDestroy() + mSoftKeyboardTool?.dismiss() + } + + private fun setEditEntities(tabPosition: Int?) { + when (tabPosition) { + 1 -> adapter.editEntities = searchEntities + 2 -> adapter.editEntities = findEntities + 3 -> adapter.editEntities = infoEntities + 4 -> adapter.editEntities = tocEntities + 5 -> adapter.editEntities = contentEntities + else -> adapter.editEntities = sourceEntities + } + binding.recyclerView.scrollToPosition(0) + } + + private fun upRecyclerView(source: BookSource? = viewModel.bookSource) { + source?.let { + binding.cbIsEnable.isChecked = it.enabled + binding.cbIsEnableFind.isChecked = it.enabledExplore + binding.spType.setSelection(it.bookSourceType) + } + //基本信息 + sourceEntities.clear() + sourceEntities.apply { + add(EditEntity("bookSourceUrl", source?.bookSourceUrl, R.string.source_url)) + add(EditEntity("bookSourceName", source?.bookSourceName, R.string.source_name)) + add(EditEntity("bookSourceGroup", source?.bookSourceGroup, R.string.source_group)) + add(EditEntity("bookSourceComment", source?.bookSourceComment, R.string.comment)) + add(EditEntity("loginUrl", source?.loginUrl, R.string.login_url)) + add(EditEntity("loginUi", source?.loginUi, R.string.login_ui)) + add(EditEntity("loginCheckJs", source?.loginCheckJs, R.string.login_check_js)) + add(EditEntity("bookUrlPattern", source?.bookUrlPattern, R.string.book_url_pattern)) + add(EditEntity("header", source?.header, R.string.source_http_header)) + add( + EditEntity( + "concurrentRate", source?.concurrentRate, R.string.source_concurrent_rate + ) + ) + } + //搜索 + val sr = source?.getSearchRule() + searchEntities.clear() + searchEntities.apply { + add(EditEntity("searchUrl", source?.searchUrl, R.string.r_search_url)) + add(EditEntity("checkKeyWord", sr?.checkKeyWord, R.string.check_key_word)) + add(EditEntity("bookList", sr?.bookList, R.string.r_book_list)) + add(EditEntity("name", sr?.name, R.string.r_book_name)) + add(EditEntity("author", sr?.author, R.string.r_author)) + add(EditEntity("kind", sr?.kind, R.string.rule_book_kind)) + add(EditEntity("wordCount", sr?.wordCount, R.string.rule_word_count)) + add(EditEntity("lastChapter", sr?.lastChapter, R.string.rule_last_chapter)) + add(EditEntity("intro", sr?.intro, R.string.rule_book_intro)) + add(EditEntity("coverUrl", sr?.coverUrl, R.string.rule_cover_url)) + add(EditEntity("bookUrl", sr?.bookUrl, R.string.r_book_url)) + } + //发现 + val er = source?.getExploreRule() + findEntities.clear() + findEntities.apply { + add(EditEntity("exploreUrl", source?.exploreUrl, R.string.r_find_url)) + add(EditEntity("bookList", er?.bookList, R.string.r_book_list)) + add(EditEntity("name", er?.name, R.string.r_book_name)) + add(EditEntity("author", er?.author, R.string.r_author)) + add(EditEntity("kind", er?.kind, R.string.rule_book_kind)) + add(EditEntity("wordCount", er?.wordCount, R.string.rule_word_count)) + add(EditEntity("lastChapter", er?.lastChapter, R.string.rule_last_chapter)) + add(EditEntity("intro", er?.intro, R.string.rule_book_intro)) + add(EditEntity("coverUrl", er?.coverUrl, R.string.rule_cover_url)) + add(EditEntity("bookUrl", er?.bookUrl, R.string.r_book_url)) + } + //详情页 + val ir = source?.getBookInfoRule() + infoEntities.clear() + infoEntities.apply { + add(EditEntity("init", ir?.init, R.string.rule_book_info_init)) + add(EditEntity("name", ir?.name, R.string.r_book_name)) + add(EditEntity("author", ir?.author, R.string.r_author)) + add(EditEntity("kind", ir?.kind, R.string.rule_book_kind)) + add(EditEntity("wordCount", ir?.wordCount, R.string.rule_word_count)) + add(EditEntity("lastChapter", ir?.lastChapter, R.string.rule_last_chapter)) + add(EditEntity("intro", ir?.intro, R.string.rule_book_intro)) + add(EditEntity("coverUrl", ir?.coverUrl, R.string.rule_cover_url)) + add(EditEntity("tocUrl", ir?.tocUrl, R.string.rule_toc_url)) + add(EditEntity("canReName", ir?.canReName, R.string.rule_can_re_name)) + } + //目录页 + val tr = source?.getTocRule() + tocEntities.clear() + tocEntities.apply { + add(EditEntity("chapterList", tr?.chapterList, R.string.rule_chapter_list)) + add(EditEntity("chapterName", tr?.chapterName, R.string.rule_chapter_name)) + add(EditEntity("chapterUrl", tr?.chapterUrl, R.string.rule_chapter_url)) + add(EditEntity("updateTime", tr?.updateTime, R.string.rule_update_time)) + add(EditEntity("isVip", tr?.isVip, R.string.rule_is_vip)) + add(EditEntity("isPay", tr?.isPay, R.string.rule_is_pay)) + add(EditEntity("nextTocUrl", tr?.nextTocUrl, R.string.rule_next_toc_url)) + } + //正文页 + val cr = source?.getContentRule() + contentEntities.clear() + contentEntities.apply { + add(EditEntity("content", cr?.content, R.string.rule_book_content)) + add(EditEntity("nextContentUrl", cr?.nextContentUrl, R.string.rule_next_content)) + add(EditEntity("webJs", cr?.webJs, R.string.rule_web_js)) + add(EditEntity("sourceRegex", cr?.sourceRegex, R.string.rule_source_regex)) + add(EditEntity("replaceRegex", cr?.replaceRegex, R.string.rule_replace_regex)) + add(EditEntity("imageStyle", cr?.imageStyle, R.string.rule_image_style)) + } + binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0)) + setEditEntities(0) + } + + private fun getSource(): BookSource { + val source = viewModel.bookSource?.copy() ?: BookSource() + source.enabled = binding.cbIsEnable.isChecked + source.enabledExplore = binding.cbIsEnableFind.isChecked + source.bookSourceType = binding.spType.selectedItemPosition + val searchRule = SearchRule() + val exploreRule = ExploreRule() + val bookInfoRule = BookInfoRule() + val tocRule = TocRule() + val contentRule = ContentRule() + sourceEntities.forEach { + when (it.key) { + "bookSourceUrl" -> source.bookSourceUrl = it.value ?: "" + "bookSourceName" -> source.bookSourceName = it.value ?: "" + "bookSourceGroup" -> source.bookSourceGroup = it.value + "loginUrl" -> source.loginUrl = it.value + "loginUi" -> source.loginUi = it.value + "loginCheckJs" -> source.loginCheckJs = it.value + "bookUrlPattern" -> source.bookUrlPattern = it.value + "header" -> source.header = it.value + "bookSourceComment" -> source.bookSourceComment = it.value ?: "" + "concurrentRate" -> source.concurrentRate = it.value + } + } + searchEntities.forEach { + when (it.key) { + "searchUrl" -> source.searchUrl = it.value + "checkKeyWord" -> searchRule.checkKeyWord = it.value + "bookList" -> searchRule.bookList = it.value + "name" -> searchRule.name = it.value + "author" -> searchRule.author = it.value + "kind" -> searchRule.kind = it.value + "intro" -> searchRule.intro = it.value + "updateTime" -> searchRule.updateTime = it.value + "wordCount" -> searchRule.wordCount = it.value + "lastChapter" -> searchRule.lastChapter = it.value + "coverUrl" -> searchRule.coverUrl = it.value + "bookUrl" -> searchRule.bookUrl = it.value + } + } + findEntities.forEach { + when (it.key) { + "exploreUrl" -> source.exploreUrl = it.value + "bookList" -> exploreRule.bookList = it.value + "name" -> exploreRule.name = it.value + "author" -> exploreRule.author = it.value + "kind" -> exploreRule.kind = it.value + "intro" -> exploreRule.intro = it.value + "updateTime" -> exploreRule.updateTime = it.value + "wordCount" -> exploreRule.wordCount = it.value + "lastChapter" -> exploreRule.lastChapter = it.value + "coverUrl" -> exploreRule.coverUrl = it.value + "bookUrl" -> exploreRule.bookUrl = it.value + } + } + infoEntities.forEach { + when (it.key) { + "init" -> bookInfoRule.init = it.value + "name" -> bookInfoRule.name = it.value + "author" -> bookInfoRule.author = it.value + "kind" -> bookInfoRule.kind = it.value + "intro" -> bookInfoRule.intro = it.value + "updateTime" -> bookInfoRule.updateTime = it.value + "wordCount" -> bookInfoRule.wordCount = it.value + "lastChapter" -> bookInfoRule.lastChapter = it.value + "coverUrl" -> bookInfoRule.coverUrl = it.value + "tocUrl" -> bookInfoRule.tocUrl = it.value + "canReName" -> bookInfoRule.canReName = it.value + } + } + tocEntities.forEach { + when (it.key) { + "chapterList" -> tocRule.chapterList = it.value + "chapterName" -> tocRule.chapterName = it.value + "chapterUrl" -> tocRule.chapterUrl = it.value + "updateTime" -> tocRule.updateTime = it.value + "isVip" -> tocRule.isVip = it.value + "isPay" -> tocRule.isPay = it.value + "nextTocUrl" -> tocRule.nextTocUrl = it.value + } + } + contentEntities.forEach { + when (it.key) { + "content" -> contentRule.content = it.value + "nextContentUrl" -> contentRule.nextContentUrl = it.value + "webJs" -> contentRule.webJs = it.value + "sourceRegex" -> contentRule.sourceRegex = it.value + "replaceRegex" -> contentRule.replaceRegex = it.value + "imageStyle" -> contentRule.imageStyle = it.value + } + } + source.ruleSearch = searchRule + source.ruleExplore = exploreRule + source.ruleBookInfo = bookInfoRule + source.ruleToc = tocRule + source.ruleContent = contentRule + return source + } + + private fun checkSource(source: BookSource): Boolean { + if (source.bookSourceUrl.isBlank() || source.bookSourceName.isBlank()) { + toastOnUi(R.string.non_null_name_url) + return false + } + return true + } + + private fun insertText(text: String) { + if (text.isBlank()) return + val view = window.decorView.findFocus() + if (view is EditText) { + val start = view.selectionStart + val end = view.selectionEnd + val edit = view.editableText//获取EditText的文字 + if (start < 0 || start >= edit.length) { + edit.append(text) + } else { + edit.replace(start, end, text)//光标所在位置插入文字 + } + } + } + + override fun sendText(text: String) { + if (text == AppConst.keyboardToolChars[0]) { + showHelpDialog() + } else { + insertText(text) + } + } + + private fun showHelpDialog() { + val items = arrayListOf("插入URL参数", "书源教程", "正则教程", "选择文件") + selector(getString(R.string.help), items) { _, index -> + when (index) { + 0 -> insertText(AppConst.urlOption) + 1 -> showRuleHelp() + 2 -> showRegexHelp() + 3 -> selectDoc.launch { + mode = HandleFileContract.FILE + } + } + } + } + + private fun showRuleHelp() { + val mdText = String(assets.open("help/ruleHelp.md").readBytes()) + showDialogFragment(TextDialog(mdText, TextDialog.Mode.MD)) + } + + private fun showRegexHelp() { + val mdText = String(assets.open("help/regexHelp.md").readBytes()) + showDialogFragment(TextDialog(mdText, TextDialog.Mode.MD)) + } + + private fun showKeyboardTopPopupWindow() { + mSoftKeyboardTool?.let { + if (it.isShowing) return + if (!isFinishing) { + it.showAtLocation(binding.root, Gravity.BOTTOM, 0, 0) + } + } + } + + private fun closePopupWindow() { + mSoftKeyboardTool?.dismiss() + } + + private inner class KeyboardOnGlobalChangeListener : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + val rect = Rect() + // 获取当前页面窗口的显示范围 + window.decorView.getWindowVisibleDisplayFrame(rect) + val screenHeight = this@BookSourceEditActivity.windowSize.heightPixels + val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 + val preShowing = mIsSoftKeyBoardShowing + if (abs(keyboardHeight) > screenHeight / 5) { + mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 + binding.recyclerView.setPadding(0, 0, 0, 100) + showKeyboardTopPopupWindow() + } else { + mIsSoftKeyBoardShowing = false + binding.recyclerView.setPadding(0, 0, 0, 0) + if (preShowing) { + closePopupWindow() + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt new file mode 100644 index 000000000..c8cc94d82 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt @@ -0,0 +1,92 @@ +package io.legado.app.ui.book.source.edit + +import android.annotation.SuppressLint +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.databinding.ItemSourceEditBinding +import io.legado.app.ui.widget.code.addJsPattern +import io.legado.app.ui.widget.code.addJsonPattern +import io.legado.app.ui.widget.code.addLegadoPattern + +class BookSourceEditAdapter : RecyclerView.Adapter() { + + var editEntities: ArrayList = ArrayList() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { + val binding = ItemSourceEditBinding + .inflate(LayoutInflater.from(parent.context), parent, false) + binding.editText.addLegadoPattern() + binding.editText.addJsonPattern() + binding.editText.addJsPattern() + return MyViewHolder(binding) + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + holder.bind(editEntities[position]) + } + + override fun getItemCount(): Int { + return editEntities.size + } + + class MyViewHolder(val binding: ItemSourceEditBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(editEntity: EditEntity) = binding.run { + if (editText.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + editText.isCursorVisible = false + editText.isCursorVisible = true + editText.isFocusable = true + editText.isFocusableInTouchMode = true + } + + override fun onViewDetachedFromWindow(v: View) { + + } + } + editText.addOnAttachStateChangeListener(listener) + editText.setTag(R.id.tag1, listener) + } + editText.getTag(R.id.tag2)?.let { + if (it is TextWatcher) { + editText.removeTextChangedListener(it) + } + } + editText.setText(editEntity.value) + textInputLayout.hint = itemView.context.getString(editEntity.hint) + val textWatcher = object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + + } + + override fun afterTextChanged(s: Editable?) { + editEntity.value = (s?.toString()) + } + } + editText.addTextChangedListener(textWatcher) + editText.setTag(R.id.tag2, textWatcher) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt new file mode 100644 index 000000000..02ed8b4a2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt @@ -0,0 +1,98 @@ +package io.legado.app.ui.book.source.edit + +import android.app.Application +import android.content.Intent +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.help.http.newCallStrResponse +import io.legado.app.help.http.okHttpClient +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers +import timber.log.Timber + +class BookSourceEditViewModel(application: Application) : BaseViewModel(application) { + + var bookSource: BookSource? = null + private var oldSourceUrl: String? = null + + fun initData(intent: Intent, onFinally: () -> Unit) { + execute { + val sourceUrl = intent.getStringExtra("sourceUrl") + var source: BookSource? = null + if (sourceUrl != null) { + source = appDb.bookSourceDao.getBookSource(sourceUrl) + } + source?.let { + oldSourceUrl = it.bookSourceUrl + bookSource = it + } + }.onFinally { + onFinally() + } + } + + fun save(source: BookSource, success: (() -> Unit)? = null) { + execute { + oldSourceUrl?.let { + if (oldSourceUrl != source.bookSourceUrl) { + appDb.bookSourceDao.delete(it) + } + } + oldSourceUrl = source.bookSourceUrl + source.lastUpdateTime = System.currentTimeMillis() + appDb.bookSourceDao.insert(source) + bookSource = source + }.onSuccess { + success?.invoke() + }.onError { + context.toastOnUi(it.localizedMessage) + Timber.e(it) + } + } + + fun pasteSource(onSuccess: (source: BookSource) -> Unit) { + execute(context = Dispatchers.Main) { + val text = context.getClipText() + if (text.isNullOrBlank()) { + throw NoStackTraceException("剪贴板为空") + } else { + importSource(text, onSuccess) + } + }.onError { + context.toastOnUi(it.localizedMessage ?: "Error") + Timber.e(it) + } + } + + fun importSource(text: String, finally: (source: BookSource) -> Unit) { + execute { + importSource(text) + }.onSuccess { + it?.let(finally) ?: context.toastOnUi("格式不对") + }.onError { + context.toastOnUi(it.localizedMessage ?: "Error") + } + } + + suspend fun importSource(text: String): BookSource? { + return when { + text.isAbsUrl() -> { + val text1 = okHttpClient.newCallStrResponse { url(text) }.body + text1?.let { importSource(text1) } + } + text.isJsonArray() -> { + val items: List> = jsonPath.parse(text).read("$") + val jsonItem = jsonPath.parse(items[0]) + BookSource.fromJson(jsonItem.jsonString()) + } + text.isJsonObject() -> { + BookSource.fromJson(text) + } + else -> { + null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/EditEntity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/EditEntity.kt new file mode 100644 index 000000000..fb98c3c4e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/EditEntity.kt @@ -0,0 +1,3 @@ +package io.legado.app.ui.book.source.edit + +data class EditEntity(var key: String, var value: String?, var hint: Int) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt new file mode 100644 index 000000000..670b9d0f9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt @@ -0,0 +1,568 @@ +package io.legado.app.ui.book.source.manage + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.ItemTouchHelper +import com.google.android.material.snackbar.Snackbar +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppPattern +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.databinding.ActivityBookSourceBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.DirectLinkUpload +import io.legado.app.help.LocalConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.model.CheckSource +import io.legado.app.model.Debug +import io.legado.app.ui.association.ImportBookSourceDialog +import io.legado.app.ui.book.local.rule.TxtTocRuleActivity +import io.legado.app.ui.book.source.debug.BookSourceDebugActivity +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.qrcode.QrCodeResult +import io.legado.app.ui.widget.SelectActionBar +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class BookSourceActivity : VMBaseActivity(), + PopupMenu.OnMenuItemClickListener, + BookSourceAdapter.CallBack, + SelectActionBar.CallBack, + SearchView.OnQueryTextListener { + override val binding by viewBinding(ActivityBookSourceBinding::inflate) + override val viewModel by viewModels() + private val importRecordKey = "bookSourceRecordKey" + private val adapter by lazy { BookSourceAdapter(this, this) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private var sourceFlowJob: Job? = null + private val groups = linkedSetOf() + private var groupMenu: SubMenu? = null + private var sort = Sort.Default + private var sortAscending = true + private var snackBar: Snackbar? = null + private val qrResult = registerForActivityResult(QrCodeResult()) { + it ?: return@registerForActivityResult + showDialogFragment(ImportBookSourceDialog(it)) + } + private val importDoc = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + try { + showDialogFragment(ImportBookSourceDialog(uri.readText(this))) + } catch (e: Exception) { + toastOnUi("readTextError:${e.localizedMessage}") + } + } + } + private val exportDir = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + alert(R.string.export_success) { + if (uri.toString().isAbsUrl()) { + DirectLinkUpload.getSummary()?.let { summary -> + setMessage(summary) + } + } + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = getString(R.string.path) + editView.setText(uri.toString()) + } + customView { alertBinding.root } + okButton { + sendToClip(uri.toString()) + } + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initRecyclerView() + initSearchView() + upBookSource() + initLiveDataGroup() + initSelectActionBar() + if (!LocalConfig.bookSourcesHelpVersionIsLast) { + showHelp() + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_source, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + groupMenu = menu?.findItem(R.id.menu_group)?.subMenu + groupMenu?.findItem(R.id.action_sort)?.subMenu + ?.setGroupCheckable(R.id.menu_group_sort, true, true) + upGroupMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add_book_source -> startActivity() + R.id.menu_import_qr -> qrResult.launch() + R.id.menu_group_manage -> showDialogFragment() + R.id.menu_import_local -> importDoc.launch { + mode = HandleFileContract.FILE + allowExtensions = arrayOf("txt", "json") + } + R.id.menu_import_onLine -> showImportDialog() + R.id.menu_text_toc_rule -> startActivity() + R.id.menu_sort_manual -> { + item.isChecked = true + sortCheck(Sort.Default) + upBookSource(searchView.query?.toString()) + } + R.id.menu_sort_auto -> { + item.isChecked = true + sortCheck(Sort.Weight) + upBookSource(searchView.query?.toString()) + } + R.id.menu_sort_name -> { + item.isChecked = true + sortCheck(Sort.Name) + upBookSource(searchView.query?.toString()) + } + R.id.menu_sort_url -> { + item.isChecked = true + sortCheck(Sort.Url) + upBookSource(searchView.query?.toString()) + } + R.id.menu_sort_time -> { + item.isChecked = true + sortCheck(Sort.Update) + upBookSource(searchView.query?.toString()) + } + R.id.menu_sort_respondTime -> { + item.isChecked = true + sortCheck(Sort.Respond) + upBookSource(searchView.query?.toString()) + } + R.id.menu_sort_enable -> { + item.isChecked = true + sortCheck(Sort.Enable) + upBookSource(searchView.query?.toString()) + } + R.id.menu_enabled_group -> { + searchView.setQuery(getString(R.string.enabled), true) + } + R.id.menu_disabled_group -> { + searchView.setQuery(getString(R.string.disabled), true) + } + R.id.menu_group_login -> { + searchView.setQuery(getString(R.string.need_login), true) + } + R.id.menu_help -> showHelp() + } + if (item.groupId == R.id.source_group) { + searchView.setQuery("group:${item.title}", true) + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.addItemDecoration(VerticalDivider(this)) + binding.recyclerView.adapter = adapter + // When this page is opened, it is in selection mode + val dragSelectTouchHelper = + DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) + dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) + dragSelectTouchHelper.activeSlideSelect() + // Note: need judge selection first, so add ItemTouchHelper after it. + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + } + + private fun initSearchView() { + searchView.applyTint(primaryTextColor) + searchView.onActionViewExpanded() + searchView.queryHint = getString(R.string.search_book_source) + searchView.clearFocus() + searchView.setOnQueryTextListener(this) + } + + private fun upBookSource(searchKey: String? = null) { + sourceFlowJob?.cancel() + sourceFlowJob = launch { + when { + searchKey.isNullOrEmpty() -> { + appDb.bookSourceDao.flowAll() + } + searchKey == getString(R.string.enabled) -> { + appDb.bookSourceDao.flowEnabled() + } + searchKey == getString(R.string.disabled) -> { + appDb.bookSourceDao.flowDisabled() + } + searchKey == getString(R.string.need_login) -> { + appDb.bookSourceDao.flowLogin() + } + searchKey.startsWith("group:") -> { + val key = searchKey.substringAfter("group:") + appDb.bookSourceDao.flowGroupSearch("%$key%") + } + else -> { + appDb.bookSourceDao.flowSearch("%$searchKey%") + } + }.collect { data -> + val sourceList = + if (sortAscending) when (sort) { + Sort.Weight -> data.sortedBy { it.weight } + Sort.Name -> data.sortedWith { o1, o2 -> + o1.bookSourceName.cnCompare(o2.bookSourceName) + } + Sort.Url -> data.sortedBy { it.bookSourceUrl } + Sort.Update -> data.sortedByDescending { it.lastUpdateTime } + Sort.Respond -> data.sortedBy { it.respondTime } + Sort.Enable -> data.sortedWith { o1, o2 -> + var sort = -o1.enabled.compareTo(o2.enabled) + if (sort == 0) { + sort = o1.bookSourceName.cnCompare(o2.bookSourceName) + } + sort + } + else -> data + } + else when (sort) { + Sort.Weight -> data.sortedByDescending { it.weight } + Sort.Name -> data.sortedWith { o1, o2 -> + o2.bookSourceName.cnCompare(o1.bookSourceName) + } + Sort.Url -> data.sortedByDescending { it.bookSourceUrl } + Sort.Update -> data.sortedBy { it.lastUpdateTime } + Sort.Respond -> data.sortedByDescending { it.respondTime } + Sort.Enable -> data.sortedWith { o1, o2 -> + var sort = o1.enabled.compareTo(o2.enabled) + if (sort == 0) { + sort = o1.bookSourceName.cnCompare(o2.bookSourceName) + } + sort + } + else -> data.reversed() + } + adapter.setItems(sourceList, adapter.diffItemCallback) + } + } + } + + private fun showHelp() { + val text = String(assets.open("help/SourceMBookHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + + private fun sortCheck(sort: Sort) { + if (this.sort == sort) { + sortAscending = !sortAscending + } else { + sortAscending = true + this.sort = sort + } + } + + private fun initLiveDataGroup() { + launch { + appDb.bookSourceDao.flowGroup() + .collect { + groups.clear() + it.forEach { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupMenu() + } + } + } + + override fun selectAll(selectAll: Boolean) { + if (selectAll) { + adapter.selectAll() + } else { + adapter.revertSelection() + } + } + + override fun revertSelection() { + adapter.revertSelection() + } + + override fun onClickSelectBarMainAction() { + alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { + okButton { viewModel.del(*adapter.selection.toTypedArray()) } + noButton() + } + } + + private fun initSelectActionBar() { + binding.selectActionBar.setMainActionText(R.string.delete) + binding.selectActionBar.inflateMenu(R.menu.book_source_sel) + binding.selectActionBar.setOnMenuItemClickListener(this) + binding.selectActionBar.setCallBack(this) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) + R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) + R.id.menu_enable_explore -> viewModel.enableSelectExplore(adapter.selection) + R.id.menu_disable_explore -> viewModel.disableSelectExplore(adapter.selection) + R.id.menu_check_source -> checkSource() + R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray()) + R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray()) + R.id.menu_add_group -> selectionAddToGroups() + R.id.menu_remove_group -> selectionRemoveFromGroups() + R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file -> + exportDir.launch { + mode = HandleFileContract.EXPORT + fileData = Triple("bookSource.json", file, "application/json") + } + } + R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) { + share(it) + } + } + return true + } + + @SuppressLint("InflateParams") + private fun checkSource() { + alert(titleResource = R.string.search_book_key) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "search word" + editView.setText(CheckSource.keyword) + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + if (it.isNotEmpty()) { + CheckSource.keyword = it + } + } + CheckSource.start(this@BookSourceActivity, adapter.selection) + checkMessageRefreshJob().start() + } + noButton() + } + } + + @SuppressLint("InflateParams") + private fun selectionAddToGroups() { + alert(titleResource = R.string.add_group) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + editView.setFilterValues(groups.toList()) + editView.dropDownHeight = 180.dp + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + if (it.isNotEmpty()) { + viewModel.selectionAddToGroups(adapter.selection, it) + } + } + } + cancelButton() + } + } + + @SuppressLint("InflateParams") + private fun selectionRemoveFromGroups() { + alert(titleResource = R.string.remove_group) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + editView.setFilterValues(groups.toList()) + editView.dropDownHeight = 180.dp + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + if (it.isNotEmpty()) { + viewModel.selectionRemoveFromGroups(adapter.selection, it) + } + } + } + cancelButton() + } + } + + private fun upGroupMenu() = groupMenu?.let { menu -> + menu.removeGroup(R.id.source_group) + groups.sortedWith { o1, o2 -> + o1.cnCompare(o2) + }.map { + menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it) + } + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(this, cacheDir = false) + val cacheUrls: MutableList = aCache + .getAsString(importRecordKey) + ?.splitNotBlank(",") + ?.toMutableList() ?: mutableListOf() + alert(titleResource = R.string.import_on_line) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + editView.setFilterValues(cacheUrls) + editView.delCallBack = { + cacheUrls.remove(it) + aCache.put(importRecordKey, cacheUrls.joinToString(",")) + } + } + customView { alertBinding.root } + okButton { + val text = alertBinding.editView.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put(importRecordKey, cacheUrls.joinToString(",")) + } + showDialogFragment(ImportBookSourceDialog(it)) + } + } + cancelButton() + } + } + + override fun observeLiveBus() { + observeEvent(EventBus.CHECK_SOURCE) { msg -> + snackBar?.setText(msg) ?: let { + snackBar = Snackbar + .make(binding.root, msg, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.cancel) { + CheckSource.stop(this) + Debug.finishChecking() + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + bundleOf(Pair("checkSourceMessage", null)) + ) + }.apply { show() } + } + } + observeEvent(EventBus.CHECK_SOURCE_DONE) { + snackBar?.dismiss() + snackBar = null + groups.map { group -> + if (group.contains("失效") && searchView.query.isEmpty()) { + searchView.setQuery("失效", true) + toastOnUi("发现有失效书源,已为您自动筛选!") + } + } + } + } + + private fun checkMessageRefreshJob(): Job { + val firstIndex = adapter.getItems().indexOf(adapter.selection.firstOrNull()) + val lastIndex = adapter.getItems().indexOf(adapter.selection.lastOrNull()) + var refreshCount = 0 + Debug.isChecking = firstIndex >= 0 && lastIndex >= 0 + return async(start = CoroutineStart.LAZY) { + flow { + while (true) { + refreshCount += 1 + emit(refreshCount) + delay(300L) + } + }.collect { + adapter.notifyItemRangeChanged( + firstIndex, + lastIndex + 1, + bundleOf(Pair("checkSourceMessage", null)) + ) + if (!Debug.isChecking) { + Debug.finishChecking() + this.cancel() + } + } + } + } + + override fun upCountView() { + binding.selectActionBar + .upCountView(adapter.selection.size, adapter.itemCount) + } + + override fun onQueryTextChange(newText: String?): Boolean { + newText?.let { + upBookSource(it) + } + return false + } + + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun del(bookSource: BookSource) { + viewModel.del(bookSource) + } + + override fun update(vararg bookSource: BookSource) { + viewModel.update(*bookSource) + } + + override fun edit(bookSource: BookSource) { + startActivity { + putExtra("sourceUrl", bookSource.bookSourceUrl) + } + } + + override fun upOrder() { + viewModel.upOrder() + } + + override fun toTop(bookSource: BookSource) { + viewModel.topSource(bookSource) + } + + override fun toBottom(bookSource: BookSource) { + viewModel.bottomSource(bookSource) + } + + override fun debug(bookSource: BookSource) { + startActivity { + putExtra("key", bookSource.bookSourceUrl) + } + } + + override fun finish() { + if (searchView.query.isNullOrEmpty()) { + super.finish() + } else { + searchView.setQuery("", true) + } + } + + override fun onDestroy() { + super.onDestroy() + if (!Debug.isChecking) { + Debug.debugMessageMap.clear() + } + } + + enum class Sort { + Default, Name, Url, Weight, Update, Enable, Respond + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt new file mode 100644 index 000000000..1c663c27b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt @@ -0,0 +1,280 @@ +package io.legado.app.ui.book.source.manage + +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.PopupMenu +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.BookSource +import io.legado.app.databinding.ItemBookSourceBinding +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.model.Debug +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.invisible +import io.legado.app.utils.startActivity +import io.legado.app.utils.visible + + +class BookSourceAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private val selected = linkedSetOf() + + val selection: List + get() { + val selection = arrayListOf() + getItems().map { + if (selected.contains(it)) { + selection.add(it) + } + } + return selection.sortedBy { it.customOrder } + } + + val diffItemCallback: DiffUtil.ItemCallback + get() = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BookSource, newItem: BookSource): Boolean { + return oldItem.bookSourceUrl == newItem.bookSourceUrl + } + + override fun areContentsTheSame(oldItem: BookSource, newItem: BookSource): Boolean { + return false + } + + } + + override fun getViewBinding(parent: ViewGroup): ItemBookSourceBinding { + return ItemBookSourceBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemBookSourceBinding, + item: BookSource, + payloads: MutableList + ) { + binding.run { + val payload = payloads.getOrNull(0) as? Bundle + if (payload == null) { + root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) + if (item.bookSourceGroup.isNullOrEmpty()) { + cbBookSource.text = item.bookSourceName + } else { + cbBookSource.text = + String.format("%s (%s)", item.bookSourceName, item.bookSourceGroup) + } + swtEnabled.isChecked = item.enabled + cbBookSource.isChecked = selected.contains(item) + ivDebugText.text = Debug.debugMessageMap[item.bookSourceUrl] ?: "" + ivDebugText.visibility = + if (ivDebugText.text.toString().length > 1) View.VISIBLE else View.GONE + upShowExplore(ivExplore, item) + } else { + payload.keySet().map { + when (it) { + "selected" -> cbBookSource.isChecked = selected.contains(item) + "checkSourceMessage" -> { + ivDebugText.text = Debug.debugMessageMap[item.bookSourceUrl] ?: "" + val isEmpty = ivDebugText.text.toString().isEmpty() + var isFinalMessage = + ivDebugText.text.toString().contains(Regex("成功|失败")) + if (!isEmpty && !Debug.isChecking && !isFinalMessage) { + Debug.updateFinalMessage(item.bookSourceUrl, "失败") + ivDebugText.text = Debug.debugMessageMap[item.bookSourceUrl] ?: "" + isFinalMessage = true + } + ivDebugText.visibility = + if (!isEmpty) View.VISIBLE else View.GONE + ivProgressBar.visibility = + if (isFinalMessage || isEmpty) View.GONE else View.VISIBLE + } + } + } + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemBookSourceBinding) { + binding.apply { + swtEnabled.setOnCheckedChangeListener { view, checked -> + getItem(holder.layoutPosition)?.let { + if (view.isPressed) { + it.enabled = checked + callBack.update(it) + } + } + } + cbBookSource.setOnCheckedChangeListener { view, checked -> + getItem(holder.layoutPosition)?.let { + if (view.isPressed) { + if (checked) { + selected.add(it) + } else { + selected.remove(it) + } + callBack.upCountView() + } + } + } + ivEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.edit(it) + } + } + ivMenuMore.setOnClickListener { + showMenu(ivMenuMore, holder.layoutPosition) + } + } + } + + override fun onCurrentListChanged() { + callBack.upCountView() + } + + private fun showMenu(view: View, position: Int) { + val source = getItem(position) ?: return + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.book_source_item) + val qyMenu = popupMenu.menu.findItem(R.id.menu_enable_explore) + if (source.exploreUrl.isNullOrEmpty()) { + qyMenu.isVisible = false + } else { + if (source.enabledExplore) { + qyMenu.setTitle(R.string.disable_explore) + } else { + qyMenu.setTitle(R.string.enable_explore) + } + } + val loginMenu = popupMenu.menu.findItem(R.id.menu_login) + loginMenu.isVisible = !source.loginUrl.isNullOrBlank() + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(source) + R.id.menu_bottom -> callBack.toBottom(source) + R.id.menu_login -> context.startActivity { + putExtra("type", "bookSource") + putExtra("key", source.bookSourceUrl) + } + R.id.menu_debug_source -> callBack.debug(source) + R.id.menu_del -> callBack.del(source) + R.id.menu_enable_explore -> { + callBack.update(source.copy(enabledExplore = !source.enabledExplore)) + } + } + true + } + popupMenu.show() + } + + private fun upShowExplore(iv: ImageView, source: BookSource) { + when { + source.exploreUrl.isNullOrEmpty() -> { + iv.invisible() + } + source.enabledExplore -> { + iv.setColorFilter(Color.GREEN) + iv.visible() + } + else -> { + iv.setColorFilter(Color.RED) + iv.visible() + } + } + } + + fun selectAll() { + getItems().forEach { + selected.add(it) + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + fun revertSelection() { + getItems().forEach { + if (selected.contains(it)) { + selected.remove(it) + } else { + selected.add(it) + } + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.customOrder == targetItem.customOrder) { + callBack.upOrder() + } else { + val srcOrder = srcItem.customOrder + srcItem.customOrder = targetItem.customOrder + targetItem.customOrder = srcOrder + movedItems.add(srcItem) + movedItems.add(targetItem) + } + } + swapItem(srcPosition, targetPosition) + return true + } + + private val movedItems = hashSetOf() + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (movedItems.isNotEmpty()) { + callBack.update(*movedItems.toTypedArray()) + movedItems.clear() + } + } + + val dragSelectCallback: DragSelectTouchHelper.Callback = + object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { + override fun currentSelectedId(): MutableSet { + return selected + } + + override fun getItemId(position: Int): BookSource { + return getItem(position)!! + } + + override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { + getItem(position)?.let { + if (isSelected) { + selected.add(it) + } else { + selected.remove(it) + } + notifyItemChanged(position, bundleOf(Pair("selected", null))) + callBack.upCountView() + return true + } + return false + } + } + + interface CallBack { + fun del(bookSource: BookSource) + fun edit(bookSource: BookSource) + fun update(vararg bookSource: BookSource) + fun toTop(bookSource: BookSource) + fun toBottom(bookSource: BookSource) + fun debug(bookSource: BookSource) + fun upOrder() + fun upCountView() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt new file mode 100644 index 000000000..bacacf4f4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt @@ -0,0 +1,185 @@ +package io.legado.app.ui.book.source.manage + +import android.app.Application +import android.text.TextUtils +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.utils.* +import java.io.File +import java.io.FileOutputStream + +class BookSourceViewModel(application: Application) : BaseViewModel(application) { + + fun topSource(vararg sources: BookSource) { + execute { + val minOrder = appDb.bookSourceDao.minOrder - 1 + sources.forEachIndexed { index, bookSource -> + bookSource.customOrder = minOrder - index + } + appDb.bookSourceDao.update(*sources) + } + } + + fun bottomSource(vararg sources: BookSource) { + execute { + val maxOrder = appDb.bookSourceDao.maxOrder + 1 + sources.forEachIndexed { index, bookSource -> + bookSource.customOrder = maxOrder + index + } + appDb.bookSourceDao.update(*sources) + } + } + + fun del(vararg sources: BookSource) { + execute { appDb.bookSourceDao.delete(*sources) } + } + + fun update(vararg bookSource: BookSource) { + execute { appDb.bookSourceDao.update(*bookSource) } + } + + fun upOrder() { + execute { + val sources = appDb.bookSourceDao.all + for ((index: Int, source: BookSource) in sources.withIndex()) { + source.customOrder = index + 1 + } + appDb.bookSourceDao.update(*sources.toTypedArray()) + } + } + + fun enableSelection(sources: List) { + execute { + val list = arrayListOf() + sources.forEach { + list.add(it.copy(enabled = true)) + } + appDb.bookSourceDao.update(*list.toTypedArray()) + } + } + + fun disableSelection(sources: List) { + execute { + val list = arrayListOf() + sources.forEach { + list.add(it.copy(enabled = false)) + } + appDb.bookSourceDao.update(*list.toTypedArray()) + } + } + + fun enableSelectExplore(sources: List) { + execute { + val list = arrayListOf() + sources.forEach { + list.add(it.copy(enabledExplore = true)) + } + appDb.bookSourceDao.update(*list.toTypedArray()) + } + } + + fun disableSelectExplore(sources: List) { + execute { + val list = arrayListOf() + sources.forEach { + list.add(it.copy(enabledExplore = false)) + } + appDb.bookSourceDao.update(*list.toTypedArray()) + } + } + + fun selectionAddToGroups(sources: List, groups: String) { + execute { + val list = arrayListOf() + sources.forEach { source -> + val newGroupList = arrayListOf() + source.bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.forEach { + newGroupList.add(it) + } + groups.splitNotBlank(",", ";", ",").forEach { + newGroupList.add(it) + } + val lh = LinkedHashSet(newGroupList) + val newGroup = ArrayList(lh).joinToString(separator = ",") + list.add(source.copy(bookSourceGroup = newGroup)) + } + appDb.bookSourceDao.update(*list.toTypedArray()) + } + } + + fun selectionRemoveFromGroups(sources: List, groups: String) { + execute { + val list = arrayListOf() + sources.forEach { source -> + val newGroupList = arrayListOf() + source.bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.forEach { + newGroupList.add(it) + } + groups.splitNotBlank(",", ";", ",").forEach { + newGroupList.remove(it) + } + val lh = LinkedHashSet(newGroupList) + val newGroup = ArrayList(lh).joinToString(separator = ",") + list.add(source.copy(bookSourceGroup = newGroup)) + } + appDb.bookSourceDao.update(*list.toTypedArray()) + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + fun saveToFile(sources: List, success: (file: File) -> Unit) { + execute { + val path = "${context.filesDir}/shareBookSource.json" + FileUtils.delete(path) + val file = FileUtils.createFileWithReplace(path) + FileOutputStream(file).use { + GSON.writeToOutputStream(it, sources) + } + file + }.onSuccess { + success.invoke(it) + }.onError { + context.toastOnUi(it.msg) + } + } + + fun addGroup(group: String) { + execute { + val sources = appDb.bookSourceDao.noGroup + sources.map { source -> + source.bookSourceGroup = group + } + appDb.bookSourceDao.update(*sources.toTypedArray()) + } + } + + fun upGroup(oldGroup: String, newGroup: String?) { + execute { + val sources = appDb.bookSourceDao.getByGroup(oldGroup) + sources.map { source -> + source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(oldGroup) + if (!newGroup.isNullOrEmpty()) + it.add(newGroup) + source.bookSourceGroup = TextUtils.join(",", it) + } + } + appDb.bookSourceDao.update(*sources.toTypedArray()) + } + } + + fun delGroup(group: String) { + execute { + execute { + val sources = appDb.bookSourceDao.getByGroup(group) + sources.map { source -> + source.removeGroup(group) + } + appDb.bookSourceDao.update(*sources.toTypedArray()) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt new file mode 100644 index 000000000..2d72477a2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt @@ -0,0 +1,144 @@ +package io.legado.app.ui.book.source.manage + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemGroupManageBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.applyTint +import io.legado.app.utils.requestInputMethod +import io.legado.app.utils.setLayout +import io.legado.app.utils.splitNotBlank +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val viewModel: BookSourceViewModel by activityViewModels() + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { GroupAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + view.setBackgroundColor(backgroundColor) + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.title = getString(R.string.group_manage) + binding.toolBar.inflateMenu(R.menu.group_manage) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) + binding.recyclerView.adapter = adapter + initData() + } + + private fun initData() { + launch { + appDb.bookSourceDao.flowGroup().collect { + val groups = linkedSetOf() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + adapter.setItems(groups.toList()) + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + } + customView { alertBinding.root } + yesButton { + alertBinding.editView.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(group: String) { + alert(title = getString(R.string.group_edit)) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + editView.setText(group) + } + customView { alertBinding.root } + yesButton { + viewModel.upGroup(group, alertBinding.editView.text?.toString()) + } + noButton() + }.requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { + return ItemGroupManageBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemGroupManageBinding, + item: String, + payloads: MutableList + ) { + binding.run { + root.setBackgroundColor(context.backgroundColor) + tvGroup.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { + binding.apply { + tvEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + editGroup(it) + } + } + tvDel.setOnClickListener { + getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt new file mode 100644 index 000000000..43a6ac72d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt @@ -0,0 +1,48 @@ +package io.legado.app.ui.book.toc + +import android.content.Context +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.Bookmark +import io.legado.app.databinding.ItemBookmarkBinding +import splitties.views.onLongClick + +class BookmarkAdapter(context: Context, val callback: Callback) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding { + return ItemBookmarkBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemBookmarkBinding, + item: Bookmark, + payloads: MutableList + ) { + binding.tvChapterName.text = item.chapterName + binding.tvBookText.text = item.bookText + binding.tvContent.text = item.content + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) { + binding.root.setOnClickListener { + getItem(holder.layoutPosition)?.let { bookmark -> + callback.onClick(bookmark) + } + } + binding.root.onLongClick { + getItem(holder.layoutPosition)?.let { bookmark -> + callback.onLongClick(bookmark) + } + } + + } + + interface Callback { + fun onClick(bookmark: Bookmark) + fun onLongClick(bookmark: Bookmark) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkDialog.kt b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkDialog.kt new file mode 100644 index 000000000..de9a31ff9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkDialog.kt @@ -0,0 +1,72 @@ +package io.legado.app.ui.book.toc + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.data.appDb +import io.legado.app.data.entities.Bookmark +import io.legado.app.databinding.DialogBookmarkBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class BookmarkDialog() : BaseDialogFragment(R.layout.dialog_bookmark) { + + constructor(bookmark: Bookmark) : this() { + arguments = Bundle().apply { + putParcelable("bookmark", bookmark) + } + } + + private val binding by viewBinding(DialogBookmarkBinding::bind) + + override fun onStart() { + super.onStart() + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + val bookmark = arguments?.getParcelable("bookmark") + bookmark ?: let { + dismiss() + return + } + binding.run { + tvChapterName.text = bookmark.chapterName + editBookText.setText(bookmark.bookText) + editContent.setText(bookmark.content) + tvCancel.setOnClickListener { + dismiss() + } + tvOk.setOnClickListener { + bookmark.bookText = editBookText.text?.toString() ?: "" + bookmark.content = editContent.text?.toString() ?: "" + launch { + withContext(IO) { + appDb.bookmarkDao.insert(bookmark) + } + dismiss() + } + } + tvFooterLeft.setOnClickListener { + launch { + withContext(IO) { + appDb.bookmarkDao.delete(bookmark) + } + dismiss() + } + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt new file mode 100644 index 000000000..9dacaca92 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt @@ -0,0 +1,74 @@ +package io.legado.app.ui.book.toc + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.data.appDb +import io.legado.app.data.entities.Bookmark +import io.legado.app.databinding.FragmentBookmarkBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class BookmarkFragment : VMBaseFragment(R.layout.fragment_bookmark), + BookmarkAdapter.Callback, + TocViewModel.BookmarkCallBack { + override val viewModel by activityViewModels() + private val binding by viewBinding(FragmentBookmarkBinding::bind) + private val adapter by lazy { BookmarkAdapter(requireContext(), this) } + private var bookmarkFlowJob: Job? = null + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + viewModel.bookMarkCallBack = this + initRecyclerView() + viewModel.bookData.observe(this) { + upBookmark(null) + } + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) + binding.recyclerView.adapter = adapter + } + + override fun upBookmark(searchKey: String?) { + val book = viewModel.bookData.value ?: return + bookmarkFlowJob?.cancel() + bookmarkFlowJob = launch { + when { + searchKey.isNullOrBlank() -> appDb.bookmarkDao.flowByBook(book.name, book.author) + else -> appDb.bookmarkDao.flowSearch(book.name, book.author, searchKey) + }.collect { + adapter.setItems(it) + } + } + } + + + override fun onClick(bookmark: Bookmark) { + activity?.run { + setResult(Activity.RESULT_OK, Intent().apply { + putExtra("index", bookmark.chapterIndex) + putExtra("chapterPos", bookmark.chapterPos) + }) + finish() + } + } + + override fun onLongClick(bookmark: Bookmark) { + showDialogFragment(BookmarkDialog(bookmark)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt b/app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt new file mode 100644 index 000000000..2a4580e67 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt @@ -0,0 +1,92 @@ +package io.legado.app.ui.book.toc + +import android.content.Context +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.BookChapter +import io.legado.app.databinding.ItemChapterListBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.visible + + +class ChapterListAdapter(context: Context, val callback: Callback) : + RecyclerAdapter(context) { + + val cacheFileNames = hashSetOf() + val diffCallBack = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BookChapter, newItem: BookChapter): Boolean { + return oldItem.index == newItem.index + } + + override fun areContentsTheSame(oldItem: BookChapter, newItem: BookChapter): Boolean { + return oldItem.bookUrl == newItem.bookUrl + && oldItem.url == newItem.url + && oldItem.isVip == newItem.isVip + && oldItem.isPay == newItem.isPay + && oldItem.title == newItem.title + && oldItem.tag == newItem.tag + } + + } + + override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding { + return ItemChapterListBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemChapterListBinding, + item: BookChapter, + payloads: MutableList + ) { + binding.run { + val isDur = callback.durChapterIndex() == item.index + val cached = callback.isLocalBook || cacheFileNames.contains(item.getFileName()) + if (payloads.isEmpty()) { + if (isDur) { + tvChapterName.setTextColor(context.accentColor) + } else { + tvChapterName.setTextColor(context.getCompatColor(R.color.primaryText)) + } + tvChapterName.text = item.getDisplayTitle() + if (!item.tag.isNullOrEmpty()) { + tvTag.text = item.tag + tvTag.visible() + } + upHasCache(binding, isDur, cached) + } else { + upHasCache(binding, isDur, cached) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callback.openChapter(it) + } + } + } + + private fun upHasCache(binding: ItemChapterListBinding, isDur: Boolean, cached: Boolean) = + binding.apply { + tvChapterName.paint.isFakeBoldText = !cached + ivChecked.setImageResource(R.drawable.ic_outline_cloud_24) + ivChecked.visible(!cached) + if (isDur) { + ivChecked.setImageResource(R.drawable.ic_check) + ivChecked.visible() + } + } + + interface Callback { + val isLocalBook: Boolean + fun openChapter(bookChapter: BookChapter) + fun durChapterIndex(): Int + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt b/app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt new file mode 100644 index 000000000..af1bd83bd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt @@ -0,0 +1,138 @@ +package io.legado.app.ui.book.toc + +import android.annotation.SuppressLint +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.databinding.FragmentChapterListBinding +import io.legado.app.help.BookHelp +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.ui.widget.recycler.UpLinearLayoutManager +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.observeEvent +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ChapterListFragment : VMBaseFragment(R.layout.fragment_chapter_list), + ChapterListAdapter.Callback, + TocViewModel.ChapterListCallBack { + override val viewModel by activityViewModels() + private val binding by viewBinding(FragmentChapterListBinding::bind) + private val mLayoutManager by lazy { UpLinearLayoutManager(requireContext()) } + private val adapter by lazy { ChapterListAdapter(requireContext(), this) } + private var durChapterIndex = 0 + private var tocFlowJob: Job? = null + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { + viewModel.chapterCallBack = this@ChapterListFragment + val bbg = bottomBackground + val btc = requireContext().getPrimaryTextColor(ColorUtils.isColorLight(bbg)) + llChapterBaseInfo.setBackgroundColor(bbg) + tvCurrentChapterInfo.setTextColor(btc) + ivChapterTop.setColorFilter(btc) + ivChapterBottom.setColorFilter(btc) + initRecyclerView() + initView() + viewModel.bookData.observe(this@ChapterListFragment) { + initBook(it) + } + } + + private fun initRecyclerView() { + binding.recyclerView.layoutManager = mLayoutManager + binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) + binding.recyclerView.adapter = adapter + } + + private fun initView() = binding.run { + ivChapterTop.setOnClickListener { + mLayoutManager.scrollToPositionWithOffset(0, 0) + } + ivChapterBottom.setOnClickListener { + if (adapter.itemCount > 0) { + mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) + } + } + tvCurrentChapterInfo.setOnClickListener { + mLayoutManager.scrollToPositionWithOffset(durChapterIndex, 0) + } + } + + @SuppressLint("SetTextI18n") + private fun initBook(book: Book) { + launch { + upChapterList(null) + durChapterIndex = book.durChapterIndex + binding.tvCurrentChapterInfo.text = + "${book.durChapterTitle}(${book.durChapterIndex + 1}/${book.totalChapterNum})" + initCacheFileNames(book) + } + } + + private fun initCacheFileNames(book: Book) { + launch(IO) { + adapter.cacheFileNames.addAll(BookHelp.getChapterFiles(book)) + withContext(Main) { + adapter.notifyItemRangeChanged(0, adapter.itemCount, true) + } + } + } + + override fun observeLiveBus() { + observeEvent(EventBus.SAVE_CONTENT) { chapter -> + viewModel.bookData.value?.bookUrl?.let { bookUrl -> + if (chapter.bookUrl == bookUrl) { + adapter.cacheFileNames.add(chapter.getFileName()) + adapter.notifyItemChanged(chapter.index, true) + } + } + } + } + + override fun upChapterList(searchKey: String?) { + tocFlowJob?.cancel() + tocFlowJob = launch { + when { + searchKey.isNullOrBlank() -> appDb.bookChapterDao.flowByBook(viewModel.bookUrl) + else -> appDb.bookChapterDao.flowSearch(viewModel.bookUrl, searchKey) + }.collect { + if (!(searchKey.isNullOrBlank() && it.isEmpty())) { + adapter.setItems(it, adapter.diffCallBack) + if (searchKey.isNullOrBlank() && mLayoutManager.findFirstVisibleItemPosition() < 0) { + mLayoutManager.scrollToPositionWithOffset(durChapterIndex, 0) + } + } + } + } + } + + override val isLocalBook: Boolean + get() = viewModel.bookData.value?.isLocalBook() == true + + override fun durChapterIndex(): Int { + return durChapterIndex + } + + override fun openChapter(bookChapter: BookChapter) { + activity?.run { + setResult(RESULT_OK, Intent().putExtra("index", bookChapter.index)) + finish() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt new file mode 100644 index 000000000..46310a27e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt @@ -0,0 +1,123 @@ +@file:Suppress("DEPRECATION") + +package io.legado.app.ui.book.toc + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentPagerAdapter +import com.google.android.material.tabs.TabLayout +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivityChapterListBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.utils.applyTint +import io.legado.app.utils.gone +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding +import io.legado.app.utils.visible + + +class TocActivity : VMBaseActivity() { + + override val binding by viewBinding(ActivityChapterListBinding::inflate) + override val viewModel by viewModels() + + private lateinit var tabLayout: TabLayout + private var searchView: SearchView? = null + + override fun onActivityCreated(savedInstanceState: Bundle?) { + tabLayout = binding.titleBar.findViewById(R.id.tab_layout) + tabLayout.isTabIndicatorFullWidth = false + tabLayout.setSelectedTabIndicatorColor(accentColor) + binding.viewPager.adapter = TabFragmentPageAdapter() + tabLayout.setupWithViewPager(binding.viewPager) + intent.getStringExtra("bookUrl")?.let { + viewModel.initBook(it) + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_toc, menu) + val search = menu.findItem(R.id.menu_search) + searchView = (search.actionView as SearchView).apply { + applyTint(primaryTextColor) + maxWidth = resources.displayMetrics.widthPixels + onActionViewCollapsed() + setOnCloseListener { + tabLayout.visible() + false + } + setOnSearchClickListener { tabLayout.gone() } + setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + if (tabLayout.selectedTabPosition == 1) { + viewModel.startBookmarkSearch(newText) + } else { + viewModel.startChapterListSearch(newText) + } + return false + } + }) + } + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_reverse_toc -> viewModel.reverseToc { + setResult(RESULT_OK, Intent().apply { + putExtra("index", it.durChapterIndex) + putExtra("chapterPos", it.durChapterPos) + }) + } + R.id.menu_log -> showDialogFragment() + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onBackPressed() { + if (tabLayout.isGone) { + searchView?.onActionViewCollapsed() + tabLayout.visible() + } else { + super.onBackPressed() + } + } + + @Suppress("DEPRECATION") + private inner class TabFragmentPageAdapter : + FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + override fun getItem(position: Int): Fragment { + return when (position) { + 1 -> BookmarkFragment() + else -> ChapterListFragment() + } + } + + override fun getCount(): Int { + return 2 + } + + override fun getPageTitle(position: Int): CharSequence { + return when (position) { + 1 -> getString(R.string.bookmark) + else -> getString(R.string.chapter_list) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocActivityResult.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocActivityResult.kt new file mode 100644 index 000000000..4cdd2ad4c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocActivityResult.kt @@ -0,0 +1,26 @@ +package io.legado.app.ui.book.toc + +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +class TocActivityResult : ActivityResultContract?>() { + + override fun createIntent(context: Context, input: String): Intent { + return Intent(context, TocActivity::class.java) + .putExtra("bookUrl", input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Pair? { + if (resultCode == RESULT_OK) { + intent?.let { + return Pair( + it.getIntExtra("index", 0), + it.getIntExtra("chapterPos", 0) + ) + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt new file mode 100644 index 000000000..1fdb66fd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -0,0 +1,56 @@ +package io.legado.app.ui.book.toc + + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book + +class TocViewModel(application: Application) : BaseViewModel(application) { + var bookUrl: String = "" + var bookData = MutableLiveData() + var chapterCallBack: ChapterListCallBack? = null + var bookMarkCallBack: BookmarkCallBack? = null + + fun initBook(bookUrl: String) { + this.bookUrl = bookUrl + execute { + appDb.bookDao.getBook(bookUrl)?.let { + bookData.postValue(it) + } + } + } + + fun reverseToc(success: (book: Book) -> Unit) { + execute { + bookData.value?.apply { + setReverseToc(!getReverseToc()) + val toc = appDb.bookChapterDao.getChapterList(bookUrl) + val newToc = toc.reversed() + newToc.forEachIndexed { index, bookChapter -> + bookChapter.index = index + } + appDb.bookChapterDao.insert(*newToc.toTypedArray()) + } + }.onSuccess { + it?.let(success) + } + } + + fun startChapterListSearch(newText: String?) { + chapterCallBack?.upChapterList(newText) + } + + fun startBookmarkSearch(newText: String?) { + bookMarkCallBack?.upBookmark(newText) + } + + interface ChapterListCallBack { + fun upChapterList(searchKey: String?) + } + + interface BookmarkCallBack { + fun upBookmark(searchKey: String?) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/browser/WebViewActivity.kt b/app/src/main/java/io/legado/app/ui/browser/WebViewActivity.kt new file mode 100644 index 000000000..fb493067f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/browser/WebViewActivity.kt @@ -0,0 +1,241 @@ +package io.legado.app.ui.browser + +import android.annotation.SuppressLint +import android.content.pm.ActivityInfo +import android.net.Uri +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.webkit.* +import androidx.activity.viewModels +import androidx.core.view.size +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivityWebViewBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.model.Download +import io.legado.app.ui.association.OnLineImportActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import java.net.URLDecoder + +class WebViewActivity : VMBaseActivity() { + + override val binding by viewBinding(ActivityWebViewBinding::inflate) + override val viewModel by viewModels() + private val imagePathKey = "imagePath" + private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null + private var webPic: String? = null + private val saveImage = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + ACache.get(this).put(imagePathKey, uri.toString()) + viewModel.saveImage(webPic, uri.toString()) + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.titleBar.title = intent.getStringExtra("title") ?: getString(R.string.loading) + initWebView() + viewModel.initData(intent) { + val url = viewModel.baseUrl + val html = viewModel.html + if (html.isNullOrEmpty()) { + binding.webView.loadUrl(url, viewModel.headerMap) + } else { + binding.webView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url) + } + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.web_view, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_open_in_browser -> openUrl(viewModel.baseUrl) + R.id.menu_copy_url -> sendToClip(viewModel.baseUrl) + } + return super.onCompatOptionsItemSelected(item) + } + + @SuppressLint("JavascriptInterface") + private fun initWebView() { + binding.webView.webChromeClient = CustomWebChromeClient() + binding.webView.webViewClient = CustomWebViewClient() + binding.webView.settings.apply { + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + domStorageEnabled = true + allowContentAccess = true + useWideViewPort = true + loadWithOverviewMode = true + } + binding.webView.addJavascriptInterface(this, "app") + upWebViewTheme() + binding.webView.setOnLongClickListener { + val hitTestResult = binding.webView.hitTestResult + if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || + hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE + ) { + hitTestResult.extra?.let { + saveImage(it) + return@setOnLongClickListener true + } + } + return@setOnLongClickListener false + } + binding.webView.setDownloadListener { url, _, contentDisposition, _, _ -> + var fileName = URLUtil.guessFileName(url, contentDisposition, null) + fileName = URLDecoder.decode(fileName, "UTF-8") + binding.llView.longSnackbar(fileName, getString(R.string.action_download)) { + Download.start(this, url, fileName) + } + } + } + + private fun upWebViewTheme() { + if (AppConfig.isNightTheme) { + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + binding.webView.settings, + WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING + ) + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark( + binding.webView.settings, + WebSettingsCompat.FORCE_DARK_ON + ) + } + } + } + + private fun saveImage(webPic: String) { + this.webPic = webPic + val path = ACache.get(this).getAsString(imagePathKey) + if (path.isNullOrEmpty()) { + selectSaveFolder() + } else { + viewModel.saveImage(webPic, path) + } + } + + private fun selectSaveFolder() { + val default = arrayListOf>() + val path = ACache.get(this).getAsString(imagePathKey) + if (!path.isNullOrEmpty()) { + default.add(SelectItem(path, -1)) + } + saveImage.launch { + otherActions = default + } + } + + override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> { + finish() + return true + } + } + return super.onKeyLongPress(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + event?.let { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> if (event.isTracking && !event.isCanceled && binding.webView.canGoBack()) { + if (binding.customWebView.size > 0) { + customWebViewCallback?.onCustomViewHidden() + return true + } else if (binding.webView.copyBackForwardList().size > 1) { + binding.webView.goBack() + return true + } + } + } + } + return super.onKeyUp(keyCode, event) + } + + override fun onDestroy() { + super.onDestroy() + binding.webView.destroy() + } + + inner class CustomWebChromeClient : WebChromeClient() { + + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + binding.llView.invisible() + binding.customWebView.addView(view) + customWebViewCallback = callback + } + + override fun onHideCustomView() { + binding.customWebView.removeAllViews() + binding.llView.visible() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + + inner class CustomWebViewClient : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + request?.let { + return shouldOverrideUrlLoading(it.url) + } + return true + } + + @Suppress("DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + url?.let { + return shouldOverrideUrlLoading(Uri.parse(it)) + } + return true + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + view?.title?.let { title -> + if (title != url && title != view.url && title.isNotBlank()) { + binding.titleBar.title = title + } else { + binding.titleBar.title = intent.getStringExtra("title") + } + } + } + + private fun shouldOverrideUrlLoading(url: Uri): Boolean { + when (url.scheme) { + "http", "https" -> { + return false + } + "legado", "yuedu" -> { + startActivity { + data = url + } + return true + } + else -> { + binding.root.longSnackbar("跳转其它应用", "确认") { + openUrl(url) + } + return true + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/browser/WebViewModel.kt b/app/src/main/java/io/legado/app/ui/browser/WebViewModel.kt new file mode 100644 index 000000000..dd72acb5b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/browser/WebViewModel.kt @@ -0,0 +1,83 @@ +package io.legado.app.ui.browser + +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import android.webkit.URLUtil +import androidx.documentfile.provider.DocumentFile +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst +import io.legado.app.help.IntentData +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.* +import timber.log.Timber +import java.io.File +import java.util.* + +class WebViewModel(application: Application) : BaseViewModel(application) { + var baseUrl: String = "" + var html: String? = null + val headerMap: HashMap = hashMapOf() + + fun initData( + intent: Intent, + success: () -> Unit + ) { + execute { + val url = intent.getStringExtra("url") + ?: throw NoStackTraceException("url不能为空") + val headerMapF = IntentData.get>(url) + val analyzeUrl = AnalyzeUrl(url, headerMapF = headerMapF) + baseUrl = analyzeUrl.url + headerMap.putAll(analyzeUrl.headerMap) + if (analyzeUrl.isPost()) { + html = analyzeUrl.getStrResponseAwait(useWebView = false).body + } + }.onSuccess { + success.invoke() + }.onError { + context.toastOnUi("error\n${it.localizedMessage}") + Timber.e(it) + } + } + + fun saveImage(webPic: String?, path: String) { + webPic ?: return + execute { + val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" + webData2bitmap(webPic)?.let { biteArray -> + if (path.isContentScheme()) { + val uri = Uri.parse(path) + DocumentFile.fromTreeUri(context, uri)?.let { doc -> + DocumentUtils.createFileIfNotExist(doc, fileName) + ?.writeBytes(context, biteArray) + } + } else { + val file = FileUtils.createFileIfNotExist(File(path), fileName) + file.writeBytes(biteArray) + } + } ?: throw Throwable("NULL") + }.onError { + context.toastOnUi("保存图片失败:${it.localizedMessage}") + }.onSuccess { + context.toastOnUi("保存成功") + } + } + + private suspend fun webData2bitmap(data: String): ByteArray? { + return if (URLUtil.isValidUrl(data)) { + @Suppress("BlockingMethodInNonBlockingContext") + okHttpClient.newCallResponseBody { + url(data) + }.bytes() + } else { + Base64.decode(data.split(",").toTypedArray()[1], Base64.DEFAULT) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt new file mode 100644 index 000000000..ec8739dd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/BackupConfigFragment.kt @@ -0,0 +1,328 @@ +package io.legado.app.ui.config + +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.text.InputType +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.lifecycleScope +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.AppLog +import io.legado.app.constant.PreferKey +import io.legado.app.help.AppConfig +import io.legado.app.help.LocalConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.storage.AppWebDav +import io.legado.app.help.storage.Backup +import io.legado.app.help.storage.ImportOldData +import io.legado.app.help.storage.Restore +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.permission.Permissions +import io.legado.app.lib.permission.PermissionsCompat +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import splitties.init.appCtx + +class BackupConfigFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val selectBackupPath = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + if (uri.isContentScheme()) { + AppConfig.backupPath = uri.toString() + } else { + AppConfig.backupPath = uri.path + } + } + } + private val backupDir = registerForActivityResult(HandleFileContract()) { result -> + result.uri?.let { uri -> + if (uri.isContentScheme()) { + AppConfig.backupPath = uri.toString() + Coroutine.async { + Backup.backup(appCtx, uri.toString()) + }.onSuccess { + appCtx.toastOnUi(R.string.backup_success) + }.onError { + AppLog.put("备份出错\n${it.localizedMessage}", it) + appCtx.toastOnUi(getString(R.string.backup_fail, it.localizedMessage)) + } + } else { + uri.path?.let { path -> + AppConfig.backupPath = path + Coroutine.async { + Backup.backup(appCtx, path) + }.onSuccess { + appCtx.toastOnUi(R.string.backup_success) + }.onError { + AppLog.put("备份出错\n${it.localizedMessage}", it) + appCtx.toastOnUi(getString(R.string.backup_fail, it.localizedMessage)) + } + } + } + } + } + private val restoreDir = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + if (uri.isContentScheme()) { + AppConfig.backupPath = uri.toString() + Coroutine.async { + Restore.restore(appCtx, uri.toString()) + } + } else { + uri.path?.let { path -> + AppConfig.backupPath = path + Coroutine.async { + Restore.restore(appCtx, path) + } + } + } + } + } + private val restoreOld = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + ImportOldData.importUri(appCtx, uri) + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_backup) + findPreference(PreferKey.webDavUrl)?.let { + it.setOnBindEditTextListener { editText -> + editText.applyTint(requireContext().accentColor) + } + + } + findPreference(PreferKey.webDavAccount)?.let { + it.setOnBindEditTextListener { editText -> + editText.applyTint(requireContext().accentColor) + } + } + findPreference(PreferKey.webDavPassword)?.let { + it.setOnBindEditTextListener { editText -> + editText.applyTint(requireContext().accentColor) + editText.inputType = + InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT + } + } + upPreferenceSummary(PreferKey.webDavUrl, getPrefString(PreferKey.webDavUrl)) + upPreferenceSummary(PreferKey.webDavAccount, getPrefString(PreferKey.webDavAccount)) + upPreferenceSummary(PreferKey.webDavPassword, getPrefString(PreferKey.webDavPassword)) + upPreferenceSummary(PreferKey.backupPath, getPrefString(PreferKey.backupPath)) + findPreference("web_dav_restore") + ?.onLongClick { restoreDir.launch(); true } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity?.setTitle(R.string.backup_restore) + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + listView.setEdgeEffectColor(primaryColor) + setHasOptionsMenu(true) + if (!LocalConfig.backupHelpVersionIsLast) { + showHelp() + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.backup_restore, menu) + menu.applyTint(requireContext()) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_help -> showHelp() + } + return super.onOptionsItemSelected(item) + } + + private fun showHelp() { + val text = String(requireContext().assets.open("help/webDavHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + + override fun onDestroy() { + super.onDestroy() + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PreferKey.webDavUrl, + PreferKey.webDavAccount, + PreferKey.webDavPassword, + PreferKey.backupPath -> { + upPreferenceSummary(key, getPrefString(key)) + } + } + } + + private fun upPreferenceSummary(preferenceKey: String, value: String?) { + val preference = findPreference(preferenceKey) ?: return + when (preferenceKey) { + PreferKey.webDavUrl -> + if (value == null) { + preference.summary = getString(R.string.web_dav_url_s) + } else { + preference.summary = value.toString() + } + PreferKey.webDavAccount -> + if (value == null) { + preference.summary = getString(R.string.web_dav_account_s) + } else { + preference.summary = value.toString() + } + PreferKey.webDavPassword -> + if (value == null) { + preference.summary = getString(R.string.web_dav_pw_s) + } else { + preference.summary = "*".repeat(value.toString().length) + } + else -> { + if (preference is ListPreference) { + val index = preference.findIndexOfValue(value) + // Set the summary to reflect the new value. + preference.summary = if (index >= 0) preference.entries[index] else null + } else { + preference.summary = value + } + } + } + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + PreferKey.backupPath -> selectBackupPath.launch() + PreferKey.restoreIgnore -> backupIgnore() + "web_dav_backup" -> backup() + "web_dav_restore" -> restore() + "import_old" -> restoreOld.launch() + } + return super.onPreferenceTreeClick(preference) + } + + + private fun backupIgnore() { + val checkedItems = BooleanArray(Backup.ignoreKeys.size) { + Backup.ignoreConfig[Backup.ignoreKeys[it]] ?: false + } + alert(R.string.restore_ignore) { + multiChoiceItems(Backup.ignoreTitle, checkedItems) { _, which, isChecked -> + Backup.ignoreConfig[Backup.ignoreKeys[which]] = isChecked + } + onDismiss { + Backup.saveIgnoreConfig() + } + } + } + + + fun backup() { + val backupPath = AppConfig.backupPath + if (backupPath.isNullOrEmpty()) { + backupDir.launch() + } else { + if (backupPath.isContentScheme()) { + val uri = Uri.parse(backupPath) + val doc = DocumentFile.fromTreeUri(requireContext(), uri) + if (doc?.canWrite() == true) { + Coroutine.async { + Backup.backup(requireContext(), backupPath) + }.onSuccess { + appCtx.toastOnUi(R.string.backup_success) + }.onError { + AppLog.put("备份出错\n${it.localizedMessage}", it) + appCtx.toastOnUi(getString(R.string.backup_fail, it.localizedMessage)) + } + } else { + backupDir.launch() + } + } else { + backupUsePermission(backupPath) + } + } + } + + private fun backupUsePermission(path: String) { + PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { + Coroutine.async { + AppConfig.backupPath = path + Backup.backup(requireContext(), path) + }.onSuccess { + appCtx.toastOnUi(R.string.backup_success) + }.onError { + AppLog.put("备份出错\n${it.localizedMessage}", it) + appCtx.toastOnUi(getString(R.string.backup_fail, it.localizedMessage)) + } + } + .request() + } + + fun restore() { + Coroutine.async(context = Main) { + AppWebDav.showRestoreDialog(requireContext()) + }.onError { + alert { + setTitle(R.string.restore) + setMessage("WebDavError:${it.localizedMessage}\n将从本地备份恢复。") + okButton { + restoreFromLocal() + } + cancelButton() + } + } + } + + private fun restoreFromLocal() { + val backupPath = getPrefString(PreferKey.backupPath) + if (backupPath?.isNotEmpty() == true) { + if (backupPath.isContentScheme()) { + val uri = Uri.parse(backupPath) + val doc = DocumentFile.fromTreeUri(requireContext(), uri) + if (doc?.canWrite() == true) { + lifecycleScope.launch { + Restore.restore(requireContext(), backupPath) + } + } else { + restoreDir.launch() + } + } else { + restoreUsePermission(backupPath) + } + } else { + restoreDir.launch() + } + } + + private fun restoreUsePermission(path: String) { + PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { + Coroutine.async { + AppConfig.backupPath = path + Restore.restoreDatabase(path) + Restore.restoreConfig(path) + } + } + .request() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt b/app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt new file mode 100644 index 000000000..4d97b0bc5 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt @@ -0,0 +1,55 @@ +package io.legado.app.ui.config + +import android.os.Bundle +import androidx.fragment.app.Fragment +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.constant.EventBus +import io.legado.app.databinding.ActivityConfigBinding +import io.legado.app.utils.observeEvent +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class ConfigActivity : BaseActivity() { + + override val binding by viewBinding(ActivityConfigBinding::inflate) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + when (val configTag = intent.getStringExtra("configTag")) { + ConfigTag.OTHER_CONFIG -> replaceFragment(configTag) + ConfigTag.THEME_CONFIG -> replaceFragment(configTag) + ConfigTag.BACKUP_CONFIG -> replaceFragment(configTag) + ConfigTag.COVER_CONFIG -> replaceFragment(configTag) + else -> finish() + } + } + + override fun setTitle(resId: Int) { + super.setTitle(resId) + binding.titleBar.setTitle(resId) + } + + inline fun replaceFragment(configTag: String) { + intent.putExtra("configTag", configTag) + val configFragment = supportFragmentManager.findFragmentByTag(configTag) + ?: T::class.java.newInstance() + supportFragmentManager.beginTransaction() + .replace(R.id.configFrameLayout, configFragment, configTag) + .commit() + } + + override fun observeLiveBus() { + super.observeLiveBus() + observeEvent(EventBus.RECREATE) { + recreate() + } + } + + override fun finish() { + if (supportFragmentManager.findFragmentByTag(ConfigTag.COVER_CONFIG) != null) { + replaceFragment(ConfigTag.THEME_CONFIG) + } else { + super.finish() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigTag.kt b/app/src/main/java/io/legado/app/ui/config/ConfigTag.kt new file mode 100644 index 000000000..dda212c3d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ConfigTag.kt @@ -0,0 +1,10 @@ +package io.legado.app.ui.config + +object ConfigTag { + + const val OTHER_CONFIG = "otherConfig" + const val THEME_CONFIG = "themeConfig" + const val BACKUP_CONFIG = "backupConfig" + const val COVER_CONFIG = "coverConfig" + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/CoverConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/CoverConfigFragment.kt new file mode 100644 index 000000000..3e6664953 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/CoverConfigFragment.kt @@ -0,0 +1,139 @@ +package io.legado.app.ui.config + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.PreferKey +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.primaryColor +import io.legado.app.model.BookCover +import io.legado.app.ui.widget.prefs.SwitchPreference +import io.legado.app.utils.* + +class CoverConfigFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val requestCodeCover = 111 + private val requestCodeCoverDark = 112 + private val selectImage = registerForActivityResult(SelectImageContract()) { + it.uri?.let { uri -> + when (it.requestCode) { + requestCodeCover -> setCoverFromUri(PreferKey.defaultCover, uri) + requestCodeCoverDark -> setCoverFromUri(PreferKey.defaultCoverDark, uri) + } + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_cover) + upPreferenceSummary(PreferKey.defaultCover, getPrefString(PreferKey.defaultCover)) + upPreferenceSummary(PreferKey.defaultCoverDark, getPrefString(PreferKey.defaultCoverDark)) + findPreference(PreferKey.coverShowAuthor) + ?.isEnabled = getPrefBoolean(PreferKey.coverShowName) + findPreference(PreferKey.coverShowAuthorN) + ?.isEnabled = getPrefBoolean(PreferKey.coverShowNameN) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity?.setTitle(R.string.cover_config) + listView.setEdgeEffectColor(primaryColor) + setHasOptionsMenu(true) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onDestroy() { + super.onDestroy() + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + sharedPreferences ?: return + when (key) { + PreferKey.defaultCover, + PreferKey.defaultCoverDark -> { + upPreferenceSummary(key, getPrefString(key)) + } + PreferKey.coverShowName -> { + findPreference(PreferKey.coverShowAuthor) + ?.isEnabled = getPrefBoolean(key) + BookCover.upDefaultCover() + } + PreferKey.coverShowNameN -> { + findPreference(PreferKey.coverShowAuthorN) + ?.isEnabled = getPrefBoolean(key) + BookCover.upDefaultCover() + } + PreferKey.coverShowAuthor, + PreferKey.coverShowAuthorN -> { + BookCover.upDefaultCover() + } + } + } + + @SuppressLint("PrivateResource") + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + PreferKey.defaultCover -> + if (getPrefString(PreferKey.defaultCover).isNullOrEmpty()) { + selectImage.launch(requestCodeCover) + } else { + context?.selector(items = arrayListOf("删除图片", "选择图片")) { _, i -> + if (i == 0) { + removePref(PreferKey.defaultCover) + BookCover.upDefaultCover() + } else { + selectImage.launch(requestCodeCover) + } + } + } + PreferKey.defaultCoverDark -> + if (getPrefString(PreferKey.defaultCoverDark).isNullOrEmpty()) { + selectImage.launch(requestCodeCoverDark) + } else { + context?.selector(items = arrayListOf("删除图片", "选择图片")) { _, i -> + if (i == 0) { + removePref(PreferKey.defaultCoverDark) + BookCover.upDefaultCover() + } else { + selectImage.launch(requestCodeCoverDark) + } + } + } + } + return super.onPreferenceTreeClick(preference) + } + + private fun upPreferenceSummary(preferenceKey: String, value: String?) { + val preference = findPreference(preferenceKey) ?: return + when (preferenceKey) { + PreferKey.defaultCover, + PreferKey.defaultCoverDark -> preference.summary = if (value.isNullOrBlank()) { + getString(R.string.select_image) + } else { + value + } + else -> preference.summary = value + } + } + + private fun setCoverFromUri(preferenceKey: String, uri: Uri) { + readUri(uri) { name, bytes -> + var file = requireContext().externalFiles + file = FileUtils.createFileIfNotExist(file, "covers", name) + file.writeBytes(bytes) + putPrefString(preferenceKey, file.absolutePath) + BookCover.upDefaultCover() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/DirectLinkUploadConfig.kt b/app/src/main/java/io/legado/app/ui/config/DirectLinkUploadConfig.kt new file mode 100644 index 000000000..b9e4ade7e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/DirectLinkUploadConfig.kt @@ -0,0 +1,59 @@ +package io.legado.app.ui.config + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogDirectLinkUploadConfigBinding +import io.legado.app.help.DirectLinkUpload +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.setLayout +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import splitties.views.onClick + +class DirectLinkUploadConfig : BaseDialogFragment(R.layout.dialog_direct_link_upload_config) { + + private val binding by viewBinding(DialogDirectLinkUploadConfigBinding::bind) + + override fun onStart() { + super.onStart() + setLayout( + 0.9f, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.editUploadUrl.setText(DirectLinkUpload.getUploadUrl()) + binding.editDownloadUrlRule.setText(DirectLinkUpload.getDownloadUrlRule()) + binding.editSummary.setText(DirectLinkUpload.getSummary()) + binding.tvCancel.onClick { + dismiss() + } + binding.tvFooterLeft.onClick { + DirectLinkUpload.delete() + dismiss() + } + binding.tvOk.onClick { + val uploadUrl = binding.editUploadUrl.text?.toString() + val downloadUrlRule = binding.editDownloadUrlRule.text?.toString() + val summary = binding.editSummary.text?.toString() + uploadUrl ?: let { + toastOnUi("上传Url不能为空") + return@onClick + } + downloadUrlRule ?: let { + toastOnUi("下载Url规则不能为空") + return@onClick + } + DirectLinkUpload.putUploadUrl(uploadUrl) + DirectLinkUpload.putDownloadUrlRule(downloadUrlRule) + DirectLinkUpload.putSummary(summary) + dismiss() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/OtherConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/OtherConfigFragment.kt new file mode 100644 index 000000000..b4b4d4ceb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/OtherConfigFragment.kt @@ -0,0 +1,193 @@ +package io.legado.app.ui.config + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import androidx.preference.ListPreference +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.receiver.SharedReceiverActivity +import io.legado.app.service.WebService +import io.legado.app.ui.widget.number.NumberPickerDialog +import io.legado.app.utils.* +import splitties.init.appCtx + + +class OtherConfigFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val packageManager = appCtx.packageManager + private val componentName = ComponentName( + appCtx, + SharedReceiverActivity::class.java.name + ) + private val webPort get() = getPrefInt(PreferKey.webPort, 1122) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + putPrefBoolean(PreferKey.processText, isProcessTextEnabled()) + addPreferencesFromResource(R.xml.pref_config_other) + if (AppConfig.isGooglePlay) { + preferenceScreen.removePreferenceRecursively("Cronet") + } + upPreferenceSummary(PreferKey.userAgent, AppConfig.userAgent) + upPreferenceSummary(PreferKey.preDownloadNum, AppConfig.preDownloadNum.toString()) + upPreferenceSummary(PreferKey.threadCount, AppConfig.threadCount.toString()) + upPreferenceSummary(PreferKey.webPort, webPort.toString()) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity?.setTitle(R.string.other_setting) + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + listView.setEdgeEffectColor(primaryColor) + } + + override fun onDestroy() { + super.onDestroy() + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + PreferKey.userAgent -> showUserAgentDialog() + PreferKey.preDownloadNum -> NumberPickerDialog(requireContext()) + .setTitle(getString(R.string.pre_download)) + .setMaxValue(9999) + .setMinValue(1) + .setValue(AppConfig.preDownloadNum) + .show { + AppConfig.preDownloadNum = it + } + PreferKey.threadCount -> NumberPickerDialog(requireContext()) + .setTitle(getString(R.string.threads_num_title)) + .setMaxValue(999) + .setMinValue(1) + .setValue(AppConfig.threadCount) + .show { + AppConfig.threadCount = it + } + PreferKey.webPort -> NumberPickerDialog(requireContext()) + .setTitle(getString(R.string.web_port_title)) + .setMaxValue(60000) + .setMinValue(1024) + .setValue(webPort) + .show { + putPrefInt(PreferKey.webPort, it) + } + PreferKey.cleanCache -> clearCache() + "uploadRule" -> DirectLinkUploadConfig().show(childFragmentManager, "uploadRuleConfig") + } + return super.onPreferenceTreeClick(preference) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PreferKey.preDownloadNum -> { + upPreferenceSummary(key, AppConfig.preDownloadNum.toString()) + } + PreferKey.threadCount -> { + upPreferenceSummary(key, AppConfig.threadCount.toString()) + postEvent(PreferKey.threadCount, "") + } + PreferKey.webPort -> { + upPreferenceSummary(key, webPort.toString()) + if (WebService.isRun) { + WebService.stop(requireContext()) + WebService.start(requireContext()) + } + } + PreferKey.recordLog -> LogUtils.upLevel() + PreferKey.processText -> sharedPreferences?.let { + setProcessTextEnable(it.getBoolean(key, true)) + } + PreferKey.showDiscovery, PreferKey.showRss -> postEvent(EventBus.NOTIFY_MAIN, true) + PreferKey.language -> listView.postDelayed({ + appCtx.restart() + }, 1000) + PreferKey.userAgent -> listView.post { + upPreferenceSummary(PreferKey.userAgent, AppConfig.userAgent) + } + } + } + + private fun upPreferenceSummary(preferenceKey: String, value: String?) { + val preference = findPreference(preferenceKey) ?: return + when (preferenceKey) { + PreferKey.preDownloadNum -> preference.summary = + getString(R.string.pre_download_s, value) + PreferKey.threadCount -> preference.summary = getString(R.string.threads_num, value) + PreferKey.webPort -> preference.summary = getString(R.string.web_port_summary, value) + else -> if (preference is ListPreference) { + val index = preference.findIndexOfValue(value) + // Set the summary to reflect the new value. + preference.summary = if (index >= 0) preference.entries[index] else null + } else { + preference.summary = value + } + } + } + + @SuppressLint("InflateParams") + private fun showUserAgentDialog() { + alert("UserAgent") { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "UserAgent" + editView.setText(AppConfig.userAgent) + } + customView { alertBinding.root } + okButton { + val userAgent = alertBinding.editView.text?.toString() + if (userAgent.isNullOrBlank()) { + removePref(PreferKey.userAgent) + } else { + putPrefString(PreferKey.userAgent, userAgent) + } + } + noButton() + } + } + + private fun clearCache() { + requireContext().alert( + titleResource = R.string.clear_cache, + messageResource = R.string.sure_del + ) { + okButton { + BookHelp.clearCache() + FileUtils.deleteFile(requireActivity().cacheDir.absolutePath) + toastOnUi(R.string.clear_cache_success) + } + noButton() + } + } + + private fun isProcessTextEnabled(): Boolean { + return packageManager.getComponentEnabledSetting(componentName) != PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + private fun setProcessTextEnable(enable: Boolean) { + if (enable) { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP + ) + } else { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt new file mode 100644 index 000000000..06828f99d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt @@ -0,0 +1,305 @@ +package io.legado.app.ui.config + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.widget.SeekBar +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.AppContextWrapper +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.AppConst +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogImageBlurringBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.LauncherIconHelp +import io.legado.app.help.ThemeConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.number.NumberPickerDialog +import io.legado.app.ui.widget.prefs.ColorPreference +import io.legado.app.ui.widget.seekbar.SeekBarChangeListener +import io.legado.app.utils.* + + +@Suppress("SameParameterValue") +class ThemeConfigFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val requestCodeBgLight = 121 + private val requestCodeBgDark = 122 + private val selectImage = registerForActivityResult(SelectImageContract()) { + it.uri?.let { uri -> + when (it.requestCode) { + requestCodeBgLight -> setBgFromUri(uri, PreferKey.bgImage) { + upTheme(false) + } + requestCodeBgDark -> setBgFromUri(uri, PreferKey.bgImageN) { + upTheme(true) + } + } + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_theme) + if (Build.VERSION.SDK_INT < 26) { + preferenceScreen.removePreferenceRecursively(PreferKey.launcherIcon) + } + upPreferenceSummary(PreferKey.bgImage, getPrefString(PreferKey.bgImage)) + upPreferenceSummary(PreferKey.bgImageN, getPrefString(PreferKey.bgImageN)) + upPreferenceSummary(PreferKey.barElevation, AppConfig.elevation.toString()) + upPreferenceSummary(PreferKey.fontScale) + findPreference(PreferKey.cBackground)?.let { + it.onSaveColor = { color -> + if (!ColorUtils.isColorLight(color)) { + toastOnUi(R.string.day_background_too_dark) + true + } else { + false + } + } + } + findPreference(PreferKey.cNBackground)?.let { + it.onSaveColor = { color -> + if (ColorUtils.isColorLight(color)) { + toastOnUi(R.string.night_background_too_light) + true + } else { + false + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity?.setTitle(R.string.theme_setting) + listView.setEdgeEffectColor(primaryColor) + setHasOptionsMenu(true) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onDestroy() { + super.onDestroy() + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.theme_config, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_theme_mode -> { + AppConfig.isNightTheme = !AppConfig.isNightTheme + ThemeConfig.applyDayNight(requireContext()) + } + } + return super.onOptionsItemSelected(item) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + sharedPreferences ?: return + when (key) { + PreferKey.launcherIcon -> LauncherIconHelp.changeIcon(getPrefString(key)) + PreferKey.transparentStatusBar -> recreateActivities() + PreferKey.immNavigationBar -> recreateActivities() + PreferKey.cPrimary, + PreferKey.cAccent, + PreferKey.cBackground, + PreferKey.cBBackground -> { + upTheme(false) + } + PreferKey.cNPrimary, + PreferKey.cNAccent, + PreferKey.cNBackground, + PreferKey.cNBBackground -> { + upTheme(true) + } + PreferKey.bgImage, + PreferKey.bgImageN -> { + upPreferenceSummary(key, getPrefString(key)) + } + } + + } + + @SuppressLint("PrivateResource") + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (val key = preference?.key) { + PreferKey.barElevation -> NumberPickerDialog(requireContext()) + .setTitle(getString(R.string.bar_elevation)) + .setMaxValue(32) + .setMinValue(0) + .setValue(AppConfig.elevation) + .setCustomButton((R.string.btn_default_s)) { + AppConfig.elevation = AppConst.sysElevation + recreateActivities() + } + .show { + AppConfig.elevation = it + recreateActivities() + } + PreferKey.fontScale -> NumberPickerDialog(requireContext()) + .setTitle(getString(R.string.font_scale)) + .setMaxValue(16) + .setMinValue(8) + .setValue(10) + .setCustomButton((R.string.btn_default_s)) { + putPrefInt(PreferKey.fontScale, 0) + recreateActivities() + } + .show { + putPrefInt(PreferKey.fontScale, it) + recreateActivities() + } + PreferKey.bgImage -> selectBgAction(false) + PreferKey.bgImageN -> selectBgAction(true) + "themeList" -> ThemeListDialog().show(childFragmentManager, "themeList") + "saveDayTheme", + "saveNightTheme" -> alertSaveTheme(key) + "coverConfig" -> (activity as? ConfigActivity) + ?.replaceFragment(ConfigTag.COVER_CONFIG) + } + return super.onPreferenceTreeClick(preference) + } + + @SuppressLint("InflateParams") + private fun alertSaveTheme(key: String) { + alert(R.string.theme_name) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "name" + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { themeName -> + when (key) { + "saveDayTheme" -> { + ThemeConfig.saveDayTheme(requireContext(), themeName) + } + "saveNightTheme" -> { + ThemeConfig.saveNightTheme(requireContext(), themeName) + } + } + } + } + noButton() + } + } + + private fun selectBgAction(isNight: Boolean) { + val bgKey = if (isNight) PreferKey.bgImageN else PreferKey.bgImage + val blurringKey = if (isNight) PreferKey.bgImageNBlurring else PreferKey.bgImageBlurring + val actions = arrayListOf( + getString(R.string.background_image_blurring), + getString(R.string.select_image) + ) + if (!getPrefString(bgKey).isNullOrEmpty()) { + actions.add(getString(R.string.delete)) + } + context?.selector(items = actions) { _, i -> + when (i) { + 0 -> alertImageBlurring(blurringKey) { + upTheme(isNight) + } + 1 -> { + if (isNight) { + selectImage.launch(requestCodeBgDark) + } else { + selectImage.launch(requestCodeBgLight) + } + } + 2 -> { + removePref(bgKey) + upTheme(isNight) + } + } + } + } + + private fun alertImageBlurring(preferKey: String, success: () -> Unit) { + alert(R.string.background_image_blurring) { + val alertBinding = DialogImageBlurringBinding.inflate(layoutInflater).apply { + getPrefInt(preferKey, 0).let { + seekBar.progress = it + textViewValue.text = it.toString() + } + seekBar.setOnSeekBarChangeListener(object : SeekBarChangeListener { + override fun onProgressChanged( + seekBar: SeekBar, + progress: Int, + fromUser: Boolean + ) { + textViewValue.text = progress.toString() + } + }) + } + customView { alertBinding.root } + okButton { + alertBinding.seekBar.progress.let { + putPrefInt(preferKey, it) + success.invoke() + } + } + noButton() + } + } + + private fun upTheme(isNightTheme: Boolean) { + if (AppConfig.isNightTheme == isNightTheme) { + listView.post { + ThemeConfig.applyTheme(requireContext()) + recreateActivities() + } + } + } + + private fun recreateActivities() { + postEvent(EventBus.RECREATE, "") + } + + private fun upPreferenceSummary(preferenceKey: String, value: String? = null) { + val preference = findPreference(preferenceKey) ?: return + when (preferenceKey) { + PreferKey.barElevation -> preference.summary = + getString(R.string.bar_elevation_s, value) + PreferKey.fontScale -> { + val fontScale = AppContextWrapper.getFontScale(requireContext()) + preference.summary = getString(R.string.font_scale_summary, fontScale) + } + PreferKey.bgImage, + PreferKey.bgImageN -> preference.summary = if (value.isNullOrBlank()) { + getString(R.string.select_image) + } else { + value + } + else -> preference.summary = value + } + } + + private fun setBgFromUri(uri: Uri, preferenceKey: String, success: () -> Unit) { + readUri(uri) { name, bytes -> + var file = requireContext().externalFiles + file = FileUtils.createFileIfNotExist(file, preferenceKey, name) + file.writeBytes(bytes) + putPrefString(preferenceKey, file.absolutePath) + success() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ThemeListDialog.kt b/app/src/main/java/io/legado/app/ui/config/ThemeListDialog.kt new file mode 100644 index 000000000..5ff5c30db --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ThemeListDialog.kt @@ -0,0 +1,121 @@ +package io.legado.app.ui.config + +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemThemeConfigBinding +import io.legado.app.help.ThemeConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class ThemeListDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { Adapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.theme_list) + initView() + initMenu() + initData() + } + + private fun initView() = binding.run { + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.addItemDecoration(VerticalDivider(requireContext())) + recyclerView.adapter = adapter + } + + private fun initMenu() = binding.run { + toolBar.setOnMenuItemClickListener(this@ThemeListDialog) + toolBar.inflateMenu(R.menu.theme_list) + toolBar.menu.applyTint(requireContext()) + } + + fun initData() { + adapter.setItems(ThemeConfig.configList) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_import -> { + requireContext().getClipText()?.let { + if (ThemeConfig.addConfig(it)) { + initData() + } else { + toastOnUi("格式不对,添加失败") + } + } + } + } + return true + } + + fun delete(index: Int) { + alert(R.string.delete, R.string.sure_del) { + okButton { + ThemeConfig.delConfig(index) + initData() + } + noButton() + } + } + + fun share(index: Int) { + val json = GSON.toJson(ThemeConfig.configList[index]) + requireContext().share(json, "主题分享") + } + + inner class Adapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemThemeConfigBinding { + return ItemThemeConfigBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemThemeConfigBinding, + item: ThemeConfig.Config, + payloads: MutableList + ) { + binding.apply { + tvName.text = item.themeName + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemThemeConfigBinding) { + binding.apply { + root.setOnClickListener { + ThemeConfig.applyConfig(context, ThemeConfig.configList[holder.layoutPosition]) + } + ivShare.setOnClickListener { + share(holder.layoutPosition) + } + ivDelete.setOnClickListener { + delete(holder.layoutPosition) + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/dict/DictDialog.kt b/app/src/main/java/io/legado/app/ui/dict/DictDialog.kt new file mode 100644 index 000000000..bff079ed7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/dict/DictDialog.kt @@ -0,0 +1,53 @@ +package io.legado.app.ui.dict + +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogDictBinding +import io.legado.app.utils.invisible +import io.legado.app.utils.setHtml +import io.legado.app.utils.setLayout +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding + +/** + * 词典 + */ +class DictDialog() : BaseDialogFragment(R.layout.dialog_dict) { + + constructor(word: String) : this() { + arguments = Bundle().apply { + putString("word", word) + } + } + + private val viewModel by viewModels() + private val binding by viewBinding(DialogDictBinding::bind) + + override fun onStart() { + super.onStart() + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.tvDict.movementMethod = LinkMovementMethod() + val word = arguments?.getString("word") + if (word.isNullOrEmpty()) { + toastOnUi(R.string.cannot_empty) + dismiss() + return + } + viewModel.dictHtmlData.observe(viewLifecycleOwner) { + binding.rotateLoading.invisible() + binding.tvDict.setHtml(it) + } + viewModel.dict(word) + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt b/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt new file mode 100644 index 000000000..41d56d03b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt @@ -0,0 +1,83 @@ +package io.legado.app.ui.dict + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.base.BaseViewModel +import io.legado.app.help.http.get +import io.legado.app.help.http.newCallStrResponse +import io.legado.app.help.http.okHttpClient +import io.legado.app.utils.toastOnUi +import org.jsoup.Jsoup +import java.util.regex.Pattern + +class DictViewModel(application: Application) : BaseViewModel(application) { + + var dictHtmlData: MutableLiveData = MutableLiveData() + + fun dict(word: String) { + if(isChinese(word)){ + baiduDict(word) + }else{ + haiciDict(word) + } + + } + + /** + * 海词英文词典 + * + * @param word + */ + private fun haiciDict(word: String) { + execute { + val body = okHttpClient.newCallStrResponse { + get("https://apii.dict.cn/mini.php", mapOf(Pair("q", word))) + }.body + val jsoup = Jsoup.parse(body!!) + jsoup.body() + }.onSuccess { + dictHtmlData.postValue(it.html()) + }.onError { + context.toastOnUi(it.localizedMessage) + } + } + + /** + * 百度汉语词典 + * + * @param word + */ + private fun baiduDict(word: String) { + execute { + val body = okHttpClient.newCallStrResponse { + get("https://dict.baidu.com/s", mapOf(Pair("wd", word))) + }.body + val jsoup = Jsoup.parse(body!!) + jsoup.select("script").remove()//移除script + jsoup.select("#word-header").remove()//移除单字的header + jsoup.select("#term-header").remove()//移除词语的header + jsoup.select(".more-button").remove()//移除展示更多 + jsoup.select(".disactive").remove() + jsoup.select("#download-wrapper").remove()//移除下载广告 + jsoup.select("#right-panel").remove()//移除右侧广告 + jsoup.select("#content-panel") + }.onSuccess { + dictHtmlData.postValue(it.html()) + }.onError { + context.toastOnUi(it.localizedMessage) + } + } + + /** + * 判断是否包含汉字 + * @param str + * @return + */ + + private fun isChinese(str: String): Boolean { + val p = Pattern.compile("[\u4e00-\u9fa5]") + val m = p.matcher(str) + return m.find() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/document/FilePickerDialog.kt b/app/src/main/java/io/legado/app/ui/document/FilePickerDialog.kt new file mode 100644 index 000000000..466a0385f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/FilePickerDialog.kt @@ -0,0 +1,204 @@ +package io.legado.app.ui.document + +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogFileChooserBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.document.HandleFileContract.Companion.DIR +import io.legado.app.ui.document.HandleFileContract.Companion.FILE +import io.legado.app.ui.document.adapter.FileAdapter +import io.legado.app.ui.document.adapter.PathAdapter +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import java.io.File + + +class FilePickerDialog : BaseDialogFragment(R.layout.dialog_file_chooser), + Toolbar.OnMenuItemClickListener, + FileAdapter.CallBack, + PathAdapter.CallBack { + + companion object { + const val tag = "FileChooserDialog" + + fun show( + manager: FragmentManager, + mode: Int = FILE, + title: String? = null, + initPath: String? = null, + isShowHomeDir: Boolean = false, + isShowUpDir: Boolean = true, + isShowHideDir: Boolean = false, + allowExtensions: Array? = null, + menus: Array? = null + ) { + FilePickerDialog().apply { + val bundle = Bundle() + bundle.putInt("mode", mode) + bundle.putString("title", title) + bundle.putBoolean("isShowHomeDir", isShowHomeDir) + bundle.putBoolean("isShowUpDir", isShowUpDir) + bundle.putBoolean("isShowHideDir", isShowHideDir) + bundle.putString("initPath", initPath) + bundle.putStringArray("allowExtensions", allowExtensions) + bundle.putStringArray("menus", menus) + arguments = bundle + }.show(manager, tag) + } + } + + private val binding by viewBinding(DialogFileChooserBinding::bind) + override var allowExtensions: Array? = null + override val isSelectDir: Boolean + get() = mode == DIR + override var isShowHomeDir: Boolean = false + override var isShowUpDir: Boolean = true + override var isShowHideDir: Boolean = false + var title: String? = null + private var initPath = FileUtils.getSdCardPath() + private var mode: Int = FILE + private lateinit var fileAdapter: FileAdapter + private lateinit var pathAdapter: PathAdapter + private var menus: Array? = null + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.8f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + view.setBackgroundResource(R.color.background_card) + arguments?.let { + mode = it.getInt("mode", FILE) + title = it.getString("title") + isShowHomeDir = it.getBoolean("isShowHomeDir") + isShowUpDir = it.getBoolean("isShowUpDir") + isShowHideDir = it.getBoolean("isShowHideDir") + it.getString("initPath")?.let { path -> + initPath = path + } + allowExtensions = it.getStringArray("allowExtensions") + menus = it.getStringArray("menus") + } + binding.toolBar.title = title ?: let { + if (isSelectDir) { + getString(R.string.folder_chooser) + } else { + getString(R.string.file_chooser) + } + } + initMenu() + initContentView() + refreshCurrentDirPath(initPath) + } + + private fun initMenu() { + binding.toolBar.inflateMenu(R.menu.file_chooser) + if (isSelectDir) { + binding.toolBar.menu.findItem(R.id.menu_ok).isVisible = true + } + menus?.let { + it.forEach { menuTitle -> + binding.toolBar.menu.add(menuTitle) + } + } + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + } + + private fun initContentView() { + fileAdapter = FileAdapter(requireContext(), this) + pathAdapter = PathAdapter(requireContext(), this) + + binding.rvFile.addItemDecoration(VerticalDivider(requireContext())) + binding.rvFile.layoutManager = LinearLayoutManager(activity) + binding.rvFile.adapter = fileAdapter + + binding.rvPath.layoutManager = LinearLayoutManager(activity, RecyclerView.HORIZONTAL, false) + binding.rvPath.adapter = pathAdapter + + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_ok -> fileAdapter.currentPath?.let { + setData(it) + dismissAllowingStateLoss() + } + } + return true + } + + override fun onFileClick(position: Int) { + val fileItem = fileAdapter.getItem(position) + if (fileItem?.isDirectory == true) { + refreshCurrentDirPath(fileItem.path) + } else { + fileItem?.path?.let { path -> + if (mode == DIR) { + toastOnUi("这是文件夹选择,不能选择文件,点击右上角的确定选择文件夹") + } else if (allowExtensions.isNullOrEmpty() || + allowExtensions?.contains(FileUtils.getExtension(path)) == true + ) { + setData(path) + dismissAllowingStateLoss() + } else { + toastOnUi("不能打开此文件") + } + } + } + } + + override fun onPathClick(position: Int) { + refreshCurrentDirPath(pathAdapter.getPath(position)) + } + + private fun refreshCurrentDirPath(currentPath: String) { + if (currentPath == "/") { + pathAdapter.updatePath("/") + } else { + pathAdapter.updatePath(currentPath) + } + fileAdapter.loadData(currentPath) + var adapterCount = fileAdapter.itemCount + if (isShowHomeDir) { + adapterCount-- + } + if (isShowUpDir) { + adapterCount-- + } + if (adapterCount < 1) { + binding.tvEmpty.visible() + binding.tvEmpty.setText(R.string.empty) + } else { + binding.tvEmpty.gone() + } + } + + private fun setData(path: String) { + val data = Intent().setData(Uri.fromFile(File(path))) + (parentFragment as? CallBack)?.onResult(data) + (activity as? CallBack)?.onResult(data) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + activity?.finish() + } + + interface CallBack { + fun onResult(data: Intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/document/HandleFileActivity.kt b/app/src/main/java/io/legado/app/ui/document/HandleFileActivity.kt new file mode 100644 index 000000000..2f4cebcb9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/HandleFileActivity.kt @@ -0,0 +1,217 @@ +package io.legado.app.ui.document + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.webkit.MimeTypeMap +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.Theme +import io.legado.app.databinding.ActivityTranslucenceBinding +import io.legado.app.help.IntentData +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.permission.Permissions +import io.legado.app.lib.permission.PermissionsCompat +import io.legado.app.utils.getJsonArray +import io.legado.app.utils.isContentScheme +import io.legado.app.utils.launch +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import java.io.File + +class HandleFileActivity : + VMBaseActivity( + theme = Theme.Transparent + ), FilePickerDialog.CallBack { + + override val binding by viewBinding(ActivityTranslucenceBinding::inflate) + override val viewModel by viewModels() + private var mode = 0 + + private val selectDocTree = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { + if (uri.isContentScheme()) { + val modeFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + contentResolver.takePersistableUriPermission(uri, modeFlags) + } + onResult(Intent().setData(uri)) + } ?: finish() + } + + private val selectDoc = registerForActivityResult(ActivityResultContracts.OpenDocument()) { + it?.let { + onResult(Intent().setData(it)) + } ?: finish() + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + mode = intent.getIntExtra("mode", 0) + viewModel.errorLiveData.observe(this) { + toastOnUi(it) + finish() + } + val allowExtensions = intent.getStringArrayExtra("allowExtensions") + val selectList: ArrayList> = when (mode) { + HandleFileContract.SYS_DIR -> getDirActions(true) + HandleFileContract.DIR -> getDirActions() + HandleFileContract.FILE -> getFileActions() + HandleFileContract.EXPORT -> arrayListOf( + SelectItem(getString(R.string.upload_url), 111) + ).apply { + addAll(getDirActions()) + } + else -> arrayListOf() + } + intent.getJsonArray>("otherActions")?.let { + selectList.addAll(it) + } + val title = intent.getStringExtra("title") ?: let { + when (mode) { + HandleFileContract.EXPORT -> return@let getString(R.string.export) + HandleFileContract.DIR -> return@let getString(R.string.select_folder) + else -> return@let getString(R.string.select_file) + } + } + alert(title) { + items(selectList) { _, item, _ -> + when (item.value) { + HandleFileContract.DIR -> kotlin.runCatching { + selectDocTree.launch() + }.onFailure { + toastOnUi(R.string.open_sys_dir_picker_error) + checkPermissions { + FilePickerDialog.show( + supportFragmentManager, + mode = HandleFileContract.DIR + ) + } + } + HandleFileContract.FILE -> selectDoc.launch(typesOfExtensions(allowExtensions)) + 10 -> checkPermissions { + FilePickerDialog.show( + supportFragmentManager, + mode = HandleFileContract.DIR + ) + } + 11 -> checkPermissions { + FilePickerDialog.show( + supportFragmentManager, + mode = HandleFileContract.FILE, + allowExtensions = allowExtensions + ) + } + 111 -> getFileData()?.let { + viewModel.upload(it.first, it.second, it.third) { url -> + val uri = Uri.parse(url) + setResult(RESULT_OK, Intent().setData(uri)) + finish() + } + } + else -> { + val path = item.title + val uri = if (path.isContentScheme()) { + Uri.parse(path) + } else { + Uri.fromFile(File(path)) + } + onResult(Intent().setData(uri)) + } + } + } + onCancelled { + finish() + } + } + } + + private fun getFileData(): Triple? { + val fileName = intent.getStringExtra("fileName") + val file = intent.getStringExtra("fileKey")?.let { + IntentData.get(it) + } + val contentType = intent.getStringExtra("contentType") + if (fileName != null && file != null && contentType != null) { + return Triple(fileName, file, contentType) + } + return null + } + + private fun getDirActions(onlySys: Boolean = false): ArrayList> { + return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q || onlySys) { + arrayListOf(SelectItem(getString(R.string.sys_folder_picker), HandleFileContract.DIR)) + } else { + arrayListOf( + SelectItem(getString(R.string.sys_folder_picker), HandleFileContract.DIR), + SelectItem(getString(R.string.app_folder_picker), 10) + ) + } + } + + private fun getFileActions(): ArrayList> { + return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + arrayListOf( + SelectItem(getString(R.string.sys_file_picker), HandleFileContract.FILE), + SelectItem(getString(R.string.app_file_picker), 11) + ) + } else { + arrayListOf(SelectItem(getString(R.string.sys_file_picker), HandleFileContract.FILE)) + } + } + + private fun checkPermissions(success: (() -> Unit)? = null) { + PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { + success?.invoke() + } + .request() + } + + private fun typesOfExtensions(allowExtensions: Array?): Array { + val types = hashSetOf() + if (allowExtensions.isNullOrEmpty()) { + types.add("*/*") + } else { + allowExtensions.forEach { + when (it) { + "*" -> types.add("*/*") + "txt", "xml" -> types.add("text/*") + else -> { + val mime = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(it) + ?: "application/octet-stream" + types.add(mime) + } + } + } + } + return types.toTypedArray() + } + + override fun onResult(data: Intent) { + val uri = data.data + uri ?: let { + finish() + return + } + if (mode == HandleFileContract.EXPORT) { + getFileData()?.let { fileData -> + viewModel.saveToLocal(uri, fileData.first, fileData.second) { savedUri -> + setResult(RESULT_OK, Intent().setData(savedUri)) + finish() + } + } + } else { + setResult(RESULT_OK, data) + finish() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/document/HandleFileContract.kt b/app/src/main/java/io/legado/app/ui/document/HandleFileContract.kt new file mode 100644 index 000000000..76d13d73d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/HandleFileContract.kt @@ -0,0 +1,68 @@ +package io.legado.app.ui.document + +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import io.legado.app.help.IntentData +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.utils.putJson + +@Suppress("unused") +class HandleFileContract : + ActivityResultContract<(HandleFileContract.HandleFileParam.() -> Unit)?, HandleFileContract.Result>() { + + private var requestCode: Int = 0 + + override fun createIntent(context: Context, input: (HandleFileParam.() -> Unit)?): Intent { + val intent = Intent(context, HandleFileActivity::class.java) + val handleFileParam = HandleFileParam() + input?.let { + handleFileParam.apply(input) + } + handleFileParam.let { + requestCode = it.requestCode + intent.putExtra("mode", it.mode) + intent.putExtra("title", it.title) + intent.putExtra("allowExtensions", it.allowExtensions) + intent.putJson("otherActions", it.otherActions) + it.fileData?.let { fileData -> + intent.putExtra("fileName", fileData.first) + intent.putExtra("fileKey", IntentData.put(fileData.second)) + intent.putExtra("contentType", fileData.third) + } + } + return intent + } + + override fun parseResult(resultCode: Int, intent: Intent?): Result { + if (resultCode == RESULT_OK) { + return Result(intent?.data, requestCode) + } + return Result(null, requestCode) + } + + companion object { + const val DIR = 0 + const val FILE = 1 + const val SYS_DIR = 2 + const val EXPORT = 3 + } + + @Suppress("ArrayInDataClass") + data class HandleFileParam( + var mode: Int = DIR, + var title: String? = null, + var allowExtensions: Array = arrayOf(), + var otherActions: ArrayList>? = null, + var fileData: Triple? = null, + var requestCode: Int = 0 + ) + + data class Result( + val uri: Uri?, + val requestCode: Int + ) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/document/HandleFileViewModel.kt b/app/src/main/java/io/legado/app/ui/document/HandleFileViewModel.kt new file mode 100644 index 000000000..b82bedd2a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/HandleFileViewModel.kt @@ -0,0 +1,67 @@ +package io.legado.app.ui.document + +import android.app.Application +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.MutableLiveData +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppLog +import io.legado.app.help.DirectLinkUpload +import io.legado.app.utils.FileUtils +import io.legado.app.utils.GSON +import io.legado.app.utils.isContentScheme + +import io.legado.app.utils.writeBytes +import timber.log.Timber +import java.io.File + +class HandleFileViewModel(application: Application) : BaseViewModel(application) { + + val errorLiveData = MutableLiveData() + + fun upload( + fileName: String, + file: Any, + contentType: String, + success: (url: String) -> Unit + ) { + execute { + DirectLinkUpload.upLoad(fileName, file, contentType) + }.onSuccess { + success.invoke(it) + }.onError { + AppLog.put("上传文件失败\n${it.localizedMessage}", it) + Timber.e(it) + errorLiveData.postValue(it.localizedMessage) + } + } + + fun saveToLocal(uri: Uri, fileName: String, data: Any, success: (uri: Uri) -> Unit) { + execute { + val bytes = when (data) { + is File -> data.readBytes() + is ByteArray -> data + is String -> data.toByteArray() + else -> GSON.toJson(data).toByteArray() + } + return@execute if (uri.isContentScheme()) { + val doc = DocumentFile.fromTreeUri(context, uri)!! + doc.findFile(fileName)?.delete() + val newDoc = doc.createFile("", fileName) + newDoc!!.writeBytes(context, bytes) + newDoc.uri + } else { + val file = File(uri.path!!) + val newFile = FileUtils.createFileIfNotExist(file, fileName) + newFile.writeBytes(bytes) + Uri.fromFile(newFile) + } + }.onError { + Timber.e(it) + errorLiveData.postValue(it.localizedMessage) + }.onSuccess { + success.invoke(it) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/document/adapter/FileAdapter.kt b/app/src/main/java/io/legado/app/ui/document/adapter/FileAdapter.kt new file mode 100644 index 000000000..975b68409 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/adapter/FileAdapter.kt @@ -0,0 +1,158 @@ +package io.legado.app.ui.document.adapter + + +import android.content.Context +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemFileFilepickerBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.getPrimaryDisabledTextColor +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.ui.document.entity.FileItem +import io.legado.app.ui.document.utils.FilePickerIcon +import io.legado.app.utils.ConvertUtils +import io.legado.app.utils.FileUtils +import java.io.File +import java.util.* + + +class FileAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + private var rootPath: String? = null + var currentPath: String? = null + private set + private val homeIcon = ConvertUtils.toDrawable(FilePickerIcon.getHome()) + private val upIcon = ConvertUtils.toDrawable(FilePickerIcon.getUpDir()) + private val folderIcon = ConvertUtils.toDrawable(FilePickerIcon.getFolder()) + private val fileIcon = ConvertUtils.toDrawable(FilePickerIcon.getFile()) + private val primaryTextColor = context.getPrimaryTextColor(!AppConfig.isNightTheme) + private val disabledTextColor = context.getPrimaryDisabledTextColor(!AppConfig.isNightTheme) + + fun loadData(path: String?) { + if (path == null) { + return + } + val data = ArrayList() + if (rootPath == null) { + rootPath = path + } + currentPath = path + if (callBack.isShowHomeDir) { + //添加“返回主目录” + val fileRoot = FileItem() + fileRoot.isDirectory = true + fileRoot.icon = homeIcon + fileRoot.name = DIR_ROOT + fileRoot.size = 0 + fileRoot.path = rootPath ?: path + data.add(fileRoot) + } + if (callBack.isShowUpDir && path != PathAdapter.sdCardDirectory) { + //添加“返回上一级目录” + val fileParent = FileItem() + fileParent.isDirectory = true + fileParent.icon = upIcon + fileParent.name = DIR_PARENT + fileParent.size = 0 + fileParent.path = File(path).parent ?: "" + data.add(fileParent) + } + currentPath?.let { currentPath -> + val files: Array? = FileUtils.listDirsAndFiles(currentPath) + if (files != null) { + for (file in files) { + if (!callBack.isShowHideDir && file.name.startsWith(".")) { + continue + } + val fileItem = FileItem() + val isDirectory = file.isDirectory + fileItem.isDirectory = isDirectory + if (isDirectory) { + fileItem.icon = folderIcon + fileItem.size = 0 + } else { + fileItem.icon = fileIcon + fileItem.size = file.length() + } + fileItem.name = file.name + fileItem.path = file.absolutePath + data.add(fileItem) + } + } + setItems(data) + } + + } + + override fun getViewBinding(parent: ViewGroup): ItemFileFilepickerBinding { + return ItemFileFilepickerBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemFileFilepickerBinding, + item: FileItem, + payloads: MutableList + ) { + binding.apply { + imageView.setImageDrawable(item.icon) + textView.text = item.name + if (item.isDirectory) { + textView.setTextColor(primaryTextColor) + } else { + if (callBack.isSelectDir) { + textView.setTextColor(disabledTextColor) + } else { + callBack.allowExtensions?.let { + if (it.isEmpty() || it.contains(FileUtils.getExtension(item.path))) { + textView.setTextColor(primaryTextColor) + } else { + textView.setTextColor(disabledTextColor) + } + } ?: textView.setTextColor(primaryTextColor) + } + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemFileFilepickerBinding) { + holder.itemView.setOnClickListener { + callBack.onFileClick(holder.layoutPosition) + } + } + + interface CallBack { + fun onFileClick(position: Int) + + //允许的扩展名 + var allowExtensions: Array? + + /** + * 是否选取目录 + */ + val isSelectDir: Boolean + + /** + * 是否显示返回主目录 + */ + var isShowHomeDir: Boolean + + /** + * 是否显示返回上一级 + */ + var isShowUpDir: Boolean + + /** + * 是否显示隐藏的目录(以“.”开头) + */ + var isShowHideDir: Boolean + } + + companion object { + const val DIR_ROOT = "." + const val DIR_PARENT = ".." + } + +} + diff --git a/app/src/main/java/io/legado/app/ui/document/adapter/PathAdapter.kt b/app/src/main/java/io/legado/app/ui/document/adapter/PathAdapter.kt new file mode 100644 index 000000000..bc8ecd4e5 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/adapter/PathAdapter.kt @@ -0,0 +1,78 @@ +package io.legado.app.ui.document.adapter + +import android.content.Context +import android.os.Environment +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemPathFilepickerBinding +import io.legado.app.ui.document.utils.FilePickerIcon +import io.legado.app.utils.ConvertUtils +import java.util.* + + +class PathAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + private val paths = LinkedList() + private val arrowIcon = ConvertUtils.toDrawable(FilePickerIcon.getArrow()) + + fun getPath(position: Int): String { + val tmp = StringBuilder("$sdCardDirectory/") + //忽略根目录 + if (position == 0) { + return tmp.toString() + } + for (i in 1..position) { + tmp.append(paths[i]).append("/") + } + return tmp.toString() + } + + fun updatePath(path: String) { + var path1 = path + path1 = path1.replace(sdCardDirectory, "") + paths.clear() + if (path1 != "/" && path1 != "") { + val subDirs = path1.substring(path1.indexOf("/") + 1) + .split("/") + .dropLastWhile { it.isEmpty() } + .toTypedArray() + Collections.addAll(paths, *subDirs) + } + paths.addFirst(ROOT_HINT) + setItems(paths) + } + + override fun getViewBinding(parent: ViewGroup): ItemPathFilepickerBinding { + return ItemPathFilepickerBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemPathFilepickerBinding, + item: String, + payloads: MutableList + ) { + binding.apply { + textView.text = item + imageView.setImageDrawable(arrowIcon) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemPathFilepickerBinding) { + holder.itemView.setOnClickListener { + callBack.onPathClick(holder.layoutPosition) + } + } + + interface CallBack { + fun onPathClick(position: Int) + } + + companion object { + private const val ROOT_HINT = "SD" + + @Suppress("DEPRECATION") + val sdCardDirectory: String = Environment.getExternalStorageDirectory().absolutePath + } +} diff --git a/app/src/main/java/io/legado/app/ui/document/entity/FileItem.kt b/app/src/main/java/io/legado/app/ui/document/entity/FileItem.kt new file mode 100644 index 000000000..88cd6b6b2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/entity/FileItem.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.document.entity + +import android.graphics.drawable.Drawable + +/** + * 文件项信息 + * + * @author 李玉江[QQ:1032694760] + * @since 2014-05-23 18:02 + */ +class FileItem : JavaBean() { + var icon: Drawable? = null + var name: String? = null + var path = "/" + var size: Long = 0 + var isDirectory = false +} diff --git a/app/src/main/java/io/legado/app/ui/document/entity/JavaBean.kt b/app/src/main/java/io/legado/app/ui/document/entity/JavaBean.kt new file mode 100644 index 000000000..eb81f1c71 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/entity/JavaBean.kt @@ -0,0 +1,52 @@ +package io.legado.app.ui.document.entity + +import java.io.Serializable +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.util.* + +/** + * JavaBean类 + * + * @author 李玉江[QQ:1032694760] + * @since 2014-04-23 16:14 + */ +open class JavaBean : Serializable { + + /** + * 反射出所有字段值 + */ + override fun toString(): String { + val list = ArrayList() + var clazz: Class<*>? = javaClass + list.addAll(listOf(*clazz!!.declaredFields))//得到自身的所有字段 + val sb = StringBuilder() + while (clazz != Any::class.java) { + clazz = clazz!!.superclass//得到继承自父类的字段 + val fields = clazz!!.declaredFields + for (field in fields) { + val modifier = field.modifiers + if (Modifier.isPublic(modifier) || Modifier.isProtected(modifier)) { + list.add(field) + } + } + } + val fields = list.toTypedArray() + for (field in fields) { + val fieldName = field.name + kotlin.runCatching { + val obj = field.get(this) + sb.append(fieldName) + sb.append("=") + sb.append(obj) + sb.append("\n") + } + } + return sb.toString() + } + + companion object { + private const val serialVersionUID = -6111323241670458039L + } + +} diff --git a/app/src/main/java/io/legado/app/ui/document/utils/FilePickerIcon.java b/app/src/main/java/io/legado/app/ui/document/utils/FilePickerIcon.java new file mode 100644 index 000000000..0f3447f6a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/document/utils/FilePickerIcon.java @@ -0,0 +1,283 @@ +package io.legado.app.ui.document.utils; + +/** + * Generated by https://github.com/gzu-liyujiang/Image2ByteVar + * + * @author 李玉江[QQ:1023694760] + * @since 2017/01/04 06:03 + */ +public class FilePickerIcon { + + public static byte[] getFile() { + return FILE; + } + + public static byte[] getFolder() { + return FOLDER; + } + + public static byte[] getHome() { + return HOME; + } + + public static byte[] getUpDir() { + return UPDIR; + } + + public static byte[] getArrow() { + return ARROW; + } + + // fixed: 17-1-7 "static final" arrays should be "private" + private static final byte[] FILE = { + -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, + 0, 0, 0, 40, 8, 6, 0, 0, 0, -120, 11, 104, 80, 0, 0, 0, 6, 98, 75, 71, + 68, 0, -1, 0, -1, 0, -1, -96, -67, -89, -109, 0, 0, 0, 9, 112, 72, 89, 115, 0, + 0, 14, -60, 0, 0, 14, -60, 1, -107, 43, 14, 27, 0, 0, 1, -73, 73, 68, 65, 84, + 88, -123, -19, -106, 63, 75, 3, 49, 24, -121, 127, -74, 69, -37, 110, -30, -94, -109, -120, -126, + -125, -101, 56, 72, 63, -125, -97, -64, -63, 77, 28, 69, 20, 81, 68, 113, 83, 113, -12, 91, + -120, -96, -117, -120, -85, 31, 65, 29, 28, -100, -124, -94, -101, 46, -83, -105, 92, 114, 113, -24, + 31, -38, -110, 59, -34, -9, 114, 87, 69, -14, 64, 41, -92, 73, -34, -89, -55, -17, 114, 1, + 60, 30, -113, -25, 79, 50, 66, -19, 120, -7, 16, 26, 0, -112, 82, -61, 24, 3, 41, 35, + 0, -128, 20, 10, 74, 69, -48, 58, -126, 82, -90, -37, -90, -75, -127, 82, 81, 119, 124, 16, + 40, -100, 109, 79, -109, -21, 13, 82, -94, 118, -108, 82, 99, -91, 54, -58, 25, 2, 0, 120, + -85, 7, -72, -71, 127, -57, -18, -58, 12, -102, -51, 87, 115, 113, 56, -105, 74, -74, 64, -19, + 104, -116, 73, 51, 63, 0, -96, 88, 104, -107, 57, -34, -102, -59, -6, -63, 75, -86, -119, -56, + -94, -99, -83, -26, -96, 123, -122, 20, -37, -107, 78, -9, -25, -79, -74, -13, -52, -106, 37, -117, + 114, -23, 72, 42, 109, -96, -93, 8, 66, 0, -97, 95, -83, -49, -55, -34, 2, 86, 55, 31, + 89, -78, -12, -116, 10, -59, 18, -20, 48, 90, -86, -96, -47, -48, -72, -66, -5, 64, -40, -98, + -94, 90, -27, -81, 15, 89, -76, -9, 9, -114, 99, 80, 18, 0, -90, 38, -127, -38, -46, 4, + -76, 78, -97, 113, -128, 33, 42, -124, 78, -4, -35, 38, -87, -62, -42, -9, -14, -30, 120, 127, + -69, 6, -82, 110, -21, -44, -46, 0, -72, 103, 77, 12, 73, -110, 125, 109, -55, -1, 53, 17, + 86, 70, 109, 66, 113, -72, 72, -39, 32, -89, -38, 53, 99, -82, 100, -14, 48, -39, -74, 57, + 107, -100, -49, -47, -84, -77, 24, 7, 121, 69, -125, -64, 126, -114, 82, -91, -66, 3, 106, 37, + 59, -12, 21, 85, -46, -87, 80, -91, -20, 52, -100, 46, -38, -108, -87, 111, 104, -103, -64, 58, + 71, -121, -15, -48, -60, -63, -54, -24, -80, -14, 104, 35, -73, -37, 83, -42, -112, 69, 5, -15, + -10, -108, 23, -12, 3, 63, -92, -65, 63, 43, 101, -5, -10, 7, 14, -111, 112, -66, -108, -28, + -111, 71, 27, -1, 47, -93, -65, 77, 38, -9, 81, 27, 46, 121, -76, -63, 18, 29, 86, 30, + 109, -80, 68, -113, -50, -97, -14, -14, -16, 120, 60, -98, 1, 126, 0, -110, 81, -78, -5, 36, + 19, -64, 3, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; + private static final byte[] FOLDER = { + -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, + 0, 0, 0, 40, 8, 6, 0, 0, 0, -120, 11, 104, 80, 0, 0, 0, 6, 98, 75, 71, + 68, 0, -1, 0, -1, 0, -1, -96, -67, -89, -109, 0, 0, 0, 9, 112, 72, 89, 115, 0, + 0, 14, -60, 0, 0, 14, -60, 1, -107, 43, 14, 27, 0, 0, 2, -102, 73, 68, 65, 84, + 88, -123, -19, -105, 61, -113, 19, 49, 16, -122, 95, 111, -110, 75, -124, -124, -60, -49, 65, 20, + -48, 34, 81, 80, 32, 81, 83, 80, -94, 52, 72, 8, 78, 66, -94, 64, 20, -108, 124, 20, + 39, -47, 64, -51, -11, -7, 87, -96, 11, -38, -75, 61, 99, 15, -59, 110, 54, 118, -42, -5, + 113, -71, 77, 117, -5, 54, -55, -50, -38, -42, -77, -81, 61, 99, 27, -104, 52, 105, -46, -92, + 73, -73, 66, 106, 104, -61, -51, 57, -92, -21, -3, -29, 79, -61, -57, 58, 70, -13, 33, -115, + 54, -25, -112, 71, -17, 127, 0, 0, -106, -38, -105, 65, 93, 64, -56, 0, 108, 33, 76, -72, + -28, -113, -14, -20, -77, 59, 25, 108, 54, 4, -14, -63, -6, 59, -112, 123, -84, -4, -84, -114, + -117, 39, 96, -74, -17, -2, -12, -59, 91, 92, -66, -103, 117, -70, 126, 19, 117, 58, -80, 57, + -121, 60, 92, 127, 5, 0, -84, -106, -53, -3, 11, 93, -108, -96, 0, 96, 52, -124, 9, 96, + 6, 116, -127, -33, -65, -66, -44, -51, 56, -117, -121, -73, 62, 3, -7, 56, -74, 123, 126, -11, + -83, -24, 100, -23, -99, -6, -43, -67, -5, -119, -96, -119, -66, 80, 1, -128, 49, 0, -128, -25, + 31, -98, -108, 65, 103, -93, 46, -62, -36, 24, -58, -27, -37, -6, -65, -39, -66, -108, -41, 63, + -13, 86, -40, 65, 107, 84, -7, 43, 64, -103, 70, 92, 108, 17, -45, 82, -94, -115, -47, 77, + -40, -22, 35, -108, 53, 32, 34, 56, 34, 112, -49, -14, 30, 4, 90, 66, -2, -119, 99, -34, + 2, -69, -23, -33, -119, -10, -32, 18, -66, -37, -63, 114, 16, 99, -122, 2, -64, 87, 127, 97, + -56, -61, -53, 24, -96, -121, -14, -107, 35, 103, 11, -120, -91, 116, 27, -25, -45, -15, 96, 9, + 80, -95, 97, -56, -61, 114, 127, 14, -10, 102, 125, 27, 36, -128, 86, -56, -70, 34, -52, 50, + -128, 109, -78, 77, 40, 118, -82, -73, 77, -65, -93, 98, 26, -128, -111, -62, 41, 62, -101, 67, + 116, -111, 110, -41, 34, -53, 2, -26, 22, -9, 3, 29, 53, -11, -111, -109, -39, -94, -4, -83, + 0, 85, -74, -120, -41, -25, 72, -22, 4, -27, -86, -58, -119, 45, -102, -119, 3, 52, -36, -124, + 61, 40, 65, 81, -58, 55, -5, -117, 99, -80, 115, -89, 115, 52, 9, 93, 65, -90, -36, 76, + 65, 94, 87, -67, -96, 74, -52, -2, 52, -46, 54, -91, -95, -109, -119, 108, 87, -13, -59, 126, + -9, 74, 40, -109, 27, 58, 106, 124, 6, -79, 40, -117, -7, 33, 100, 0, 23, -71, -72, -37, + -1, -85, 105, 31, -61, 77, 96, -24, -44, -109, 1, 40, -19, 70, 50, 113, -126, -75, -39, -22, + -26, 53, -85, -61, 113, 89, 127, 8, 23, 78, 119, 80, 55, -5, -36, -12, 117, 34, -11, 23, + -4, 78, 80, -14, 10, 112, 22, -30, 92, -5, -6, 28, 2, 25, -70, -103, 112, -46, -75, -19, + 98, 67, 65, 57, 60, -110, -11, 13, 118, 4, -92, -9, 2, 79, 4, -94, 17, 118, 38, 97, + 6, -104, 64, -108, -34, -103, -124, -35, -63, 115, -20, -68, -46, 58, 124, 2, 0, 56, 91, -18, + 118, -123, -74, -48, 122, 88, -78, 117, -126, 90, 95, 102, -80, 49, 5, -88, 26, 80, -101, -46, + 33, 83, -24, -42, 126, 53, 116, 42, 97, -104, -22, 2, -97, 111, 115, 108, -13, 17, 64, 1, + -64, 85, 103, 71, 50, 84, 1, -106, 110, 88, 106, 95, 10, 93, -5, -67, -81, 62, -44, 22, + 26, 84, 1, -33, -67, -77, -24, 5, -19, -67, -116, 93, -84, 87, 2, 32, -70, 66, -104, -32, + -112, -53, 94, -63, -113, 112, 1, 125, 119, -15, -17, -92, -73, -40, 73, -109, 38, 77, -70, -19, + -6, 15, -2, -54, -98, -96, -19, -118, -95, -10, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, + 96, -126}; + private static final byte[] HOME = { + -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, + 0, 0, 0, 40, 8, 4, 0, 0, 0, 34, 2, -96, -37, 0, 0, 0, 1, 115, 82, 71, + 66, 0, -82, -50, 28, -23, 0, 0, 0, 2, 98, 75, 71, 68, 0, -1, -121, -113, -52, -65, + 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 46, 35, 0, 0, 46, 35, 1, 120, -91, 63, + 118, 0, 0, 0, 7, 116, 73, 77, 69, 7, -37, 8, 4, 10, 36, 16, -22, -9, -18, -50, + 0, 0, 2, -84, 73, 68, 65, 84, 72, -57, -19, -106, 91, 72, 20, 97, 20, -57, 127, -77, + -18, -106, -41, 54, -14, -106, -23, -102, 4, 21, 97, 5, 5, 61, 70, 74, -76, -122, -94, -11, + 18, -108, -92, 121, 41, 16, -124, 96, -95, 48, -118, -108, -94, 18, -70, 96, 33, 68, -81, -127, + 15, 25, 68, 15, 21, 89, 80, 42, 25, 42, -91, 97, -106, -103, -122, 107, 41, -19, -86, -37, + -22, 122, -39, 77, -73, -103, -81, 7, -105, 84, -36, -85, -26, -125, -44, -127, -31, 59, -52, -103, + -7, 113, -50, -103, -17, -4, -65, -127, -1, -74, 44, 76, -102, 113, -29, 22, -119, 50, -3, -15, + 84, 75, -111, -87, -38, -81, -89, 84, 24, -120, 1, -96, -106, -102, -65, 83, -66, -118, 59, -119, + 39, 54, 97, 69, -31, -109, 115, -14, 8, 15, 124, -107, -17, 27, 42, 113, 123, 125, 81, 46, + 102, 28, 8, -84, -68, 116, 78, -26, 80, 77, 5, -79, 104, -48, -48, 70, -39, 124, -88, -38, + 103, 37, -107, -70, -94, 28, -6, 105, 7, 34, 89, 67, -86, -90, -74, 106, 82, -51, 65, 125, + -110, 13, 27, -99, 43, 2, -17, -87, -60, -51, -124, -30, 99, -104, -88, -63, -12, 20, -93, -90, + 96, 111, -80, -106, 20, 117, -3, -35, -97, -65, 54, -13, 29, 21, 8, -9, -3, -14, -122, -68, + 17, 127, 50, 15, 51, -49, 49, 85, -109, 73, -79, 51, -81, 94, -103, 96, 21, 123, -126, 66, + 86, 10, 64, 4, 12, -107, -72, -70, -50, -112, -57, 0, 47, -24, 123, 66, 46, 50, 80, -19, + 40, 121, -59, 20, -31, -20, 102, 0, 121, 1, -48, -14, -72, 83, -7, 12, 81, -121, -79, -106, + 67, 76, -71, -18, 94, -73, 85, 54, 34, 19, -122, 115, -102, 23, 16, -12, 114, 92, 73, 1, + 22, 26, -24, 110, -26, 0, -114, 89, 17, -61, -32, -61, 22, 4, 2, -127, -30, -31, 125, -9, + -48, -117, -79, 103, -13, -79, -48, 68, -57, 123, -46, 25, -101, 19, -109, 57, -38, -41, -40, -127, + 64, 16, -127, 70, -113, -34, 63, 104, 105, -52, -7, 2, -84, -76, -48, -42, 69, 26, -42, 121, + 113, 59, 89, 93, -35, -67, 8, -126, -39, -87, 81, -35, 103, -69, 111, -24, -71, -24, 11, -123, + 12, -45, -58, -37, -81, -20, -61, -20, -74, 18, 11, -23, -19, -125, 102, 20, -76, 36, 107, 121, + 76, -68, 119, -24, -103, -88, 75, -123, -116, -16, -111, 38, 51, 122, -66, 121, -4, -116, 95, 68, + -42, 59, -5, 8, -126, 24, 54, -24, 120, 68, -124, 103, -24, -23, -88, -14, -29, -40, -24, -28, + -75, 85, -92, -47, -27, 117, 48, -102, -27, -20, 86, 121, 28, 5, 29, 107, 119, 112, 111, -10, + 24, 5, -51, -72, 17, -122, -32, 107, -39, -110, -99, 30, -22, -58, -108, -3, -76, -6, 20, -93, + -49, -78, -59, -102, 17, -50, 40, -126, -47, -115, 66, 59, 94, -29, 78, 80, -86, -40, 74, -108, + 20, -119, 34, 50, -88, -13, 83, 58, -81, -112, 73, 47, 70, -116, 52, -104, -34, 120, 86, 41, + -119, 4, 10, 1, 24, -26, -106, 71, 88, 10, 41, 0, 60, -93, -47, 31, -107, 18, -124, -123, + -106, -19, -30, 7, 31, 122, -68, 64, 83, 19, 75, -75, -12, 51, 108, -101, -127, -6, -40, -4, + 97, -92, -110, -20, 67, 18, -109, -40, -58, 106, -113, -86, -66, 96, 83, -36, 15, -2, 34, -96, + -110, 88, 8, 84, -8, 60, -37, 60, 67, -43, -18, -127, 2, 1, 26, -74, 120, -124, 70, 11, + 20, -108, 64, -113, 104, -119, 88, -99, -24, 112, -105, -71, 112, 117, -44, 22, 88, -90, 32, 8, + -27, 48, 33, 76, 119, 78, -52, 89, 101, 20, -100, -12, 49, 21, 24, 84, 97, 12, 59, 19, + 46, -44, -36, 75, 65, 70, 70, 118, -83, -2, 67, 101, -21, 80, -123, -65, -69, -64, -79, -68, + 127, -48, -106, 4, -6, -113, -37, 111, 38, -57, 11, 112, 71, 102, 113, -50, 0, 0, 0, 0, + 73, 69, 78, 68, -82, 66, 96, -126}; + private static final byte[] UPDIR = { + -119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 42, + 0, 0, 0, 40, 8, 6, 0, 0, 0, -120, 11, 104, 80, 0, 0, 0, 6, 98, 75, 71, + 68, 0, -1, 0, -1, 0, -1, -96, -67, -89, -109, 0, 0, 0, 9, 112, 72, 89, 115, 0, + 0, 14, -60, 0, 0, 14, -60, 1, -107, 43, 14, 27, 0, 0, 2, -111, 73, 68, 65, 84, + 88, -123, -19, -106, -51, 74, -21, 64, 20, -57, -1, -87, -47, 7, -15, 41, 92, 118, -47, 82, + 10, -59, 110, 20, -95, 32, 77, -101, 44, -92, -37, -66, 80, -23, 3, 8, -126, -76, -48, -115, + -120, 43, 117, -19, -109, -44, -113, -50, -57, -103, 115, 87, 103, 76, 106, -117, 77, 19, -17, -107, + 75, -2, 48, -52, -112, 100, 38, -65, 57, -25, -52, 57, 3, 84, -86, 84, -87, -46, -1, -83, + 36, 73, 56, 73, 18, -2, -87, -11, -61, 50, 22, -119, -29, -104, 47, 47, 47, 97, -116, -127, + -75, -106, 39, -109, 73, 80, -58, -70, 105, 21, 94, 48, -114, 99, 78, -110, 4, 68, 4, -91, + 20, -116, 49, 120, 121, 121, -63, 120, 60, 46, 21, -74, 86, 100, -14, 112, 56, -28, -85, -85, + 43, 0, -128, -75, 22, -50, 57, 48, 51, -114, -113, -113, -47, -21, -11, 74, 13, -125, -67, 65, + -121, -61, 33, -113, 70, 35, 4, 65, 0, 99, -116, 111, -42, 90, 16, 17, 58, -99, 14, 46, + 46, 46, 74, -125, -35, 11, 84, 44, -23, -100, -61, -57, -57, 7, 86, -85, 21, -116, 49, 32, + 34, 111, 89, 107, 45, 90, -83, 22, -50, -50, -50, 74, -127, -51, 125, -104, 6, -125, 1, -57, + 113, -20, -83, -89, -108, -126, -75, 22, 0, -32, -100, -13, 45, 8, 2, 48, 51, -102, -51, 38, + -100, 115, 124, 125, 125, 93, 40, 102, 115, -127, 14, 6, 3, -114, -94, -56, -69, 87, 107, 13, + 34, 2, 0, -1, 44, 8, -78, 60, 68, -124, 70, -93, 1, 34, -30, -101, -101, -101, -67, 97, + 119, 6, -115, -94, -120, 123, -67, -98, -121, 19, 43, 10, -88, -12, -58, 24, 56, -25, -4, 55, + -78, -127, 122, -67, 14, 34, -30, -37, -37, -37, -67, 96, 119, -102, 20, 69, 17, 119, -69, 93, + 15, 98, -83, -59, -63, -63, 1, 0, 32, 12, -61, 12, -88, -124, -61, -37, -37, 27, -34, -33, + -33, -3, -122, 68, 15, 15, 15, -104, -49, -25, -71, 97, -65, -99, -48, -17, -9, -71, -35, 110, + -5, 83, 45, 58, 58, 58, -14, -112, 34, -79, -30, -21, -21, 43, -106, -53, 37, -76, -42, 0, + 0, -26, -49, -13, 20, -122, 33, -18, -17, -17, -79, 88, 44, 114, -63, 126, -21, 122, 102, -58, + 108, 54, -13, 22, 19, 32, 0, 56, 61, 61, -3, -14, 76, 107, -115, -43, 106, 5, 34, -62, + -45, -45, 83, -26, -35, -6, -72, 84, -48, -23, 116, -70, 113, -25, -25, -25, -25, -20, -100, -53, + -4, 60, 29, -109, 114, -6, 103, -77, 89, 41, 21, 106, -17, 90, 47, -112, 2, 8, -64, 3, + -118, -85, -45, -33, 20, -43, -34, -96, -52, -100, 57, -35, -64, 103, -116, -82, 3, -1, 83, 80, + -87, 62, 2, -72, -98, 79, 5, -74, 44, -107, 2, 42, 112, 0, 50, -15, -7, 43, 64, -103, + 121, 35, -24, -81, 118, -67, -28, -41, -76, -53, -91, -43, -21, 117, -106, 119, -23, 94, -58, -113, + -113, -113, 59, 101, -123, -62, -96, -101, -54, -87, 88, -14, -28, -28, 4, -121, -121, -121, 25, 48, + -71, 35, 40, -91, -16, -4, -4, -68, -13, -1, 10, -71, 62, 125, -75, 91, 7, -83, -43, 106, + 30, 74, 42, -104, -28, 89, -83, 53, -76, -42, -71, 98, -72, -112, 69, -91, 68, 110, -69, -96, + -92, 123, -7, 70, 41, 5, -91, 84, -18, 10, 85, 10, 104, 58, 70, -45, 125, -6, -80, 73, + 47, 22, -51, -85, 66, -82, 87, 74, 1, -128, -17, 55, -127, 109, 2, -1, -85, -96, 98, -47, + -76, 91, -73, 1, -82, -113, 1, -8, 107, -30, -113, -125, -34, -35, -35, 5, 68, -28, 19, -27, + -74, -12, 83, 102, -46, -81, 84, -87, 82, -91, 95, -88, 63, 49, -122, -88, 68, 127, -55, -90, + 73, 0, 0, 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; + private static final byte[] ARROW = {-119, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 48, + 0, 0, 0, 117, 8, 3, 0, 0, 0, 63, 73, -110, 106, 0, 0, 2, -9, 80, 76, 84, + 69, -46, -46, -46, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -65, -65, -65, 0, + 0, 0, -52, -52, -52, -49, -49, -49, -47, -47, -47, -47, -47, -47, -48, -48, -48, -54, -54, -54, + -48, -48, -48, -48, -48, -48, -47, -47, -47, -47, -47, -47, -49, -49, -49, -86, -86, -86, -48, -48, + -48, -48, -48, -48, -47, -47, -47, -52, -52, -52, -49, -49, -49, -48, -48, -48, -48, -48, -48, -1, + -1, -1, -49, -49, -49, -48, -48, -48, -48, -48, -48, -51, -51, -51, -50, -50, -50, -48, -48, -48, + -48, -48, -48, -50, -50, -50, -48, -48, -48, -48, -48, -48, -48, -48, -48, -52, -52, -52, -48, -48, + -48, -48, -48, -48, -48, -48, -48, -46, -46, -46, -47, -47, -47, -52, -52, -52, -48, -48, -48, -48, + -48, -48, -1, -1, -1, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, + -48, -48, -48, -41, -41, -41, -48, -48, -48, -49, -49, -49, -43, -43, -43, -47, -47, -47, -49, -49, + -49, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -49, -49, -49, -47, + -47, -47, -49, -49, -49, -47, -47, -47, -48, -48, -48, -48, -48, -48, -48, -48, -48, -49, -49, -49, + -47, -47, -47, -44, -44, -44, -48, -48, -48, -48, -48, -48, -1, -1, -1, -46, -46, -46, -48, -48, + -48, -49, -49, -49, -47, -47, -47, -49, -49, -49, -35, -35, -35, -49, -49, -49, -49, -49, -49, -49, + -49, -49, -47, -47, -47, -40, -40, -40, -49, -49, -49, -48, -48, -48, -48, -48, -48, -47, -47, -47, + -47, -47, -47, -47, -47, -47, -48, -48, -48, -60, -60, -60, -53, -53, -53, -48, -48, -48, -46, -46, + -46, -65, -65, -65, -48, -48, -48, -54, -54, -54, -45, -45, -45, -47, -47, -47, -49, -49, -49, -48, + -48, -48, -49, -49, -49, -51, -51, -51, -48, -48, -48, -48, -48, -48, -46, -46, -46, -47, -47, -47, + -47, -47, -47, -37, -37, -37, -47, -47, -47, -49, -49, -49, -50, -50, -50, -48, -48, -48, -128, -128, + -128, -48, -48, -48, -48, -48, -48, -50, -50, -50, -48, -48, -48, -48, -48, -48, -43, -43, -43, -47, + -47, -47, -48, -48, -48, -48, -48, -48, -48, -48, -48, -43, -43, -43, -47, -47, -47, -48, -48, -48, + -50, -50, -50, -49, -49, -49, -48, -48, -48, -48, -48, -48, -33, -33, -33, -48, -48, -48, -52, -52, + -52, -51, -51, -51, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -52, + -52, -52, -48, -48, -48, -46, -46, -46, -48, -48, -48, -37, -37, -37, -29, -29, -29, -48, -48, -48, + -48, -48, -48, -47, -47, -47, -48, -48, -48, -49, -49, -49, -46, -46, -46, -48, -48, -48, -49, -49, + -49, -47, -47, -47, -58, -58, -58, -49, -49, -49, -47, -47, -47, -48, -48, -48, -50, -50, -50, -47, + -47, -47, -50, -50, -50, -48, -48, -48, -48, -48, -48, -50, -50, -50, -48, -48, -48, -48, -48, -48, + -48, -48, -48, -55, -55, -55, -45, -45, -45, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, -48, + -48, -46, -46, -46, -47, -47, -47, -48, -48, -48, -47, -47, -47, -51, -51, -51, -47, -47, -47, -51, + -51, -51, -48, -48, -48, -47, -47, -47, -48, -48, -48, -65, -65, -65, -48, -48, -48, -50, -50, -50, + -48, -48, -48, -47, -47, -47, -49, -49, -49, -47, -47, -47, -47, -47, -47, -48, -48, -48, -48, -48, + -48, -50, -50, -50, -47, -47, -47, -49, -49, -49, -49, -49, -49, -47, -47, -47, -48, -48, -48, -47, + -47, -47, -49, -49, -49, -39, -39, -39, -50, -50, -50, -47, -47, -47, -48, -48, -48, -42, -42, -42, + -47, -47, -47, -46, -46, -46, -47, -47, -47, -47, -47, -47, -48, -48, -48, -49, -49, -49, -49, -49, + -49, -47, -47, -47, -48, -48, -48, -47, -47, -47, -48, -48, -48, -50, -50, -50, -47, -47, -47, -48, + -48, -48, -47, -47, -47, -49, -49, -49, -48, -48, -48, -49, -49, -49, -50, -50, -50, -47, -47, -47, + -56, -56, -56, -48, -48, -48, -48, -48, -48, -40, -40, -40, -45, -45, -45, -47, -47, -47, -47, -47, + -47, -49, -49, -49, -48, -48, -48, -43, -43, -43, -52, -52, -52, -49, -49, -49, -47, -47, -47, -47, + -47, -47, -50, -50, -50, -49, -49, -49, -47, -47, -47, -49, -49, -49, -48, -48, -48, -49, -49, -49, + -115, 52, -27, 40, 0, 0, 0, -3, 116, 82, 78, 83, 34, -37, -1, -7, 120, 4, 0, 5, + 118, -9, -41, 38, 24, -46, -2, -100, 78, 69, 3, -98, -54, 28, 40, -37, -8, 115, 3, 122, + -6, -43, 36, 31, -49, -109, 78, 70, -93, -50, 20, 43, -38, -10, 113, 123, 30, -45, -115, 2, + 86, -5, -9, 65, -94, -55, 19, -29, 111, 6, 127, 32, -39, -3, -114, 92, -4, 58, -86, -63, + 22, -32, 109, -126, -5, -51, 36, -44, -121, 1, 90, -11, 59, -79, -59, 15, 48, -11, 106, -123, + 26, 37, -40, 98, -3, -14, -80, -61, 13, 44, -25, 103, 8, -120, 29, 35, -35, -4, -127, 101, + 46, -76, -71, 17, -24, 99, 7, -118, -58, 42, 124, 2, 103, -13, 47, -67, -65, 12, 55, -27, + -116, -57, 24, 44, -35, 119, 112, -21, 49, 8, -70, 10, 51, -19, -14, 93, -111, -62, 25, -30, + 119, -78, 14, 9, -108, 113, 117, -18, -53, 63, -23, -15, 89, 9, -106, -2, -66, 21, 50, -125, + -33, -28, 57, 97, -97, -73, 19, 69, -12, -17, 81, -87, 57, 94, -99, -73, 72, -18, 82, -88, + -20, 27, 12, -100, 67, -16, 83, 16, -91, -122, 54, -26, 104, -101, 64, 85, 11, -82, 100, -101, + 20, 84, -47, 125, 31, -102, 62, -57, -88, -125, 107, -102, -69, -15, -53, -77, 52, -31, -105, 66, + -21, 87, 121, 110, -67, 23, -60, -84, 13, 46, 111, 88, -75, 119, 42, 50, -32, 106, -107, 63, + 91, -78, 117, 114, -108, 41, -51, -28, -65, 0, 0, 3, -107, 73, 68, 65, 84, 88, -61, -107, + -40, 105, 84, -108, 85, 24, 7, 112, -4, -113, -13, -112, -37, -101, -111, -96, 78, 67, -94, 38, + -18, -66, 14, 46, 52, -38, -28, -96, 34, 90, -67, -31, -106, 75, -114, 70, 74, 42, 90, 86, + -109, 4, 38, -88, -111, -90, 50, -126, -72, -46, -54, -102, 38, -102, -53, 104, -101, 123, -88, -71, + 78, -18, -91, -90, 88, 82, 46, 89, 90, -71, -107, -27, 7, 95, 62, 120, -114, -100, -93, -121, + -5, 127, 63, -65, -65, 15, -9, -36, 123, -97, -5, 127, -98, -96, -96, 106, -80, 84, -73, -118, + -14, 23, 20, -4, 64, 13, -44, -84, 69, 0, -87, 93, 7, -38, -125, 12, -112, -70, 22, -32, + 33, 6, -124, 60, 12, -44, 11, 37, -128, -124, -43, 71, -125, -122, 33, 4, -80, 61, 98, 71, + -8, -93, 4, -112, 70, 17, -48, 26, 51, 64, -102, 0, 53, -102, 50, 64, 30, -45, -48, 44, + -110, 1, -51, 91, -96, 101, 43, 43, 1, -84, -83, -19, -88, -39, -122, 0, 18, -38, 22, 104, + -89, 19, 64, -38, 59, 16, -43, -127, 1, -42, -114, 64, -89, -50, 4, -112, -80, 104, 68, 61, + -82, 19, -64, -39, -59, -126, -120, -82, 4, -112, -48, 39, -32, 122, -110, 1, -46, -51, 1, 119, + 12, 3, -84, -35, -127, 30, 61, 9, 32, -51, 99, 17, -43, 75, 39, 64, 112, 92, 111, -12, + 105, 68, 0, 121, -22, 105, 104, -49, 48, -64, 120, 22, -120, -17, 75, 0, -111, 126, 64, -1, + 1, 12, 24, 24, -117, -25, 6, -39, 8, -32, 28, -20, 64, -77, 33, 4, -112, -95, -49, 3, + 113, 6, 1, 100, -104, 7, -61, 99, 24, -32, 28, 1, -68, -112, 64, 0, 121, 113, 36, -30, + 71, -23, 4, 72, 124, -55, -114, 78, -93, 9, 32, 99, -58, 34, 105, 28, 3, 100, -68, 27, + 13, 94, 102, -128, -13, 21, 96, -62, -85, 4, -112, -127, -81, 33, -2, 117, -125, 0, -34, 55, + -110, 48, 49, -108, 0, -110, -36, 2, -82, 55, 25, -112, -110, 10, 76, 122, -117, 0, 34, -109, + -127, -76, 116, 6, 76, -103, -118, 73, -61, 116, 2, 120, -89, -71, -15, 118, 6, 1, -28, -99, + -23, -64, 12, -125, 0, -58, -69, -64, -52, 89, 4, -112, -39, -26, -70, 51, 125, 4, -112, 57, + 89, -56, -98, -101, 66, 0, 95, -114, 3, -13, -26, 19, 64, -110, 23, -64, -79, -112, 1, 82, + -35, -123, -8, -95, 12, -16, -103, -21, 94, -76, -104, 0, 50, 101, 38, -122, 119, 51, 8, -112, + -5, -98, 11, 105, -75, 9, 80, 81, 58, -19, -17, 51, 64, -1, 0, -8, 112, 12, 1, -60, + -6, -111, -71, -18, -39, 4, -112, -113, -13, -112, 95, -112, 66, -128, -62, 34, -83, -8, -109, 37, + 4, -112, -91, -79, -16, -92, 50, -64, -8, 20, 88, -58, 0, -55, 1, 74, 24, -80, 124, 36, + 44, 43, 8, -112, -16, -103, -85, 120, -27, 42, 2, -84, 94, 3, -1, 90, 67, 29, -92, -41, + 1, -42, 89, -43, 55, -50, 54, 24, -120, 14, 34, -114, -58, -25, 37, 112, 127, 65, 28, 62, + -33, -105, -59, -38, 87, -99, 9, -16, -11, 122, -8, 55, 16, 23, -56, 23, 14, 108, -12, -86, + 95, 81, 91, 28, -80, 105, 51, 81, 4, -52, 10, 110, 73, 37, -54, -52, -106, -83, 46, -84, + -116, 36, -64, 55, 107, 80, -70, -115, -88, -83, -37, -21, 1, 59, -104, 98, -4, -83, -122, -84, + -47, 68, -71, -33, -71, 11, -10, -35, -60, 11, -108, -66, -57, -115, -52, 72, 2, -20, -51, -122, + 127, -97, -95, 14, 2, 17, -64, 119, 78, -11, 103, 87, -17, 98, -34, -29, 90, -60, -61, -66, + 60, 26, -98, -3, 68, 116, -16, 29, -48, -76, -52, -125, 4, 88, -67, 9, -2, 67, 68, -4, + -15, 30, 6, -70, 39, -86, 7, 44, -37, 17, 32, -21, 40, 17, -31, 118, -106, -64, 50, -120, + 8, -119, -127, -17, 93, -104, -80, -124, 0, 63, 100, 35, -65, 61, -111, -116, -73, 31, 3, -114, + 7, 8, 112, -62, 60, -43, 109, -120, -80, -66, 116, 42, -20, 63, 18, -3, 67, -32, -92, 27, + -89, 14, 18, -96, 111, -39, -67, 79, -11, -3, -128, -17, 52, -16, -109, 83, -67, 105, -46, -51, + 61, -34, 21, 70, -76, 101, 63, -97, -127, 103, 60, -47, -8, 21, -106, 87, 122, -110, -85, 6, + -65, -4, -118, -46, -77, -122, 58, -16, -98, -82, 28, 43, -86, 2, 33, -25, -52, -32, 114, -108, + 104, -64, -25, -100, -127, -3, 60, -47, -30, -5, -54, 93, -72, -16, 27, 1, 98, -4, -56, 46, + 32, -26, 26, 9, 23, -127, -33, -1, 32, -64, 17, 13, 101, 42, 73, -27, -50, 63, -105, 74, + -48, -5, 50, 49, -3, 9, 20, 37, -95, -86, 61, -82, 4, 98, 74, -111, -1, -89, -95, 14, + 114, -51, 123, -4, 87, -94, -6, -56, -53, -8, 27, -56, -69, 66, 12, -43, -82, 94, -125, -25, + 58, 49, -74, 43, -68, -95, 21, 87, 57, -109, -71, 27, -4, -109, -121, -78, 127, 83, -44, -127, + -43, 124, -113, 111, 22, -118, 50, -48, -1, 3, -4, -86, -109, -54, 10, 80, 17, -8, -1, 23, + 117, -112, 123, -53, 108, 41, 50, -44, -63, 109, -80, -19, 79, -78, 17, 39, 44, -102, 0, 0, + 0, 0, 73, 69, 78, 68, -82, 66, 96, -126}; + +} diff --git a/app/src/main/java/io/legado/app/ui/font/FontAdapter.kt b/app/src/main/java/io/legado/app/ui/font/FontAdapter.kt new file mode 100644 index 000000000..31bc06f1f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/font/FontAdapter.kt @@ -0,0 +1,72 @@ +package io.legado.app.ui.font + +import android.content.Context +import android.graphics.Typeface +import android.os.Build +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemFontBinding +import io.legado.app.utils.* +import timber.log.Timber +import java.io.File +import java.net.URLDecoder + +class FontAdapter(context: Context, curFilePath: String, val callBack: CallBack) : + RecyclerAdapter(context) { + + private val curName = URLDecoder.decode(curFilePath, "utf-8") + .substringAfterLast(File.separator) + + override fun getViewBinding(parent: ViewGroup): ItemFontBinding { + return ItemFontBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemFontBinding, + item: FileDoc, + payloads: MutableList + ) { + binding.run { + kotlin.runCatching { + val typeface: Typeface? = if (item.isContentScheme) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.contentResolver + .openFileDescriptor(item.uri, "r") + ?.fileDescriptor?.let { + Typeface.Builder(it).build() + } + } else { + Typeface.createFromFile(RealPathUtil.getPath(context, item.uri)) + } + } else { + Typeface.createFromFile(item.uri.path!!) + } + tvFont.typeface = typeface + }.onFailure { + Timber.e(it) + context.toastOnUi("Read ${item.name} Error: ${it.localizedMessage}") + } + tvFont.text = item.name + root.setOnClickListener { callBack.onFontSelect(item) } + if (item.name == curName) { + ivChecked.visible() + } else { + ivChecked.invisible() + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemFontBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.onFontSelect(it) + } + } + } + + interface CallBack { + fun onFontSelect(docItem: FileDoc) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/font/FontSelectDialog.kt b/app/src/main/java/io/legado/app/ui/font/FontSelectDialog.kt new file mode 100644 index 000000000..d89f8fab5 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/font/FontSelectDialog.kt @@ -0,0 +1,204 @@ +package io.legado.app.ui.font + +import android.net.Uri +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.documentfile.provider.DocumentFile +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.DialogFontSelectBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.permission.Permissions +import io.legado.app.lib.permission.PermissionsCompat +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import java.io.File +import java.util.* +import kotlin.collections.ArrayList + +class FontSelectDialog : BaseDialogFragment(R.layout.dialog_font_select), + Toolbar.OnMenuItemClickListener, + FontAdapter.CallBack { + private val fontRegex = Regex("(?i).*\\.[ot]tf") + private val binding by viewBinding(DialogFontSelectBinding::bind) + private val adapter by lazy { + val curFontPath = callBack?.curFontPath ?: "" + FontAdapter(requireContext(), curFontPath, this) + } + private val selectFontDir = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + if (uri.toString().isContentScheme()) { + putPrefString(PreferKey.fontFolder, uri.toString()) + val doc = DocumentFile.fromTreeUri(requireContext(), uri) + if (doc != null) { + loadFontFiles(doc) + } else { + RealPathUtil.getPath(requireContext(), uri)?.let { path -> + loadFontFilesByPermission(path) + } + } + } else { + uri.path?.let { path -> + putPrefString(PreferKey.fontFolder, path) + loadFontFilesByPermission(path) + } + } + } + } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.select_font) + binding.toolBar.inflateMenu(R.menu.font_select) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener(this) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.adapter = adapter + + val fontPath = getPrefString(PreferKey.fontFolder) + if (fontPath.isNullOrEmpty()) { + openFolder() + } else { + if (fontPath.isContentScheme()) { + val doc = DocumentFile.fromTreeUri(requireContext(), Uri.parse(fontPath)) + if (doc?.canRead() == true) { + loadFontFiles(doc) + } else { + openFolder() + } + } else { + loadFontFilesByPermission(fontPath) + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_default -> { + val requireContext = requireContext() + alert(titleResource = R.string.system_typeface) { + items( + requireContext.resources.getStringArray(R.array.system_typefaces).toList() + ) { _, i -> + AppConfig.systemTypefaces = i + onDefaultFontChange() + dismissAllowingStateLoss() + } + } + } + R.id.menu_other -> { + openFolder() + } + } + return true + } + + private fun openFolder() { + launch(Main) { + val defaultPath = "SD${File.separator}Fonts" + selectFontDir.launch { + otherActions = arrayListOf(SelectItem(defaultPath, -1)) + } + } + } + + private fun getLocalFonts(): ArrayList { + val path = FileUtils.getPath(requireContext().externalFiles, "font") + return DocumentUtils.listFiles(path) { + it.name.matches(fontRegex) + } + } + + private fun loadFontFiles(doc: DocumentFile) { + execute { + val fontItems = DocumentUtils.listFiles(doc.uri) { + it.name.matches(fontRegex) + } + mergeFontItems(fontItems, getLocalFonts()) + }.onSuccess { + adapter.setItems(it) + }.onError { + toastOnUi("getFontFiles:${it.localizedMessage}") + } + } + + private fun loadFontFilesByPermission(path: String) { + PermissionsCompat.Builder(this@FontSelectDialog) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { + loadFontFiles(path) + } + .request() + } + + private fun loadFontFiles(path: String) { + execute { + val fontItems = DocumentUtils.listFiles(path) { + it.name.matches(fontRegex) + } + mergeFontItems(fontItems, getLocalFonts()) + }.onSuccess { + adapter.setItems(it) + }.onError { + toastOnUi("getFontFiles:${it.localizedMessage}") + } + } + + private fun mergeFontItems( + items1: ArrayList, + items2: ArrayList + ): List { + val items = ArrayList(items1) + items2.forEach { item2 -> + var isInFirst = false + items1.forEach for1@{ item1 -> + if (item2.name == item1.name) { + isInFirst = true + return@for1 + } + } + if (!isInFirst) { + items.add(item2) + } + } + return items.sortedWith { o1, o2 -> + o1.name.cnCompare(o2.name) + } + } + + override fun onFontSelect(docItem: FileDoc) { + execute { + callBack?.selectFont(docItem.toString()) + }.onSuccess { + dismissAllowingStateLoss() + } + } + + private fun onDefaultFontChange() { + callBack?.selectFont("") + } + + private val callBack: CallBack? + get() = (parentFragment as? CallBack) ?: (activity as? CallBack) + + interface CallBack { + fun selectFont(path: String) + val curFontPath: String + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/login/SourceLoginActivity.kt b/app/src/main/java/io/legado/app/ui/login/SourceLoginActivity.kt new file mode 100644 index 000000000..779f7a7ea --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/login/SourceLoginActivity.kt @@ -0,0 +1,34 @@ +package io.legado.app.ui.login + +import android.os.Bundle +import androidx.activity.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.BaseSource +import io.legado.app.databinding.ActivitySourceLoginBinding +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class SourceLoginActivity : VMBaseActivity() { + + override val binding by viewBinding(ActivitySourceLoginBinding::inflate) + override val viewModel by viewModels() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + viewModel.initData(intent) { source -> + initView(source) + } + } + + private fun initView(source: BaseSource) { + if (source.loginUi.isNullOrEmpty()) { + supportFragmentManager.beginTransaction() + .replace(R.id.fl_fragment, WebViewLoginFragment()) + .commit() + } else { + showDialogFragment() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt b/app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt new file mode 100644 index 000000000..556599ae7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt @@ -0,0 +1,139 @@ +package io.legado.app.ui.login + +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputType +import android.view.View +import android.view.ViewGroup +import androidx.core.view.setPadding +import androidx.fragment.app.activityViewModels +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.constant.AppLog +import io.legado.app.data.entities.BaseSource +import io.legado.app.databinding.DialogLoginBinding +import io.legado.app.databinding.ItemFilletTextBinding +import io.legado.app.databinding.ItemSourceEditBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import splitties.views.onClick +import timber.log.Timber + +class SourceLoginDialog : BaseDialogFragment(R.layout.dialog_login) { + + private val binding by viewBinding(DialogLoginBinding::bind) + private val viewModel by activityViewModels() + + override fun onStart() { + super.onStart() + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + val source = viewModel.source ?: return + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.title = getString(R.string.login_source, source.getTag()) + val loginInfo = source.getLoginInfoMap() + val loginUi = source.loginUi() + loginUi?.forEachIndexed { index, rowUi -> + when (rowUi.type) { + "text" -> ItemSourceEditBinding.inflate(layoutInflater, binding.root, false).let { + binding.flexbox.addView(it.root) + it.root.id = index + it.textInputLayout.hint = rowUi.name + it.editText.setText(loginInfo?.get(rowUi.name)) + } + "password" -> ItemSourceEditBinding.inflate(layoutInflater, binding.root, false) + .let { + binding.flexbox.addView(it.root) + it.root.id = index + it.textInputLayout.hint = rowUi.name + it.editText.inputType = + InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT + it.editText.setText(loginInfo?.get(rowUi.name)) + } + "button" -> ItemFilletTextBinding.inflate(layoutInflater, binding.root, false).let { + binding.flexbox.addView(it.root) + it.root.id = index + it.textView.text = rowUi.name + it.textView.setPadding(16.dp) + it.root.onClick { + if (rowUi.action.isAbsUrl()) { + context?.openUrl(rowUi.action!!) + } + } + } + } + } + binding.toolBar.inflateMenu(R.menu.source_login) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.menu_ok -> { + val loginData = hashMapOf() + loginUi?.forEachIndexed { index, rowUi -> + when (rowUi.type) { + "text", "password" -> { + val rowView = binding.root.findViewById(index) + ItemSourceEditBinding.bind(rowView).editText.text?.let { + loginData[rowUi.name] = it.toString() + } + } + } + } + login(source, loginData) + } + R.id.menu_show_login_header -> alert { + setTitle(R.string.login_header) + source.getLoginHeader()?.let { loginHeader -> + setMessage(loginHeader) + } + } + R.id.menu_del_login_header -> source.removeLoginHeader() + R.id.menu_log -> showDialogFragment() + } + return@setOnMenuItemClickListener true + } + } + + private fun login(source: BaseSource, loginData: HashMap) { + launch(IO) { + if (loginData.isEmpty()) { + source.removeLoginInfo() + withContext(Main) { + dismiss() + } + } else if (source.putLoginInfo(GSON.toJson(loginData))) { + source.getLoginJs()?.let { + try { + source.evalJS(it) + context?.toastOnUi(R.string.success) + withContext(Main) { + dismiss() + } + } catch (e: Exception) { + AppLog.put("登录出错\n${e.localizedMessage}", e) + context?.toastOnUi("登录出错\n${e.localizedMessage}") + Timber.e(e) + } + } + } + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + activity?.finish() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt b/app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt new file mode 100644 index 000000000..0c03c95cd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt @@ -0,0 +1,34 @@ +package io.legado.app.ui.login + +import android.app.Application +import android.content.Intent +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.BaseSource +import io.legado.app.model.NoStackTraceException +import io.legado.app.utils.toastOnUi + +class SourceLoginViewModel(application: Application) : BaseViewModel(application) { + + var source: BaseSource? = null + + fun initData(intent: Intent, success: (bookSource: BaseSource) -> Unit) { + execute { + val sourceKey = intent.getStringExtra("key") + ?: throw NoStackTraceException("没有参数") + when (intent.getStringExtra("type")) { + "bookSource" -> source = appDb.bookSourceDao.getBookSource(sourceKey) + "rssSource" -> source = appDb.rssSourceDao.getByKey(sourceKey) + "httpTts" -> source = appDb.httpTTSDao.get(sourceKey.toLong()) + } + source + }.onSuccess { + if (it != null) { + success.invoke(it) + } else { + context.toastOnUi("未找到书源") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/login/WebViewLoginFragment.kt b/app/src/main/java/io/legado/app/ui/login/WebViewLoginFragment.kt new file mode 100644 index 000000000..44ad50229 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/login/WebViewLoginFragment.kt @@ -0,0 +1,90 @@ +package io.legado.app.ui.login + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.webkit.CookieManager +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.fragment.app.activityViewModels +import io.legado.app.R +import io.legado.app.base.BaseFragment +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.BaseSource +import io.legado.app.databinding.FragmentWebViewLoginBinding +import io.legado.app.help.http.CookieStore +import io.legado.app.utils.snackbar +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class WebViewLoginFragment : BaseFragment(R.layout.fragment_web_view_login) { + + private val binding by viewBinding(FragmentWebViewLoginBinding::bind) + private val viewModel by activityViewModels() + + private var checking = false + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + viewModel.source?.let { + binding.titleBar.title = getString(R.string.login_source, it.getTag()) + initWebView(it) + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.source_login, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + when (item.itemId) { + R.id.menu_ok -> { + if (!checking) { + checking = true + binding.titleBar.snackbar(R.string.check_host_cookie) + viewModel.source?.loginUrl?.let { + binding.webView.loadUrl(it) + } + } + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initWebView(source: BaseSource) { + val settings = binding.webView.settings + settings.setSupportZoom(true) + settings.builtInZoomControls = true + settings.javaScriptEnabled = true + source.getHeaderMap()[AppConst.UA_NAME]?.let { + settings.userAgentString = it + } + val cookieManager = CookieManager.getInstance() + binding.webView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + val cookie = cookieManager.getCookie(url) + CookieStore.setCookie(source.getKey(), cookie) + super.onPageStarted(view, url, favicon) + } + + override fun onPageFinished(view: WebView?, url: String?) { + val cookie = cookieManager.getCookie(url) + CookieStore.setCookie(source.getKey(), cookie) + if (checking) { + activity?.finish() + } + super.onPageFinished(view, url) + } + } + source.loginUrl?.let { + binding.webView.loadUrl(it) + } + } + + override fun onDestroy() { + super.onDestroy() + binding.webView.destroy() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt new file mode 100644 index 000000000..df4f7007e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -0,0 +1,272 @@ +@file:Suppress("DEPRECATION") + +package io.legado.app.ui.main + +import android.os.Bundle +import android.view.KeyEvent +import android.view.MenuItem +import android.view.ViewGroup +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.viewpager.widget.ViewPager +import com.google.android.material.bottomnavigation.BottomNavigationView +import io.legado.app.BuildConfig +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst.appInfo +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.ActivityMainBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.BookHelp +import io.legado.app.help.LocalConfig +import io.legado.app.help.storage.Backup +import io.legado.app.lib.theme.elevation +import io.legado.app.lib.theme.primaryColor +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment +import io.legado.app.ui.main.bookshelf.style1.BookshelfFragment1 +import io.legado.app.ui.main.bookshelf.style2.BookshelfFragment2 +import io.legado.app.ui.main.explore.ExploreFragment +import io.legado.app.ui.main.my.MyFragment +import io.legado.app.ui.main.rss.RssFragment +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.observeEvent +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class MainActivity : VMBaseActivity(), + BottomNavigationView.OnNavigationItemSelectedListener, + BottomNavigationView.OnNavigationItemReselectedListener { + + override val binding by viewBinding(ActivityMainBinding::inflate) + override val viewModel by viewModels() + private var exitTime: Long = 0 + private var bookshelfReselected: Long = 0 + private var exploreReselected: Long = 0 + private var pagePosition = 0 + private val fragmentMap = hashMapOf() + private var bottomMenuCount = 2 + private val realPositions = arrayOf(0, 1, 2, 3) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + upBottomMenu() + binding.apply { + viewPagerMain.setEdgeEffectColor(primaryColor) + viewPagerMain.offscreenPageLimit = 3 + viewPagerMain.adapter = TabFragmentPageAdapter(supportFragmentManager) + viewPagerMain.addOnPageChangeListener(PageChangeCallback()) + bottomNavigationView.elevation = + if (AppConfig.elevation < 0) elevation else AppConfig.elevation.toFloat() + bottomNavigationView.setOnNavigationItemSelectedListener(this@MainActivity) + bottomNavigationView.setOnNavigationItemReselectedListener(this@MainActivity) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + upVersion() + //自动更新书籍 + if (AppConfig.autoRefreshBook) { + binding.viewPagerMain.postDelayed({ + viewModel.upAllBookToc() + }, 1000) + } + binding.viewPagerMain.postDelayed({ + viewModel.postLoad() + }, 3000) + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean = binding.run { + when (item.itemId) { + R.id.menu_bookshelf -> viewPagerMain.setCurrentItem(0, false) + R.id.menu_discovery -> viewPagerMain.setCurrentItem(1, false) + R.id.menu_rss -> if (AppConfig.showDiscovery) { + viewPagerMain.setCurrentItem(2, false) + } else { + viewPagerMain.setCurrentItem(1, false) + } + R.id.menu_my_config -> viewPagerMain.setCurrentItem(bottomMenuCount - 1, false) + } + return false + } + + override fun onNavigationItemReselected(item: MenuItem) { + when (item.itemId) { + R.id.menu_bookshelf -> { + if (System.currentTimeMillis() - bookshelfReselected > 300) { + bookshelfReselected = System.currentTimeMillis() + } else { + (fragmentMap[getFragmentId(0)] as? BaseBookshelfFragment)?.gotoTop() + } + } + R.id.menu_discovery -> { + if (System.currentTimeMillis() - exploreReselected > 300) { + exploreReselected = System.currentTimeMillis() + } else { + (fragmentMap[1] as? ExploreFragment)?.compressExplore() + } + } + } + } + + private fun upVersion() { + if (LocalConfig.versionCode != appInfo.versionCode) { + LocalConfig.versionCode = appInfo.versionCode + if (LocalConfig.isFirstOpenApp) { + val text = String(assets.open("help/appHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } else if (!BuildConfig.DEBUG) { + val log = String(assets.open("updateLog.md").readBytes()) + showDialogFragment(TextDialog(log, TextDialog.Mode.MD)) + } + viewModel.upVersion() + } + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + event?.let { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> if (event.isTracking && !event.isCanceled) { + if (pagePosition != 0) { + binding.viewPagerMain.currentItem = 0 + return true + } + (fragmentMap[getFragmentId(0)] as? BookshelfFragment2)?.let { + if (it.back()) { + return true + } + } + if (System.currentTimeMillis() - exitTime > 2000) { + toastOnUi(R.string.double_click_exit) + exitTime = System.currentTimeMillis() + } else { + if (BaseReadAloudService.pause) { + finish() + } else { + moveTaskToBack(true) + } + } + return true + } + } + } + return super.onKeyUp(keyCode, event) + } + + override fun onPause() { + super.onPause() + if (!BuildConfig.DEBUG) { + Backup.autoBack(this) + } + } + + override fun onDestroy() { + super.onDestroy() + BookHelp.clearRemovedCache() + } + + override fun observeLiveBus() { + observeEvent(EventBus.RECREATE) { + recreate() + } + observeEvent(EventBus.NOTIFY_MAIN) { + binding.apply { + upBottomMenu() + viewPagerMain.adapter?.notifyDataSetChanged() + if (it) { + viewPagerMain.setCurrentItem(bottomMenuCount - 1, false) + } + } + } + observeEvent(PreferKey.threadCount) { + viewModel.upPool() + } + } + + private fun upBottomMenu() { + val showDiscovery = AppConfig.showDiscovery + val showRss = AppConfig.showRSS + binding.bottomNavigationView.menu.let { menu -> + menu.findItem(R.id.menu_discovery).isVisible = showDiscovery + menu.findItem(R.id.menu_rss).isVisible = showRss + } + bottomMenuCount = 2 + realPositions[1] = 1 + realPositions[2] = 2 + when { + showDiscovery -> bottomMenuCount++ + showRss -> { + realPositions[1] = 2 + realPositions[2] = 3 + } + else -> { + realPositions[1] = 3 + realPositions[2] = 3 + } + } + if (showRss) { + bottomMenuCount++ + } else { + realPositions[2] = 3 + } + } + + private fun getFragmentId(position: Int): Int { + val p = realPositions[position] + if (p == 0) { + return if (AppConfig.bookGroupStyle == 1) 11 else 0 + } + return p + } + + private inner class PageChangeCallback : ViewPager.SimpleOnPageChangeListener() { + + override fun onPageSelected(position: Int) { + pagePosition = position + binding.bottomNavigationView.menu + .getItem(realPositions[position]).isChecked = true + } + + } + + @Suppress("DEPRECATION") + private inner class TabFragmentPageAdapter(fm: FragmentManager) : + FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + private fun getId(position: Int): Int { + return getFragmentId(position) + } + + override fun getItemPosition(`object`: Any): Int { + return POSITION_NONE + } + + override fun getItem(position: Int): Fragment { + return when (getId(position)) { + 0 -> BookshelfFragment1() + 11 -> BookshelfFragment2() + 1 -> ExploreFragment() + 2 -> RssFragment() + else -> MyFragment() + } + } + + override fun getCount(): Int { + return bottomMenuCount + } + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val fragment = super.instantiateItem(container, position) as Fragment + fragmentMap[getId(position)] = fragment + return fragment + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt new file mode 100644 index 000000000..07ffce86d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt @@ -0,0 +1,213 @@ +package io.legado.app.ui.main + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppLog +import io.legado.app.constant.BookType +import io.legado.app.constant.EventBus +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.help.AppConfig +import io.legado.app.help.DefaultData +import io.legado.app.help.LocalConfig +import io.legado.app.model.CacheBook +import io.legado.app.model.webBook.WebBook +import io.legado.app.service.CacheBookService +import io.legado.app.utils.postEvent +import kotlinx.coroutines.* +import timber.log.Timber +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import kotlin.math.min + +class MainViewModel(application: Application) : BaseViewModel(application) { + private var threadCount = AppConfig.threadCount + private var upTocPool = + Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + private val waitUpTocBooks = arrayListOf() + private val onUpTocBooks = CopyOnWriteArraySet() + private var upTocJob: Job? = null + private var cacheBookJob: Job? = null + + override fun onCleared() { + super.onCleared() + upTocPool.close() + } + + fun upPool() { + threadCount = AppConfig.threadCount + upTocPool.close() + upTocPool = Executors + .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() + } + + fun isUpdate(bookUrl: String): Boolean { + return onUpTocBooks.contains(bookUrl) + } + + fun upAllBookToc() { + execute { + addToWaitUp(appDb.bookDao.hasUpdateBooks) + } + } + + fun upToc(books: List) { + execute(context = upTocPool) { + books.filter { + it.origin != BookType.local && it.canUpdate + }.let { + addToWaitUp(it) + } + } + } + + @Synchronized + private fun addToWaitUp(books: List) { + books.forEach { book -> + if (!waitUpTocBooks.contains(book.bookUrl) && !onUpTocBooks.contains(book.bookUrl)) { + waitUpTocBooks.add(book.bookUrl) + } + } + if (upTocJob == null) { + startUpTocJob() + } + } + + private fun startUpTocJob() { + upTocJob = viewModelScope.launch(upTocPool) { + while (isActive) { + when { + waitUpTocBooks.isEmpty() -> { + upTocJob?.cancel() + upTocJob = null + } + onUpTocBooks.size < threadCount -> { + updateToc() + } + else -> { + delay(500) + } + } + } + } + } + + @Synchronized + private fun updateToc() { + val bookUrl = waitUpTocBooks.firstOrNull() ?: return + if (onUpTocBooks.contains(bookUrl)) { + waitUpTocBooks.remove(bookUrl) + return + } + val book = appDb.bookDao.getBook(bookUrl) + if (book == null) { + waitUpTocBooks.remove(bookUrl) + return + } + val source = appDb.bookSourceDao.getBookSource(book.origin) + if (source == null) { + waitUpTocBooks.remove(book.bookUrl) + return + } + waitUpTocBooks.remove(book.bookUrl) + onUpTocBooks.add(book.bookUrl) + postEvent(EventBus.UP_BOOKSHELF, book.bookUrl) + execute(context = upTocPool) { + if (book.tocUrl.isBlank()) { + WebBook.getBookInfoAwait(this, source, book) + } + val toc = WebBook.getChapterListAwait(this, source, book) + appDb.bookDao.update(book) + appDb.bookChapterDao.delByBook(book.bookUrl) + appDb.bookChapterDao.insert(*toc.toTypedArray()) + addDownload(source, book) + }.onError(upTocPool) { + AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it) + Timber.e(it, "${book.name} 更新目录失败") + }.onCancel(upTocPool) { + upTocCancel(book.bookUrl) + }.onFinally(upTocPool) { + upTocFinally(book.bookUrl) + } + } + + @Synchronized + private fun upTocCancel(bookUrl: String) { + onUpTocBooks.remove(bookUrl) + waitUpTocBooks.add(bookUrl) + } + + @Synchronized + private fun upTocFinally(bookUrl: String) { + waitUpTocBooks.remove(bookUrl) + onUpTocBooks.remove(bookUrl) + postEvent(EventBus.UP_BOOKSHELF, bookUrl) + } + + @Synchronized + private fun addDownload(source: BookSource, book: Book) { + val endIndex = min( + book.totalChapterNum - 1, + book.durChapterIndex.plus(AppConfig.preDownloadNum) + ) + val cacheBook = CacheBook.getOrCreate(source, book) + cacheBook.addDownload(book.durChapterIndex, endIndex) + if (cacheBookJob == null && !CacheBookService.isRun) { + cacheBook() + } + } + + /** + * 缓存书籍 + */ + private fun cacheBook() { + cacheBookJob?.cancel() + cacheBookJob = viewModelScope.launch(upTocPool) { + while (isActive) { + if (CacheBookService.isRun) { + cacheBookJob?.cancel() + cacheBookJob = null + return@launch + } + if (!CacheBook.isRun) { + cacheBookJob?.cancel() + cacheBookJob = null + return@launch + } + CacheBook.cacheBookMap.forEach { + while (CacheBook.onDownloadCount > threadCount) { + delay(100) + } + it.value.download(this, upTocPool) + } + } + } + } + + fun postLoad() { + execute { + if (appDb.httpTTSDao.count == 0) { + DefaultData.httpTTS.let { + appDb.httpTTSDao.insert(*it.toTypedArray()) + } + } + } + } + + fun upVersion() { + execute { + if (LocalConfig.needUpHttpTTS) { + DefaultData.importDefaultHttpTTS() + } + if (LocalConfig.needUpTxtTocRule) { + DefaultData.importDefaultTocRules() + } + if (LocalConfig.needUpRssSources) { + DefaultData.importDefaultRssSources() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt new file mode 100644 index 000000000..8dd71e171 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BaseBookshelfFragment.kt @@ -0,0 +1,191 @@ +package io.legado.app.ui.main.bookshelf + +import android.annotation.SuppressLint +import android.view.Menu +import android.view.MenuItem +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.DialogBookshelfConfigBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.DirectLinkUpload +import io.legado.app.lib.dialogs.alert +import io.legado.app.ui.about.AppLogDialog +import io.legado.app.ui.book.arrange.ArrangeBookActivity +import io.legado.app.ui.book.cache.CacheActivity +import io.legado.app.ui.book.group.GroupManageDialog +import io.legado.app.ui.book.local.ImportBookActivity +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.main.MainViewModel +import io.legado.app.utils.* + +abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment(layoutId) { + + val activityViewModel by activityViewModels() + override val viewModel by viewModels() + + private val importBookshelf = registerForActivityResult(HandleFileContract()) { + kotlin.runCatching { + it.uri?.readText(requireContext())?.let { text -> + viewModel.importBookshelf(text, groupId) + } + }.onFailure { + toastOnUi(it.localizedMessage ?: "ERROR") + } + } + private val exportResult = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + alert(R.string.export_success) { + if (uri.toString().isAbsUrl()) { + DirectLinkUpload.getSummary()?.let { summary -> + setMessage(summary) + } + } + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = getString(R.string.path) + editView.setText(uri.toString()) + } + customView { alertBinding.root } + okButton { + requireContext().sendToClip(uri.toString()) + } + } + } + } + abstract val groupId: Long + abstract val books: List + private var groupsLiveData: LiveData>? = null + + abstract fun gotoTop() + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.main_bookshelf, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + super.onCompatOptionsItemSelected(item) + when (item.itemId) { + R.id.menu_search -> startActivity() + R.id.menu_update_toc -> activityViewModel.upToc(books) + R.id.menu_bookshelf_layout -> configBookshelf() + R.id.menu_group_manage -> showDialogFragment() + R.id.menu_add_local -> startActivity() + R.id.menu_add_url -> addBookByUrl() + R.id.menu_arrange_bookshelf -> startActivity { + putExtra("groupId", groupId) + } + R.id.menu_download -> startActivity { + putExtra("groupId", groupId) + } + R.id.menu_export_bookshelf -> viewModel.exportBookshelf(books) { file -> + exportResult.launch { + mode = HandleFileContract.EXPORT + fileData = Triple("bookshelf.json", file, "application/json") + } + } + R.id.menu_import_bookshelf -> importBookshelfAlert(groupId) + R.id.menu_log -> showDialogFragment() + } + } + + protected fun initBookGroupData() { + groupsLiveData?.removeObservers(viewLifecycleOwner) + groupsLiveData = appDb.bookGroupDao.show.apply { + observe(viewLifecycleOwner) { + upGroup(it) + } + } + } + + abstract fun upGroup(data: List) + + @SuppressLint("InflateParams") + fun addBookByUrl() { + alert(titleResource = R.string.add_book_url) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + viewModel.addBookByUrl(it) + } + } + noButton() + } + } + + @SuppressLint("InflateParams") + fun configBookshelf() { + alert(titleResource = R.string.bookshelf_layout) { + val bookshelfLayout = getPrefInt(PreferKey.bookshelfLayout) + val bookshelfSort = getPrefInt(PreferKey.bookshelfSort) + val alertBinding = + DialogBookshelfConfigBinding.inflate(layoutInflater) + .apply { + spGroupStyle.setSelection(AppConfig.bookGroupStyle) + swShowUnread.isChecked = AppConfig.showUnread + rgLayout.checkByIndex(bookshelfLayout) + rgSort.checkByIndex(bookshelfSort) + } + customView { alertBinding.root } + okButton { + alertBinding.apply { + if (AppConfig.bookGroupStyle != spGroupStyle.selectedItemPosition) { + AppConfig.bookGroupStyle = spGroupStyle.selectedItemPosition + postEvent(EventBus.NOTIFY_MAIN, false) + } + if (AppConfig.showUnread != swShowUnread.isChecked) { + AppConfig.showUnread = swShowUnread.isChecked + postEvent(EventBus.BOOKSHELF_REFRESH, "") + } + var changed = false + if (bookshelfLayout != rgLayout.getCheckedIndex()) { + putPrefInt(PreferKey.bookshelfLayout, rgLayout.getCheckedIndex()) + changed = true + } + if (bookshelfSort != rgSort.getCheckedIndex()) { + putPrefInt(PreferKey.bookshelfSort, rgSort.getCheckedIndex()) + changed = true + } + if (changed) { + postEvent(EventBus.RECREATE, "") + } + } + } + noButton() + } + } + + + private fun importBookshelfAlert(groupId: Long) { + alert(titleResource = R.string.import_bookshelf) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url/json" + } + customView { alertBinding.root } + okButton { + alertBinding.editView.text?.toString()?.let { + viewModel.importBookshelf(it, groupId) + } + } + noButton() + neutralButton(R.string.select_file) { + importBookshelf.launch { + mode = HandleFileContract.FILE + allowExtensions = arrayOf("txt", "json") + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt new file mode 100644 index 000000000..471a0d976 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -0,0 +1,151 @@ +package io.legado.app.ui.main.bookshelf + +import android.app.Application +import com.google.gson.stream.JsonWriter +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.model.NoStackTraceException +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.isActive +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStreamWriter + +class BookshelfViewModel(application: Application) : BaseViewModel(application) { + + fun addBookByUrl(bookUrls: String) { + var successCount = 0 + execute { + var hasBookUrlPattern: List? = null + val urls = bookUrls.split("\n") + for (url in urls) { + val bookUrl = url.trim() + if (bookUrl.isEmpty()) continue + if (appDb.bookDao.getBook(bookUrl) != null) continue + val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue + var source = appDb.bookSourceDao.getBookSource(baseUrl) + if (source == null) { + if (hasBookUrlPattern == null) { + hasBookUrlPattern = appDb.bookSourceDao.hasBookUrlPattern + } + hasBookUrlPattern.forEach { bookSource -> + if (bookUrl.matches(bookSource.bookUrlPattern!!.toRegex())) { + source = bookSource + return@forEach + } + } + } + source?.let { bookSource -> + val book = Book( + bookUrl = bookUrl, + origin = bookSource.bookSourceUrl, + originName = bookSource.bookSourceName + ) + WebBook.getBookInfo(this, bookSource, book) + .onSuccess(IO) { + it.order = appDb.bookDao.maxOrder + 1 + it.save() + successCount++ + }.onError { + throw it + } + } + } + }.onSuccess { + if (successCount > 0) { + context.toastOnUi(R.string.success) + } else { + context.toastOnUi("ERROR") + } + }.onError { + context.toastOnUi(it.localizedMessage ?: "ERROR") + } + } + + fun exportBookshelf(books: List?, success: (file: File) -> Unit) { + execute { + books?.let { + val path = "${context.filesDir}/books.json" + FileUtils.delete(path) + val file = FileUtils.createFileWithReplace(path) + @Suppress("BlockingMethodInNonBlockingContext") + FileOutputStream(file).use { out -> + val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) + writer.setIndent(" ") + writer.beginArray() + books.forEach { + val bookMap = hashMapOf() + bookMap["name"] = it.name + bookMap["author"] = it.author + bookMap["intro"] = it.getDisplayIntro() + GSON.toJson(bookMap, bookMap::class.java, writer) + } + writer.endArray() + writer.close() + } + file + } ?: throw NoStackTraceException("书籍不能为空") + }.onSuccess { + success(it) + }.onError { + context.toastOnUi("导出书籍出错\n${it.localizedMessage}") + } + } + + fun importBookshelf(str: String, groupId: Long) { + execute { + val text = str.trim() + when { + text.isAbsUrl() -> { + okHttpClient.newCallResponseBody { + url(text) + }.text().let { + importBookshelf(it, groupId) + } + } + text.isJsonArray() -> { + importBookshelfByJson(text, groupId) + } + else -> { + throw NoStackTraceException("格式不对") + } + } + }.onError { + context.toastOnUi(it.localizedMessage ?: "ERROR") + } + } + + private fun importBookshelfByJson(json: String, groupId: Long) { + execute { + val bookSources = appDb.bookSourceDao.allEnabled + GSON.fromJsonArray>(json)?.forEach { bookInfo -> + if (!isActive) return@execute + val name = bookInfo["name"] ?: "" + val author = bookInfo["author"] ?: "" + if (name.isNotEmpty() && appDb.bookDao.getBook(name, author) == null) { + WebBook.preciseSearch(this, bookSources, name, author) + .onSuccess { + val book = it.second + if (groupId > 0) { + book.group = groupId + } + book.save() + }.onError { e -> + context.toastOnUi(e.localizedMessage) + } + } + } + }.onFinally { + context.toastOnUi(R.string.success) + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/BookshelfFragment1.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/BookshelfFragment1.kt new file mode 100644 index 000000000..afd72e992 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/BookshelfFragment1.kt @@ -0,0 +1,148 @@ +@file:Suppress("DEPRECATION") + +package io.legado.app.ui.main.bookshelf.style1 + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import com.google.android.material.tabs.TabLayout +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.FragmentBookshelfBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment +import io.legado.app.ui.main.bookshelf.style1.books.BooksFragment +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.putPrefInt +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding + +/** + * 书架界面 + */ +class BookshelfFragment1 : BaseBookshelfFragment(R.layout.fragment_bookshelf), + TabLayout.OnTabSelectedListener, + SearchView.OnQueryTextListener { + + private val binding by viewBinding(FragmentBookshelfBinding::bind) + private val adapter by lazy { TabFragmentPageAdapter(childFragmentManager) } + private val tabLayout: TabLayout by lazy { + binding.titleBar.findViewById(R.id.tab_layout) + } + private val bookGroups = mutableListOf() + private val fragmentMap = hashMapOf() + override val groupId: Long get() = selectedGroup?.groupId ?: 0 + + override val books: List + get() { + val fragment = fragmentMap[groupId] + return fragment?.getBooks() ?: emptyList() + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(binding.titleBar.toolbar) + initView() + initBookGroupData() + } + + private val selectedGroup: BookGroup? + get() = bookGroups.getOrNull(tabLayout.selectedTabPosition) + + private fun initView() { + binding.viewPagerBookshelf.setEdgeEffectColor(primaryColor) + tabLayout.isTabIndicatorFullWidth = false + tabLayout.tabMode = TabLayout.MODE_SCROLLABLE + tabLayout.setSelectedTabIndicatorColor(requireContext().accentColor) + tabLayout.setupWithViewPager(binding.viewPagerBookshelf) + binding.viewPagerBookshelf.offscreenPageLimit = 1 + binding.viewPagerBookshelf.adapter = adapter + } + + override fun onQueryTextSubmit(query: String?): Boolean { + SearchActivity.start(requireContext(), query) + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + + @Synchronized + override fun upGroup(data: List) { + if (data.isEmpty()) { + appDb.bookGroupDao.enableGroup(AppConst.bookGroupAllId) + } else { + if (data != bookGroups) { + bookGroups.clear() + bookGroups.addAll(data) + adapter.notifyDataSetChanged() + selectLastTab() + } + } + } + + @Synchronized + private fun selectLastTab() { + tabLayout.removeOnTabSelectedListener(this) + tabLayout.getTabAt(getPrefInt(PreferKey.saveTabPosition, 0))?.select() + tabLayout.addOnTabSelectedListener(this) + } + + override fun onTabReselected(tab: TabLayout.Tab) { + selectedGroup?.let { group -> + fragmentMap[group.groupId]?.let { + toastOnUi("${group.groupName}(${it.getBooksCount()})") + } + } + } + + override fun onTabUnselected(tab: TabLayout.Tab) = Unit + + override fun onTabSelected(tab: TabLayout.Tab) { + putPrefInt(PreferKey.saveTabPosition, tab.position) + } + + override fun gotoTop() { + fragmentMap[groupId]?.gotoTop() + } + + private inner class TabFragmentPageAdapter(fm: FragmentManager) : + FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + override fun getPageTitle(position: Int): CharSequence { + return bookGroups[position].groupName + } + + override fun getItemPosition(`object`: Any): Int { + return POSITION_NONE + } + + override fun getItem(position: Int): Fragment { + val group = bookGroups[position] + return BooksFragment(position, group.groupId) + } + + override fun getCount(): Int { + return bookGroups.size + } + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val fragment = super.instantiateItem(container, position) as BooksFragment + val group = bookGroups[position] + fragmentMap[group.groupId] = fragment + return fragment + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BaseBooksAdapter.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BaseBooksAdapter.kt new file mode 100644 index 000000000..b2dbb3863 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BaseBooksAdapter.kt @@ -0,0 +1,81 @@ +package io.legado.app.ui.main.bookshelf.style1.books + +import android.content.Context +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.DiffUtil +import androidx.viewbinding.ViewBinding +import io.legado.app.base.adapter.DiffRecyclerAdapter +import io.legado.app.data.entities.Book + +abstract class BaseBooksAdapter(context: Context) : + DiffRecyclerAdapter(context) { + + override val diffItemCallback: DiffUtil.ItemCallback + get() = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean { + return oldItem.name == newItem.name + && oldItem.author == newItem.author + } + + override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean { + return when { + oldItem.durChapterTime != newItem.durChapterTime -> false + oldItem.name != newItem.name -> false + oldItem.author != newItem.author -> false + oldItem.durChapterTitle != newItem.durChapterTitle -> false + oldItem.latestChapterTitle != newItem.latestChapterTitle -> false + oldItem.lastCheckCount != newItem.lastCheckCount -> false + oldItem.getDisplayCover() != newItem.getDisplayCover() -> false + oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() -> false + else -> true + } + } + + override fun getChangePayload(oldItem: Book, newItem: Book): Any? { + val bundle = bundleOf() + if (oldItem.name != newItem.name) { + bundle.putString("name", newItem.name) + } + if (oldItem.author != newItem.author) { + bundle.putString("author", newItem.author) + } + if (oldItem.durChapterTitle != newItem.durChapterTitle) { + bundle.putString("dur", newItem.durChapterTitle) + } + if (oldItem.latestChapterTitle != newItem.latestChapterTitle) { + bundle.putString("last", newItem.latestChapterTitle) + } + if (oldItem.getDisplayCover() != newItem.getDisplayCover()) { + bundle.putString("cover", newItem.getDisplayCover()) + } + if (oldItem.lastCheckCount != newItem.lastCheckCount + || oldItem.durChapterTime != newItem.durChapterTime + || oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() + || oldItem.lastCheckCount != newItem.lastCheckCount + ) { + bundle.putBoolean("refresh", true) + } + if (bundle.isEmpty) return null + return bundle + } + + } + + fun notification(bookUrl: String) { + for (i in 0 until itemCount) { + getItem(i)?.let { + if (it.bookUrl == bookUrl) { + notifyItemChanged(i, bundleOf(Pair("refresh", null))) + return + } + } + } + } + + interface CallBack { + fun open(book: Book) + fun openBookInfo(book: Book) + fun isUpdate(bookUrl: String): Boolean + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterGrid.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterGrid.kt new file mode 100644 index 000000000..b029e5eed --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterGrid.kt @@ -0,0 +1,73 @@ +package io.legado.app.ui.main.bookshelf.style1.books + +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.databinding.ItemBookshelfGridBinding +import io.legado.app.help.AppConfig +import io.legado.app.utils.invisible +import splitties.views.onLongClick + +class BooksAdapterGrid(context: Context, private val callBack: CallBack) : + BaseBooksAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemBookshelfGridBinding { + return ItemBookshelfGridBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemBookshelfGridBinding, + item: Book, + payloads: MutableList + ) = binding.run { + val bundle = payloads.getOrNull(0) as? Bundle + if (bundle == null) { + tvName.text = item.name + ivCover.load(item.getDisplayCover(), item.name, item.author) + upRefresh(binding, item) + } else { + bundle.keySet().forEach { + when (it) { + "name" -> tvName.text = item.name + "cover" -> ivCover.load(item.getDisplayCover(), item.name, item.author) + "refresh" -> upRefresh(binding, item) + } + } + } + } + + private fun upRefresh(binding: ItemBookshelfGridBinding, item: Book) { + if (item.origin != BookType.local && callBack.isUpdate(item.bookUrl)) { + binding.bvUnread.invisible() + binding.rlLoading.show() + } else { + binding.rlLoading.hide() + if (AppConfig.showUnread) { + binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) + binding.bvUnread.setHighlight(item.lastCheckCount > 0) + } else { + binding.bvUnread.invisible() + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfGridBinding) { + holder.itemView.apply { + setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.open(it) + } + } + + onLongClick { + getItem(holder.layoutPosition)?.let { + callBack.openBookInfo(it) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterList.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterList.kt new file mode 100644 index 000000000..453cefbd0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksAdapterList.kt @@ -0,0 +1,79 @@ +package io.legado.app.ui.main.bookshelf.style1.books + +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.databinding.ItemBookshelfListBinding +import io.legado.app.help.AppConfig +import io.legado.app.utils.invisible +import splitties.views.onLongClick + +class BooksAdapterList(context: Context, private val callBack: CallBack) : + BaseBooksAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemBookshelfListBinding { + return ItemBookshelfListBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemBookshelfListBinding, + item: Book, + payloads: MutableList + ) = binding.run { + val bundle = payloads.getOrNull(0) as? Bundle + if (bundle == null) { + tvName.text = item.name + tvAuthor.text = item.author + tvRead.text = item.durChapterTitle + tvLast.text = item.latestChapterTitle + ivCover.load(item.getDisplayCover(), item.name, item.author) + upRefresh(binding, item) + } else { + tvRead.text = item.durChapterTitle + tvLast.text = item.latestChapterTitle + bundle.keySet().forEach { + when (it) { + "name" -> tvName.text = item.name + "author" -> tvAuthor.text = item.author + "cover" -> ivCover.load(item.getDisplayCover()) + "refresh" -> upRefresh(binding, item) + } + } + } + } + + private fun upRefresh(binding: ItemBookshelfListBinding, item: Book) { + if (item.origin != BookType.local && callBack.isUpdate(item.bookUrl)) { + binding.bvUnread.invisible() + binding.rlLoading.show() + } else { + binding.rlLoading.hide() + if (AppConfig.showUnread) { + binding.bvUnread.setHighlight(item.lastCheckCount > 0) + binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) + } else { + binding.bvUnread.invisible() + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfListBinding) { + holder.itemView.apply { + setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.open(it) + } + } + + onLongClick { + getItem(holder.layoutPosition)?.let { + callBack.openBookInfo(it) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt new file mode 100644 index 000000000..75f729b6f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt @@ -0,0 +1,177 @@ +package io.legado.app.ui.main.bookshelf.style1.books + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.BaseFragment +import io.legado.app.constant.AppConst +import io.legado.app.constant.BookType +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.databinding.FragmentBooksBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.audio.AudioPlayActivity +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.MainViewModel +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlin.math.max + +/** + * 书架界面 + */ +class BooksFragment() : BaseFragment(R.layout.fragment_books), + BaseBooksAdapter.CallBack { + + constructor(position: Int, groupId: Long) : this() { + val bundle = Bundle() + bundle.putInt("position", position) + bundle.putLong("groupId", groupId) + arguments = bundle + } + + private val binding by viewBinding(FragmentBooksBinding::bind) + private val activityViewModel by activityViewModels() + private val bookshelfLayout by lazy { + getPrefInt(PreferKey.bookshelfLayout) + } + private val booksAdapter: BaseBooksAdapter<*> by lazy { + if (bookshelfLayout == 0) { + BooksAdapterList(requireContext(), this) + } else { + BooksAdapterGrid(requireContext(), this) + } + } + private var booksFlowJob: Job? = null + private var position = 0 + private var groupId = -1L + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + arguments?.let { + position = it.getInt("position", 0) + groupId = it.getLong("groupId", -1) + } + initRecyclerView() + upRecyclerData() + } + + private fun initRecyclerView() { + binding.rvBookshelf.setEdgeEffectColor(primaryColor) + binding.refreshLayout.setColorSchemeColors(accentColor) + binding.refreshLayout.setOnRefreshListener { + binding.refreshLayout.isRefreshing = false + activityViewModel.upToc(booksAdapter.getItems()) + } + if (bookshelfLayout == 0) { + binding.rvBookshelf.layoutManager = LinearLayoutManager(context) + } else { + binding.rvBookshelf.layoutManager = GridLayoutManager(context, bookshelfLayout + 2) + } + binding.rvBookshelf.adapter = booksAdapter + booksAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + val layoutManager = binding.rvBookshelf.layoutManager + if (positionStart == 0 && layoutManager is LinearLayoutManager) { + val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount + binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) + } + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + val layoutManager = binding.rvBookshelf.layoutManager + if (toPosition == 0 && layoutManager is LinearLayoutManager) { + val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount + binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) + } + } + }) + } + + private fun upRecyclerData() { + booksFlowJob?.cancel() + booksFlowJob = launch { + when (groupId) { + AppConst.bookGroupAllId -> appDb.bookDao.flowAll() + AppConst.bookGroupLocalId -> appDb.bookDao.flowLocal() + AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio() + AppConst.bookGroupNoneId -> appDb.bookDao.flowNoGroup() + else -> appDb.bookDao.flowByGroup(groupId) + }.collect { list -> + binding.tvEmptyMsg.isGone = list.isNotEmpty() + val books = when (getPrefInt(PreferKey.bookshelfSort)) { + 1 -> list.sortedByDescending { it.latestChapterTime } + 2 -> list.sortedWith { o1, o2 -> + o1.name.cnCompare(o2.name) + } + 3 -> list.sortedBy { it.order } + else -> list.sortedByDescending { it.durChapterTime } + } + booksAdapter.setItems(books) + } + } + } + + fun getBooks(): List { + return booksAdapter.getItems() + } + + fun gotoTop() { + if (AppConfig.isEInkMode) { + binding.rvBookshelf.scrollToPosition(0) + } else { + binding.rvBookshelf.smoothScrollToPosition(0) + } + } + + fun getBooksCount(): Int { + return booksAdapter.itemCount + } + + override fun open(book: Book) { + when (book.type) { + BookType.audio -> + startActivity { + putExtra("bookUrl", book.bookUrl) + } + else -> startActivity { + putExtra("bookUrl", book.bookUrl) + } + } + } + + override fun openBookInfo(book: Book) { + startActivity { + putExtra("name", book.name) + putExtra("author", book.author) + } + } + + override fun isUpdate(bookUrl: String): Boolean { + return activityViewModel.isUpdate(bookUrl) + } + + @SuppressLint("NotifyDataSetChanged") + override fun observeLiveBus() { + super.observeLiveBus() + observeEvent(EventBus.UP_BOOKSHELF) { + booksAdapter.notification(it) + } + observeEvent(EventBus.BOOKSHELF_REFRESH) { + booksAdapter.notifyDataSetChanged() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BaseBooksAdapter.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BaseBooksAdapter.kt new file mode 100644 index 000000000..6f3a1f9a9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BaseBooksAdapter.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.main.bookshelf.style2 + +import android.content.Context +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.data.entities.Book + +abstract class BaseBooksAdapter( + val context: Context, + val callBack: CallBack +) : RecyclerView.Adapter() { + + val diffItemCallback = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean { + return oldItem.name == newItem.name + && oldItem.author == newItem.author + } + + override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean { + return when { + oldItem.durChapterTime != newItem.durChapterTime -> false + oldItem.name != newItem.name -> false + oldItem.author != newItem.author -> false + oldItem.durChapterTitle != newItem.durChapterTitle -> false + oldItem.latestChapterTitle != newItem.latestChapterTitle -> false + oldItem.lastCheckCount != newItem.lastCheckCount -> false + oldItem.getDisplayCover() != newItem.getDisplayCover() -> false + oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() -> false + else -> true + } + } + + override fun getChangePayload(oldItem: Book, newItem: Book): Any? { + val bundle = bundleOf() + if (oldItem.name != newItem.name) { + bundle.putString("name", newItem.name) + } + if (oldItem.author != newItem.author) { + bundle.putString("author", newItem.author) + } + if (oldItem.durChapterTitle != newItem.durChapterTitle) { + bundle.putString("dur", newItem.durChapterTitle) + } + if (oldItem.latestChapterTitle != newItem.latestChapterTitle) { + bundle.putString("last", newItem.latestChapterTitle) + } + if (oldItem.getDisplayCover() != newItem.getDisplayCover()) { + bundle.putString("cover", newItem.getDisplayCover()) + } + if (oldItem.lastCheckCount != newItem.lastCheckCount + || oldItem.durChapterTime != newItem.durChapterTime + || oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() + || oldItem.lastCheckCount != newItem.lastCheckCount + ) { + bundle.putBoolean("refresh", true) + } + if (bundle.isEmpty) return null + return bundle + } + + } + + fun notification(bookUrl: String) { + for (i in 0 until itemCount) { + callBack.getItem(i).let { + if (it is Book && it.bookUrl == bookUrl) { + notifyItemChanged(i, bundleOf(Pair("refresh", null))) + return + } + } + } + } + + + interface CallBack { + fun onItemClick(position: Int) + fun onItemLongClick(position: Int) + fun isUpdate(bookUrl: String): Boolean + fun getItemCount(): Int + fun getItemType(position: Int): Int + fun getItem(position: Int): Any + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterGrid.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterGrid.kt new file mode 100644 index 000000000..9166759b0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterGrid.kt @@ -0,0 +1,142 @@ +package io.legado.app.ui.main.bookshelf.style2 + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.ItemBookshelfGridBinding +import io.legado.app.databinding.ItemBookshelfGridGroupBinding +import io.legado.app.help.AppConfig +import io.legado.app.utils.invisible +import splitties.views.onLongClick + +class BooksAdapterGrid(context: Context, callBack: CallBack) : + BaseBooksAdapter(context, callBack) { + + override fun getItemCount(): Int { + return callBack.getItemCount() + } + + override fun getItemViewType(position: Int): Int { + return callBack.getItemType(position) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecyclerView.ViewHolder { + return when (viewType) { + 1 -> GroupViewHolder( + ItemBookshelfGridGroupBinding.inflate(LayoutInflater.from(context), parent, false) + ) + else -> BookViewHolder( + ItemBookshelfGridBinding.inflate(LayoutInflater.from(context), parent, false) + ) + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + val bundle = payloads.getOrNull(0) as? Bundle + when { + bundle == null -> super.onBindViewHolder(holder, position, payloads) + holder is BookViewHolder -> onBindBook(holder.binding, position, bundle) + holder is GroupViewHolder -> onBindGroup(holder.binding, position, bundle) + } + } + + + private fun onBindGroup( + binding: ItemBookshelfGridGroupBinding, + position: Int, + @Suppress("UNUSED_PARAMETER") bundle: Bundle + ) { + binding.run { + val item = callBack.getItem(position) as BookGroup + tvName.text = item.groupName + ivCover.load(item.cover) + } + } + + private fun onBindBook(binding: ItemBookshelfGridBinding, position: Int, bundle: Bundle) { + binding.run { + val item = callBack.getItem(position) as Book + bundle.keySet().forEach { + when (it) { + "name" -> tvName.text = item.name + "cover" -> ivCover.load(item.getDisplayCover(), item.name, item.author) + "refresh" -> upRefresh(this, item) + } + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is BookViewHolder -> onBindBook(holder.binding, position) + is GroupViewHolder -> onBindGroup(holder.binding, position) + } + } + + private fun onBindGroup(binding: ItemBookshelfGridGroupBinding, position: Int) { + binding.run { + val item = callBack.getItem(position) + if (item is BookGroup) { + tvName.text = item.groupName + ivCover.load(item.cover) + } + root.setOnClickListener { + callBack.onItemClick(position) + } + root.onLongClick { + callBack.onItemLongClick(position) + } + } + } + + private fun onBindBook(binding: ItemBookshelfGridBinding, position: Int) { + binding.run { + val item = callBack.getItem(position) + if (item is Book) { + tvName.text = item.name + ivCover.load(item.getDisplayCover(), item.name, item.author) + upRefresh(this, item) + } + root.setOnClickListener { + callBack.onItemClick(position) + } + root.onLongClick { + callBack.onItemLongClick(position) + } + } + } + + private fun upRefresh(binding: ItemBookshelfGridBinding, item: Book) { + if (item.origin != BookType.local && callBack.isUpdate(item.bookUrl)) { + binding.bvUnread.invisible() + binding.rlLoading.show() + } else { + binding.rlLoading.hide() + if (AppConfig.showUnread) { + binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) + binding.bvUnread.setHighlight(item.lastCheckCount > 0) + } else { + binding.bvUnread.invisible() + } + } + } + + class BookViewHolder(val binding: ItemBookshelfGridBinding) : + RecyclerView.ViewHolder(binding.root) + + class GroupViewHolder(val binding: ItemBookshelfGridGroupBinding) : + RecyclerView.ViewHolder(binding.root) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterList.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterList.kt new file mode 100644 index 000000000..c2a700ab1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BooksAdapterList.kt @@ -0,0 +1,157 @@ +package io.legado.app.ui.main.bookshelf.style2 + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.ItemBookshelfListBinding +import io.legado.app.databinding.ItemBookshelfListGroupBinding +import io.legado.app.help.AppConfig +import io.legado.app.utils.gone +import io.legado.app.utils.invisible +import io.legado.app.utils.visible +import splitties.views.onLongClick + +class BooksAdapterList(context: Context, callBack: CallBack) : + BaseBooksAdapter(context, callBack) { + + override fun getItemCount(): Int { + return callBack.getItemCount() + } + + override fun getItemViewType(position: Int): Int { + return callBack.getItemType(position) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + 1 -> GroupViewHolder( + ItemBookshelfListGroupBinding.inflate(LayoutInflater.from(context), parent, false) + ) + else -> BookViewHolder( + ItemBookshelfListBinding.inflate(LayoutInflater.from(context), parent, false) + ) + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + val bundle = payloads.getOrNull(0) as? Bundle + when { + bundle == null -> super.onBindViewHolder(holder, position, payloads) + holder is BookViewHolder -> onBindBook(holder.binding, position, bundle) + holder is GroupViewHolder -> onBindGroup(holder.binding, position, bundle) + } + } + + private fun onBindGroup( + binding: ItemBookshelfListGroupBinding, + position: Int, + @Suppress("UNUSED_PARAMETER") bundle: Bundle + ) { + binding.run { + val item = callBack.getItem(position) as BookGroup + tvName.text = item.groupName + ivCover.load(item.cover) + } + } + + private fun onBindBook(binding: ItemBookshelfListBinding, position: Int, bundle: Bundle) { + binding.run { + val item = callBack.getItem(position) as Book + tvRead.text = item.durChapterTitle + tvLast.text = item.latestChapterTitle + bundle.keySet().forEach { + when (it) { + "name" -> tvName.text = item.name + "author" -> tvAuthor.text = item.author + "cover" -> ivCover.load(item.getDisplayCover(), item.name, item.author) + "refresh" -> upRefresh(this, item) + } + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is BookViewHolder -> onBindBook(holder.binding, position) + is GroupViewHolder -> onBindGroup(holder.binding, position) + } + } + + private fun onBindGroup(binding: ItemBookshelfListGroupBinding, position: Int) { + binding.run { + val item = callBack.getItem(position) + if (item is BookGroup) { + tvName.text = item.groupName + ivCover.load(item.cover) + flHasNew.gone() + ivAuthor.gone() + ivLast.gone() + ivRead.gone() + tvAuthor.gone() + tvLast.gone() + tvRead.gone() + } + root.setOnClickListener { + callBack.onItemClick(position) + } + root.onLongClick { + callBack.onItemLongClick(position) + } + } + } + + private fun onBindBook(binding: ItemBookshelfListBinding, position: Int) { + binding.run { + val item = callBack.getItem(position) + if (item is Book) { + tvName.text = item.name + tvAuthor.text = item.author + tvRead.text = item.durChapterTitle + tvLast.text = item.latestChapterTitle + ivCover.load(item.getDisplayCover(), item.name, item.author) + flHasNew.visible() + ivAuthor.visible() + ivLast.visible() + ivRead.visible() + upRefresh(this, item) + } + root.setOnClickListener { + callBack.onItemClick(position) + } + root.onLongClick { + callBack.onItemLongClick(position) + } + } + } + + private fun upRefresh(binding: ItemBookshelfListBinding, item: Book) { + if (item.origin != BookType.local && callBack.isUpdate(item.bookUrl)) { + binding.bvUnread.invisible() + binding.rlLoading.show() + } else { + binding.rlLoading.hide() + if (AppConfig.showUnread) { + binding.bvUnread.setHighlight(item.lastCheckCount > 0) + binding.bvUnread.setBadgeCount(item.getUnreadChapterNum()) + } else { + binding.bvUnread.invisible() + } + } + } + + class BookViewHolder(val binding: ItemBookshelfListBinding) : + RecyclerView.ViewHolder(binding.root) + + class GroupViewHolder(val binding: ItemBookshelfListGroupBinding) : + RecyclerView.ViewHolder(binding.root) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt new file mode 100644 index 000000000..f1ebc2fce --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt @@ -0,0 +1,243 @@ +package io.legado.app.ui.main.bookshelf.style2 + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.core.view.isGone +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.constant.BookType +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import io.legado.app.databinding.FragmentBookshelf1Binding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.audio.AudioPlayActivity +import io.legado.app.ui.book.group.GroupEditDialog +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlin.math.max + +/** + * 书架界面 + */ +class BookshelfFragment2 : BaseBookshelfFragment(R.layout.fragment_bookshelf1), + SearchView.OnQueryTextListener, + BaseBooksAdapter.CallBack { + + private val binding by viewBinding(FragmentBookshelf1Binding::bind) + private val bookshelfLayout by lazy { + getPrefInt(PreferKey.bookshelfLayout) + } + private val booksAdapter: BaseBooksAdapter<*> by lazy { + if (bookshelfLayout == 0) { + BooksAdapterList(requireContext(), this) + } else { + BooksAdapterGrid(requireContext(), this) + } + } + private var bookGroups: List = emptyList() + private var booksFlowJob: Job? = null + override var groupId = AppConst.bookGroupNoneId + override var books: List = emptyList() + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(binding.titleBar.toolbar) + initRecyclerView() + initBookGroupData() + initBooksData() + } + + private fun initRecyclerView() { + binding.rvBookshelf.setEdgeEffectColor(primaryColor) + binding.refreshLayout.setColorSchemeColors(accentColor) + binding.refreshLayout.setOnRefreshListener { + binding.refreshLayout.isRefreshing = false + activityViewModel.upToc(books) + } + if (bookshelfLayout == 0) { + binding.rvBookshelf.layoutManager = LinearLayoutManager(context) + } else { + binding.rvBookshelf.layoutManager = GridLayoutManager(context, bookshelfLayout + 2) + } + binding.rvBookshelf.adapter = booksAdapter + booksAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + val layoutManager = binding.rvBookshelf.layoutManager + if (positionStart == 0 && layoutManager is LinearLayoutManager) { + val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount + binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) + } + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + val layoutManager = binding.rvBookshelf.layoutManager + if (toPosition == 0 && layoutManager is LinearLayoutManager) { + val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount + binding.rvBookshelf.scrollToPosition(max(0, scrollTo)) + } + } + }) + } + + @SuppressLint("NotifyDataSetChanged") + override fun upGroup(data: List) { + if (data != bookGroups) { + bookGroups = data + booksAdapter.notifyDataSetChanged() + binding.tvEmptyMsg.isGone = getItemCount() > 0 + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun initBooksData() { + if (groupId == AppConst.bookGroupNoneId) { + binding.titleBar.title = getString(R.string.bookshelf) + } else { + bookGroups.forEach { + if (groupId == it.groupId) { + binding.titleBar.title = "${getString(R.string.bookshelf)}(${it.groupName})" + } + } + } + booksFlowJob?.cancel() + booksFlowJob = launch { + when (groupId) { + AppConst.bookGroupAllId -> appDb.bookDao.flowAll() + AppConst.bookGroupLocalId -> appDb.bookDao.flowLocal() + AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio() + AppConst.bookGroupNoneId -> appDb.bookDao.flowNoGroup() + else -> appDb.bookDao.flowByGroup(groupId) + }.collect { list -> + books = when (getPrefInt(PreferKey.bookshelfSort)) { + 1 -> list.sortedByDescending { + it.latestChapterTime + } + 2 -> list.sortedWith { o1, o2 -> + o1.name.cnCompare(o2.name) + } + 3 -> list.sortedBy { + it.order + } + else -> list.sortedByDescending { + it.durChapterTime + } + } + booksAdapter.notifyDataSetChanged() + binding.tvEmptyMsg.isGone = getItemCount() > 0 + } + } + } + + fun back(): Boolean { + if (groupId != AppConst.bookGroupNoneId) { + groupId = AppConst.bookGroupNoneId + initBooksData() + return true + } + return false + } + + override fun onQueryTextSubmit(query: String?): Boolean { + SearchActivity.start(requireContext(), query) + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + + override fun gotoTop() { + if (AppConfig.isEInkMode) { + binding.rvBookshelf.scrollToPosition(0) + } else { + binding.rvBookshelf.smoothScrollToPosition(0) + } + } + + override fun onItemClick(position: Int) { + when (val item = getItem(position)) { + is Book -> when (item.type) { + BookType.audio -> + startActivity { + putExtra("bookUrl", item.bookUrl) + } + else -> startActivity { + putExtra("bookUrl", item.bookUrl) + } + } + is BookGroup -> { + groupId = item.groupId + initBooksData() + } + } + } + + override fun onItemLongClick(position: Int) { + when (val item = getItem(position)) { + is Book -> startActivity { + putExtra("name", item.name) + putExtra("author", item.author) + } + is BookGroup -> showDialogFragment(GroupEditDialog(item)) + } + } + + override fun isUpdate(bookUrl: String): Boolean { + return activityViewModel.isUpdate(bookUrl) + } + + override fun getItemCount(): Int { + return if (groupId == AppConst.bookGroupNoneId) { + bookGroups.size + books.size + } else { + books.size + } + } + + override fun getItemType(position: Int): Int { + return if (groupId == AppConst.bookGroupNoneId) { + if (position < bookGroups.size) 1 else 0 + } else { + 0 + } + } + + override fun getItem(position: Int): Any { + return if (groupId == AppConst.bookGroupNoneId) { + if (position < bookGroups.size) { + bookGroups[position] + } else { + books[position - bookGroups.size] + } + } else { + books[position] + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun observeLiveBus() { + super.observeLiveBus() + observeEvent(EventBus.UP_BOOKSHELF) { + booksAdapter.notification(it) + } + observeEvent(EventBus.BOOKSHELF_REFRESH) { + booksAdapter.notifyDataSetChanged() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt new file mode 100644 index 000000000..5f7f9cb3b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt @@ -0,0 +1,192 @@ +package io.legado.app.ui.main.explore + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import android.widget.TextView +import androidx.core.view.children +import com.google.android.flexbox.FlexboxLayout +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.databinding.ItemFilletTextBinding +import io.legado.app.databinding.ItemFindBookBinding +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* +import kotlinx.coroutines.CoroutineScope +import splitties.views.onLongClick + +class ExploreAdapter(context: Context, private val scope: CoroutineScope, val callBack: CallBack) : + RecyclerAdapter(context) { + + private val recycler = arrayListOf() + private var exIndex = -1 + private var scrollTo = -1 + + override fun getViewBinding(parent: ViewGroup): ItemFindBookBinding { + return ItemFindBookBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemFindBookBinding, + item: BookSource, + payloads: MutableList + ) { + binding.run { + if (holder.layoutPosition == itemCount - 1) { + root.setPadding(16.dp, 12.dp, 16.dp, 12.dp) + } else { + root.setPadding(16.dp, 12.dp, 16.dp, 0) + } + if (payloads.isEmpty()) { + tvName.text = item.bookSourceName + } + if (exIndex == holder.layoutPosition) { + ivStatus.setImageResource(R.drawable.ic_arrow_down) + rotateLoading.loadingColor = context.accentColor + rotateLoading.show() + if (scrollTo >= 0) { + callBack.scrollTo(scrollTo) + } + Coroutine.async(scope) { + item.exploreKinds + }.onSuccess { kindList -> + upKindList(flexbox, item.bookSourceUrl, kindList) + }.onFinally { + rotateLoading.hide() + if (scrollTo >= 0) { + callBack.scrollTo(scrollTo) + scrollTo = -1 + } + } + } else { + ivStatus.setImageResource(R.drawable.ic_arrow_right) + rotateLoading.hide() + recyclerFlexbox(flexbox) + flexbox.gone() + } + } + } + + private fun upKindList(flexbox: FlexboxLayout, sourceUrl: String, kinds: List) { + if (!kinds.isNullOrEmpty()) { + recyclerFlexbox(flexbox) + flexbox.visible() + kinds.forEach { kind -> + val tv = getFlexboxChild(flexbox) + flexbox.addView(tv) + tv.text = kind.title + val lp = tv.layoutParams as FlexboxLayout.LayoutParams + kind.style().let { style -> + lp.flexGrow = style.layout_flexGrow + lp.flexShrink = style.layout_flexShrink + lp.alignSelf = style.alignSelf() + lp.flexBasisPercent = style.layout_flexBasisPercent + lp.isWrapBefore = style.layout_wrapBefore + } + if (kind.url.isNullOrBlank()) { + tv.setOnClickListener(null) + } else { + tv.setOnClickListener { + if (kind.title.startsWith("ERROR:")) { + it.activity?.showDialogFragment(TextDialog(kind.url)) + } else { + callBack.openExplore(sourceUrl, kind.title, kind.url) + } + } + } + } + } + } + + @Synchronized + private fun getFlexboxChild(flexbox: FlexboxLayout): TextView { + return if (recycler.isEmpty()) { + ItemFilletTextBinding.inflate(inflater, flexbox, false).root + } else { + recycler.last().also { + recycler.removeLast() + } as TextView + } + } + + @Synchronized + private fun recyclerFlexbox(flexbox: FlexboxLayout) { + recycler.addAll(flexbox.children) + flexbox.removeAllViews() + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemFindBookBinding) { + binding.apply { + llTitle.setOnClickListener { + val position = holder.layoutPosition + val oldEx = exIndex + exIndex = if (exIndex == position) -1 else position + notifyItemChanged(oldEx, false) + if (exIndex != -1) { + scrollTo = position + callBack.scrollTo(position) + notifyItemChanged(position, false) + } + } + llTitle.onLongClick { + showMenu(llTitle, holder.layoutPosition) + } + } + } + + fun compressExplore(): Boolean { + return if (exIndex < 0) { + false + } else { + val oldExIndex = exIndex + exIndex = -1 + notifyItemChanged(oldExIndex) + true + } + } + + private fun showMenu(view: View, position: Int): Boolean { + val source = getItem(position) ?: return true + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.explore_item) + popupMenu.menu.findItem(R.id.menu_login).isVisible = !source.loginUrl.isNullOrBlank() + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_edit -> callBack.editSource(source.bookSourceUrl) + R.id.menu_top -> callBack.toTop(source) + R.id.menu_login -> context.startActivity { + putExtra("type", "bookSource") + putExtra("key", source.bookSourceUrl) + } + R.id.menu_refresh -> Coroutine.async(scope) { + ACache.get(context, "explore").remove(source.bookSourceUrl) + }.onSuccess { + callBack.refreshData() + } + R.id.menu_del -> Coroutine.async(scope) { + appDb.bookSourceDao.delete(source) + } + } + true + } + popupMenu.show() + return true + } + + interface CallBack { + fun refreshData() + fun scrollTo(pos: Int) + fun openExplore(sourceUrl: String, title: String, exploreUrl: String?) + fun editSource(sourceUrl: String) + fun toTop(source: BookSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreDiffItemCallBack.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreDiffItemCallBack.kt new file mode 100644 index 000000000..67d527d2a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreDiffItemCallBack.kt @@ -0,0 +1,20 @@ +package io.legado.app.ui.main.explore + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.BookSource + + +class ExploreDiffItemCallBack : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BookSource, newItem: BookSource): Boolean { + return true + } + + override fun areContentsTheSame(oldItem: BookSource, newItem: BookSource): Boolean { + if (oldItem.bookSourceName != newItem.bookSourceName) { + return false + } + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt new file mode 100644 index 000000000..7c321c9ca --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt @@ -0,0 +1,189 @@ +package io.legado.app.ui.main.explore + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.core.view.isGone +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource +import io.legado.app.databinding.FragmentExploreBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.primaryColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.book.explore.ExploreShowActivity +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +/** + * 发现界面 + */ +class ExploreFragment : VMBaseFragment(R.layout.fragment_explore), + ExploreAdapter.CallBack { + override val viewModel by viewModels() + private val binding by viewBinding(FragmentExploreBinding::bind) + private val adapter by lazy { ExploreAdapter(requireContext(), lifecycleScope, this) } + private val linearLayoutManager by lazy { LinearLayoutManager(context) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private val diffItemCallBack = ExploreDiffItemCallBack() + private val groups = linkedSetOf() + private var exploreFlowJob: Job? = null + private var groupsMenu: SubMenu? = null + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(binding.titleBar.toolbar) + initSearchView() + initRecyclerView() + initGroupData() + upExploreData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + super.onCompatCreateOptionsMenu(menu) + menuInflater.inflate(R.menu.main_explore, menu) + groupsMenu = menu.findItem(R.id.menu_group)?.subMenu + upGroupsMenu() + } + + override fun onPause() { + super.onPause() + searchView.clearFocus() + } + + private fun initSearchView() { + searchView.applyTint(primaryTextColor) + searchView.onActionViewExpanded() + searchView.isSubmitButtonEnabled = true + searchView.queryHint = getString(R.string.screen_find) + searchView.clearFocus() + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + upExploreData(newText) + return false + } + }) + } + + private fun initRecyclerView() { + binding.rvFind.setEdgeEffectColor(primaryColor) + binding.rvFind.layoutManager = linearLayoutManager + binding.rvFind.adapter = adapter + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + if (positionStart == 0) { + binding.rvFind.scrollToPosition(0) + } + } + }) + } + + private fun initGroupData() { + launch { + appDb.bookSourceDao.flowExploreGroup() + .collect { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupsMenu() + } + } + } + + private fun upExploreData(searchKey: String? = null) { + exploreFlowJob?.cancel() + exploreFlowJob = launch { + val exploreFlow = when { + searchKey.isNullOrBlank() -> { + appDb.bookSourceDao.flowExplore() + } + searchKey.startsWith("group:") -> { + val key = searchKey.substringAfter("group:") + appDb.bookSourceDao.flowGroupExplore("%$key%") + } + else -> { + appDb.bookSourceDao.flowExplore("%$searchKey%") + } + } + exploreFlow.collect { + binding.tvEmptyMsg.isGone = it.isNotEmpty() || searchView.query.isNotEmpty() + adapter.setItems(it, diffItemCallBack) + } + } + } + + private fun upGroupsMenu() = groupsMenu?.let { subMenu -> + subMenu.removeGroup(R.id.menu_group_text) + groups.sortedWith { o1, o2 -> + o1.cnCompare(o2) + }.forEach { + subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, it) + } + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + super.onCompatOptionsItemSelected(item) + if (item.groupId == R.id.menu_group_text) { + searchView.setQuery("group:${item.title}", true) + } + } + + override fun refreshData() { + upExploreData(searchView.query?.toString()) + } + + override fun scrollTo(pos: Int) { + (binding.rvFind.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0) + } + + override fun openExplore(sourceUrl: String, title: String, exploreUrl: String?) { + if (exploreUrl.isNullOrBlank()) return + startActivity { + putExtra("exploreName", title) + putExtra("sourceUrl", sourceUrl) + putExtra("exploreUrl", exploreUrl) + } + } + + override fun editSource(sourceUrl: String) { + startActivity { + putExtra("sourceUrl", sourceUrl) + } + } + + override fun toTop(source: BookSource) { + viewModel.topSource(source) + } + + fun compressExplore() { + if (!adapter.compressExplore()) { + if (AppConfig.isEInkMode) { + binding.rvFind.scrollToPosition(0) + } else { + binding.rvFind.smoothScrollToPosition(0) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt new file mode 100644 index 000000000..e6a2cc569 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt @@ -0,0 +1,18 @@ +package io.legado.app.ui.main.explore + +import android.app.Application +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSource + +class ExploreViewModel(application: Application) : BaseViewModel(application) { + + fun topSource(bookSource: BookSource) { + execute { + val minXh = appDb.bookSourceDao.minOrder + bookSource.customOrder = minXh - 1 + appDb.bookSourceDao.insert(bookSource) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt b/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt new file mode 100644 index 000000000..2116abe8b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt @@ -0,0 +1,156 @@ +package io.legado.app.ui.main.my + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.preference.Preference +import io.legado.app.R +import io.legado.app.base.BaseFragment +import io.legado.app.base.BasePreferenceFragment +import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.databinding.FragmentMyConfigBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.ThemeConfig +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.primaryColor +import io.legado.app.service.WebService +import io.legado.app.ui.about.AboutActivity +import io.legado.app.ui.about.DonateActivity +import io.legado.app.ui.about.ReadRecordActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.config.ConfigActivity +import io.legado.app.ui.config.ConfigTag +import io.legado.app.ui.replace.ReplaceRuleActivity +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.ui.widget.prefs.NameListPreference +import io.legado.app.ui.widget.prefs.PreferenceCategory +import io.legado.app.ui.widget.prefs.SwitchPreference +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class MyFragment : BaseFragment(R.layout.fragment_my_config) { + + private val binding by viewBinding(FragmentMyConfigBinding::bind) + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(binding.titleBar.toolbar) + val fragmentTag = "prefFragment" + var preferenceFragment = childFragmentManager.findFragmentByTag(fragmentTag) + if (preferenceFragment == null) preferenceFragment = PreferenceFragment() + childFragmentManager.beginTransaction() + .replace(R.id.pre_fragment, preferenceFragment, fragmentTag).commit() + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.main_my, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + when (item.itemId) { + R.id.menu_help -> { + val text = String(requireContext().assets.open("help/appHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + } + } + + /** + * 配置 + */ + class PreferenceFragment : BasePreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + putPrefBoolean(PreferKey.webService, WebService.isRun) + addPreferencesFromResource(R.xml.pref_main) + findPreference("webService")?.onLongClick { + if (!WebService.isRun) { + return@onLongClick false + } + context?.selector(arrayListOf("复制地址", "浏览器打开")) { _, i -> + when (i) { + 0 -> context?.sendToClip(it.summary.toString()) + 1 -> context?.openUrl(it.summary.toString()) + } + } + true + } + observeEventSticky(EventBus.WEB_SERVICE) { + findPreference(PreferKey.webService)?.let { + it.isChecked = WebService.isRun + it.summary = if (WebService.isRun) { + WebService.hostAddress + } else { + getString(R.string.web_service_desc) + } + } + } + findPreference(PreferKey.themeMode)?.let { + it.setOnPreferenceChangeListener { _, _ -> + view?.post { ThemeConfig.applyDayNight(requireContext()) } + true + } + } + if (AppConfig.isGooglePlay) { + findPreference("aboutCategory") + ?.removePreferenceRecursively("donate") + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.setEdgeEffectColor(primaryColor) + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + PreferKey.webService -> { + if (requireContext().getPrefBoolean("webService")) { + WebService.start(requireContext()) + } else { + WebService.stop(requireContext()) + } + } + "recordLog" -> LogUtils.upLevel() + } + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "bookSourceManage" -> startActivity() + "replaceManage" -> startActivity() + "setting" -> startActivity { + putExtra("configTag", ConfigTag.OTHER_CONFIG) + } + "web_dav_setting" -> startActivity { + putExtra("configTag", ConfigTag.BACKUP_CONFIG) + } + "theme_setting" -> startActivity { + putExtra("configTag", ConfigTag.THEME_CONFIG) + } + "readRecord" -> startActivity() + "donate" -> startActivity() + "about" -> startActivity() + } + return super.onPreferenceTreeClick(preference) + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt new file mode 100644 index 000000000..b3df28b0d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt @@ -0,0 +1,73 @@ +package io.legado.app.ui.main.rss + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.RssSource +import io.legado.app.databinding.ItemRssBinding +import io.legado.app.help.glide.ImageLoader +import splitties.views.onLongClick + +class RssAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemRssBinding { + return ItemRssBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemRssBinding, + item: RssSource, + payloads: MutableList + ) { + binding.apply { + tvName.text = item.sourceName + ImageLoader.load(context, item.sourceIcon) + .centerCrop() + .placeholder(R.drawable.image_rss) + .error(R.drawable.image_rss) + .into(ivIcon) + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRssBinding) { + binding.apply { + root.setOnClickListener { + getItemByLayoutPosition(holder.layoutPosition)?.let { + callBack.openRss(it) + } + } + root.onLongClick { + getItemByLayoutPosition(holder.layoutPosition)?.let { + showMenu(ivIcon, it) + } + } + } + } + + private fun showMenu(view: View, rssSource: RssSource) { + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.rss_main_item) + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_top -> callBack.toTop(rssSource) + R.id.menu_edit -> callBack.edit(rssSource) + R.id.menu_del -> callBack.del(rssSource) + } + true + } + popupMenu.show() + } + + interface CallBack { + fun openRss(rssSource: RssSource) + fun toTop(rssSource: RssSource) + fun edit(rssSource: RssSource) + fun del(rssSource: RssSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt new file mode 100644 index 000000000..bbf0ade13 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt @@ -0,0 +1,179 @@ +package io.legado.app.ui.main.rss + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.databinding.FragmentRssBinding +import io.legado.app.databinding.ItemRssBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.rss.article.RssSortActivity +import io.legado.app.ui.rss.favorites.RssFavoritesActivity +import io.legado.app.ui.rss.read.ReadRssActivity +import io.legado.app.ui.rss.source.edit.RssSourceEditActivity +import io.legado.app.ui.rss.source.manage.RssSourceActivity +import io.legado.app.ui.rss.source.manage.RssSourceViewModel +import io.legado.app.ui.rss.subscription.RuleSubActivity +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +/** + * 订阅界面 + */ +class RssFragment : VMBaseFragment(R.layout.fragment_rss), + RssAdapter.CallBack { + private val binding by viewBinding(FragmentRssBinding::bind) + override val viewModel by viewModels() + private val adapter by lazy { RssAdapter(requireContext(), this) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private var groupsFlowJob: Job? = null + private var rssFlowJob: Job? = null + private val groups = linkedSetOf() + private var groupsMenu: SubMenu? = null + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(binding.titleBar.toolbar) + initSearchView() + initRecyclerView() + initGroupData() + upRssFlowJob() + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.main_rss, menu) + groupsMenu = menu.findItem(R.id.menu_group)?.subMenu + upGroupsMenu() + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + super.onCompatOptionsItemSelected(item) + when (item.itemId) { + R.id.menu_rss_config -> startActivity() + R.id.menu_rss_star -> startActivity() + else -> if (item.groupId == R.id.menu_group_text) { + searchView.setQuery(item.title, true) + } + } + } + + override fun onPause() { + super.onPause() + searchView.clearFocus() + } + + private fun upGroupsMenu() = groupsMenu?.let { subMenu -> + subMenu.removeGroup(R.id.menu_group_text) + groups.sortedWith { o1, o2 -> + o1.cnCompare(o2) + }.forEach { + subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, it) + } + } + + private fun initSearchView() { + searchView.applyTint(primaryTextColor) + searchView.onActionViewExpanded() + searchView.isSubmitButtonEnabled = true + searchView.queryHint = getString(R.string.rss) + searchView.clearFocus() + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + upRssFlowJob(newText) + return false + } + }) + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.adapter = adapter + adapter.addHeaderView { + ItemRssBinding.inflate(layoutInflater, it, false).apply { + tvName.setText(R.string.rule_subscription) + ivIcon.setImageResource(R.drawable.image_legado) + root.setOnClickListener { + startActivity() + } + } + } + } + + private fun initGroupData() { + groupsFlowJob?.cancel() + groupsFlowJob = launch { + appDb.rssSourceDao.flowGroup().collect { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupsMenu() + } + } + } + + private fun upRssFlowJob(searchKey: String? = null) { + rssFlowJob?.cancel() + rssFlowJob = launch { + when { + searchKey.isNullOrEmpty() -> appDb.rssSourceDao.flowEnabled() + searchKey.startsWith("group:") -> { + val key = searchKey.substringAfter("group:") + appDb.rssSourceDao.flowEnabledByGroup("%$key%") + } + else -> appDb.rssSourceDao.flowEnabled("%$searchKey%") + }.collect { + adapter.setItems(it) + } + } + } + + override fun openRss(rssSource: RssSource) { + if (rssSource.singleUrl) { + if (rssSource.sourceUrl.startsWith("http", true)) { + startActivity { + putExtra("title", rssSource.sourceName) + putExtra("origin", rssSource.sourceUrl) + } + } else { + context?.openUrl(rssSource.sourceUrl) + } + } else { + startActivity { + putExtra("url", rssSource.sourceUrl) + } + } + } + + override fun toTop(rssSource: RssSource) { + viewModel.topSource(rssSource) + } + + override fun edit(rssSource: RssSource) { + startActivity { + putExtra("sourceUrl", rssSource.sourceUrl) + } + } + + override fun del(rssSource: RssSource) { + viewModel.del(rssSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt new file mode 100644 index 000000000..7ca13dd4a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt @@ -0,0 +1,58 @@ +package io.legado.app.ui.qrcode + +import android.content.Intent +import android.graphics.BitmapFactory +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import com.google.zxing.Result +import com.king.zxing.CameraScan.OnScanResultCallback +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.databinding.ActivityQrcodeCaptureBinding +import io.legado.app.utils.QRCodeUtils +import io.legado.app.utils.SelectImageContract +import io.legado.app.utils.launch +import io.legado.app.utils.readBytes +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class QrCodeActivity : BaseActivity(), OnScanResultCallback { + + override val binding by viewBinding(ActivityQrcodeCaptureBinding::inflate) + + private val selectQrImage = registerForActivityResult(SelectImageContract()) { + it?.uri?.readBytes(this)?.let { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + onScanResultCallback(QRCodeUtils.parseCodeResult(bitmap)) + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + val fTag = "qrCodeFragment" + val qrCodeFragment = QrCodeFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.fl_content, qrCodeFragment, fTag) + .commit() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.qr_code_scan, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_choose_from_gallery -> selectQrImage.launch() + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onScanResultCallback(result: Result?): Boolean { + val intent = Intent() + intent.putExtra("result", result?.text) + setResult(RESULT_OK, intent) + finish() + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/qrcode/QrCodeFragment.kt b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeFragment.kt new file mode 100644 index 000000000..ba5d4269d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeFragment.kt @@ -0,0 +1,32 @@ +package io.legado.app.ui.qrcode + +import com.google.zxing.Result +import com.king.zxing.CaptureFragment +import com.king.zxing.DecodeConfig +import com.king.zxing.DecodeFormatManager +import com.king.zxing.analyze.MultiFormatAnalyzer + + +class QrCodeFragment : CaptureFragment() { + + override fun initCameraScan() { + super.initCameraScan() + //初始化解码配置 + val decodeConfig = DecodeConfig() + //如果只有识别二维码的需求,这样设置效率会更高,不设置默认为DecodeFormatManager.DEFAULT_HINTS + decodeConfig.hints = DecodeFormatManager.QR_CODE_HINTS + //设置是否全区域识别,默认false + decodeConfig.isFullAreaScan = true + //设置识别区域比例,默认0.8,设置的比例最终会在预览区域裁剪基于此比例的一个矩形进行扫码识别 + decodeConfig.areaRectRatio = 0.8f + + //在启动预览之前,设置分析器,只识别二维码 + cameraScan.setAnalyzer(MultiFormatAnalyzer(decodeConfig)) + } + + override fun onScanResultCallback(result: Result?): Boolean { + (activity as? QrCodeActivity)?.onScanResultCallback(result) + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/qrcode/QrCodeResult.kt b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeResult.kt new file mode 100644 index 000000000..d93184ca3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeResult.kt @@ -0,0 +1,23 @@ +package io.legado.app.ui.qrcode + +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +class QrCodeResult : ActivityResultContract() { + + override fun createIntent(context: Context, input: Unit?): Intent { + return Intent(context, QrCodeActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): String? { + if (resultCode == RESULT_OK) { + intent?.getStringExtra("result")?.let { + return it + } + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt new file mode 100644 index 000000000..f6621eb15 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/GroupManageDialog.kt @@ -0,0 +1,146 @@ +package io.legado.app.ui.replace + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemGroupManageBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val viewModel: ReplaceRuleViewModel by activityViewModels() + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { GroupAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + view.setBackgroundColor(backgroundColor) + binding.toolBar.setBackgroundColor(primaryColor) + initView() + initData() + } + + private fun initView() = binding.run { + toolBar.title = getString(R.string.group_manage) + toolBar.inflateMenu(R.menu.group_manage) + toolBar.menu.applyTint(requireContext()) + toolBar.setOnMenuItemClickListener(this@GroupManageDialog) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.addItemDecoration(VerticalDivider(requireContext())) + recyclerView.adapter = adapter + } + + private fun initData() { + launch { + appDb.replaceRuleDao.flowGroup().collect { + val groups = linkedSetOf() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + adapter.setItems(groups.toList()) + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + } + customView { alertBinding.root } + yesButton { + alertBinding.editView.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(group: String) { + alert(title = getString(R.string.group_edit)) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + editView.setText(group) + } + customView { alertBinding.root } + yesButton { + viewModel.upGroup(group, alertBinding.editView.text?.toString()) + } + noButton() + }.requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { + return ItemGroupManageBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemGroupManageBinding, + item: String, + payloads: MutableList + ) { + binding.run { + root.setBackgroundColor(context.backgroundColor) + tvGroup.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { + binding.apply { + tvEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + editGroup(it) + } + } + + tvDel.setOnClickListener { + getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt new file mode 100644 index 000000000..1b6e15354 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt @@ -0,0 +1,349 @@ +package io.legado.app.ui.replace + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.databinding.ActivityReplaceRuleBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.ContentProcessor +import io.legado.app.help.DirectLinkUpload +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.association.ImportReplaceRuleDialog +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.qrcode.QrCodeResult +import io.legado.app.ui.replace.edit.ReplaceEditActivity +import io.legado.app.ui.widget.SelectActionBar +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +/** + * 替换规则管理 + */ +class ReplaceRuleActivity : VMBaseActivity(), + SearchView.OnQueryTextListener, + PopupMenu.OnMenuItemClickListener, + SelectActionBar.CallBack, + ReplaceRuleAdapter.CallBack { + override val binding by viewBinding(ActivityReplaceRuleBinding::inflate) + override val viewModel by viewModels() + private val importRecordKey = "replaceRuleRecordKey" + private val adapter by lazy { ReplaceRuleAdapter(this, this) } + private val searchView: SearchView by lazy { + binding.titleBar.findViewById(R.id.search_view) + } + private var groups = hashSetOf() + private var groupMenu: SubMenu? = null + private var replaceRuleFlowJob: Job? = null + private var dataInit = false + private val qrCodeResult = registerForActivityResult(QrCodeResult()) { + it ?: return@registerForActivityResult + showDialogFragment( + ImportReplaceRuleDialog(it) + ) + } + private val editActivity = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + setResult(RESULT_OK) + } + } + private val importDoc = registerForActivityResult(HandleFileContract()) { + kotlin.runCatching { + it.uri?.readText(this)?.let { + showDialogFragment( + ImportReplaceRuleDialog(it) + ) + } + }.onFailure { + toastOnUi("readTextError:${it.localizedMessage}") + } + } + private val exportResult = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + alert(R.string.export_success) { + if (uri.toString().isAbsUrl()) { + DirectLinkUpload.getSummary()?.let { summary -> + setMessage(summary) + } + } + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = getString(R.string.path) + editView.setText(uri.toString()) + } + customView { alertBinding.root } + okButton { + sendToClip(uri.toString()) + } + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initRecyclerView() + initSearchView() + initSelectActionView() + observeReplaceRuleData() + observeGroupData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.replace_rule, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + groupMenu = menu?.findItem(R.id.menu_group)?.subMenu + upGroupMenu() + return super.onPrepareOptionsMenu(menu) + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = adapter + binding.recyclerView.addItemDecoration(VerticalDivider(this)) + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + val dragSelectTouchHelper: DragSelectTouchHelper = + DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) + dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) + // When this page is opened, it is in selection mode + dragSelectTouchHelper.activeSlideSelect() + + // Note: need judge selection first, so add ItemTouchHelper after it. + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + } + + private fun initSearchView() { + searchView.applyTint(primaryTextColor) + searchView.onActionViewExpanded() + searchView.queryHint = getString(R.string.replace_purify_search) + searchView.clearFocus() + searchView.setOnQueryTextListener(this) + } + + override fun selectAll(selectAll: Boolean) { + if (selectAll) { + adapter.selectAll() + } else { + adapter.revertSelection() + } + } + + override fun revertSelection() { + adapter.revertSelection() + } + + override fun onClickSelectBarMainAction() { + delSourceDialog() + } + + private fun initSelectActionView() { + binding.selectActionBar.setMainActionText(R.string.delete) + binding.selectActionBar.inflateMenu(R.menu.replace_rule_sel) + binding.selectActionBar.setOnMenuItemClickListener(this) + binding.selectActionBar.setCallBack(this) + } + + private fun delSourceDialog() { + alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { + okButton { viewModel.delSelection(adapter.selection) } + noButton() + } + } + + private fun observeReplaceRuleData(searchKey: String? = null) { + dataInit = false + replaceRuleFlowJob?.cancel() + replaceRuleFlowJob = launch { + when { + searchKey.isNullOrEmpty() -> { + appDb.replaceRuleDao.flowAll() + } + searchKey.startsWith("group:") -> { + val key = searchKey.substringAfter("group:") + appDb.replaceRuleDao.flowGroupSearch("%$key%") + } + else -> { + appDb.replaceRuleDao.flowSearch("%$searchKey%") + } + }.collect { + if (dataInit) { + setResult(Activity.RESULT_OK) + } + adapter.setItems(it, adapter.diffItemCallBack) + dataInit = true + } + } + } + + private fun observeGroupData() { + launch { + appDb.replaceRuleDao.flowGroup().collect { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupMenu() + } + } + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add_replace_rule -> + editActivity.launch(ReplaceEditActivity.startIntent(this)) + R.id.menu_group_manage -> showDialogFragment() + R.id.menu_del_selection -> viewModel.delSelection(adapter.selection) + R.id.menu_import_onLine -> showImportDialog() + R.id.menu_import_local -> importDoc.launch { + mode = HandleFileContract.FILE + allowExtensions = arrayOf("txt", "json") + } + R.id.menu_import_qr -> qrCodeResult.launch() + R.id.menu_help -> showHelp() + else -> if (item.groupId == R.id.replace_group) { + searchView.setQuery("group:${item.title}", true) + } + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) + R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) + R.id.menu_top_sel -> viewModel.topSelect(adapter.selection) + R.id.menu_bottom_sel -> viewModel.bottomSelect(adapter.selection) + R.id.menu_export_selection -> exportResult.launch { + mode = HandleFileContract.EXPORT + fileData = Triple( + "exportReplaceRule.json", + GSON.toJson(adapter.selection).toByteArray(), + "application/json" + ) + } + } + return false + } + + private fun upGroupMenu() { + groupMenu?.removeGroup(R.id.replace_group) + groups.map { + groupMenu?.add(R.id.replace_group, Menu.NONE, Menu.NONE, it) + } + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(this, cacheDir = false) + val cacheUrls: MutableList = aCache + .getAsString(importRecordKey) + ?.splitNotBlank(",") + ?.toMutableList() ?: mutableListOf() + alert(titleResource = R.string.import_replace_rule_on_line) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + editView.setFilterValues(cacheUrls) + editView.delCallBack = { + cacheUrls.remove(it) + aCache.put(importRecordKey, cacheUrls.joinToString(",")) + } + } + customView { alertBinding.root } + okButton { + val text = alertBinding.editView.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put(importRecordKey, cacheUrls.joinToString(",")) + } + showDialogFragment( + ImportReplaceRuleDialog(it) + ) + } + } + cancelButton() + } + } + + private fun showHelp() { + val text = String(assets.open("help/replaceRuleHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + + override fun onQueryTextChange(newText: String?): Boolean { + observeReplaceRuleData(newText) + return false + } + + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onDestroy() { + super.onDestroy() + Coroutine.async { ContentProcessor.upReplaceRules() } + } + + override fun upCountView() { + binding.selectActionBar.upCountView( + adapter.selection.size, + adapter.itemCount + ) + } + + override fun update(vararg rule: ReplaceRule) { + setResult(RESULT_OK) + viewModel.update(*rule) + } + + override fun delete(rule: ReplaceRule) { + setResult(RESULT_OK) + viewModel.delete(rule) + } + + override fun edit(rule: ReplaceRule) { + setResult(RESULT_OK) + editActivity.launch(ReplaceEditActivity.startIntent(this, rule.id)) + } + + override fun toTop(rule: ReplaceRule) { + setResult(RESULT_OK) + viewModel.toTop(rule) + } + + override fun toBottom(rule: ReplaceRule) { + setResult(RESULT_OK) + viewModel.toBottom(rule) + } + + override fun upOrder() { + setResult(RESULT_OK) + viewModel.upOrder() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt new file mode 100644 index 000000000..99bdddb64 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt @@ -0,0 +1,249 @@ +package io.legado.app.ui.replace + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.databinding.ItemReplaceRuleBinding +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.utils.ColorUtils + +import java.util.* + + +class ReplaceRuleAdapter(context: Context, var callBack: CallBack) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private val selected = linkedSetOf() + + val selection: LinkedHashSet + get() { + val selection = linkedSetOf() + getItems().map { + if (selected.contains(it)) { + selection.add(it) + } + } + return selection + } + + val diffItemCallBack = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean { + if (oldItem.name != newItem.name) { + return false + } + if (oldItem.group != newItem.group) { + return false + } + if (oldItem.isEnabled != newItem.isEnabled) { + return false + } + return true + } + + override fun getChangePayload(oldItem: ReplaceRule, newItem: ReplaceRule): Any? { + val payload = Bundle() + if (oldItem.name != newItem.name) { + payload.putString("name", newItem.name) + } + if (oldItem.group != newItem.group) { + payload.putString("group", newItem.group) + } + if (oldItem.isEnabled != newItem.isEnabled) { + payload.putBoolean("enabled", newItem.isEnabled) + } + if (payload.isEmpty) { + return null + } + return payload + } + } + + fun selectAll() { + getItems().forEach { + selected.add(it) + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + fun revertSelection() { + getItems().forEach { + if (selected.contains(it)) { + selected.remove(it) + } else { + selected.add(it) + } + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + override fun getViewBinding(parent: ViewGroup): ItemReplaceRuleBinding { + return ItemReplaceRuleBinding.inflate(inflater, parent, false) + } + + override fun onCurrentListChanged() { + callBack.upCountView() + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemReplaceRuleBinding, + item: ReplaceRule, + payloads: MutableList + ) { + binding.run { + val bundle = payloads.getOrNull(0) as? Bundle + if (bundle == null) { + root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) + if (item.group.isNullOrEmpty()) { + cbName.text = item.name + } else { + cbName.text = + String.format("%s (%s)", item.name, item.group) + } + swtEnabled.isChecked = item.isEnabled + cbName.isChecked = selected.contains(item) + } else { + bundle.keySet().map { + when (it) { + "selected" -> cbName.isChecked = selected.contains(item) + "name", "group" -> + if (item.group.isNullOrEmpty()) { + cbName.text = item.name + } else { + cbName.text = + String.format("%s (%s)", item.name, item.group) + } + "enabled" -> swtEnabled.isChecked = item.isEnabled + } + } + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemReplaceRuleBinding) { + binding.apply { + swtEnabled.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + getItem(holder.layoutPosition)?.let { + it.isEnabled = isChecked + callBack.update(it) + } + } + } + ivEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.edit(it) + } + } + cbName.setOnClickListener { + getItem(holder.layoutPosition)?.let { + if (cbName.isChecked) { + selected.add(it) + } else { + selected.remove(it) + } + } + callBack.upCountView() + } + ivMenuMore.setOnClickListener { + showMenu(ivMenuMore, holder.layoutPosition) + } + } + } + + private fun showMenu(view: View, position: Int) { + val item = getItem(position) ?: return + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.replace_rule_item) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(item) + R.id.menu_bottom -> callBack.toBottom(item) + R.id.menu_del -> callBack.delete(item) + } + true + } + popupMenu.show() + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.order == targetItem.order) { + callBack.upOrder() + } else { + val srcOrder = srcItem.order + srcItem.order = targetItem.order + targetItem.order = srcOrder + movedItems.add(srcItem) + movedItems.add(targetItem) + } + } + swapItem(srcPosition, targetPosition) + return true + } + + private val movedItems = linkedSetOf() + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (movedItems.isNotEmpty()) { + callBack.update(*movedItems.toTypedArray()) + movedItems.clear() + } + } + + val dragSelectCallback: DragSelectTouchHelper.Callback = + object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { + override fun currentSelectedId(): MutableSet { + return selected + } + + override fun getItemId(position: Int): ReplaceRule { + return getItem(position)!! + } + + override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { + getItem(position)?.let { + if (isSelected) { + selected.add(it) + } else { + selected.remove(it) + } + notifyItemChanged(position, bundleOf(Pair("selected", null))) + callBack.upCountView() + return true + } + return false + } + } + + interface CallBack { + fun update(vararg rule: ReplaceRule) + fun delete(rule: ReplaceRule) + fun edit(rule: ReplaceRule) + fun toTop(rule: ReplaceRule) + fun toBottom(rule: ReplaceRule) + fun upOrder() + fun upCountView() + } +} diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt new file mode 100644 index 000000000..9f8aaa92e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt @@ -0,0 +1,133 @@ +package io.legado.app.ui.replace + +import android.app.Application +import android.text.TextUtils +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.utils.splitNotBlank + +class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) { + + fun update(vararg rule: ReplaceRule) { + execute { + appDb.replaceRuleDao.update(*rule) + } + } + + fun delete(rule: ReplaceRule) { + execute { + appDb.replaceRuleDao.delete(rule) + } + } + + fun toTop(rule: ReplaceRule) { + execute { + rule.order = appDb.replaceRuleDao.minOrder - 1 + appDb.replaceRuleDao.update(rule) + } + } + + fun topSelect(rules: LinkedHashSet) { + execute { + var minOrder = appDb.replaceRuleDao.minOrder - rules.size + rules.forEach { + it.order = ++minOrder + } + appDb.replaceRuleDao.update(*rules.toTypedArray()) + } + } + + fun toBottom(rule: ReplaceRule) { + execute { + rule.order = appDb.replaceRuleDao.maxOrder + 1 + appDb.replaceRuleDao.update(rule) + } + } + + fun bottomSelect(rules: LinkedHashSet) { + execute { + var maxOrder = appDb.replaceRuleDao.maxOrder + rules.forEach { + it.order = maxOrder++ + } + appDb.replaceRuleDao.update(*rules.toTypedArray()) + } + } + + fun upOrder() { + execute { + val rules = appDb.replaceRuleDao.all + for ((index, rule) in rules.withIndex()) { + rule.order = index + 1 + } + appDb.replaceRuleDao.update(*rules.toTypedArray()) + } + } + + fun enableSelection(rules: LinkedHashSet) { + execute { + val list = arrayListOf() + rules.forEach { + list.add(it.copy(isEnabled = true)) + } + appDb.replaceRuleDao.update(*list.toTypedArray()) + } + } + + fun disableSelection(rules: LinkedHashSet) { + execute { + val list = arrayListOf() + rules.forEach { + list.add(it.copy(isEnabled = false)) + } + appDb.replaceRuleDao.update(*list.toTypedArray()) + } + } + + fun delSelection(rules: LinkedHashSet) { + execute { + appDb.replaceRuleDao.delete(*rules.toTypedArray()) + } + } + + fun addGroup(group: String) { + execute { + val sources = appDb.replaceRuleDao.noGroup + sources.map { source -> + source.group = group + } + appDb.replaceRuleDao.update(*sources.toTypedArray()) + } + } + + fun upGroup(oldGroup: String, newGroup: String?) { + execute { + val sources = appDb.replaceRuleDao.getByGroup(oldGroup) + sources.map { source -> + source.group?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(oldGroup) + if (!newGroup.isNullOrEmpty()) + it.add(newGroup) + source.group = TextUtils.join(",", it) + } + } + appDb.replaceRuleDao.update(*sources.toTypedArray()) + } + } + + fun delGroup(group: String) { + execute { + execute { + val sources = appDb.replaceRuleDao.getByGroup(group) + sources.map { source -> + source.group?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(group) + source.group = TextUtils.join(",", it) + } + } + appDb.replaceRuleDao.update(*sources.toTypedArray()) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt new file mode 100644 index 000000000..0b0bd0c92 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt @@ -0,0 +1,186 @@ +package io.legado.app.ui.replace.edit + +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.ViewTreeObserver +import android.widget.EditText +import android.widget.PopupWindow +import androidx.activity.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.databinding.ActivityReplaceEditBinding +import io.legado.app.lib.dialogs.selector +import io.legado.app.ui.widget.KeyboardToolPop +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import io.legado.app.utils.windowSize +import kotlin.math.abs + +/** + * 编辑替换规则 + */ +class ReplaceEditActivity : + VMBaseActivity(false), + ViewTreeObserver.OnGlobalLayoutListener, + KeyboardToolPop.CallBack { + + companion object { + + fun startIntent( + context: Context, + id: Long = -1, + pattern: String? = null, + isRegex: Boolean = false, + scope: String? = null + ): Intent { + val intent = Intent(context, ReplaceEditActivity::class.java) + intent.putExtra("id", id) + intent.putExtra("pattern", pattern) + intent.putExtra("isRegex", isRegex) + intent.putExtra("scope", scope) + return intent + } + + } + + override val binding by viewBinding(ActivityReplaceEditBinding::inflate) + override val viewModel by viewModels() + + private var mSoftKeyboardTool: PopupWindow? = null + private var mIsSoftKeyBoardShowing = false + + override fun onActivityCreated(savedInstanceState: Bundle?) { + mSoftKeyboardTool = KeyboardToolPop(this, AppConst.keyboardToolChars, this) + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(this) + viewModel.initData(intent) { + upReplaceView(it) + } + binding.ivHelp.setOnClickListener { + showRegexHelp() + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.replace_edit, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> { + val rule = getReplaceRule() + if (!rule.isValid()) { + toastOnUi(R.string.replace_rule_invalid) + } else { + viewModel.save(rule) { + setResult(RESULT_OK) + finish() + } + } + } + } + return true + } + + private fun upReplaceView(replaceRule: ReplaceRule) = binding.run { + etName.setText(replaceRule.name) + etGroup.setText(replaceRule.group) + etReplaceRule.setText(replaceRule.pattern) + cbUseRegex.isChecked = replaceRule.isRegex + etReplaceTo.setText(replaceRule.replacement) + etScope.setText(replaceRule.scope) + } + + private fun getReplaceRule(): ReplaceRule = binding.run { + val replaceRule: ReplaceRule = viewModel.replaceRule ?: ReplaceRule() + replaceRule.name = etName.text.toString() + replaceRule.group = etGroup.text.toString() + replaceRule.pattern = etReplaceRule.text.toString() + replaceRule.isRegex = cbUseRegex.isChecked + replaceRule.replacement = etReplaceTo.text.toString() + replaceRule.scope = etScope.text.toString() + return replaceRule + } + + private fun insertText(text: String) { + if (text.isBlank()) return + val view = window?.decorView?.findFocus() + if (view is EditText) { + val start = view.selectionStart + val end = view.selectionEnd + //TODO 获取EditText的文字 + val edit = view.editableText + if (start < 0 || start >= edit.length) { + edit.append(text) + } else { + //TODO 光标所在位置插入文字 + edit.replace(start, end, text) + } + } + } + + override fun sendText(text: String) { + if (text == AppConst.keyboardToolChars[0]) { + showHelpDialog() + } else { + insertText(text) + } + } + + private fun showHelpDialog() { + val items = arrayListOf("正则教程") + selector(getString(R.string.help), items) { _, index -> + when (index) { + 0 -> showRegexHelp() + } + } + } + + private fun showRegexHelp() { + val mdText = String(assets.open("help/regexHelp.md").readBytes()) + showDialogFragment(TextDialog(mdText, TextDialog.Mode.MD)) + } + + private fun showKeyboardTopPopupWindow() { + mSoftKeyboardTool?.let { + if (it.isShowing) return + if (!isFinishing) { + it.showAtLocation(binding.llContent, Gravity.BOTTOM, 0, 0) + } + } + } + + private fun closePopupWindow() { + mSoftKeyboardTool?.dismiss() + } + + override fun onGlobalLayout() { + val rect = Rect() + // 获取当前页面窗口的显示范围 + window.decorView.getWindowVisibleDisplayFrame(rect) + val screenHeight = this.windowSize.heightPixels + val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 + val preShowing = mIsSoftKeyBoardShowing + if (abs(keyboardHeight) > screenHeight / 5) { + mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 + binding.rootView.setPadding(0, 0, 0, 100) + showKeyboardTopPopupWindow() + } else { + mIsSoftKeyBoardShowing = false + binding.rootView.setPadding(0, 0, 0, 0) + if (preShowing) { + closePopupWindow() + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt new file mode 100644 index 000000000..fd37bb36d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt @@ -0,0 +1,47 @@ +package io.legado.app.ui.replace.edit + +import android.app.Application +import android.content.Intent +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.ReplaceRule + +class ReplaceEditViewModel(application: Application) : BaseViewModel(application) { + + var replaceRule: ReplaceRule? = null + + fun initData(intent: Intent, finally: (replaceRule: ReplaceRule) -> Unit) { + execute { + val id = intent.getLongExtra("id", -1) + if (id > 0) { + replaceRule = appDb.replaceRuleDao.findById(id) + } else { + val pattern = intent.getStringExtra("pattern") ?: "" + val isRegex = intent.getBooleanExtra("isRegex", false) + val scope = intent.getStringExtra("scope") + replaceRule = ReplaceRule( + name = pattern, + pattern = pattern, + isRegex = isRegex, + scope = scope + ) + } + }.onFinally { + replaceRule?.let { + finally(it) + } + } + } + + fun save(replaceRule: ReplaceRule, success: () -> Unit) { + execute { + if (replaceRule.order == 0) { + replaceRule.order = appDb.replaceRuleDao.maxOrder + 1 + } + appDb.replaceRuleDao.insert(replaceRule) + }.onSuccess { + success() + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt new file mode 100644 index 000000000..69eb4ccd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt @@ -0,0 +1,16 @@ +package io.legado.app.ui.rss.article + +import android.content.Context +import androidx.viewbinding.ViewBinding +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.RssArticle + + +abstract class BaseRssArticlesAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + + interface CallBack { + val isGridLayout: Boolean + fun readRss(rssArticle: RssArticle) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt new file mode 100644 index 000000000..b2d14e9f1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt @@ -0,0 +1,87 @@ +package io.legado.app.ui.rss.article + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.ViewGroup +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.data.entities.RssArticle +import io.legado.app.databinding.ItemRssArticleBinding +import io.legado.app.help.glide.ImageLoader +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +class RssArticlesAdapter(context: Context, callBack: CallBack) : + BaseRssArticlesAdapter(context, callBack) { + + override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding { + return ItemRssArticleBinding.inflate(inflater, parent, false) + } + + @SuppressLint("CheckResult") + override fun convert( + holder: ItemViewHolder, + binding: ItemRssArticleBinding, + item: RssArticle, + payloads: MutableList + ) { + binding.run { + tvTitle.text = item.title + tvPubDate.text = item.pubDate + if (item.image.isNullOrBlank() && !callBack.isGridLayout) { + imageView.gone() + } else { + ImageLoader.load(context, item.image).apply { + if (callBack.isGridLayout) { + placeholder(R.drawable.image_rss_article) + } else { + addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + imageView.gone() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + imageView.visible() + return false + } + + }) + } + }.into(imageView) + } + if (item.read) { + tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary)) + } else { + tvTitle.setTextColor(context.getCompatColor(R.color.primaryText)) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.readRss(it) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt new file mode 100644 index 000000000..d92fb4b94 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.rss.article + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.ViewGroup +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.data.entities.RssArticle +import io.legado.app.databinding.ItemRssArticle1Binding +import io.legado.app.help.glide.ImageLoader +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +class RssArticlesAdapter1(context: Context, callBack: CallBack) : + BaseRssArticlesAdapter(context, callBack) { + + override fun getViewBinding(parent: ViewGroup): ItemRssArticle1Binding { + return ItemRssArticle1Binding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemRssArticle1Binding, + item: RssArticle, + payloads: MutableList + ) { + binding.run { + tvTitle.text = item.title + tvPubDate.text = item.pubDate + if (item.image.isNullOrBlank() && !callBack.isGridLayout) { + imageView.gone() + } else { + ImageLoader.load(context, item.image).apply { + if (callBack.isGridLayout) { + placeholder(R.drawable.image_rss_article) + } else { + addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + imageView.gone() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + imageView.visible() + return false + } + + }) + } + }.into(imageView) + } + if (item.read) { + tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary)) + } else { + tvTitle.setTextColor(context.getCompatColor(R.color.primaryText)) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle1Binding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.readRss(it) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt new file mode 100644 index 000000000..5a512a063 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.rss.article + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.ViewGroup +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.data.entities.RssArticle +import io.legado.app.databinding.ItemRssArticle2Binding +import io.legado.app.help.glide.ImageLoader +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +class RssArticlesAdapter2(context: Context, callBack: CallBack) : + BaseRssArticlesAdapter(context, callBack) { + + override fun getViewBinding(parent: ViewGroup): ItemRssArticle2Binding { + return ItemRssArticle2Binding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemRssArticle2Binding, + item: RssArticle, + payloads: MutableList + ) { + binding.run { + tvTitle.text = item.title + tvPubDate.text = item.pubDate + if (item.image.isNullOrBlank() && !callBack.isGridLayout) { + imageView.gone() + } else { + ImageLoader.load(context, item.image).apply { + if (callBack.isGridLayout) { + placeholder(R.drawable.image_rss_article) + } else { + addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + imageView.gone() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + imageView.visible() + return false + } + + }) + } + }.into(imageView) + } + if (item.read) { + tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary)) + } else { + tvTitle.setTextColor(context.getCompatColor(R.color.primaryText)) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle2Binding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.readRss(it) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt new file mode 100644 index 000000000..00a6b42ae --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt @@ -0,0 +1,139 @@ +package io.legado.app.ui.rss.article + + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssArticle +import io.legado.app.databinding.FragmentRssArticlesBinding +import io.legado.app.databinding.ViewLoadMoreBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.rss.read.ReadRssActivity +import io.legado.app.ui.widget.recycler.LoadMoreView +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.startActivity +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +class RssArticlesFragment() : VMBaseFragment(R.layout.fragment_rss_articles), + BaseRssArticlesAdapter.CallBack { + + constructor(sortName: String, sortUrl: String) : this() { + arguments = Bundle().apply { + putString("sortName", sortName) + putString("sortUrl", sortUrl) + } + } + + private val binding by viewBinding(FragmentRssArticlesBinding::bind) + private val activityViewModel by activityViewModels() + override val viewModel by viewModels() + private val adapter: BaseRssArticlesAdapter<*> by lazy { + when (activityViewModel.rssSource?.articleStyle) { + 1 -> RssArticlesAdapter1(requireContext(), this@RssArticlesFragment) + 2 -> RssArticlesAdapter2(requireContext(), this@RssArticlesFragment) + else -> RssArticlesAdapter(requireContext(), this@RssArticlesFragment) + } + } + private val loadMoreView: LoadMoreView by lazy { + LoadMoreView(requireContext()) + } + private var articlesFlowJob: Job? = null + override val isGridLayout: Boolean + get() = activityViewModel.isGridLayout + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + viewModel.init(arguments) + initView() + initData() + } + + private fun initView() = binding.run { + refreshLayout.setColorSchemeColors(accentColor) + recyclerView.setEdgeEffectColor(primaryColor) + recyclerView.layoutManager = if (activityViewModel.isGridLayout) { + recyclerView.setPadding(8, 0, 8, 0) + GridLayoutManager(requireContext(), 2) + } else { + recyclerView.addItemDecoration(VerticalDivider(requireContext())) + LinearLayoutManager(requireContext()) + } + recyclerView.adapter = adapter + adapter.addFooterView { + ViewLoadMoreBinding.bind(loadMoreView) + } + refreshLayout.setOnRefreshListener { + loadArticles() + } + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!recyclerView.canScrollVertically(1)) { + scrollToBottom() + } + } + }) + refreshLayout.post { + refreshLayout.isRefreshing = true + loadArticles() + } + } + + private fun initData() { + val rssUrl = activityViewModel.url ?: return + articlesFlowJob?.cancel() + articlesFlowJob = lifecycleScope.launch { + appDb.rssArticleDao.flowByOriginSort(rssUrl, viewModel.sortName).collect { + adapter.setItems(it) + } + } + } + + private fun loadArticles() { + activityViewModel.rssSource?.let { + viewModel.loadContent(it) + } + } + + private fun scrollToBottom() { + if (viewModel.isLoading) return + if (loadMoreView.hasMore && adapter.getActualItemCount() > 0) { + loadMoreView.startLoad() + activityViewModel.rssSource?.let { + viewModel.loadMore(it) + } + } + } + + override fun observeLiveBus() { + viewModel.loadFinally.observe(viewLifecycleOwner) { + binding.refreshLayout.isRefreshing = false + if (it) { + loadMoreView.startLoad() + } else { + loadMoreView.noMore() + } + } + } + + override fun readRss(rssArticle: RssArticle) { + activityViewModel.read(rssArticle) + startActivity { + putExtra("title", rssArticle.title) + putExtra("origin", rssArticle.origin) + putExtra("link", rssArticle.link) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt new file mode 100644 index 000000000..808d37881 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt @@ -0,0 +1,102 @@ +package io.legado.app.ui.rss.article + +import android.app.Application +import android.os.Bundle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.model.rss.Rss + +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +class RssArticlesViewModel(application: Application) : BaseViewModel(application) { + val loadFinally = MutableLiveData() + var isLoading = true + var order = System.currentTimeMillis() + private var nextPageUrl: String? = null + var sortName: String = "" + var sortUrl: String = "" + var page = 1 + + fun init(bundle: Bundle?) { + bundle?.let { + sortName = it.getString("sortName") ?: "" + sortUrl = it.getString("sortUrl") ?: "" + } + } + + fun loadContent(rssSource: RssSource) { + isLoading = true + page = 1 + Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page) + .onSuccess(Dispatchers.IO) { + nextPageUrl = it.second + it.first.let { list -> + list.forEach { rssArticle -> + rssArticle.order = order-- + } + appDb.rssArticleDao.insert(*list.toTypedArray()) + if (!rssSource.ruleNextPage.isNullOrEmpty()) { + appDb.rssArticleDao.clearOld(rssSource.sourceUrl, sortName, order) + loadFinally.postValue(true) + } else { + withContext(Dispatchers.Main) { + loadFinally.postValue(false) + } + } + isLoading = false + } + }.onError { + loadFinally.postValue(false) + Timber.e(it) + context.toastOnUi(it.localizedMessage) + } + } + + fun loadMore(rssSource: RssSource) { + isLoading = true + page++ + val pageUrl = nextPageUrl + if (!pageUrl.isNullOrEmpty()) { + Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page) + .onSuccess(Dispatchers.IO) { + nextPageUrl = it.second + loadMoreSuccess(it.first) + } + .onError { + Timber.e(it) + loadFinally.postValue(false) + } + } else { + loadFinally.postValue(false) + } + } + + private fun loadMoreSuccess(articles: MutableList) { + articles.let { list -> + if (list.isEmpty()) { + loadFinally.postValue(false) + return@let + } + val firstArticle = list.first() + val dbArticle = appDb.rssArticleDao + .get(firstArticle.origin, firstArticle.link) + if (dbArticle != null) { + loadFinally.postValue(false) + } else { + list.forEach { rssArticle -> + rssArticle.order = order-- + } + appDb.rssArticleDao.insert(*list.toTypedArray()) + } + } + isLoading = false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt new file mode 100644 index 000000000..296965a1e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt @@ -0,0 +1,153 @@ +@file:Suppress("DEPRECATION") + +package io.legado.app.ui.rss.article + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.ViewGroup +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentStatePagerAdapter +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivityRssArtivlesBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.rss.source.edit.RssSourceEditActivity +import io.legado.app.utils.StartActivityContract +import io.legado.app.utils.gone +import io.legado.app.utils.startActivity +import io.legado.app.utils.viewbindingdelegate.viewBinding +import io.legado.app.utils.visible +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RssSortActivity : VMBaseActivity() { + + override val binding by viewBinding(ActivityRssArtivlesBinding::inflate) + override val viewModel by viewModels() + private val adapter by lazy { TabFragmentPageAdapter() } + private val sortList = mutableListOf>() + private val fragmentMap = hashMapOf() + private val editSourceResult = registerForActivityResult( + StartActivityContract(RssSourceEditActivity::class.java) + ) { + if (it.resultCode == RESULT_OK) { + viewModel.initData(intent) { + upFragments() + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.viewPager.adapter = adapter + binding.tabLayout.setupWithViewPager(binding.viewPager) + viewModel.titleLiveData.observe(this, { + binding.titleBar.title = it + }) + viewModel.initData(intent) { + upFragments() + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_articles, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.findItem(R.id.menu_login)?.isVisible = + !viewModel.rssSource?.loginUrl.isNullOrBlank() + return super.onMenuOpened(featureId, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_login -> startActivity { + putExtra("type", "rssSource") + putExtra("key", viewModel.rssSource?.sourceUrl) + } + R.id.menu_set_source_variable -> setSourceVariable() + R.id.menu_edit_source -> viewModel.rssSource?.sourceUrl?.let { + editSourceResult.launch { + putExtra("sourceUrl", it) + } + } + R.id.menu_clear -> { + viewModel.url?.let { + viewModel.clearArticles() + } + } + R.id.menu_switch_layout -> { + viewModel.switchLayout() + upFragments() + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun upFragments() { + viewModel.rssSource?.sortUrls()?.let { + sortList.clear() + sortList.addAll(it) + } + if (sortList.size == 1) { + binding.tabLayout.gone() + } else { + binding.tabLayout.visible() + } + adapter.notifyDataSetChanged() + } + + private fun setSourceVariable() { + launch { + val variable = withContext(Dispatchers.IO) { viewModel.rssSource?.getVariable() } + alert(R.string.set_source_variable) { + setMessage("源变量可在js中通过source.getVariable()获取") + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "source variable" + editView.setText(variable) + } + customView { alertBinding.root } + okButton { + viewModel.rssSource?.setVariable(alertBinding.editView.text?.toString()) + } + cancelButton() + neutralButton(R.string.delete) { + viewModel.rssSource?.setVariable(null) + } + } + } + } + + private inner class TabFragmentPageAdapter : + FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + override fun getItemPosition(`object`: Any): Int { + return POSITION_NONE + } + + override fun getPageTitle(position: Int): CharSequence { + return sortList[position].first + } + + override fun getItem(position: Int): Fragment { + val sort = sortList[position] + return RssArticlesFragment(sort.first, sort.second) + } + + override fun getCount(): Int { + return sortList.size + } + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val fragment = super.instantiateItem(container, position) as Fragment + fragmentMap[sortList[position].first] = fragment + return fragment + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt new file mode 100644 index 000000000..9a3b4be72 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt @@ -0,0 +1,66 @@ +package io.legado.app.ui.rss.article + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssReadRecord +import io.legado.app.data.entities.RssSource + + +class RssSortViewModel(application: Application) : BaseViewModel(application) { + var url: String? = null + var rssSource: RssSource? = null + val titleLiveData = MutableLiveData() + var order = System.currentTimeMillis() + val isGridLayout get() = rssSource?.articleStyle == 2 + + fun initData(intent: Intent, finally: () -> Unit) { + execute { + url = intent.getStringExtra("url") + url?.let { url -> + rssSource = appDb.rssSourceDao.getByKey(url) + rssSource?.let { + titleLiveData.postValue(it.sourceName) + } ?: let { + rssSource = RssSource(sourceUrl = url) + } + } + }.onFinally { + finally() + } + } + + fun switchLayout() { + rssSource?.let { + if (it.articleStyle < 2) { + it.articleStyle = it.articleStyle + 1 + } else { + it.articleStyle = 0 + } + execute { + appDb.rssSourceDao.update(it) + } + } + } + + fun read(rssArticle: RssArticle) { + execute { + appDb.rssArticleDao.insertRecord(RssReadRecord(rssArticle.link)) + } + } + + fun clearArticles() { + execute { + url?.let { + appDb.rssArticleDao.delete(it) + } + order = System.currentTimeMillis() + }.onSuccess { + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt new file mode 100644 index 000000000..18c255eb9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt @@ -0,0 +1,51 @@ +package io.legado.app.ui.rss.favorites + +import android.os.Bundle +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.base.BaseActivity +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssStar +import io.legado.app.databinding.ActivityRssFavoritesBinding +import io.legado.app.ui.rss.read.ReadRssActivity +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.startActivity +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class RssFavoritesActivity : BaseActivity(), + RssFavoritesAdapter.CallBack { + + override val binding by viewBinding(ActivityRssFavoritesBinding::inflate) + private val adapter by lazy { RssFavoritesAdapter(this, this) } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + initData() + } + + private fun initView() { + binding.recyclerView.let { + it.layoutManager = LinearLayoutManager(this) + it.addItemDecoration(VerticalDivider(this)) + it.adapter = adapter + } + } + + private fun initData() { + launch { + appDb.rssStarDao.liveAll().collect { + adapter.setItems(it) + } + } + } + + override fun readRss(rssStar: RssStar) { + startActivity { + putExtra("title", rssStar.title) + putExtra("origin", rssStar.origin) + putExtra("link", rssStar.link) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt new file mode 100644 index 000000000..ba0a252c2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt @@ -0,0 +1,78 @@ +package io.legado.app.ui.rss.favorites + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.ViewGroup +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.RssStar +import io.legado.app.databinding.ItemRssArticleBinding +import io.legado.app.help.glide.ImageLoader +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +class RssFavoritesAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding { + return ItemRssArticleBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemRssArticleBinding, + item: RssStar, + payloads: MutableList + ) { + binding.run { + tvTitle.text = item.title + tvPubDate.text = item.pubDate + if (item.image.isNullOrBlank()) { + imageView.gone() + } else { + ImageLoader.load(context, item.image) + .addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + imageView.gone() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + imageView.visible() + return false + } + + }) + .into(imageView) + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.readRss(it) + } + } + } + + interface CallBack { + fun readRss(rssStar: RssStar) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt new file mode 100644 index 000000000..7874afcec --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt @@ -0,0 +1,356 @@ +package io.legado.app.ui.rss.read + +import android.annotation.SuppressLint +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.view.* +import android.webkit.* +import androidx.activity.viewModels +import androidx.core.view.size +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivityRssReadBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.model.Download +import io.legado.app.ui.association.OnLineImportActivity +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.launch +import org.apache.commons.text.StringEscapeUtils +import org.jsoup.Jsoup +import java.net.URLDecoder + +/** + * rss阅读界面 + */ +class ReadRssActivity : VMBaseActivity(false), + ReadRssViewModel.CallBack { + + override val binding by viewBinding(ActivityRssReadBinding::inflate) + override val viewModel by viewModels() + private val imagePathKey = "imagePath" + private var starMenuItem: MenuItem? = null + private var ttsMenuItem: MenuItem? = null + private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null + private var webPic: String? = null + private val saveImage = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + ACache.get(this).put(imagePathKey, uri.toString()) + viewModel.saveImage(webPic, uri.toString()) + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + viewModel.callBack = this + binding.titleBar.title = intent.getStringExtra("title") + initWebView() + initLiveData() + viewModel.initData(intent) + } + + @Suppress("DEPRECATION") + @SuppressLint("SwitchIntDef") + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + when (newConfig.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + Configuration.ORIENTATION_PORTRAIT -> { + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) + } + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_read, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + starMenuItem = menu?.findItem(R.id.menu_rss_star) + ttsMenuItem = menu?.findItem(R.id.menu_aloud) + upStarMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank() + return super.onMenuOpened(featureId, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_rss_refresh -> viewModel.refresh() + R.id.menu_rss_star -> viewModel.favorite() + R.id.menu_share_it -> viewModel.rssArticle?.let { + share(it.link) + } ?: toastOnUi(R.string.null_url) + R.id.menu_aloud -> readAloud() + R.id.menu_login -> startActivity { + putExtra("type", "rssSource") + putExtra("key", viewModel.rssSource?.loginUrl) + } + R.id.menu_browser_open -> binding.webView.url?.let { + openUrl(it) + } ?: toastOnUi("url null") + } + return super.onCompatOptionsItemSelected(item) + } + + @JavascriptInterface + fun isNightTheme(): Boolean { + return AppConfig.isNightTheme(this) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initWebView() { + binding.webView.webChromeClient = CustomWebChromeClient() + binding.webView.webViewClient = CustomWebViewClient() + binding.webView.settings.apply { + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + domStorageEnabled = true + allowContentAccess = true + } + binding.webView.addJavascriptInterface(this, "app") + upWebViewTheme() + binding.webView.setOnLongClickListener { + val hitTestResult = binding.webView.hitTestResult + if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || + hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE + ) { + hitTestResult.extra?.let { + saveImage(it) + return@setOnLongClickListener true + } + } + return@setOnLongClickListener false + } + binding.webView.setDownloadListener { url, _, contentDisposition, _, _ -> + var fileName = URLUtil.guessFileName(url, contentDisposition, null) + fileName = URLDecoder.decode(fileName, "UTF-8") + binding.llView.longSnackbar(fileName, getString(R.string.action_download)) { + Download.start(this, url, fileName) + } + } + + } + + private fun saveImage(webPic: String) { + this.webPic = webPic + val path = ACache.get(this@ReadRssActivity).getAsString(imagePathKey) + if (path.isNullOrEmpty()) { + selectSaveFolder() + } else { + viewModel.saveImage(webPic, path) + } + } + + private fun selectSaveFolder() { + val default = arrayListOf>() + val path = ACache.get(this@ReadRssActivity).getAsString(imagePathKey) + if (!path.isNullOrEmpty()) { + default.add(SelectItem(path, -1)) + } + saveImage.launch { + otherActions = default + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initLiveData() { + viewModel.contentLiveData.observe(this) { content -> + viewModel.rssArticle?.let { + upJavaScriptEnable() + val url = NetworkUtils.getAbsoluteURL(it.origin, it.link) + val html = viewModel.clHtml(content) + if (viewModel.rssSource?.loadWithBaseUrl == true) { + binding.webView + .loadDataWithBaseURL(url, html, "text/html", "utf-8", url)//不想用baseUrl进else + } else { + binding.webView + .loadDataWithBaseURL(null, html, "text/html;charset=utf-8", "utf-8", url) + } + } + } + viewModel.urlLiveData.observe(this) { + upJavaScriptEnable() + binding.webView.loadUrl(it.url, it.headerMap) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun upJavaScriptEnable() { + if (viewModel.rssSource?.enableJs == true) { + binding.webView.settings.javaScriptEnabled = true + } + } + + private fun upWebViewTheme() { + if (AppConfig.isNightTheme) { + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + binding.webView.settings, + WebSettingsCompat.DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING + ) + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark( + binding.webView.settings, + WebSettingsCompat.FORCE_DARK_ON + ) + } + } + } + + override fun upStarMenu() { + if (viewModel.rssStar != null) { + starMenuItem?.setIcon(R.drawable.ic_star) + starMenuItem?.setTitle(R.string.in_favorites) + } else { + starMenuItem?.setIcon(R.drawable.ic_star_border) + starMenuItem?.setTitle(R.string.out_favorites) + } + starMenuItem?.icon?.setTintMutate(primaryTextColor) + } + + override fun upTtsMenu(isPlaying: Boolean) { + launch { + if (isPlaying) { + ttsMenuItem?.setIcon(R.drawable.ic_stop_black_24dp) + ttsMenuItem?.setTitle(R.string.aloud_stop) + } else { + ttsMenuItem?.setIcon(R.drawable.ic_volume_up) + ttsMenuItem?.setTitle(R.string.read_aloud) + } + ttsMenuItem?.icon?.setTintMutate(primaryTextColor) + } + } + + override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> { + finish() + return true + } + } + return super.onKeyLongPress(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + event?.let { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> if (event.isTracking && !event.isCanceled && binding.webView.canGoBack()) { + if (binding.customWebView.size > 0) { + customWebViewCallback?.onCustomViewHidden() + return true + } else if (binding.webView.copyBackForwardList().size > 1) { + binding.webView.goBack() + return true + } + } + } + } + return super.onKeyUp(keyCode, event) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun readAloud() { + if (viewModel.textToSpeech?.isSpeaking == true) { + viewModel.textToSpeech?.stop() + upTtsMenu(false) + } else { + binding.webView.settings.javaScriptEnabled = true + binding.webView.evaluateJavascript("document.documentElement.outerHTML") { + val html = StringEscapeUtils.unescapeJson(it) + .replace("^\"|\"$".toRegex(), "") + Jsoup.parse(html).text() + viewModel.readAloud(Jsoup.parse(html).textArray()) + } + } + } + + override fun onDestroy() { + super.onDestroy() + binding.webView.destroy() + } + + inner class CustomWebChromeClient : WebChromeClient() { + + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + binding.llView.invisible() + binding.customWebView.addView(view) + customWebViewCallback = callback + } + + override fun onHideCustomView() { + binding.customWebView.removeAllViews() + binding.llView.visible() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + + inner class CustomWebViewClient : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + request?.let { + return shouldOverrideUrlLoading(it.url) + } + return true + } + + @Suppress("DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + url?.let { + return shouldOverrideUrlLoading(Uri.parse(it)) + } + return true + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + view?.title?.let { title -> + if (title != url && title != view.url && title.isNotBlank() && url != "about:blank") { + binding.titleBar.title = title + } else { + binding.titleBar.title = intent.getStringExtra("title") + } + } + } + + private fun shouldOverrideUrlLoading(url: Uri): Boolean { + when (url.scheme) { + "http", "https" -> { + return false + } + "legado", "yuedu" -> { + startActivity { + data = url + } + return true + } + else -> { + binding.root.longSnackbar("跳转其它应用", "确认") { + openUrl(url) + } + return true + } + } + } + + } + +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt new file mode 100644 index 000000000..1cfbe34fc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt @@ -0,0 +1,254 @@ +package io.legado.app.ui.rss.read + +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Base64 +import android.webkit.URLUtil +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.data.entities.RssStar +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.rss.Rss +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import java.io.File +import java.util.* + + +class ReadRssViewModel(application: Application) : BaseViewModel(application), + TextToSpeech.OnInitListener { + var callBack: CallBack? = null + var rssSource: RssSource? = null + var rssArticle: RssArticle? = null + val contentLiveData = MutableLiveData() + val urlLiveData = MutableLiveData() + var rssStar: RssStar? = null + var textToSpeech: TextToSpeech? = null + private var ttsInitFinish = false + private var ttsTextList = arrayListOf() + + fun initData(intent: Intent) { + execute { + val origin = intent.getStringExtra("origin") + val link = intent.getStringExtra("link") + origin?.let { + rssSource = appDb.rssSourceDao.getByKey(origin) + if (link != null) { + rssStar = appDb.rssStarDao.get(origin, link) + rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.get(origin, link) + rssArticle?.let { rssArticle -> + if (!rssArticle.description.isNullOrBlank()) { + contentLiveData.postValue(rssArticle.description!!) + } else { + rssSource?.let { + val ruleContent = it.ruleContent + if (!ruleContent.isNullOrBlank()) { + loadContent(rssArticle, ruleContent) + } else { + loadUrl(rssArticle.link, rssArticle.origin) + } + } ?: loadUrl(rssArticle.link, rssArticle.origin) + } + } + } else { + val ruleContent = rssSource?.ruleContent + if (ruleContent.isNullOrBlank()) { + loadUrl(origin, origin) + } else { + val rssArticle = RssArticle() + rssArticle.origin = origin + rssArticle.link = origin + rssArticle.title = rssSource!!.sourceName + loadContent(rssArticle, ruleContent) + } + } + } + }.onFinally { + callBack?.upStarMenu() + } + } + + private fun loadUrl(url: String, baseUrl: String) { + val analyzeUrl = AnalyzeUrl( + mUrl = url, + baseUrl = baseUrl, + headerMapF = rssSource?.getHeaderMap() + ) + urlLiveData.postValue(analyzeUrl) + } + + private fun loadContent(rssArticle: RssArticle, ruleContent: String) { + rssSource?.let { source -> + Rss.getContent(viewModelScope, rssArticle, ruleContent, source) + .onSuccess(IO) { body -> + rssArticle.description = body + appDb.rssArticleDao.insert(rssArticle) + rssStar?.let { + it.description = body + appDb.rssStarDao.insert(it) + } + contentLiveData.postValue(body) + } + } + } + + fun refresh() { + rssArticle?.let { rssArticle -> + rssSource?.let { + val ruleContent = it.ruleContent + if (!ruleContent.isNullOrBlank()) { + loadContent(rssArticle, ruleContent) + } else { + loadUrl(rssArticle.link, rssArticle.origin) + } + } ?: loadUrl(rssArticle.link, rssArticle.origin) + } + } + + fun favorite() { + execute { + rssStar?.let { + appDb.rssStarDao.delete(it.origin, it.link) + rssStar = null + } ?: rssArticle?.toStar()?.let { + appDb.rssStarDao.insert(it) + rssStar = it + } + }.onSuccess { + callBack?.upStarMenu() + } + } + + fun saveImage(webPic: String?, path: String) { + webPic ?: return + execute { + val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" + webData2bitmap(webPic)?.let { biteArray -> + if (path.isContentScheme()) { + val uri = Uri.parse(path) + DocumentFile.fromTreeUri(context, uri)?.let { doc -> + DocumentUtils.createFileIfNotExist(doc, fileName) + ?.writeBytes(context, biteArray) + } + } else { + val file = FileUtils.createFileIfNotExist(File(path), fileName) + file.writeBytes(biteArray) + } + } ?: throw Throwable("NULL") + }.onError { + context.toastOnUi("保存图片失败:${it.localizedMessage}") + }.onSuccess { + context.toastOnUi("保存成功") + } + } + + private suspend fun webData2bitmap(data: String): ByteArray? { + return if (URLUtil.isValidUrl(data)) { + @Suppress("BlockingMethodInNonBlockingContext") + okHttpClient.newCallResponseBody { + url(data) + }.bytes() + } else { + Base64.decode(data.split(",").toTypedArray()[1], Base64.DEFAULT) + } + } + + fun clHtml(content: String): String { + return when { + !rssSource?.style.isNullOrEmpty() -> { + """ + + $content + """.trimIndent() + } + content.contains(" + $content + """.trimIndent() + } + } + } + + @Synchronized + override fun onInit(status: Int) { + if (status == TextToSpeech.SUCCESS) { + textToSpeech?.language = Locale.CHINA + textToSpeech?.setOnUtteranceProgressListener(TTSUtteranceListener()) + ttsInitFinish = true + play() + } else { + context.toastOnUi(R.string.tts_init_failed) + } + } + + @Synchronized + private fun play() { + if (!ttsInitFinish) return + textToSpeech?.stop() + ttsTextList.forEach { + textToSpeech?.speak(it, TextToSpeech.QUEUE_ADD, null, "rss") + } + } + + fun readAloud(textArray: Array) { + ttsTextList.clear() + ttsTextList.addAll(textArray) + textToSpeech?.let { + play() + } ?: let { + textToSpeech = TextToSpeech(context, this) + } + } + + override fun onCleared() { + super.onCleared() + textToSpeech?.stop() + textToSpeech?.shutdown() + } + + /** + * 朗读监听 + */ + private inner class TTSUtteranceListener : UtteranceProgressListener() { + + override fun onStart(s: String) { + callBack?.upTtsMenu(true) + } + + override fun onDone(s: String) { + callBack?.upTtsMenu(false) + } + + override fun onError(s: String) { + + } + + } + + interface CallBack { + fun upStarMenu() + fun upTtsMenu(isPlaying: Boolean) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt b/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt new file mode 100644 index 000000000..0e6e1b40b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.rss.read + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.webkit.WebView + +class VisibleWebView( + context: Context, + attrs: AttributeSet? = null +) : WebView(context, attrs) { + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(View.VISIBLE) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt new file mode 100644 index 000000000..812d536fd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt @@ -0,0 +1,76 @@ +package io.legado.app.ui.rss.source.debug + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.SearchView +import androidx.activity.viewModels +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.databinding.ActivitySourceDebugBinding +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.gone +import io.legado.app.utils.setEdgeEffectColor +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.launch + + +class RssSourceDebugActivity : VMBaseActivity() { + + override val binding by viewBinding(ActivitySourceDebugBinding::inflate) + override val viewModel by viewModels() + + private val adapter by lazy { RssSourceDebugAdapter(this) } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initRecyclerView() + initSearchView() + viewModel.observe { state, msg -> + launch { + adapter.addItem(msg) + if (state == -1 || state == 1000) { + binding.rotateLoading.hide() + } + } + } + viewModel.initData(intent.getStringExtra("key")) { + startSearch() + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_source_debug, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_list_src -> showDialogFragment(TextDialog(viewModel.listSrc)) + R.id.menu_content_src -> showDialogFragment(TextDialog(viewModel.contentSrc)) + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.adapter = adapter + binding.rotateLoading.loadingColor = accentColor + } + + private fun initSearchView() { + binding.titleBar.findViewById(R.id.search_view).gone() + } + + private fun startSearch() { + adapter.clearItems() + viewModel.startDebug({ + binding.rotateLoading.show() + }, { + toastOnUi("未获取到源") + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt new file mode 100644 index 000000000..dc92e12d6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt @@ -0,0 +1,44 @@ +package io.legado.app.ui.rss.source.debug + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemLogBinding + +class RssSourceDebugAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemLogBinding { + return ItemLogBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemLogBinding, + item: String, + payloads: MutableList + ) { + binding.apply { + if (textView.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + textView.isCursorVisible = false + textView.isCursorVisible = true + } + + override fun onViewDetachedFromWindow(v: View) {} + } + textView.addOnAttachStateChangeListener(listener) + textView.setTag(R.id.tag1, listener) + } + textView.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) { + //nothing + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt new file mode 100644 index 000000000..23892dc6f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt @@ -0,0 +1,52 @@ +package io.legado.app.ui.rss.source.debug + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.model.Debug + +class RssSourceDebugModel(application: Application) : BaseViewModel(application), + Debug.Callback { + private var rssSource: RssSource? = null + private var callback: ((Int, String) -> Unit)? = null + var listSrc: String? = null + var contentSrc: String? = null + + fun initData(sourceUrl: String?, finally: () -> Unit) { + sourceUrl?.let { + execute { + rssSource = appDb.rssSourceDao.getByKey(sourceUrl) + }.onFinally { + finally() + } + } + } + + fun observe(callback: (Int, String) -> Unit) { + this.callback = callback + } + + fun startDebug(start: (() -> Unit)? = null, error: (() -> Unit)? = null) { + rssSource?.let { + start?.invoke() + Debug.callback = this + Debug.startDebug(viewModelScope, it) + } ?: error?.invoke() + } + + override fun printLog(state: Int, msg: String) { + when (state) { + 10 -> listSrc = msg + 20 -> contentSrc = msg + else -> callback?.invoke(state, msg) + } + } + + override fun onCleared() { + super.onCleared() + Debug.cancelDebug(true) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/EditEntity.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/EditEntity.kt new file mode 100644 index 000000000..f0f23b32b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/EditEntity.kt @@ -0,0 +1,3 @@ +package io.legado.app.ui.rss.source.edit + +data class EditEntity(var key: String, var value: String?, var hint: Int) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt new file mode 100644 index 000000000..f6229d0c1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt @@ -0,0 +1,303 @@ +package io.legado.app.ui.rss.source.edit + +import android.app.Activity +import android.graphics.Rect +import android.os.Bundle +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.ViewTreeObserver +import android.widget.EditText +import android.widget.PopupWindow +import androidx.activity.viewModels +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.RssSource +import io.legado.app.databinding.ActivityRssSourceEditBinding +import io.legado.app.help.LocalConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.qrcode.QrCodeResult +import io.legado.app.ui.rss.source.debug.RssSourceDebugActivity +import io.legado.app.ui.widget.KeyboardToolPop +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlin.math.abs + +class RssSourceEditActivity : + VMBaseActivity(false), + ViewTreeObserver.OnGlobalLayoutListener, + KeyboardToolPop.CallBack { + + override val binding by viewBinding(ActivityRssSourceEditBinding::inflate) + override val viewModel by viewModels() + private var mSoftKeyboardTool: PopupWindow? = null + private var mIsSoftKeyBoardShowing = false + private val adapter by lazy { RssSourceEditAdapter() } + private val sourceEntities: ArrayList = ArrayList() + private val qrCodeResult = registerForActivityResult(QrCodeResult()) { + it?.let { + viewModel.importSource(it) { source: RssSource -> + upRecyclerView(source) + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + viewModel.initData(intent) { + upRecyclerView() + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + if (!LocalConfig.ruleHelpVersionIsLast) { + showRuleHelp() + } + } + + override fun finish() { + val source = getRssSource() + if (!source.equal(viewModel.rssSource)) { + alert(R.string.exit) { + setMessage(R.string.exit_no_save) + positiveButton(R.string.yes) + negativeButton(R.string.no) { + super.finish() + } + } + } else { + super.finish() + } + } + + override fun onDestroy() { + super.onDestroy() + mSoftKeyboardTool?.dismiss() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.source_edit, menu) + menu.findItem(R.id.menu_login).isVisible = false + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { + menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource.loginUrl.isNullOrBlank() + return super.onMenuOpened(featureId, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> { + val source = getRssSource() + if (checkSource(source)) { + viewModel.save(source) { + setResult(Activity.RESULT_OK) + finish() + } + } + } + R.id.menu_debug_source -> { + val source = getRssSource() + if (checkSource(source)) { + viewModel.save(source) { + startActivity { + putExtra("key", source.sourceUrl) + } + } + } + } + R.id.menu_login -> getRssSource().let { + if (checkSource(it)) { + viewModel.save(it) { + startActivity { + putExtra("type", "rssSource") + putExtra("key", it.sourceUrl) + } + } + } + } + R.id.menu_copy_source -> sendToClip(GSON.toJson(getRssSource())) + R.id.menu_qr_code_camera -> qrCodeResult.launch() + R.id.menu_paste_source -> viewModel.pasteSource { upRecyclerView(it) } + R.id.menu_share_str -> share(GSON.toJson(getRssSource())) + R.id.menu_share_qr -> shareWithQr( + GSON.toJson(getRssSource()), + getString(R.string.share_rss_source), + ErrorCorrectionLevel.L + ) + R.id.menu_help -> showRuleHelp() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + mSoftKeyboardTool = KeyboardToolPop(this, AppConst.keyboardToolChars, this) + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(this) + binding.recyclerView.adapter = adapter + } + + private fun upRecyclerView(source: RssSource? = viewModel.rssSource) { + source?.let { + binding.cbIsEnable.isChecked = source.enabled + binding.cbSingleUrl.isChecked = source.singleUrl + binding.cbEnableJs.isChecked = source.enableJs + binding.cbEnableBaseUrl.isChecked = source.loadWithBaseUrl + } + sourceEntities.clear() + sourceEntities.apply { + add(EditEntity("sourceName", source?.sourceName, R.string.source_name)) + add(EditEntity("sourceUrl", source?.sourceUrl, R.string.source_url)) + add(EditEntity("sourceIcon", source?.sourceIcon, R.string.source_icon)) + add(EditEntity("sourceGroup", source?.sourceGroup, R.string.source_group)) + add(EditEntity("sourceComment", source?.sourceComment, R.string.comment)) + add(EditEntity("loginUrl", source?.loginUrl, R.string.login_url)) + add(EditEntity("loginUi", source?.loginUi, R.string.login_ui)) + add(EditEntity("loginCheckJs", source?.loginCheckJs, R.string.login_check_js)) + add(EditEntity("header", source?.header, R.string.source_http_header)) + add( + EditEntity( + "concurrentRate", source?.concurrentRate, R.string.source_concurrent_rate + ) + ) + add(EditEntity("sortUrl", source?.sortUrl, R.string.sort_url)) + add(EditEntity("ruleArticles", source?.ruleArticles, R.string.r_articles)) + add(EditEntity("ruleNextPage", source?.ruleNextPage, R.string.r_next)) + add(EditEntity("ruleTitle", source?.ruleTitle, R.string.r_title)) + add(EditEntity("rulePubDate", source?.rulePubDate, R.string.r_date)) + add(EditEntity("ruleDescription", source?.ruleDescription, R.string.r_description)) + add(EditEntity("ruleImage", source?.ruleImage, R.string.r_image)) + add(EditEntity("ruleLink", source?.ruleLink, R.string.r_link)) + add(EditEntity("ruleContent", source?.ruleContent, R.string.r_content)) + add(EditEntity("style", source?.style, R.string.r_style)) + } + adapter.editEntities = sourceEntities + } + + private fun getRssSource(): RssSource { + val source = viewModel.rssSource + source.enabled = binding.cbIsEnable.isChecked + source.singleUrl = binding.cbSingleUrl.isChecked + source.enableJs = binding.cbEnableJs.isChecked + source.loadWithBaseUrl = binding.cbEnableBaseUrl.isChecked + sourceEntities.forEach { + when (it.key) { + "sourceName" -> source.sourceName = it.value ?: "" + "sourceUrl" -> source.sourceUrl = it.value ?: "" + "sourceIcon" -> source.sourceIcon = it.value ?: "" + "sourceGroup" -> source.sourceGroup = it.value + "sourceComment" -> source.sourceComment = it.value + "loginUrl" -> source.loginUrl = it.value + "loginUi" -> source.loginUi = it.value + "loginCheckJs" -> source.loginCheckJs = it.value + "header" -> source.header = it.value + "concurrentRate" -> source.concurrentRate = it.value + "sortUrl" -> source.sortUrl = it.value + "ruleArticles" -> source.ruleArticles = it.value + "ruleNextPage" -> source.ruleNextPage = it.value + "ruleTitle" -> source.ruleTitle = it.value + "rulePubDate" -> source.rulePubDate = it.value + "ruleDescription" -> source.ruleDescription = it.value + "ruleImage" -> source.ruleImage = it.value + "ruleLink" -> source.ruleLink = it.value + "ruleContent" -> source.ruleContent = it.value + "style" -> source.style = it.value + } + } + return source + } + + private fun checkSource(source: RssSource): Boolean { + if (source.sourceName.isBlank() || source.sourceName.isBlank()) { + toastOnUi("名称或url不能为空") + return false + } + return true + } + + private fun insertText(text: String) { + if (text.isBlank()) return + val view = window.decorView.findFocus() + if (view is EditText) { + val start = view.selectionStart + val end = view.selectionEnd + val edit = view.editableText//获取EditText的文字 + if (start < 0 || start >= edit.length) { + edit.append(text) + } else { + edit.replace(start, end, text)//光标所在位置插入文字 + } + } + } + + override fun sendText(text: String) { + if (text == AppConst.keyboardToolChars[0]) { + showHelpDialog() + } else { + insertText(text) + } + } + + private fun showHelpDialog() { + val items = arrayListOf("插入URL参数", "订阅源教程", "正则教程") + selector(getString(R.string.help), items) { _, index -> + when (index) { + 0 -> insertText(AppConst.urlOption) + 1 -> showRuleHelp() + 2 -> showRegexHelp() + } + } + } + + private fun showRuleHelp() { + val mdText = String(assets.open("help/ruleHelp.md").readBytes()) + showDialogFragment(TextDialog(mdText, TextDialog.Mode.MD)) + } + + private fun showRegexHelp() { + val mdText = String(assets.open("help/regexHelp.md").readBytes()) + showDialogFragment(TextDialog(mdText, TextDialog.Mode.MD)) + } + + private fun showKeyboardTopPopupWindow() { + mSoftKeyboardTool?.let { + if (it.isShowing) return + if (!isFinishing) { + it.showAtLocation(binding.root, Gravity.BOTTOM, 0, 0) + } + } + } + + private fun closePopupWindow() { + mSoftKeyboardTool?.dismiss() + } + + override fun onGlobalLayout() { + val rect = Rect() + // 获取当前页面窗口的显示范围 + window.decorView.getWindowVisibleDisplayFrame(rect) + val screenHeight = this@RssSourceEditActivity.windowSize.heightPixels + val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 + val preShowing = mIsSoftKeyBoardShowing + if (abs(keyboardHeight) > screenHeight / 5) { + mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 + binding.recyclerView.setPadding(0, 0, 0, 100) + showKeyboardTopPopupWindow() + } else { + mIsSoftKeyBoardShowing = false + binding.recyclerView.setPadding(0, 0, 0, 0) + if (preShowing) { + closePopupWindow() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt new file mode 100644 index 000000000..4470421ba --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt @@ -0,0 +1,91 @@ +package io.legado.app.ui.rss.source.edit + +import android.annotation.SuppressLint +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.databinding.ItemSourceEditBinding +import io.legado.app.ui.widget.code.addJsPattern +import io.legado.app.ui.widget.code.addJsonPattern +import io.legado.app.ui.widget.code.addLegadoPattern + +class RssSourceEditAdapter : RecyclerView.Adapter() { + + var editEntities: ArrayList = ArrayList() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { + val binding = ItemSourceEditBinding + .inflate(LayoutInflater.from(parent.context), parent, false) + binding.editText.addLegadoPattern() + binding.editText.addJsonPattern() + binding.editText.addJsPattern() + return MyViewHolder(binding) + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + holder.bind(editEntities[position]) + } + + override fun getItemCount(): Int { + return editEntities.size + } + + class MyViewHolder(val binding: ItemSourceEditBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(editEntity: EditEntity) = binding.run { + if (editText.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + editText.isCursorVisible = false + editText.isCursorVisible = true + editText.isFocusable = true + editText.isFocusableInTouchMode = true + } + + override fun onViewDetachedFromWindow(v: View) { + + } + } + editText.addOnAttachStateChangeListener(listener) + editText.setTag(R.id.tag1, listener) + } + editText.getTag(R.id.tag2)?.let { + if (it is TextWatcher) { + editText.removeTextChangedListener(it) + } + } + editText.setText(editEntity.value) + textInputLayout.hint = itemView.context.getString(editEntity.hint) + val textWatcher = object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + + } + + override fun afterTextChanged(s: Editable?) { + editEntity.value = (s?.toString()) + } + } + editText.addTextChangedListener(textWatcher) + editText.setTag(R.id.tag2, textWatcher) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt new file mode 100644 index 000000000..3f4ebfe95 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt @@ -0,0 +1,78 @@ +package io.legado.app.ui.rss.source.edit + +import android.app.Application +import android.content.Intent +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.utils.getClipText +import io.legado.app.utils.msg + +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers +import timber.log.Timber + +class RssSourceEditViewModel(application: Application) : BaseViewModel(application) { + + var rssSource: RssSource = RssSource() + private var oldSourceUrl: String = "" + + fun initData(intent: Intent, onFinally: () -> Unit) { + execute { + val key = intent.getStringExtra("sourceUrl") + if (key != null) { + appDb.rssSourceDao.getByKey(key)?.let { + rssSource = it + } + } + oldSourceUrl = rssSource.sourceUrl + }.onFinally { + onFinally() + } + } + + fun save(source: RssSource, success: (() -> Unit)) { + execute { + if (oldSourceUrl != source.sourceUrl) { + appDb.rssSourceDao.delete(oldSourceUrl) + oldSourceUrl = source.sourceUrl + } + appDb.rssSourceDao.insert(source) + }.onSuccess { + success() + }.onError { + context.toastOnUi(it.localizedMessage) + Timber.e(it) + } + } + + fun pasteSource(onSuccess: (source: RssSource) -> Unit) { + execute(context = Dispatchers.Main) { + var source: RssSource? = null + context.getClipText()?.let { json -> + source = RssSource.fromJson(json) + } + source + }.onError { + context.toastOnUi(it.localizedMessage) + }.onSuccess { + if (it != null) { + onSuccess(it) + } else { + context.toastOnUi("格式不对") + } + } + } + + fun importSource(text: String, finally: (source: RssSource) -> Unit) { + execute { + val text1 = text.trim() + RssSource.fromJson(text1)?.let { + finally.invoke(it) + } + }.onError { + context.toastOnUi(it.msg) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt new file mode 100644 index 000000000..92a794cc7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt @@ -0,0 +1,149 @@ +package io.legado.app.ui.rss.source.manage + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemGroupManageBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + + +class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view), + Toolbar.OnMenuItemClickListener { + + private val viewModel: RssSourceViewModel by activityViewModels() + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { GroupAdapter(requireContext()) } + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { + toolBar.setBackgroundColor(primaryColor) + toolBar.title = getString(R.string.group_manage) + toolBar.inflateMenu(R.menu.group_manage) + toolBar.menu.applyTint(requireContext()) + toolBar.setOnMenuItemClickListener(this@GroupManageDialog) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.addItemDecoration(VerticalDivider(requireContext())) + recyclerView.adapter = adapter + tvOk.setTextColor(requireContext().accentColor) + tvOk.visible() + tvOk.setOnClickListener { + dismissAllowingStateLoss() + } + initData() + } + + private fun initData() { + launch { + appDb.rssSourceDao.flowGroup().collect { + val groups = linkedSetOf() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + adapter.setItems(groups.toList()) + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + } + customView { alertBinding.root } + yesButton { + alertBinding.editView.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(group: String) { + alert(title = getString(R.string.group_edit)) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.setHint(R.string.group_name) + editView.setText(group) + } + customView { alertBinding.root } + yesButton { + viewModel.upGroup(group, alertBinding.editView.text?.toString()) + } + noButton() + }.requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { + return ItemGroupManageBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemGroupManageBinding, + item: String, + payloads: MutableList + ) { + binding.run { + root.setBackgroundColor(context.backgroundColor) + tvGroup.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { + binding.apply { + tvEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + editGroup(it) + } + } + + tvDel.setOnClickListener { + getItem(holder.layoutPosition)?.let { + viewModel.delGroup(it) + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt new file mode 100644 index 000000000..fbd344838 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt @@ -0,0 +1,324 @@ +package io.legado.app.ui.rss.source.manage + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.ItemTouchHelper +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppPattern +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.databinding.ActivityRssSourceBinding +import io.legado.app.databinding.DialogEditTextBinding +import io.legado.app.help.DirectLinkUpload +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.theme.primaryColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.association.ImportRssSourceDialog +import io.legado.app.ui.document.HandleFileContract +import io.legado.app.ui.qrcode.QrCodeResult +import io.legado.app.ui.rss.source.edit.RssSourceEditActivity +import io.legado.app.ui.widget.SelectActionBar +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.ui.widget.recycler.VerticalDivider +import io.legado.app.utils.* +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +/** + * 订阅源管理 + */ +class RssSourceActivity : VMBaseActivity(), + PopupMenu.OnMenuItemClickListener, + SelectActionBar.CallBack, + RssSourceAdapter.CallBack { + + override val binding by viewBinding(ActivityRssSourceBinding::inflate) + override val viewModel by viewModels() + private val importRecordKey = "rssSourceRecordKey" + private val adapter by lazy { RssSourceAdapter(this, this) } + private var sourceFlowJob: Job? = null + private var groups = hashSetOf() + private var groupMenu: SubMenu? = null + private val qrCodeResult = registerForActivityResult(QrCodeResult()) { + it ?: return@registerForActivityResult + showDialogFragment( + ImportRssSourceDialog(it) + ) + } + private val importDoc = registerForActivityResult(HandleFileContract()) { + kotlin.runCatching { + it.uri?.readText(this)?.let { + showDialogFragment( + ImportRssSourceDialog(it) + ) + } + }.onFailure { + toastOnUi("readTextError:${it.localizedMessage}") + } + } + private val exportResult = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + alert(R.string.export_success) { + if (uri.toString().isAbsUrl()) { + DirectLinkUpload.getSummary()?.let { summary -> + setMessage(summary) + } + } + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = getString(R.string.path) + editView.setText(uri.toString()) + } + customView { alertBinding.root } + okButton { + sendToClip(uri.toString()) + } + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initRecyclerView() + initSearchView() + initGroupFlow() + upSourceFlow() + initSelectActionBar() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_source, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + groupMenu = menu?.findItem(R.id.menu_group)?.subMenu + upGroupMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add -> startActivity() + R.id.menu_import_local -> importDoc.launch { + mode = HandleFileContract.FILE + allowExtensions = arrayOf("txt", "json") + } + R.id.menu_import_onLine -> showImportDialog() + R.id.menu_import_qr -> qrCodeResult.launch() + R.id.menu_group_manage -> showDialogFragment() + R.id.menu_import_default -> viewModel.importDefault() + R.id.menu_help -> showHelp() + else -> if (item.groupId == R.id.source_group) { + binding.titleBar.findViewById(R.id.search_view) + .setQuery("group:${item.title}", true) + } + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) + R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) + R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray()) + R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray()) + R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file -> + exportResult.launch { + mode = HandleFileContract.EXPORT + fileData = Triple("exportRssSource.json", file, "application/json") + } + } + R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) { + share(it) + } + } + return true + } + + private fun initRecyclerView() { + binding.recyclerView.setEdgeEffectColor(primaryColor) + binding.recyclerView.addItemDecoration(VerticalDivider(this)) + binding.recyclerView.adapter = adapter + // When this page is opened, it is in selection mode + val dragSelectTouchHelper: DragSelectTouchHelper = + DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) + dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) + dragSelectTouchHelper.activeSlideSelect() + // Note: need judge selection first, so add ItemTouchHelper after it. + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + } + + private fun initSearchView() { + binding.titleBar.findViewById(R.id.search_view).let { + it.applyTint(primaryTextColor) + it.onActionViewExpanded() + it.queryHint = getString(R.string.search_rss_source) + it.clearFocus() + it.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + upSourceFlow(newText) + return false + } + }) + } + } + + private fun initSelectActionBar() { + binding.selectActionBar.setMainActionText(R.string.delete) + binding.selectActionBar.inflateMenu(R.menu.rss_source_sel) + binding.selectActionBar.setOnMenuItemClickListener(this) + binding.selectActionBar.setCallBack(this) + } + + private fun initGroupFlow() { + launch { + appDb.rssSourceDao.flowGroup().collect { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex)) + } + upGroupMenu() + } + } + } + + override fun selectAll(selectAll: Boolean) { + if (selectAll) { + adapter.selectAll() + } else { + adapter.revertSelection() + } + } + + override fun revertSelection() { + adapter.revertSelection() + } + + override fun onClickSelectBarMainAction() { + delSourceDialog() + } + + private fun delSourceDialog() { + alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { + okButton { viewModel.del(*adapter.selection.toTypedArray()) } + noButton() + } + } + + private fun upGroupMenu() = groupMenu?.let { menu -> + menu.removeGroup(R.id.source_group) + groups.sortedWith { o1, o2 -> + o1.cnCompare(o2) + }.map { + menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it) + } + } + + private fun upSourceFlow(searchKey: String? = null) { + sourceFlowJob?.cancel() + sourceFlowJob = launch { + when { + searchKey.isNullOrBlank() -> { + appDb.rssSourceDao.flowAll() + } + searchKey.startsWith("group:") -> { + val key = searchKey.substringAfter("group:") + appDb.rssSourceDao.flowGroupSearch("%$key%") + } + else -> { + appDb.rssSourceDao.flowSearch("%$searchKey%") + } + }.collect { + adapter.setItems(it, adapter.diffItemCallback) + } + } + } + + private fun showHelp() { + val text = String(assets.open("help/SourceMRssHelp.md").readBytes()) + showDialogFragment(TextDialog(text, TextDialog.Mode.MD)) + } + + override fun upCountView() { + binding.selectActionBar.upCountView( + adapter.selection.size, + adapter.itemCount + ) + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(this, cacheDir = false) + val cacheUrls: MutableList = aCache + .getAsString(importRecordKey) + ?.splitNotBlank(",") + ?.toMutableList() ?: mutableListOf() + alert(titleResource = R.string.import_on_line) { + val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { + editView.hint = "url" + editView.setFilterValues(cacheUrls) + editView.delCallBack = { + cacheUrls.remove(it) + aCache.put(importRecordKey, cacheUrls.joinToString(",")) + } + } + customView { alertBinding.root } + okButton { + val text = alertBinding.editView.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put(importRecordKey, cacheUrls.joinToString(",")) + } + showDialogFragment( + ImportRssSourceDialog(it) + ) + } + } + cancelButton() + } + } + + override fun del(source: RssSource) { + viewModel.del(source) + } + + override fun edit(source: RssSource) { + startActivity { + putExtra("sourceUrl", source.sourceUrl) + } + } + + override fun update(vararg source: RssSource) { + viewModel.update(*source) + } + + override fun toTop(source: RssSource) { + viewModel.topSource(source) + } + + override fun toBottom(source: RssSource) { + viewModel.bottomSource(source) + } + + override fun upOrder() { + viewModel.upOrder() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt new file mode 100644 index 000000000..34147a14c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt @@ -0,0 +1,238 @@ +package io.legado.app.ui.rss.source.manage + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.RssSource +import io.legado.app.databinding.ItemRssSourceBinding +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.utils.ColorUtils + + +class RssSourceAdapter(context: Context, val callBack: CallBack) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private val selected = linkedSetOf() + + val selection: List + get() { + val selection = arrayListOf() + getItems().forEach { + if (selected.contains(it)) { + selection.add(it) + } + } + return selection.sortedBy { it.customOrder } + } + + val diffItemCallback = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: RssSource, newItem: RssSource): Boolean { + return oldItem.sourceUrl == newItem.sourceUrl + } + + override fun areContentsTheSame(oldItem: RssSource, newItem: RssSource): Boolean { + return oldItem.sourceName == newItem.sourceName + && oldItem.sourceGroup == newItem.sourceGroup + && oldItem.enabled == newItem.enabled + } + + override fun getChangePayload(oldItem: RssSource, newItem: RssSource): Any? { + val payload = Bundle() + if (oldItem.sourceName != newItem.sourceName) { + payload.putString("name", newItem.sourceName) + } + if (oldItem.sourceGroup != newItem.sourceGroup) { + payload.putString("group", newItem.sourceGroup) + } + if (oldItem.enabled != newItem.enabled) { + payload.putBoolean("enabled", newItem.enabled) + } + if (payload.isEmpty) { + return null + } + return payload + } + } + + override fun getViewBinding(parent: ViewGroup): ItemRssSourceBinding { + return ItemRssSourceBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemRssSourceBinding, + item: RssSource, + payloads: MutableList + ) { + binding.run { + val bundle = payloads.getOrNull(0) as? Bundle + if (bundle == null) { + root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) + if (item.sourceGroup.isNullOrEmpty()) { + cbSource.text = item.sourceName + } else { + cbSource.text = + String.format("%s (%s)", item.sourceName, item.sourceGroup) + } + swtEnabled.isChecked = item.enabled + cbSource.isChecked = selected.contains(item) + } else { + bundle.keySet().map { + when (it) { + "selected" -> cbSource.isChecked = selected.contains(item) + } + } + } + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRssSourceBinding) { + binding.apply { + swtEnabled.setOnCheckedChangeListener { view, checked -> + if (view.isPressed) { + getItem(holder.layoutPosition)?.let { + if (view.isPressed) { + it.enabled = checked + callBack.update(it) + } + } + } + } + cbSource.setOnCheckedChangeListener { view, checked -> + if (view.isPressed) { + getItem(holder.layoutPosition)?.let { + if (view.isPressed) { + if (checked) { + selected.add(it) + } else { + selected.remove(it) + } + callBack.upCountView() + } + } + } + } + ivEdit.setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack.edit(it) + } + } + ivMenuMore.setOnClickListener { + showMenu(ivMenuMore, holder.layoutPosition) + } + } + } + + override fun onCurrentListChanged() { + callBack.upCountView() + } + + fun selectAll() { + getItems().forEach { + selected.add(it) + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + fun revertSelection() { + getItems().forEach { + if (selected.contains(it)) { + selected.remove(it) + } else { + selected.add(it) + } + } + notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) + callBack.upCountView() + } + + private fun showMenu(view: View, position: Int) { + val source = getItem(position) ?: return + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.rss_source_item) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(source) + R.id.menu_bottom -> callBack.toBottom(source) + R.id.menu_del -> callBack.del(source) + } + true + } + popupMenu.show() + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.customOrder == targetItem.customOrder) { + callBack.upOrder() + } else { + val srcOrder = srcItem.customOrder + srcItem.customOrder = targetItem.customOrder + targetItem.customOrder = srcOrder + movedItems.add(srcItem) + movedItems.add(targetItem) + } + } + swapItem(srcPosition, targetPosition) + return true + } + + private val movedItems = hashSetOf() + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (movedItems.isNotEmpty()) { + callBack.update(*movedItems.toTypedArray()) + movedItems.clear() + } + } + + val dragSelectCallback: DragSelectTouchHelper.Callback = + object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { + override fun currentSelectedId(): MutableSet { + return selected + } + + override fun getItemId(position: Int): RssSource { + return getItem(position)!! + } + + override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { + getItem(position)?.let { + if (isSelected) { + selected.add(it) + } else { + selected.remove(it) + } + notifyItemChanged(position, bundleOf(Pair("selected", null))) + callBack.upCountView() + return true + } + return false + } + } + + interface CallBack { + fun del(source: RssSource) + fun edit(source: RssSource) + fun update(vararg source: RssSource) + fun toTop(source: RssSource) + fun toBottom(source: RssSource) + fun upOrder() + fun upCountView() + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt new file mode 100644 index 000000000..b3bb3895f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt @@ -0,0 +1,132 @@ +package io.legado.app.ui.rss.source.manage + +import android.app.Application +import android.text.TextUtils +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.help.DefaultData +import io.legado.app.utils.* +import java.io.File + +class RssSourceViewModel(application: Application) : BaseViewModel(application) { + + fun topSource(vararg sources: RssSource) { + execute { + val minOrder = appDb.rssSourceDao.minOrder - 1 + sources.forEachIndexed { index, rssSource -> + rssSource.customOrder = minOrder - index + } + appDb.rssSourceDao.update(*sources) + } + } + + fun bottomSource(vararg sources: RssSource) { + execute { + val maxOrder = appDb.rssSourceDao.maxOrder + 1 + sources.forEachIndexed { index, rssSource -> + rssSource.customOrder = maxOrder + index + } + appDb.rssSourceDao.update(*sources) + } + } + + fun del(vararg rssSource: RssSource) { + execute { appDb.rssSourceDao.delete(*rssSource) } + } + + fun update(vararg rssSource: RssSource) { + execute { appDb.rssSourceDao.update(*rssSource) } + } + + fun upOrder() { + execute { + val sources = appDb.rssSourceDao.all + for ((index: Int, source: RssSource) in sources.withIndex()) { + source.customOrder = index + 1 + } + appDb.rssSourceDao.update(*sources.toTypedArray()) + } + } + + fun enableSelection(sources: List) { + execute { + val list = arrayListOf() + sources.forEach { + list.add(it.copy(enabled = true)) + } + appDb.rssSourceDao.update(*list.toTypedArray()) + } + } + + fun disableSelection(sources: List) { + execute { + val list = arrayListOf() + sources.forEach { + list.add(it.copy(enabled = false)) + } + appDb.rssSourceDao.update(*list.toTypedArray()) + } + } + + fun saveToFile(sources: List, success: (file: File) -> Unit) { + execute { + val path = "${context.filesDir}/shareRssSource.json" + FileUtils.delete(path) + val file = FileUtils.createFileWithReplace(path) + file.writeText(GSON.toJson(sources)) + file + }.onSuccess { + success.invoke(it) + }.onError { + context.toastOnUi(it.msg) + } + } + + fun addGroup(group: String) { + execute { + val sources = appDb.rssSourceDao.noGroup + sources.map { source -> + source.sourceGroup = group + } + appDb.rssSourceDao.update(*sources.toTypedArray()) + } + } + + fun upGroup(oldGroup: String, newGroup: String?) { + execute { + val sources = appDb.rssSourceDao.getByGroup(oldGroup) + sources.map { source -> + source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(oldGroup) + if (!newGroup.isNullOrEmpty()) + it.add(newGroup) + source.sourceGroup = TextUtils.join(",", it) + } + } + appDb.rssSourceDao.update(*sources.toTypedArray()) + } + } + + fun delGroup(group: String) { + execute { + execute { + val sources = appDb.rssSourceDao.getByGroup(group) + sources.map { source -> + source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(group) + source.sourceGroup = TextUtils.join(",", it) + } + } + appDb.rssSourceDao.update(*sources.toTypedArray()) + } + } + } + + fun importDefault() { + execute { + DefaultData.importDefaultRssSources() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt new file mode 100644 index 000000000..b22529d90 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt @@ -0,0 +1,137 @@ +package io.legado.app.ui.rss.subscription + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.core.view.isGone +import androidx.recyclerview.widget.ItemTouchHelper +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.data.appDb +import io.legado.app.data.entities.RuleSub +import io.legado.app.databinding.ActivityRuleSubBinding +import io.legado.app.databinding.DialogRuleSubEditBinding +import io.legado.app.lib.dialogs.alert +import io.legado.app.ui.association.ImportBookSourceDialog +import io.legado.app.ui.association.ImportReplaceRuleDialog +import io.legado.app.ui.association.ImportRssSourceDialog +import io.legado.app.ui.widget.recycler.ItemTouchCallback +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.viewbindingdelegate.viewBinding +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 规则订阅界面 + */ +class RuleSubActivity : BaseActivity(), + RuleSubAdapter.Callback { + + override val binding by viewBinding(ActivityRuleSubBinding::inflate) + private val adapter by lazy { RuleSubAdapter(this, this) } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + initData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.source_subscription, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add -> { + val order = appDb.ruleSubDao.maxOrder + 1 + editSubscription(RuleSub(customOrder = order)) + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + binding.recyclerView.adapter = adapter + val itemTouchCallback = ItemTouchCallback(adapter) + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) + } + + private fun initData() { + launch { + appDb.ruleSubDao.flowAll().collect { + binding.tvEmptyMsg.isGone = it.isNotEmpty() + adapter.setItems(it) + } + } + } + + override fun openSubscription(ruleSub: RuleSub) { + when (ruleSub.type) { + 0 -> showDialogFragment( + ImportBookSourceDialog(ruleSub.url) + ) + 1 -> showDialogFragment( + ImportRssSourceDialog(ruleSub.url) + ) + 2 -> showDialogFragment( + ImportReplaceRuleDialog(ruleSub.url) + ) + } + } + + override fun editSubscription(ruleSub: RuleSub) { + alert(R.string.rule_subscription) { + val alertBinding = DialogRuleSubEditBinding.inflate(layoutInflater).apply { + spType.setSelection(ruleSub.type) + etName.setText(ruleSub.name) + etUrl.setText(ruleSub.url) + } + customView { alertBinding.root } + okButton { + launch { + ruleSub.type = alertBinding.spType.selectedItemPosition + ruleSub.name = alertBinding.etName.text?.toString() ?: "" + ruleSub.url = alertBinding.etUrl.text?.toString() ?: "" + val rs = withContext(IO) { + appDb.ruleSubDao.findByUrl(ruleSub.url) + } + if (rs != null && rs.id != ruleSub.id) { + toastOnUi("${getString(R.string.url_already)}(${rs.name})") + return@launch + } + withContext(IO) { + appDb.ruleSubDao.insert(ruleSub) + } + } + } + cancelButton() + } + } + + override fun delSubscription(ruleSub: RuleSub) { + launch(IO) { + appDb.ruleSubDao.delete(ruleSub) + } + } + + override fun updateSourceSub(vararg ruleSub: RuleSub) { + launch(IO) { + appDb.ruleSubDao.update(*ruleSub) + } + } + + override fun upOrder() { + launch(IO) { + val sourceSubs = appDb.ruleSubDao.all + for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) { + ruleSub.customOrder = index + 1 + } + appDb.ruleSubDao.update(*sourceSubs.toTypedArray()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubAdapter.kt new file mode 100644 index 000000000..2bb5560be --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubAdapter.kt @@ -0,0 +1,97 @@ +package io.legado.app.ui.rss.subscription + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.data.entities.RuleSub +import io.legado.app.databinding.ItemRuleSubBinding +import io.legado.app.ui.widget.recycler.ItemTouchCallback + + +class RuleSubAdapter(context: Context, val callBack: Callback) : + RecyclerAdapter(context), + ItemTouchCallback.Callback { + + private val typeArray = context.resources.getStringArray(R.array.rule_type) + + override fun convert( + holder: ItemViewHolder, + binding: ItemRuleSubBinding, + item: RuleSub, + payloads: MutableList + ) { + binding.tvType.text = typeArray[item.type] + binding.tvName.text = item.name + binding.tvUrl.text = item.url + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemRuleSubBinding) { + binding.root.setOnClickListener { + callBack.openSubscription(getItem(holder.layoutPosition)!!) + } + binding.ivEdit.setOnClickListener { + callBack.editSubscription(getItem(holder.layoutPosition)!!) + } + binding.ivMenuMore.setOnClickListener { + showMenu(binding.ivMenuMore, holder.layoutPosition) + } + } + + private fun showMenu(view: View, position: Int) { + val source = getItem(position) ?: return + val popupMenu = PopupMenu(context, view) + popupMenu.inflate(R.menu.source_sub_item) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_del -> callBack.delSubscription(source) + } + true + } + popupMenu.show() + } + + override fun getViewBinding(parent: ViewGroup): ItemRuleSubBinding { + return ItemRuleSubBinding.inflate(inflater, parent, false) + } + + override fun swap(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.customOrder == targetItem.customOrder) { + callBack.upOrder() + } else { + val srcOrder = srcItem.customOrder + srcItem.customOrder = targetItem.customOrder + targetItem.customOrder = srcOrder + movedItems.add(srcItem) + movedItems.add(targetItem) + } + } + swapItem(srcPosition, targetPosition) + return true + } + + private val movedItems = hashSetOf() + + override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + if (movedItems.isNotEmpty()) { + callBack.updateSourceSub(*movedItems.toTypedArray()) + movedItems.clear() + } + } + + interface Callback { + fun openSubscription(ruleSub: RuleSub) + fun editSubscription(ruleSub: RuleSub) + fun delSubscription(ruleSub: RuleSub) + fun updateSourceSub(vararg ruleSub: RuleSub) + fun upOrder() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt b/app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt new file mode 100644 index 000000000..260de8d44 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt @@ -0,0 +1,87 @@ +package io.legado.app.ui.welcome + +import android.content.Intent +import android.os.Bundle +import com.github.liuyueyi.quick.transfer.ChineseUtils +import io.legado.app.base.BaseActivity +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.databinding.ActivityWelcomeBinding +import io.legado.app.help.AppConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.storage.AppWebDav +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.MainActivity +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.startActivity +import io.legado.app.utils.viewbindingdelegate.viewBinding +import java.util.concurrent.TimeUnit + +open class WelcomeActivity : BaseActivity() { + + override val binding by viewBinding(ActivityWelcomeBinding::inflate) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + binding.ivBook.setColorFilter(accentColor) + binding.vwTitleLine.setBackgroundColor(accentColor) + // 避免从桌面启动程序后,会重新实例化入口类的activity + if (intent.flags and Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT != 0) { + finish() + } else { + init() + } + } + + private fun init() { + Coroutine.async { + if (!AppConfig.syncBookProgress) return@async + val books = appDb.bookDao.all + books.forEach { book -> + AppWebDav.getBookProgress(book)?.let { bookProgress -> + if (bookProgress.durChapterIndex > book.durChapterIndex || + (bookProgress.durChapterIndex == book.durChapterIndex && + bookProgress.durChapterPos > book.durChapterPos) + ) { + book.durChapterIndex = bookProgress.durChapterIndex + book.durChapterPos = bookProgress.durChapterPos + book.durChapterTitle = bookProgress.durChapterTitle + book.durChapterTime = bookProgress.durChapterTime + appDb.bookDao.update(book) + } + } + } + } + Coroutine.async { + appDb.cacheDao.clearDeadline(System.currentTimeMillis()) + //清除过期数据 + if (getPrefBoolean(PreferKey.autoClearExpired, true)) { + appDb.searchBookDao + .clearExpired(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) + } + //初始化简繁转换引擎 + when (AppConfig.chineseConverterType) { + 1 -> ChineseUtils.t2s("初始化") + 2 -> ChineseUtils.s2t("初始化") + else -> null + } + } + binding.root.postDelayed({ startMainActivity() }, 500) + } + + private fun startMainActivity() { + startActivity() + if (getPrefBoolean(PreferKey.defaultToRead)) { + startActivity() + } + finish() + } + +} + +class Launcher1 : WelcomeActivity() +class Launcher2 : WelcomeActivity() +class Launcher3 : WelcomeActivity() +class Launcher4 : WelcomeActivity() +class Launcher5 : WelcomeActivity() +class Launcher6 : WelcomeActivity() \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/BatteryView.kt b/app/src/main/java/io/legado/app/ui/widget/BatteryView.kt new file mode 100644 index 000000000..2b2cddb71 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/BatteryView.kt @@ -0,0 +1,79 @@ +package io.legado.app.ui.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Typeface +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.utils.dp + +class BatteryView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + private val batteryTypeface by lazy { + Typeface.createFromAsset(context.assets, "font/number.ttf") + } + private val batteryPaint = Paint() + private val outFrame = Rect() + private val polar = Rect() + var isBattery = false + set(value) { + field = value + if (value && !isInEditMode) { + super.setTypeface(batteryTypeface) + postInvalidate() + } + } + + init { + setPadding(4.dp, 2.dp, 6.dp, 2.dp) + batteryPaint.strokeWidth = 1.dp.toFloat() + batteryPaint.isAntiAlias = true + batteryPaint.color = paint.color + } + + override fun setTypeface(tf: Typeface?) { + if (!isBattery) { + super.setTypeface(tf) + } + } + + fun setColor(@ColorInt color: Int) { + setTextColor(color) + batteryPaint.color = color + invalidate() + } + + @SuppressLint("SetTextI18n") + fun setBattery(battery: Int) { + text = "$battery" + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (!isBattery) return + outFrame.set( + 1.dp, + 1.dp, + width - 3.dp, + height - 1.dp + ) + val dj = (outFrame.bottom - outFrame.top) / 3 + polar.set( + outFrame.right, + outFrame.top + dj, + width - 1.dp, + outFrame.bottom - dj + ) + batteryPaint.style = Paint.Style.STROKE + canvas.drawRect(outFrame, batteryPaint) + batteryPaint.style = Paint.Style.FILL + canvas.drawRect(polar, batteryPaint) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/DetailSeekBar.kt b/app/src/main/java/io/legado/app/ui/widget/DetailSeekBar.kt new file mode 100644 index 000000000..441a1853b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/DetailSeekBar.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.SeekBar +import io.legado.app.R +import io.legado.app.databinding.ViewDetailSeekBarBinding +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.ui.widget.seekbar.SeekBarChangeListener +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.progressAdd + + +class DetailSeekBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs), + SeekBarChangeListener { + private var binding: ViewDetailSeekBarBinding = + ViewDetailSeekBarBinding.inflate(LayoutInflater.from(context), this, true) + private val isBottomBackground: Boolean + + var valueFormat: ((progress: Int) -> String)? = null + var onChanged: ((progress: Int) -> Unit)? = null + var progress: Int + get() = binding.seekBar.progress + set(value) { + binding.seekBar.progress = value + } + var max: Int + get() = binding.seekBar.max + set(value) { + binding.seekBar.max = value + } + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DetailSeekBar) + isBottomBackground = + typedArray.getBoolean(R.styleable.DetailSeekBar_isBottomBackground, false) + binding.tvSeekTitle.text = typedArray.getText(R.styleable.DetailSeekBar_title) + binding.seekBar.max = typedArray.getInteger(R.styleable.DetailSeekBar_max, 0) + typedArray.recycle() + if (isBottomBackground && !isInEditMode) { + val isLight = ColorUtils.isColorLight(context.bottomBackground) + val textColor = context.getPrimaryTextColor(isLight) + binding.tvSeekTitle.setTextColor(textColor) + binding.ivSeekPlus.setColorFilter(textColor) + binding.ivSeekReduce.setColorFilter(textColor) + binding.tvSeekValue.setTextColor(textColor) + } + binding.ivSeekPlus.setOnClickListener { + binding.seekBar.progressAdd(1) + onChanged?.invoke(binding.seekBar.progress) + } + binding.ivSeekReduce.setOnClickListener { + binding.seekBar.progressAdd(-1) + onChanged?.invoke(binding.seekBar.progress) + } + binding.seekBar.setOnSeekBarChangeListener(this) + } + + private fun upValue(progress: Int = binding.seekBar.progress) { + valueFormat?.let { + binding.tvSeekValue.text = it.invoke(progress) + } ?: let { + binding.tvSeekValue.text = progress.toString() + } + } + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + upValue(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + onChanged?.invoke(binding.seekBar.progress) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/KeyboardToolPop.kt b/app/src/main/java/io/legado/app/ui/widget/KeyboardToolPop.kt new file mode 100644 index 000000000..276ce4704 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/KeyboardToolPop.kt @@ -0,0 +1,74 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.PopupWindow +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.ItemFilletTextBinding +import io.legado.app.databinding.PopupKeyboardToolBinding + + +class KeyboardToolPop( + context: Context, + private val chars: List, + val callBack: CallBack? +) : PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) { + + private val binding = PopupKeyboardToolBinding.inflate(LayoutInflater.from(context)) + + init { + contentView = binding.root + + isTouchable = true + isOutsideTouchable = false + isFocusable = false + inputMethodMode = INPUT_METHOD_NEEDED //解决遮盖输入法 + initRecyclerView() + } + + private fun initRecyclerView() = with(contentView) { + val adapter = Adapter(context) + binding.recyclerView.layoutManager = + LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + binding.recyclerView.adapter = adapter + adapter.setItems(chars) + } + + inner class Adapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemFilletTextBinding { + return ItemFilletTextBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemFilletTextBinding, + item: String, + payloads: MutableList + ) { + binding.run { + textView.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemFilletTextBinding) { + holder.itemView.apply { + setOnClickListener { + getItem(holder.layoutPosition)?.let { + callBack?.sendText(it) + } + } + } + } + } + + interface CallBack { + fun sendText(text: String) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/LabelsBar.kt b/app/src/main/java/io/legado/app/ui/widget/LabelsBar.kt new file mode 100644 index 000000000..01c4957db --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/LabelsBar.kt @@ -0,0 +1,62 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import io.legado.app.ui.widget.text.AccentBgTextView +import io.legado.app.utils.dp + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class LabelsBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : LinearLayout(context, attrs) { + + private val unUsedViews = arrayListOf() + private val usedViews = arrayListOf() + var textSize = 12f + + fun setLabels(labels: Array) { + clear() + labels.forEach { + addLabel(it) + } + } + + fun setLabels(labels: List) { + clear() + labels.forEach { + addLabel(it) + } + } + + fun clear() { + unUsedViews.addAll(usedViews) + usedViews.clear() + removeAllViews() + } + + fun addLabel(label: String) { + val tv = if (unUsedViews.isEmpty()) { + AccentBgTextView(context, null).apply { + setPadding(3.dp, 0, 3.dp, 0) + setRadius(2) + val lp = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + lp.setMargins(0, 0, 2.dp, 0) + layoutParams = lp + text = label + maxLines = 1 + usedViews.add(this) + } + } else { + unUsedViews.last().apply { + usedViews.add(this) + unUsedViews.removeAt(unUsedViews.lastIndex) + } + } + tv.textSize = textSize + tv.text = label + addView(tv) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/SearchView.kt b/app/src/main/java/io/legado/app/ui/widget/SearchView.kt new file mode 100644 index 000000000..f2c289ef3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/SearchView.kt @@ -0,0 +1,106 @@ +package io.legado.app.ui.widget + +import android.annotation.SuppressLint +import android.app.SearchableInfo +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.ImageSpan +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.widget.TextView +import androidx.appcompat.widget.SearchView +import io.legado.app.R +import timber.log.Timber + + +class SearchView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : SearchView(context, attrs) { + private var mSearchHintIcon: Drawable? = null + private var textView: TextView? = null + + @SuppressLint("UseCompatLoadingForDrawables") + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int + ) { + super.onLayout(changed, left, top, right, bottom) + try { + if (textView == null) { + textView = findViewById(androidx.appcompat.R.id.search_src_text) + mSearchHintIcon = this.context.getDrawable(R.drawable.ic_search_hint) + updateQueryHint() + } + // 改变字体 + textView!!.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + textView!!.gravity = Gravity.CENTER_VERTICAL + } catch (e: Exception) { + Timber.e(e) + } + } + + private fun getDecoratedHint(hintText: CharSequence): CharSequence { + // If the field is always expanded or we don't have a search hint icon, + // then don't add the search icon to the hint. + if (mSearchHintIcon == null) { + return hintText + } + val textSize = (textView!!.textSize * 0.8).toInt() + mSearchHintIcon!!.setBounds(0, 0, textSize, textSize) + val ssb = SpannableStringBuilder(" ") + ssb.setSpan(CenteredImageSpan(mSearchHintIcon), 1, 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + ssb.append(hintText) + return ssb + } + + private fun updateQueryHint() { + textView?.let { + it.hint = getDecoratedHint(queryHint ?: "") + } + } + + override fun setIconifiedByDefault(iconified: Boolean) { + super.setIconifiedByDefault(iconified) + updateQueryHint() + } + + override fun setSearchableInfo(searchable: SearchableInfo?) { + super.setSearchableInfo(searchable) + searchable?.let { + updateQueryHint() + } + } + + override fun setQueryHint(hint: CharSequence?) { + super.setQueryHint(hint) + updateQueryHint() + } + + internal class CenteredImageSpan(drawable: Drawable?) : ImageSpan(drawable!!) { + override fun draw( + canvas: Canvas, text: CharSequence, + start: Int, end: Int, x: Float, + top: Int, y: Int, bottom: Int, paint: Paint + ) { + // image to draw + val b = drawable + // font metrics of text to be replaced + val fm = paint.fontMetricsInt + val transY = ((y + fm.descent + y + fm.ascent) / 2 + - b.bounds.bottom / 2) + canvas.save() + canvas.translate(x, transY.toFloat()) + b.draw(canvas) + canvas.restore() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/SelectActionBar.kt b/app/src/main/java/io/legado/app/ui/widget/SelectActionBar.kt new file mode 100644 index 000000000..c381b391f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/SelectActionBar.kt @@ -0,0 +1,113 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.Menu +import android.widget.FrameLayout +import androidx.annotation.MenuRes +import androidx.annotation.StringRes +import androidx.appcompat.widget.PopupMenu +import io.legado.app.R +import io.legado.app.databinding.ViewSelectActionBarBinding +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.* +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.visible + + +@Suppress("unused") +class SelectActionBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + private var callBack: CallBack? = null + private var selMenu: PopupMenu? = null + private val binding = + ViewSelectActionBarBinding.inflate(LayoutInflater.from(context), this, true) + + init { + setBackgroundColor(context.bottomBackground) + elevation = + if (AppConfig.elevation < 0) context.elevation else AppConfig.elevation.toFloat() + val textIsDark = ColorUtils.isColorLight(context.bottomBackground) + val primaryTextColor = context.getPrimaryTextColor(textIsDark) + val secondaryTextColor = context.getSecondaryTextColor(textIsDark) + binding.cbSelectedAll.setTextColor(primaryTextColor) + TintHelper.setTint(binding.cbSelectedAll, context.accentColor, !textIsDark) + binding.ivMenuMore.setColorFilter(secondaryTextColor) + binding.cbSelectedAll.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + callBack?.selectAll(isChecked) + } + } + binding.btnRevertSelection.setOnClickListener { callBack?.revertSelection() } + binding.btnSelectActionMain.setOnClickListener { callBack?.onClickSelectBarMainAction() } + binding.ivMenuMore.setOnClickListener { selMenu?.show() } + } + + fun setMainActionText(text: String) = binding.run { + btnSelectActionMain.text = text + btnSelectActionMain.visible() + } + + fun setMainActionText(@StringRes id: Int) = binding.run { + btnSelectActionMain.setText(id) + btnSelectActionMain.visible() + } + + fun inflateMenu(@MenuRes resId: Int): Menu? { + selMenu = PopupMenu(context, binding.ivMenuMore) + selMenu?.inflate(resId) + binding.ivMenuMore.visible() + return selMenu?.menu + } + + fun setCallBack(callBack: CallBack) { + this.callBack = callBack + } + + fun setOnMenuItemClickListener(listener: PopupMenu.OnMenuItemClickListener) { + selMenu?.setOnMenuItemClickListener(listener) + } + + fun upCountView(selectCount: Int, allCount: Int) = binding.run { + if (selectCount == 0) { + cbSelectedAll.isChecked = false + } else { + cbSelectedAll.isChecked = selectCount >= allCount + } + + //重置全选的文字 + if (cbSelectedAll.isChecked) { + cbSelectedAll.text = context.getString( + R.string.select_cancel_count, + selectCount, + allCount + ) + } else { + cbSelectedAll.text = context.getString( + R.string.select_all_count, + selectCount, + allCount + ) + } + setMenuClickable(selectCount > 0) + } + + private fun setMenuClickable(isClickable: Boolean) = binding.run { + btnRevertSelection.isEnabled = isClickable + btnRevertSelection.isClickable = isClickable + btnSelectActionMain.isEnabled = isClickable + btnSelectActionMain.isClickable = isClickable + } + + interface CallBack { + + fun selectAll(selectAll: Boolean) + + fun revertSelection() + + fun onClickSelectBarMainAction() {} + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/ShadowLayout.kt b/app/src/main/java/io/legado/app/ui/widget/ShadowLayout.kt new file mode 100644 index 000000000..95e54ac63 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/ShadowLayout.kt @@ -0,0 +1,185 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.widget.RelativeLayout +import io.legado.app.R +import io.legado.app.utils.getCompatColor + +/** + * ShadowLayout.java + * + * Created by lijiankun on 17/8/11. + */ +@Suppress("unused") +class ShadowLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : RelativeLayout(context, attrs) { + private val mPaint = + Paint(Paint.ANTI_ALIAS_FLAG) + private val mRectF = RectF() + + /** + * 阴影的颜色 + */ + private var mShadowColor = Color.TRANSPARENT + + /** + * 阴影的大小范围 + */ + private var mShadowRadius = 0f + + /** + * 阴影 x 轴的偏移量 + */ + private var mShadowDx = 0f + + /** + * 阴影 y 轴的偏移量 + */ + private var mShadowDy = 0f + + /** + * 阴影显示的边界 + */ + private var mShadowSide = ALL + + /** + * 阴影的形状,圆形/矩形 + */ + private var mShadowShape = SHAPE_RECTANGLE + + + init { + setLayerType(View.LAYER_TYPE_SOFTWARE, null) // 关闭硬件加速 + setWillNotDraw(false) // 调用此方法后,才会执行 onDraw(Canvas) 方法 + val typedArray = + context.obtainStyledAttributes(attrs, R.styleable.ShadowLayout) + mShadowColor = typedArray.getColor( + R.styleable.ShadowLayout_shadowColor, + context.getCompatColor(android.R.color.black) + ) + mShadowRadius = + typedArray.getDimension(R.styleable.ShadowLayout_shadowRadius, dip2px(0f)) + mShadowDx = typedArray.getDimension(R.styleable.ShadowLayout_shadowDx, dip2px(0f)) + mShadowDy = typedArray.getDimension(R.styleable.ShadowLayout_shadowDy, dip2px(0f)) + mShadowSide = + typedArray.getInt(R.styleable.ShadowLayout_shadowSide, ALL) + mShadowShape = typedArray.getInt( + R.styleable.ShadowLayout_shadowShape, + SHAPE_RECTANGLE + ) + typedArray.recycle() + + setUpShadowPaint() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val effect = mShadowRadius + dip2px(5f) + var rectLeft = 0f + var rectTop = 0f + var rectRight = this.measuredWidth.toFloat() + var rectBottom = this.measuredHeight.toFloat() + var paddingLeft = 0 + var paddingTop = 0 + var paddingRight = 0 + var paddingBottom = 0 + this.width + if (mShadowSide and LEFT == LEFT) { + rectLeft = effect + paddingLeft = effect.toInt() + } + if (mShadowSide and TOP == TOP) { + rectTop = effect + paddingTop = effect.toInt() + } + if (mShadowSide and RIGHT == RIGHT) { + rectRight = this.measuredWidth - effect + paddingRight = effect.toInt() + } + if (mShadowSide and BOTTOM == BOTTOM) { + rectBottom = this.measuredHeight - effect + paddingBottom = effect.toInt() + } + if (mShadowDy != 0.0f) { + rectBottom -= mShadowDy + paddingBottom += mShadowDy.toInt() + } + if (mShadowDx != 0.0f) { + rectRight -= mShadowDx + paddingRight += mShadowDx.toInt() + } + mRectF.left = rectLeft + mRectF.top = rectTop + mRectF.right = rectRight + mRectF.bottom = rectBottom + setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) + } + + /** + * 真正绘制阴影的方法 + */ + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + setUpShadowPaint() + if (mShadowShape == SHAPE_RECTANGLE) { + canvas.drawRect(mRectF, mPaint) + } else if (mShadowShape == SHAPE_OVAL) { + canvas.drawCircle( + mRectF.centerX(), + mRectF.centerY(), + mRectF.width().coerceAtMost(mRectF.height()) / 2, + mPaint + ) + } + } + + fun setShadowColor(shadowColor: Int) { + mShadowColor = shadowColor + requestLayout() + postInvalidate() + } + + fun setShadowRadius(shadowRadius: Float) { + mShadowRadius = shadowRadius + requestLayout() + postInvalidate() + } + + private fun setUpShadowPaint() { + mPaint.reset() + mPaint.isAntiAlias = true + mPaint.color = Color.TRANSPARENT + mPaint.setShadowLayer(mShadowRadius, mShadowDx, mShadowDy, mShadowColor) + } + + /** + * dip2px dp 值转 px 值 + * + * @param dpValue dp 值 + * @return px 值 + */ + private fun dip2px(dpValue: Float): Float { + val dm = context.resources.displayMetrics + val scale = dm.density + return dpValue * scale + 0.5f + } + + companion object { + const val ALL = 0x1111 + const val LEFT = 0x0001 + const val TOP = 0x0010 + const val RIGHT = 0x0100 + const val BOTTOM = 0x1000 + const val SHAPE_RECTANGLE = 0x0001 + const val SHAPE_OVAL = 0x0010 + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt b/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt new file mode 100644 index 000000000..b44191fb3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt @@ -0,0 +1,219 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.util.AttributeSet +import android.view.Menu +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.StyleRes +import androidx.appcompat.widget.Toolbar +import com.google.android.material.appbar.AppBarLayout +import io.legado.app.R +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.elevation +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.activity +import io.legado.app.utils.navigationBarHeight +import io.legado.app.utils.statusBarHeight + +@Suppress("unused") +class TitleBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppBarLayout(context, attrs) { + + val toolbar: Toolbar + val menu: Menu + get() = toolbar.menu + + var title: CharSequence? + get() = toolbar.title + set(title) { + toolbar.title = title + } + + var subtitle: CharSequence? + get() = toolbar.subtitle + set(subtitle) { + toolbar.subtitle = subtitle + } + + private val displayHomeAsUp: Boolean + private val navigationIconTint: ColorStateList? + private val navigationIconTintMode: Int + private val fitStatusBar: Boolean + private val fitNavigationBar: Boolean + private val attachToActivity: Boolean + + init { + val a = context.obtainStyledAttributes( + attrs, R.styleable.TitleBar, + R.attr.titleBarStyle, 0 + ) + navigationIconTint = a.getColorStateList(R.styleable.TitleBar_navigationIconTint) + navigationIconTintMode = a.getInt(R.styleable.TitleBar_navigationIconTintMode, 9) + attachToActivity = a.getBoolean(R.styleable.TitleBar_attachToActivity, true) + displayHomeAsUp = a.getBoolean(R.styleable.TitleBar_displayHomeAsUp, true) + fitStatusBar = a.getBoolean(R.styleable.TitleBar_fitStatusBar, true) + fitNavigationBar = a.getBoolean(R.styleable.TitleBar_fitNavigationBar, false) + + val navigationIcon = a.getDrawable(R.styleable.TitleBar_navigationIcon) + val navigationContentDescription = + a.getText(R.styleable.TitleBar_navigationContentDescription) + val titleText = a.getString(R.styleable.TitleBar_title) + val subtitleText = a.getString(R.styleable.TitleBar_subtitle) + + when (a.getInt(R.styleable.TitleBar_themeMode, 0)) { + 1 -> inflate(context, R.layout.view_title_bar_dark, this) + else -> inflate(context, R.layout.view_title_bar, this) + } + toolbar = findViewById(R.id.toolbar) + + toolbar.apply { + navigationIcon?.let { + this.navigationIcon = it + this.navigationContentDescription = navigationContentDescription + } + + if (a.hasValue(R.styleable.TitleBar_titleTextAppearance)) { + this.setTitleTextAppearance( + context, + a.getResourceId(R.styleable.TitleBar_titleTextAppearance, 0) + ) + } + + if (a.hasValue(R.styleable.TitleBar_titleTextColor)) { + this.setTitleTextColor(a.getColor(R.styleable.TitleBar_titleTextColor, -0x1)) + } + + if (a.hasValue(R.styleable.TitleBar_subtitleTextAppearance)) { + this.setSubtitleTextAppearance( + context, + a.getResourceId(R.styleable.TitleBar_subtitleTextAppearance, 0) + ) + } + + if (a.hasValue(R.styleable.TitleBar_subtitleTextColor)) { + this.setSubtitleTextColor(a.getColor(R.styleable.TitleBar_subtitleTextColor, -0x1)) + } + + + if (a.hasValue(R.styleable.TitleBar_contentInsetLeft) + || a.hasValue(R.styleable.TitleBar_contentInsetRight) + ) { + this.setContentInsetsAbsolute( + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetLeft, 0), + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetRight, 0) + ) + } + + if (a.hasValue(R.styleable.TitleBar_contentInsetStart) + || a.hasValue(R.styleable.TitleBar_contentInsetEnd) + ) { + this.setContentInsetsRelative( + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetStart, 0), + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetEnd, 0) + ) + } + + if (a.hasValue(R.styleable.TitleBar_contentInsetStartWithNavigation)) { + this.contentInsetStartWithNavigation = a.getDimensionPixelOffset( + R.styleable.TitleBar_contentInsetStartWithNavigation, 0 + ) + } + + if (a.hasValue(R.styleable.TitleBar_contentInsetEndWithActions)) { + this.contentInsetEndWithActions = a.getDimensionPixelOffset( + R.styleable.TitleBar_contentInsetEndWithActions, 0 + ) + } + + if (!titleText.isNullOrBlank()) { + this.title = titleText + } + + if (!subtitleText.isNullOrBlank()) { + this.subtitle = subtitleText + } + + if (a.hasValue(R.styleable.TitleBar_contentLayout)) { + inflate(context, a.getResourceId(R.styleable.TitleBar_contentLayout, 0), this) + } + } + + if (!isInEditMode) { + if (fitStatusBar) { + setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom) + } + + if (fitNavigationBar) { + setPadding(paddingLeft, paddingTop, paddingRight, context.navigationBarHeight) + } + + setBackgroundColor(context.primaryColor) + + stateListAnimator = null + elevation = if (AppConfig.elevation < 0) { + context.elevation + } else { + AppConfig.elevation.toFloat() + } + } + a.recycle() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + attachToActivity() + } + + fun setNavigationOnClickListener(clickListener: ((View) -> Unit)) { + toolbar.setNavigationOnClickListener(clickListener) + } + + fun setTitle(titleId: Int) { + toolbar.setTitle(titleId) + } + + fun setSubTitle(subtitleId: Int) { + toolbar.setSubtitle(subtitleId) + } + + fun setTitleTextColor(@ColorInt color: Int) { + toolbar.setTitleTextColor(color) + } + + fun setTitleTextAppearance(@StyleRes resId: Int) { + toolbar.setTitleTextAppearance(context, resId) + } + + fun setSubTitleTextColor(@ColorInt color: Int) { + toolbar.setSubtitleTextColor(color) + } + + fun setSubTitleTextAppearance(@StyleRes resId: Int) { + toolbar.setSubtitleTextAppearance(context, resId) + } + + fun transparent() { + elevation = 0f + setBackgroundColor(Color.TRANSPARENT) + } + + fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, fullScreen: Boolean) { + val topPadding = if (!isInMultiWindowMode && fullScreen) context.statusBarHeight else 0 + setPadding(paddingLeft, topPadding, paddingRight, paddingBottom) + } + + private fun attachToActivity() { + if (attachToActivity) { + activity?.let { + it.setSupportActionBar(toolbar) + it.supportActionBar?.setDisplayHomeAsUpEnabled(displayHomeAsUp) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt b/app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt new file mode 100644 index 000000000..3a0af5b03 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt @@ -0,0 +1,198 @@ +package io.legado.app.ui.widget.anima + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.os.Looper +import android.util.AttributeSet +import android.view.View + +import io.legado.app.R + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class RefreshProgressBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + private var a = 1 + private var durProgress = 0 + private var secondDurProgress = 0 + var maxProgress = 100 + var secondMaxProgress = 100 + var bgColor = 0x00000000 + var secondColor = -0x3e3e3f + var fontColor = -0xc9c9ca + var speed = 2 + var secondFinalProgress = 0 + private set + private var paint: Paint = Paint() + private val bgRect = Rect() + private val secondRect = Rect() + private val fontRectF = RectF() + + var isAutoLoading: Boolean = false + set(loading) { + field = loading + if (!loading) { + secondDurProgress = 0 + secondFinalProgress = 0 + } + maxProgress = 0 + + invalidate() + } + + init { + paint.style = Paint.Style.FILL + + val a = context.obtainStyledAttributes(attrs, R.styleable.RefreshProgressBar) + speed = a.getDimensionPixelSize(R.styleable.RefreshProgressBar_speed, speed) + maxProgress = a.getInt(R.styleable.RefreshProgressBar_max_progress, maxProgress) + durProgress = a.getInt(R.styleable.RefreshProgressBar_dur_progress, durProgress) + secondDurProgress = a.getDimensionPixelSize( + R.styleable.RefreshProgressBar_second_dur_progress, + secondDurProgress + ) + secondFinalProgress = secondDurProgress + secondMaxProgress = a.getDimensionPixelSize( + R.styleable.RefreshProgressBar_second_max_progress, + secondMaxProgress + ) + bgColor = a.getColor(R.styleable.RefreshProgressBar_bg_color, bgColor) + secondColor = a.getColor(R.styleable.RefreshProgressBar_second_color, secondColor) + fontColor = a.getColor(R.styleable.RefreshProgressBar_font_color, fontColor) + a.recycle() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + paint.color = bgColor + bgRect.set(0, 0, measuredWidth, measuredHeight) + canvas.drawRect(bgRect, paint) + + if (secondDurProgress > 0 && secondMaxProgress > 0) { + var secondDur = secondDurProgress + if (secondDur < 0) { + secondDur = 0 + } + if (secondDur > secondMaxProgress) { + secondDur = secondMaxProgress + } + paint.color = secondColor + val tempW = + (measuredWidth.toFloat() * 1.0f * (secondDur * 1.0f / secondMaxProgress)).toInt() + secondRect.set( + measuredWidth / 2 - tempW / 2, + 0, + measuredWidth / 2 + tempW / 2, + measuredHeight + ) + canvas.drawRect(secondRect, paint) + } + + if (durProgress > 0 && maxProgress > 0) { + paint.color = fontColor + fontRectF.set( + 0f, + 0f, + measuredWidth.toFloat() * 1.0f * (durProgress * 1.0f / maxProgress), + measuredHeight.toFloat() + ) + canvas.drawRect(fontRectF, paint) + } + + if (this.isAutoLoading) { + if (secondDurProgress >= secondMaxProgress) { + a = -1 + } else if (secondDurProgress <= 0) { + a = 1 + } + secondDurProgress += a * speed + if (secondDurProgress < 0) + secondDurProgress = 0 + else if (secondDurProgress > secondMaxProgress) + secondDurProgress = secondMaxProgress + secondFinalProgress = secondDurProgress + invalidate() + } else { + if (secondDurProgress != secondFinalProgress) { + if (secondDurProgress > secondFinalProgress) { + secondDurProgress -= speed + if (secondDurProgress < secondFinalProgress) { + secondDurProgress = secondFinalProgress + } + } else { + secondDurProgress += speed + if (secondDurProgress > secondFinalProgress) { + secondDurProgress = secondFinalProgress + } + } + this.invalidate() + } + } + } + + fun getDurProgress(): Int { + return durProgress + } + + fun setDurProgress(durProgress: Int) { + var durProgress1 = durProgress + if (durProgress1 < 0) { + durProgress1 = 0 + } + if (durProgress1 > maxProgress) { + durProgress1 = maxProgress + } + this.durProgress = durProgress1 + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } + + fun getSecondDurProgress(): Int { + return secondDurProgress + } + + fun setSecondDurProgress(secondDur: Int) { + this.secondDurProgress = secondDur + this.secondFinalProgress = secondDurProgress + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } + + fun setSecondDurProgressWithAnim(secondDur: Int) { + var secondDur1 = secondDur + if (secondDur1 < 0) { + secondDur1 = 0 + } + if (secondDur1 > secondMaxProgress) { + secondDur1 = secondMaxProgress + } + this.secondFinalProgress = secondDur1 + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } + + fun clean() { + durProgress = 0 + secondDurProgress = 0 + secondFinalProgress = 0 + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt b/app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt new file mode 100644 index 000000000..2ff870766 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt @@ -0,0 +1,224 @@ +package io.legado.app.ui.widget.anima + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.dp + +/** + * RotateLoading + * Created by Victor on 2015/4/28. + */ +@Suppress("MemberVisibilityCanBePrivate") +class RotateLoading @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + private var mPaint: Paint + + private var loadingRectF: RectF? = null + private var shadowRectF: RectF? = null + + private var topDegree = 10 + private var bottomDegree = 190 + + private var arc: Float = 0.toFloat() + + private var thisWidth: Int = 0 + + private var changeBigger = true + + private var shadowPosition: Int = 0 + + var hideMode = GONE + + var isStarted = false + private set + + var loadingColor: Int = 0 + set(value) { + field = value + invalidate() + } + + private var speedOfDegree: Int = 0 + + private var speedOfArc: Float = 0.toFloat() + + private val shown = Runnable { this.startInternal() } + + private val hidden = Runnable { this.stopInternal() } + + init { + loadingColor = context.accentColor + thisWidth = DEFAULT_WIDTH.dp + shadowPosition = DEFAULT_SHADOW_POSITION.dp + speedOfDegree = DEFAULT_SPEED_OF_DEGREE + + if (null != attrs) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.RotateLoading) + loadingColor = + typedArray.getColor(R.styleable.RotateLoading_loading_color, loadingColor) + thisWidth = typedArray.getDimensionPixelSize( + R.styleable.RotateLoading_loading_width, + DEFAULT_WIDTH.dp + ) + shadowPosition = typedArray.getInt( + R.styleable.RotateLoading_shadow_position, + DEFAULT_SHADOW_POSITION + ) + speedOfDegree = + typedArray.getInt(R.styleable.RotateLoading_loading_speed, DEFAULT_SPEED_OF_DEGREE) + hideMode = when (typedArray.getInt(R.styleable.RotateLoading_hide_mode, 2)) { + 1 -> INVISIBLE + else -> GONE + } + typedArray.recycle() + } + speedOfArc = (speedOfDegree / 4).toFloat() + mPaint = Paint() + mPaint.color = loadingColor + mPaint.isAntiAlias = true + mPaint.style = Paint.Style.STROKE + mPaint.strokeWidth = thisWidth.toFloat() + mPaint.strokeCap = Paint.Cap.ROUND + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + arc = 10f + + loadingRectF = + RectF( + (2 * thisWidth).toFloat(), + (2 * thisWidth).toFloat(), + (w - 2 * thisWidth).toFloat(), + (h - 2 * thisWidth).toFloat() + ) + shadowRectF = RectF( + (2 * thisWidth + shadowPosition).toFloat(), + (2 * thisWidth + shadowPosition).toFloat(), + (w - 2 * thisWidth + shadowPosition).toFloat(), + (h - 2 * thisWidth + shadowPosition).toFloat() + ) + } + + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (!isStarted) { + return + } + + mPaint.color = Color.parseColor("#1a000000") + shadowRectF?.let { + canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint) + canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint) + } + + mPaint.color = loadingColor + loadingRectF?.let { + canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint) + canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint) + } + + topDegree += speedOfDegree + bottomDegree += speedOfDegree + if (topDegree > 360) { + topDegree -= 360 + } + if (bottomDegree > 360) { + bottomDegree -= 360 + } + + if (changeBigger) { + if (arc < 160) { + arc += speedOfArc + invalidate() + } + } else { + if (arc > speedOfDegree) { + arc -= 2 * speedOfArc + invalidate() + } + } + if (arc >= 160 || arc <= 10) { + changeBigger = !changeBigger + invalidate() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (visibility == VISIBLE) { + startInternal() + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + isStarted = false + animate().cancel() + removeCallbacks(shown) + removeCallbacks(hidden) + } + + fun show() { + removeCallbacks(shown) + removeCallbacks(hidden) + post(shown) + } + + fun hide() { + removeCallbacks(shown) + removeCallbacks(hidden) + stopInternal() + } + + private fun startInternal() { + startAnimator() + isStarted = true + invalidate() + } + + private fun stopInternal() { + stopAnimator() + invalidate() + } + + private fun startAnimator() { + animate().cancel() + animate().scaleX(1.0f) + .scaleY(1.0f) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + visibility = VISIBLE + } + }) + .start() + } + + private fun stopAnimator() { + animate().cancel() + isStarted = false + this.visibility = hideMode + } + + companion object { + private const val DEFAULT_WIDTH = 6 + private const val DEFAULT_SHADOW_POSITION = 2 + private const val DEFAULT_SPEED_OF_DEGREE = 10 + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt new file mode 100644 index 000000000..582f7239c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2015 tyrantgit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.anima.explosion_field + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.graphics.* +import android.view.View +import android.view.animation.AccelerateInterpolator +import java.util.* +import kotlin.math.pow + +@SuppressLint("Recycle") +class ExplosionAnimator(private val mContainer: View, bitmap: Bitmap, bound: Rect) : + ValueAnimator() { + + private val mPaint: Paint = Paint() + private val mParticles: Array + private val mBound: Rect = Rect(bound) + + init { + val partLen = 15 + mParticles = arrayOfNulls(partLen * partLen) + val random = Random(System.currentTimeMillis()) + val w = bitmap.width / (partLen + 2) + val h = bitmap.height / (partLen + 2) + for (i in 0 until partLen) { + for (j in 0 until partLen) { + mParticles[i * partLen + j] = + generateParticle(bitmap.getPixel((j + 1) * w, (i + 1) * h), random) + } + } + setFloatValues(0f, END_VALUE) + interpolator = DEFAULT_INTERPOLATOR + duration = DEFAULT_DURATION + } + + private fun generateParticle(color: Int, random: Random): Particle { + val particle = Particle() + particle.color = color + particle.radius = V + if (random.nextFloat() < 0.2f) { + particle.baseRadius = V + (X - V) * random.nextFloat() + } else { + particle.baseRadius = W + (V - W) * random.nextFloat() + } + val nextFloat = random.nextFloat() + particle.top = mBound.height() * (0.18f * random.nextFloat() + 0.2f) + particle.top = + if (nextFloat < 0.2f) particle.top else particle.top + particle.top * 0.2f * random.nextFloat() + particle.bottom = mBound.height() * (random.nextFloat() - 0.5f) * 1.8f + var f = + if (nextFloat < 0.2f) particle.bottom else if (nextFloat < 0.8f) particle.bottom * 0.6f else particle.bottom * 0.3f + particle.bottom = f + particle.mag = 4.0f * particle.top / particle.bottom + particle.neg = -particle.mag / particle.bottom + f = mBound.centerX() + Y * (random.nextFloat() - 0.5f) + particle.baseCx = f + particle.cx = f + f = mBound.centerY() + Y * (random.nextFloat() - 0.5f) + particle.baseCy = f + particle.cy = f + particle.life = END_VALUE / 10 * random.nextFloat() + particle.overflow = 0.4f * random.nextFloat() + particle.alpha = 1f + return particle + } + + fun draw(canvas: Canvas): Boolean { + if (!isStarted) { + return false + } + for (particle in mParticles) { + particle?.let { + particle.advance(animatedValue as Float) + if (particle.alpha > 0f) { + mPaint.color = particle.color + mPaint.alpha = (Color.alpha(particle.color) * particle.alpha).toInt() + canvas.drawCircle(particle.cx, particle.cy, particle.radius, mPaint) + } + } + } + mContainer.invalidate() + return true + } + + override fun start() { + super.start() + mContainer.invalidate() + } + + private inner class Particle { + var alpha: Float = 0.toFloat() + var color: Int = 0 + var cx: Float = 0.toFloat() + var cy: Float = 0.toFloat() + var radius: Float = 0.toFloat() + var baseCx: Float = 0.toFloat() + var baseCy: Float = 0.toFloat() + var baseRadius: Float = 0.toFloat() + var top: Float = 0.toFloat() + var bottom: Float = 0.toFloat() + var mag: Float = 0.toFloat() + var neg: Float = 0.toFloat() + var life: Float = 0.toFloat() + var overflow: Float = 0.toFloat() + + + fun advance(factor: Float) { + var f = 0f + var normalization = factor / END_VALUE + if (normalization < life || normalization > 1f - overflow) { + alpha = 0f + return + } + normalization = (normalization - life) / (1f - life - overflow) + val f2 = normalization * END_VALUE + if (normalization >= 0.7f) { + f = (normalization - 0.7f) / 0.3f + } + alpha = 1f - f + f = bottom * f2 + cx = baseCx + f + cy = (baseCy - this.neg * f.toDouble().pow(2.0)).toFloat() - f * mag + radius = V + (baseRadius - V) * f2 + } + } + + companion object { + + internal var DEFAULT_DURATION: Long = 0x400 + private val DEFAULT_INTERPOLATOR = AccelerateInterpolator(0.6f) + private const val END_VALUE = 1.4f + private val X = Utils.dp2Px(5).toFloat() + private val Y = Utils.dp2Px(20).toFloat() + private val V = Utils.dp2Px(2).toFloat() + private val W = Utils.dp2Px(1).toFloat() + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt new file mode 100644 index 000000000..9b014b7d2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt @@ -0,0 +1,21 @@ +package io.legado.app.ui.widget.anima.explosion_field + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import android.view.Window + +object ExplosionField { + + fun attach2Window(activity: Activity): ExplosionView { + val rootView = activity.findViewById(Window.ID_ANDROID_CONTENT) as ViewGroup + val explosionField = ExplosionView(activity) + rootView.addView( + explosionField, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + return explosionField + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionView.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionView.kt new file mode 100644 index 000000000..606e81b6c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionView.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2015 tyrantgit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.anima.explosion_field + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.media.MediaPlayer +import android.util.AttributeSet +import android.view.View +import timber.log.Timber +import java.util.* + + +@Suppress("unused") +class ExplosionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + View(context, attrs) { + + private var customDuration = ExplosionAnimator.DEFAULT_DURATION + private var idPlayAnimationEffect = 0 + private var mZAnimatorListener: OnAnimatorListener? = null + private var mOnClickListener: OnClickListener? = null + + private val mExplosions = ArrayList() + private val mExpandInset = IntArray(2) + + init { + Arrays.fill(mExpandInset, Utils.dp2Px(32)) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + for (explosion in mExplosions) { + explosion.draw(canvas) + } + } + + fun playSoundAnimationEffect(id: Int) { + this.idPlayAnimationEffect = id + } + + fun setCustomDuration(customDuration: Long) { + this.customDuration = customDuration + } + + fun addActionEvent(iEvents: OnAnimatorListener) { + this.mZAnimatorListener = iEvents + } + + + fun expandExplosionBound(dx: Int, dy: Int) { + mExpandInset[0] = dx + mExpandInset[1] = dy + } + + @JvmOverloads + fun explode(bitmap: Bitmap?, bound: Rect, startDelay: Long, view: View? = null) { + val currentDuration = customDuration + val explosion = ExplosionAnimator(this, bitmap!!, bound) + explosion.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + mExplosions.remove(animation) + view?.let { + view.scaleX = 1f + view.scaleY = 1f + view.alpha = 1f + view.setOnClickListener(mOnClickListener)//set event + } + } + }) + explosion.startDelay = startDelay + explosion.duration = currentDuration + mExplosions.add(explosion) + explosion.start() + } + + @JvmOverloads + fun explode(view: View, restartState: Boolean? = false) { + + val r = Rect() + view.getGlobalVisibleRect(r) + val location = IntArray(2) + getLocationOnScreen(location) + r.offset(-location[0], -location[1]) + r.inset(-mExpandInset[0], -mExpandInset[1]) + val startDelay = 100 + val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(150) + animator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { + + var random = Random() + + override fun onAnimationUpdate(animation: ValueAnimator) { + view.translationX = (random.nextFloat() - 0.5f) * view.width.toFloat() * 0.05f + view.translationY = (random.nextFloat() - 0.5f) * view.height.toFloat() * 0.05f + } + }) + + animator.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animator: Animator) { + if (idPlayAnimationEffect != 0) + MediaPlayer.create(context, idPlayAnimationEffect).start() + } + + override fun onAnimationEnd(animator: Animator) { + if (mZAnimatorListener != null) { + mZAnimatorListener!!.onAnimationEnd(animator, this@ExplosionView) + } + } + + override fun onAnimationCancel(animator: Animator) { + Timber.i("CANCEL") + } + + override fun onAnimationRepeat(animator: Animator) { + Timber.i("REPEAT") + } + }) + + animator.start() + view.animate().setDuration(150).setStartDelay(startDelay.toLong()).scaleX(0f).scaleY(0f) + .alpha(0f).start() + if (restartState!!) + explode(Utils.createBitmapFromView(view), r, startDelay.toLong(), view) + else + explode(Utils.createBitmapFromView(view), r, startDelay.toLong()) + + } + + fun clear() { + mExplosions.clear() + invalidate() + } + + override fun setOnClickListener(mOnClickListener: OnClickListener?) { + this.mOnClickListener = mOnClickListener + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt new file mode 100644 index 000000000..13a04c670 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt @@ -0,0 +1,8 @@ +package io.legado.app.ui.widget.anima.explosion_field + +import android.animation.Animator +import android.view.View + +interface OnAnimatorListener { + fun onAnimationEnd(animator: Animator, view: View) +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt new file mode 100644 index 000000000..d04de80f0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2015 tyrantgit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.anima.explosion_field + + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.view.View +import android.widget.ImageView +import timber.log.Timber + +import kotlin.math.roundToInt + +object Utils { + + private val DENSITY = Resources.getSystem().displayMetrics.density + private val sCanvas = Canvas() + + fun dp2Px(dp: Int): Int { + return (dp * DENSITY).roundToInt() + } + + fun createBitmapFromView(view: View): Bitmap? { + if (view is ImageView) { + val drawable = view.drawable + if (drawable != null && drawable is BitmapDrawable) { + return drawable.bitmap + } + } + view.clearFocus() + val bitmap = createBitmapSafely( + view.width, + view.height, Bitmap.Config.ARGB_8888, 1 + ) + if (bitmap != null) { + synchronized(sCanvas) { + val canvas = sCanvas + canvas.setBitmap(bitmap) + view.draw(canvas) + canvas.setBitmap(null) + } + } + return bitmap + } + + private fun createBitmapSafely( + width: Int, + height: Int, + config: Bitmap.Config, + retryCount: Int + ): Bitmap? { + try { + return Bitmap.createBitmap(width, height, config) + } catch (e: OutOfMemoryError) { + Timber.e(e) + if (retryCount > 0) { + System.gc() + return createBitmapSafely(width, height, config, retryCount - 1) + } + return null + } + + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/checkbox/SmoothCheckBox.kt b/app/src/main/java/io/legado/app/ui/widget/checkbox/SmoothCheckBox.kt new file mode 100644 index 000000000..b18e59332 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/checkbox/SmoothCheckBox.kt @@ -0,0 +1,326 @@ +package io.legado.app.ui.widget.checkbox + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.View +import android.view.animation.LinearInterpolator +import android.widget.Checkable +import io.legado.app.R +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sqrt + +class SmoothCheckBox @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs), Checkable { + private var mPaint: Paint + private var mTickPaint: Paint + private var mFloorPaint: Paint + private var mTickPoints: Array + private var mCenterPoint: Point + private var mTickPath: Path + private var mLeftLineDistance = 0f + private var mRightLineDistance = 0f + private var mDrewDistance = 0f + private var mScaleVal = 1.0f + private var mFloorScale = 1.0f + private var mWidth = 0 + private var mAnimDuration = 0 + private var mStrokeWidth = 0 + private var mCheckedColor = 0 + private var mUnCheckedColor = 0 + private var mFloorColor = 0 + private var mFloorUnCheckedColor = 0 + private var mChecked = false + private var mTickDrawing = false + var onCheckedChangeListener: ((checkBox: SmoothCheckBox, isChecked: Boolean) -> Unit)? = null + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.SmoothCheckBox) + var tickColor = ThemeStore.accentColor(context) + mCheckedColor = context.getCompatColor(R.color.background_menu) + mUnCheckedColor = context.getCompatColor(R.color.background_menu) + mFloorColor = context.getCompatColor(R.color.transparent30) + tickColor = ta.getColor(R.styleable.SmoothCheckBox_color_tick, tickColor) + mAnimDuration = ta.getInt(R.styleable.SmoothCheckBox_duration, DEF_ANIM_DURATION) + mFloorColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked_stroke, mFloorColor) + mCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_checked, mCheckedColor) + mUnCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked, mUnCheckedColor) + mStrokeWidth = ta.getDimensionPixelSize(R.styleable.SmoothCheckBox_stroke_width, 0) + ta.recycle() + mFloorUnCheckedColor = mFloorColor + mTickPaint = Paint(Paint.ANTI_ALIAS_FLAG) + mTickPaint.style = Paint.Style.STROKE + mTickPaint.strokeCap = Paint.Cap.ROUND + mTickPaint.color = tickColor + mFloorPaint = Paint(Paint.ANTI_ALIAS_FLAG) + mFloorPaint.style = Paint.Style.FILL + mFloorPaint.color = mFloorColor + mPaint = Paint(Paint.ANTI_ALIAS_FLAG) + mPaint.style = Paint.Style.FILL + mPaint.color = mCheckedColor + mTickPath = Path() + mCenterPoint = Point() + mTickPoints = arrayOf(Point(), Point(), Point()) + setOnClickListener { + toggle() + mTickDrawing = false + mDrewDistance = 0f + if (isChecked) { + startCheckedAnimation() + } else { + startUnCheckedAnimation() + } + } + } + + override fun isChecked(): Boolean { + return mChecked + } + + override fun setChecked(checked: Boolean) { + mChecked = checked + reset() + invalidate() + onCheckedChangeListener?.invoke(this@SmoothCheckBox, mChecked) + } + + override fun toggle() { + this.isChecked = !isChecked + } + + /** + * checked with animation + * + * @param checked checked + * @param animate change with animation + */ + fun setChecked(checked: Boolean, animate: Boolean) { + if (animate) { + mTickDrawing = false + mChecked = checked + mDrewDistance = 0f + if (checked) { + startCheckedAnimation() + } else { + startUnCheckedAnimation() + } + onCheckedChangeListener?.invoke(this@SmoothCheckBox, mChecked) + } else { + this.isChecked = checked + } + } + + private fun reset() { + mTickDrawing = true + mFloorScale = 1.0f + mScaleVal = if (isChecked) 0f else 1.0f + mFloorColor = if (isChecked) mCheckedColor else mFloorUnCheckedColor + mDrewDistance = if (isChecked) mLeftLineDistance + mRightLineDistance else 0f + } + + private fun measureSize(measureSpec: Int): Int { + val defSize: Int = DEF_DRAW_SIZE.dp + val specSize = MeasureSpec.getSize(measureSpec) + val specMode = MeasureSpec.getMode(measureSpec) + var result = 0 + when (specMode) { + MeasureSpec.UNSPECIFIED, MeasureSpec.AT_MOST -> result = min(defSize, specSize) + MeasureSpec.EXACTLY -> result = specSize + } + return result + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(measureSize(widthMeasureSpec), measureSize(heightMeasureSpec)) + } + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int + ) { + mWidth = measuredWidth + mStrokeWidth = if (mStrokeWidth == 0) measuredWidth / 10 else mStrokeWidth + mStrokeWidth = + if (mStrokeWidth > measuredWidth / 5) measuredWidth / 5 else mStrokeWidth + mStrokeWidth = if (mStrokeWidth < 3) 3 else mStrokeWidth + mCenterPoint.x = mWidth / 2 + mCenterPoint.y = measuredHeight / 2 + mTickPoints[0].x = (measuredWidth.toFloat() / 30 * 7).roundToInt() + mTickPoints[0].y = (measuredHeight.toFloat() / 30 * 14).roundToInt() + mTickPoints[1].x = (measuredWidth.toFloat() / 30 * 13).roundToInt() + mTickPoints[1].y = (measuredHeight.toFloat() / 30 * 20).roundToInt() + mTickPoints[2].x = (measuredWidth.toFloat() / 30 * 22).roundToInt() + mTickPoints[2].y = (measuredHeight.toFloat() / 30 * 10).roundToInt() + mLeftLineDistance = sqrt( + (mTickPoints[1].x - mTickPoints[0].x.toDouble()).pow(2.0) + + (mTickPoints[1].y - mTickPoints[0].y.toDouble()).pow(2.0) + ).toFloat() + mRightLineDistance = sqrt( + (mTickPoints[2].x - mTickPoints[1].x.toDouble()).pow(2.0) + + (mTickPoints[2].y - mTickPoints[1].y.toDouble()).pow(2.0) + ).toFloat() + mTickPaint.strokeWidth = mStrokeWidth.toFloat() + } + + override fun onDraw(canvas: Canvas) { + drawBorder(canvas) + drawCenter(canvas) + drawTick(canvas) + } + + private fun drawCenter(canvas: Canvas) { + mPaint.color = mUnCheckedColor + val radius = (mCenterPoint.x - mStrokeWidth) * mScaleVal + canvas.drawCircle(mCenterPoint.x.toFloat(), mCenterPoint.y.toFloat(), radius, mPaint) + } + + private fun drawBorder(canvas: Canvas) { + mFloorPaint.color = mFloorColor + val radius = mCenterPoint.x + canvas.drawCircle( + mCenterPoint.x.toFloat(), + mCenterPoint.y.toFloat(), + radius * mFloorScale, + mFloorPaint + ) + } + + private fun drawTick(canvas: Canvas) { + if (mTickDrawing && isChecked) { + drawTickPath(canvas) + } + } + + private fun drawTickPath(canvas: Canvas) { + mTickPath.reset() + // draw left of the tick + if (mDrewDistance < mLeftLineDistance) { + val step: Float = if (mWidth / 20.0f < 3) 3f else mWidth / 20.0f + mDrewDistance += step + val stopX = + mTickPoints[0].x + (mTickPoints[1].x - mTickPoints[0].x) * mDrewDistance / mLeftLineDistance + val stopY = + mTickPoints[0].y + (mTickPoints[1].y - mTickPoints[0].y) * mDrewDistance / mLeftLineDistance + mTickPath.moveTo(mTickPoints[0].x.toFloat(), mTickPoints[0].y.toFloat()) + mTickPath.lineTo(stopX, stopY) + canvas.drawPath(mTickPath, mTickPaint) + if (mDrewDistance > mLeftLineDistance) { + mDrewDistance = mLeftLineDistance + } + } else { + mTickPath.moveTo(mTickPoints[0].x.toFloat(), mTickPoints[0].y.toFloat()) + mTickPath.lineTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat()) + canvas.drawPath(mTickPath, mTickPaint) + // draw right of the tick + if (mDrewDistance < mLeftLineDistance + mRightLineDistance) { + val stopX = + mTickPoints[1].x + (mTickPoints[2].x - mTickPoints[1].x) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance + val stopY = + mTickPoints[1].y - (mTickPoints[1].y - mTickPoints[2].y) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance + mTickPath.reset() + mTickPath.moveTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat()) + mTickPath.lineTo(stopX, stopY) + canvas.drawPath(mTickPath, mTickPaint) + val step: Float = if (mWidth / 20f < 3) 3f else mWidth / 20f + mDrewDistance += step + } else { + mTickPath.reset() + mTickPath.moveTo(mTickPoints[1].x.toFloat(), mTickPoints[1].y.toFloat()) + mTickPath.lineTo(mTickPoints[2].x.toFloat(), mTickPoints[2].y.toFloat()) + canvas.drawPath(mTickPath, mTickPaint) + } + } + // invalidate + if (mDrewDistance < mLeftLineDistance + mRightLineDistance) { + postDelayed({ this.postInvalidate() }, 10) + } + } + + private fun startCheckedAnimation() { + val animator = ValueAnimator.ofFloat(1.0f, 0f) + animator.duration = mAnimDuration / 3 * 2.toLong() + animator.interpolator = LinearInterpolator() + animator.addUpdateListener { animation: ValueAnimator -> + mScaleVal = animation.animatedValue as Float + mFloorColor = getGradientColor( + mUnCheckedColor, + mCheckedColor, + 1 - mScaleVal + ) + postInvalidate() + } + animator.start() + val floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f) + floorAnimator.duration = mAnimDuration.toLong() + floorAnimator.interpolator = LinearInterpolator() + floorAnimator.addUpdateListener { animation: ValueAnimator -> + mFloorScale = animation.animatedValue as Float + postInvalidate() + } + floorAnimator.start() + drawTickDelayed() + } + + private fun startUnCheckedAnimation() { + val animator = ValueAnimator.ofFloat(0f, 1.0f) + animator.duration = mAnimDuration.toLong() + animator.interpolator = LinearInterpolator() + animator.addUpdateListener { animation: ValueAnimator -> + mScaleVal = animation.animatedValue as Float + mFloorColor = getGradientColor( + mCheckedColor, + mFloorUnCheckedColor, + mScaleVal + ) + postInvalidate() + } + animator.start() + val floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f) + floorAnimator.duration = mAnimDuration.toLong() + floorAnimator.interpolator = LinearInterpolator() + floorAnimator.addUpdateListener { animation: ValueAnimator -> + mFloorScale = animation.animatedValue as Float + postInvalidate() + } + floorAnimator.start() + } + + private fun drawTickDelayed() { + postDelayed({ + mTickDrawing = true + postInvalidate() + }, mAnimDuration.toLong()) + } + + companion object { + private const val DEF_DRAW_SIZE = 25 + private const val DEF_ANIM_DURATION = 300 + private fun getGradientColor(startColor: Int, endColor: Int, percent: Float): Int { + val startA = Color.alpha(startColor) + val startR = Color.red(startColor) + val startG = Color.green(startColor) + val startB = Color.blue(startColor) + val endA = Color.alpha(endColor) + val endR = Color.red(endColor) + val endG = Color.green(endColor) + val endB = Color.blue(endColor) + val currentA = (startA * (1 - percent) + endA * percent).toInt() + val currentR = (startR * (1 - percent) + endR * percent).toInt() + val currentG = (startG * (1 - percent) + endG * percent).toInt() + val currentB = (startB * (1 - percent) + endB * percent).toInt() + return Color.argb(currentA, currentR, currentG, currentB) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/code/CodeView.kt b/app/src/main/java/io/legado/app/ui/widget/code/CodeView.kt new file mode 100644 index 000000000..82bd4a53d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/code/CodeView.kt @@ -0,0 +1,422 @@ +package io.legado.app.ui.widget.code + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.graphics.Rect +import android.os.Handler +import android.os.Looper +import android.text.* +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ReplacementSpan +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView +import java.util.* +import java.util.regex.Matcher +import java.util.regex.Pattern +import kotlin.math.roundToInt + +@Suppress("unused") +class CodeView : AppCompatMultiAutoCompleteTextView { + private var tabWidth = 0 + private var tabWidthInCharacters = 0 + private var mUpdateDelayTime = 500 + private var modified = true + private var highlightWhileTextChanging = true + private var hasErrors = false + private var mRemoveErrorsWhenTextChanged = true + private val mUpdateHandler = Handler(Looper.getMainLooper()) + private var mAutoCompleteTokenizer: Tokenizer? = null + private val displayDensity = resources.displayMetrics.density + private val mErrorHashSet: SortedMap = TreeMap() + private val mSyntaxPatternMap: MutableMap = HashMap() + private var mIndentCharacterList = mutableListOf('{', '+', '-', '*', '/', '=') + + constructor(context: Context?) : super(context!!) { + initEditorView() + } + + constructor(context: Context?, attrs: AttributeSet?) : super( + context!!, attrs + ) { + initEditorView() + } + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context!!, attrs, defStyleAttr + ) { + initEditorView() + } + + private fun initEditorView() { + if (mAutoCompleteTokenizer == null) { + mAutoCompleteTokenizer = KeywordTokenizer() + } + setTokenizer(mAutoCompleteTokenizer) + filters = arrayOf( + InputFilter { source, start, end, dest, dStart, dEnd -> + if (modified && end - start == 1 && start < source.length && dStart < dest.length) { + val c = source[start] + if (c == '\n') { + return@InputFilter autoIndent(source, dest, dStart, dEnd) + } + } + source + } + ) + addTextChangedListener(mEditorTextWatcher) + } + + private fun autoIndent( + source: CharSequence, + dest: Spanned, + dStart: Int, + dEnd: Int + ): CharSequence { + var indent = "" + var iStart = dStart - 1 + var dataBefore = false + var pt = 0 + while (iStart > -1) { + val c = dest[iStart] + if (c == '\n') break + if (c != ' ' && c != '\t') { + if (!dataBefore) { + if (mIndentCharacterList.contains(c)) --pt + dataBefore = true + } + if (c == '(') { + --pt + } else if (c == ')') { + ++pt + } + } + --iStart + } + if (iStart > -1) { + val charAtCursor = dest[dStart] + var iEnd: Int = ++iStart + while (iEnd < dEnd) { + val c = dest[iEnd] + if (charAtCursor != '\n' && c == '/' && iEnd + 1 < dEnd && dest[iEnd] == c) { + iEnd += 2 + break + } + if (c != ' ' && c != '\t') { + break + } + ++iEnd + } + indent += dest.subSequence(iStart, iEnd) + } + if (pt < 0) { + indent += "\t" + } + return source.toString() + indent + } + + private fun highlightSyntax(editable: Editable) { + if (mSyntaxPatternMap.isEmpty()) return + for (pattern in mSyntaxPatternMap.keys) { + val color = mSyntaxPatternMap[pattern]!! + val m = pattern.matcher(editable) + while (m.find()) { + createForegroundColorSpan(editable, m, color) + } + } + } + + private fun highlightErrorLines(editable: Editable) { + if (mErrorHashSet.isEmpty()) return + val maxErrorLineValue = mErrorHashSet.lastKey() + var lineNumber = 0 + val matcher = PATTERN_LINE.matcher(editable) + while (matcher.find()) { + if (mErrorHashSet.containsKey(lineNumber)) { + val color = mErrorHashSet[lineNumber]!! + createBackgroundColorSpan(editable, matcher, color) + } + lineNumber += 1 + if (lineNumber > maxErrorLineValue) break + } + } + + private fun createForegroundColorSpan( + editable: Editable, + matcher: Matcher, + @ColorInt color: Int + ) { + editable.setSpan( + ForegroundColorSpan(color), + matcher.start(), matcher.end(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + private fun createBackgroundColorSpan( + editable: Editable, + matcher: Matcher, + @ColorInt color: Int + ) { + editable.setSpan( + BackgroundColorSpan(color), + matcher.start(), matcher.end(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + private fun highlight(editable: Editable): Editable { + if (editable.isEmpty()) return editable + try { + clearSpans(editable) + highlightErrorLines(editable) + highlightSyntax(editable) + } catch (e: IllegalStateException) { + e.printStackTrace() + } + return editable + } + + private fun highlightWithoutChange(editable: Editable) { + modified = false + highlight(editable) + modified = true + } + + fun setTextHighlighted(text: CharSequence?) { + if (text.isNullOrEmpty()) return + cancelHighlighterRender() + removeAllErrorLines() + modified = false + setText(highlight(SpannableStringBuilder(text))) + modified = true + } + + fun setTabWidth(characters: Int) { + if (tabWidthInCharacters == characters) return + tabWidthInCharacters = characters + tabWidth = (paint.measureText("m") * characters).roundToInt() + } + + private fun clearSpans(editable: Editable) { + val length = editable.length + val foregroundSpans = editable.getSpans( + 0, length, ForegroundColorSpan::class.java + ) + run { + var i = foregroundSpans.size + while (i-- > 0) { + editable.removeSpan(foregroundSpans[i]) + } + } + val backgroundSpans = editable.getSpans( + 0, length, BackgroundColorSpan::class.java + ) + var i = backgroundSpans.size + while (i-- > 0) { + editable.removeSpan(backgroundSpans[i]) + } + } + + fun cancelHighlighterRender() { + mUpdateHandler.removeCallbacks(mUpdateRunnable) + } + + private fun convertTabs(editable: Editable, start: Int, count: Int) { + var startIndex = start + if (tabWidth < 1) return + val s = editable.toString() + val stop = startIndex + count + while (s.indexOf("\t", startIndex).also { startIndex = it } > -1 && startIndex < stop) { + editable.setSpan( + TabWidthSpan(), + startIndex, + startIndex + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + ++startIndex + } + } + + fun setSyntaxPatternsMap(syntaxPatterns: Map?) { + if (mSyntaxPatternMap.isNotEmpty()) mSyntaxPatternMap.clear() + mSyntaxPatternMap.putAll(syntaxPatterns!!) + } + + fun addSyntaxPattern(pattern: Pattern, @ColorInt Color: Int) { + mSyntaxPatternMap[pattern] = Color + } + + fun removeSyntaxPattern(pattern: Pattern) { + mSyntaxPatternMap.remove(pattern) + } + + fun getSyntaxPatternsSize(): Int { + return mSyntaxPatternMap.size + } + + fun resetSyntaxPatternList() { + mSyntaxPatternMap.clear() + } + + fun setAutoIndentCharacterList(characterList: MutableList) { + mIndentCharacterList = characterList + } + + fun clearAutoIndentCharacterList() { + mIndentCharacterList.clear() + } + + fun getAutoIndentCharacterList(): List { + return mIndentCharacterList + } + + fun addErrorLine(lineNum: Int, color: Int) { + mErrorHashSet[lineNum] = color + hasErrors = true + } + + fun removeErrorLine(lineNum: Int) { + mErrorHashSet.remove(lineNum) + hasErrors = mErrorHashSet.size > 0 + } + + fun removeAllErrorLines() { + mErrorHashSet.clear() + hasErrors = false + } + + fun getErrorsSize(): Int { + return mErrorHashSet.size + } + + fun getTextWithoutTrailingSpace(): String { + return PATTERN_TRAILING_WHITE_SPACE + .matcher(text) + .replaceAll("") + } + + fun setAutoCompleteTokenizer(tokenizer: Tokenizer?) { + mAutoCompleteTokenizer = tokenizer + } + + fun setRemoveErrorsWhenTextChanged(removeErrors: Boolean) { + mRemoveErrorsWhenTextChanged = removeErrors + } + + fun reHighlightSyntax() { + highlightSyntax(editableText) + } + + fun reHighlightErrors() { + highlightErrorLines(editableText) + } + + fun isHasError(): Boolean { + return hasErrors + } + + fun setUpdateDelayTime(time: Int) { + mUpdateDelayTime = time + } + + fun getUpdateDelayTime(): Int { + return mUpdateDelayTime + } + + fun setHighlightWhileTextChanging(updateWhileTextChanging: Boolean) { + highlightWhileTextChanging = updateWhileTextChanging + } + + override fun showDropDown() { + val screenPoint = IntArray(2) + getLocationOnScreen(screenPoint) + val displayFrame = Rect() + getWindowVisibleDisplayFrame(displayFrame) + val position = selectionStart + val layout = layout + val line = layout.getLineForOffset(position) + val verticalDistanceInDp = (750 + 140 * line) / displayDensity + dropDownVerticalOffset = verticalDistanceInDp.toInt() + val horizontalDistanceInDp = layout.getPrimaryHorizontal(position) / displayDensity + dropDownHorizontalOffset = horizontalDistanceInDp.toInt() + super.showDropDown() + } + + private val mUpdateRunnable = Runnable { + val source = text + highlightWithoutChange(source) + } + private val mEditorTextWatcher: TextWatcher = object : TextWatcher { + private var start = 0 + private var count = 0 + override fun beforeTextChanged( + charSequence: CharSequence, + start: Int, + before: Int, + count: Int + ) { + this.start = start + this.count = count + } + + override fun onTextChanged( + charSequence: CharSequence, + start: Int, + before: Int, + count: Int + ) { + if (!modified) return + if (highlightWhileTextChanging) { + if (mSyntaxPatternMap.isNotEmpty()) { + convertTabs(editableText, start, count) + mUpdateHandler.postDelayed(mUpdateRunnable, mUpdateDelayTime.toLong()) + } + } + if (mRemoveErrorsWhenTextChanged) removeAllErrorLines() + } + + override fun afterTextChanged(editable: Editable) { + if (!highlightWhileTextChanging) { + if (!modified) return + cancelHighlighterRender() + if (mSyntaxPatternMap.isNotEmpty()) { + convertTabs(editableText, start, count) + mUpdateHandler.postDelayed(mUpdateRunnable, mUpdateDelayTime.toLong()) + } + } + } + } + + private inner class TabWidthSpan : ReplacementSpan() { + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: FontMetricsInt? + ): Int { + return tabWidth + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + } + } + + companion object { + private val PATTERN_LINE = Pattern.compile("(^.+$)+", Pattern.MULTILINE) + private val PATTERN_TRAILING_WHITE_SPACE = Pattern.compile("[\\t ]+$", Pattern.MULTILINE) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/code/CodeViewExtensions.kt b/app/src/main/java/io/legado/app/ui/widget/code/CodeViewExtensions.kt new file mode 100644 index 000000000..e8d92759e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/code/CodeViewExtensions.kt @@ -0,0 +1,35 @@ +@file:Suppress("unused") + +package io.legado.app.ui.widget.code + +import android.content.Context +import android.widget.ArrayAdapter +import io.legado.app.R +import splitties.init.appCtx +import splitties.resources.color +import java.util.regex.Pattern + +val legadoPattern: Pattern = Pattern.compile("\\|\\||&&|%%|@js:|@Json:|@css:|@@|@XPath:") +val jsonPattern: Pattern = Pattern.compile("\"[A-Za-z0-9]*?\"\\:|\"|\\{|\\}|\\[|\\]") +val wrapPattern: Pattern = Pattern.compile("\\\\n") +val operationPattern: Pattern = + Pattern.compile(":|==|>|<|!=|>=|<=|->|=|>|<|%|-|-=|%=|\\+|\\-|\\-=|\\+=|\\^|\\&|\\|::|\\?|\\*") +val jsPattern: Pattern = Pattern.compile("var") + +fun CodeView.addLegadoPattern() { + addSyntaxPattern(legadoPattern, appCtx.color(R.color.md_orange_900)) +} + +fun CodeView.addJsonPattern() { + addSyntaxPattern(jsonPattern, appCtx.color(R.color.md_blue_800)) +} + +fun CodeView.addJsPattern() { + addSyntaxPattern(wrapPattern, appCtx.color(R.color.md_blue_grey_500)) + addSyntaxPattern(operationPattern, appCtx.color(R.color.md_orange_900)) + addSyntaxPattern(jsPattern, appCtx.color(R.color.md_light_blue_600)) +} + +fun Context.arrayAdapter(keywords: Array): ArrayAdapter { + return ArrayAdapter(this, R.layout.item_1line_text_and_del, R.id.text_view, keywords) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/code/KeywordTokenizer.kt b/app/src/main/java/io/legado/app/ui/widget/code/KeywordTokenizer.kt new file mode 100644 index 000000000..607fc9b9c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/code/KeywordTokenizer.kt @@ -0,0 +1,25 @@ +package io.legado.app.ui.widget.code + +import android.widget.MultiAutoCompleteTextView +import kotlin.math.max + +class KeywordTokenizer : MultiAutoCompleteTextView.Tokenizer { + override fun findTokenStart(charSequence: CharSequence, cursor: Int): Int { + var sequenceStr = charSequence.toString() + sequenceStr = sequenceStr.substring(0, cursor) + val spaceIndex = sequenceStr.lastIndexOf(" ") + val lineIndex = sequenceStr.lastIndexOf("\n") + val bracketIndex = sequenceStr.lastIndexOf("(") + val index = max(0, max(spaceIndex, max(lineIndex, bracketIndex))) + if (index == 0) return 0 + return if (index + 1 < charSequence.length) index + 1 else index + } + + override fun findTokenEnd(charSequence: CharSequence, cursor: Int): Int { + return charSequence.length + } + + override fun terminateToken(charSequence: CharSequence): CharSequence { + return charSequence + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/CodeDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/CodeDialog.kt new file mode 100644 index 000000000..3010e422b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/dialog/CodeDialog.kt @@ -0,0 +1,76 @@ +package io.legado.app.ui.widget.dialog + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogCodeViewBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.widget.code.addJsPattern +import io.legado.app.ui.widget.code.addJsonPattern +import io.legado.app.ui.widget.code.addLegadoPattern +import io.legado.app.utils.applyTint +import io.legado.app.utils.disableEdit +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class CodeDialog() : BaseDialogFragment(R.layout.dialog_code_view) { + + constructor(code: String, disableEdit: Boolean = true, requestId: String? = null) : this() { + arguments = Bundle().apply { + putBoolean("disableEdit", disableEdit) + putString("code", code) + putString("requestId", requestId) + } + } + + val binding by viewBinding(DialogCodeViewBinding::bind) + + override fun onStart() { + super.onStart() + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + if (arguments?.getBoolean("disableEdit") == true) { + binding.toolBar.title = "code view" + binding.codeView.disableEdit() + } else { + initMenu() + } + binding.codeView.addLegadoPattern() + binding.codeView.addJsonPattern() + binding.codeView.addJsPattern() + arguments?.getString("code")?.let { + binding.codeView.setText(it) + } + } + + private fun initMenu() { + binding.toolBar.inflateMenu(R.menu.code_edit) + binding.toolBar.menu.applyTint(requireContext()) + binding.toolBar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_save -> { + binding.codeView.text?.toString()?.let { code -> + val requestId = arguments?.getString("requestId") + (parentFragment as? Callback)?.onCodeSave(code, requestId) + ?: (activity as? Callback)?.onCodeSave(code, requestId) + } + dismiss() + } + } + return@setOnMenuItemClickListener true + } + } + + + interface Callback { + + fun onCodeSave(code: String, requestId: String?) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/TextDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/TextDialog.kt new file mode 100644 index 000000000..3bc4f79c4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/dialog/TextDialog.kt @@ -0,0 +1,90 @@ +package io.legado.app.ui.widget.dialog + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.databinding.DialogTextViewBinding +import io.legado.app.utils.setHtml +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding +import io.noties.markwon.Markwon +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.glide.GlideImagesPlugin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + + +class TextDialog() : BaseDialogFragment(R.layout.dialog_text_view) { + + enum class Mode { + MD, HTML, TEXT + } + + constructor( + content: String?, + mode: Mode = Mode.TEXT, + time: Long = 0, + autoClose: Boolean = false + ) : this() { + arguments = Bundle().apply { + putString("content", content) + putString("mode", mode.name) + putLong("time", time) + } + isCancelable = false + this.autoClose = autoClose + } + + private val binding by viewBinding(DialogTextViewBinding::bind) + private var time = 0L + private var autoClose: Boolean = false + + override fun onStart() { + super.onStart() + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + arguments?.let { + val content = it.getString("content") ?: "" + when (it.getString("mode")) { + Mode.MD.name -> binding.textView.post { + Markwon.builder(requireContext()) + .usePlugin(GlideImagesPlugin.create(requireContext())) + .usePlugin(HtmlPlugin.create()) + .usePlugin(TablePlugin.create(requireContext())) + .build() + .setMarkdown(binding.textView, content) + } + Mode.HTML.name -> binding.textView.setHtml(content) + else -> binding.textView.text = content + } + time = it.getLong("time", 0L) + } + if (time > 0) { + binding.badgeView.setBadgeCount((time / 1000).toInt()) + launch { + while (time > 0) { + delay(1000) + time -= 1000 + binding.badgeView.setBadgeCount((time / 1000).toInt()) + if (time <= 0) { + view.post { + dialog?.setCancelable(true) + if (autoClose) dialog?.cancel() + } + } + } + } + } else { + view.post { + dialog?.setCancelable(true) + if (autoClose) dialog?.cancel() + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/TextListDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/TextListDialog.kt new file mode 100644 index 000000000..7a7e90ce2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/dialog/TextListDialog.kt @@ -0,0 +1,80 @@ +package io.legado.app.ui.widget.dialog + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemLogBinding +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding + +class TextListDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) { + + constructor(title: String, values: ArrayList) : this() { + arguments = Bundle().apply { + putString("title", title) + putStringArrayList("values", values) + } + } + + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + private val adapter by lazy { TextAdapter(requireContext()) } + private var values: ArrayList? = null + + override fun onStart() { + super.onStart() + setLayout(0.9f, 0.9f) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { + arguments?.let { + toolBar.title = it.getString("title") + values = it.getStringArrayList("values") + } + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.adapter = adapter + adapter.setItems(values) + } + + class TextAdapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemLogBinding { + return ItemLogBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemLogBinding, + item: String, + payloads: MutableList + ) { + binding.apply { + if (textView.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + textView.isCursorVisible = false + textView.isCursorVisible = true + } + + override fun onViewDetachedFromWindow(v: View) {} + } + textView.addOnAttachStateChangeListener(listener) + textView.setTag(R.id.tag1, listener) + } + textView.text = item + } + } + + override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) { + //nothing + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/WaitDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/WaitDialog.kt new file mode 100644 index 000000000..9e2d9ed2f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/dialog/WaitDialog.kt @@ -0,0 +1,28 @@ +package io.legado.app.ui.widget.dialog + +import android.app.Dialog +import android.content.Context +import io.legado.app.databinding.DialogWaitBinding + + +@Suppress("unused") +class WaitDialog(context: Context) : Dialog(context) { + + val binding = DialogWaitBinding.inflate(layoutInflater) + + init { + setCanceledOnTouchOutside(false) + setContentView(binding.root) + } + + fun setText(text: String): WaitDialog { + binding.tvMsg.text = text + return this + } + + fun setText(res: Int): WaitDialog { + binding.tvMsg.setText(res) + return this + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt new file mode 100644 index 000000000..c2a45147f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt @@ -0,0 +1,192 @@ +package io.legado.app.ui.widget.dynamiclayout + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.view.ViewStub +import android.widget.FrameLayout +import android.widget.ProgressBar +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R + +@Suppress("unused") +class DynamicFrameLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs), ViewSwitcher { + + private var errorView: View? = null + private var errorImage: AppCompatImageView? = null + private var errorTextView: AppCompatTextView? = null + private var actionBtn: AppCompatButton? = null + + private var progressView: View? = null + private var progressBar: ProgressBar? = null + + private var contentView: View? = null + + private var errorIcon: Drawable? = null + private var emptyIcon: Drawable? = null + + private var errorActionDescription: CharSequence? = null + private var emptyActionDescription: CharSequence? = null + private var emptyDescription: CharSequence? = null + + private var errorAction: Action? = null + private var emptyAction: Action? = null + + private var changeListener: OnVisibilityChangeListener? = null + + init { + View.inflate(context, R.layout.view_dynamic, this) + + val a = context.obtainStyledAttributes(attrs, R.styleable.DynamicFrameLayout) + errorIcon = a.getDrawable(R.styleable.DynamicFrameLayout_errorSrc) + emptyIcon = a.getDrawable(R.styleable.DynamicFrameLayout_emptySrc) + + emptyActionDescription = a.getText(R.styleable.DynamicFrameLayout_emptyActionDescription) + emptyDescription = a.getText(R.styleable.DynamicFrameLayout_emptyDescription) + + errorActionDescription = a.getText(R.styleable.DynamicFrameLayout_errorActionDescription) + if (errorActionDescription == null) { + errorActionDescription = context.getString(R.string.dynamic_click_retry) + } + a.recycle() + } + + override fun onFinishInflate() { + super.onFinishInflate() + if (childCount > 2) { + contentView = getChildAt(2) + } + } + + override fun showErrorView(message: CharSequence) { + ensureErrorView() + + setViewVisible(errorView, true) + setViewVisible(contentView, false) + setViewVisible(progressView, false) + + errorTextView?.text = message + errorImage?.setImageDrawable(errorIcon) + + actionBtn?.let { + it.tag = ACTION_WHEN_ERROR + it.visibility = View.VISIBLE + if (errorActionDescription != null) { + it.text = errorActionDescription + } + } + + dispatchVisibilityChanged(ViewSwitcher.SHOW_ERROR_VIEW) + } + + override fun showErrorView(messageId: Int) { + showErrorView(resources.getText(messageId)) + } + + override fun showEmptyView() { + ensureErrorView() + + setViewVisible(errorView, true) + setViewVisible(contentView, false) + setViewVisible(progressView, false) + + errorTextView?.text = emptyDescription + errorImage?.setImageDrawable(emptyIcon) + + actionBtn?.let { + it.tag = ACTION_WHEN_EMPTY + if (errorActionDescription != null) { + it.visibility = View.VISIBLE + it.text = errorActionDescription + } else { + it.visibility = View.INVISIBLE + } + } + + dispatchVisibilityChanged(ViewSwitcher.SHOW_EMPTY_VIEW) + } + + override fun showProgressView() { + ensureProgressView() + + setViewVisible(errorView, false) + setViewVisible(contentView, false) + setViewVisible(progressView, true) + + dispatchVisibilityChanged(ViewSwitcher.SHOW_PROGRESS_VIEW) + } + + override fun showContentView() { + setViewVisible(errorView, false) + setViewVisible(contentView, true) + setViewVisible(progressView, false) + + dispatchVisibilityChanged(ViewSwitcher.SHOW_CONTENT_VIEW) + } + + fun setOnVisibilityChangeListener(listener: OnVisibilityChangeListener) { + changeListener = listener + } + + fun setErrorAction(action: Action) { + errorAction = action + } + + fun setEmptyAction(action: Action) { + emptyAction = action + } + + private fun setViewVisible(view: View?, visible: Boolean) { + view?.let { + it.visibility = if (visible) View.VISIBLE else View.INVISIBLE + } + } + + private fun ensureErrorView() { + if (errorView == null) { + errorView = findViewById(R.id.error_view_stub).inflate() + errorImage = errorView?.findViewById(R.id.iv_error_image) + errorTextView = errorView?.findViewById(R.id.tv_error_message) + actionBtn = errorView?.findViewById(R.id.btn_error_retry) + + actionBtn?.setOnClickListener { + when (it.tag) { + ACTION_WHEN_EMPTY -> emptyAction?.onAction(this@DynamicFrameLayout) + ACTION_WHEN_ERROR -> errorAction?.onAction(this@DynamicFrameLayout) + } + } + } + } + + private fun ensureProgressView() { + if (progressView == null) { + progressView = findViewById(R.id.progress_view_stub).inflate() + progressBar = progressView?.findViewById(R.id.loading_progress) + } + } + + private fun dispatchVisibilityChanged(@ViewSwitcher.Visibility visibility: Int) { + changeListener?.onVisibilityChanged(visibility) + } + + interface Action { + fun onAction(switcher: ViewSwitcher) + } + + + interface OnVisibilityChangeListener { + + fun onVisibilityChanged(@ViewSwitcher.Visibility visibility: Int) + } + + companion object { + private const val ACTION_WHEN_ERROR = "ACTION_WHEN_ERROR" + private const val ACTION_WHEN_EMPTY = "ACTION_WHEN_EMPTY" + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt new file mode 100644 index 000000000..dbcc0f132 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt @@ -0,0 +1,29 @@ +package io.legado.app.ui.widget.dynamiclayout + +import androidx.annotation.IntDef +import androidx.annotation.StringRes + +interface ViewSwitcher { + + companion object { + const val SHOW_CONTENT_VIEW = 0 + const val SHOW_ERROR_VIEW = 1 + const val SHOW_EMPTY_VIEW = 2 + const val SHOW_PROGRESS_VIEW = 3 + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef(SHOW_CONTENT_VIEW, SHOW_ERROR_VIEW, SHOW_EMPTY_VIEW, SHOW_PROGRESS_VIEW) + annotation class Visibility + + fun showErrorView(message: CharSequence) + + fun showErrorView(@StringRes messageId: Int) + + fun showEmptyView() + + fun showProgressView() + + fun showContentView() + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/ArcView.kt b/app/src/main/java/io/legado/app/ui/widget/image/ArcView.kt new file mode 100644 index 000000000..b2bfc241a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/ArcView.kt @@ -0,0 +1,87 @@ +package io.legado.app.ui.widget.image + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.View +import io.legado.app.R + +/** + * 弧形View + */ +class ArcView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + private var mWidth = 0 + private var mHeight = 0 + + //弧形高度 + private val mArcHeight: Int + + //背景颜色 + private var mBgColor: Int + private val mPaint: Paint = Paint().apply { + isAntiAlias = true + } + private val mDirectionTop: Boolean + val rect = Rect() + val path = Path() + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ArcView) + mArcHeight = typedArray.getDimensionPixelSize(R.styleable.ArcView_arcHeight, 0) + mBgColor = typedArray.getColor( + R.styleable.ArcView_bgColor, + Color.parseColor("#303F9F") + ) + mDirectionTop = typedArray.getBoolean(R.styleable.ArcView_arcDirectionTop, false) + typedArray.recycle() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + mPaint.style = Paint.Style.FILL + mPaint.color = mBgColor + if (mDirectionTop) { + rect.set(0, mArcHeight, mWidth, mHeight) + canvas.drawRect(rect, mPaint) + path.reset() + path.moveTo(0f, mArcHeight.toFloat()) + path.quadTo(mWidth / 2.toFloat(), 0f, mWidth.toFloat(), mArcHeight.toFloat()) + canvas.drawPath(path, mPaint) + } else { + rect.set(0, 0, mWidth, mHeight - mArcHeight) + canvas.drawRect(rect, mPaint) + path.reset() + path.moveTo(0f, mHeight - mArcHeight.toFloat()) + path.quadTo( + mWidth / 2.toFloat(), + mHeight.toFloat(), + mWidth.toFloat(), + mHeight - mArcHeight.toFloat() + ) + canvas.drawPath(path, mPaint) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + if (widthMode == MeasureSpec.EXACTLY) { + mWidth = widthSize + } + if (heightMode == MeasureSpec.EXACTLY) { + mHeight = heightSize + } + setMeasuredDimension(mWidth, mHeight) + } + + fun setBgColor(color: Int) { + mBgColor = color + invalidate() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt new file mode 100644 index 000000000..47893d6c6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt @@ -0,0 +1,458 @@ +package io.legado.app.ui.widget.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.* +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.text.TextPaint +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewOutlineProvider +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.appcompat.widget.AppCompatImageView +import io.legado.app.R +import io.legado.app.utils.getCompatColor + +import io.legado.app.utils.sp +import timber.log.Timber +import kotlin.math.min +import kotlin.math.pow + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class CircleImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatImageView(context, attrs) { + + private val mDrawableRect = RectF() + private val mBorderRect = RectF() + + private val mShaderMatrix = Matrix() + private val mBitmapPaint = Paint() + private val mBorderPaint = Paint() + private val mCircleBackgroundPaint = Paint() + private val textPaint by lazy { + val textPaint = TextPaint() + textPaint.isAntiAlias = true + textPaint.textAlign = Paint.Align.CENTER + textPaint + } + + private var mBorderColor = DEFAULT_BORDER_COLOR + private var mBorderWidth = DEFAULT_BORDER_WIDTH + private var mCircleBackgroundColor = DEFAULT_CIRCLE_BACKGROUND_COLOR + + private var mBitmap: Bitmap? = null + private var mBitmapShader: BitmapShader? = null + private var mBitmapWidth: Int = 0 + private var mBitmapHeight: Int = 0 + + private var mDrawableRadius: Float = 0.toFloat() + private var mBorderRadius: Float = 0.toFloat() + + private var mColorFilter: ColorFilter? = null + + private var mReady: Boolean = false + private var mSetupPending: Boolean = false + private var mBorderOverlay: Boolean = false + var isDisableCircularTransformation: Boolean = false + set(disableCircularTransformation) { + if (field == disableCircularTransformation) { + return + } + field = disableCircularTransformation + initializeBitmap() + } + + var borderColor: Int + get() = mBorderColor + set(@ColorInt borderColor) { + if (borderColor == mBorderColor) { + return + } + + mBorderColor = borderColor + mBorderPaint.color = mBorderColor + invalidate() + } + + var circleBackgroundColor: Int + get() = mCircleBackgroundColor + set(@ColorInt circleBackgroundColor) { + if (circleBackgroundColor == mCircleBackgroundColor) { + return + } + mCircleBackgroundColor = circleBackgroundColor + mCircleBackgroundPaint.color = circleBackgroundColor + invalidate() + } + + var borderWidth: Int + get() = mBorderWidth + set(borderWidth) { + if (borderWidth == mBorderWidth) { + return + } + + mBorderWidth = borderWidth + setup() + } + + var isBorderOverlay: Boolean + get() = mBorderOverlay + set(borderOverlay) { + if (borderOverlay == mBorderOverlay) { + return + } + + mBorderOverlay = borderOverlay + setup() + } + + private var text: String? = null + + private var textColor = context.getCompatColor(R.color.primaryText) + private var textBold = false + var isInView = false + + init { + val a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView) + mBorderWidth = + a.getDimensionPixelSize( + R.styleable.CircleImageView_civ_border_width, + DEFAULT_BORDER_WIDTH + ) + mBorderColor = + a.getColor(R.styleable.CircleImageView_civ_border_color, DEFAULT_BORDER_COLOR) + mBorderOverlay = + a.getBoolean(R.styleable.CircleImageView_civ_border_overlay, DEFAULT_BORDER_OVERLAY) + mCircleBackgroundColor = + a.getColor( + R.styleable.CircleImageView_civ_circle_background_color, + DEFAULT_CIRCLE_BACKGROUND_COLOR + ) + text = a.getString(R.styleable.CircleImageView_text) + contentDescription = text + if (a.hasValue(R.styleable.CircleImageView_textColor)) { + textColor = a.getColor( + R.styleable.CircleImageView_textColor, + context.getCompatColor(R.color.primaryText) + ) + } + a.recycle() + + mReady = true + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + outlineProvider = OutlineProvider() + } + + if (mSetupPending) { + setup() + mSetupPending = false + } + } + + override fun setAdjustViewBounds(adjustViewBounds: Boolean) { + if (adjustViewBounds) { + throw IllegalArgumentException("adjustViewBounds not supported.") + } + } + + override fun onDraw(canvas: Canvas) { + if (isDisableCircularTransformation) { + super.onDraw(canvas) + return + } + if (mBitmap == null) { + return + } + + if (mCircleBackgroundColor != Color.TRANSPARENT) { + canvas.drawCircle( + mDrawableRect.centerX(), + mDrawableRect.centerY(), + mDrawableRadius, + mCircleBackgroundPaint + ) + } + canvas.drawCircle( + mDrawableRect.centerX(), + mDrawableRect.centerY(), + mDrawableRadius, + mBitmapPaint + ) + if (mBorderWidth > 0) { + canvas.drawCircle( + mBorderRect.centerX(), + mBorderRect.centerY(), + mBorderRadius, + mBorderPaint + ) + } + drawText(canvas) + } + + private fun drawText(canvas: Canvas) { + text?.let { + textPaint.color = textColor + textPaint.isFakeBoldText = textBold + textPaint.textSize = 15.sp.toFloat() + val fm = textPaint.fontMetrics + canvas.drawText( + it, + width * 0.5f, + (height * 0.5f + (fm.bottom - fm.top) * 0.5f - fm.bottom), + textPaint + ) + } + } + + fun setText(text: String?) { + this.text = text + contentDescription = text + invalidate() + } + + fun setTextColor(@ColorInt textColor: Int) { + this.textColor = textColor + invalidate() + } + + fun setTextBold(bold: Boolean) { + this.textBold = bold + invalidate() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + setup() + } + + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + super.setPadding(left, top, right, bottom) + setup() + } + + override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { + super.setPaddingRelative(start, top, end, bottom) + setup() + } + + fun setCircleBackgroundColorResource(@ColorRes circleBackgroundRes: Int) { + circleBackgroundColor = context.getCompatColor(circleBackgroundRes) + } + + override fun setImageBitmap(bm: Bitmap) { + super.setImageBitmap(bm) + initializeBitmap() + } + + override fun setImageDrawable(drawable: Drawable?) { + super.setImageDrawable(drawable) + initializeBitmap() + } + + override fun setImageResource(@DrawableRes resId: Int) { + super.setImageResource(resId) + initializeBitmap() + } + + override fun setImageURI(uri: Uri?) { + super.setImageURI(uri) + initializeBitmap() + } + + override fun setColorFilter(cf: ColorFilter) { + if (cf === mColorFilter) { + return + } + + mColorFilter = cf + applyColorFilter() + invalidate() + } + + override fun getColorFilter(): ColorFilter? { + return mColorFilter + } + + private fun applyColorFilter() { + mBitmapPaint.colorFilter = mColorFilter + } + + private fun getBitmapFromDrawable(drawable: Drawable?): Bitmap? { + if (drawable == null) { + return null + } + + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + + return try { + val bitmap: Bitmap = if (drawable is ColorDrawable) { + Bitmap.createBitmap( + COLOR_DRAWABLE_DIMENSION, + COLOR_DRAWABLE_DIMENSION, + BITMAP_CONFIG + ) + } else { + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + BITMAP_CONFIG + ) + } + + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } catch (e: Exception) { + Timber.e(e) + null + } + + } + + private fun initializeBitmap() { + mBitmap = if (isDisableCircularTransformation) { + null + } else { + getBitmapFromDrawable(drawable) + } + setup() + } + + private fun setup() { + if (!mReady) { + mSetupPending = true + return + } + + if (width == 0 && height == 0) { + return + } + + if (mBitmap == null) { + invalidate() + return + } + + mBitmapShader = BitmapShader(mBitmap!!, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + + mBitmapPaint.isAntiAlias = true + mBitmapPaint.shader = mBitmapShader + + mBorderPaint.style = Paint.Style.STROKE + mBorderPaint.isAntiAlias = true + mBorderPaint.color = mBorderColor + mBorderPaint.strokeWidth = mBorderWidth.toFloat() + + mCircleBackgroundPaint.style = Paint.Style.FILL + mCircleBackgroundPaint.isAntiAlias = true + mCircleBackgroundPaint.color = mCircleBackgroundColor + + mBitmapHeight = mBitmap!!.height + mBitmapWidth = mBitmap!!.width + + mBorderRect.set(calculateBounds()) + mBorderRadius = + min( + (mBorderRect.height() - mBorderWidth) / 2.0f, + (mBorderRect.width() - mBorderWidth) / 2.0f + ) + + mDrawableRect.set(mBorderRect) + if (!mBorderOverlay && mBorderWidth > 0) { + mDrawableRect.inset(mBorderWidth - 1.0f, mBorderWidth - 1.0f) + } + mDrawableRadius = min(mDrawableRect.height() / 2.0f, mDrawableRect.width() / 2.0f) + + applyColorFilter() + updateShaderMatrix() + invalidate() + } + + private fun calculateBounds(): RectF { + val availableWidth = width - paddingLeft - paddingRight + val availableHeight = height - paddingTop - paddingBottom + + val sideLength = min(availableWidth, availableHeight) + + val left = paddingLeft + (availableWidth - sideLength) / 2f + val top = paddingTop + (availableHeight - sideLength) / 2f + + return RectF(left, top, left + sideLength, top + sideLength) + } + + private fun updateShaderMatrix() { + val scale: Float + var dx = 0f + var dy = 0f + + mShaderMatrix.set(null) + + if (mBitmapWidth * mDrawableRect.height() > mDrawableRect.width() * mBitmapHeight) { + scale = mDrawableRect.height() / mBitmapHeight.toFloat() + dx = (mDrawableRect.width() - mBitmapWidth * scale) * 0.5f + } else { + scale = mDrawableRect.width() / mBitmapWidth.toFloat() + dy = (mDrawableRect.height() - mBitmapHeight * scale) * 0.5f + } + + mShaderMatrix.setScale(scale, scale) + mShaderMatrix.postTranslate( + (dx + 0.5f).toInt() + mDrawableRect.left, + (dy + 0.5f).toInt() + mDrawableRect.top + ) + + mBitmapShader!!.setLocalMatrix(mShaderMatrix) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isInView = (inTouchableArea(event.x, event.y)) + } + } + return super.onTouchEvent(event) + } + + private fun inTouchableArea(x: Float, y: Float): Boolean { + return (x - mBorderRect.centerX()).toDouble() + .pow(2.0) + (y - mBorderRect.centerY()).toDouble() + .pow(2.0) <= mBorderRadius.toDouble().pow(2.0) + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private inner class OutlineProvider : ViewOutlineProvider() { + + override fun getOutline(view: View, outline: Outline) { + val bounds = Rect() + mBorderRect.roundOut(bounds) + outline.setRoundRect(bounds, bounds.width() / 2.0f) + } + + } + + companion object { + private val BITMAP_CONFIG = Bitmap.Config.ARGB_8888 + private const val COLOR_DRAWABLE_DIMENSION = 2 + private const val DEFAULT_BORDER_WIDTH = 0 + private const val DEFAULT_BORDER_COLOR = Color.BLACK + private const val DEFAULT_CIRCLE_BACKGROUND_COLOR = Color.TRANSPARENT + private const val DEFAULT_BORDER_OVERLAY = false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt new file mode 100644 index 000000000..898e5663f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt @@ -0,0 +1,191 @@ +package io.legado.app.ui.widget.image + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.text.TextPaint +import android.util.AttributeSet +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.legado.app.constant.AppPattern +import io.legado.app.help.AppConfig +import io.legado.app.help.glide.ImageLoader +import io.legado.app.lib.theme.accentColor +import io.legado.app.model.BookCover +import io.legado.app.utils.textHeight +import io.legado.app.utils.toStringArray + +/** + * 封面 + */ +@Suppress("unused") +class CoverImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : androidx.appcompat.widget.AppCompatImageView( + context, + attrs +) { + private var filletPath = Path() + private var width: Float = 0.toFloat() + private var height: Float = 0.toFloat() + private var defaultCover = true + var bitmapPath: String? = null + private set + private var name: String? = null + private var author: String? = null + private var nameHeight = 0f + private var authorHeight = 0f + private val namePaint by lazy { + val textPaint = TextPaint() + textPaint.typeface = Typeface.DEFAULT_BOLD + textPaint.isAntiAlias = true + textPaint.textAlign = Paint.Align.CENTER + textPaint + } + private val authorPaint by lazy { + val textPaint = TextPaint() + textPaint.typeface = Typeface.DEFAULT + textPaint.isAntiAlias = true + textPaint.textAlign = Paint.Align.CENTER + textPaint + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) + val measuredHeight = measuredWidth * 7 / 5 + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY) + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + width = getWidth().toFloat() + height = getHeight().toFloat() + filletPath.reset() + if (width > 10 && height > 10) { + filletPath.apply { + moveTo(10f, 0f) + lineTo(width - 10, 0f) + quadTo(width, 0f, width, 10f) + lineTo(width, height - 10) + quadTo(width, height, width - 10, height) + lineTo(10f, height) + quadTo(0f, height, 0f, height - 10) + lineTo(0f, 10f) + quadTo(0f, 0f, 10f, 0f) + close() + } + } + } + + override fun onDraw(canvas: Canvas) { + if (!filletPath.isEmpty) { + canvas.clipPath(filletPath) + } + super.onDraw(canvas) + if (defaultCover && !isInEditMode) { + drawNameAuthor(canvas) + } + } + + private fun drawNameAuthor(canvas: Canvas) { + if (!BookCover.drawBookName) return + var startX = width * 0.2f + var startY = height * 0.2f + name?.toStringArray()?.let { name -> + namePaint.textSize = width / 6 + namePaint.strokeWidth = namePaint.textSize / 5 + name.forEachIndexed { index, char -> + namePaint.color = Color.WHITE + namePaint.style = Paint.Style.STROKE + canvas.drawText(char, startX, startY, namePaint) + namePaint.color = context.accentColor + namePaint.style = Paint.Style.FILL + canvas.drawText(char, startX, startY, namePaint) + startY += namePaint.textHeight + if (startY > height * 0.8) { + startX += namePaint.textSize + namePaint.textSize = width / 10 + startY = (height - (name.size - index - 1) * namePaint.textHeight) / 2 + } + } + } + if (!BookCover.drawBookAuthor) return + author?.toStringArray()?.let { author -> + authorPaint.textSize = width / 10 + authorPaint.strokeWidth = authorPaint.textSize / 5 + startX = width * 0.8f + startY = height * 0.95f - author.size * authorPaint.textHeight + startY = maxOf(startY, height * 0.3f) + author.forEach { + authorPaint.color = Color.WHITE + authorPaint.style = Paint.Style.STROKE + canvas.drawText(it, startX, startY, authorPaint) + authorPaint.color = context.accentColor + authorPaint.style = Paint.Style.FILL + canvas.drawText(it, startX, startY, authorPaint) + startY += authorPaint.textHeight + if (startY > height * 0.95) { + return@let + } + } + } + } + + fun setHeight(height: Int) { + val width = height * 5 / 7 + minimumWidth = width + } + + private val glideListener by lazy { + object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + defaultCover = true + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + defaultCover = false + return false + } + + } + } + + fun load(path: String? = null, name: String? = null, author: String? = null) { + this.bitmapPath = path + this.name = name?.replace(AppPattern.bdRegex, "")?.trim() + this.author = author?.replace(AppPattern.bdRegex, "")?.trim() + if (AppConfig.useDefaultCover) { + defaultCover = true + ImageLoader.load(context, BookCover.defaultDrawable) + .centerCrop() + .into(this) + } else { + ImageLoader.load(context, path)//Glide自动识别http://,content://和file:// + .placeholder(BookCover.defaultDrawable) + .error(BookCover.defaultDrawable) + .listener(glideListener) + .centerCrop() + .into(this) + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt new file mode 100644 index 000000000..eefcdafc8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt @@ -0,0 +1,99 @@ +package io.legado.app.ui.widget.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Path +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import io.legado.app.R +import io.legado.app.utils.dp +import kotlin.math.max + +class FilletImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatImageView(context, attrs) { + internal var width: Float = 0.toFloat() + internal var height: Float = 0.toFloat() + private var leftTopRadius: Int = 0 + private var rightTopRadius: Int = 0 + private var rightBottomRadius: Int = 0 + private var leftBottomRadius: Int = 0 + + init { + // 读取配置 + val array = context.obtainStyledAttributes(attrs, R.styleable.FilletImageView) + val defaultRadius = 5.dp + val radius = + array.getDimensionPixelOffset(R.styleable.FilletImageView_radius, defaultRadius) + leftTopRadius = array.getDimensionPixelOffset( + R.styleable.FilletImageView_left_top_radius, + defaultRadius + ) + rightTopRadius = array.getDimensionPixelOffset( + R.styleable.FilletImageView_right_top_radius, + defaultRadius + ) + rightBottomRadius = + array.getDimensionPixelOffset( + R.styleable.FilletImageView_right_bottom_radius, + defaultRadius + ) + leftBottomRadius = array.getDimensionPixelOffset( + R.styleable.FilletImageView_left_bottom_radius, + defaultRadius + ) + + //如果四个角的值没有设置,那么就使用通用的radius的值。 + if (defaultRadius == leftTopRadius) { + leftTopRadius = radius + } + if (defaultRadius == rightTopRadius) { + rightTopRadius = radius + } + if (defaultRadius == rightBottomRadius) { + rightBottomRadius = radius + } + if (defaultRadius == leftBottomRadius) { + leftBottomRadius = radius + } + array.recycle() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + width = getWidth().toFloat() + height = getHeight().toFloat() + } + + override fun onDraw(canvas: Canvas) { + //这里做下判断,只有图片的宽高大于设置的圆角距离的时候才进行裁剪 + val maxLeft = max(leftTopRadius, leftBottomRadius) + val maxRight = max(rightTopRadius, rightBottomRadius) + val minWidth = maxLeft + maxRight + val maxTop = max(leftTopRadius, rightTopRadius) + val maxBottom = max(leftBottomRadius, rightBottomRadius) + val minHeight = maxTop + maxBottom + if (width >= minWidth && height > minHeight) { + @SuppressLint("DrawAllocation") val path = Path() + //四个角:右上,右下,左下,左上 + path.moveTo(leftTopRadius.toFloat(), 0f) + path.lineTo(width - rightTopRadius, 0f) + path.quadTo(width, 0f, width, rightTopRadius.toFloat()) + + path.lineTo(width, height - rightBottomRadius) + path.quadTo(width, height, width - rightBottomRadius, height) + + path.lineTo(leftBottomRadius.toFloat(), height) + path.quadTo(0f, height, 0f, height - leftBottomRadius) + + path.lineTo(0f, leftTopRadius.toFloat()) + path.quadTo(0f, 0f, leftTopRadius.toFloat(), 0f) + + canvas.clipPath(path) + } + super.onDraw(canvas) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/image/PhotoView.kt b/app/src/main/java/io/legado/app/ui/widget/image/PhotoView.kt new file mode 100644 index 000000000..b028dd116 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/PhotoView.kt @@ -0,0 +1,1258 @@ +package io.legado.app.ui.widget.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.PointF +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.* +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.ScaleGestureDetector.OnScaleGestureListener +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.widget.ImageView +import android.widget.OverScroller +import android.widget.Scroller +import androidx.appcompat.widget.AppCompatImageView +import io.legado.app.R +import io.legado.app.ui.widget.image.photo.Info +import io.legado.app.ui.widget.image.photo.OnRotateListener +import io.legado.app.ui.widget.image.photo.RotateGestureDetector +import kotlin.math.abs +import kotlin.math.roundToInt + +@Suppress("UNUSED_PARAMETER", "unused", "MemberVisibilityCanBePrivate", "PropertyName") +class PhotoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatImageView(context, attrs) { + val MIN_ROTATE = 35 + val ANIMA_DURING = 340 + val MAX_SCALE = 2.5f + + private var mMinRotate = 0 + var mAnimaDuring = 0 + private var mMaxScale = 0f + + var MAX_OVER_SCROLL = 0 + var MAX_FLING_OVER_SCROLL = 0 + var MAX_OVER_RESISTANCE = 0 + var MAX_ANIM_FROM_WAITE = 500 + + private val mBaseMatrix: Matrix = Matrix() + private val mAnimMatrix: Matrix = Matrix() + private val mSynthesisMatrix: Matrix = Matrix() + private val mTmpMatrix: Matrix = Matrix() + + private val mRotateDetector: RotateGestureDetector + private val mDetector: GestureDetector + private val mScaleDetector: ScaleGestureDetector + private var mClickListener: OnClickListener? = null + + private var mScaleType: ScaleType? = null + + private var hasMultiTouch = false + private var hasDrawable = false + private var isKnowSize = false + private var hasOverTranslate = false + + //缩放 + var isEnable = true + + //旋转 + var isRotateEnable = false + private var isInit = false + private var mAdjustViewBounds = false + + // 当前是否处于放大状态 + private var isZoonUp = false + private var canRotate = false + + private var imgLargeWidth = false + private var imgLargeHeight = false + + private var mRotateFlag = 0f + private var mDegrees = 0f + private var mScale = 1.0f + private var mTranslateX = 0 + private var mTranslateY = 0 + + private var mHalfBaseRectWidth = 0f + private var mHalfBaseRectHeight = 0f + + private val mWidgetRect = RectF() + private val mBaseRect = RectF() + private val mImgRect = RectF() + private val mTmpRect = RectF() + private val mCommonRect = RectF() + + private val mScreenCenter = PointF() + private val mScaleCenter = PointF() + private val mRotateCenter = PointF() + + private val mTranslate: Transform = Transform() + + private var mClip: RectF? = null + private var mFromInfo: Info? = null + private var mInfoTime: Long = 0 + private var mCompleteCallBack: Runnable? = null + + private var mLongClick: OnLongClickListener? = null + + private val mRotateListener = RotateListener() + private val mGestureListener = GestureListener() + private val mScaleListener = ScaleGestureListener() + + init { + super.setScaleType(ScaleType.MATRIX) + if (mScaleType == null) mScaleType = ScaleType.CENTER_INSIDE + mRotateDetector = RotateGestureDetector(mRotateListener) + mDetector = GestureDetector(context, mGestureListener) + mScaleDetector = ScaleGestureDetector(context, mScaleListener) + val density = resources.displayMetrics.density + MAX_OVER_SCROLL = (density * 30).toInt() + MAX_FLING_OVER_SCROLL = (density * 30).toInt() + MAX_OVER_RESISTANCE = (density * 140).toInt() + mMinRotate = MIN_ROTATE + mAnimaDuring = ANIMA_DURING + mMaxScale = MAX_SCALE + } + + /** + * 获取默认的动画持续时间 + */ + fun getDefaultAnimDuring(): Int { + return ANIMA_DURING + } + + override fun setOnClickListener(l: OnClickListener?) { + super.setOnClickListener(l) + mClickListener = l + } + + override fun setScaleType(scaleType: ScaleType) { + if (scaleType == ScaleType.MATRIX) return + if (scaleType != mScaleType) { + mScaleType = scaleType + if (isInit) { + initBase() + } + } + } + + override fun setOnLongClickListener(l: OnLongClickListener?) { + mLongClick = l + } + + /** + * 设置动画的插入器 + */ + fun setInterpolator(interpolator: Interpolator?) { + mTranslate.setInterpolator(interpolator) + } + + /** + * 获取动画持续时间 + */ + fun getAnimDuring(): Int { + return mAnimaDuring + } + + /** + * 设置动画的持续时间 + */ + fun setAnimDuring(during: Int) { + mAnimaDuring = during + } + + /** + * 设置最大可以缩放的倍数 + */ + fun setMaxScale(maxScale: Float) { + mMaxScale = maxScale + } + + /** + * 获取最大可以缩放的倍数 + */ + fun getMaxScale(): Float { + return mMaxScale + } + + /** + */ + fun setMaxAnimFromWaiteTime(wait: Int) { + MAX_ANIM_FROM_WAITE = wait + } + + @SuppressLint("UseCompatLoadingForDrawables") + override fun setImageResource(resId: Int) { + var drawable: Drawable? = null + try { + drawable = resources.getDrawable(resId, null) + } catch (e: Exception) { + } + setImageDrawable(drawable) + } + + override fun setImageDrawable(drawable: Drawable?) { + super.setImageDrawable(drawable) + if (drawable == null) { + hasDrawable = false + return + } + if (!hasSize(drawable)) return + if (!hasDrawable) { + hasDrawable = true + } + initBase() + } + + private fun hasSize(d: Drawable): Boolean { + return !((d.intrinsicHeight <= 0 || d.intrinsicWidth <= 0) + && (d.minimumWidth <= 0 || d.minimumHeight <= 0) + && (d.bounds.width() <= 0 || d.bounds.height() <= 0)) + } + + private fun getDrawableWidth(d: Drawable): Int { + var width = d.intrinsicWidth + if (width <= 0) width = d.minimumWidth + if (width <= 0) width = d.bounds.width() + return width + } + + private fun getDrawableHeight(d: Drawable): Int { + var height = d.intrinsicHeight + if (height <= 0) height = d.minimumHeight + if (height <= 0) height = d.bounds.height() + return height + } + + private fun initBase() { + if (!hasDrawable) return + if (!isKnowSize) return + mBaseMatrix.reset() + mAnimMatrix.reset() + isZoonUp = false + val img = drawable + val w = width + val h = height + val imgW = getDrawableWidth(img) + val imgH = getDrawableHeight(img) + mBaseRect[0f, 0f, imgW.toFloat()] = imgH.toFloat() + + // 以图片中心点居中位移 + val tx = (w - imgW) / 2 + val ty = (h - imgH) / 2 + var sx = 1f + var sy = 1f + + // 缩放,默认不超过屏幕大小 + if (imgW > w) { + sx = w.toFloat() / imgW + } + if (imgH > h) { + sy = h.toFloat() / imgH + } + val scale = if (sx < sy) sx else sy + mBaseMatrix.reset() + mBaseMatrix.postTranslate(tx.toFloat(), ty.toFloat()) + mBaseMatrix.postScale(scale, scale, mScreenCenter.x, mScreenCenter.y) + mBaseMatrix.mapRect(mBaseRect) + mHalfBaseRectWidth = mBaseRect.width() / 2 + mHalfBaseRectHeight = mBaseRect.height() / 2 + mScaleCenter.set(mScreenCenter) + mRotateCenter.set(mScaleCenter) + executeTranslate() + when (mScaleType) { + ScaleType.CENTER -> initCenter() + ScaleType.CENTER_CROP -> initCenterCrop() + ScaleType.CENTER_INSIDE -> initCenterInside() + ScaleType.FIT_CENTER -> initFitCenter() + ScaleType.FIT_START -> initFitStart() + ScaleType.FIT_END -> initFitEnd() + ScaleType.FIT_XY -> initFitXY() + else -> { + } + } + isInit = true + mFromInfo?.let { + if (System.currentTimeMillis() - mInfoTime < MAX_ANIM_FROM_WAITE) { + animaFrom(it) + } + } + mFromInfo = null + } + + private fun initCenter() { + if (!hasDrawable) return + if (!isKnowSize) return + val img = drawable + val imgW = getDrawableWidth(img) + val imgH = getDrawableHeight(img) + if (imgW > mWidgetRect.width() || imgH > mWidgetRect.height()) { + val scaleX = imgW / mImgRect.width() + val scaleY = imgH / mImgRect.height() + mScale = if (scaleX > scaleY) scaleX else scaleY + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) + executeTranslate() + resetBase() + } + } + + private fun initCenterCrop() { + if (mImgRect.width() < mWidgetRect.width() || mImgRect.height() < mWidgetRect.height()) { + val scaleX = mWidgetRect.width() / mImgRect.width() + val scaleY = mWidgetRect.height() / mImgRect.height() + mScale = if (scaleX > scaleY) scaleX else scaleY + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) + executeTranslate() + resetBase() + } + } + + private fun initCenterInside() { + if (mImgRect.width() > mWidgetRect.width() || mImgRect.height() > mWidgetRect.height()) { + val scaleX = mWidgetRect.width() / mImgRect.width() + val scaleY = mWidgetRect.height() / mImgRect.height() + mScale = if (scaleX < scaleY) scaleX else scaleY + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) + executeTranslate() + resetBase() + } + } + + private fun initFitCenter() { + if (mImgRect.width() < mWidgetRect.width()) { + mScale = mWidgetRect.width() / mImgRect.width() + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y) + executeTranslate() + resetBase() + } + } + + private fun initFitStart() { + initFitCenter() + val ty = -mImgRect.top + mAnimMatrix.postTranslate(0f, ty) + executeTranslate() + resetBase() + mTranslateY += ty.toInt() + } + + private fun initFitEnd() { + initFitCenter() + val ty = mWidgetRect.bottom - mImgRect.bottom + mTranslateY += ty.toInt() + mAnimMatrix.postTranslate(0f, ty) + executeTranslate() + resetBase() + } + + private fun initFitXY() { + val scaleX = mWidgetRect.width() / mImgRect.width() + val scaleY = mWidgetRect.height() / mImgRect.height() + mAnimMatrix.postScale(scaleX, scaleY, mScreenCenter.x, mScreenCenter.y) + executeTranslate() + resetBase() + } + + private fun resetBase() { + val img = drawable + val imgW = getDrawableWidth(img) + val imgH = getDrawableHeight(img) + mBaseRect[0f, 0f, imgW.toFloat()] = imgH.toFloat() + mBaseMatrix.set(mSynthesisMatrix) + mBaseMatrix.mapRect(mBaseRect) + mHalfBaseRectWidth = mBaseRect.width() / 2 + mHalfBaseRectHeight = mBaseRect.height() / 2 + mScale = 1f + mTranslateX = 0 + mTranslateY = 0 + mAnimMatrix.reset() + } + + private fun executeTranslate() { + mSynthesisMatrix.set(mBaseMatrix) + mSynthesisMatrix.postConcat(mAnimMatrix) + imageMatrix = mSynthesisMatrix + mAnimMatrix.mapRect(mImgRect, mBaseRect) + imgLargeWidth = mImgRect.width() > mWidgetRect.width() + imgLargeHeight = mImgRect.height() > mWidgetRect.height() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (!hasDrawable) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + val d = drawable + val drawableW = getDrawableWidth(d) + val drawableH = getDrawableHeight(d) + val pWidth = MeasureSpec.getSize(widthMeasureSpec) + val pHeight = MeasureSpec.getSize(heightMeasureSpec) + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + var width: Int + var height: Int + var p = layoutParams + if (p == null) { + p = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + width = if (p.width == ViewGroup.LayoutParams.MATCH_PARENT) { + if (widthMode == MeasureSpec.UNSPECIFIED) { + drawableW + } else { + pWidth + } + } else { + if (widthMode == MeasureSpec.EXACTLY) { + pWidth + } else if (widthMode == MeasureSpec.AT_MOST) { + if (drawableW > pWidth) pWidth else drawableW + } else { + drawableW + } + } + height = if (p.height == ViewGroup.LayoutParams.MATCH_PARENT) { + if (heightMode == MeasureSpec.UNSPECIFIED) { + drawableH + } else { + pHeight + } + } else { + if (heightMode == MeasureSpec.EXACTLY) { + pHeight + } else if (heightMode == MeasureSpec.AT_MOST) { + if (drawableH > pHeight) pHeight else drawableH + } else { + drawableH + } + } + if (mAdjustViewBounds && drawableW.toFloat() / drawableH != width.toFloat() / height) { + val hScale = height.toFloat() / drawableH + val wScale = width.toFloat() / drawableW + val scale = if (hScale < wScale) hScale else wScale + width = + if (p.width == ViewGroup.LayoutParams.MATCH_PARENT) width else (drawableW * scale).toInt() + height = + if (p.height == ViewGroup.LayoutParams.MATCH_PARENT) height else (drawableH * scale).toInt() + } + setMeasuredDimension(width, height) + } + + override fun setAdjustViewBounds(adjustViewBounds: Boolean) { + super.setAdjustViewBounds(adjustViewBounds) + mAdjustViewBounds = adjustViewBounds + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + mWidgetRect[0f, 0f, w.toFloat()] = h.toFloat() + mScreenCenter[w / 2.toFloat()] = h / 2.toFloat() + if (!isKnowSize) { + isKnowSize = true + initBase() + } + } + + override fun draw(canvas: Canvas) { + mClip?.let { + canvas.clipRect(it) + mClip = null + } + super.draw(canvas) + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + return if (isEnable) { + val action = event.actionMasked + if (event.pointerCount >= 2) hasMultiTouch = true + mDetector.onTouchEvent(event) + if (isRotateEnable) { + mRotateDetector.onTouchEvent(event) + } + mScaleDetector.onTouchEvent(event) + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) onUp() + true + } else { + super.dispatchTouchEvent(event) + } + } + + private fun onUp() { + if (mTranslate.isRunning) return + if (canRotate || mDegrees % 90 != 0f) { + var toDegrees = (mDegrees / 90).toInt() * 90.toFloat() + val remainder = mDegrees % 90 + if (remainder > 45) toDegrees += 90f else if (remainder < -45) toDegrees -= 90f + mTranslate.withRotate(mDegrees.toInt(), toDegrees.toInt()) + mDegrees = toDegrees + } + var scale = mScale + if (mScale < 1) { + scale = 1f + mTranslate.withScale(mScale, 1F) + } else if (mScale > mMaxScale) { + scale = mMaxScale + mTranslate.withScale(mScale, mMaxScale) + } + val cx = mImgRect.left + mImgRect.width() / 2 + val cy = mImgRect.top + mImgRect.height() / 2 + mScaleCenter[cx] = cy + mRotateCenter[cx] = cy + mTranslateX = 0 + mTranslateY = 0 + mTmpMatrix.reset() + mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top) + mTmpMatrix.postTranslate(cx - mHalfBaseRectWidth, cy - mHalfBaseRectHeight) + mTmpMatrix.postScale(scale, scale, cx, cy) + mTmpMatrix.postRotate(mDegrees, cx, cy) + mTmpMatrix.mapRect(mTmpRect, mBaseRect) + doTranslateReset(mTmpRect) + mTranslate.start() + } + + private fun doTranslateReset(imgRect: RectF) { + var tx = 0 + var ty = 0 + if (imgRect.width() <= mWidgetRect.width()) { + if (!isImageCenterWidth(imgRect)) tx = + (-((mWidgetRect.width() - imgRect.width()) / 2 - imgRect.left)).toInt() + } else { + if (imgRect.left > mWidgetRect.left) { + tx = (imgRect.left - mWidgetRect.left).toInt() + } else if (imgRect.right < mWidgetRect.right) { + tx = (imgRect.right - mWidgetRect.right).toInt() + } + } + if (imgRect.height() <= mWidgetRect.height()) { + if (!isImageCenterHeight(imgRect)) ty = + (-((mWidgetRect.height() - imgRect.height()) / 2 - imgRect.top)).toInt() + } else { + if (imgRect.top > mWidgetRect.top) { + ty = (imgRect.top - mWidgetRect.top).toInt() + } else if (imgRect.bottom < mWidgetRect.bottom) { + ty = (imgRect.bottom - mWidgetRect.bottom).toInt() + } + } + if (tx != 0 || ty != 0) { + if (!mTranslate.mFlingScroller.isFinished) mTranslate.mFlingScroller.abortAnimation() + mTranslate.withTranslate(mTranslateX, mTranslateY, -tx, -ty) + } + } + + private fun isImageCenterHeight(rect: RectF): Boolean { + return abs(rect.top.roundToInt() - (mWidgetRect.height() - rect.height()) / 2) < 1 + } + + private fun isImageCenterWidth(rect: RectF): Boolean { + return abs(rect.left.roundToInt() - (mWidgetRect.width() - rect.width()) / 2) < 1 + } + + private fun resistanceScrollByX( + overScroll: Float, + detalX: Float + ): Float { + return detalX * (abs(abs(overScroll) - MAX_OVER_RESISTANCE) / MAX_OVER_RESISTANCE.toFloat()) + } + + private fun resistanceScrollByY( + overScroll: Float, + detalY: Float + ): Float { + return detalY * (abs(abs(overScroll) - MAX_OVER_RESISTANCE) / MAX_OVER_RESISTANCE.toFloat()) + } + + /** + * 匹配两个Rect的共同部分输出到out,若无共同部分则输出0,0,0,0 + */ + private fun mapRect(r1: RectF, r2: RectF, out: RectF) { + val l: Float = if (r1.left > r2.left) r1.left else r2.left + val r: Float = if (r1.right < r2.right) r1.right else r2.right + if (l > r) { + out[0f, 0f, 0f] = 0f + return + } + val t: Float = if (r1.top > r2.top) r1.top else r2.top + val b: Float = if (r1.bottom < r2.bottom) r1.bottom else r2.bottom + if (t > b) { + out[0f, 0f, 0f] = 0f + return + } + out[l, t, r] = b + } + + private fun checkRect() { + if (!hasOverTranslate) { + mapRect(mWidgetRect, mImgRect, mCommonRect) + } + } + + private val mClickRunnable = Runnable { + mClickListener?.onClick(this) + } + + fun canScrollHorizontallySelf(direction: Float): Boolean { + if (mImgRect.width() <= mWidgetRect.width()) + return false + if (direction < 0 && mImgRect.left.roundToInt() - direction >= mWidgetRect.left) + return false + return !(direction > 0 && mImgRect.right.roundToInt() - direction <= mWidgetRect.right) + } + + fun canScrollVerticallySelf(direction: Float): Boolean { + if (mImgRect.height() <= mWidgetRect.height()) + return false + if (direction < 0 && mImgRect.top.roundToInt() - direction >= mWidgetRect.top) + return false + return !(direction > 0 && mImgRect.bottom.roundToInt() - direction <= mWidgetRect.bottom) + } + + override fun canScrollHorizontally(direction: Int): Boolean { + return if (hasMultiTouch) true else canScrollHorizontallySelf(direction.toFloat()) + } + + override fun canScrollVertically(direction: Int): Boolean { + return if (hasMultiTouch) true else canScrollVerticallySelf(direction.toFloat()) + } + + private inner class InterpolatorProxy : Interpolator { + private var mTarget: Interpolator? + + init { + mTarget = DecelerateInterpolator() + } + + fun setTargetInterpolator(interpolator: Interpolator?) { + mTarget = interpolator + } + + override fun getInterpolation(input: Float): Float { + return mTarget?.getInterpolation(input) ?: input + } + + } + + private inner class Transform : Runnable { + var isRunning = false + var mTranslateScroller: OverScroller + var mFlingScroller: OverScroller + var mScaleScroller: Scroller + var mClipScroller: Scroller + var mRotateScroller: Scroller + var c: ClipCalculate? = null + var mLastFlingX = 0 + var mLastFlingY = 0 + var mLastTranslateX = 0 + var mLastTranslateY = 0 + var mClipRect = RectF() + var mInterpolatorProxy = InterpolatorProxy() + + fun setInterpolator(interpolator: Interpolator?) { + mInterpolatorProxy.setTargetInterpolator(interpolator) + } + + init { + val ctx: Context = context + mTranslateScroller = OverScroller(ctx, mInterpolatorProxy) + mScaleScroller = Scroller(ctx, mInterpolatorProxy) + mFlingScroller = OverScroller(ctx, mInterpolatorProxy) + mClipScroller = Scroller(ctx, mInterpolatorProxy) + mRotateScroller = Scroller(ctx, mInterpolatorProxy) + } + + fun withTranslate(startX: Int, startY: Int, deltaX: Int, deltaY: Int) { + mLastTranslateX = 0 + mLastTranslateY = 0 + mTranslateScroller.startScroll(0, 0, deltaX, deltaY, mAnimaDuring) + } + + fun withScale(form: Float, to: Float) { + mScaleScroller.startScroll( + (form * 10000).toInt(), + 0, + ((to - form) * 10000).toInt(), + 0, + mAnimaDuring + ) + } + + fun withClip( + fromX: Float, + fromY: Float, + deltaX: Float, + deltaY: Float, + d: Int, + c: ClipCalculate? + ) { + mClipScroller.startScroll( + (fromX * 10000).toInt(), + (fromY * 10000).toInt(), + (deltaX * 10000).toInt(), + (deltaY * 10000).toInt(), + d + ) + this.c = c + } + + fun withRotate(fromDegrees: Int, toDegrees: Int) { + mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, mAnimaDuring) + } + + fun withRotate(fromDegrees: Int, toDegrees: Int, during: Int) { + mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, during) + } + + fun withFling(velocityX: Float, velocityY: Float) { + mLastFlingX = if (velocityX < 0) Int.MAX_VALUE else 0 + var distanceX = + (if (velocityX > 0) abs(mImgRect.left) else mImgRect.right - mWidgetRect.right).toInt() + distanceX = if (velocityX < 0) Int.MAX_VALUE - distanceX else distanceX + var minX = if (velocityX < 0) distanceX else 0 + var maxX = if (velocityX < 0) Int.MAX_VALUE else distanceX + val overX = if (velocityX < 0) Int.MAX_VALUE - minX else distanceX + mLastFlingY = if (velocityY < 0) Int.MAX_VALUE else 0 + var distanceY = + (if (velocityY > 0) abs(mImgRect.top) else mImgRect.bottom - mWidgetRect.bottom).toInt() + distanceY = if (velocityY < 0) Int.MAX_VALUE - distanceY else distanceY + var minY = if (velocityY < 0) distanceY else 0 + var maxY = if (velocityY < 0) Int.MAX_VALUE else distanceY + val overY = if (velocityY < 0) Int.MAX_VALUE - minY else distanceY + if (velocityX == 0f) { + maxX = 0 + minX = 0 + } + if (velocityY == 0f) { + maxY = 0 + minY = 0 + } + mFlingScroller.fling( + mLastFlingX, + mLastFlingY, + velocityX.toInt(), + velocityY.toInt(), + minX, + maxX, + minY, + maxY, + if (abs(overX) < MAX_FLING_OVER_SCROLL * 2) 0 else MAX_FLING_OVER_SCROLL, + if (abs(overY) < MAX_FLING_OVER_SCROLL * 2) 0 else MAX_FLING_OVER_SCROLL + ) + } + + fun start() { + isRunning = true + postExecute() + } + + fun stop() { + removeCallbacks(this) + mTranslateScroller.abortAnimation() + mScaleScroller.abortAnimation() + mFlingScroller.abortAnimation() + mRotateScroller.abortAnimation() + isRunning = false + } + + override fun run() { + + // if (!isRuning) return; + var endAnima = true + if (mScaleScroller.computeScrollOffset()) { + mScale = mScaleScroller.currX / 10000f + endAnima = false + } + if (mTranslateScroller.computeScrollOffset()) { + val tx = mTranslateScroller.currX - mLastTranslateX + val ty = mTranslateScroller.currY - mLastTranslateY + mTranslateX += tx + mTranslateY += ty + mLastTranslateX = mTranslateScroller.currX + mLastTranslateY = mTranslateScroller.currY + endAnima = false + } + if (mFlingScroller.computeScrollOffset()) { + val x = mFlingScroller.currX - mLastFlingX + val y = mFlingScroller.currY - mLastFlingY + mLastFlingX = mFlingScroller.currX + mLastFlingY = mFlingScroller.currY + mTranslateX += x + mTranslateY += y + endAnima = false + } + if (mRotateScroller.computeScrollOffset()) { + mDegrees = mRotateScroller.currX.toFloat() + endAnima = false + } + if (mClipScroller.computeScrollOffset() || mClip != null) { + val sx = mClipScroller.currX / 10000f + val sy = mClipScroller.currY / 10000f + mTmpMatrix.setScale( + sx, + sy, + (mImgRect.left + mImgRect.right) / 2, + c!!.calculateTop() + ) + mTmpMatrix.mapRect(mClipRect, mImgRect) + if (sx == 1f) { + mClipRect.left = mWidgetRect.left + mClipRect.right = mWidgetRect.right + } + if (sy == 1f) { + mClipRect.top = mWidgetRect.top + mClipRect.bottom = mWidgetRect.bottom + } + mClip = mClipRect + } + if (!endAnima) { + applyAnima() + postExecute() + } else { + isRunning = false + + // 修复动画结束后边距有些空隙, + var needFix = false + if (imgLargeWidth) { + if (mImgRect.left > 0) { + mTranslateX -= mImgRect.left.toInt() + } else if (mImgRect.right < mWidgetRect.width()) { + mTranslateX -= (mWidgetRect.width() - mImgRect.right).toInt() + } + needFix = true + } + if (imgLargeHeight) { + if (mImgRect.top > 0) { + mTranslateY -= mImgRect.top.toInt() + } else if (mImgRect.bottom < mWidgetRect.height()) { + mTranslateY -= (mWidgetRect.height() - mImgRect.bottom).toInt() + } + needFix = true + } + if (needFix) { + applyAnima() + } + invalidate() + mCompleteCallBack?.let { + it.run() + mCompleteCallBack = null + } + } + } + + private fun applyAnima() { + mAnimMatrix.reset() + mAnimMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top) + mAnimMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y) + mAnimMatrix.postTranslate(-mHalfBaseRectWidth, -mHalfBaseRectHeight) + mAnimMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y) + mAnimMatrix.postScale(mScale, mScale, mScaleCenter.x, mScaleCenter.y) + mAnimMatrix.postTranslate(mTranslateX.toFloat(), mTranslateY.toFloat()) + executeTranslate() + } + + private fun postExecute() { + if (isRunning) post(this) + } + + } + + fun getInfo(): Info { + val rect = RectF() + val p = IntArray(2) + getLocation(this, p) + rect[p[0] + mImgRect.left, p[1] + mImgRect.top, p[0] + mImgRect.right] = + p[1] + mImgRect.bottom + return Info( + rect, + mImgRect, + mWidgetRect, + mBaseRect, + mScreenCenter, + mScale, + mDegrees, + mScaleType + ) + } + + fun getImageViewInfo(imgView: ImageView): Info { + val p = IntArray(2) + getLocation(imgView, p) + val drawable: Drawable = imgView.drawable + val matrix: Matrix = imgView.imageMatrix + val width = getDrawableWidth(drawable) + val height = getDrawableHeight(drawable) + val imgRect = RectF(0F, 0F, width.toFloat(), height.toFloat()) + matrix.mapRect(imgRect) + val rect = RectF( + p[0] + imgRect.left, + p[1] + imgRect.top, + p[0] + imgRect.right, + p[1] + imgRect.bottom + ) + val widgetRect = RectF(0F, 0F, imgView.width.toFloat(), imgView.height.toFloat()) + val baseRect = RectF(widgetRect) + val screenCenter = PointF(widgetRect.width() / 2, widgetRect.height() / 2) + return Info( + rect, + imgRect, + widgetRect, + baseRect, + screenCenter, + 1F, + 0F, + imgView.scaleType + ) + } + + private fun getLocation(target: View, position: IntArray) { + position[0] += target.left + position[1] += target.top + var viewParent: ViewParent = target.parent + while (viewParent is View) { + val view: View = viewParent + if (view.id == R.id.content) return + position[0] -= view.scrollX + position[1] -= view.scrollY + position[0] += view.left + position[1] += view.top + viewParent = view.parent + } + position[0] = (position[0] + 0.5f).toInt() + position[1] = (position[1] + 0.5f).toInt() + } + + private fun reset() { + mAnimMatrix.reset() + executeTranslate() + mScale = 1f + mTranslateX = 0 + mTranslateY = 0 + } + + interface ClipCalculate { + fun calculateTop(): Float + } + + inner class START : ClipCalculate { + override fun calculateTop(): Float { + return mImgRect.top + } + } + + inner class END : ClipCalculate { + override fun calculateTop(): Float { + return mImgRect.bottom + } + } + + inner class OTHER : ClipCalculate { + override fun calculateTop(): Float { + return (mImgRect.top + mImgRect.bottom) / 2 + } + } + + /** + * 在PhotoView内部还没有图片的时候同样可以调用该方法 + * + * + * 此时并不会播放动画,当给PhotoView设置图片后会自动播放动画。 + * + * + * 若等待时间过长也没有给控件设置图片,则会忽略该动画,若要再次播放动画则需要重新调用该方法 + * (等待的时间默认500毫秒,可以通过setMaxAnimFromWaiteTime(int)设置最大等待时间) + */ + fun animaFrom(info: Info) { + if (isInit) { + reset() + val mine = getInfo() + val scaleX = info.mImgRect.width() / mine.mImgRect.width() + val scaleY = info.mImgRect.height() / mine.mImgRect.height() + val scale = if (scaleX < scaleY) scaleX else scaleY + val ocx = info.mRect.left + info.mRect.width() / 2 + val ocy = info.mRect.top + info.mRect.height() / 2 + val mcx = mine.mRect.left + mine.mRect.width() / 2 + val mcy = mine.mRect.top + mine.mRect.height() / 2 + mAnimMatrix.reset() + // mAnimaMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mAnimMatrix.postTranslate(ocx - mcx, ocy - mcy) + mAnimMatrix.postScale(scale, scale, ocx, ocy) + mAnimMatrix.postRotate(info.mDegrees, ocx, ocy) + executeTranslate() + mScaleCenter[ocx] = ocy + mRotateCenter[ocx] = ocy + mTranslate.withTranslate(0, 0, (-(ocx - mcx)).toInt(), (-(ocy - mcy)).toInt()) + mTranslate.withScale(scale, 1F) + mTranslate.withRotate(info.mDegrees.toInt(), 0) + if (info.mWidgetRect.width() < info.mImgRect.width() || info.mWidgetRect.height() < info.mImgRect.height()) { + var clipX = info.mWidgetRect.width() / info.mImgRect.width() + var clipY = info.mWidgetRect.height() / info.mImgRect.height() + clipX = if (clipX > 1) 1F else clipX + clipY = if (clipY > 1) 1F else clipY + val c = + if (info.mScaleType == ScaleType.FIT_START) START() else if (info.mScaleType == ScaleType.FIT_END) END() else OTHER() + mTranslate.withClip(clipX, clipY, 1 - clipX, 1 - clipY, mAnimaDuring / 3, c) + mTmpMatrix.setScale( + clipX, + clipY, + (mImgRect.left + mImgRect.right) / 2, + c.calculateTop() + ) + mTmpMatrix.mapRect(mTranslate.mClipRect, mImgRect) + mClip = mTranslate.mClipRect + } + mTranslate.start() + } else { + mFromInfo = info + mInfoTime = System.currentTimeMillis() + } + } + + fun animaTo( + info: Info, + completeCallBack: Runnable + ) { + if (isInit) { + mTranslate.stop() + mTranslateX = 0 + mTranslateY = 0 + val tcx = info.mRect.left + info.mRect.width() / 2 + val tcy = info.mRect.top + info.mRect.height() / 2 + mScaleCenter[mImgRect.left + mImgRect.width() / 2] = + mImgRect.top + mImgRect.height() / 2 + mRotateCenter.set(mScaleCenter) + + // 将图片旋转回正常位置,用以计算 + mAnimMatrix.postRotate(-mDegrees, mScaleCenter.x, mScaleCenter.y) + mAnimMatrix.mapRect(mImgRect, mBaseRect) + + // 缩放 + val scaleX = info.mImgRect.width() / mBaseRect.width() + val scaleY = info.mImgRect.height() / mBaseRect.height() + val scale = if (scaleX > scaleY) scaleX else scaleY + mAnimMatrix.postRotate(mDegrees, mScaleCenter.x, mScaleCenter.y) + mAnimMatrix.mapRect(mImgRect, mBaseRect) + mDegrees %= 360 + mTranslate.withTranslate( + 0, + 0, + (tcx - mScaleCenter.x).toInt(), + (tcy - mScaleCenter.y).toInt() + ) + mTranslate.withScale(mScale, scale) + mTranslate.withRotate(mDegrees.toInt(), info.mDegrees.toInt(), mAnimaDuring * 2 / 3) + if (info.mWidgetRect.width() < info.mRect.width() || info.mWidgetRect.height() < info.mRect.height()) { + var clipX = info.mWidgetRect.width() / info.mRect.width() + var clipY = info.mWidgetRect.height() / info.mRect.height() + clipX = if (clipX > 1) 1F else clipX + clipY = if (clipY > 1) 1F else clipY + val cx = clipX + val cy = clipY + val c = + if (info.mScaleType == ScaleType.FIT_START) START() else if (info.mScaleType == ScaleType.FIT_END) END() else OTHER() + postDelayed( + { mTranslate.withClip(1F, 1F, -1 + cx, -1 + cy, mAnimaDuring / 2, c) }, + mAnimaDuring / 2.toLong() + ) + } + mCompleteCallBack = completeCallBack + mTranslate.start() + } + } + + fun rotate(degrees: Float) { + mDegrees += degrees + val centerX = (mWidgetRect.left + mWidgetRect.width() / 2).toInt() + val centerY = (mWidgetRect.top + mWidgetRect.height() / 2).toInt() + mAnimMatrix.postRotate(degrees, centerX.toFloat(), centerY.toFloat()) + executeTranslate() + } + + inner class RotateListener : OnRotateListener { + override fun onRotate( + degrees: Float, + focusX: Float, + focusY: Float + ) { + mRotateFlag += degrees + if (canRotate) { + mDegrees += degrees + mAnimMatrix.postRotate(degrees, focusX, focusY) + } else { + if (abs(mRotateFlag) >= mMinRotate) { + canRotate = true + mRotateFlag = 0f + } + } + } + } + + inner class GestureListener : SimpleOnGestureListener() { + override fun onLongPress(e: MotionEvent) { + mLongClick?.onLongClick(this@PhotoView) + } + + override fun onDown(e: MotionEvent): Boolean { + hasOverTranslate = false + hasMultiTouch = false + canRotate = false + removeCallbacks(mClickRunnable) + return false + } + + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (hasMultiTouch) return false + if (!imgLargeWidth && !imgLargeHeight) return false + if (mTranslate.isRunning) return false + var vx = velocityX + var vy = velocityY + if (mImgRect.left.roundToInt() >= mWidgetRect.left + || mImgRect.right.roundToInt() <= mWidgetRect.right + ) { + vx = 0f + } + if (mImgRect.top.roundToInt() >= mWidgetRect.top + || mImgRect.bottom.roundToInt() <= mWidgetRect.bottom + ) { + vy = 0f + } + if (canRotate || mDegrees % 90 != 0f) { + var toDegrees = (mDegrees / 90).toInt() * 90.toFloat() + val remainder = mDegrees % 90 + if (remainder > 45) toDegrees += 90f else if (remainder < -45) toDegrees -= 90f + mTranslate.withRotate(mDegrees.toInt(), toDegrees.toInt()) + mDegrees = toDegrees + } + doTranslateReset(mImgRect) + mTranslate.withFling(vx, vy) + mTranslate.start() + // onUp(e2); + return super.onFling(e1, e2, velocityX, velocityY) + } + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + var x = distanceX + var y = distanceY + if (mTranslate.isRunning) { + mTranslate.stop() + } + if (canScrollHorizontallySelf(x)) { + if (x < 0 && mImgRect.left - x > mWidgetRect.left) + x = mImgRect.left + if (x > 0 && mImgRect.right - x < mWidgetRect.right) + x = mImgRect.right - mWidgetRect.right + mAnimMatrix.postTranslate(-x, 0f) + mTranslateX -= x.toInt() + } else if (imgLargeWidth || hasMultiTouch || hasOverTranslate) { + checkRect() + if (!hasMultiTouch) { + if (x < 0 && mImgRect.left - x > mCommonRect.left) x = + resistanceScrollByX(mImgRect.left - mCommonRect.left, x) + if (x > 0 && mImgRect.right - x < mCommonRect.right) x = + resistanceScrollByX(mImgRect.right - mCommonRect.right, x) + } + mTranslateX -= x.toInt() + mAnimMatrix.postTranslate(-x, 0f) + hasOverTranslate = true + } + if (canScrollVerticallySelf(y)) { + if (y < 0 && mImgRect.top - y > mWidgetRect.top) y = + mImgRect.top + if (y > 0 && mImgRect.bottom - y < mWidgetRect.bottom) y = + mImgRect.bottom - mWidgetRect.bottom + mAnimMatrix.postTranslate(0f, -y) + mTranslateY -= y.toInt() + } else if (imgLargeHeight || hasOverTranslate || hasMultiTouch) { + checkRect() + if (!hasMultiTouch) { + if (y < 0 && mImgRect.top - y > mCommonRect.top) y = + resistanceScrollByY(mImgRect.top - mCommonRect.top, y) + if (y > 0 && mImgRect.bottom - y < mCommonRect.bottom) y = + resistanceScrollByY(mImgRect.bottom - mCommonRect.bottom, y) + } + mAnimMatrix.postTranslate(0f, -y) + mTranslateY -= y.toInt() + hasOverTranslate = true + } + executeTranslate() + return true + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + postDelayed(mClickRunnable, 250) + return false + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + mTranslate.stop() + val from: Float + val to: Float + val imgCx = mImgRect.left + mImgRect.width() / 2 + val imgCy = mImgRect.top + mImgRect.height() / 2 + mScaleCenter[imgCx] = imgCy + mRotateCenter[imgCx] = imgCy + mTranslateX = 0 + mTranslateY = 0 + if (isZoonUp) { + from = mScale + to = 1f + } else { + from = mScale + to = mMaxScale + mScaleCenter[e.x] = e.y + } + mTmpMatrix.reset() + mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top) + mTmpMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y) + mTmpMatrix.postTranslate(-mHalfBaseRectWidth, -mHalfBaseRectHeight) + mTmpMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y) + mTmpMatrix.postScale(to, to, mScaleCenter.x, mScaleCenter.y) + mTmpMatrix.postTranslate(mTranslateX.toFloat(), mTranslateY.toFloat()) + mTmpMatrix.mapRect(mTmpRect, mBaseRect) + doTranslateReset(mTmpRect) + isZoonUp = !isZoonUp + mTranslate.withScale(from, to) + mTranslate.start() + return false + } + } + + inner class ScaleGestureListener : OnScaleGestureListener { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val scaleFactor = detector.scaleFactor + if (java.lang.Float.isNaN(scaleFactor) || java.lang.Float.isInfinite(scaleFactor)) return false + mScale *= scaleFactor + //mScaleCenter.set(detector.getFocusX(), detector.getFocusY()); + mAnimMatrix.postScale( + scaleFactor, + scaleFactor, + detector.focusX, + detector.focusY + ) + executeTranslate() + return true + } + + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/photo/Info.kt b/app/src/main/java/io/legado/app/ui/widget/image/photo/Info.kt new file mode 100644 index 000000000..6ce1adfc6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/photo/Info.kt @@ -0,0 +1,49 @@ +package io.legado.app.ui.widget.image.photo + +import android.graphics.PointF + +import android.graphics.RectF +import android.widget.ImageView + + +@Suppress("MemberVisibilityCanBePrivate") +class Info( + rect: RectF, + img: RectF, + widget: RectF, + base: RectF, + screenCenter: PointF, + scale: Float, + degrees: Float, + scaleType: ImageView.ScaleType? +) { + // 内部图片在整个手机界面的位置 + var mRect = RectF() + + // 控件在窗口的位置 + var mImgRect = RectF() + + var mWidgetRect = RectF() + + var mBaseRect = RectF() + + var mScreenCenter = PointF() + + var mScale = 0f + + var mDegrees = 0f + + var mScaleType: ImageView.ScaleType? = null + + init { + mRect.set(rect) + mImgRect.set(img) + mWidgetRect.set(widget) + mScale = scale + mScaleType = scaleType + mDegrees = degrees + mBaseRect.set(base) + mScreenCenter.set(screenCenter) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/photo/RotateGestureDetector.kt b/app/src/main/java/io/legado/app/ui/widget/image/photo/RotateGestureDetector.kt new file mode 100644 index 000000000..7dddbebbf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/photo/RotateGestureDetector.kt @@ -0,0 +1,54 @@ +package io.legado.app.ui.widget.image.photo + +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.atan + +class RotateGestureDetector(private val mListener: OnRotateListener) { + + private val MAX_DEGREES_STEP = 120 + + private var mPrevSlope = 0f + private var mCurrSlope = 0f + + private val x1 = 0f + private val y1 = 0f + private val x2 = 0f + private val y2 = 0f + + fun onTouchEvent(event: MotionEvent) { + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_POINTER_UP -> { + if (event.pointerCount == 2) mPrevSlope = calculateSlope(event) + } + MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1) { + mCurrSlope = calculateSlope(event) + + val currDegrees = Math.toDegrees(atan(mCurrSlope.toDouble())) + val prevDegrees = Math.toDegrees(atan(mPrevSlope.toDouble())) + + val deltaSlope = currDegrees - prevDegrees + + if (abs(deltaSlope) <= MAX_DEGREES_STEP) { + mListener.onRotate(deltaSlope.toFloat(), (x2 + x1) / 2, (y2 + y1) / 2) + } + mPrevSlope = mCurrSlope + } + } + + } + + private fun calculateSlope(event: MotionEvent): Float { + val x1 = event.getX(0) + val y1 = event.getY(0) + val x2 = event.getX(1) + val y2 = event.getY(1) + return (y2 - y1) / (x2 - x1) + } +} + +interface OnRotateListener { + fun onRotate(degrees: Float, focusX: Float, focusY: Float) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/number/NumberPickerDialog.kt b/app/src/main/java/io/legado/app/ui/widget/number/NumberPickerDialog.kt new file mode 100644 index 000000000..01e11efad --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/number/NumberPickerDialog.kt @@ -0,0 +1,76 @@ +package io.legado.app.ui.widget.number + +import android.content.Context +import android.widget.NumberPicker +import androidx.appcompat.app.AlertDialog +import io.legado.app.R +import io.legado.app.utils.applyTint +import io.legado.app.utils.hideSoftInput + + +class NumberPickerDialog(context: Context) { + private val builder = AlertDialog.Builder(context) + private var numberPicker: NumberPicker? = null + private var maxValue: Int? = null + private var minValue: Int? = null + private var value: Int? = null + + init { + builder.setView(R.layout.dialog_number_picker) + } + + fun setTitle(title: String): NumberPickerDialog { + builder.setTitle(title) + return this + } + + fun setMaxValue(value: Int): NumberPickerDialog { + maxValue = value + return this + } + + fun setMinValue(value: Int): NumberPickerDialog { + minValue = value + return this + } + + fun setValue(value: Int): NumberPickerDialog { + this.value = value + return this + } + + fun setCustomButton(textId: Int, listener: (() -> Unit)?): NumberPickerDialog { + builder.setNeutralButton(textId) { _, _ -> + numberPicker?.let { + it.clearFocus() + it.hideSoftInput() + listener?.invoke() + } + } + return this + } + + fun show(callBack: ((value: Int) -> Unit)?) { + builder.setPositiveButton(R.string.ok) { _, _ -> + numberPicker?.let { + it.clearFocus() + it.hideSoftInput() + callBack?.invoke(it.value) + } + } + builder.setNegativeButton(R.string.cancel, null) + val dialog = builder.show().applyTint() + numberPicker = dialog.findViewById(R.id.number_picker) + numberPicker?.let { np -> + minValue?.let { + np.minValue = it + } + maxValue?.let { + np.maxValue = it + } + value?.let { + np.value = it + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/ColorPreference.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/ColorPreference.kt new file mode 100644 index 000000000..b9efd2398 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/ColorPreference.kt @@ -0,0 +1,446 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.TypedArray +import android.graphics.Color +import android.os.Bundle +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentActivity +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.jaredrummler.android.colorpicker.* +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.applyTint + +@Suppress("MemberVisibilityCanBePrivate", "unused") +class ColorPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs), + ColorPickerDialogListener { + + var onSaveColor: ((color: Int) -> Boolean)? = null + + private val sizeNormal = 0 + private val sizeLarge = 1 + + private var onShowDialogListener: OnShowDialogListener? = null + private var mColor = Color.BLACK + private var showDialog: Boolean = false + + @ColorPickerDialog.DialogType + private var dialogType: Int = 0 + private var colorShape: Int = 0 + private var allowPresets: Boolean = false + private var allowCustom: Boolean = false + private var showAlphaSlider: Boolean = false + private var showColorShades: Boolean = false + private var previewSize: Int = 0 + private var presets: IntArray? = null + private var dialogTitle: Int = 0 + + init { + isPersistent = true + layoutResource = io.legado.app.R.layout.view_preference + + val a = context.obtainStyledAttributes(attrs, R.styleable.ColorPreference) + showDialog = a.getBoolean(R.styleable.ColorPreference_cpv_showDialog, true) + + dialogType = + a.getInt(R.styleable.ColorPreference_cpv_dialogType, ColorPickerDialog.TYPE_PRESETS) + colorShape = a.getInt(R.styleable.ColorPreference_cpv_colorShape, ColorShape.CIRCLE) + allowPresets = a.getBoolean(R.styleable.ColorPreference_cpv_allowPresets, true) + allowCustom = a.getBoolean(R.styleable.ColorPreference_cpv_allowCustom, true) + showAlphaSlider = a.getBoolean(R.styleable.ColorPreference_cpv_showAlphaSlider, false) + showColorShades = a.getBoolean(R.styleable.ColorPreference_cpv_showColorShades, true) + previewSize = a.getInt(R.styleable.ColorPreference_cpv_previewSize, sizeNormal) + val presetsResId = a.getResourceId(R.styleable.ColorPreference_cpv_colorPresets, 0) + dialogTitle = + a.getResourceId(R.styleable.ColorPreference_cpv_dialogTitle, R.string.cpv_default_title) + presets = if (presetsResId != 0) { + context.resources.getIntArray(presetsResId) + } else { + ColorPickerDialog.MATERIAL_COLORS + } + widgetLayoutResource = if (colorShape == ColorShape.CIRCLE) { + if (previewSize == sizeLarge) R.layout.cpv_preference_circle_large else R.layout.cpv_preference_circle + } else { + if (previewSize == sizeLarge) R.layout.cpv_preference_square_large else R.layout.cpv_preference_square + } + a.recycle() + } + + override fun onClick() { + super.onClick() + if (onShowDialogListener != null) { + onShowDialogListener!!.onShowColorPickerDialog(title as String, mColor) + } else if (showDialog) { + val dialog = ColorPickerDialogCompat.newBuilder() + .setDialogType(dialogType) + .setDialogTitle(dialogTitle) + .setColorShape(colorShape) + .setPresets(presets!!) + .setAllowPresets(allowPresets) + .setAllowCustom(allowCustom) + .setShowAlphaSlider(showAlphaSlider) + .setShowColorShades(showColorShades) + .setColor(mColor) + .create() + dialog.setColorPickerDialogListener(this) + getActivity().supportFragmentManager + .beginTransaction() + .add(dialog, getFragmentTag()) + .commitAllowingStateLoss() + } + } + + private fun getActivity(): FragmentActivity { + val context = context + if (context is FragmentActivity) { + return context + } else if (context is ContextWrapper) { + val baseContext = context.baseContext + if (baseContext is FragmentActivity) { + return baseContext + } + } + throw IllegalStateException("Error getting activity from context") + } + + override fun onAttached() { + super.onAttached() + if (showDialog) { + val fragment = + getActivity().supportFragmentManager.findFragmentByTag(getFragmentTag()) as ColorPickerDialog? + fragment?.setColorPickerDialogListener(this) + } + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + val v = io.legado.app.ui.widget.prefs.Preference.bindView( + context, holder, icon, title, summary, widgetLayoutResource, + io.legado.app.R.id.cpv_preference_preview_color_panel, 30, 30 + ) + if (v is ColorPanelView) { + v.color = mColor + } + super.onBindViewHolder(holder) + } + + override fun onSetInitialValue(defaultValue: Any?) { + super.onSetInitialValue(defaultValue) + if (defaultValue is Int) { + mColor = if (!showAlphaSlider) ColorUtils.withAlpha(defaultValue, 1f) else defaultValue + persistInt(mColor) + } else { + mColor = getPersistedInt(-0x1000000) + } + } + + override fun onGetDefaultValue(a: TypedArray?, index: Int): Any { + return a!!.getInteger(index, Color.BLACK) + } + + override fun onColorSelected(dialogId: Int, @ColorInt color: Int) { + //返回值为true时说明已经处理过,不再处理 + if (onSaveColor?.invoke(color) == true) { + return + } + saveValue(color) + } + + override fun onDialogDismissed(dialogId: Int) { + // no-op + } + + /** + * Set the new color + * + * @param color The newly selected color + */ + fun saveValue(@ColorInt color: Int) { + mColor = if (showAlphaSlider) color else ColorUtils.withAlpha(color, 1f) + persistInt(mColor) + notifyChanged() + callChangeListener(color) + } + + /** + * Get the colors that will be shown in the [ColorPickerDialog]. + * + * @return An array of color ints + */ + fun getPresets(): IntArray? { + return presets + } + + /** + * Set the colors shown in the [ColorPickerDialog]. + * + * @param presets An array of color ints + */ + fun setPresets(presets: IntArray) { + this.presets = presets + } + + /** + * The listener used for showing the [ColorPickerDialog]. + * Call [.saveValue] after the user chooses a color. + * If this is set then it is up to you to show the dialog. + * + * @param listener The listener to show the dialog + */ + fun setOnShowDialogListener(listener: OnShowDialogListener) { + onShowDialogListener = listener + } + + /** + * The tag used for the [ColorPickerDialog]. + * + * @return The tag + */ + fun getFragmentTag(): String { + return "color_$key" + } + + interface OnShowDialogListener { + + fun onShowColorPickerDialog(title: String, currentColor: Int) + } + + + internal class ColorPickerDialogCompat : ColorPickerDialog() { + + override fun onStart() { + super.onStart() + val alertDialog = dialog as? AlertDialog + alertDialog?.applyTint() + } + + + companion object { + fun newBuilder(): Builder { + return Builder() + } + + private const val ARG_ID = "id" + private const val ARG_TYPE = "dialogType" + private const val ARG_COLOR = "color" + private const val ARG_ALPHA = "alpha" + private const val ARG_PRESETS = "presets" + private const val ARG_ALLOW_PRESETS = "allowPresets" + private const val ARG_ALLOW_CUSTOM = "allowCustom" + private const val ARG_DIALOG_TITLE = "dialogTitle" + private const val ARG_SHOW_COLOR_SHADES = "showColorShades" + private const val ARG_COLOR_SHAPE = "colorShape" + private const val ARG_PRESETS_BUTTON_TEXT = "presetsButtonText" + private const val ARG_CUSTOM_BUTTON_TEXT = "customButtonText" + private const val ARG_SELECTED_BUTTON_TEXT = "selectedButtonText" + } + + class Builder internal constructor() { + + internal var colorPickerDialogListener: ColorPickerDialogListener? = null + + @StringRes + internal var dialogTitle = R.string.cpv_default_title + + @StringRes + internal var presetsButtonText = R.string.cpv_presets + + @StringRes + internal var customButtonText = R.string.cpv_custom + + @StringRes + internal var selectedButtonText = R.string.cpv_select + + @DialogType + internal var dialogType = TYPE_PRESETS + internal var presets = MATERIAL_COLORS + + @ColorInt + internal var color = Color.BLACK + internal var dialogId = 0 + internal var showAlphaSlider = false + internal var allowPresets = true + internal var allowCustom = true + internal var showColorShades = true + + @ColorShape + internal var colorShape = ColorShape.CIRCLE + + /** + * Set the dialog title string resource id + * + * @param dialogTitle The string resource used for the dialog title + * @return This builder object for chaining method calls + */ + fun setDialogTitle(@StringRes dialogTitle: Int): Builder { + this.dialogTitle = dialogTitle + return this + } + + /** + * Set the selected button text string resource id + * + * @param selectedButtonText The string resource used for the selected button text + * @return This builder object for chaining method calls + */ + fun setSelectedButtonText(@StringRes selectedButtonText: Int): Builder { + this.selectedButtonText = selectedButtonText + return this + } + + /** + * Set the presets button text string resource id + * + * @param presetsButtonText The string resource used for the presets button text + * @return This builder object for chaining method calls + */ + fun setPresetsButtonText(@StringRes presetsButtonText: Int): Builder { + this.presetsButtonText = presetsButtonText + return this + } + + /** + * Set the custom button text string resource id + * + * @param customButtonText The string resource used for the custom button text + * @return This builder object for chaining method calls + */ + fun setCustomButtonText(@StringRes customButtonText: Int): Builder { + this.customButtonText = customButtonText + return this + } + + /** + * Set which dialog view to show. + * + * @param dialogType Either [ColorPickerDialog.TYPE_CUSTOM] or [ColorPickerDialog.TYPE_PRESETS]. + * @return This builder object for chaining method calls + */ + fun setDialogType(@DialogType dialogType: Int): Builder { + this.dialogType = dialogType + return this + } + + /** + * Set the colors used for the presets + * + * @param presets An array of color ints. + * @return This builder object for chaining method calls + */ + fun setPresets(presets: IntArray): Builder { + this.presets = presets + return this + } + + /** + * Set the original color + * + * @param color The default color for the color picker + * @return This builder object for chaining method calls + */ + fun setColor(color: Int): Builder { + this.color = color + return this + } + + /** + * Set the dialog id used for callbacks + * + * @param dialogId The id that is sent back to the [ColorPickerDialogListener]. + * @return This builder object for chaining method calls + */ + fun setDialogId(dialogId: Int): Builder { + this.dialogId = dialogId + return this + } + + /** + * Show the alpha slider + * + * @param showAlphaSlider `true` to show the alpha slider. Currently only supported with the [ ]. + * @return This builder object for chaining method calls + */ + fun setShowAlphaSlider(showAlphaSlider: Boolean): Builder { + this.showAlphaSlider = showAlphaSlider + return this + } + + /** + * Show/Hide a neutral button to select preset colors. + * + * @param allowPresets `false` to disable showing the presets button. + * @return This builder object for chaining method calls + */ + fun setAllowPresets(allowPresets: Boolean): Builder { + this.allowPresets = allowPresets + return this + } + + /** + * Show/Hide the neutral button to select a custom color. + * + * @param allowCustom `false` to disable showing the custom button. + * @return This builder object for chaining method calls + */ + fun setAllowCustom(allowCustom: Boolean): Builder { + this.allowCustom = allowCustom + return this + } + + /** + * Show/Hide the color shades in the presets picker + * + * @param showColorShades `false` to hide the color shades. + * @return This builder object for chaining method calls + */ + fun setShowColorShades(showColorShades: Boolean): Builder { + this.showColorShades = showColorShades + return this + } + + /** + * Set the shape of the color panel view. + * + * @param colorShape Either [ColorShape.CIRCLE] or [ColorShape.SQUARE]. + * @return This builder object for chaining method calls + */ + fun setColorShape(colorShape: Int): Builder { + this.colorShape = colorShape + return this + } + + /** + * Create the [ColorPickerDialog] instance. + * + * @return A new [ColorPickerDialog]. + * @see .show + */ + fun create(): ColorPickerDialog { + val dialog = + ColorPickerDialogCompat() + val args = Bundle() + args.putInt(ARG_ID, dialogId) + args.putInt(ARG_TYPE, dialogType) + args.putInt(ARG_COLOR, color) + args.putIntArray(ARG_PRESETS, presets) + args.putBoolean(ARG_ALPHA, showAlphaSlider) + args.putBoolean(ARG_ALLOW_CUSTOM, allowCustom) + args.putBoolean(ARG_ALLOW_PRESETS, allowPresets) + args.putInt(ARG_DIALOG_TITLE, dialogTitle) + args.putBoolean(ARG_SHOW_COLOR_SHADES, showColorShades) + args.putInt(ARG_COLOR_SHAPE, colorShape) + args.putInt(ARG_PRESETS_BUTTON_TEXT, presetsButtonText) + args.putInt(ARG_CUSTOM_BUTTON_TEXT, customButtonText) + args.putInt(ARG_SELECTED_BUTTON_TEXT, selectedButtonText) + dialog.arguments = args + return dialog + } + + } + + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/EditTextPreference.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/EditTextPreference.kt new file mode 100644 index 000000000..97ffa92b8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/EditTextPreference.kt @@ -0,0 +1,22 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.PreferenceViewHolder +import io.legado.app.R + +class EditTextPreference(context: Context, attrs: AttributeSet) : + androidx.preference.EditTextPreference(context, attrs) { + + init { + // isPersistent = true + layoutResource = R.layout.view_preference + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + Preference.bindView(context, holder, icon, title, summary, null, null) + super.onBindViewHolder(holder) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/EditTextPreferenceDialog.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/EditTextPreferenceDialog.kt new file mode 100644 index 000000000..06a6bb960 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/EditTextPreferenceDialog.kt @@ -0,0 +1,29 @@ +package io.legado.app.ui.widget.prefs + +import android.app.Dialog +import android.os.Bundle +import androidx.preference.EditTextPreferenceDialogFragmentCompat +import androidx.preference.PreferenceDialogFragmentCompat +import io.legado.app.lib.theme.filletBackground + +class EditTextPreferenceDialog : EditTextPreferenceDialogFragmentCompat() { + + companion object { + + fun newInstance(key: String): EditTextPreferenceDialog { + val fragment = EditTextPreferenceDialog() + val b = Bundle(1) + b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key) + fragment.arguments = b + return fragment + } + + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.window?.setBackgroundDrawable(requireContext().filletBackground) + return dialog + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/IconListPreference.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/IconListPreference.kt new file mode 100644 index 000000000..7bfbe4c47 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/IconListPreference.kt @@ -0,0 +1,218 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.content.ContextWrapper +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.fragment.app.FragmentActivity +import androidx.preference.ListPreference +import androidx.preference.PreferenceViewHolder +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.BaseDialogFragment +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.RecyclerAdapter +import io.legado.app.databinding.DialogRecyclerViewBinding +import io.legado.app.databinding.ItemIconPreferenceBinding +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.getCompatDrawable +import io.legado.app.utils.setLayout +import io.legado.app.utils.viewbindingdelegate.viewBinding + + +class IconListPreference(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) { + private var iconNames: Array + private val mEntryDrawables = arrayListOf() + + init { + layoutResource = R.layout.view_preference + widgetLayoutResource = R.layout.view_icon + + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.IconListPreference, 0, 0) + + iconNames = try { + a.getTextArray(R.styleable.IconListPreference_icons) + } finally { + a.recycle() + } + + for (iconName in iconNames) { + val resId = context.resources + .getIdentifier(iconName.toString(), "mipmap", context.packageName) + var d: Drawable? = null + kotlin.runCatching { + d = context.getCompatDrawable(resId) + } + mEntryDrawables.add(d) + } + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val v = Preference.bindView( + context, + holder, + icon, + title, + summary, + widgetLayoutResource, + R.id.preview, + 50, + 50 + ) + if (v is ImageView) { + val selectedIndex = findIndexOfValue(value) + if (selectedIndex >= 0) { + val drawable = mEntryDrawables[selectedIndex] + v.setImageDrawable(drawable) + } + } + } + + override fun onClick() { + getActivity()?.let { + val dialog = IconDialog().apply { + val args = Bundle() + args.putString("value", value) + args.putCharSequenceArray("entries", entries) + args.putCharSequenceArray("entryValues", entryValues) + args.putCharSequenceArray("iconNames", iconNames) + arguments = args + onChanged = { value -> + this@IconListPreference.value = value + } + } + it.supportFragmentManager + .beginTransaction() + .add(dialog, getFragmentTag()) + .commitAllowingStateLoss() + } + } + + override fun onAttached() { + super.onAttached() + val fragment = + getActivity()?.supportFragmentManager?.findFragmentByTag(getFragmentTag()) as IconDialog? + fragment?.onChanged = { value -> + this@IconListPreference.value = value + } + } + + private fun getActivity(): FragmentActivity? { + val context = context + if (context is FragmentActivity) { + return context + } else if (context is ContextWrapper) { + val baseContext = context.baseContext + if (baseContext is FragmentActivity) { + return baseContext + } + } + return null + } + + private fun getFragmentTag(): String { + return "icon_$key" + } + + class IconDialog : BaseDialogFragment(R.layout.dialog_recycler_view) { + + var onChanged: ((value: String) -> Unit)? = null + var dialogValue: String? = null + var dialogEntries: Array? = null + var dialogEntryValues: Array? = null + var dialogIconNames: Array? = null + private val binding by viewBinding(DialogRecyclerViewBinding::bind) + + override fun onStart() { + super.onStart() + setLayout( + 0.8f, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + binding.toolBar.setBackgroundColor(primaryColor) + binding.toolBar.setTitle(R.string.change_icon) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + val adapter = Adapter(requireContext()) + binding.recyclerView.adapter = adapter + arguments?.let { + dialogValue = it.getString("value") + dialogEntries = it.getCharSequenceArray("entries") + dialogEntryValues = it.getCharSequenceArray("entryValues") + dialogIconNames = it.getCharSequenceArray("iconNames") + dialogEntryValues?.let { values -> + adapter.setItems(values.toList()) + } + } + } + + + inner class Adapter(context: Context) : + RecyclerAdapter(context) { + + override fun getViewBinding(parent: ViewGroup): ItemIconPreferenceBinding { + return ItemIconPreferenceBinding.inflate(inflater, parent, false) + } + + override fun convert( + holder: ItemViewHolder, + binding: ItemIconPreferenceBinding, + item: CharSequence, + payloads: MutableList + ) { + binding.run { + val index = findIndexOfValue(item.toString()) + dialogEntries?.let { + label.text = it[index] + } + dialogIconNames?.let { + val resId = context.resources + .getIdentifier(it[index].toString(), "mipmap", context.packageName) + val d = try { + context.getCompatDrawable(resId) + } catch (e: Exception) { + null + } + d?.let { + icon.setImageDrawable(d) + } + } + label.isChecked = item.toString() == dialogValue + root.setOnClickListener { + onChanged?.invoke(item.toString()) + this@IconDialog.dismissAllowingStateLoss() + } + } + } + + override fun registerListener( + holder: ItemViewHolder, + binding: ItemIconPreferenceBinding + ) { + holder.itemView.setOnClickListener { + getItem(holder.layoutPosition)?.let { + onChanged?.invoke(it.toString()) + } + } + } + + private fun findIndexOfValue(value: String?): Int { + dialogEntryValues?.let { values -> + for (i in values.indices.reversed()) { + if (values[i] == value) { + return i + } + } + } + return -1 + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/ListPreferenceDialog.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/ListPreferenceDialog.kt new file mode 100644 index 000000000..3c324601e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/ListPreferenceDialog.kt @@ -0,0 +1,29 @@ +package io.legado.app.ui.widget.prefs + +import android.app.Dialog +import android.os.Bundle +import androidx.preference.ListPreferenceDialogFragmentCompat +import androidx.preference.PreferenceDialogFragmentCompat +import io.legado.app.lib.theme.filletBackground + +class ListPreferenceDialog : ListPreferenceDialogFragmentCompat() { + + companion object { + + fun newInstance(key: String?): ListPreferenceDialog { + val fragment = ListPreferenceDialog() + val b = Bundle(1) + b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key) + fragment.arguments = b + return fragment + } + + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.window?.setBackgroundDrawable(requireContext().filletBackground) + return dialog + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/MultiSelectListPreferenceDialog.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/MultiSelectListPreferenceDialog.kt new file mode 100644 index 000000000..62c057f74 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/MultiSelectListPreferenceDialog.kt @@ -0,0 +1,32 @@ +package io.legado.app.ui.widget.prefs + +import android.app.Dialog +import android.os.Bundle +import androidx.preference.MultiSelectListPreferenceDialogFragmentCompat +import androidx.preference.PreferenceDialogFragmentCompat +import io.legado.app.lib.theme.filletBackground + +class MultiSelectListPreferenceDialog : MultiSelectListPreferenceDialogFragmentCompat() { + + companion object { + + fun newInstance(key: String?): MultiSelectListPreferenceDialog { + val fragment = + MultiSelectListPreferenceDialog() + val b = Bundle(1) + b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key) + fragment.arguments = b + return fragment + } + + } + + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.window?.setBackgroundDrawable(requireContext().filletBackground) + return dialog + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/NameListPreference.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/NameListPreference.kt new file mode 100644 index 000000000..cf06ec6f8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/NameListPreference.kt @@ -0,0 +1,48 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.ListPreference +import androidx.preference.PreferenceViewHolder +import io.legado.app.R +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.utils.ColorUtils + + +class NameListPreference(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) { + + private val isBottomBackground: Boolean + + init { + layoutResource = R.layout.view_preference + widgetLayoutResource = R.layout.item_fillet_text + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference) + isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false) + typedArray.recycle() + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + val v = Preference.bindView( + context, + holder, + icon, + title, + summary, + widgetLayoutResource, + R.id.text_view, + isBottomBackground = isBottomBackground + ) + if (v is TextView) { + v.text = entry + if (isBottomBackground) { + val bgColor = context.bottomBackground + val pTextColor = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor)) + v.setTextColor(pTextColor) + } + } + super.onBindViewHolder(holder) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/Preference.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/Preference.kt new file mode 100644 index 000000000..6cd98ce18 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/Preference.kt @@ -0,0 +1,130 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.preference.PreferenceViewHolder +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.lib.theme.getPrimaryTextColor +import io.legado.app.lib.theme.getSecondaryTextColor +import io.legado.app.utils.ColorUtils +import splitties.views.onLongClick +import kotlin.math.roundToInt + +class Preference(context: Context, attrs: AttributeSet) : + androidx.preference.Preference(context, attrs) { + + private var onLongClick: ((preference: Preference) -> Boolean)? = null + private val isBottomBackground: Boolean + + init { + layoutResource = R.layout.view_preference + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference) + isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false) + typedArray.recycle() + } + + companion object { + + fun bindView( + context: Context, + it: PreferenceViewHolder?, + icon: Drawable?, + title: CharSequence?, + summary: CharSequence?, + weightLayoutRes: Int? = null, + viewId: Int? = null, + weightWidth: Int = 0, + weightHeight: Int = 0, + isBottomBackground: Boolean = false + ): T? { + if (it == null) return null + val tvTitle = it.findViewById(R.id.preference_title) as TextView + tvTitle.text = title + tvTitle.isVisible = title != null && title.isNotEmpty() + val tvSummary = it.findViewById(R.id.preference_desc) as? TextView + tvSummary?.let { + tvSummary.text = summary + tvSummary.isGone = summary.isNullOrEmpty() + } + if (isBottomBackground && !tvTitle.isInEditMode) { + val isLight = ColorUtils.isColorLight(context.bottomBackground) + val pTextColor = context.getPrimaryTextColor(isLight) + tvTitle.setTextColor(pTextColor) + val sTextColor = context.getSecondaryTextColor(isLight) + tvSummary?.setTextColor(sTextColor) + } + val iconView = it.findViewById(R.id.preference_icon) + if (iconView is ImageView) { + iconView.isVisible = icon != null + iconView.setImageDrawable(icon) + iconView.setColorFilter(context.accentColor) + } + + if (weightLayoutRes != null && weightLayoutRes != 0 && viewId != null && viewId != 0) { + val lay = it.findViewById(R.id.preference_widget) + if (lay is FrameLayout) { + var needRequestLayout = false + var v = it.itemView.findViewById(viewId) + if (v == null) { + val inflater: LayoutInflater = LayoutInflater.from(context) + val childView = inflater.inflate(weightLayoutRes, null) + lay.removeAllViews() + lay.addView(childView) + lay.isVisible = true + v = lay.findViewById(viewId) + } else + needRequestLayout = true + + if (weightWidth > 0 || weightHeight > 0) { + val lp = lay.layoutParams + if (weightHeight > 0) + lp.height = + (context.resources.displayMetrics.density * weightHeight).roundToInt() + if (weightWidth > 0) + lp.width = + (context.resources.displayMetrics.density * weightWidth).roundToInt() + lay.layoutParams = lp + } else if (needRequestLayout) + v.requestLayout() + + return v + } + } + + return null + } + + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + bindView( + context, + holder, + icon, + title, + summary, + isBottomBackground = isBottomBackground + ) + super.onBindViewHolder(holder) + onLongClick?.let { listener -> + holder.itemView.onLongClick { + listener.invoke(this) + } + } + } + + fun onLongClick(listener: (preference: Preference) -> Boolean) { + onLongClick = listener + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/PreferenceCategory.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/PreferenceCategory.kt new file mode 100644 index 000000000..c0c8fae2d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/PreferenceCategory.kt @@ -0,0 +1,58 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceViewHolder +import io.legado.app.R +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.backgroundColor +import io.legado.app.utils.ColorUtils + + +class PreferenceCategory(context: Context, attrs: AttributeSet) : + PreferenceCategory(context, attrs) { + + init { + isPersistent = true + layoutResource = R.layout.view_preference_category + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val view = holder.findViewById(R.id.preference_title) + if (view is TextView) { // && !view.isInEditMode + view.text = title + if (view.isInEditMode) return + view.setTextColor(context.accentColor) + view.isVisible = title != null && title.isNotEmpty() + + val da = holder.findViewById(R.id.preference_divider_above) + val dividerColor = if (AppConfig.isNightTheme) { + ColorUtils.withAlpha( + ColorUtils.shiftColor(context.backgroundColor, 1.05f), + 0.5f + ) + } else { + ColorUtils.withAlpha( + ColorUtils.shiftColor(context.backgroundColor, 0.95f), + 0.5f + ) + } + if (da is View) { + da.setBackgroundColor(dividerColor) + da.isVisible = holder.isDividerAllowedAbove + } + val db = holder.findViewById(R.id.preference_divider_below) + if (db is View) { + db.setBackgroundColor(dividerColor) + db.isVisible = holder.isDividerAllowedBelow + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/prefs/SwitchPreference.kt b/app/src/main/java/io/legado/app/ui/widget/prefs/SwitchPreference.kt new file mode 100644 index 000000000..3c4e29aa9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/prefs/SwitchPreference.kt @@ -0,0 +1,51 @@ +package io.legado.app.ui.widget.prefs + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.SwitchCompat +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreferenceCompat +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint + +class SwitchPreference(context: Context, attrs: AttributeSet) : + SwitchPreferenceCompat(context, attrs) { + + private val isBottomBackground: Boolean + private var onLongClick: ((preference: SwitchPreference) -> Boolean)? = null + + init { + layoutResource = R.layout.view_preference + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference) + isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false) + typedArray.recycle() + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + val v = Preference.bindView( + context, + holder, + icon, + title, + summary, + widgetLayoutResource, + R.id.switchWidget, + isBottomBackground = isBottomBackground + ) + if (v is SwitchCompat && !v.isInEditMode) { + v.applyTint(context.accentColor) + } + super.onBindViewHolder(holder) + onLongClick?.let { listener -> + holder.itemView.setOnLongClickListener { + listener.invoke(this) + } + } + } + + fun onLongClick(listener: (preference: SwitchPreference) -> Boolean) { + onLongClick = listener + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/DividerNoLast.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/DividerNoLast.kt new file mode 100644 index 000000000..c1db3b6fc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/DividerNoLast.kt @@ -0,0 +1,166 @@ +package io.legado.app.ui.widget.recycler + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import timber.log.Timber +import kotlin.math.roundToInt + + +/** + * 不画最后一条分隔线 + */ +@Suppress("MemberVisibilityCanBePrivate", "RedundantRequireNotNullCall", "unused") +class DividerNoLast(context: Context, orientation: Int) : + RecyclerView.ItemDecoration() { + + companion object { + const val HORIZONTAL = LinearLayout.HORIZONTAL + const val VERTICAL = LinearLayout.VERTICAL + } + + private val attrs = intArrayOf(android.R.attr.listDivider) + + private var mDivider: Drawable? = null + + /** + * Current orientation. Either [.HORIZONTAL] or [.VERTICAL]. + */ + private var mOrientation = 0 + + private val mBounds = Rect() + + init { + val a = context.obtainStyledAttributes(attrs) + mDivider = a.getDrawable(0) + if (mDivider == null) { + Timber.w("@android:attr/listDivider was not set in the theme used for this DividerItemDecoration. Please set that attribute all call setDrawable()") + } + a.recycle() + setOrientation(orientation) + } + + /** + * Sets the orientation for this divider. This should be called if + * [RecyclerView.LayoutManager] changes orientation. + * + * @param orientation [.HORIZONTAL] or [.VERTICAL] + */ + fun setOrientation(orientation: Int) { + require(!(orientation != HORIZONTAL && orientation != VERTICAL)) { "Invalid orientation. It should be either HORIZONTAL or VERTICAL" } + mOrientation = orientation + } + + /** + * Sets the [Drawable] for this divider. + * + * @param drawable Drawable that should be used as a divider. + */ + fun setDrawable(drawable: Drawable) { + requireNotNull(drawable) { "Drawable cannot be null." } + mDivider = drawable + } + + /** + * @return the [Drawable] for this divider. + */ + fun getDrawable(): Drawable? { + return mDivider + } + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (parent.layoutManager == null || mDivider == null) { + return + } + if (mOrientation == VERTICAL) { + drawVertical(c, parent) + } else { + drawHorizontal(c, parent) + } + } + + private fun drawVertical( + canvas: Canvas, + parent: RecyclerView + ) { + canvas.save() + val left: Int + val right: Int + if (parent.clipToPadding) { + left = parent.paddingLeft + right = parent.width - parent.paddingRight + canvas.clipRect( + left, parent.paddingTop, right, + parent.height - parent.paddingBottom + ) + } else { + left = 0 + right = parent.width + } + val childCount = parent.childCount + for (i in 0 until childCount - 1) { + val child = parent.getChildAt(i) + parent.getDecoratedBoundsWithMargins(child, mBounds) + val bottom = mBounds.bottom + child.translationY.roundToInt() + val top = bottom - mDivider!!.intrinsicHeight + mDivider!!.setBounds(left, top, right, bottom) + mDivider!!.draw(canvas) + } + canvas.restore() + } + + private fun drawHorizontal(canvas: Canvas, parent: RecyclerView) { + canvas.save() + val top: Int + val bottom: Int + if (parent.clipToPadding) { + top = parent.paddingTop + bottom = parent.height - parent.paddingBottom + canvas.clipRect( + parent.paddingLeft, top, + parent.width - parent.paddingRight, bottom + ) + } else { + top = 0 + bottom = parent.height + } + val childCount = parent.childCount + for (i in 0 until childCount - 1) { + val child = parent.getChildAt(i) + parent.layoutManager!!.getDecoratedBoundsWithMargins(child, mBounds) + val right = mBounds.right + child.translationX.roundToInt() + val left = right - mDivider!!.intrinsicWidth + mDivider!!.setBounds(left, top, right, bottom) + mDivider!!.draw(canvas) + } + canvas.restore() + } + + override fun getItemOffsets( + outRect: Rect, view: View, parent: RecyclerView, + state: RecyclerView.State + ) { + if (mDivider == null) { + outRect[0, 0, 0] = 0 + return + } + + if (mOrientation == VERTICAL) { + outRect[0, 0, 0] = mDivider!!.intrinsicHeight + } else { + val childAdapterPosition = parent.getChildAdapterPosition(view) + val lastCount = parent.adapter!!.itemCount - 1 + //如果不是最后一条 正常赋值 如果是最后一条 赋值为0 + if (childAdapterPosition != lastCount) { + outRect[0, 0, mDivider!!.intrinsicWidth] = 0 + } else { + outRect[0, 0, 0] = 0 + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/DragSelectTouchHelper.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/DragSelectTouchHelper.kt new file mode 100644 index 000000000..129e0c9eb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/DragSelectTouchHelper.kt @@ -0,0 +1,1001 @@ +/* + * Copyright 2020 Mupceet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.recycler + +import android.content.res.Resources +import android.text.TextUtils +import android.util.DisplayMetrics +import android.util.TypedValue +import android.view.MotionEvent +import android.view.View +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener +import io.legado.app.BuildConfig +import io.legado.app.ui.widget.recycler.DragSelectTouchHelper.AdvanceCallback.Mode +import timber.log.Timber +import java.util.* +import kotlin.math.max +import kotlin.math.min + +/** + * @author mupceet + * !autoChangeMode +-------------------+ inactiveSelect() + * +------------------------------------> | | <--------------------+ + * | | Normal | | + * | activeDragSelect(position) | | activeSlideSelect() | + * | +------------------------------ | | ----------+ | + * | v +-------------------+ v | + * +-------------------+ autoChangeMode +-----------------------+ + * | Drag From Disable | ----------------------------------------------> | | + * +-------------------+ | | + * | | | | + * | | activeDragSelect(position) && allowDragInSlide | Slide | + * | | <---------------------------------------------- | | + * | Drag From Slide | | | + * | | | | + * | | ----------------------------------------------> | | + * +-------------------+ +-----------------------+ + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +class DragSelectTouchHelper( + /** + * Developer callback which controls the behavior of DragSelectTouchHelper. + */ + private val mCallback: Callback, +) { + + companion object { + private const val TAG = "DSTH" + private const val MAX_HOTSPOT_RATIO = 0.5f + private val DEFAULT_EDGE_TYPE = EdgeType.INSIDE_EXTEND + private const val DEFAULT_HOTSPOT_RATIO = 0.2f + private const val DEFAULT_HOTSPOT_OFFSET = 0 + private const val DEFAULT_MAX_SCROLL_VELOCITY = 10 + private const val SELECT_STATE_NORMAL = 0x00 + private const val SELECT_STATE_SLIDE = 0x01 + private const val SELECT_STATE_DRAG_FROM_NORMAL = 0x10 + private const val SELECT_STATE_DRAG_FROM_SLIDE = 0x11 + } + + private val mDisplayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics + + /** + * Start of the slide area. + */ + private var mSlideAreaLeft = 0f + + /** + * End of the slide area. + */ + private var mSlideAreaRight = 0f + + /** + * The hotspot height by the ratio of RecyclerView. + */ + private var mHotspotHeightRatio = 0f + + /** + * The hotspot height. + */ + private var mHotspotHeight = 0f + + /** + * The hotspot offset. + */ + private var mHotspotOffset = 0f + + /** + * Whether should continue scrolling when move outside top hotspot region. + */ + private var mScrollAboveTopRegion = false + + /** + * Whether should continue scrolling when move outside bottom hotspot region. + */ + private var mScrollBelowBottomRegion = false + + /** + * The maximum velocity of auto scrolling. + */ + private var mMaximumVelocity = 0 + + /** + * Whether should auto enter slide mode after drag select finished. + */ + private var mShouldAutoChangeState = false + + /** + * Whether can drag selection in slide select mode. + */ + private var mIsAllowDragInSlideState = false + private var mRecyclerView: RecyclerView? = null + + /** + * The coordinate of hotspot area. + */ + private var mTopRegionFrom = -1f + private var mTopRegionTo = -1f + private var mBottomRegionFrom = -1f + private var mBottomRegionTo = -1f + private val mOnLayoutChangeListener = + View.OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + if (oldLeft != left || oldRight != right || oldTop != top || oldBottom != bottom) { + if (v === mRecyclerView) { + Logger.i( + "onLayoutChange:new: " + + left + " " + top + " " + right + " " + bottom + ) + Logger.i( + "onLayoutChange:old: " + + oldLeft + " " + oldTop + " " + oldRight + " " + oldBottom + ) + init(bottom - top) + } + } + } + + /** + * The current mode of selection. + */ + private var mSelectState = SELECT_STATE_NORMAL + + /** + * Whether is in top hotspot area. + */ + private var mIsInTopHotspot = false + + /** + * Whether is in bottom hotspot area. + */ + private var mIsInBottomHotspot = false + + /** + * Indicates automatically scroll. + */ + private var mIsScrolling = false + + /** + * The actual speed of the current moment. + */ + private var mScrollDistance = 0 + + /** + * The reference coordinate for the action start, used to avoid reverse scrolling. + */ + private var mDownY = Float.MIN_VALUE + + /** + * The reference coordinates for the last action. + */ + private var mLastX = Float.MIN_VALUE + private var mLastY = Float.MIN_VALUE + + /** + * The selected items position. + */ + private var mStart = RecyclerView.NO_POSITION + private var mEnd = RecyclerView.NO_POSITION + private var mLastRealStart = RecyclerView.NO_POSITION + private var mLastRealEnd = RecyclerView.NO_POSITION + private var mSlideStateStartPosition = RecyclerView.NO_POSITION + private var mHaveCalledSelectStart = false + private val mScrollRunnable: Runnable by lazy { + object : Runnable { + override fun run() { + if (mIsScrolling) { + scrollBy(mScrollDistance) + ViewCompat.postOnAnimation(mRecyclerView!!, this) + } + } + } + } + private val mOnItemTouchListener: OnItemTouchListener by lazy { + object : OnItemTouchListener { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + Logger.d( + "onInterceptTouchEvent: x:" + e.x + ",y:" + e.y + + ", " + MotionEvent.actionToString(e.action) + ) + val adapter = rv.adapter + if (adapter == null || adapter.itemCount == 0) { + return false + } + var intercept = false + val action = e.action + when (action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN -> { + mDownY = e.y + // call the selection start's callback before moving + if (mSelectState == SELECT_STATE_SLIDE && isInSlideArea(e)) { + mSlideStateStartPosition = getItemPosition(rv, e) + if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { + mCallback.onSelectStart(mSlideStateStartPosition) + mHaveCalledSelectStart = true + } + intercept = true + } + } + MotionEvent.ACTION_MOVE -> if (mSelectState == SELECT_STATE_DRAG_FROM_NORMAL + || mSelectState == SELECT_STATE_DRAG_FROM_SLIDE + ) { + Logger.i("onInterceptTouchEvent: drag mode move") + intercept = true + } + MotionEvent.ACTION_UP -> { + if (mSelectState == SELECT_STATE_DRAG_FROM_NORMAL + || mSelectState == SELECT_STATE_DRAG_FROM_SLIDE + ) { + intercept = true + } + // finger is lifted before moving + if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { + selectFinished(mSlideStateStartPosition) + mSlideStateStartPosition = RecyclerView.NO_POSITION + } + // selection has triggered + if (mStart != RecyclerView.NO_POSITION) { + selectFinished(mEnd) + } + } + MotionEvent.ACTION_CANCEL -> { + if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { + selectFinished(mSlideStateStartPosition) + mSlideStateStartPosition = RecyclerView.NO_POSITION + } + if (mStart != RecyclerView.NO_POSITION) { + selectFinished(mEnd) + } + } + else -> { + } + } + // Intercept only when the selection is triggered + Logger.d("intercept result: $intercept") + return intercept + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + if (!isActivated) { + return + } + Logger.d( + "onTouchEvent: x:" + e.x + ",y:" + e.y + + ", " + MotionEvent.actionToString(e.action) + ) + val action = e.action + when (action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_MOVE -> { + if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { + selectFirstItem(mSlideStateStartPosition) + // selection is triggered + mSlideStateStartPosition = RecyclerView.NO_POSITION + Logger.i("onTouchEvent: after slide mode down") + } + processAutoScroll(e) + if (!mIsInTopHotspot && !mIsInBottomHotspot) { + updateSelectedRange(rv, e) + } + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + if (mSlideStateStartPosition != RecyclerView.NO_POSITION) { + selectFirstItem(mSlideStateStartPosition) + // selection is triggered + mSlideStateStartPosition = RecyclerView.NO_POSITION + Logger.i("onTouchEvent: after slide mode down") + } + if (!mIsInTopHotspot && !mIsInBottomHotspot) { + updateSelectedRange(rv, e) + } + selectFinished(mEnd) + } + } + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + if (disallowIntercept) { + inactiveSelect() + } + } + } + } + + init { + setHotspotRatio(DEFAULT_HOTSPOT_RATIO) + setHotspotOffset(DEFAULT_HOTSPOT_OFFSET) + setMaximumVelocity(DEFAULT_MAX_SCROLL_VELOCITY) + setEdgeType(DEFAULT_EDGE_TYPE) + setAutoEnterSlideState(false) + setAllowDragInSlideState(false) + setSlideArea(0, 0) + } + + /** + * Attaches the DragSelectTouchHelper to the provided RecyclerView. If TouchHelper is already + * attached to a RecyclerView, it will first detach from the previous one. You can call this + * method with `null` to detach it from the current RecyclerView. + * + * @param recyclerView The RecyclerView instance to which you want to add this helper or + * `null` if you want to remove DragSelectTouchHelper from the + * current RecyclerView. + */ + fun attachToRecyclerView(recyclerView: RecyclerView?) { + if (mRecyclerView === recyclerView) { + return // nothing to do + } + mRecyclerView?.removeOnItemTouchListener(mOnItemTouchListener) + mRecyclerView = recyclerView + mRecyclerView?.let { + it.addOnItemTouchListener(mOnItemTouchListener) + it.addOnLayoutChangeListener(mOnLayoutChangeListener) + } + } + + /** + * Activate the slide selection mode. + */ + fun activeSlideSelect() { + activeSelectInternal(RecyclerView.NO_POSITION) + } + + /** + * Activate the selection mode with selected item position. Normally called on long press. + * + * @param position Indicates the position of selected item. + */ + fun activeDragSelect(position: Int) { + activeSelectInternal(position) + } + + /** + * Exit the selection mode. + */ + fun inactiveSelect() { + if (isActivated) { + selectFinished(mEnd) + } else { + selectFinished(RecyclerView.NO_POSITION) + } + Logger.logSelectStateChange(mSelectState, SELECT_STATE_NORMAL) + mSelectState = SELECT_STATE_NORMAL + } + + /** + * To determine whether it is in the selection mode. + * + * @return true if is in the selection mode. + */ + val isActivated: Boolean + get() = mSelectState != SELECT_STATE_NORMAL + + /** + * Sets hotspot height by ratio of RecyclerView. + * + * @param ratio range (0, 0.5). + * @return The select helper, which may used to chain setter calls. + */ + fun setHotspotRatio(ratio: Float): DragSelectTouchHelper { + mHotspotHeightRatio = ratio + return this + } + + /** + * Sets hotspot height. + * + * @param hotspotHeight hotspot height which unit is dp. + * @return The select helper, which may used to chain setter calls. + */ + fun setHotspotHeight(hotspotHeight: Int): DragSelectTouchHelper { + mHotspotHeight = dp2px(hotspotHeight.toFloat()).toFloat() + return this + } + + /** + * Sets hotspot offset. It don't need to be set if no special requirement. + * + * @param hotspotOffset hotspot offset which unit is dp. + * @return The select helper, which may used to chain setter calls. + */ + fun setHotspotOffset(hotspotOffset: Int): DragSelectTouchHelper { + mHotspotOffset = dp2px(hotspotOffset.toFloat()).toFloat() + return this + } + + /** + * Sets the activation edge type, one of: + * + * * [EdgeType.INSIDE] for edges that respond to touches inside + * the bounds of the host view. If touch moves outside the bounds, scrolling + * will stop. + * * [EdgeType.INSIDE_EXTEND] for inside edges that continued to + * scroll when touch moves outside the bounds of the host view. + * + * + * @param type The type of edge to use. + * @return The select helper, which may used to chain setter calls. + */ + fun setEdgeType(type: EdgeType?): DragSelectTouchHelper { + when (type) { + EdgeType.INSIDE -> { + mScrollAboveTopRegion = false + mScrollBelowBottomRegion = false + } + EdgeType.INSIDE_EXTEND -> { + mScrollAboveTopRegion = true + mScrollBelowBottomRegion = true + } + else -> { + mScrollAboveTopRegion = true + mScrollBelowBottomRegion = true + } + } + return this + } + + /** + * Sets sliding area's start and end, has been considered RTL situation + * + * @param startDp The start of the sliding area + * @param endDp The end of the sliding area + * @return The select helper, which may used to chain setter calls. + */ + fun setSlideArea(startDp: Int, endDp: Int): DragSelectTouchHelper { + if (!isRtl) { + mSlideAreaLeft = dp2px(startDp.toFloat()).toFloat() + mSlideAreaRight = dp2px(endDp.toFloat()).toFloat() + } else { + val displayWidth = mDisplayMetrics.widthPixels + mSlideAreaLeft = displayWidth - dp2px(endDp.toFloat()).toFloat() + mSlideAreaRight = displayWidth - dp2px(startDp.toFloat()).toFloat() + } + return this + } + + /** + * Sets the maximum velocity for scrolling + * + * @param velocity maximum velocity + * @return The select helper, which may used to chain setter calls. + */ + fun setMaximumVelocity(velocity: Int): DragSelectTouchHelper { + mMaximumVelocity = (velocity * mDisplayMetrics.density + 0.5f).toInt() + return this + } + + /** + * Sets whether should auto enter slide mode after drag select finished. + * It's usefully for LinearLayout RecyclerView. + * + * @param autoEnterSlideState should auto enter slide mode + * @return The select helper, which may used to chain setter calls. + */ + fun setAutoEnterSlideState(autoEnterSlideState: Boolean): DragSelectTouchHelper { + mShouldAutoChangeState = autoEnterSlideState + return this + } + + /** + * Sets whether can drag selection in slide select mode. + * It's usefully for LinearLayout RecyclerView. + * + * @param allowDragInSlideState allow drag selection in slide select mode + * @return The select helper, which may used to chain setter calls. + */ + fun setAllowDragInSlideState(allowDragInSlideState: Boolean): DragSelectTouchHelper { + mIsAllowDragInSlideState = allowDragInSlideState + return this + } + + private fun init(rvHeight: Int) { + if (mHotspotOffset >= rvHeight * MAX_HOTSPOT_RATIO) { + mHotspotOffset = rvHeight * MAX_HOTSPOT_RATIO + } + // The height of hotspot area is not set, using (RV height x ratio) + if (mHotspotHeight <= 0) { + if (mHotspotHeightRatio <= 0 || mHotspotHeightRatio >= MAX_HOTSPOT_RATIO) { + mHotspotHeightRatio = DEFAULT_HOTSPOT_RATIO + } + mHotspotHeight = rvHeight * mHotspotHeightRatio + } else { + if (mHotspotHeight >= rvHeight * MAX_HOTSPOT_RATIO) { + mHotspotHeight = rvHeight * MAX_HOTSPOT_RATIO + } + } + mTopRegionFrom = mHotspotOffset + mTopRegionTo = mTopRegionFrom + mHotspotHeight + mBottomRegionTo = rvHeight - mHotspotOffset + mBottomRegionFrom = mBottomRegionTo - mHotspotHeight + if (mTopRegionTo > mBottomRegionFrom) { + mBottomRegionFrom = (rvHeight shr 1.toFloat().toInt()).toFloat() + mTopRegionTo = mBottomRegionFrom + } + Logger.d( + "Hotspot: [" + mTopRegionFrom + ", " + mTopRegionTo + "], [" + + mBottomRegionFrom + ", " + mBottomRegionTo + "]" + ) + } + + private fun activeSelectInternal(position: Int) { + // We should initialize the hotspot here, because its data may be delayed load + mRecyclerView?.let { + init(it.height) + } + if (position == RecyclerView.NO_POSITION) { + Logger.logSelectStateChange(mSelectState, SELECT_STATE_SLIDE) + mSelectState = SELECT_STATE_SLIDE + } else { + if (!mHaveCalledSelectStart) { + mCallback.onSelectStart(position) + mHaveCalledSelectStart = true + } + if (mSelectState == SELECT_STATE_SLIDE) { + if (mIsAllowDragInSlideState && selectFirstItem(position)) { + Logger.logSelectStateChange(mSelectState, SELECT_STATE_DRAG_FROM_SLIDE) + mSelectState = SELECT_STATE_DRAG_FROM_SLIDE + } + } else if (mSelectState == SELECT_STATE_NORMAL) { + if (selectFirstItem(position)) { + Logger.logSelectStateChange(mSelectState, SELECT_STATE_DRAG_FROM_NORMAL) + mSelectState = SELECT_STATE_DRAG_FROM_NORMAL + } + } else { + Logger.e("activeSelect in unexpected state: $mSelectState") + } + } + } + + private fun selectFirstItem(position: Int): Boolean { + val selectFirstItemSucceed = mCallback.onSelectChange(position, true) + // The drag select feature is only available if the first item is available for selection + if (selectFirstItemSucceed) { + mStart = position + mEnd = position + mLastRealStart = position + mLastRealEnd = position + } + return selectFirstItemSucceed + } + + private fun updateSelectedRange(rv: RecyclerView, e: MotionEvent) { + updateSelectedRange(rv, e.x, e.y) + } + + private fun updateSelectedRange(rv: RecyclerView, x: Float, y: Float) { + val position = getItemPosition(rv, x, y) + if (position != RecyclerView.NO_POSITION && mEnd != position) { + mEnd = position + notifySelectRangeChange() + } + } + + private fun notifySelectRangeChange() { + if (mStart == RecyclerView.NO_POSITION || mEnd == RecyclerView.NO_POSITION) { + return + } + val newStart: Int = min(mStart, mEnd) + val newEnd: Int = max(mStart, mEnd) + if (mLastRealStart == RecyclerView.NO_POSITION || mLastRealEnd == RecyclerView.NO_POSITION) { + if (newEnd - newStart == 1) { + notifySelectChange(newStart, newStart, true) + } else { + notifySelectChange(newStart, newEnd, true) + } + } else { + if (newStart > mLastRealStart) { + notifySelectChange(mLastRealStart, newStart - 1, false) + } else if (newStart < mLastRealStart) { + notifySelectChange(newStart, mLastRealStart - 1, true) + } + if (newEnd > mLastRealEnd) { + notifySelectChange(mLastRealEnd + 1, newEnd, true) + } else if (newEnd < mLastRealEnd) { + notifySelectChange(newEnd + 1, mLastRealEnd, false) + } + } + mLastRealStart = newStart + mLastRealEnd = newEnd + } + + private fun notifySelectChange(start: Int, end: Int, newState: Boolean) { + for (i in start..end) { + mCallback.onSelectChange(i, newState) + } + } + + private fun selectFinished(lastItem: Int) { + if (lastItem != RecyclerView.NO_POSITION) { + mCallback.onSelectEnd(lastItem) + } + mStart = RecyclerView.NO_POSITION + mEnd = RecyclerView.NO_POSITION + mLastRealStart = RecyclerView.NO_POSITION + mLastRealEnd = RecyclerView.NO_POSITION + mHaveCalledSelectStart = false + mIsInTopHotspot = false + mIsInBottomHotspot = false + stopAutoScroll() + when (mSelectState) { + SELECT_STATE_DRAG_FROM_NORMAL -> mSelectState = if (mShouldAutoChangeState) { + Logger.logSelectStateChange( + mSelectState, + SELECT_STATE_SLIDE + ) + SELECT_STATE_SLIDE + } else { + Logger.logSelectStateChange( + mSelectState, + SELECT_STATE_NORMAL + ) + SELECT_STATE_NORMAL + } + SELECT_STATE_DRAG_FROM_SLIDE -> { + Logger.logSelectStateChange(mSelectState, SELECT_STATE_SLIDE) + mSelectState = SELECT_STATE_SLIDE + } + else -> { + } + } + } + + /** + * Process motion event, according to the location to determine whether to scroll + */ + private fun processAutoScroll(e: MotionEvent) { + val y = e.y + if (y in mTopRegionFrom..mTopRegionTo && y < mDownY) { + mLastX = e.x + mLastY = e.y + val scrollDistanceFactor = (y - mTopRegionTo) / mHotspotHeight + mScrollDistance = (mMaximumVelocity * scrollDistanceFactor).toInt() + if (!mIsInTopHotspot) { + mIsInTopHotspot = true + startAutoScroll() + mDownY = mTopRegionTo + } + } else if (mScrollAboveTopRegion && y < mTopRegionFrom && mIsInTopHotspot) { + mLastX = e.x + mLastY = mTopRegionFrom + // Use the maximum speed + mScrollDistance = mMaximumVelocity * -1 + startAutoScroll() + } else if (y in mBottomRegionFrom..mBottomRegionTo && y > mDownY) { + mLastX = e.x + mLastY = e.y + val scrollDistanceFactor = (y - mBottomRegionFrom) / mHotspotHeight + mScrollDistance = (mMaximumVelocity * scrollDistanceFactor).toInt() + if (!mIsInBottomHotspot) { + mIsInBottomHotspot = true + startAutoScroll() + mDownY = mBottomRegionFrom + } + } else if (mScrollBelowBottomRegion && y > mBottomRegionTo && mIsInBottomHotspot) { + mLastX = e.x + mLastY = mBottomRegionTo + // Use the maximum speed + mScrollDistance = mMaximumVelocity + startAutoScroll() + } else { + mIsInTopHotspot = false + mIsInBottomHotspot = false + mLastX = Float.MIN_VALUE + mLastY = Float.MIN_VALUE + stopAutoScroll() + } + } + + private fun startAutoScroll() { + if (!mIsScrolling) { + mIsScrolling = true + mRecyclerView!!.removeCallbacks(mScrollRunnable) + ViewCompat.postOnAnimation(mRecyclerView!!, mScrollRunnable) + } + } + + private fun stopAutoScroll() { + if (mIsScrolling) { + mIsScrolling = false + mRecyclerView?.removeCallbacks(mScrollRunnable) + } + } + + private fun scrollBy(distance: Int) { + val scrollDistance: Int = + if (distance > 0) { + min(distance, mMaximumVelocity) + } else { + max(distance, -mMaximumVelocity) + } + mRecyclerView!!.scrollBy(0, scrollDistance) + if (mLastX != Float.MIN_VALUE && mLastY != Float.MIN_VALUE) { + updateSelectedRange(mRecyclerView!!, mLastX, mLastY) + } + } + + private fun dp2px(dpVal: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dpVal, mDisplayMetrics + ).toInt() + } + + private val isRtl: Boolean + get() = (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) + == View.LAYOUT_DIRECTION_RTL) + + private fun isInSlideArea(e: MotionEvent): Boolean { + val x = e.x + return x > mSlideAreaLeft && x < mSlideAreaRight + } + + private fun getItemPosition(rv: RecyclerView, e: MotionEvent): Int { + return getItemPosition(rv, e.x, e.y) + } + + private fun getItemPosition(rv: RecyclerView, x: Float, y: Float): Int { + val v = rv.findChildViewUnder(x, y) + if (v == null) { + val layoutManager = rv.layoutManager + if (layoutManager is GridLayoutManager) { + val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() + val lastItemPosition = layoutManager.getItemCount() - 1 + if (lastItemPosition == lastVisibleItemPosition) { + return lastItemPosition + } + } + return RecyclerView.NO_POSITION + } + return rv.getChildAdapterPosition(v) + } + + /** + * Edge type that specifies an activation area starting at the view bounds and extending inward. + */ + enum class EdgeType { + /** + * After activation begins, moving outside the view bounds will stop scrolling. + */ + INSIDE, + + /** + * After activation begins, moving outside the view bounds will continue scrolling. + */ + INSIDE_EXTEND + } + + /** + * This class is the contract between DragSelectTouchHelper and your application. It lets you + * update adapter when selection start/end and state changed. + */ + abstract class Callback { + /** + * Called when changing item state. + * + * @param position this item want to change the state to new state. + * @param isSelected true if the position should be selected, false otherwise. + * @return Whether to set the new state successfully. + */ + abstract fun onSelectChange(position: Int, isSelected: Boolean): Boolean + + /** + * Called when selection start. + * + * @param start the first selected item. + */ + open fun onSelectStart(start: Int) {} + + /** + * Called when selection end. + * + * @param end the last selected item. + */ + open fun onSelectEnd(end: Int) {} + } + + /** + * An advance Callback which provide 4 useful selection modes [Mode]. + * + * + * Note: Since the state of item may be repeatedly set, in order to improve efficiency, + * please process it in the Adapter + */ + abstract class AdvanceCallback : Callback { + private var mMode: Mode? = null + private var mOriginalSelection: MutableSet = mutableSetOf() + private var mFirstWasSelected = false + + /** + * Creates a SimpleCallback with default [Mode.SelectAndReverse]# mode. + * + * @see Mode + */ + constructor() { + setMode(Mode.SelectAndReverse) + } + + /** + * Creates a SimpleCallback with select mode. + * + * @param mode the initial select mode + * @see Mode + */ + constructor(mode: Mode?) { + setMode(mode) + } + + /** + * Sets the select mode. + * + * @param mode The type of select mode. + * @see Mode + */ + fun setMode(mode: Mode?) { + mMode = mode + } + + override fun onSelectStart(start: Int) { + mOriginalSelection.clear() + val selected = currentSelectedId() + if (selected != null) { + mOriginalSelection.addAll(selected) + } + mFirstWasSelected = mOriginalSelection.contains(getItemId(start)) + } + + override fun onSelectEnd(end: Int) { + mOriginalSelection.clear() + } + + override fun onSelectChange(position: Int, isSelected: Boolean): Boolean { + return when (mMode) { + Mode.SelectAndKeep -> { + updateSelectState(position, true) + } + Mode.SelectAndReverse -> { + updateSelectState(position, isSelected) + } + Mode.SelectAndUndo -> { + if (isSelected) { + updateSelectState(position, true) + } else { + updateSelectState( + position, + mOriginalSelection.contains(getItemId(position)) + ) + } + } + Mode.ToggleAndKeep -> { + updateSelectState(position, !mFirstWasSelected) + } + Mode.ToggleAndReverse -> { + if (isSelected) { + updateSelectState(position, !mFirstWasSelected) + } else { + updateSelectState(position, mFirstWasSelected) + } + } + Mode.ToggleAndUndo -> { + if (isSelected) { + updateSelectState(position, !mFirstWasSelected) + } else { + updateSelectState( + position, + mOriginalSelection.contains(getItemId(position)) + ) + } + } + else -> // SelectAndReverse Mode + updateSelectState(position, isSelected) + } + } + + /** + * Get the currently selected items when selecting first item. + * + * @return the currently selected item's id set. + */ + abstract fun currentSelectedId(): Set? + + /** + * Get the ID of the item. + * + * @param position item position to be judged. + * @return item's identity. + */ + abstract fun getItemId(position: Int): T + + /** + * Update the selection status of the position. + * + * @param position the position who's selection state changed. + * @param isSelected true if the position should be selected, false otherwise. + * @return Whether to set the state successfully. + */ + abstract fun updateSelectState(position: Int, isSelected: Boolean): Boolean + + /** + * Different existing selection modes + */ + enum class Mode { + /** + * Selects the first item and applies the same state to each item you go by + * and keep the state on move back + */ + SelectAndKeep, + + /** + * Selects the first item and applies the same state to each item you go by + * and applies inverted state on move back + */ + SelectAndReverse, + + /** + * Selects the first item and applies the same state to each item you go by + * and reverts to the original state on move back + */ + SelectAndUndo, + + /** + * Toggles the first item and applies the same state to each item you go by + * and keep the state on move back + */ + ToggleAndKeep, + + /** + * Toggles the first item and applies the same state to each item you go by + * and applies inverted state on move back + */ + ToggleAndReverse, + + /** + * Toggles the first item and applies the same state to each item you go by + * and reverts to the original state on move back + */ + ToggleAndUndo + } + } + + private object Logger { + private val DEBUG = BuildConfig.DEBUG + fun d(msg: String) { + Timber.d(msg) + } + + fun e(msg: String) { + Timber.e(msg) + } + + fun i(msg: String) { + Timber.i(msg) + } + + fun logSelectStateChange(before: Int, after: Int) { + i("Select state changed: " + stateName(before) + " --> " + stateName(after)) + } + + private fun stateName(state: Int): String { + return when (state) { + SELECT_STATE_NORMAL -> "NormalState" + SELECT_STATE_SLIDE -> "SlideState" + SELECT_STATE_DRAG_FROM_NORMAL -> "DragFromNormal" + SELECT_STATE_DRAG_FROM_SLIDE -> "DragFromSlide" + else -> "Unknown" + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/HeaderAdapterDataObserver.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/HeaderAdapterDataObserver.kt new file mode 100644 index 000000000..f8e2a0c8c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/HeaderAdapterDataObserver.kt @@ -0,0 +1,33 @@ +package io.legado.app.ui.widget.recycler + +import androidx.recyclerview.widget.RecyclerView + +internal class HeaderAdapterDataObserver( + private var adapterDataObserver: RecyclerView.AdapterDataObserver, + private var headerCount: Int +) : RecyclerView.AdapterDataObserver() { + override fun onChanged() { + adapterDataObserver.onChanged() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount) + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { + adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount, payload) + } + + // 当第n个数据被获取,更新第n+1个position + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + adapterDataObserver.onItemRangeInserted(positionStart + headerCount, itemCount) + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + adapterDataObserver.onItemRangeRemoved(positionStart + headerCount, itemCount) + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + super.onItemRangeMoved(fromPosition + headerCount, toPosition + headerCount, itemCount) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/ItemTouchCallback.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/ItemTouchCallback.kt new file mode 100644 index 000000000..8c8a0d907 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/ItemTouchCallback.kt @@ -0,0 +1,148 @@ +package io.legado.app.ui.widget.recycler + + +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout + +/** + * Created by GKF on 2018/3/16. + */ +@Suppress("MemberVisibilityCanBePrivate") +class ItemTouchCallback(private val callback: Callback) : ItemTouchHelper.Callback() { + + private var swipeRefreshLayout: SwipeRefreshLayout? = null + + /** + * 是否可以拖拽 + */ + var isCanDrag = false + + /** + * 是否可以被滑动 + */ + var isCanSwipe = false + + /** + * 当Item被长按的时候是否可以被拖拽 + */ + override fun isLongPressDragEnabled(): Boolean { + return isCanDrag + } + + /** + * Item是否可以被滑动(H:左右滑动,V:上下滑动) + */ + override fun isItemViewSwipeEnabled(): Boolean { + return isCanSwipe + } + + /** + * 当用户拖拽或者滑动Item的时候需要我们告诉系统滑动或者拖拽的方向 + */ + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val layoutManager = recyclerView.layoutManager + if (layoutManager is GridLayoutManager) {// GridLayoutManager + // flag如果值是0,相当于这个功能被关闭 + val dragFlag = + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT or ItemTouchHelper.UP or ItemTouchHelper.DOWN + val swipeFlag = 0 + // create make + return makeMovementFlags(dragFlag, swipeFlag) + } else if (layoutManager is LinearLayoutManager) {// linearLayoutManager + val linearLayoutManager = layoutManager as LinearLayoutManager? + val orientation = linearLayoutManager!!.orientation + + var dragFlag = 0 + var swipeFlag = 0 + + // 为了方便理解,相当于分为横着的ListView和竖着的ListView + if (orientation == LinearLayoutManager.HORIZONTAL) {// 如果是横向的布局 + swipeFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN + dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + } else if (orientation == LinearLayoutManager.VERTICAL) {// 如果是竖向的布局,相当于ListView + dragFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN + swipeFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + } + return makeMovementFlags(dragFlag, swipeFlag) + } + return 0 + } + + /** + * 当Item被拖拽的时候被回调 + * + * @param recyclerView recyclerView + * @param srcViewHolder 拖拽的ViewHolder + * @param targetViewHolder 目的地的viewHolder + */ + override fun onMove( + recyclerView: RecyclerView, + srcViewHolder: RecyclerView.ViewHolder, + targetViewHolder: RecyclerView.ViewHolder + ): Boolean { + val fromPosition: Int = srcViewHolder.adapterPosition + val toPosition: Int = targetViewHolder.adapterPosition + if (fromPosition < toPosition) { + for (i in fromPosition until toPosition) { + callback.swap(i, i + 1) + } + } else { + for (i in fromPosition downTo toPosition + 1) { + callback.swap(i, i - 1) + } + } + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + callback.onSwiped(viewHolder.adapterPosition) + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + val swiping = actionState == ItemTouchHelper.ACTION_STATE_DRAG + swipeRefreshLayout?.isEnabled = !swiping + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + callback.onClearView(recyclerView, viewHolder) + } + + interface Callback { + + /** + * 当某个Item被滑动删除的时候 + * + * @param adapterPosition item的position + */ + fun onSwiped(adapterPosition: Int) { + + } + + /** + * 当两个Item位置互换的时候被回调 + * + * @param srcPosition 拖拽的item的position + * @param targetPosition 目的地的Item的position + * @return 开发者处理了操作应该返回true,开发者没有处理就返回false + */ + fun swap(srcPosition: Int, targetPosition: Int): Boolean { + return true + } + + /** + * 手指松开 + */ + fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + + } + + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt new file mode 100644 index 000000000..c623938ae --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt @@ -0,0 +1,57 @@ +package io.legado.app.ui.widget.recycler + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import io.legado.app.R +import io.legado.app.databinding.ViewLoadMoreBinding +import io.legado.app.utils.invisible +import io.legado.app.utils.visible + +@Suppress("unused") +class LoadMoreView(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { + private val binding = ViewLoadMoreBinding.inflate(LayoutInflater.from(context), this) + var hasMore = true + private set + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + } + + fun startLoad() { + binding.tvText.invisible() + binding.rotateLoading.show() + } + + fun stopLoad() { + binding.rotateLoading.hide() + } + + fun hasMore() { + hasMore = true + binding.tvText.invisible() + binding.rotateLoading.show() + } + + fun noMore(msg: String? = null) { + hasMore = false + binding.rotateLoading.hide() + if (msg != null) { + binding.tvText.text = msg + } else { + binding.tvText.setText(R.string.bottom_line) + } + binding.tvText.visible() + } + + fun error(msg: String) { + hasMore = false + binding.rotateLoading.hide() + binding.tvText.text = msg + binding.tvText.visible() + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtPager2.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtPager2.kt new file mode 100644 index 000000000..1dc4d7b5f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtPager2.kt @@ -0,0 +1,48 @@ +package io.legado.app.ui.widget.recycler + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs + +class RecyclerViewAtPager2 : RecyclerView { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + private var startX = 0 + private var startY = 0 + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + startX = ev.x.toInt() + startY = ev.y.toInt() + parent.requestDisallowInterceptTouchEvent(true) + } + MotionEvent.ACTION_MOVE -> { + val endX = ev.x.toInt() + val endY = ev.y.toInt() + val disX = abs(endX - startX) + val disY = abs(endY - startY) + if (disX > disY) { + if (disX > 50) { + parent.requestDisallowInterceptTouchEvent(false) + } + } else { + parent.requestDisallowInterceptTouchEvent(true) + } + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(false) + } + return super.dispatchTouchEvent(ev) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/UpLinearLayoutManager.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/UpLinearLayoutManager.kt new file mode 100644 index 000000000..f0236bbea --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/UpLinearLayoutManager.kt @@ -0,0 +1,46 @@ +package io.legado.app.ui.widget.recycler + +import android.content.Context +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller + +@Suppress("MemberVisibilityCanBePrivate", "unused") +class UpLinearLayoutManager(val context: Context) : LinearLayoutManager(context) { + + fun smoothScrollToPosition(position: Int) { + smoothScrollToPosition(position, 0) + } + + fun smoothScrollToPosition(position: Int, offset: Int) { + val scroller = UpLinearSmoothScroller(context) + scroller.targetPosition = position + scroller.offset = offset + startSmoothScroll(scroller) + } + + class UpLinearSmoothScroller(context: Context?) : LinearSmoothScroller(context) { + var offset = 0 + + override fun getVerticalSnapPreference(): Int { + return SNAP_TO_START + } + + override fun getHorizontalSnapPreference(): Int { + return SNAP_TO_START + } + + override fun calculateDtToFit( + viewStart: Int, + viewEnd: Int, + boxStart: Int, + boxEnd: Int, + snapPreference: Int + ): Int { + if (snapPreference == SNAP_TO_START) { + return boxStart - viewStart + offset + } + throw IllegalArgumentException("snap preference should be SNAP_TO_START") + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/VerticalDivider.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/VerticalDivider.kt new file mode 100644 index 000000000..2ea450d4c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/VerticalDivider.kt @@ -0,0 +1,16 @@ +package io.legado.app.ui.widget.recycler + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DividerItemDecoration +import io.legado.app.R + +class VerticalDivider(context: Context) : DividerItemDecoration(context, VERTICAL) { + + init { + ContextCompat.getDrawable(context, R.drawable.ic_divider)?.let { + this.setDrawable(it) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt new file mode 100644 index 000000000..a7299f5e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt @@ -0,0 +1,165 @@ +package io.legado.app.ui.widget.recycler.scroller + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.annotation.ColorInt +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R + +@Suppress("MemberVisibilityCanBePrivate", "unused") +class FastScrollRecyclerView : RecyclerView { + + private lateinit var mFastScroller: FastScroller + + constructor(context: Context) : super(context) { + layout(context, null) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet, + defStyleAttr: Int = 0 + ) : super(context, attrs, defStyleAttr) { + layout(context, attrs) + } + + private fun layout(context: Context, attrs: AttributeSet?) { + mFastScroller = FastScroller(context, attrs) + mFastScroller.id = R.id.fast_scroller + } + + override fun setAdapter(adapter: Adapter<*>?) { + super.setAdapter(adapter) + if (adapter is FastScroller.SectionIndexer) { + setSectionIndexer(adapter as FastScroller.SectionIndexer?) + } else if (adapter == null) { + setSectionIndexer(null) + } + } + + + override fun setVisibility(visibility: Int) { + super.setVisibility(visibility) + mFastScroller.visibility = visibility + } + + + /** + * Set the [FastScroller.SectionIndexer] for the [FastScroller]. + * + * @param sectionIndexer The SectionIndexer that provides section text for the FastScroller + */ + fun setSectionIndexer(sectionIndexer: FastScroller.SectionIndexer?) { + mFastScroller.setSectionIndexer(sectionIndexer) + } + + + /** + * Set the enabled state of fast scrolling. + * + * @param enabled True to enable fast scrolling, false otherwise + */ + fun setFastScrollEnabled(enabled: Boolean) { + mFastScroller.isEnabled = enabled + } + + + /** + * Hide the scrollbar when not scrolling. + * + * @param hideScrollbar True to hide the scrollbar, false to show + */ + fun setHideScrollbar(hideScrollbar: Boolean) { + mFastScroller.setFadeScrollbar(hideScrollbar) + } + + /** + * Display a scroll track while scrolling. + * + * @param visible True to show scroll track, false to hide + */ + fun setTrackVisible(visible: Boolean) { + mFastScroller.setTrackVisible(visible) + } + + /** + * Set the color of the scroll track. + * + * @param color The color for the scroll track + */ + fun setTrackColor(@ColorInt color: Int) { + mFastScroller.setTrackColor(color) + } + + + /** + * Set the color for the scroll handle. + * + * @param color The color for the scroll handle + */ + fun setHandleColor(@ColorInt color: Int) { + mFastScroller.setHandleColor(color) + } + + + /** + * Show the section bubble while scrolling. + * + * @param visible True to show the bubble, false to hide + */ + fun setBubbleVisible(visible: Boolean) { + mFastScroller.setBubbleVisible(visible) + } + + + /** + * Set the background color of the index bubble. + * + * @param color The background color for the index bubble + */ + fun setBubbleColor(@ColorInt color: Int) { + mFastScroller.setBubbleColor(color) + } + + + /** + * Set the text color of the index bubble. + * + * @param color The text color for the index bubble + */ + fun setBubbleTextColor(@ColorInt color: Int) { + mFastScroller.setBubbleTextColor(color) + } + + + /** + * Set the fast scroll state change listener. + * + * @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events + */ + fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) { + mFastScroller.setFastScrollStateChangeListener(fastScrollStateChangeListener) + } + + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + mFastScroller.attachRecyclerView(this) + val parent = parent + if (parent is ViewGroup && parent.indexOfChild(mFastScroller) == -1) { + parent.addView(mFastScroller) + mFastScroller.setLayoutParams(parent) + } + } + + + override fun onDetachedFromWindow() { + mFastScroller.detachRecyclerView() + super.onDetachedFromWindow() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt new file mode 100644 index 000000000..55afa8370 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt @@ -0,0 +1,14 @@ +package io.legado.app.ui.widget.recycler.scroller + +interface FastScrollStateChangeListener { + + /** + * Called when fast scrolling begins + */ + fun onFastScrollStart(fastScroller: FastScroller) + + /** + * Called when fast scrolling ends + */ + fun onFastScrollStop(fastScroller: FastScroller) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt new file mode 100644 index 000000000..b789db419 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt @@ -0,0 +1,548 @@ +package io.legado.app.ui.widget.recycler.scroller + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.widget.* +import androidx.annotation.ColorInt +import androidx.annotation.IdRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.GravityCompat +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.getCompatColor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + + +@Suppress("SameParameterValue") +class FastScroller : LinearLayout { + @ColorInt + private var mBubbleColor: Int = 0 + + @ColorInt + private var mHandleColor: Int = 0 + private var mBubbleHeight: Int = 0 + private var mHandleHeight: Int = 0 + private var mViewHeight: Int = 0 + private var mFadeScrollbar: Boolean = false + private var mShowBubble: Boolean = false + private var mSectionIndexer: SectionIndexer? = null + private var mScrollbarAnimator: ViewPropertyAnimator? = null + private var mBubbleAnimator: ViewPropertyAnimator? = null + private var mRecyclerView: RecyclerView? = null + private lateinit var mBubbleView: TextView + private lateinit var mHandleView: ImageView + private lateinit var mTrackView: ImageView + private lateinit var mScrollbar: View + private var mBubbleImage: Drawable? = null + private var mHandleImage: Drawable? = null + private var mTrackImage: Drawable? = null + private var mFastScrollStateChangeListener: FastScrollStateChangeListener? = null + private val mScrollbarHider = Runnable { this.hideScrollbar() } + + private val mScrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (!mHandleView.isSelected && isEnabled) { + setViewPositions(getScrollProportion(recyclerView)) + } + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (isEnabled) { + when (newState) { + RecyclerView.SCROLL_STATE_DRAGGING -> { + handler.removeCallbacks(mScrollbarHider) + cancelAnimation(mScrollbarAnimator) + if (!isViewVisible(mScrollbar)) { + showScrollbar() + } + } + RecyclerView.SCROLL_STATE_IDLE -> if (mFadeScrollbar && !mHandleView.isSelected) { + handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong()) + } + } + } + } + } + + constructor(context: Context) : super(context) { + layout(context, null) + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) + } + + @JvmOverloads + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) : super( + context, + attrs, + defStyleAttr + ) { + layout(context, attrs) + layoutParams = generateLayoutParams(attrs) + } + + override fun setLayoutParams(params: ViewGroup.LayoutParams) { + params.width = LayoutParams.WRAP_CONTENT + super.setLayoutParams(params) + } + + fun setLayoutParams(viewGroup: ViewGroup) { + @IdRes val recyclerViewId = mRecyclerView?.id ?: View.NO_ID + val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) + val marginBottom = + resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) + require(recyclerViewId != View.NO_ID) { "RecyclerView must have a view ID" } + when (viewGroup) { + is ConstraintLayout -> { + val constraintSet = ConstraintSet() + @IdRes val layoutId = id + constraintSet.clone(viewGroup) + constraintSet.connect( + layoutId, + ConstraintSet.TOP, + recyclerViewId, + ConstraintSet.TOP + ) + constraintSet.connect( + layoutId, + ConstraintSet.BOTTOM, + recyclerViewId, + ConstraintSet.BOTTOM + ) + constraintSet.connect( + layoutId, + ConstraintSet.END, + recyclerViewId, + ConstraintSet.END + ) + constraintSet.applyTo(viewGroup) + val layoutParams = layoutParams as ConstraintLayout.LayoutParams + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + is CoordinatorLayout -> { + val layoutParams = layoutParams as CoordinatorLayout.LayoutParams + layoutParams.anchorId = recyclerViewId + layoutParams.anchorGravity = GravityCompat.END + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + is FrameLayout -> { + val layoutParams = layoutParams as FrameLayout.LayoutParams + layoutParams.gravity = GravityCompat.END + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + is RelativeLayout -> { + val layoutParams = layoutParams as RelativeLayout.LayoutParams + val endRule = RelativeLayout.ALIGN_END + layoutParams.addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) + layoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) + layoutParams.addRule(endRule, recyclerViewId) + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") + } + updateViewHeights() + } + + fun setSectionIndexer(sectionIndexer: SectionIndexer?) { + mSectionIndexer = sectionIndexer + } + + fun attachRecyclerView(recyclerView: RecyclerView) { + mRecyclerView = recyclerView + mRecyclerView!!.addOnScrollListener(mScrollListener) + post { + // set initial positions for bubble and handle + setViewPositions(getScrollProportion(mRecyclerView)) + } + } + + fun detachRecyclerView() { + if (mRecyclerView != null) { + mRecyclerView!!.removeOnScrollListener(mScrollListener) + mRecyclerView = null + } + } + + /** + * Hide the scrollbar when not scrolling. + * + * @param fadeScrollbar True to hide the scrollbar, false to show + */ + fun setFadeScrollbar(fadeScrollbar: Boolean) { + mFadeScrollbar = fadeScrollbar + mScrollbar.visibility = if (fadeScrollbar) View.INVISIBLE else View.VISIBLE + } + + /** + * Show the section bubble while scrolling. + * + * @param visible True to show the bubble, false to hide + */ + fun setBubbleVisible(visible: Boolean) { + mShowBubble = visible + } + + /** + * Display a scroll track while scrolling. + * + * @param visible True to show scroll track, false to hide + */ + fun setTrackVisible(visible: Boolean) { + mTrackView.visibility = if (visible) View.VISIBLE else View.INVISIBLE + } + + /** + * Set the color of the scroll track. + * + * @param color The color for the scroll track + */ + fun setTrackColor(@ColorInt color: Int) { + if (mTrackImage == null) { + val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_track) + if (drawable != null) { + mTrackImage = DrawableCompat.wrap(drawable) + } + } + DrawableCompat.setTint(mTrackImage!!, color) + mTrackView.setImageDrawable(mTrackImage) + } + + /** + * Set the color for the scroll handle. + * + * @param color The color for the scroll handle + */ + fun setHandleColor(@ColorInt color: Int) { + mHandleColor = color + if (mHandleImage == null) { + val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle) + if (drawable != null) { + mHandleImage = DrawableCompat.wrap(drawable) + } + } + DrawableCompat.setTint(mHandleImage!!, mHandleColor) + mHandleView.setImageDrawable(mHandleImage) + } + + /** + * Set the background color of the index bubble. + * + * @param color The background color for the index bubble + */ + fun setBubbleColor(@ColorInt color: Int) { + mBubbleColor = color + if (mBubbleImage == null) { + val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_bubble) + if (drawable != null) { + mBubbleImage = DrawableCompat.wrap(drawable) + } + } + DrawableCompat.setTint(mBubbleImage!!, mBubbleColor) + mBubbleView.background = mBubbleImage + } + + /** + * Set the text color of the index bubble. + * + * @param color The text color for the index bubble + */ + fun setBubbleTextColor(@ColorInt color: Int) { + mBubbleView.setTextColor(color) + } + + /** + * Set the fast scroll state change listener. + * + * @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events + */ + fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) { + mFastScrollStateChangeListener = fastScrollStateChangeListener + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + visibility = if (enabled) View.VISIBLE else View.INVISIBLE + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (event.x < mHandleView.x - ViewCompat.getPaddingStart(mHandleView)) { + return false + } + if (!mScrollbar.isVisible) { + return false + } + requestDisallowInterceptTouchEvent(true) + setHandleSelected(true) + handler.removeCallbacks(mScrollbarHider) + cancelAnimation(mScrollbarAnimator) + cancelAnimation(mBubbleAnimator) + if (mShowBubble && mSectionIndexer != null) { + showBubble() + } + if (mFastScrollStateChangeListener != null) { + mFastScrollStateChangeListener!!.onFastScrollStart(this) + } + val y = event.y + setViewPositions(y) + setRecyclerViewPosition(y) + return true + } + MotionEvent.ACTION_MOVE -> { + val y = event.y + setViewPositions(y) + setRecyclerViewPosition(y) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + requestDisallowInterceptTouchEvent(false) + setHandleSelected(false) + if (mFadeScrollbar) { + handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong()) + } + hideBubble() + if (mFastScrollStateChangeListener != null) { + mFastScrollStateChangeListener!!.onFastScrollStop(this) + } + return true + } + } + return super.onTouchEvent(event) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + mViewHeight = h + } + + private fun setRecyclerViewPosition(y: Float) { + mRecyclerView?.adapter?.let { adapter -> + val itemCount = adapter.itemCount + val proportion: Float = when { + mHandleView.y == 0f -> 0f + mHandleView.y + mHandleHeight >= mViewHeight - sTrackSnapRange -> 1f + else -> y / mViewHeight.toFloat() + } + var scrolledItemCount = (proportion * itemCount).roundToInt() + if (isLayoutReversed(mRecyclerView?.layoutManager)) { + scrolledItemCount = itemCount - scrolledItemCount + } + val targetPos = getValueInRange(0, itemCount - 1, scrolledItemCount) + mRecyclerView?.layoutManager?.scrollToPosition(targetPos) + mSectionIndexer?.let { sectionIndexer -> + if (mShowBubble) { + mBubbleView.text = sectionIndexer.getSectionText(targetPos) + } + } + } + } + + private fun getScrollProportion(recyclerView: RecyclerView?): Float { + recyclerView ?: return 0f + val verticalScrollOffset = recyclerView.computeVerticalScrollOffset() + val verticalScrollRange = recyclerView.computeVerticalScrollRange() + val rangeDiff = (verticalScrollRange - mViewHeight).toFloat() + val proportion = verticalScrollOffset.toFloat() / if (rangeDiff > 0) rangeDiff else 1f + return mViewHeight * proportion + } + + private fun getValueInRange(min: Int, max: Int, value: Int): Int { + val minimum = max(min, value) + return min(minimum, max) + } + + private fun setViewPositions(y: Float) { + mBubbleHeight = mBubbleView.height + mHandleHeight = mHandleView.height + val bubbleY = getValueInRange( + 0, + mViewHeight - mBubbleHeight - mHandleHeight / 2, + (y - mBubbleHeight).toInt() + ) + val handleY = + getValueInRange(0, mViewHeight - mHandleHeight, (y - mHandleHeight / 2).toInt()) + if (mShowBubble) { + mBubbleView.y = bubbleY.toFloat() + } + mHandleView.y = handleY.toFloat() + } + + private fun updateViewHeights() { + val measureSpec = + MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + mBubbleView.measure(measureSpec, measureSpec) + mBubbleHeight = mBubbleView.measuredHeight + mHandleView.measure(measureSpec, measureSpec) + mHandleHeight = mHandleView.measuredHeight + } + + private fun isLayoutReversed(layoutManager: RecyclerView.LayoutManager?): Boolean { + if (layoutManager is LinearLayoutManager) { + return layoutManager.reverseLayout + } else if (layoutManager is StaggeredGridLayoutManager) { + return layoutManager.reverseLayout + } + return false + } + + private fun isViewVisible(view: View?): Boolean { + return view != null && view.visibility == View.VISIBLE + } + + private fun cancelAnimation(animator: ViewPropertyAnimator?) { + animator?.cancel() + } + + private fun showBubble() { + if (!isViewVisible(mBubbleView)) { + mBubbleView.visibility = View.VISIBLE + mBubbleAnimator = mBubbleView.animate().alpha(1f) + .setDuration(sBubbleAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + + // adapter required for new alpha value to stick + }) + } + } + + private fun hideBubble() { + if (isViewVisible(mBubbleView)) { + mBubbleAnimator = mBubbleView.animate().alpha(0f) + .setDuration(sBubbleAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + mBubbleView.visibility = View.INVISIBLE + mBubbleAnimator = null + } + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + mBubbleView.visibility = View.INVISIBLE + mBubbleAnimator = null + } + }) + } + } + + private fun showScrollbar() { + mRecyclerView?.let { mRecyclerView -> + if (mRecyclerView.computeVerticalScrollRange() - mViewHeight > 0) { + val transX = + resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end) + .toFloat() + mScrollbar.translationX = transX + mScrollbar.visibility = View.VISIBLE + mScrollbarAnimator = mScrollbar.animate().translationX(0f).alpha(1f) + .setDuration(sScrollbarAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + + // adapter required for new alpha value to stick + }) + } + } + } + + private fun hideScrollbar() { + val transX = + resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end).toFloat() + mScrollbarAnimator = mScrollbar.animate().translationX(transX).alpha(0f) + .setDuration(sScrollbarAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + mScrollbar.visibility = View.INVISIBLE + mScrollbarAnimator = null + } + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + mScrollbar.visibility = View.INVISIBLE + mScrollbarAnimator = null + } + }) + } + + private fun setHandleSelected(selected: Boolean) { + mHandleView.isSelected = selected + DrawableCompat.setTint(mHandleImage!!, if (selected) mBubbleColor else mHandleColor) + } + + private fun layout(context: Context, attrs: AttributeSet?) { + View.inflate(context, R.layout.view_fastscroller, this) + clipChildren = false + orientation = HORIZONTAL + mBubbleView = findViewById(R.id.fastscroll_bubble) + mHandleView = findViewById(R.id.fastscroll_handle) + mTrackView = findViewById(R.id.fastscroll_track) + mScrollbar = findViewById(R.id.fastscroll_scrollbar) + @ColorInt var bubbleColor = ColorUtils.adjustAlpha(context.accentColor, 0.8f) + @ColorInt var handleColor = context.accentColor + @ColorInt var trackColor = context.getCompatColor(R.color.transparent30) + @ColorInt var textColor = + if (ColorUtils.isColorLight(bubbleColor)) Color.BLACK else Color.WHITE + var fadeScrollbar = true + var showBubble = false + var showTrack = true + if (attrs != null) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, 0, 0) + try { + bubbleColor = typedArray.getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) + handleColor = typedArray.getColor(R.styleable.FastScroller_handleColor, handleColor) + trackColor = typedArray.getColor(R.styleable.FastScroller_trackColor, trackColor) + textColor = typedArray.getColor(R.styleable.FastScroller_bubbleTextColor, textColor) + fadeScrollbar = + typedArray.getBoolean(R.styleable.FastScroller_fadeScrollbar, fadeScrollbar) + showBubble = typedArray.getBoolean(R.styleable.FastScroller_showBubble, showBubble) + showTrack = typedArray.getBoolean(R.styleable.FastScroller_showTrack, showTrack) + } finally { + typedArray.recycle() + } + } + setTrackColor(trackColor) + setHandleColor(handleColor) + setBubbleColor(bubbleColor) + setBubbleTextColor(textColor) + setFadeScrollbar(fadeScrollbar) + setBubbleVisible(showBubble) + setTrackVisible(showTrack) + } + + interface SectionIndexer { + fun getSectionText(position: Int): String + } + + companion object { + private const val sBubbleAnimDuration = 100 + private const val sScrollbarAnimDuration = 300 + private const val sScrollbarHideDelay = 1000 + private const val sTrackSnapRange = 5 + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/seekbar/SeekBarChangeListener.kt b/app/src/main/java/io/legado/app/ui/widget/seekbar/SeekBarChangeListener.kt new file mode 100644 index 000000000..eaa159029 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/seekbar/SeekBarChangeListener.kt @@ -0,0 +1,19 @@ +package io.legado.app.ui.widget.seekbar + +import android.widget.SeekBar + +interface SeekBarChangeListener : SeekBar.OnSeekBarChangeListener { + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt new file mode 100644 index 000000000..b6761a7a0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt @@ -0,0 +1,339 @@ +package io.legado.app.ui.widget.seekbar + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.MotionEvent +import android.widget.ProgressBar +import androidx.appcompat.widget.AppCompatSeekBar +import androidx.core.view.ViewCompat +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method + +@Suppress("SameParameterValue") +class VerticalSeekBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + AppCompatSeekBar(context, attrs) { + + private var mIsDragging: Boolean = false + private var mThumb: Drawable? = null + private var mMethodSetProgressFromUser: Method? = null + private var mRotationAngle = ROTATION_ANGLE_CW_90 + + var rotationAngle: Int + get() = mRotationAngle + set(angle) { + require(isValidRotationAngle(angle)) { "Invalid angle specified :$angle" } + + if (mRotationAngle == angle) { + return + } + + mRotationAngle = angle + + if (useViewRotation()) { + val wrapper = wrapper + wrapper?.applyViewRotation() + } else { + requestLayout() + } + } + + private val wrapper: VerticalSeekBarWrapper? + get() { + val parent = parent + + return if (parent is VerticalSeekBarWrapper) { + parent + } else { + null + } + } + + init { + applyTint(context.accentColor) + ViewCompat.setLayoutDirection(this, ViewCompat.LAYOUT_DIRECTION_LTR) + + if (attrs != null) { + val a = context.obtainStyledAttributes(attrs, R.styleable.VerticalSeekBar) + val rotationAngle = a.getInteger(R.styleable.VerticalSeekBar_seekBarRotation, 0) + if (isValidRotationAngle(rotationAngle)) { + mRotationAngle = rotationAngle + } + a.recycle() + } + } + + override fun setThumb(thumb: Drawable) { + mThumb = thumb + super.setThumb(thumb) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + return if (useViewRotation()) { + onTouchEventUseViewRotation(event) + } else { + onTouchEventTraditionalRotation(event) + } + } + + private fun onTouchEventTraditionalRotation(event: MotionEvent): Boolean { + if (!isEnabled) { + return false + } + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isPressed = true + onStartTrackingTouch() + trackTouchEvent(event) + attemptClaimDrag(true) + invalidate() + } + + MotionEvent.ACTION_MOVE -> if (mIsDragging) { + trackTouchEvent(event) + } + + MotionEvent.ACTION_UP -> { + if (mIsDragging) { + trackTouchEvent(event) + onStopTrackingTouch() + isPressed = false + } else { + // Touch up when we never crossed the touch slop threshold + // should + // be interpreted as a tap-seek to that location. + onStartTrackingTouch() + trackTouchEvent(event) + onStopTrackingTouch() + attemptClaimDrag(false) + } + // ProgressBar doesn't know to repaint the thumb drawable + // in its inactive state when the touch stops (because the + // value has not apparently changed) + invalidate() + } + + MotionEvent.ACTION_CANCEL -> { + if (mIsDragging) { + onStopTrackingTouch() + isPressed = false + } + invalidate() // see above explanation + } + } + return true + } + + private fun onTouchEventUseViewRotation(event: MotionEvent): Boolean { + val handled = super.onTouchEvent(event) + + if (handled) { + when (event.action) { + MotionEvent.ACTION_DOWN -> attemptClaimDrag(true) + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> attemptClaimDrag(false) + } + } + + return handled + } + + private fun trackTouchEvent(event: MotionEvent) { + val paddingLeft = super.getPaddingLeft() + val paddingRight = super.getPaddingRight() + val height = height + + val available = height - paddingLeft - paddingRight + val y = event.y.toInt() + + val scale: Float + var value = 0f + + when (mRotationAngle) { + ROTATION_ANGLE_CW_90 -> value = (y - paddingLeft).toFloat() + ROTATION_ANGLE_CW_270 -> value = (height - paddingLeft - y).toFloat() + } + + scale = if (value < 0 || available == 0) { + 0.0f + } else if (value > available) { + 1.0f + } else { + value / available.toFloat() + } + + val max = max + val progress = scale * max + + setProgressFromUser(progress.toInt(), true) + } + + /** + * Tries to claim the user's drag motion, and requests disallowing any + * ancestors from stealing events in the drag. + */ + private fun attemptClaimDrag(active: Boolean) { + val parent = parent + parent?.requestDisallowInterceptTouchEvent(active) + } + + /** + * This is called when the user has started touching this widget. + */ + private fun onStartTrackingTouch() { + mIsDragging = true + } + + /** + * This is called when the user either releases his touch or the touch is + * canceled. + */ + private fun onStopTrackingTouch() { + mIsDragging = false + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (isEnabled) { + val handled: Boolean + var direction = 0 + + when (keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + direction = if (mRotationAngle == ROTATION_ANGLE_CW_90) 1 else -1 + handled = true + } + KeyEvent.KEYCODE_DPAD_UP -> { + direction = if (mRotationAngle == ROTATION_ANGLE_CW_270) 1 else -1 + handled = true + } + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> + // move view focus to previous/next view + return false + else -> handled = false + } + + if (handled) { + val keyProgressIncrement = keyProgressIncrement + var progress = progress + + progress += direction * keyProgressIncrement + + if (progress in 0..max) { + setProgressFromUser(progress, true) + } + + return true + } + } + + return super.onKeyDown(keyCode, event) + } + + @Synchronized + override fun setProgress(progress: Int) { + super.setProgress(progress) + if (!useViewRotation()) { + refreshThumb() + } + } + + @Synchronized + private fun setProgressFromUser(progress: Int, fromUser: Boolean) { + if (mMethodSetProgressFromUser == null) { + try { + val m: Method = ProgressBar::class.java.getDeclaredMethod( + "setProgress", + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType + ) + m.isAccessible = true + mMethodSetProgressFromUser = m + } catch (e: NoSuchMethodException) { + } + + } + + if (mMethodSetProgressFromUser != null) { + try { + mMethodSetProgressFromUser!!.invoke(this, progress, fromUser) + } catch (e: IllegalArgumentException) { + } catch (e: IllegalAccessException) { + } catch (e: InvocationTargetException) { + } + + } else { + super.setProgress(progress) + } + refreshThumb() + } + + @Synchronized + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (useViewRotation()) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } else { + super.onMeasure(heightMeasureSpec, widthMeasureSpec) + + val lp = layoutParams + + if (isInEditMode && lp != null && lp.height >= 0) { + setMeasuredDimension(super.getMeasuredHeight(), lp.height) + } else { + setMeasuredDimension(super.getMeasuredHeight(), super.getMeasuredWidth()) + } + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + if (useViewRotation()) { + super.onSizeChanged(w, h, oldw, oldh) + } else { + super.onSizeChanged(h, w, oldh, oldw) + } + } + + @Synchronized + override fun onDraw(canvas: Canvas) { + if (!useViewRotation()) { + when (mRotationAngle) { + ROTATION_ANGLE_CW_90 -> { + canvas.rotate(90f) + canvas.translate(0f, (-super.getWidth()).toFloat()) + } + ROTATION_ANGLE_CW_270 -> { + canvas.rotate(-90f) + canvas.translate((-super.getHeight()).toFloat(), 0f) + } + } + } + + super.onDraw(canvas) + } + + // refresh thumb position + private fun refreshThumb() { + onSizeChanged(super.getWidth(), super.getHeight(), 0, 0) + } + + /*package*/ + internal fun useViewRotation(): Boolean { + return !isInEditMode + } + + companion object { + const val ROTATION_ANGLE_CW_90 = 90 + const val ROTATION_ANGLE_CW_270 = 270 + + private fun isValidRotationAngle(angle: Int): Boolean { + return angle == ROTATION_ANGLE_CW_90 || angle == ROTATION_ANGLE_CW_270 + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt new file mode 100644 index 000000000..683953f1a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt @@ -0,0 +1,183 @@ +package io.legado.app.ui.widget.seekbar + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout + +import androidx.core.view.ViewCompat +import kotlin.math.max + +class VerticalSeekBarWrapper @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + private val childSeekBar: VerticalSeekBar? + get() { + val child = if (childCount > 0) getChildAt(0) else null + return if (child is VerticalSeekBar) child else null + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + if (useViewRotation()) { + onSizeChangedUseViewRotation(w, h, oldw, oldh) + } else { + onSizeChangedTraditionalRotation(w, h, oldw, oldh) + } + } + + @SuppressLint("RtlHardcoded") + private fun onSizeChangedTraditionalRotation(w: Int, h: Int, oldw: Int, oldh: Int) { + val seekBar = childSeekBar + + if (seekBar != null) { + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val lp = seekBar.layoutParams as LayoutParams + + lp.width = ViewGroup.LayoutParams.WRAP_CONTENT + lp.height = max(0, h - vPadding) + seekBar.layoutParams = lp + + seekBar.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + + val seekBarMeasuredWidth = seekBar.measuredWidth + seekBar.measure( + MeasureSpec.makeMeasureSpec( + max(0, w - hPadding), + MeasureSpec.AT_MOST + ), + MeasureSpec.makeMeasureSpec( + max(0, h - vPadding), + MeasureSpec.EXACTLY + ) + ) + + lp.gravity = Gravity.TOP or Gravity.LEFT + lp.leftMargin = (max(0, w - hPadding) - seekBarMeasuredWidth) / 2 + seekBar.layoutParams = lp + } + + super.onSizeChanged(w, h, oldw, oldh) + } + + private fun onSizeChangedUseViewRotation(w: Int, h: Int, oldw: Int, oldh: Int) { + val seekBar = childSeekBar + + if (seekBar != null) { + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + seekBar.measure( + MeasureSpec.makeMeasureSpec( + max(0, h - vPadding), + MeasureSpec.EXACTLY + ), + MeasureSpec.makeMeasureSpec( + max(0, w - hPadding), + MeasureSpec.AT_MOST + ) + ) + } + + applyViewRotation(w, h) + super.onSizeChanged(w, h, oldw, oldh) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val seekBar = childSeekBar + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + if (seekBar != null && widthMode != MeasureSpec.EXACTLY) { + val seekBarWidth: Int + val seekBarHeight: Int + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val innerContentWidthMeasureSpec = + MeasureSpec.makeMeasureSpec(max(0, widthSize - hPadding), widthMode) + val innerContentHeightMeasureSpec = + MeasureSpec.makeMeasureSpec(max(0, heightSize - vPadding), heightMode) + + if (useViewRotation()) { + seekBar.measure(innerContentHeightMeasureSpec, innerContentWidthMeasureSpec) + seekBarWidth = seekBar.measuredHeight + seekBarHeight = seekBar.measuredWidth + } else { + seekBar.measure(innerContentWidthMeasureSpec, innerContentHeightMeasureSpec) + seekBarWidth = seekBar.measuredWidth + seekBarHeight = seekBar.measuredHeight + } + + val measuredWidth = + View.resolveSizeAndState(seekBarWidth + hPadding, widthMeasureSpec, 0) + val measuredHeight = + View.resolveSizeAndState(seekBarHeight + vPadding, heightMeasureSpec, 0) + + setMeasuredDimension(measuredWidth, measuredHeight) + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } + + /*package*/ + internal fun applyViewRotation() { + applyViewRotation(width, height) + } + + private fun applyViewRotation(w: Int, h: Int) { + val seekBar = childSeekBar + + if (seekBar != null) { + val isLTR = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR + val rotationAngle = seekBar.rotationAngle + val seekBarMeasuredWidth = seekBar.measuredWidth + val seekBarMeasuredHeight = seekBar.measuredHeight + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val hOffset = (max(0, w - hPadding) - seekBarMeasuredHeight) * 0.5f + val lp = seekBar.layoutParams + + lp.width = max(0, h - vPadding) + lp.height = ViewGroup.LayoutParams.WRAP_CONTENT + + seekBar.layoutParams = lp + + seekBar.pivotX = (if (isLTR) 0 else max(0, h - vPadding)).toFloat() + seekBar.pivotY = 0f + + when (rotationAngle) { + VerticalSeekBar.ROTATION_ANGLE_CW_90 -> { + seekBar.rotation = 90f + if (isLTR) { + seekBar.translationX = seekBarMeasuredHeight + hOffset + seekBar.translationY = 0f + } else { + seekBar.translationX = -hOffset + seekBar.translationY = seekBarMeasuredWidth.toFloat() + } + } + VerticalSeekBar.ROTATION_ANGLE_CW_270 -> { + seekBar.rotation = 270f + if (isLTR) { + seekBar.translationX = hOffset + seekBar.translationY = seekBarMeasuredWidth.toFloat() + } else { + seekBar.translationX = -(seekBarMeasuredHeight + hOffset) + seekBar.translationY = 0f + } + } + } + } + } + + private fun useViewRotation(): Boolean { + val seekBar = childSeekBar + return seekBar?.useViewRotation() ?: false + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/AccentBgTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/AccentBgTextView.kt new file mode 100644 index 000000000..ccaffaf79 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/AccentBgTextView.kt @@ -0,0 +1,49 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +class AccentBgTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + + private var radius = 0 + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccentBgTextView) + radius = typedArray.getDimensionPixelOffset(R.styleable.AccentBgTextView_radius, radius) + typedArray.recycle() + upBackground() + setTextColor(Color.WHITE) + } + + fun setRadius(radius: Int) { + this.radius = radius.dp + upBackground() + } + + private fun upBackground() { + background = if (isInEditMode) { + Selector.shapeBuild() + .setCornerRadius(radius) + .setDefaultBgColor(context.getCompatColor(R.color.accent)) + .setPressedBgColor(ColorUtils.darkenColor(context.getCompatColor(R.color.accent))) + .create() + } else { + Selector.shapeBuild() + .setCornerRadius(radius) + .setDefaultBgColor(ThemeStore.accentColor(context)) + .setPressedBgColor(ColorUtils.darkenColor(ThemeStore.accentColor(context))) + .create() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/AccentStrokeTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/AccentStrokeTextView.kt new file mode 100644 index 000000000..abd14615a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/AccentStrokeTextView.kt @@ -0,0 +1,55 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.theme.bottomBackground +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +class AccentStrokeTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + private var radius = 3.dp + private val isBottomBackground: Boolean + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccentStrokeTextView) + radius = typedArray.getDimensionPixelOffset(R.styleable.StrokeTextView_radius, radius) + isBottomBackground = + typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false) + typedArray.recycle() + upStyle() + } + + private fun upStyle() { + val isLight = ColorUtils.isColorLight(context.bottomBackground) + val disableColor = if (isBottomBackground) { + if (isLight) { + context.getCompatColor(R.color.md_light_disabled) + } else { + context.getCompatColor(R.color.md_dark_disabled) + } + } else { + context.getCompatColor(R.color.disabled) + } + background = Selector.shapeBuild() + .setCornerRadius(radius) + .setStrokeWidth(1.dp) + .setDisabledStrokeColor(disableColor) + .setDefaultStrokeColor(ThemeStore.accentColor(context)) + .setPressedBgColor(context.getCompatColor(R.color.transparent30)) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(ThemeStore.accentColor(context)) + .setDisabledColor(disableColor) + .create() + ) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/AccentTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/AccentTextView.kt new file mode 100644 index 000000000..8dee26350 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/AccentTextView.kt @@ -0,0 +1,21 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.getCompatColor + +class AccentTextView(context: Context, attrs: AttributeSet?) : + AppCompatTextView(context, attrs) { + + init { + if (!isInEditMode) { + setTextColor(context.accentColor) + } else { + setTextColor(context.getCompatColor(R.color.accent)) + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/AutoCompleteTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/AutoCompleteTextView.kt new file mode 100644 index 000000000..f42cda253 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/AutoCompleteTextView.kt @@ -0,0 +1,76 @@ +package io.legado.app.ui.widget.text + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.widget.AppCompatAutoCompleteTextView +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.applyTint +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +@Suppress("unused") +class AutoCompleteTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatAutoCompleteTextView(context, attrs) { + + var delCallBack: ((value: String) -> Unit)? = null + + init { + applyTint(context.accentColor) + } + + override fun enoughToFilter(): Boolean { + return true + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (event?.action == MotionEvent.ACTION_DOWN) { + showDropDown() + } + return super.onTouchEvent(event) + } + + fun setFilterValues(values: List?) { + values?.let { + setAdapter(MyAdapter(context, values)) + } + } + + fun setFilterValues(vararg value: String) { + setAdapter(MyAdapter(context, value.toMutableList())) + } + + inner class MyAdapter(context: Context, values: List) : + ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, values) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.item_1line_text_and_del, parent, false) + val textView = view.findViewById(R.id.text_view) + textView.text = getItem(position) + val ivDelete = view.findViewById(R.id.iv_delete) + if (delCallBack != null) ivDelete.visible() else ivDelete.gone() + ivDelete.setOnClickListener { + getItem(position)?.let { + remove(it) + delCallBack?.invoke(it) + showDropDown() + } + } + return view + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/BadgeView.kt b/app/src/main/java/io/legado/app/ui/widget/text/BadgeView.kt new file mode 100644 index 000000000..771448b7d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/BadgeView.kt @@ -0,0 +1,231 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape +import android.text.TextUtils +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.FrameLayout.LayoutParams +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +/** + * Created by milad heydari on 5/6/2016. + */ +@Suppress("MemberVisibilityCanBePrivate", "unused") +class BadgeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + + var isHideOnNull = true + set(hideOnNull) { + field = hideOnNull + text = text + } + private var radius: Float = 0.toFloat() + private var flatangle: Boolean + + val badgeCount: Int? + get() { + if (text == null) { + return null + } + val text = text.toString() + return kotlin.runCatching { + Integer.parseInt(text) + }.getOrNull() + } + + var badgeGravity: Int + get() { + val params = layoutParams as LayoutParams + return params.gravity + } + set(gravity) { + val params = layoutParams as LayoutParams + params.gravity = gravity + layoutParams = params + } + + val badgeMargin: IntArray + get() { + val params = layoutParams as LayoutParams + return intArrayOf( + params.leftMargin, + params.topMargin, + params.rightMargin, + params.bottomMargin + ) + } + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BadgeView) + val radios = + typedArray.getDimensionPixelOffset(R.styleable.BadgeView_radius, 8) + flatangle = + typedArray.getBoolean(R.styleable.BadgeView_up_flat_angle, false) + typedArray.recycle() + + if (layoutParams !is LayoutParams) { + val layoutParams = LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ) + setLayoutParams(layoutParams) + } + + // set default font + setTextColor(Color.WHITE) + //setTypeface(Typeface.DEFAULT_BOLD); + setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) + setPadding(dip2Px(5f), dip2Px(1f), dip2Px(5f), dip2Px(1f)) + radius = radios.toFloat() + + // set default background + setBackground(radius, context.accentColor) + + gravity = Gravity.CENTER + + // default values + isHideOnNull = true + setBadgeCount(0) + minWidth = dip2Px(16f) + minHeight = dip2Px(16f) + } + + fun setBackground(dipRadius: Float, badgeColor: Int) { + val radius = dip2Px(dipRadius).toFloat() + val radiusArray = + floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius) + if (flatangle) { + radiusArray.fill(0f, 0, 3) + } + + val roundRect = RoundRectShape(radiusArray, null, null) + val bgDrawable = ShapeDrawable(roundRect) + bgDrawable.paint.color = badgeColor + background = bgDrawable + } + + fun setBackground(badgeColor: Int) { + setBackground(radius, badgeColor) + } + + /** + * @see android.widget.TextView.setText + */ + override fun setText(text: CharSequence, type: BufferType) { + if (isHideOnNull && TextUtils.isEmpty(text)) { + gone() + } else { + visible() + } + super.setText(text, type) + } + + fun setBadgeCount(count: Int) { + text = count.toString() + if (count == 0) { + gone() + } else { + visible() + } + } + + fun setHighlight(highlight: Boolean) { + if (highlight) { + setBackground(context.accentColor) + } else { + setBackground(context.getCompatColor(R.color.darker_gray)) + } + } + + fun setBadgeMargin(dipMargin: Int) { + setBadgeMargin(dipMargin, dipMargin, dipMargin, dipMargin) + } + + fun setBadgeMargin( + leftDipMargin: Int, + topDipMargin: Int, + rightDipMargin: Int, + bottomDipMargin: Int + ) { + val params = layoutParams as LayoutParams + params.leftMargin = dip2Px(leftDipMargin.toFloat()) + params.topMargin = dip2Px(topDipMargin.toFloat()) + params.rightMargin = dip2Px(rightDipMargin.toFloat()) + params.bottomMargin = dip2Px(bottomDipMargin.toFloat()) + layoutParams = params + } + + fun incrementBadgeCount(increment: Int) { + val count = badgeCount + if (count == null) { + setBadgeCount(increment) + } else { + setBadgeCount(increment + count) + } + } + + fun decrementBadgeCount(decrement: Int) { + incrementBadgeCount(-decrement) + } + + /** + * Attach the BadgeView to the target view + * @param target the view to attach the BadgeView + */ + fun setTargetView(target: View?) { + if (parent != null) { + (parent as ViewGroup).removeView(this) + } + + if (target == null) { + return + } + + if (target.parent is FrameLayout) { + (target.parent as FrameLayout).addView(this) + + } else if (target.parent is ViewGroup) { + // use a new FrameLayout container for adding badge + val parentContainer = target.parent as ViewGroup + val groupIndex = parentContainer.indexOfChild(target) + parentContainer.removeView(target) + + val badgeContainer = FrameLayout(context) + val parentLayoutParams = target.layoutParams + + badgeContainer.layoutParams = parentLayoutParams + target.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + + parentContainer.addView(badgeContainer, groupIndex, parentLayoutParams) + badgeContainer.addView(target) + + badgeContainer.addView(this) + } + + } + + /** + * converts dip to px + */ + private fun dip2Px(dip: Float): Int { + return (dip * context.resources.displayMetrics.density + 0.5f).toInt() + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt new file mode 100644 index 000000000..41afecaff --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/InertiaScrollTextView.kt @@ -0,0 +1,230 @@ +package io.legado.app.ui.widget.text + +import android.annotation.SuppressLint +import android.content.Context +import android.text.method.LinkMovementMethod +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.ViewConfiguration +import android.view.animation.Interpolator +import android.widget.OverScroller +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.ViewCompat +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + + +@Suppress("unused") +open class InertiaScrollTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + + private val scrollStateIdle = 0 + private val scrollStateDragging = 1 + val scrollStateSettling = 2 + + private val mViewFling: ViewFling by lazy { ViewFling() } + private var velocityTracker: VelocityTracker? = null + private var mScrollState = scrollStateIdle + private var mLastTouchY: Int = 0 + private var mTouchSlop: Int = 0 + private var mMinFlingVelocity: Int = 0 + private var mMaxFlingVelocity: Int = 0 + + //滑动距离的最大边界 + private var mOffsetHeight: Int = 0 + + //f(x) = (x-1)^5 + 1 + private val sQuinticInterpolator = Interpolator { + var t = it + t -= 1.0f + t * t * t * t * t + 1.0f + } + + init { + val vc = ViewConfiguration.get(context) + mTouchSlop = vc.scaledTouchSlop + mMinFlingVelocity = vc.scaledMinimumFlingVelocity + mMaxFlingVelocity = vc.scaledMaximumFlingVelocity + movementMethod = LinkMovementMethod.getInstance() + } + + fun atTop(): Boolean { + return scrollY <= 0 + } + + fun atBottom(): Boolean { + return scrollY >= mOffsetHeight + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + initOffsetHeight() + } + + override fun onTextChanged( + text: CharSequence?, + start: Int, + lengthBefore: Int, + lengthAfter: Int + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + initOffsetHeight() + } + + private fun initOffsetHeight() { + val mLayoutHeight: Int + + //获得内容面板 + val mLayout = layout ?: return + //获得内容面板的高度 + mLayoutHeight = mLayout.height + + //计算滑动距离的边界 + mOffsetHeight = mLayoutHeight + totalPaddingTop + totalPaddingBottom - measuredHeight + } + + override fun scrollTo(x: Int, y: Int) { + super.scrollTo(x, min(y, mOffsetHeight)) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + event?.let { + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(it) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + setScrollState(scrollStateIdle) + mLastTouchY = (event.y + 0.5f).toInt() + } + MotionEvent.ACTION_MOVE -> { + val y = (event.y + 0.5f).toInt() + var dy = mLastTouchY - y + if (mScrollState != scrollStateDragging) { + var startScroll = false + + if (abs(dy) > mTouchSlop) { + if (dy > 0) { + dy -= mTouchSlop + } else { + dy += mTouchSlop + } + startScroll = true + } + if (startScroll) { + setScrollState(scrollStateDragging) + } + } + if (mScrollState == scrollStateDragging) { + mLastTouchY = y + } + } + MotionEvent.ACTION_UP -> { + velocityTracker?.computeCurrentVelocity(1000, mMaxFlingVelocity.toFloat()) + val yVelocity = velocityTracker?.yVelocity ?: 0f + if (abs(yVelocity) > mMinFlingVelocity) { + mViewFling.fling(-yVelocity.toInt()) + } else { + setScrollState(scrollStateIdle) + } + resetTouch() + } + MotionEvent.ACTION_CANCEL -> { + resetTouch() + } + } + } + return super.onTouchEvent(event) + } + + private fun resetTouch() { + velocityTracker?.clear() + } + + private fun setScrollState(state: Int) { + if (state == mScrollState) { + return + } + mScrollState = state + if (state != scrollStateSettling) { + mViewFling.stop() + } + } + + /** + * 惯性滚动 + */ + private inner class ViewFling : Runnable { + + private var mLastFlingY = 0 + private val mScroller: OverScroller = OverScroller(context, sQuinticInterpolator) + private var mEatRunOnAnimationRequest = false + private var mReSchedulePostAnimationCallback = false + + override fun run() { + disableRunOnAnimationRequests() + val scroller = mScroller + if (scroller.computeScrollOffset()) { + val y = scroller.currY + val dy = y - mLastFlingY + mLastFlingY = y + if (dy < 0 && scrollY > 0) { + scrollBy(0, max(dy, -scrollY)) + } else if (dy > 0 && scrollY < mOffsetHeight) { + scrollBy(0, min(dy, mOffsetHeight - scrollY)) + } + postOnAnimation() + } + enableRunOnAnimationRequests() + } + + fun fling(velocityY: Int) { + mLastFlingY = 0 + setScrollState(scrollStateSettling) + mScroller.fling( + 0, + 0, + 0, + velocityY, + Integer.MIN_VALUE, + Integer.MAX_VALUE, + Integer.MIN_VALUE, + Integer.MAX_VALUE + ) + postOnAnimation() + } + + fun stop() { + removeCallbacks(this) + mScroller.abortAnimation() + } + + private fun disableRunOnAnimationRequests() { + mReSchedulePostAnimationCallback = false + mEatRunOnAnimationRequest = true + } + + private fun enableRunOnAnimationRequests() { + mEatRunOnAnimationRequest = false + if (mReSchedulePostAnimationCallback) { + postOnAnimation() + } + } + + fun postOnAnimation() { + if (mEatRunOnAnimationRequest) { + mReSchedulePostAnimationCallback = true + } else { + removeCallbacks(this) + ViewCompat.postOnAnimation(this@InertiaScrollTextView, this) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/text/MultilineTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/MultilineTextView.kt new file mode 100644 index 000000000..56e36f07d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/MultilineTextView.kt @@ -0,0 +1,22 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView + +class MultilineTextView(context: Context, attrs: AttributeSet?) : + AppCompatTextView(context, attrs) { + + override fun onDraw(canvas: Canvas?) { + calculateLines() + super.onDraw(canvas) + } + + private fun calculateLines() { + val mHeight = measuredHeight + val lHeight = lineHeight + val lines = mHeight / lHeight + setLines(lines) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/text/PrimaryTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/PrimaryTextView.kt new file mode 100644 index 000000000..2c2666e07 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/PrimaryTextView.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.lib.theme.ThemeStore + +/** + * @author Aidan Follestad (afollestad) + */ +class PrimaryTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + init { + setTextColor(ThemeStore.textColorPrimary(context)) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/ScrollTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/ScrollTextView.kt new file mode 100644 index 000000000..4a9da6e0f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/ScrollTextView.kt @@ -0,0 +1,81 @@ +package io.legado.app.ui.widget.text + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.appcompat.widget.AppCompatTextView + +class ScrollTextView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) { + //滑动距离的最大边界 + private var mOffsetHeight = 0 + + //是否到顶或者到底的标志 + private var mBottomFlag = false + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + initOffsetHeight() + } + + override fun onTextChanged( + text: CharSequence, + start: Int, + lengthBefore: Int, + lengthAfter: Int + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + initOffsetHeight() + } + + private fun initOffsetHeight() { + val mLayoutHeight: Int + + //获得内容面板 + val mLayout = layout ?: return + //获得内容面板的高度 + mLayoutHeight = mLayout.height + //获取上内边距 + val paddingTop: Int = totalPaddingTop + //获取下内边距 + val paddingBottom: Int = totalPaddingBottom + + //获得控件的实际高度 + val mHeight: Int = measuredHeight + + //计算滑动距离的边界 + mOffsetHeight = mLayoutHeight + paddingTop + paddingBottom - mHeight + if (mOffsetHeight <= 0) { + scrollTo(0, 0) + } + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + //如果是新的按下事件,则对mBottomFlag重新初始化 + mBottomFlag = mOffsetHeight <= 0 + } + //如果已经不要这次事件,则传出取消的信号,这里的作用不大 + if (mBottomFlag) { + event.action = MotionEvent.ACTION_CANCEL + } + return super.dispatchTouchEvent(event) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + val result = super.onTouchEvent(event) + //如果是需要拦截,则再拦截,这个方法会在onScrollChanged方法之后再调用一次 + if (!mBottomFlag) parent.requestDisallowInterceptTouchEvent(true) + return result + } + + override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) { + super.onScrollChanged(horiz, vert, oldHoriz, oldVert) + if (vert == mOffsetHeight || vert == 0) { + //这里触发父布局或祖父布局的滑动事件 + parent.requestDisallowInterceptTouchEvent(false) + mBottomFlag = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/text/SecondaryTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/SecondaryTextView.kt new file mode 100644 index 000000000..6d59af0e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/SecondaryTextView.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.lib.theme.secondaryTextColor + +/** + * @author Aidan Follestad (afollestad) + */ +class SecondaryTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + init { + setTextColor(context.secondaryTextColor) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/StrokeTextView.kt b/app/src/main/java/io/legado/app/ui/widget/text/StrokeTextView.kt new file mode 100644 index 000000000..b992f772f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/StrokeTextView.kt @@ -0,0 +1,89 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.* +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +@Suppress("unused") +open class StrokeTextView(context: Context, attrs: AttributeSet?) : + AppCompatTextView(context, attrs) { + + private var radius = 1.dp + private val isBottomBackground: Boolean + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.StrokeTextView) + radius = typedArray.getDimensionPixelOffset(R.styleable.StrokeTextView_radius, radius) + isBottomBackground = + typedArray.getBoolean(R.styleable.StrokeTextView_isBottomBackground, false) + typedArray.recycle() + upBackground() + } + + fun setRadius(radius: Int) { + this.radius = radius.dp + upBackground() + } + + private fun upBackground() { + when { + isInEditMode -> { + background = Selector.shapeBuild() + .setCornerRadius(radius) + .setStrokeWidth(1.dp) + .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) + .setDefaultStrokeColor(context.getCompatColor(R.color.secondaryText)) + .setSelectedStrokeColor(context.getCompatColor(R.color.accent)) + .setPressedBgColor(context.getCompatColor(R.color.transparent30)) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(context.getCompatColor(R.color.secondaryText)) + .setSelectedColor(context.getCompatColor(R.color.accent)) + .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) + .create() + ) + } + isBottomBackground -> { + val isLight = ColorUtils.isColorLight(context.bottomBackground) + background = Selector.shapeBuild() + .setCornerRadius(radius) + .setStrokeWidth(1.dp) + .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) + .setDefaultStrokeColor(context.getPrimaryTextColor(isLight)) + .setSelectedStrokeColor(context.accentColor) + .setPressedBgColor(context.getCompatColor(R.color.transparent30)) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(context.getPrimaryTextColor(isLight)) + .setSelectedColor(context.accentColor) + .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) + .create() + ) + } + else -> { + background = Selector.shapeBuild() + .setCornerRadius(radius) + .setStrokeWidth(1.dp) + .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) + .setDefaultStrokeColor(ThemeStore.textColorSecondary(context)) + .setSelectedStrokeColor(ThemeStore.accentColor(context)) + .setPressedBgColor(context.getCompatColor(R.color.transparent30)) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(ThemeStore.textColorSecondary(context)) + .setSelectedColor(ThemeStore.accentColor(context)) + .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) + .create() + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/text/TextInputLayout.kt b/app/src/main/java/io/legado/app/ui/widget/text/TextInputLayout.kt new file mode 100644 index 000000000..015e848c3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/text/TextInputLayout.kt @@ -0,0 +1,18 @@ +package io.legado.app.ui.widget.text + +import android.content.Context +import android.util.AttributeSet +import com.google.android.material.textfield.TextInputLayout +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore + +class TextInputLayout(context: Context, attrs: AttributeSet?) : TextInputLayout(context, attrs) { + + init { + if (!isInEditMode) { + defaultHintTextColor = + Selector.colorBuild().setDefaultColor(ThemeStore.accentColor(context)).create() + } + } + +} diff --git a/app/src/main/java/io/legado/app/utils/ACache.kt b/app/src/main/java/io/legado/app/utils/ACache.kt new file mode 100644 index 000000000..11b448aa6 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ACache.kt @@ -0,0 +1,781 @@ +//Copyright (c) 2017. 章钦豪. All rights reserved. +package io.legado.app.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.PixelFormat +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import org.json.JSONArray +import org.json.JSONObject +import splitties.init.appCtx +import timber.log.Timber +import java.io.* +import java.util.* +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.min + + +/** + * 本地缓存 + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +class ACache private constructor(cacheDir: File, max_size: Long, max_count: Int) { + + companion object { + const val TIME_HOUR = 60 * 60 + const val TIME_DAY = TIME_HOUR * 24 + private const val MAX_SIZE = 1000 * 1000 * 50 // 50 mb + private const val MAX_COUNT = Integer.MAX_VALUE // 不限制存放数据的数量 + private val mInstanceMap = HashMap() + + @JvmOverloads + fun get( + ctx: Context, + cacheName: String = "ACache", + maxSize: Long = MAX_SIZE.toLong(), + maxCount: Int = MAX_COUNT, + cacheDir: Boolean = true + ): ACache { + val f = if (cacheDir) File(ctx.cacheDir, cacheName) else File(ctx.filesDir, cacheName) + return get(f, maxSize, maxCount) + } + + @JvmOverloads + fun get( + cacheDir: File, + maxSize: Long = MAX_SIZE.toLong(), + maxCount: Int = MAX_COUNT + ): ACache { + synchronized(this) { + var manager = mInstanceMap[cacheDir.absoluteFile.toString() + myPid()] + if (manager == null) { + manager = ACache(cacheDir, maxSize, maxCount) + mInstanceMap[cacheDir.absolutePath + myPid()] = manager + } + return manager + } + } + + private fun myPid(): String { + return "_" + android.os.Process.myPid() + } + } + + private var mCache: ACacheManager? = null + + init { + try { + if (!cacheDir.exists() && !cacheDir.mkdirs()) { + Timber.i("can't make dirs in %s" + cacheDir.absolutePath) + } + mCache = ACacheManager(cacheDir, max_size, max_count) + } catch (e: Exception) { + Timber.e(e) + } + + } + + // ======================================= + // ============ String数据 读写 ============== + // ======================================= + + /** + * 保存 String数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的String数据 + */ + fun put(key: String, value: String) { + mCache?.let { mCache -> + try { + val file = mCache.newFile(key) + file.writeText(value) + mCache.put(file) + } catch (e: Exception) { + Timber.e(e) + } + } + } + + /** + * 保存 String数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的String数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: String, saveTime: Int) { + put(key, Utils.newStringWithDateInfo(saveTime, value)) + } + + /** + * 读取 String数据 + * + * @return String 数据 + */ + fun getAsString(key: String): String? { + mCache?.let { mCache -> + val file = mCache[key] + if (!file.exists()) + return null + var removeFile = false + try { + val text = file.readText() + if (!Utils.isDue(text)) { + return Utils.clearDateInfo(text) + } else { + removeFile = true + } + } catch (e: IOException) { + Timber.e(e) + } finally { + if (removeFile) + remove(key) + } + } + return null + } + + // ======================================= + // ========== JSONObject 数据 读写 ========= + // ======================================= + + /** + * 保存 JSONObject数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSON数据 + */ + fun put(key: String, value: JSONObject) { + put(key, value.toString()) + } + + /** + * 保存 JSONObject数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSONObject数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: JSONObject, saveTime: Int) { + put(key, value.toString(), saveTime) + } + + /** + * 读取JSONObject数据 + * + * @return JSONObject数据 + */ + fun getAsJSONObject(key: String): JSONObject? { + val json = getAsString(key) ?: return null + return try { + JSONObject(json) + } catch (e: Exception) { + null + } + } + + // ======================================= + // ============ JSONArray 数据 读写 ============= + // ======================================= + + /** + * 保存 JSONArray数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSONArray数据 + */ + fun put(key: String, value: JSONArray) { + put(key, value.toString()) + } + + /** + * 保存 JSONArray数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSONArray数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: JSONArray, saveTime: Int) { + put(key, value.toString(), saveTime) + } + + /** + * 读取JSONArray数据 + * + * @return JSONArray数据 + */ + fun getAsJSONArray(key: String): JSONArray? { + val json = getAsString(key) + return try { + JSONArray(json) + } catch (e: Exception) { + null + } + + } + + // ======================================= + // ============== byte 数据 读写 ============= + // ======================================= + + /** + * 保存 byte数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的数据 + */ + fun put(key: String, value: ByteArray) { + mCache?.let { mCache -> + val file = mCache.newFile(key) + file.writeBytes(value) + mCache.put(file) + } + } + + /** + * 保存 byte数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: ByteArray, saveTime: Int) { + put(key, Utils.newByteArrayWithDateInfo(saveTime, value)) + } + + /** + * 获取 byte 数据 + * + * @return byte 数据 + */ + fun getAsBinary(key: String): ByteArray? { + mCache?.let { mCache -> + var removeFile = false + try { + val file = mCache[key] + if (!file.exists()) + return null + + val byteArray = file.readBytes() + return if (!Utils.isDue(byteArray)) { + Utils.clearDateInfo(byteArray) + } else { + removeFile = true + null + } + } catch (e: Exception) { + Timber.e(e) + } finally { + if (removeFile) + remove(key) + } + } + return null + } + + /** + * 保存 Serializable数据到 缓存中 + * + * @param key 保存的key + * @param value 保存的value + * @param saveTime 保存的时间,单位:秒 + */ + @JvmOverloads + fun put(key: String, value: Serializable, saveTime: Int = -1) { + try { + val byteArrayOutputStream = ByteArrayOutputStream() + ObjectOutputStream(byteArrayOutputStream).use { oos -> + oos.writeObject(value) + val data = byteArrayOutputStream.toByteArray() + if (saveTime != -1) { + put(key, data, saveTime) + } else { + put(key, data) + } + } + } catch (e: Exception) { + Timber.e(e) + } + } + + /** + * 读取 Serializable数据 + * + * @return Serializable 数据 + */ + fun getAsObject(key: String): Any? { + val data = getAsBinary(key) + if (data != null) { + var bis: ByteArrayInputStream? = null + var ois: ObjectInputStream? = null + try { + bis = ByteArrayInputStream(data) + ois = ObjectInputStream(bis) + return ois.readObject() + } catch (e: Exception) { + Timber.e(e) + } finally { + try { + bis?.close() + } catch (e: IOException) { + Timber.e(e) + } + + try { + ois?.close() + } catch (e: IOException) { + Timber.e(e) + } + + } + } + return null + + } + + // ======================================= + // ============== bitmap 数据 读写 ============= + // ======================================= + + /** + * 保存 bitmap 到 缓存中 + * + * @param key 保存的key + * @param value 保存的bitmap数据 + */ + fun put(key: String, value: Bitmap) { + put(key, Utils.bitmap2Bytes(value)) + } + + /** + * 保存 bitmap 到 缓存中 + * + * @param key 保存的key + * @param value 保存的 bitmap 数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: Bitmap, saveTime: Int) { + put(key, Utils.bitmap2Bytes(value), saveTime) + } + + /** + * 读取 bitmap 数据 + * + * @return bitmap 数据 + */ + fun getAsBitmap(key: String): Bitmap? { + return if (getAsBinary(key) == null) { + null + } else Utils.bytes2Bitmap(getAsBinary(key)!!) + } + + // ======================================= + // ============= drawable 数据 读写 ============= + // ======================================= + + /** + * 保存 drawable 到 缓存中 + * + * @param key 保存的key + * @param value 保存的drawable数据 + */ + fun put(key: String, value: Drawable) { + put(key, Utils.drawable2Bitmap(value)) + } + + /** + * 保存 drawable 到 缓存中 + * + * @param key 保存的key + * @param value 保存的 drawable 数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: Drawable, saveTime: Int) { + put(key, Utils.drawable2Bitmap(value), saveTime) + } + + /** + * 读取 Drawable 数据 + * + * @return Drawable 数据 + */ + fun getAsDrawable(key: String): Drawable? { + return if (getAsBinary(key) == null) { + null + } else Utils.bitmap2Drawable( + Utils.bytes2Bitmap( + getAsBinary(key)!! + ) + ) + } + + /** + * 获取缓存文件 + * + * @return value 缓存的文件 + */ + fun file(key: String): File? { + mCache?.let { mCache -> + try { + val f = mCache.newFile(key) + if (f.exists()) { + return f + } + } catch (e: Exception) { + Timber.e(e) + } + } + return null + } + + /** + * 移除某个key + * + * @return 是否移除成功 + */ + fun remove(key: String): Boolean { + return mCache?.remove(key) == true + } + + /** + * 清除所有数据 + */ + fun clear() { + mCache?.clear() + } + + /** + * @author 杨福海(michael) www.yangfuhai.com + * @version 1.0 + * title 时间计算工具类 + */ + private object Utils { + + private const val mSeparator = ' ' + + /** + * 判断缓存的String数据是否到期 + * + * @return true:到期了 false:还没有到期 + */ + fun isDue(str: String): Boolean { + return isDue(str.toByteArray()) + } + + /** + * 判断缓存的byte数据是否到期 + * + * @return true:到期了 false:还没有到期 + */ + fun isDue(data: ByteArray): Boolean { + try { + val text = getDateInfoFromDate(data) + if (text != null && text.size == 2) { + var saveTimeStr = text[0] + while (saveTimeStr.startsWith("0")) { + saveTimeStr = saveTimeStr + .substring(1) + } + val saveTime = java.lang.Long.valueOf(saveTimeStr) + val deleteAfter = java.lang.Long.valueOf(text[1]) + if (System.currentTimeMillis() > saveTime + deleteAfter * 1000) { + return true + } + } + } catch (e: Exception) { + Timber.e(e) + } + + return false + } + + fun newStringWithDateInfo(second: Int, strInfo: String): String { + return createDateInfo(second) + strInfo + } + + fun newByteArrayWithDateInfo(second: Int, data2: ByteArray): ByteArray { + val data1 = createDateInfo(second).toByteArray() + val retData = ByteArray(data1.size + data2.size) + System.arraycopy(data1, 0, retData, 0, data1.size) + System.arraycopy(data2, 0, retData, data1.size, data2.size) + return retData + } + + fun clearDateInfo(strInfo: String?): String? { + strInfo?.let { + if (hasDateInfo(strInfo.toByteArray())) { + return strInfo.substring(strInfo.indexOf(mSeparator) + 1) + } + } + return strInfo + } + + fun clearDateInfo(data: ByteArray): ByteArray { + return if (hasDateInfo(data)) { + copyOfRange( + data, indexOf(data, mSeparator) + 1, + data.size + ) + } else data + } + + fun hasDateInfo(data: ByteArray?): Boolean { + return (data != null && data.size > 15 && data[13] == '-'.code.toByte() + && indexOf(data, mSeparator) > 14) + } + + fun getDateInfoFromDate(data: ByteArray): Array? { + if (hasDateInfo(data)) { + val saveDate = String(copyOfRange(data, 0, 13)) + val deleteAfter = String( + copyOfRange( + data, 14, + indexOf(data, mSeparator) + ) + ) + return arrayOf(saveDate, deleteAfter) + } + return null + } + + @Suppress("SameParameterValue") + private fun indexOf(data: ByteArray, c: Char): Int { + for (i in data.indices) { + if (data[i] == c.code.toByte()) { + return i + } + } + return -1 + } + + private fun copyOfRange(original: ByteArray, from: Int, to: Int): ByteArray { + val newLength = to - from + require(newLength >= 0) { "$from > $to" } + val copy = ByteArray(newLength) + System.arraycopy( + original, from, copy, 0, + min(original.size - from, newLength) + ) + return copy + } + + private fun createDateInfo(second: Int): String { + val currentTime = StringBuilder(System.currentTimeMillis().toString() + "") + while (currentTime.length < 13) { + currentTime.insert(0, "0") + } + return "$currentTime-$second$mSeparator" + } + + /* + * Bitmap → byte[] + */ + fun bitmap2Bytes(bm: Bitmap): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + bm.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) + return byteArrayOutputStream.toByteArray() + } + + /* + * byte[] → Bitmap + */ + fun bytes2Bitmap(b: ByteArray): Bitmap? { + return if (b.isEmpty()) { + null + } else BitmapFactory.decodeByteArray(b, 0, b.size) + } + + /* + * Drawable → Bitmap + */ + fun drawable2Bitmap(drawable: Drawable): Bitmap { + // 取 drawable 的长宽 + val w = drawable.intrinsicWidth + val h = drawable.intrinsicHeight + // 取 drawable 的颜色格式 + @Suppress("DEPRECATION") + val config = if (drawable.opacity != PixelFormat.OPAQUE) + Bitmap.Config.ARGB_8888 + else + Bitmap.Config.RGB_565 + // 建立对应 bitmap + val bitmap = Bitmap.createBitmap(w, h, config) + // 建立对应 bitmap 的画布 + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, w, h) + // 把 drawable 内容画到画布中 + drawable.draw(canvas) + return bitmap + } + + /* + * Bitmap → Drawable + */ + fun bitmap2Drawable(bm: Bitmap?): Drawable? { + return if (bm == null) { + null + } else BitmapDrawable(appCtx.resources, bm) + } + } + + /** + * @author 杨福海(michael) www.yangfuhai.com + * @version 1.0 + * title 缓存管理器 + */ + open inner class ACacheManager( + private var cacheDir: File, + private val sizeLimit: Long, + private val countLimit: Int + ) { + private val cacheSize: AtomicLong = AtomicLong() + private val cacheCount: AtomicInteger = AtomicInteger() + private val lastUsageDates = Collections + .synchronizedMap(HashMap()) + + init { + calculateCacheSizeAndCacheCount() + } + + /** + * 计算 cacheSize和cacheCount + */ + private fun calculateCacheSizeAndCacheCount() { + Thread { + + try { + var size = 0 + var count = 0 + val cachedFiles = cacheDir.listFiles() + if (cachedFiles != null) { + for (cachedFile in cachedFiles) { + size += calculateSize(cachedFile).toInt() + count += 1 + lastUsageDates[cachedFile] = cachedFile.lastModified() + } + cacheSize.set(size.toLong()) + cacheCount.set(count) + } + } catch (e: Exception) { + Timber.e(e) + } + + + }.start() + } + + fun put(file: File) { + + try { + var curCacheCount = cacheCount.get() + while (curCacheCount + 1 > countLimit) { + val freedSize = removeNext() + cacheSize.addAndGet(-freedSize) + + curCacheCount = cacheCount.addAndGet(-1) + } + cacheCount.addAndGet(1) + + val valueSize = calculateSize(file) + var curCacheSize = cacheSize.get() + while (curCacheSize + valueSize > sizeLimit) { + val freedSize = removeNext() + curCacheSize = cacheSize.addAndGet(-freedSize) + } + cacheSize.addAndGet(valueSize) + + val currentTime = System.currentTimeMillis() + file.setLastModified(currentTime) + lastUsageDates[file] = currentTime + } catch (e: Exception) { + Timber.e(e) + } + + } + + operator fun get(key: String): File { + val file = newFile(key) + val currentTime = System.currentTimeMillis() + file.setLastModified(currentTime) + lastUsageDates[file] = currentTime + + return file + } + + fun newFile(key: String): File { + return File(cacheDir, key.hashCode().toString() + "") + } + + fun remove(key: String): Boolean { + val image = get(key) + return image.delete() + } + + fun clear() { + try { + lastUsageDates.clear() + cacheSize.set(0) + val files = cacheDir.listFiles() + if (files != null) { + for (f in files) { + f.delete() + } + } + } catch (e: Exception) { + Timber.e(e) + } + + } + + /** + * 移除旧的文件 + */ + private fun removeNext(): Long { + try { + if (lastUsageDates.isEmpty()) { + return 0 + } + + var oldestUsage: Long? = null + var mostLongUsedFile: File? = null + val entries = lastUsageDates.entries + synchronized(lastUsageDates) { + for ((key, lastValueUsage) in entries) { + if (mostLongUsedFile == null) { + mostLongUsedFile = key + oldestUsage = lastValueUsage + } else { + if (lastValueUsage < oldestUsage!!) { + oldestUsage = lastValueUsage + mostLongUsedFile = key + } + } + } + } + + var fileSize: Long = 0 + if (mostLongUsedFile != null) { + fileSize = calculateSize(mostLongUsedFile!!) + if (mostLongUsedFile!!.delete()) { + lastUsageDates.remove(mostLongUsedFile) + } + } + return fileSize + } catch (e: Exception) { + Timber.e(e) + return 0 + } + + } + + private fun calculateSize(file: File): Long { + return file.length() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt new file mode 100644 index 000000000..9a0fe2355 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt @@ -0,0 +1,190 @@ +package io.legado.app.utils + +import android.app.Activity +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.* +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment +import io.legado.app.R + +inline fun AppCompatActivity.showDialogFragment( + arguments: Bundle.() -> Unit = {} +) { + val dialog = T::class.java.newInstance() + val bundle = Bundle() + bundle.apply(arguments) + dialog.arguments = bundle + dialog.show(supportFragmentManager, T::class.simpleName) +} + +fun AppCompatActivity.showDialogFragment(dialogFragment: DialogFragment) { + dialogFragment.show(supportFragmentManager, dialogFragment::class.simpleName) +} + +val Activity.windowSize: DisplayMetrics + get() { + val displayMetrics = DisplayMetrics() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics: WindowMetrics = windowManager.currentWindowMetrics + val insets = windowMetrics.windowInsets + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + displayMetrics.widthPixels = windowMetrics.bounds.width() - insets.left - insets.right + displayMetrics.heightPixels = windowMetrics.bounds.height() - insets.top - insets.bottom + } else { + @Suppress("DEPRECATION") + windowManager.defaultDisplay.getMetrics(displayMetrics) + } + return displayMetrics + } + +fun Activity.fullScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(true) + } + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + @Suppress("DEPRECATION") + window.clearFlags( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + or WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION + ) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) +} + +/** + * 设置状态栏颜色 + */ +fun Activity.setStatusBarColorAuto( + @ColorInt color: Int, + isTransparent: Boolean, + fullScreen: Boolean +) { + val isLightBar = ColorUtils.isColorLight(color) + if (fullScreen) { + if (isTransparent) { + window.statusBarColor = Color.TRANSPARENT + } else { + window.statusBarColor = getCompatColor(R.color.status_bar_bag) + } + } else { + window.statusBarColor = color + } + setLightStatusBar(isLightBar) +} + +fun Activity.setLightStatusBar(isLightBar: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.let { + if (isLightBar) { + it.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + } else { + it.setSystemBarsAppearance( + 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + } + } + } + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val decorView = window.decorView + val systemUiVisibility = decorView.systemUiVisibility + if (isLightBar) { + decorView.systemUiVisibility = + systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else { + decorView.systemUiVisibility = + systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } + } +} + +/** + * 设置导航栏颜色 + */ +fun Activity.setNavigationBarColorAuto(@ColorInt color: Int) { + val isLightBor = ColorUtils.isColorLight(color) + window.navigationBarColor = color + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.let { + if (isLightBor) { + it.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS, + WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ) + } else { + it.setSystemBarsAppearance( + 0, + WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ) + } + } + } + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val decorView = window.decorView + var systemUiVisibility = decorView.systemUiVisibility + systemUiVisibility = if (isLightBor) { + systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } else { + systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() + } + decorView.systemUiVisibility = systemUiVisibility + } +} + +/////以下方法需要在View完全被绘制出来之后调用,否则判断不了,在比如 onWindowFocusChanged()方法中可以得到正确的结果///// + +/** + * 返回NavigationBar + */ +val Activity.navigationBar: View? + get() { + val viewGroup = (window.decorView as? ViewGroup) ?: return null + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) + val childId = child.id + if (childId != View.NO_ID + && resources.getResourceEntryName(childId) == "navigationBarBackground" + ) { + return child + } + } + return null + } + +/** + * 返回NavigationBar是否存在 + */ +val Activity.isNavigationBarExist: Boolean + get() = navigationBar != null + +/** + * 返回NavigationBar高度 + */ +val Activity.navigationBarHeight: Int + get() { + if (isNavigationBarExist) { + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) + } + return 0 + } + +/** + * 返回navigationBar位置 + */ +val Activity.navigationBarGravity: Int + get() { + val gravity = (navigationBar?.layoutParams as? FrameLayout.LayoutParams)?.gravity + return gravity ?: Gravity.BOTTOM + } diff --git a/app/src/main/java/io/legado/app/utils/ActivityResultContracts.kt b/app/src/main/java/io/legado/app/utils/ActivityResultContracts.kt new file mode 100644 index 000000000..6a8e94a00 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ActivityResultContracts.kt @@ -0,0 +1,57 @@ +package io.legado.app.utils + +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract + +fun ActivityResultLauncher<*>.launch() { + launch(null) +} + +class SelectImageContract : ActivityResultContract() { + + var requestCode: Int? = null + + override fun createIntent(context: Context, input: Int?): Intent { + requestCode = input + return Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("image/*") + } + + override fun parseResult(resultCode: Int, intent: Intent?): Result { + if (resultCode == RESULT_OK) { + return Result(requestCode, intent?.data) + } + return Result(requestCode, null) + } + + data class Result( + val requestCode: Int?, + val uri: Uri? = null + ) + +} + +class StartActivityContract(private val cls: Class<*>) : + ActivityResultContract<(Intent.() -> Unit)?, ActivityResult>() { + + override fun createIntent(context: Context, input: (Intent.() -> Unit)?): Intent { + val intent = Intent(context, cls) + input?.let { + intent.apply(input) + } + return intent + } + + override fun parseResult( + resultCode: Int, intent: Intent? + ): ActivityResult { + return ActivityResult(resultCode, intent) + } + +} diff --git a/app/src/main/java/io/legado/app/utils/AnimationUtilsSupport.kt b/app/src/main/java/io/legado/app/utils/AnimationUtilsSupport.kt new file mode 100644 index 000000000..1400b11f2 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/AnimationUtilsSupport.kt @@ -0,0 +1,17 @@ +package io.legado.app.utils + +import android.content.Context +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.AnimRes +import io.legado.app.help.AppConfig + +object AnimationUtilsSupport { + fun loadAnimation(context: Context, @AnimRes id: Int): Animation { + val animation = AnimationUtils.loadAnimation(context, id) + if (AppConfig.isEInkMode) { + animation.duration = 0 + } + return animation + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/BitmapUtils.kt b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt new file mode 100644 index 000000000..e0f5d7114 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt @@ -0,0 +1,311 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.Context +import android.graphics.* +import android.graphics.Bitmap.Config +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import android.view.View +import splitties.init.appCtx +import java.io.FileInputStream +import java.io.IOException +import kotlin.math.* + + +@Suppress("WeakerAccess", "MemberVisibilityCanBePrivate") +object BitmapUtils { + + /** + * 从path中获取图片信息,在通过BitmapFactory.decodeFile(String path)方法将突破转成Bitmap时, + * 遇到大一些的图片,我们经常会遇到OOM(Out Of Memory)的问题。所以用到了我们上面提到的BitmapFactory.Options这个类。 + * + * @param path 文件路径 + * @param width 想要显示的图片的宽度 + * @param height 想要显示的图片的高度 + * @return + */ + @Throws(IOException::class) + fun decodeBitmap(path: String, width: Int, height: Int): Bitmap { + val op = BitmapFactory.Options() + val ips = FileInputStream(path) + // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; + op.inJustDecodeBounds = true + BitmapFactory.decodeFileDescriptor(ips.fd, null, op) + //获取比例大小 + val wRatio = ceil((op.outWidth / width).toDouble()).toInt() + val hRatio = ceil((op.outHeight / height).toDouble()).toInt() + //如果超出指定大小,则缩小相应的比例 + if (wRatio > 1 && hRatio > 1) { + if (wRatio > hRatio) { + op.inSampleSize = wRatio + } else { + op.inSampleSize = hRatio + } + } + op.inJustDecodeBounds = false + return BitmapFactory.decodeFileDescriptor(ips.fd, null, op) + } + + /** 从path中获取Bitmap图片 + * @param path 图片路径 + * @return + */ + @Throws(IOException::class) + fun decodeBitmap(path: String): Bitmap { + val opts = BitmapFactory.Options() + val ips = FileInputStream(path) + opts.inJustDecodeBounds = true + BitmapFactory.decodeFileDescriptor(ips.fd, null, opts) + opts.inSampleSize = computeSampleSize(opts, -1, 128 * 128) + opts.inJustDecodeBounds = false + return BitmapFactory.decodeFileDescriptor(ips.fd, null, opts) + } + + /** + * 以最省内存的方式读取本地资源的图片 + * @param context 设备上下文 + * @param resId 资源ID + * @return + */ + fun decodeBitmap(context: Context, resId: Int): Bitmap? { + val opt = BitmapFactory.Options() + opt.inPreferredConfig = Config.RGB_565 + //获取资源图片 + val `is` = context.resources.openRawResource(resId) + return BitmapFactory.decodeStream(`is`, null, opt) + } + + /** + * @param context 设备上下文 + * @param resId 资源ID + * @param width + * @param height + * @return + */ + fun decodeBitmap(context: Context, resId: Int, width: Int, height: Int): Bitmap? { + + var inputStream = context.resources.openRawResource(resId) + + val op = BitmapFactory.Options() + // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; + op.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, op) //获取尺寸信息 + //获取比例大小 + val wRatio = ceil((op.outWidth / width).toDouble()).toInt() + val hRatio = ceil((op.outHeight / height).toDouble()).toInt() + //如果超出指定大小,则缩小相应的比例 + if (wRatio > 1 && hRatio > 1) { + if (wRatio > hRatio) { + op.inSampleSize = wRatio + } else { + op.inSampleSize = hRatio + } + } + inputStream = context.resources.openRawResource(resId) + op.inJustDecodeBounds = false + return BitmapFactory.decodeStream(inputStream, null, op) + } + + /** + * @param context 设备上下文 + * @param fileNameInAssets Assets里面文件的名称 + * @param width 图片的宽度 + * @param height 图片的高度 + * @return Bitmap + * @throws IOException + */ + @Throws(IOException::class) + fun decodeAssetsBitmap( + context: Context, + fileNameInAssets: String, + width: Int, + height: Int + ): Bitmap? { + var inputStream = context.assets.open(fileNameInAssets) + val op = BitmapFactory.Options() + // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; + op.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, op) //获取尺寸信息 + //获取比例大小 + val wRatio = ceil((op.outWidth / width).toDouble()).toInt() + val hRatio = ceil((op.outHeight / height).toDouble()).toInt() + //如果超出指定大小,则缩小相应的比例 + if (wRatio > 1 && hRatio > 1) { + if (wRatio > hRatio) { + op.inSampleSize = wRatio + } else { + op.inSampleSize = hRatio + } + } + inputStream = context.assets.open(fileNameInAssets) + op.inJustDecodeBounds = false + return BitmapFactory.decodeStream(inputStream, null, op) + } + + //图片不被压缩 + fun convertViewToBitmap(view: View, bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Config.ARGB_8888) + view.draw(Canvas(bitmap)) + return bitmap + } + + /** + * @param options + * @param minSideLength + * @param maxNumOfPixels + * @return + * 设置恰当的inSampleSize是解决该问题的关键之一。BitmapFactory.Options提供了另一个成员inJustDecodeBounds。 + * 设置inJustDecodeBounds为true后,decodeFile并不分配空间,但可计算出原始图片的长度和宽度,即opts.width和opts.height。 + * 有了这两个参数,再通过一定的算法,即可得到一个恰当的inSampleSize。 + * 查看Android源码,Android提供了下面这种动态计算的方法。 + */ + fun computeSampleSize( + options: BitmapFactory.Options, + minSideLength: Int, + maxNumOfPixels: Int + ): Int { + val initialSize = computeInitialSampleSize(options, minSideLength, maxNumOfPixels) + var roundedSize: Int + if (initialSize <= 8) { + roundedSize = 1 + while (roundedSize < initialSize) { + roundedSize = roundedSize shl 1 + } + } else { + roundedSize = (initialSize + 7) / 8 * 8 + } + return roundedSize + } + + + private fun computeInitialSampleSize( + options: BitmapFactory.Options, + minSideLength: Int, + maxNumOfPixels: Int + ): Int { + + val w = options.outWidth.toDouble() + val h = options.outHeight.toDouble() + + val lowerBound = when (maxNumOfPixels) { + -1 -> 1 + else -> ceil(sqrt(w * h / maxNumOfPixels)).toInt() + } + + val upperBound = when (minSideLength) { + -1 -> 128 + else -> min( + floor(w / minSideLength), + floor(h / minSideLength) + ).toInt() + } + + if (upperBound < lowerBound) { + // return the larger one when there is no overlapping zone. + return lowerBound + } + + return when { + maxNumOfPixels == -1 && minSideLength == -1 -> { + 1 + } + minSideLength == -1 -> { + lowerBound + } + else -> { + upperBound + } + } + } + +} + +fun Bitmap.changeSize(newWidth: Int, newHeight: Int): Bitmap { + val width = this.width + val height = this.height + + //计算压缩的比率 + var scaleWidth = newWidth.toFloat() / width + var scaleHeight = newHeight.toFloat() / height + + if (scaleWidth > scaleHeight) { + scaleWidth = scaleHeight + } else { + scaleHeight = scaleWidth + } + + //获取想要缩放的matrix + val matrix = Matrix() + matrix.postScale(scaleWidth, scaleHeight) + + //获取新的bitmap + return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) + +} + +/** + * 高斯模糊 + */ +fun Bitmap.stackBlur(radius: Float = 8f): Bitmap? { + val rs = RenderScript.create(appCtx) + val blurredBitmap = this.copy(Config.ARGB_8888, true) + + //分配用于渲染脚本的内存 + val input = Allocation.createFromBitmap( + rs, + blurredBitmap, + Allocation.MipmapControl.MIPMAP_FULL, + Allocation.USAGE_SHARED + ) + val output = Allocation.createTyped(rs, input.type) + + //加载我们想要使用的特定脚本的实例。 + val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + script.setInput(input) + + //设置模糊半径 + script.setRadius(radius) + + //启动 ScriptIntrinsicBlur + script.forEach(output) + + //将输出复制到模糊的位图 + output.copyTo(blurredBitmap) + + return blurredBitmap +} + +/** + * 取平均色 + */ +fun Bitmap.getMeanColor(): Int { + val width: Int = this.width + val height: Int = this.height + var pixel: Int + var pixelSumRed = 0 + var pixelSumBlue = 0 + var pixelSumGreen = 0 + for (i in 0..99) { + for (j in 70..99) { + pixel = this.getPixel( + (i * width / 100.toFloat()).roundToInt(), + (j * height / 100.toFloat()).roundToInt() + ) + pixelSumRed += Color.red(pixel) + pixelSumGreen += Color.green(pixel) + pixelSumBlue += Color.blue(pixel) + } + } + val averagePixelRed = pixelSumRed / 3000 + val averagePixelBlue = pixelSumBlue / 3000 + val averagePixelGreen = pixelSumGreen / 3000 + return Color.rgb( + averagePixelRed + 3, + averagePixelGreen + 3, + averagePixelBlue + 3 + ) +} diff --git a/app/src/main/java/io/legado/app/utils/ColorUtils.kt b/app/src/main/java/io/legado/app/utils/ColorUtils.kt new file mode 100644 index 000000000..8105bab29 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ColorUtils.kt @@ -0,0 +1,249 @@ +package io.legado.app.utils + +import android.graphics.Color + +import androidx.annotation.ColorInt +import androidx.annotation.FloatRange +import java.util.* +import kotlin.math.* + +@Suppress("unused", "MemberVisibilityCanBePrivate") +object ColorUtils { + + fun intToString(intColor: Int): String { + return String.format("#%06X", 0xFFFFFF and intColor) + } + + + fun stripAlpha(@ColorInt color: Int): Int { + return -0x1000000 or color + } + + @ColorInt + fun shiftColor(@ColorInt color: Int, @FloatRange(from = 0.0, to = 2.0) by: Float): Int { + if (by == 1f) return color + val alpha = Color.alpha(color) + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[2] *= by // value component + return (alpha shl 24) + (0x00ffffff and Color.HSVToColor(hsv)) + } + + @ColorInt + fun darkenColor(@ColorInt color: Int): Int { + return shiftColor(color, 0.9f) + } + + @ColorInt + fun lightenColor(@ColorInt color: Int): Int { + return shiftColor(color, 1.1f) + } + + fun isColorLight(@ColorInt color: Int): Boolean { + val darkness = + 1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255 + return darkness < 0.4 + } + + @ColorInt + fun invertColor(@ColorInt color: Int): Int { + val r = 255 - Color.red(color) + val g = 255 - Color.green(color) + val b = 255 - Color.blue(color) + return Color.argb(Color.alpha(color), r, g, b) + } + + @ColorInt + fun adjustAlpha(@ColorInt color: Int, @FloatRange(from = 0.0, to = 1.0) factor: Float): Int { + val alpha = (Color.alpha(color) * factor).roundToInt() + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + @ColorInt + fun withAlpha(@ColorInt baseColor: Int, @FloatRange(from = 0.0, to = 1.0) alpha: Float): Int { + val a = min(255, max(0, (alpha * 255).toInt())) shl 24 + val rgb = 0x00ffffff and baseColor + return a + rgb + } + + /** + * Taken from CollapsingToolbarLayout's CollapsingTextHelper class. + */ + fun blendColors(color1: Int, color2: Int, @FloatRange(from = 0.0, to = 1.0) ratio: Float): Int { + val inverseRatio = 1f - ratio + val a = Color.alpha(color1) * inverseRatio + Color.alpha(color2) * ratio + val r = Color.red(color1) * inverseRatio + Color.red(color2) * ratio + val g = Color.green(color1) * inverseRatio + Color.green(color2) * ratio + val b = Color.blue(color1) * inverseRatio + Color.blue(color2) * ratio + return Color.argb(a.toInt(), r.toInt(), g.toInt(), b.toInt()) + } + + /** + * 按条件的到随机颜色 + * + * @param alpha 透明 + * @param lower 下边界 + * @param upper 上边界 + * @return 颜色值 + */ + fun getRandomColor(alpha: Int, lower: Int, upper: Int): Int { + return RandomColor(alpha, lower, upper).color + } + + /** + * @return 获取随机色 + */ + fun getRandomColor(): Int { + return RandomColor(255, 80, 200).color + } + + + /** + * 随机颜色 + */ + class RandomColor(alpha: Int, lower: Int, upper: Int) { + private var alpha: Int = 0 + private var lower: Int = 0 + private var upper: Int = 0 + + //随机数是前闭 后开 + val color: Int + get() { + val red = getLower() + Random().nextInt(getUpper() - getLower() + 1) + val green = getLower() + Random().nextInt(getUpper() - getLower() + 1) + val blue = getLower() + Random().nextInt(getUpper() - getLower() + 1) + + return Color.argb(getAlpha(), red, green, blue) + } + + init { + require(upper > lower) { "must be lower < upper" } + setAlpha(alpha) + setLower(lower) + setUpper(upper) + } + + private fun getAlpha(): Int { + return alpha + } + + private fun setAlpha(alpha: Int) { + var alpha1 = alpha + if (alpha1 > 255) alpha1 = 255 + if (alpha1 < 0) alpha1 = 0 + this.alpha = alpha1 + } + + private fun getLower(): Int { + return lower + } + + private fun setLower(lower: Int) { + var lower1 = lower + if (lower1 < 0) lower1 = 0 + this.lower = lower1 + } + + private fun getUpper(): Int { + return upper + } + + private fun setUpper(upper: Int) { + var upper1 = upper + if (upper1 > 255) upper1 = 255 + this.upper = upper1 + } + } + + fun argb(R: Int, G: Int, B: Int): Int { + return argb(Byte.MAX_VALUE.toInt(), R, G, B) + } + + fun argb(A: Int, R: Int, G: Int, B: Int): Int { + val colorByteArr = + byteArrayOf(A.toByte(), R.toByte(), G.toByte(), B.toByte()) + return byteArrToInt(colorByteArr) + } + + fun rgb(argb: Int): IntArray { + return intArrayOf(argb shr 16 and 0xFF, argb shr 8 and 0xFF, argb and 0xFF) + } + + fun byteArrToInt(colorByteArr: ByteArray): Int { + return ((colorByteArr[0].toInt() shl 24) + (colorByteArr[1].toInt() and 0xFF shl 16) + + (colorByteArr[2].toInt() and 0xFF shl 8) + (colorByteArr[3].toInt() and 0xFF)) + } + + fun rgb2lab(R: Int, G: Int, B: Int): IntArray { + val x: Float + val y: Float + val z: Float + val fx: Float + val fy: Float + val fz: Float + val xr: Float + val yr: Float + val zr: Float + val eps = 216f / 24389f + val k = 24389f / 27f + val xr1 = 0.964221f // reference white D50 + val yr1 = 1.0f + val zr1 = 0.825211f + + // RGB to XYZ + var r: Float = R / 255f //R 0..1 + var g: Float = G / 255f //G 0..1 + var b: Float = B / 255f //B 0..1 + + // assuming sRGB (D65) + r = if (r <= 0.04045) r / 12 else ((r + 0.055) / 1.055).pow(2.4).toFloat() + g = if (g <= 0.04045) g / 12 else ((g + 0.055) / 1.055).pow(2.4).toFloat() + b = if (b <= 0.04045) b / 12 else ((b + 0.055) / 1.055).pow(2.4).toFloat() + x = 0.436052025f * r + 0.385081593f * g + 0.143087414f * b + y = 0.222491598f * r + 0.71688606f * g + 0.060621486f * b + z = 0.013929122f * r + 0.097097002f * g + 0.71418547f * b + + // XYZ to Lab + xr = x / xr1 + yr = y / yr1 + zr = z / zr1 + fx = if (xr > eps) xr.toDouble().pow(1 / 3.0) + .toFloat() else ((k * xr + 16.0) / 116.0).toFloat() + fy = if (yr > eps) yr.toDouble().pow(1 / 3.0) + .toFloat() else ((k * yr + 16.0) / 116.0).toFloat() + fz = if (zr > eps) zr.toDouble().pow(1 / 3.0) + .toFloat() else ((k * zr + 16.0) / 116).toFloat() + val ls: Float = 116 * fy - 16 + val `as`: Float = 500 * (fx - fy) + val bs: Float = 200 * (fy - fz) + val lab = IntArray(3) + lab[0] = (2.55 * ls + .5).toInt() + lab[1] = (`as` + .5).toInt() + lab[2] = (bs + .5).toInt() + return lab + } + + /** + * Computes the difference between two RGB colors by converting them to the L*a*b scale and + * comparing them using the CIE76 algorithm { http://en.wikipedia.org/wiki/Color_difference#CIE76} + */ + fun getColorDifference(a: Int, b: Int): Double { + val r1: Int = Color.red(a) + val g1: Int = Color.green(a) + val b1: Int = Color.blue(a) + val r2: Int = Color.red(b) + val g2: Int = Color.green(b) + val b2: Int = Color.blue(b) + val lab1 = rgb2lab(r1, g1, b1) + val lab2 = rgb2lab(r2, g2, b2) + return sqrt( + (lab2[0] - lab1[0].toDouble()) + .pow(2.0) + (lab2[1] - lab1[1].toDouble()) + .pow(2.0) + (lab2[2] - lab1[2].toDouble()) + .pow(2.0) + ) + } +} diff --git a/app/src/main/java/io/legado/app/utils/ConfigurationExtensions.kt b/app/src/main/java/io/legado/app/utils/ConfigurationExtensions.kt new file mode 100644 index 000000000..186e8ae69 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ConfigurationExtensions.kt @@ -0,0 +1,14 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.res.Configuration +import android.content.res.Resources + +val sysConfiguration: Configuration = Resources.getSystem().configuration + +val Configuration.isNightMode: Boolean + get() { + val mode = uiMode and Configuration.UI_MODE_NIGHT_MASK + return mode == Configuration.UI_MODE_NIGHT_YES + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ConstraintModify.kt b/app/src/main/java/io/legado/app/utils/ConstraintModify.kt new file mode 100644 index 000000000..ed4a55caf --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ConstraintModify.kt @@ -0,0 +1,283 @@ +package io.legado.app.utils + +import androidx.annotation.IdRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.transition.TransitionManager + + +@Suppress("MemberVisibilityCanBePrivate", "unused") +class ConstraintModify(private val constraintLayout: ConstraintLayout) { + + private var begin: ConstraintBegin? = null + private val applyConstraintSet = ConstraintSet() + private val resetConstraintSet = ConstraintSet() + + init { + resetConstraintSet.clone(constraintLayout) + } + + /** + * 开始修改 + */ + fun begin(): ConstraintBegin { + synchronized(ConstraintBegin::class.java) { + if (begin == null) { + begin = ConstraintBegin(constraintLayout, applyConstraintSet) + } + } + applyConstraintSet.clone(constraintLayout) + return begin!! + } + + /** + * 带动画的修改 + * @return + */ + fun beginWithAnim(): ConstraintBegin { + TransitionManager.beginDelayedTransition(constraintLayout) + return begin() + } + + /** + * 重置 + */ + fun reSet() { + resetConstraintSet.applyTo(constraintLayout) + } + + /** + * 带动画的重置 + */ + fun reSetWidthAnim() { + TransitionManager.beginDelayedTransition(constraintLayout) + resetConstraintSet.applyTo(constraintLayout) + } + + + @Suppress("unused", "MemberVisibilityCanBePrivate") + class ConstraintBegin( + private val constraintLayout: ConstraintLayout, + private val applyConstraintSet: ConstraintSet + ) { + + /** + * 清除关系

    + * 注意:这里不仅仅会清除关系,还会清除对应控件的宽高为 w:0,h:0 + * @param viewIds + * @return + */ + fun clear(@IdRes vararg viewIds: Int): ConstraintBegin { + for (viewId in viewIds) { + applyConstraintSet.clear(viewId) + } + return this + } + + /** + * 清除某个控件的,某个关系 + * @param viewId + * @param anchor + * @return + */ + fun clear(viewId: Int, anchor: Int): ConstraintBegin { + applyConstraintSet.clear(viewId, anchor) + return this + } + + fun setHorizontalWeight(viewId: Int, weight: Float): ConstraintBegin { + applyConstraintSet.setHorizontalWeight(viewId, weight) + return this + } + + fun setVerticalWeight(viewId: Int, weight: Float): ConstraintBegin { + applyConstraintSet.setVerticalWeight(viewId, weight) + return this + } + + /** + * 为某个控件设置 margin + * @param viewId 某个控件ID + * @param left marginLeft + * @param top marginTop + * @param right marginRight + * @param bottom marginBottom + * @return + */ + fun setMargin( + @IdRes viewId: Int, + left: Int, + top: Int, + right: Int, + bottom: Int + ): ConstraintBegin { + setMarginLeft(viewId, left) + setMarginTop(viewId, top) + setMarginRight(viewId, right) + setMarginBottom(viewId, bottom) + return this + } + + /** + * 为某个控件设置 marginLeft + * @param viewId 某个控件ID + * @param left marginLeft + * @return + */ + fun setMarginLeft(@IdRes viewId: Int, left: Int): ConstraintBegin { + applyConstraintSet.setMargin(viewId, ConstraintSet.LEFT, left) + return this + } + + /** + * 为某个控件设置 marginRight + * @param viewId 某个控件ID + * @param right marginRight + * @return + */ + fun setMarginRight(@IdRes viewId: Int, right: Int): ConstraintBegin { + applyConstraintSet.setMargin(viewId, ConstraintSet.RIGHT, right) + return this + } + + /** + * 为某个控件设置 marginTop + * @param viewId 某个控件ID + * @param top marginTop + * @return + */ + fun setMarginTop(@IdRes viewId: Int, top: Int): ConstraintBegin { + applyConstraintSet.setMargin(viewId, ConstraintSet.TOP, top) + return this + } + + /** + * 为某个控件设置marginBottom + * @param viewId 某个控件ID + * @param bottom marginBottom + * @return + */ + fun setMarginBottom(@IdRes viewId: Int, bottom: Int): ConstraintBegin { + applyConstraintSet.setMargin(viewId, ConstraintSet.BOTTOM, bottom) + return this + } + + /** + * 为某个控件设置关联关系 left_to_left_of + * @param startId + * @param endId + * @return + */ + fun leftToLeftOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.LEFT, endId, ConstraintSet.LEFT) + return this + } + + /** + * 为某个控件设置关联关系 left_to_right_of + * @param startId + * @param endId + * @return + */ + fun leftToRightOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.LEFT, endId, ConstraintSet.RIGHT) + return this + } + + /** + * 为某个控件设置关联关系 top_to_top_of + * @param startId + * @param endId + * @return + */ + fun topToTopOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP) + return this + } + + /** + * 为某个控件设置关联关系 top_to_bottom_of + * @param startId + * @param endId + * @return + */ + fun topToBottomOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.TOP, endId, ConstraintSet.BOTTOM) + return this + } + + /** + * 为某个控件设置关联关系 right_to_left_of + * @param startId + * @param endId + * @return + */ + fun rightToLeftOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.RIGHT, endId, ConstraintSet.LEFT) + return this + } + + /** + * 为某个控件设置关联关系 right_to_right_of + * @param startId + * @param endId + * @return + */ + fun rightToRightOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.RIGHT, endId, ConstraintSet.RIGHT) + return this + } + + /** + * 为某个控件设置关联关系 bottom_to_bottom_of + * @param startId + * @param endId + * @return + */ + fun bottomToBottomOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM) + return this + } + + /** + * 为某个控件设置关联关系 bottom_to_top_of + * @param startId + * @param endId + * @return + */ + fun bottomToTopOf(@IdRes startId: Int, @IdRes endId: Int): ConstraintBegin { + applyConstraintSet.connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.TOP) + return this + } + + /** + * 为某个控件设置宽度 + * @param viewId + * @param width + * @return + */ + fun setWidth(@IdRes viewId: Int, width: Int): ConstraintBegin { + applyConstraintSet.constrainWidth(viewId, width) + return this + } + + /** + * 某个控件设置高度 + * @param viewId + * @param height + * @return + */ + fun setHeight(@IdRes viewId: Int, height: Int): ConstraintBegin { + applyConstraintSet.constrainHeight(viewId, height) + return this + } + + /** + * 提交应用生效 + */ + fun commit() { + applyConstraintSet.applyTo(constraintLayout) + } + } + +} diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt new file mode 100644 index 000000000..f04eba50c --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt @@ -0,0 +1,340 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.PendingIntent +import android.app.PendingIntent.* +import android.app.Service +import android.content.* +import android.content.pm.PackageManager +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.BatteryManager +import android.os.Build +import android.os.Process +import android.provider.Settings +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import io.legado.app.R +import io.legado.app.constant.AppConst +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream + +inline fun Context.startActivity(configIntent: Intent.() -> Unit = {}) { + val intent = Intent(this, A::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.apply(configIntent) + startActivity(intent) +} + +inline fun Context.startService(configIntent: Intent.() -> Unit = {}) { + startService(Intent(this, T::class.java).apply(configIntent)) +} + +inline fun Context.stopService() { + stopService(Intent(this, T::class.java)) +} + +@SuppressLint("UnspecifiedImmutableFlag") +inline fun Context.servicePendingIntent( + action: String, + configIntent: Intent.() -> Unit = {} +): PendingIntent? { + val intent = Intent(this, T::class.java) + intent.action = action + configIntent.invoke(intent) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + FLAG_UPDATE_CURRENT or FLAG_MUTABLE + } else { + FLAG_UPDATE_CURRENT + } + return getService(this, 0, intent, flags) +} + +@SuppressLint("UnspecifiedImmutableFlag") +inline fun Context.activityPendingIntent( + action: String, + configIntent: Intent.() -> Unit = {} +): PendingIntent? { + val intent = Intent(this, T::class.java) + intent.action = action + configIntent.invoke(intent) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + FLAG_UPDATE_CURRENT or FLAG_MUTABLE + } else { + FLAG_UPDATE_CURRENT + } + return getActivity(this, 0, intent, flags) +} + +@SuppressLint("UnspecifiedImmutableFlag") +inline fun Context.broadcastPendingIntent( + action: String, + configIntent: Intent.() -> Unit = {} +): PendingIntent? { + val intent = Intent(this, T::class.java) + intent.action = action + configIntent.invoke(intent) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + FLAG_UPDATE_CURRENT or FLAG_MUTABLE + } else { + FLAG_UPDATE_CURRENT + } + return getBroadcast(this, 0, intent, flags) +} + +val Context.defaultSharedPreferences: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(this) + +fun Context.getPrefBoolean(key: String, defValue: Boolean = false) = + defaultSharedPreferences.getBoolean(key, defValue) + +fun Context.putPrefBoolean(key: String, value: Boolean = false) = + defaultSharedPreferences.edit { putBoolean(key, value) } + +fun Context.getPrefInt(key: String, defValue: Int = 0) = + defaultSharedPreferences.getInt(key, defValue) + +fun Context.putPrefInt(key: String, value: Int) = + defaultSharedPreferences.edit { putInt(key, value) } + +fun Context.getPrefLong(key: String, defValue: Long = 0L) = + defaultSharedPreferences.getLong(key, defValue) + +fun Context.putPrefLong(key: String, value: Long) = + defaultSharedPreferences.edit { putLong(key, value) } + +fun Context.getPrefString(key: String, defValue: String? = null) = + defaultSharedPreferences.getString(key, defValue) + +fun Context.putPrefString(key: String, value: String?) = + defaultSharedPreferences.edit { putString(key, value) } + +fun Context.getPrefStringSet( + key: String, + defValue: MutableSet? = null +): MutableSet? = defaultSharedPreferences.getStringSet(key, defValue) + +fun Context.putPrefStringSet(key: String, value: MutableSet) = + defaultSharedPreferences.edit { putStringSet(key, value) } + +fun Context.removePref(key: String) = + defaultSharedPreferences.edit { remove(key) } + + +fun Context.getCompatColor(@ColorRes id: Int): Int = ContextCompat.getColor(this, id) + +fun Context.getCompatDrawable(@DrawableRes id: Int): Drawable? = ContextCompat.getDrawable(this, id) + +fun Context.getCompatColorStateList(@ColorRes id: Int): ColorStateList? = + ContextCompat.getColorStateList(this, id) + +fun Context.restart() { + val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName) + intent?.let { + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TOP + ) + startActivity(intent) + //杀掉以前进程 + Process.killProcess(Process.myPid()) + } +} + +/** + * 系统息屏时间 + */ +val Context.sysScreenOffTime: Int + get() { + return kotlin.runCatching { + Settings.System.getInt(contentResolver, Settings.System.SCREEN_OFF_TIMEOUT) + }.onFailure { + Timber.e(it) + }.getOrDefault(0) + } + +val Context.statusBarHeight: Int + get() { + if (Build.BOARD == "windows") { + return 0 + } + val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) + } + +val Context.navigationBarHeight: Int + get() { + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) + } + +fun Context.share(text: String, title: String = getString(R.string.share)) { + kotlin.runCatching { + val intent = Intent(Intent.ACTION_SEND) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(Intent.EXTRA_SUBJECT, title) + intent.putExtra(Intent.EXTRA_TEXT, text) + intent.type = "text/plain" + startActivity(Intent.createChooser(intent, title)) + } +} + +fun Context.share(file: File, type: String = "text/*") { + val fileUri = FileProvider.getUriForFile(this, AppConst.authority, file) + val intent = Intent(Intent.ACTION_SEND) + intent.type = type + intent.putExtra(Intent.EXTRA_STREAM, fileUri) + intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity( + Intent.createChooser( + intent, + getString(R.string.share_selected_source) + ) + ) +} + +@SuppressLint("SetWorldReadable") +fun Context.shareWithQr( + text: String, + title: String = getString(R.string.share), + errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H +) { + val bitmap = QRCodeUtils.createQRCode(text, errorCorrectionLevel = errorCorrectionLevel) + if (bitmap == null) { + toastOnUi(R.string.text_too_long_qr_error) + } else { + try { + val file = File(externalCacheDir, "qr.png") + val fOut = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut) + fOut.flush() + fOut.close() + file.setReadable(true, false) + val contentUri = FileProvider.getUriForFile(this, AppConst.authority, file) + val intent = Intent(Intent.ACTION_SEND) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(Intent.EXTRA_STREAM, contentUri) + intent.type = "image/png" + startActivity(Intent.createChooser(intent, title)) + } catch (e: Exception) { + toastOnUi(e.localizedMessage ?: "ERROR") + } + } +} + +fun Context.sendToClip(text: String) { + val clipboard = + getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clipData = ClipData.newPlainText(null, text) + clipboard?.let { + clipboard.setPrimaryClip(clipData) + longToastOnUi(R.string.copy_complete) + } +} + +fun Context.getClipText(): String? { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + clipboard?.primaryClip?.let { + if (it.itemCount > 0) { + return it.getItemAt(0).text.toString().trim() + } + } + return null +} + +fun Context.sendMail(mail: String) { + try { + val intent = Intent(Intent.ACTION_SENDTO) + intent.data = Uri.parse("mailto:$mail") + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } catch (e: Exception) { + toastOnUi(e.localizedMessage ?: "Error") + } +} + +/** + * 获取电量 + */ +val Context.sysBattery: Int + get() { + val iFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = registerReceiver(null, iFilter) + return batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + } + +val Context.externalFiles: File + get() = this.getExternalFilesDir(null) ?: this.filesDir + +val Context.externalCache: File + get() = this.externalCacheDir ?: this.cacheDir + +fun Context.openUrl(url: String) { + openUrl(Uri.parse(url)) +} + +fun Context.openUrl(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (intent.resolveActivity(packageManager) != null) { + try { + startActivity(intent) + } catch (e: Exception) { + toastOnUi(e.localizedMessage ?: "open url error") + } + } else { + try { + startActivity(Intent.createChooser(intent, "请选择浏览器")) + } catch (e: Exception) { + toastOnUi(e.localizedMessage ?: "open url error") + } + } +} + +fun Context.openFileUri(uri: Uri, type: String? = null) { + val intent = Intent() + intent.action = Intent.ACTION_VIEW + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + //7.0版本以上 + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + intent.setDataAndType(uri, type ?: IntentType.from(uri)) + try { + startActivity(intent) + } catch (e: Exception) { + toastOnUi(e.msg) + } +} + +val Context.isPad: Boolean + get() { + return resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK >= Configuration.SCREENLAYOUT_SIZE_LARGE + } + +val Context.channel: String + get() { + try { + val pm = packageManager + val appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + return appInfo.metaData.getString("channel") ?: "" + } catch (e: Exception) { + Timber.e(e) + } + return "" + } diff --git a/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt b/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt new file mode 100644 index 000000000..9a7e63722 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt @@ -0,0 +1,130 @@ +package io.legado.app.utils + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.text.DecimalFormat +import kotlin.math.log10 +import kotlin.math.pow + +/** + * 数据类型转换、单位转换 + * + * @author 李玉江[QQ:1023694760] + * @since 2014-4-18 + */ +@Suppress("MemberVisibilityCanBePrivate") +object ConvertUtils { + const val GB: Long = 1073741824 + const val MB: Long = 1048576 + const val KB: Long = 1024 + + fun toInt(obj: Any): Int { + return kotlin.runCatching { + Integer.parseInt(obj.toString()) + }.getOrDefault(-1) + } + + fun toInt(bytes: ByteArray): Int { + var result = 0 + var byte: Byte + for (i in bytes.indices) { + byte = bytes[i] + result += (byte.toInt() and 0xFF).shl(8 * i) + } + return result + } + + fun toFloat(obj: Any): Float { + return kotlin.runCatching { + java.lang.Float.parseFloat(obj.toString()) + }.getOrDefault(-1f) + } + + fun toString(objects: Array, tag: String): String { + val sb = StringBuilder() + for (`object` in objects) { + sb.append(`object`) + sb.append(tag) + } + return sb.toString() + } + + @JvmOverloads + fun toBitmap(bytes: ByteArray, width: Int = -1, height: Int = -1): Bitmap? { + var bitmap: Bitmap? = null + if (bytes.isNotEmpty()) { + kotlin.runCatching { + val options = BitmapFactory.Options() + // 设置让解码器以最佳方式解码 + options.inPreferredConfig = null + if (width > 0 && height > 0) { + options.outWidth = width + options.outHeight = height + } + bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + bitmap!!.density = 96// 96 dpi + } + } + return bitmap + } + + private fun toDrawable(bitmap: Bitmap?): Drawable? { + return if (bitmap == null) null else BitmapDrawable(Resources.getSystem(), bitmap) + } + + fun toDrawable(bytes: ByteArray): Drawable? { + return toDrawable(toBitmap(bytes)) + } + + fun formatFileSize(length: Long): String { + if (length <= 0) return "0" + val units = arrayOf("b", "kb", "M", "G", "T") + //计算单位的,原理是利用lg,公式是 lg(1024^n) = nlg(1024),最后 nlg(1024)/lg(1024) = n。 + val digitGroups = (log10(length.toDouble()) / log10(1024.0)).toInt() + //计算原理是,size/单位值。单位值指的是:比如说b = 1024,KB = 1024^2 + return DecimalFormat("#,##0.##").format(length / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] + } + + @JvmOverloads + fun toString(`is`: InputStream, charset: String = "utf-8"): String { + val sb = StringBuilder() + kotlin.runCatching { + val reader = BufferedReader(InputStreamReader(`is`, charset)) + while (true) { + val line = reader.readLine() + if (line == null) { + break + } else { + sb.append(line).append("\n") + } + } + reader.close() + `is`.close() + } + return sb.toString() + } + +} + +val Int.dp: Int + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics + ).toInt() + +val Int.sp: Int + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_SP, + this.toFloat(), + Resources.getSystem().displayMetrics + ).toInt() + +val Int.hexString: String + get() = Integer.toHexString(this) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/DialogExtensions.kt b/app/src/main/java/io/legado/app/utils/DialogExtensions.kt new file mode 100644 index 000000000..0aa488322 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/DialogExtensions.kt @@ -0,0 +1,58 @@ +package io.legado.app.utils + +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.theme.filletBackground + +fun AlertDialog.applyTint(): AlertDialog { + window?.setBackgroundDrawable(context.filletBackground) + val colorStateList = Selector.colorBuild() + .setDefaultColor(ThemeStore.accentColor(context)) + .setPressedColor(ColorUtils.darkenColor(ThemeStore.accentColor(context))) + .create() + if (getButton(AlertDialog.BUTTON_NEGATIVE) != null) { + getButton(AlertDialog.BUTTON_NEGATIVE).setTextColor(colorStateList) + } + if (getButton(AlertDialog.BUTTON_POSITIVE) != null) { + getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(colorStateList) + } + if (getButton(AlertDialog.BUTTON_NEUTRAL) != null) { + getButton(AlertDialog.BUTTON_NEUTRAL).setTextColor(colorStateList) + } + return this +} + +fun AlertDialog.requestInputMethod() { + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) +} + +fun DialogFragment.setLayout(widthMix: Float, heightMix: Float) { + val dm = requireActivity().windowSize + dialog?.window?.setLayout( + (dm.widthPixels * widthMix).toInt(), + (dm.heightPixels * heightMix).toInt() + ) +} + +fun DialogFragment.setLayout(width: Int, heightMix: Float) { + val dm = requireActivity().windowSize + dialog?.window?.setLayout( + width, + (dm.heightPixels * heightMix).toInt() + ) +} + +fun DialogFragment.setLayout(widthMix: Float, height: Int) { + val dm = requireActivity().windowSize + dialog?.window?.setLayout( + (dm.widthPixels * widthMix).toInt(), + height + ) +} + +fun DialogFragment.setLayout(width: Int, height: Int) { + dialog?.window?.setLayout(width, height) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/DocumentExtensions.kt b/app/src/main/java/io/legado/app/utils/DocumentExtensions.kt new file mode 100644 index 000000000..10c3a6496 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/DocumentExtensions.kt @@ -0,0 +1,226 @@ +package io.legado.app.utils + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import io.legado.app.model.NoStackTraceException +import splitties.init.appCtx +import java.io.File +import java.nio.charset.Charset +import java.util.* + + +@Suppress("MemberVisibilityCanBePrivate") +object DocumentUtils { + + fun exists(root: DocumentFile, fileName: String, vararg subDirs: String): Boolean { + val parent = getDirDocument(root, *subDirs) ?: return false + return parent.findFile(fileName)?.exists() ?: false + } + + fun delete(root: DocumentFile, fileName: String, vararg subDirs: String) { + val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs) + parent?.findFile(fileName)?.delete() + } + + fun createFileIfNotExist( + root: DocumentFile, + fileName: String, + mimeType: String = "", + vararg subDirs: String + ): DocumentFile? { + val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs) + return parent?.createFile(mimeType, fileName) + } + + fun createFolderIfNotExist(root: DocumentFile, vararg subDirs: String): DocumentFile? { + var parent: DocumentFile? = root + for (subDirName in subDirs) { + val subDir = parent?.findFile(subDirName) + ?: parent?.createDirectory(subDirName) + parent = subDir + } + return parent + } + + fun getDirDocument(root: DocumentFile, vararg subDirs: String): DocumentFile? { + var parent = root + for (subDirName in subDirs) { + val subDir = parent.findFile(subDirName) + parent = subDir ?: return null + } + return parent + } + + @JvmStatic + @Throws(Exception::class) + fun writeText( + context: Context, + data: String, + fileUri: Uri, + charset: Charset = Charsets.UTF_8 + ): Boolean { + return writeBytes(context, data.toByteArray(charset), fileUri) + } + + @JvmStatic + @Throws(Exception::class) + fun writeBytes(context: Context, data: ByteArray, fileUri: Uri): Boolean { + context.contentResolver.openOutputStream(fileUri)?.let { + it.write(data) + it.close() + return true + } + return false + } + + @JvmStatic + @Throws(Exception::class) + fun readText(context: Context, uri: Uri): String { + return String(readBytes(context, uri)) + } + + @JvmStatic + @Throws(Exception::class) + fun readBytes(context: Context, uri: Uri): ByteArray { + context.contentResolver.openInputStream(uri)?.let { + val len: Int = it.available() + val buffer = ByteArray(len) + it.read(buffer) + it.close() + return buffer + } ?: throw NoStackTraceException("打开文件失败\n${uri}") + } + + @Throws(Exception::class) + fun listFiles(uri: Uri, filter: ((file: FileDoc) -> Boolean)? = null): ArrayList { + if (!uri.isContentScheme()) { + return listFiles(uri.path!!, filter) + } + val childrenUri = DocumentsContract + .buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri)) + val docList = arrayListOf() + var cursor: Cursor? = null + try { + cursor = appCtx.contentResolver.query( + childrenUri, arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_MIME_TYPE + ), null, null, DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + cursor?.let { + val ici = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val sci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE) + val mci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) + val dci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + if (cursor.moveToFirst()) { + do { + val item = FileDoc( + name = cursor.getString(nci), + isDir = cursor.getString(mci) == DocumentsContract.Document.MIME_TYPE_DIR, + size = cursor.getLong(sci), + date = Date(cursor.getLong(dci)), + uri = DocumentsContract + .buildDocumentUriUsingTree(uri, cursor.getString(ici)) + ) + if (filter == null || filter.invoke(item)) { + docList.add(item) + } + } while (cursor.moveToNext()) + } + } + } finally { + cursor?.close() + } + return docList + } + + @Throws(Exception::class) + fun listFiles(path: String, filter: ((file: FileDoc) -> Boolean)? = null): ArrayList { + val docList = arrayListOf() + val file = File(path) + file.listFiles()?.forEach { + val item = FileDoc( + it.name, + it.isDirectory, + it.length(), + Date(it.lastModified()), + Uri.fromFile(it) + ) + if (filter == null || filter.invoke(item)) { + docList.add(item) + } + } + return docList + } + +} + +data class FileDoc( + val name: String, + val isDir: Boolean, + val size: Long, + val date: Date, + val uri: Uri +) { + + override fun toString(): String { + return if (uri.isContentScheme()) uri.toString() else uri.path!! + } + + val isContentScheme get() = uri.isContentScheme() + + fun readBytes(): ByteArray { + return uri.readBytes(appCtx) + } + + companion object { + + fun fromDocumentFile(doc: DocumentFile): FileDoc { + return FileDoc( + name = doc.name ?: "", + isDir = doc.isDirectory, + size = doc.length(), + date = Date(doc.lastModified()), + uri = doc.uri + ) + } + + fun fromFile(file: File): FileDoc { + return FileDoc( + name = file.name, + isDir = file.isDirectory, + size = file.length(), + date = Date(file.lastModified()), + uri = Uri.fromFile(file) + ) + } + + } +} + +@Throws(Exception::class) +fun DocumentFile.writeText(context: Context, data: String, charset: Charset = Charsets.UTF_8) { + DocumentUtils.writeText(context, data, this.uri, charset) +} + +@Throws(Exception::class) +fun DocumentFile.writeBytes(context: Context, data: ByteArray) { + DocumentUtils.writeBytes(context, data, this.uri) +} + +@Throws(Exception::class) +fun DocumentFile.readText(context: Context): String { + return DocumentUtils.readText(context, this.uri) +} + +@Throws(Exception::class) +fun DocumentFile.readBytes(context: Context): ByteArray { + return DocumentUtils.readBytes(context, this.uri) +} diff --git a/app/src/main/java/io/legado/app/utils/DrawableUtils.kt b/app/src/main/java/io/legado/app/utils/DrawableUtils.kt new file mode 100644 index 000000000..7f986f632 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/DrawableUtils.kt @@ -0,0 +1,55 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.TransitionDrawable +import androidx.annotation.ColorInt +import androidx.core.graphics.drawable.DrawableCompat + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +@Suppress("unused") +object DrawableUtils { + + fun createTransitionDrawable( + @ColorInt startColor: Int, + @ColorInt endColor: Int + ): TransitionDrawable { + return createTransitionDrawable(ColorDrawable(startColor), ColorDrawable(endColor)) + } + + fun createTransitionDrawable(start: Drawable, end: Drawable): TransitionDrawable { + val drawables = arrayOfNulls(2) + + drawables[0] = start + drawables[1] = end + + return TransitionDrawable(drawables) + } + +} + +fun Drawable.setTintListMutate( + tint: ColorStateList, + tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_ATOP +) { + val wrappedDrawable = DrawableCompat.wrap(this) + wrappedDrawable.mutate() + DrawableCompat.setTintMode(wrappedDrawable, tintMode) + DrawableCompat.setTintList(wrappedDrawable, tint) +} + +fun Drawable.setTintMutate( + @ColorInt tint: Int, + tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_ATOP +) { + val wrappedDrawable = DrawableCompat.wrap(this) + wrappedDrawable.mutate() + DrawableCompat.setTintMode(wrappedDrawable, tintMode) + DrawableCompat.setTint(wrappedDrawable, tint) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/EncoderUtils.kt b/app/src/main/java/io/legado/app/utils/EncoderUtils.kt new file mode 100644 index 000000000..9bf400bad --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/EncoderUtils.kt @@ -0,0 +1,169 @@ +package io.legado.app.utils + +import android.util.Base64 +import java.security.spec.AlgorithmParameterSpec +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Suppress("unused") +object EncoderUtils { + + fun escape(src: String): String { + val tmp = StringBuilder() + for (char in src) { + val charCode = char.code + if (charCode in 48..57 || charCode in 65..90 || charCode in 97..122) { + tmp.append(char) + continue + } + + val prefix = when { + charCode < 16 -> "%0" + charCode < 256 -> "%" + else -> "%u" + } + tmp.append(prefix).append(charCode.toString(16)) + } + return tmp.toString() + } + + @JvmOverloads + fun base64Decode(str: String, flags: Int = Base64.DEFAULT): String { + val bytes = Base64.decode(str, flags) + return String(bytes) + } + + @JvmOverloads + fun base64Encode(str: String, flags: Int = Base64.NO_WRAP): String? { + return Base64.encodeToString(str.toByteArray(), flags) + } + + //////////AES Start + + /** + * Return the Base64-encode bytes of AES encryption. + * + * @param data The data. + * @param key The key. + * @param transformation The name of the transformation, + * 加密算法/加密模式/填充类型, *DES/CBC/PKCS5Padding*. + * @param iv The buffer with the IV. The contents of the + * buffer are copied to protect against subsequent modification. + * @return the Base64-encode bytes of AES encryption + */ + @Throws(Exception::class) + fun encryptAES2Base64( + data: ByteArray?, + key: ByteArray?, + transformation: String? = "DES/ECB/PKCS5Padding", + iv: ByteArray? = null + ): ByteArray? { + return Base64.encode(encryptAES(data, key, transformation, iv), Base64.NO_WRAP) + } + + /** + * Return the bytes of AES encryption. + * + * @param data The data. + * @param key The key. + * @param transformation The name of the transformation, + * 加密算法/加密模式/填充类型, *DES/CBC/PKCS5Padding*. + * @param iv The buffer with the IV. The contents of the + * buffer are copied to protect against subsequent modification. + * @return the bytes of AES encryption + */ + @Throws(Exception::class) + fun encryptAES( + data: ByteArray?, + key: ByteArray?, + transformation: String? = "DES/ECB/PKCS5Padding", + iv: ByteArray? = null + ): ByteArray? { + return symmetricTemplate(data, key, "AES", transformation!!, iv, true) + } + + + /** + * Return the bytes of AES decryption for Base64-encode bytes. + * + * @param data The data. + * @param key The key. + * @param transformation The name of the transformation, + * 加密算法/加密模式/填充类型, *DES/CBC/PKCS5Padding*. + * @param iv The buffer with the IV. The contents of the + * buffer are copied to protect against subsequent modification. + * @return the bytes of AES decryption for Base64-encode bytes + */ + @Throws(Exception::class) + fun decryptBase64AES( + data: ByteArray?, + key: ByteArray?, + transformation: String = "DES/ECB/PKCS5Padding", + iv: ByteArray? = null + ): ByteArray? { + return decryptAES(Base64.decode(data, Base64.NO_WRAP), key, transformation, iv) + } + + /** + * Return the bytes of AES decryption. + * + * @param data The data. + * @param key The key. + * @param transformation The name of the transformation, + * 加密算法/加密模式/填充类型, *DES/CBC/PKCS5Padding*. + * @param iv The buffer with the IV. The contents of the + * buffer are copied to protect against subsequent modification. + * @return the bytes of AES decryption + */ + @Throws(Exception::class) + fun decryptAES( + data: ByteArray?, + key: ByteArray?, + transformation: String = "DES/ECB/PKCS5Padding", + iv: ByteArray? = null + ): ByteArray? { + return symmetricTemplate(data, key, "AES", transformation, iv, false) + } + + + /** + * Return the bytes of symmetric encryption or decryption. + * + * @param data The data. + * @param key The key. + * @param algorithm The name of algorithm. + * @param transformation The name of the transformation, + * 加密算法/加密模式/填充类型, DES/CBC/PKCS5Padding. + * @param iv The buffer with the IV. The contents of the + * buffer are copied to protect against subsequent modification. + * @param isEncrypt True to encrypt, false otherwise. + * @return the bytes of symmetric encryption or decryption + */ + @Suppress("SameParameterValue") + @Throws(Exception::class) + private fun symmetricTemplate( + data: ByteArray?, + key: ByteArray?, + algorithm: String, + transformation: String, + iv: ByteArray?, + isEncrypt: Boolean + ): ByteArray? { + return if (data == null || data.isEmpty() || key == null || key.isEmpty()) null + else { + val keySpec = SecretKeySpec(key, algorithm) + val cipher = Cipher.getInstance(transformation) + val mode = if (isEncrypt) Cipher.ENCRYPT_MODE else Cipher.DECRYPT_MODE + if (iv == null || iv.isEmpty()) { + cipher.init(mode, keySpec) + } else { + val params: AlgorithmParameterSpec = IvParameterSpec(iv) + cipher.init(mode, keySpec, params) + } + cipher.doFinal(data) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/EncodingDetect.kt b/app/src/main/java/io/legado/app/utils/EncodingDetect.kt new file mode 100644 index 000000000..83f659fa9 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/EncodingDetect.kt @@ -0,0 +1,79 @@ +package io.legado.app.utils + +import android.text.TextUtils +import io.legado.app.lib.icu4j.CharsetDetector +import org.jsoup.Jsoup +import java.io.File +import java.io.FileInputStream +import java.nio.charset.StandardCharsets +import java.util.* + +/** + * 自动获取文件的编码 + * */ +@Suppress("MemberVisibilityCanBePrivate", "unused") +object EncodingDetect { + + fun getHtmlEncode(bytes: ByteArray): String? { + try { + val doc = Jsoup.parse(String(bytes, StandardCharsets.UTF_8)) + val metaTags = doc.getElementsByTag("meta") + var charsetStr: String + for (metaTag in metaTags) { + charsetStr = metaTag.attr("charset") + if (!TextUtils.isEmpty(charsetStr)) { + return charsetStr + } + val content = metaTag.attr("content") + val httpEquiv = metaTag.attr("http-equiv") + if (httpEquiv.lowercase(Locale.getDefault()) == "content-type") { + charsetStr = if (content.lowercase(Locale.getDefault()).contains("charset")) { + content.substring( + content.lowercase(Locale.getDefault()) + .indexOf("charset") + "charset=".length + ) + } else { + content.substring(content.lowercase(Locale.getDefault()).indexOf(";") + 1) + } + if (!TextUtils.isEmpty(charsetStr)) { + return charsetStr + } + } + } + } catch (ignored: Exception) { + } + return getEncode(bytes) + } + + fun getEncode(bytes: ByteArray): String { + val match = CharsetDetector().setText(bytes).detect() + return match?.name ?: "UTF-8" + } + + /** + * 得到文件的编码 + */ + fun getEncode(filePath: String): String { + return getEncode(File(filePath)) + } + + /** + * 得到文件的编码 + */ + fun getEncode(file: File): String { + val tempByte = getFileBytes(file) + return getEncode(tempByte) + } + + private fun getFileBytes(file: File?): ByteArray { + val byteArray = ByteArray(8000) + try { + FileInputStream(file).use { + it.read(byteArray) + } + } catch (e: Exception) { + System.err.println("Error: $e") + } + return byteArray + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/EventBusExtensions.kt b/app/src/main/java/io/legado/app/utils/EventBusExtensions.kt new file mode 100644 index 000000000..0293e4893 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/EventBusExtensions.kt @@ -0,0 +1,73 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import com.jeremyliao.liveeventbus.LiveEventBus +import com.jeremyliao.liveeventbus.core.Observable + +inline fun eventObservable(tag: String): Observable { + return LiveEventBus.get(tag, EVENT::class.java) +} + +inline fun postEvent(tag: String, event: EVENT) { + LiveEventBus.get(tag).post(event) +} + +inline fun postEventDelay(tag: String, event: EVENT, delay: Long) { + LiveEventBus.get(tag).postDelay(event, delay) +} + +inline fun postEventOrderly(tag: String, event: EVENT) { + LiveEventBus.get(tag).postOrderly(event) +} + +inline fun AppCompatActivity.observeEvent( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observe(this, o) + } +} + +inline fun AppCompatActivity.observeEventSticky( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observeSticky(this, o) + } +} + +inline fun Fragment.observeEvent( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observe(this, o) + } +} + +inline fun Fragment.observeEventSticky( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observeSticky(this, o) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/FileExtensions.kt b/app/src/main/java/io/legado/app/utils/FileExtensions.kt new file mode 100644 index 000000000..31f03c04c --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/FileExtensions.kt @@ -0,0 +1,13 @@ +package io.legado.app.utils + +import java.io.File + +fun File.getFile(vararg subDirFiles: String): File { + val path = FileUtils.getPath(this, *subDirFiles) + return File(path) +} + +fun File.exists(vararg subDirFiles: String): Boolean { + return getFile(*subDirFiles).exists() +} + diff --git a/app/src/main/java/io/legado/app/utils/FileUtils.kt b/app/src/main/java/io/legado/app/utils/FileUtils.kt new file mode 100644 index 000000000..8b9d04afe --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/FileUtils.kt @@ -0,0 +1,782 @@ +package io.legado.app.utils + +import android.os.Environment +import android.webkit.MimeTypeMap +import androidx.annotation.IntDef +import splitties.init.appCtx +import timber.log.Timber +import java.io.* +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + +@Suppress("unused", "MemberVisibilityCanBePrivate") +object FileUtils { + + fun createFileIfNotExist(root: File, vararg subDirFiles: String): File { + val filePath = getPath(root, *subDirFiles) + return createFileIfNotExist(filePath) + } + + fun createFolderIfNotExist(root: File, vararg subDirs: String): File { + val filePath = getPath(root, *subDirs) + return createFolderIfNotExist(filePath) + } + + fun createFolderIfNotExist(filePath: String): File { + val file = File(filePath) + //如果文件夹不存在,就创建它 + if (!file.exists()) { + file.mkdirs() + } + return file + } + + @Synchronized + fun createFileIfNotExist(filePath: String): File { + val file = File(filePath) + try { + if (!file.exists()) { + //创建父类文件夹 + file.parent?.let { + createFolderIfNotExist(it) + } + //创建文件 + file.createNewFile() + } + } catch (e: IOException) { + Timber.e(e) + } + return file + } + + fun createFileWithReplace(filePath: String): File { + val file = File(filePath) + if (!file.exists()) { + //创建父类文件夹 + file.parent?.let { + createFolderIfNotExist(it) + } + //创建文件 + file.createNewFile() + } else { + file.delete() + file.createNewFile() + } + return file + } + + fun getPath(rootPath: String, vararg subDirFiles: String): String { + val path = StringBuilder(rootPath) + subDirFiles.forEach { + if (it.isNotEmpty()) { + if (!path.endsWith(File.separator)) { + path.append(File.separator) + } + path.append(it) + } + } + return path.toString() + } + + fun getPath(root: File, vararg subDirFiles: String): String { + val path = StringBuilder(root.absolutePath) + subDirFiles.forEach { + if (it.isNotEmpty()) { + path.append(File.separator).append(it) + } + } + return path.toString() + } + + //递归删除文件夹下的数据 + @Synchronized + fun deleteFile(filePath: String) { + val file = File(filePath) + if (!file.exists()) return + + if (file.isDirectory) { + val files = file.listFiles() + files?.forEach { subFile -> + val path = subFile.path + deleteFile(path) + } + } + //删除文件 + file.delete() + } + + fun getCachePath(): String { + return appCtx.externalCache.absolutePath + } + + fun getSdCardPath(): String { + @Suppress("DEPRECATION") + var sdCardDirectory = Environment.getExternalStorageDirectory().absolutePath + try { + sdCardDirectory = File(sdCardDirectory).canonicalPath + } catch (e: IOException) { + Timber.e(e) + } + return sdCardDirectory + } + + const val BY_NAME_ASC = 0 + const val BY_NAME_DESC = 1 + const val BY_TIME_ASC = 2 + const val BY_TIME_DESC = 3 + const val BY_SIZE_ASC = 4 + const val BY_SIZE_DESC = 5 + const val BY_EXTENSION_ASC = 6 + const val BY_EXTENSION_DESC = 7 + + @IntDef(value = [BY_NAME_ASC, BY_NAME_DESC, BY_TIME_ASC, BY_TIME_DESC, BY_SIZE_ASC, BY_SIZE_DESC, BY_EXTENSION_ASC, BY_EXTENSION_DESC]) + @kotlin.annotation.Retention(AnnotationRetention.SOURCE) + annotation class SortType + + /** + * 将目录分隔符统一为平台默认的分隔符,并为目录结尾添加分隔符 + */ + fun separator(path: String): String { + var path1 = path + val separator = File.separator + path1 = path1.replace("\\", separator) + if (!path1.endsWith(separator)) { + path1 += separator + } + return path1 + } + + fun closeSilently(c: Closeable?) { + if (c == null) { + return + } + try { + c.close() + } catch (ignored: IOException) { + } + + } + + /** + * 列出指定目录下的所有子目录 + */ + @JvmOverloads + fun listDirs( + startDirPath: String, + excludeDirs: Array? = null, @SortType sortType: Int = BY_NAME_ASC + ): Array { + var excludeDirs1 = excludeDirs + val dirList = ArrayList() + val startDir = File(startDirPath) + if (!startDir.isDirectory) { + return arrayOf() + } + val dirs = startDir.listFiles(FileFilter { f -> + if (f == null) { + return@FileFilter false + } + f.isDirectory + }) ?: return arrayOf() + if (excludeDirs1 == null) { + excludeDirs1 = arrayOf() + } + for (dir in dirs) { + val file = dir.absoluteFile + if (!excludeDirs1.contentDeepToString().contains(file.name)) { + dirList.add(file) + } + } + when (sortType) { + BY_NAME_ASC -> Collections.sort(dirList, SortByName()) + BY_NAME_DESC -> { + Collections.sort(dirList, SortByName()) + dirList.reverse() + } + BY_TIME_ASC -> Collections.sort(dirList, SortByTime()) + BY_TIME_DESC -> { + Collections.sort(dirList, SortByTime()) + dirList.reverse() + } + BY_SIZE_ASC -> Collections.sort(dirList, SortBySize()) + BY_SIZE_DESC -> { + Collections.sort(dirList, SortBySize()) + dirList.reverse() + } + BY_EXTENSION_ASC -> Collections.sort(dirList, SortByExtension()) + BY_EXTENSION_DESC -> { + Collections.sort(dirList, SortByExtension()) + dirList.reverse() + } + } + return dirList.toTypedArray() + } + + /** + * 列出指定目录下的所有子目录及所有文件 + */ + @JvmOverloads + fun listDirsAndFiles( + startDirPath: String, + allowExtensions: Array? = null + ): Array? { + val dirs: Array? + val files: Array? = if (allowExtensions == null) { + listFiles(startDirPath) + } else { + listFiles(startDirPath, allowExtensions) + } + dirs = listDirs(startDirPath) + if (files == null) { + return null + } + return dirs + files + } + + /** + * 列出指定目录下的所有文件 + */ + @JvmOverloads + fun listFiles( + startDirPath: String, + filterPattern: Pattern? = null, @SortType sortType: Int = BY_NAME_ASC + ): Array { + val fileList = ArrayList() + val f = File(startDirPath) + if (!f.isDirectory) { + return arrayOf() + } + val files = f.listFiles(FileFilter { file -> + if (file == null) { + return@FileFilter false + } + if (file.isDirectory) { + return@FileFilter false + } + + filterPattern?.matcher(file.name)?.find() ?: true + }) + ?: return arrayOf() + for (file in files) { + fileList.add(file.absoluteFile) + } + when (sortType) { + BY_NAME_ASC -> Collections.sort(fileList, SortByName()) + BY_NAME_DESC -> { + Collections.sort(fileList, SortByName()) + fileList.reverse() + } + BY_TIME_ASC -> Collections.sort(fileList, SortByTime()) + BY_TIME_DESC -> { + Collections.sort(fileList, SortByTime()) + fileList.reverse() + } + BY_SIZE_ASC -> Collections.sort(fileList, SortBySize()) + BY_SIZE_DESC -> { + Collections.sort(fileList, SortBySize()) + fileList.reverse() + } + BY_EXTENSION_ASC -> Collections.sort(fileList, SortByExtension()) + BY_EXTENSION_DESC -> { + Collections.sort(fileList, SortByExtension()) + fileList.reverse() + } + } + return fileList.toTypedArray() + } + + /** + * 列出指定目录下的所有文件 + */ + fun listFiles(startDirPath: String, allowExtensions: Array?): Array? { + val file = File(startDirPath) + return file.listFiles { _, name -> + //返回当前目录所有以某些扩展名结尾的文件 + val extension = getExtension(name) + allowExtensions?.contentDeepToString()?.contains(extension) == true + || allowExtensions == null + } + } + + /** + * 列出指定目录下的所有文件 + */ + fun listFiles(startDirPath: String, allowExtension: String?): Array? { + return if (allowExtension == null) + listFiles(startDirPath, allowExtension = null) + else + listFiles(startDirPath, arrayOf(allowExtension)) + } + + /** + * 判断文件或目录是否存在 + */ + fun exist(path: String): Boolean { + val file = File(path) + return file.exists() + } + + /** + * 删除文件或目录 + */ + @JvmOverloads + fun delete(file: File, deleteRootDir: Boolean = false): Boolean { + var result = false + if (file.isFile) { + //是文件 + result = deleteResolveEBUSY(file) + } else { + //是目录 + val files = file.listFiles() ?: return false + if (files.isEmpty()) { + result = deleteRootDir && deleteResolveEBUSY(file) + } else { + for (f in files) { + delete(f, deleteRootDir) + result = deleteResolveEBUSY(f) + } + } + if (deleteRootDir) { + result = deleteResolveEBUSY(file) + } + } + return result + } + + /** + * bug: open failed: EBUSY (Device or resource busy) + * fix: http://stackoverflow.com/questions/11539657/open-failed-ebusy-device-or-resource-busy + */ + private fun deleteResolveEBUSY(file: File): Boolean { + // Before you delete a Directory or File: rename it! + val to = File(file.absolutePath + System.currentTimeMillis()) + + file.renameTo(to) + return to.delete() + } + + /** + * 删除文件或目录 + */ + @JvmOverloads + fun delete(path: String, deleteRootDir: Boolean = false): Boolean { + val file = File(path) + + return if (file.exists()) { + delete(file, deleteRootDir) + } else false + } + + /** + * 复制文件为另一个文件,或复制某目录下的所有文件及目录到另一个目录下 + */ + fun copy(src: String, tar: String): Boolean { + val srcFile = File(src) + return srcFile.exists() && copy(srcFile, File(tar)) + } + + /** + * 复制文件或目录 + */ + fun copy(src: File, tar: File): Boolean { + try { + if (src.isFile) { + val inputStream = FileInputStream(src) + val outputStream = FileOutputStream(tar) + inputStream.use { + outputStream.use { + inputStream.copyTo(outputStream) + outputStream.flush() + } + } + } else if (src.isDirectory) { + tar.mkdirs() + src.listFiles()?.forEach { file -> + copy(file.absoluteFile, File(tar.absoluteFile, file.name)) + } + } + return true + } catch (e: Exception) { + return false + } + + } + + /** + * 移动文件或目录 + */ + fun move(src: String, tar: String): Boolean { + return move(File(src), File(tar)) + } + + /** + * 移动文件或目录 + */ + fun move(src: File, tar: File): Boolean { + return rename(src, tar) + } + + /** + * 文件重命名 + */ + fun rename(oldPath: String, newPath: String): Boolean { + return rename(File(oldPath), File(newPath)) + } + + /** + * 文件重命名 + */ + fun rename(src: File, tar: File): Boolean { + return src.renameTo(tar) + } + + /** + * 读取文本文件, 失败将返回空串 + */ + @JvmOverloads + fun readText(filepath: String, charset: String = "utf-8"): String { + try { + val data = readBytes(filepath) + if (data != null) { + return String(data, Charset.forName(charset)).trim { it <= ' ' } + } + } catch (ignored: UnsupportedEncodingException) { + } + + return "" + } + + /** + * 读取文件内容, 失败将返回空串 + */ + fun readBytes(filepath: String): ByteArray? { + var fis: FileInputStream? = null + try { + fis = FileInputStream(filepath) + val outputStream = ByteArrayOutputStream() + val buffer = ByteArray(1024) + while (true) { + val len = fis.read(buffer, 0, buffer.size) + if (len == -1) { + break + } else { + outputStream.write(buffer, 0, len) + } + } + val data = outputStream.toByteArray() + outputStream.close() + return data + } catch (e: IOException) { + return null + } finally { + closeSilently(fis) + } + } + + /** + * 保存文本内容 + */ + @JvmOverloads + fun writeText(filepath: String, content: String, charset: String = "utf-8"): Boolean { + return try { + writeBytes(filepath, content.toByteArray(charset(charset))) + } catch (e: UnsupportedEncodingException) { + false + } + + } + + /** + * 保存文件内容 + */ + fun writeBytes(filepath: String, data: ByteArray): Boolean { + val file = File(filepath) + var fos: FileOutputStream? = null + return try { + if (!file.exists()) { + file.parentFile?.mkdirs() + file.createNewFile() + } + fos = FileOutputStream(filepath) + fos.write(data) + true + } catch (e: IOException) { + false + } finally { + closeSilently(fos) + } + } + + /** + * 保存文件内容 + */ + fun writeInputStream(filepath: String, data: InputStream): Boolean { + val file = File(filepath) + return writeInputStream(file, data) + } + + /** + * 保存文件内容 + */ + fun writeInputStream(file: File, data: InputStream): Boolean { + return try { + if (!file.exists()) { + file.parentFile?.mkdirs() + file.createNewFile() + } + data.use { + FileOutputStream(file).use { fos -> + data.copyTo(fos) + fos.flush() + } + } + true + } catch (e: IOException) { + false + } + } + + /** + * 追加文本内容 + */ + fun appendText(path: String, content: String): Boolean { + val file = File(path) + var writer: FileWriter? = null + return try { + if (!file.exists()) { + file.createNewFile() + } + writer = FileWriter(file, true) + writer.write(content) + true + } catch (e: IOException) { + false + } finally { + closeSilently(writer) + } + } + + /** + * 获取文件大小 + */ + fun getLength(path: String): Long { + val file = File(path) + return if (!file.isFile || !file.exists()) { + 0 + } else file.length() + } + + /** + * 获取文件或网址的名称(包括后缀) + */ + fun getName(pathOrUrl: String?): String { + if (pathOrUrl == null) { + return "" + } + val pos = pathOrUrl.lastIndexOf('/') + return if (0 <= pos) { + pathOrUrl.substring(pos + 1) + } else { + System.currentTimeMillis().toString() + "." + getExtension(pathOrUrl) + } + } + + /** + * 获取文件名(不包括扩展名) + */ + fun getNameExcludeExtension(path: String): String { + return try { + var fileName = File(path).name + val lastIndexOf = fileName.lastIndexOf(".") + if (lastIndexOf != -1) { + fileName = fileName.substring(0, lastIndexOf) + } + fileName + } catch (e: Exception) { + "" + } + + } + + /** + * 获取格式化后的文件大小 + */ + fun getSize(path: String): String { + val fileSize = getLength(path) + return ConvertUtils.formatFileSize(fileSize) + } + + /** + * 获取文件后缀,不包括“.” + */ + fun getExtension(pathOrUrl: String): String { + val dotPos = pathOrUrl.lastIndexOf('.') + return if (0 <= dotPos) { + pathOrUrl.substring(dotPos + 1) + } else { + "ext" + } + } + + /** + * 获取文件的MIME类型 + */ + fun getMimeType(pathOrUrl: String): String { + val ext = getExtension(pathOrUrl) + val map = MimeTypeMap.getSingleton() + return map.getMimeTypeFromExtension(ext) ?: "*/*" + } + + /** + * 获取格式化后的文件/目录创建或最后修改时间 + */ + @JvmOverloads + fun getDateTime(path: String, format: String = "yyyy年MM月dd日HH:mm"): String { + val file = File(path) + return getDateTime(file, format) + } + + /** + * 获取格式化后的文件/目录创建或最后修改时间 + */ + fun getDateTime(file: File, format: String): String { + val cal = Calendar.getInstance() + cal.timeInMillis = file.lastModified() + return SimpleDateFormat(format, Locale.PRC).format(cal.time) + } + + /** + * 比较两个文件的最后修改时间 + */ + fun compareLastModified(path1: String, path2: String): Int { + val stamp1 = File(path1).lastModified() + val stamp2 = File(path2).lastModified() + return when { + stamp1 > stamp2 -> 1 + stamp1 < stamp2 -> -1 + else -> 0 + } + } + + /** + * 创建多级别的目录 + */ + fun makeDirs(path: String): Boolean { + return makeDirs(File(path)) + } + + /** + * 创建多级别的目录 + */ + fun makeDirs(file: File): Boolean { + return file.mkdirs() + } + + class SortByExtension : Comparator { + + override fun compare(f1: File?, f2: File?): Int { + return if (f1 == null || f2 == null) { + if (f1 == null) -1 else 1 + } else { + if (f1.isDirectory && f2.isFile) { + -1 + } else if (f1.isFile && f2.isDirectory) { + 1 + } else { + f1.name.compareTo(f2.name, ignoreCase = true) + } + } + } + + } + + class SortByName : Comparator { + private var caseSensitive: Boolean = false + + constructor(caseSensitive: Boolean) { + this.caseSensitive = caseSensitive + } + + constructor() { + this.caseSensitive = false + } + + override fun compare(f1: File?, f2: File?): Int { + if (f1 == null || f2 == null) { + return if (f1 == null) { + -1 + } else { + 1 + } + } else { + return if (f1.isDirectory && f2.isFile) { + -1 + } else if (f1.isFile && f2.isDirectory) { + 1 + } else { + val s1 = f1.name + val s2 = f2.name + if (caseSensitive) { + s1.cnCompare(s2) + } else { + s1.compareTo(s2, ignoreCase = true) + } + } + } + } + + } + + class SortBySize : Comparator { + + override fun compare(f1: File?, f2: File?): Int { + return if (f1 == null || f2 == null) { + if (f1 == null) { + -1 + } else { + 1 + } + } else { + if (f1.isDirectory && f2.isFile) { + -1 + } else if (f1.isFile && f2.isDirectory) { + 1 + } else { + if (f1.length() < f2.length()) { + -1 + } else { + 1 + } + } + } + } + + } + + class SortByTime : Comparator { + + override fun compare(f1: File?, f2: File?): Int { + return if (f1 == null || f2 == null) { + if (f1 == null) { + -1 + } else { + 1 + } + } else { + if (f1.isDirectory && f2.isFile) { + -1 + } else if (f1.isFile && f2.isDirectory) { + 1 + } else { + if (f1.lastModified() > f2.lastModified()) { + -1 + } else { + 1 + } + } + } + } + + } +} diff --git a/app/src/main/java/io/legado/app/utils/FloatExtensions.kt b/app/src/main/java/io/legado/app/utils/FloatExtensions.kt new file mode 100644 index 000000000..ab5178cd6 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/FloatExtensions.kt @@ -0,0 +1,16 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.res.Resources + +val Float.dp: Float + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics + ) + +val Float.sp: Float + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_SP, this, Resources.getSystem().displayMetrics + ) + diff --git a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt new file mode 100644 index 000000000..691fa833d --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt @@ -0,0 +1,78 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.app.Activity +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.os.Bundle +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.edit +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment + +inline fun Fragment.showDialogFragment( + arguments: Bundle.() -> Unit = {} +) { + val dialog = T::class.java.newInstance() + val bundle = Bundle() + bundle.apply(arguments) + dialog.arguments = bundle + dialog.show(childFragmentManager, T::class.simpleName) +} + +fun Fragment.showDialogFragment(dialogFragment: DialogFragment) { + dialogFragment.show(childFragmentManager, dialogFragment::class.simpleName) +} + +fun Fragment.getPrefBoolean(key: String, defValue: Boolean = false) = + requireContext().defaultSharedPreferences.getBoolean(key, defValue) + +fun Fragment.putPrefBoolean(key: String, value: Boolean = false) = + requireContext().defaultSharedPreferences.edit { putBoolean(key, value) } + +fun Fragment.getPrefInt(key: String, defValue: Int = 0) = + requireContext().defaultSharedPreferences.getInt(key, defValue) + +fun Fragment.putPrefInt(key: String, value: Int) = + requireContext().defaultSharedPreferences.edit { putInt(key, value) } + +fun Fragment.getPrefLong(key: String, defValue: Long = 0L) = + requireContext().defaultSharedPreferences.getLong(key, defValue) + +fun Fragment.putPrefLong(key: String, value: Long) = + requireContext().defaultSharedPreferences.edit { putLong(key, value) } + +fun Fragment.getPrefString(key: String, defValue: String? = null) = + requireContext().defaultSharedPreferences.getString(key, defValue) + +fun Fragment.putPrefString(key: String, value: String) = + requireContext().defaultSharedPreferences.edit { putString(key, value) } + +fun Fragment.getPrefStringSet( + key: String, + defValue: MutableSet? = null +): MutableSet? = + requireContext().defaultSharedPreferences.getStringSet(key, defValue) + +fun Fragment.putPrefStringSet(key: String, value: MutableSet) = + requireContext().defaultSharedPreferences.edit { putStringSet(key, value) } + +fun Fragment.removePref(key: String) = + requireContext().defaultSharedPreferences.edit { remove(key) } + +fun Fragment.getCompatColor(@ColorRes id: Int): Int = requireContext().getCompatColor(id) + +fun Fragment.getCompatDrawable(@DrawableRes id: Int): Drawable? = + requireContext().getCompatDrawable(id) + +fun Fragment.getCompatColorStateList(@ColorRes id: Int): ColorStateList? = + requireContext().getCompatColorStateList(id) + +inline fun Fragment.startActivity( + configIntent: Intent.() -> Unit = {} +) { + startActivity(Intent(requireContext(), T::class.java).apply(configIntent)) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/GsonExtensions.kt b/app/src/main/java/io/legado/app/utils/GsonExtensions.kt new file mode 100644 index 000000000..c5535c407 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/GsonExtensions.kt @@ -0,0 +1,159 @@ +package io.legado.app.utils + +import com.google.gson.* +import com.google.gson.internal.LinkedTreeMap +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonWriter +import timber.log.Timber +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import kotlin.math.ceil + + +val GSON: Gson by lazy { + GsonBuilder() + .registerTypeAdapter( + object : TypeToken?>() {}.type, + MapDeserializerDoubleAsIntFix() + ) + .registerTypeAdapter(Int::class.java, IntJsonDeserializer()) + .disableHtmlEscaping() + .setPrettyPrinting() + .create() +} + +inline fun genericType(): Type = object : TypeToken() {}.type + +inline fun Gson.fromJsonObject(json: String?): T? {//可转成任意类型 + return kotlin.runCatching { + fromJson(json, genericType()) as? T + }.onFailure { + Timber.e(it, json) + }.getOrNull() +} + +inline fun Gson.fromJsonArray(json: String?): List? { + return kotlin.runCatching { + fromJson(json, ParameterizedTypeImpl(T::class.java)) as? List + }.onFailure { + Timber.e(it, json) + }.getOrNull() +} + +fun Gson.writeToOutputStream(out: OutputStream, any: Any) { + val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) + writer.setIndent(" ") + if (any is List<*>) { + writer.beginArray() + any.forEach { + it?.let { + toJson(it, it::class.java, writer) + } + } + writer.endArray() + } else { + toJson(any, any::class.java, writer) + } + writer.close() +} + +class ParameterizedTypeImpl(private val clazz: Class<*>) : ParameterizedType { + override fun getRawType(): Type = List::class.java + + override fun getOwnerType(): Type? = null + + override fun getActualTypeArguments(): Array = arrayOf(clazz) +} + +/** + * int类型转化失败时跳过 + */ +class IntJsonDeserializer : JsonDeserializer { + + override fun deserialize( + json: JsonElement, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Int? { + return when { + json.isJsonPrimitive -> { + val prim = json.asJsonPrimitive + if (prim.isNumber) { + prim.asNumber.toInt() + } else { + null + } + } + else -> null + } + } + +} + + +/** + * 修复Int变为Double的问题 + */ +class MapDeserializerDoubleAsIntFix : + JsonDeserializer?> { + + @Throws(JsonParseException::class) + override fun deserialize( + jsonElement: JsonElement, + type: Type, + jsonDeserializationContext: JsonDeserializationContext + ): Map? { + @Suppress("unchecked_cast") + return read(jsonElement) as? Map + } + + fun read(json: JsonElement): Any? { + when { + json.isJsonArray -> { + val list: MutableList = ArrayList() + val arr = json.asJsonArray + for (anArr in arr) { + list.add(read(anArr)) + } + return list + } + json.isJsonObject -> { + val map: MutableMap = + LinkedTreeMap() + val obj = json.asJsonObject + val entitySet = + obj.entrySet() + for ((key, value) in entitySet) { + map[key] = read(value) + } + return map + } + json.isJsonPrimitive -> { + val prim = json.asJsonPrimitive + when { + prim.isBoolean -> { + return prim.asBoolean + } + prim.isString -> { + return prim.asString + } + prim.isNumber -> { + val num: Number = prim.asNumber + // here you can handle double int/long values + // and return any type you want + // this solution will transform 3.0 float to long values + return if (ceil(num.toDouble()) == num.toLong().toDouble()) { + num.toLong() + } else { + num.toDouble() + } + } + } + } + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/HandlerUtils.kt b/app/src/main/java/io/legado/app/utils/HandlerUtils.kt new file mode 100644 index 000000000..3ce76e5ca --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/HandlerUtils.kt @@ -0,0 +1,54 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch + +/** This main looper cache avoids synchronization overhead when accessed repeatedly. */ +@JvmField +val mainLooper: Looper = Looper.getMainLooper() + +@JvmField +val mainThread: Thread = mainLooper.thread + +val isMainThread: Boolean inline get() = mainThread === Thread.currentThread() + +@PublishedApi +internal val currentThread: Any? + inline get() = Thread.currentThread() + +val mainHandler: Handler by lazy { + if (SDK_INT >= 28) Handler.createAsync(mainLooper) else try { + Handler::class.java.getDeclaredConstructor( + Looper::class.java, + Handler.Callback::class.java, + Boolean::class.javaPrimitiveType // async + ).newInstance(mainLooper, null, true) + } catch (ignored: NoSuchMethodException) { + // Hidden constructor absent. Fall back to non-async constructor. + Handler(mainLooper) + } +} + +fun runOnUI(function: () -> Unit) { + if (isMainThread) { + function() + } else { + mainHandler.post(function) + } +} + +fun CoroutineScope.runOnIO(function: () -> Unit) { + if (isMainThread) { + launch(IO) { + function() + } + } else { + function() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/HtmlFormatter.kt b/app/src/main/java/io/legado/app/utils/HtmlFormatter.kt new file mode 100644 index 000000000..ad374c857 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/HtmlFormatter.kt @@ -0,0 +1,68 @@ +package io.legado.app.utils + +import io.legado.app.model.analyzeRule.AnalyzeUrl +import java.net.URL +import java.util.regex.Pattern + +@Suppress("RegExpRedundantEscape") +object HtmlFormatter { + private val nbspRegex = "( )+".toRegex() + private val espRegex = "( | )".toRegex() + private val noPrintRegex = "( |‌|‍)".toRegex() + private val wrapHtmlRegex = "]*>".toRegex() + private val commentRegex = "".toRegex() //注释 + private val notImgHtmlRegex = "])[^<>]*>".toRegex() + private val otherHtmlRegex = "])[^<>]*>".toRegex() + private val formatImagePattern = Pattern.compile( + "]*src *= *\"([^\"{]*\\{(?:[^{}]|\\{[^}]+\\})+\\})\"[^>]*>|]*data-[^=]*= *\"([^\"]*)\"[^>]*>|]*src *= *\"([^\"]*)\"[^>]*>", + Pattern.CASE_INSENSITIVE + ) + + fun format(html: String?, otherRegex: Regex = otherHtmlRegex): String { + html ?: return "" + return html.replace(nbspRegex, " ") + .replace(espRegex, " ") + .replace(noPrintRegex, "") + .replace(wrapHtmlRegex, "\n") + .replace(commentRegex, "") + .replace(otherRegex, "") + .replace("\\s*\\n+\\s*".toRegex(), "\n  ") + .replace("^[\\n\\s]+".toRegex(), "  ") + .replace("[\\n\\s]+$".toRegex(), "") + } + + fun formatKeepImg(html: String?, redirectUrl: URL? = null): String { + html ?: return "" + val keepImgHtml = format(html, notImgHtmlRegex) + + //正则的“|”处于顶端而不处于()中时,具有类似||的熔断效果,故以此机制简化原来的代码 + val matcher = formatImagePattern.matcher(keepImgHtml) + var appendPos = 0 + val sb = StringBuffer() + while (matcher.find()) { + var param = "" + sb.append( + keepImgHtml.substring(appendPos, matcher.start()), "" + ) + appendPos = matcher.end() + } + if (appendPos < keepImgHtml.length) sb.append( + keepImgHtml.substring( + appendPos, + keepImgHtml.length + ) + ) + return sb.toString() + } +} diff --git a/app/src/main/java/io/legado/app/utils/IntentExtensions.kt b/app/src/main/java/io/legado/app/utils/IntentExtensions.kt new file mode 100644 index 000000000..f64dcf7b4 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/IntentExtensions.kt @@ -0,0 +1,21 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.Intent + +fun Intent.putJson(key: String, any: Any?) { + any?.let { + putExtra(key, GSON.toJson(any)) + } +} + +inline fun Intent.getJsonObject(key: String): T? { + val value = getStringExtra(key) + return GSON.fromJsonObject(value) +} + +inline fun Intent.getJsonArray(key: String): List? { + val value = getStringExtra(key) + return GSON.fromJsonArray(value) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/IntentType.kt b/app/src/main/java/io/legado/app/utils/IntentType.kt new file mode 100644 index 000000000..e1b88f6c2 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/IntentType.kt @@ -0,0 +1,30 @@ +package io.legado.app.utils + +import android.net.Uri +import java.io.File + +object IntentType { + + @JvmStatic + fun from(uri: Uri): String? { + return from(uri.toString()) + } + + @JvmStatic + fun from(file: File): String? { + return from(file.absolutePath) + } + + @JvmStatic + fun from(path: String?): String? { + return when (path?.substringAfterLast(".")?.lowercase()) { + "apk" -> "application/vnd.android.package-archive" + "m4a", "mp3", "mid", "xmf", "ogg", "wav" -> "video/*" + "3gp", "mp4" -> "audio/*" + "jpg", "gif", "png", "jpeg", "bmp" -> "image/*" + "txt", "json" -> "text/plain" + else -> null + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/JsonExtensions.kt b/app/src/main/java/io/legado/app/utils/JsonExtensions.kt new file mode 100644 index 000000000..1e9a0289d --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/JsonExtensions.kt @@ -0,0 +1,20 @@ +package io.legado.app.utils + +import com.jayway.jsonpath.* + +val jsonPath: ParseContext by lazy { + JsonPath.using( + Configuration.builder() + .options(Option.SUPPRESS_EXCEPTIONS) + .build() + ) +} + +fun ReadContext.readString(path: String): String? = this.read(path, String::class.java) + +fun ReadContext.readBool(path: String): Boolean? = this.read(path, Boolean::class.java) + +fun ReadContext.readInt(path: String): Int? = this.read(path, Int::class.java) + +fun ReadContext.readLong(path: String): Long? = this.read(path, Long::class.java) + diff --git a/app/src/main/java/io/legado/app/utils/JsoupExtensions.kt b/app/src/main/java/io/legado/app/utils/JsoupExtensions.kt new file mode 100644 index 000000000..2c9829ddd --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/JsoupExtensions.kt @@ -0,0 +1,63 @@ +package io.legado.app.utils + +import org.jsoup.internal.StringUtil +import org.jsoup.nodes.CDataNode +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeTraversor +import org.jsoup.select.NodeVisitor + + +fun Element.textArray(): Array { + val sb = StringUtil.borrowBuilder() + NodeTraversor.traverse(object : NodeVisitor { + override fun head(node: Node, depth: Int) { + if (node is TextNode) { + appendNormalisedText(sb, node) + } else if (node is Element) { + if (sb.isNotEmpty() && + (node.isBlock || node.tag().name == "br") && + !lastCharIsWhitespace(sb) + ) sb.append("\n") + } + } + + override fun tail(node: Node, depth: Int) { + if (node is Element) { + if (node.isBlock && node.nextSibling() is TextNode + && !lastCharIsWhitespace(sb) + ) { + sb.append("\n") + } + } + } + }, this) + val text = StringUtil.releaseBuilder(sb).trim { it <= ' ' } + return text.splitNotBlank("\n") +} + +private fun appendNormalisedText(sb: StringBuilder, textNode: TextNode) { + val text = textNode.wholeText + if (preserveWhitespace(textNode.parentNode()) || textNode is CDataNode) + sb.append(text) + else StringUtil.appendNormalisedWhitespace(sb, text, lastCharIsWhitespace(sb)) +} + +private fun preserveWhitespace(node: Node?): Boolean { + if (node is Element) { + var el = node as Element? + var i = 0 + do { + if (el!!.tag().preserveWhitespace()) return true + el = el.parent() + i++ + } while (i < 6 && el != null) + } + return false +} + +private fun lastCharIsWhitespace(sb: java.lang.StringBuilder): Boolean { + return sb.isNotEmpty() && sb[sb.length - 1] == ' ' +} + diff --git a/app/src/main/java/io/legado/app/utils/LogUtils.kt b/app/src/main/java/io/legado/app/utils/LogUtils.kt new file mode 100644 index 000000000..c525a90de --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/LogUtils.kt @@ -0,0 +1,71 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.annotation.SuppressLint +import splitties.init.appCtx +import java.text.SimpleDateFormat +import java.util.* +import java.util.logging.* + +@SuppressLint("SimpleDateFormat") +@Suppress("unused") +object LogUtils { + const val TIME_PATTERN = "yy-MM-dd HH:mm:ss.SSS" + val logTimeFormat by lazy { SimpleDateFormat(TIME_PATTERN) } + + @JvmStatic + fun d(tag: String, msg: String) { + logger.log(Level.INFO, "$tag $msg") + } + + @JvmStatic + fun e(tag: String, msg: String) { + logger.log(Level.WARNING, "$tag $msg") + } + + private val logger: Logger by lazy { + Logger.getGlobal().apply { + fileHandler?.let { + addHandler(it) + } + } + } + + private val fileHandler by lazy { + val root = appCtx.externalCacheDir ?: return@lazy null + val logFolder = FileUtils.createFolderIfNotExist(root, "logs") + val logPath = FileUtils.getPath(root = logFolder, "appLog") + FileHandler(logPath, 10240, 10).apply { + formatter = object : java.util.logging.Formatter() { + override fun format(record: LogRecord): String { + // 设置文件输出格式 + return (getCurrentDateStr(TIME_PATTERN) + ": " + record.message + "\n") + } + } + level = if (appCtx.getPrefBoolean("recordLog")) { + Level.INFO + } else { + Level.OFF + } + } + } + + fun upLevel() { + fileHandler?.level = if (appCtx.getPrefBoolean("recordLog")) { + Level.INFO + } else { + Level.OFF + } + } + + /** + * 获取当前时间 + */ + @SuppressLint("SimpleDateFormat") + fun getCurrentDateStr(pattern: String): String { + val date = Date() + val sdf = SimpleDateFormat(pattern) + return sdf.format(date) + } +} diff --git a/app/src/main/java/io/legado/app/utils/MD5Utils.kt b/app/src/main/java/io/legado/app/utils/MD5Utils.kt new file mode 100644 index 000000000..4c4c6959c --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/MD5Utils.kt @@ -0,0 +1,40 @@ +package io.legado.app.utils + +import timber.log.Timber +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * 将字符串转化为MD5 + */ +@Suppress("unused") +object MD5Utils { + + fun md5Encode(str: String?): String { + if (str == null) return "" + var reStr = "" + try { + val md5: MessageDigest = MessageDigest.getInstance("MD5") + val bytes: ByteArray = md5.digest(str.toByteArray()) + val stringBuffer: StringBuilder = StringBuilder() + for (b in bytes) { + val bt: Int = b.toInt() and 0xff + if (bt < 16) { + stringBuffer.append(0) + } + stringBuffer.append(Integer.toHexString(bt)) + } + reStr = stringBuffer.toString() + } catch (e: NoSuchAlgorithmException) { + Timber.e(e) + } + + return reStr + } + + fun md5Encode16(str: String): String { + var reStr = md5Encode(str) + reStr = reStr.substring(8, 24) + return reStr + } +} diff --git a/app/src/main/java/io/legado/app/utils/MenuExtensions.kt b/app/src/main/java/io/legado/app/utils/MenuExtensions.kt new file mode 100644 index 000000000..d325ecdea --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/MenuExtensions.kt @@ -0,0 +1,54 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuItemImpl +import androidx.core.view.forEach +import io.legado.app.R +import io.legado.app.constant.Theme +import java.lang.reflect.Method +import java.util.* + +@SuppressLint("RestrictedApi") +fun Menu.applyTint(context: Context, theme: Theme = Theme.Auto): Menu = this.let { menu -> + if (menu is MenuBuilder) { + menu.setOptionalIconsVisible(true) + } + val defaultTextColor = context.getCompatColor(R.color.primaryText) + val tintColor = UIUtils.getMenuColor(context, theme) + menu.forEach { item -> + (item as MenuItemImpl).let { impl -> + //overflow:展开的item + impl.icon?.setTintMutate( + if (impl.requiresOverflow()) defaultTextColor else tintColor + ) + } + } + return menu +} + +fun Menu.applyOpenTint(context: Context) { + //展开菜单显示图标 + if (this.javaClass.simpleName.equals("MenuBuilder", ignoreCase = true)) { + val defaultTextColor = context.getCompatColor(R.color.primaryText) + try { + var method: Method = + this.javaClass.getDeclaredMethod("setOptionalIconsVisible", java.lang.Boolean.TYPE) + method.isAccessible = true + method.invoke(this, true) + method = this.javaClass.getDeclaredMethod("getNonActionItems") + val menuItems = method.invoke(this) + if (menuItems is ArrayList<*>) { + for (menuItem in menuItems) { + if (menuItem is MenuItem) { + menuItem.icon?.setTintMutate(defaultTextColor) + } + } + } + } catch (ignored: Exception) { + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/NavigationViewUtils.kt b/app/src/main/java/io/legado/app/utils/NavigationViewUtils.kt new file mode 100644 index 000000000..6f68d090f --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/NavigationViewUtils.kt @@ -0,0 +1,42 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.res.ColorStateList +import androidx.annotation.ColorInt +import com.google.android.material.internal.NavigationMenuView +import com.google.android.material.navigation.NavigationView + +fun NavigationView.setItemIconColors( + @ColorInt normalColor: Int, + @ColorInt selectedColor: Int +) { + val iconSl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_checked), + intArrayOf(android.R.attr.state_checked) + ), + intArrayOf(normalColor, selectedColor) + ) + itemIconTintList = iconSl +} + +fun NavigationView.setItemTextColors( + @ColorInt normalColor: Int, + @ColorInt selectedColor: Int +) { + val textSl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_checked), + intArrayOf(android.R.attr.state_checked) + ), + intArrayOf(normalColor, selectedColor) + ) + itemTextColor = textSl +} + +fun NavigationView.disableScrollbar() { + val navigationMenuView = getChildAt(0) as? NavigationMenuView + navigationMenuView?.isVerticalScrollBarEnabled = false +} + diff --git a/app/src/main/java/io/legado/app/utils/NetworkUtils.kt b/app/src/main/java/io/legado/app/utils/NetworkUtils.kt new file mode 100644 index 000000000..ece587df0 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/NetworkUtils.kt @@ -0,0 +1,200 @@ +package io.legado.app.utils + +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import splitties.systemservices.connectivityManager +import timber.log.Timber +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException +import java.net.URL +import java.util.* +import java.util.regex.Pattern + + +@Suppress("unused", "MemberVisibilityCanBePrivate") +object NetworkUtils { + + /** + * 判断是否联网 + */ + @Suppress("DEPRECATION") + fun isAvailable(): Boolean { + if (Build.VERSION.SDK_INT < 23) { + val mWiFiNetworkInfo = connectivityManager.activeNetworkInfo + if (mWiFiNetworkInfo != null) { + //移动数据 + return if (mWiFiNetworkInfo.type == ConnectivityManager.TYPE_WIFI) { + //WIFI + true + } else mWiFiNetworkInfo.type == ConnectivityManager.TYPE_MOBILE + } + } else { + val network = connectivityManager.activeNetwork + if (network != null) { + val nc = connectivityManager.getNetworkCapabilities(network) + if (nc != null) { + //移动数据 + return if (nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + //WIFI + true + } else nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + } + } + } + return false + } + + private val notNeedEncoding: BitSet by lazy { + val bitSet = BitSet(256) + for (i in 'a'.code..'z'.code) { + bitSet.set(i) + } + for (i in 'A'.code..'Z'.code) { + bitSet.set(i) + } + for (i in '0'.code..'9'.code) { + bitSet.set(i) + } + for (char in "+-_.$:()!*@&#,[]") { + bitSet.set(char.code) + } + return@lazy bitSet + } + + /** + * 支持JAVA的URLEncoder.encode出来的string做判断。 即: 将' '转成'+' + * 0-9a-zA-Z保留

    + * ! * ' ( ) ; : @ & = + $ , / ? # [ ] 保留 + * 其他字符转成%XX的格式,X是16进制的大写字符,范围是[0-9A-F] + */ + fun hasUrlEncoded(str: String): Boolean { + var needEncode = false + var i = 0 + while (i < str.length) { + val c = str[i] + if (notNeedEncoding.get(c.code)) { + i++ + continue + } + if (c == '%' && i + 2 < str.length) { + // 判断是否符合urlEncode规范 + val c1 = str[++i] + val c2 = str[++i] + if (isDigit16Char(c1) && isDigit16Char(c2)) { + i++ + continue + } + } + // 其他字符,肯定需要urlEncode + needEncode = true + break + } + + return !needEncode + } + + /** + * 判断c是否是16进制的字符 + */ + private fun isDigit16Char(c: Char): Boolean { + return c in '0'..'9' || c in 'A'..'F' || c in 'a'..'f' + } + + /** + * 获取绝对地址 + */ + fun getAbsoluteURL(baseURL: String?, relativePath: String): String { + if (baseURL.isNullOrEmpty()) return relativePath + if (relativePath.isAbsUrl()) return relativePath + var relativeUrl = relativePath + try { + val absoluteUrl = URL(baseURL.substringBefore(",")) + val parseUrl = URL(absoluteUrl, relativePath) + relativeUrl = parseUrl.toString() + return relativeUrl + } catch (e: Exception) { + Timber.e(e) + } + return relativeUrl + } + + /** + * 获取绝对地址 + */ + fun getAbsoluteURL(baseURL: URL?, relativePath: String): String { + if (baseURL == null) return relativePath + var relativeUrl = relativePath + try { + val parseUrl = URL(baseURL, relativePath) + relativeUrl = parseUrl.toString() + return relativeUrl + } catch (e: Exception) { + Timber.e("网址拼接出错\n${e.localizedMessage}", e) + } + return relativeUrl + } + + fun getBaseUrl(url: String?): String? { + if (url == null || !url.startsWith("http")) return null + val index = url.indexOf("/", 9) + return if (index == -1) { + url + } else url.substring(0, index) + } + + fun getSubDomain(url: String?): String { + val baseUrl = getBaseUrl(url) ?: return "" + return if (baseUrl.indexOf(".") == baseUrl.lastIndexOf(".")) { + baseUrl.substring(baseUrl.lastIndexOf("/") + 1) + } else baseUrl.substring(baseUrl.indexOf(".") + 1) + } + + /** + * Get local Ip address. + */ + fun getLocalIPAddress(): InetAddress? { + var enumeration: Enumeration? = null + try { + enumeration = NetworkInterface.getNetworkInterfaces() + } catch (e: SocketException) { + Timber.e(e) + } + + if (enumeration != null) { + while (enumeration.hasMoreElements()) { + val nif = enumeration.nextElement() + val addresses = nif.inetAddresses + if (addresses != null) { + while (addresses.hasMoreElements()) { + val address = addresses.nextElement() + if (!address.isLoopbackAddress && isIPv4Address(address.hostAddress)) { + return address + } + } + } + } + } + return null + } + + /** + * Check if valid IPV4 address. + * + * @param input the address string to check for validity. + * @return True if the input parameter is a valid IPv4 address. + */ + fun isIPv4Address(input: String?): Boolean { + return input != null && IPV4_PATTERN.matcher(input).matches() + } + + /** + * Ipv4 address check. + */ + private val IPV4_PATTERN = Pattern.compile( + "^(" + "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}" + + "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" + ) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/PaintExtensions.kt b/app/src/main/java/io/legado/app/utils/PaintExtensions.kt new file mode 100644 index 000000000..5feccd464 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/PaintExtensions.kt @@ -0,0 +1,6 @@ +package io.legado.app.utils + +import android.text.TextPaint + +val TextPaint.textHeight: Float + get() = fontMetrics.descent - fontMetrics.ascent + fontMetrics.leading \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/QRCodeUtils.kt b/app/src/main/java/io/legado/app/utils/QRCodeUtils.kt new file mode 100644 index 000000000..a6a8747a0 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/QRCodeUtils.kt @@ -0,0 +1,467 @@ +package io.legado.app.utils + +import android.graphics.* +import android.text.TextPaint +import android.text.TextUtils +import androidx.annotation.ColorInt +import androidx.annotation.FloatRange +import com.google.zxing.* +import com.google.zxing.common.GlobalHistogramBinarizer +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.king.zxing.DecodeFormatManager +import com.king.zxing.util.LogUtils +import java.util.* +import kotlin.math.max + + +@Suppress("MemberVisibilityCanBePrivate", "unused") +object QRCodeUtils { + + const val DEFAULT_REQ_WIDTH = 480 + const val DEFAULT_REQ_HEIGHT = 640 + + /** + * 生成二维码 + * @param content 二维码的内容 + * @param heightPix 二维码的高 + * @param logo 二维码中间的logo + * @param ratio logo所占比例 因为二维码的最大容错率为30%,所以建议ratio的范围小于0.3 + * @param errorCorrectionLevel + */ + fun createQRCode( + content: String, + heightPix: Int = DEFAULT_REQ_HEIGHT, + logo: Bitmap? = null, + @FloatRange(from = 0.0, to = 1.0) ratio: Float = 0.2f, + errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H + ): Bitmap? { + //配置参数 + val hints: MutableMap = EnumMap(EncodeHintType::class.java) + hints[EncodeHintType.CHARACTER_SET] = "utf-8" + //容错级别 + hints[EncodeHintType.ERROR_CORRECTION] = errorCorrectionLevel + //设置空白边距的宽度 + hints[EncodeHintType.MARGIN] = 1 //default is 4 + return createQRCode(content, heightPix, logo, ratio, hints) + } + + /** + * 生成二维码 + * @param content 二维码的内容 + * @param heightPix 二维码的高 + * @param logo 二维码中间的logo + * @param ratio logo所占比例 因为二维码的最大容错率为30%,所以建议ratio的范围小于0.3 + * @param hints + * @param codeColor 二维码的颜色 + * @return + */ + fun createQRCode( + content: String?, + heightPix: Int, + logo: Bitmap?, + @FloatRange(from = 0.0, to = 1.0) ratio: Float = 0.2f, + hints: Map, + codeColor: Int = Color.BLACK + ): Bitmap? { + try { + // 图像数据转换,使用了矩阵转换 + val bitMatrix = + QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, heightPix, heightPix, hints) + val pixels = IntArray(heightPix * heightPix) + // 下面这里按照二维码的算法,逐个生成二维码的图片, + // 两个for循环是图片横列扫描的结果 + for (y in 0 until heightPix) { + for (x in 0 until heightPix) { + if (bitMatrix[x, y]) { + pixels[y * heightPix + x] = codeColor + } else { + pixels[y * heightPix + x] = Color.WHITE + } + } + } + + // 生成二维码图片的格式 + var bitmap = Bitmap.createBitmap(heightPix, heightPix, Bitmap.Config.ARGB_8888) + bitmap!!.setPixels(pixels, 0, heightPix, 0, 0, heightPix, heightPix) + if (logo != null) { + bitmap = addLogo(bitmap, logo, ratio) + } + return bitmap + } catch (e: WriterException) { + LogUtils.w(e.message) + } + return null + } + + /** + * 在二维码中间添加Logo图案 + * @param src + * @param logo + * @param ratio logo所占比例 因为二维码的最大容错率为30%,所以建议ratio的范围小于0.3 + * @return + */ + private fun addLogo( + src: Bitmap?, + logo: Bitmap?, + @FloatRange(from = 0.0, to = 1.0) ratio: Float + ): Bitmap? { + if (src == null) { + return null + } + if (logo == null) { + return src + } + + //获取图片的宽高 + val srcWidth = src.width + val srcHeight = src.height + val logoWidth = logo.width + val logoHeight = logo.height + if (srcWidth == 0 || srcHeight == 0) { + return null + } + if (logoWidth == 0 || logoHeight == 0) { + return src + } + + //logo大小为二维码整体大小 + val scaleFactor = srcWidth * ratio / logoWidth + var bitmap = Bitmap.createBitmap(srcWidth, srcHeight, Bitmap.Config.ARGB_8888) + try { + val canvas = Canvas(bitmap!!) + canvas.drawBitmap(src, 0f, 0f, null) + canvas.scale( + scaleFactor, + scaleFactor, + (srcWidth / 2).toFloat(), + (srcHeight / 2).toFloat() + ) + canvas.drawBitmap( + logo, + ((srcWidth - logoWidth) / 2).toFloat(), + ((srcHeight - logoHeight) / 2).toFloat(), + null + ) + canvas.save() + canvas.restore() + } catch (e: Exception) { + bitmap = null + LogUtils.w(e.message) + } + return bitmap + } + + /** + * 解析一维码/二维码图片 + * @param bitmap 解析的图片 + * @param hints 解析编码类型 + * @return + */ + fun parseCode( + bitmap: Bitmap, + reqWidth: Int = DEFAULT_REQ_WIDTH, + reqHeight: Int = DEFAULT_REQ_HEIGHT, + hints: Map = DecodeFormatManager.ALL_HINTS + ): String? { + val result = parseCodeResult(bitmap, reqWidth, reqHeight, hints) + return result?.text + } + + /** + * 解析一维码/二维码图片 + * @param bitmap 解析的图片 + * @param hints 解析编码类型 + * @return + */ + fun parseCodeResult( + bitmap: Bitmap, + reqWidth: Int = DEFAULT_REQ_WIDTH, + reqHeight: Int = DEFAULT_REQ_HEIGHT, + hints: Map = DecodeFormatManager.ALL_HINTS + ): Result? { + if (bitmap.width > reqWidth || bitmap.height > reqHeight) { + val bm = bitmap.changeSize(reqWidth, reqHeight) + return parseCodeResult(getRGBLuminanceSource(bm), hints) + } + return parseCodeResult(getRGBLuminanceSource(bitmap), hints) + } + + /** + * 解析一维码/二维码图片 + * @param source + * @param hints + * @return + */ + fun parseCodeResult(source: LuminanceSource?, hints: Map?): Result? { + var result: Result? = null + val reader = MultiFormatReader() + try { + reader.setHints(hints) + if (source != null) { + result = decodeInternal(reader, source) + if (result == null) { + result = decodeInternal(reader, source.invert()) + } + if (result == null && source.isRotateSupported) { + result = decodeInternal(reader, source.rotateCounterClockwise()) + } + } + } catch (e: java.lang.Exception) { + LogUtils.w(e.message) + } finally { + reader.reset() + } + return result + } + + /** + * 解析二维码图片 + * @param bitmapPath 需要解析的图片路径 + * @return + */ + fun parseQRCode(bitmapPath: String?): String? { + val result = parseQRCodeResult(bitmapPath) + return result?.text + } + + /** + * 解析二维码图片 + * @param bitmapPath 需要解析的图片路径 + * @param reqWidth 请求目标宽度,如果实际图片宽度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 + * @param reqHeight 请求目标高度,如果实际图片高度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 + * @return + */ + fun parseQRCodeResult( + bitmapPath: String?, + reqWidth: Int = DEFAULT_REQ_WIDTH, + reqHeight: Int = DEFAULT_REQ_HEIGHT + ): Result? { + return parseCodeResult(bitmapPath, reqWidth, reqHeight, DecodeFormatManager.QR_CODE_HINTS) + } + + /** + * 解析一维码/二维码图片 + * @param bitmapPath 需要解析的图片路径 + * @return + */ + fun parseCode( + bitmapPath: String?, + reqWidth: Int = DEFAULT_REQ_WIDTH, + reqHeight: Int = DEFAULT_REQ_HEIGHT, + hints: Map = DecodeFormatManager.ALL_HINTS + ): String? { + return parseCodeResult(bitmapPath, reqWidth, reqHeight, hints)?.text + } + + /** + * 解析一维码/二维码图片 + * @param bitmapPath 需要解析的图片路径 + * @param reqWidth 请求目标宽度,如果实际图片宽度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 + * @param reqHeight 请求目标高度,如果实际图片高度大于此值,会自动进行压缩处理,当 reqWidth 和 reqHeight都小于或等于0时,则不进行压缩处理 + * @param hints 解析编码类型 + * @return + */ + fun parseCodeResult( + bitmapPath: String?, + reqWidth: Int = DEFAULT_REQ_WIDTH, + reqHeight: Int = DEFAULT_REQ_HEIGHT, + hints: Map = DecodeFormatManager.ALL_HINTS + ): Result? { + var result: Result? = null + val reader = MultiFormatReader() + try { + reader.setHints(hints) + val source = getRGBLuminanceSource(compressBitmap(bitmapPath, reqWidth, reqHeight)) + result = decodeInternal(reader, source) + if (result == null) { + result = decodeInternal(reader, source.invert()) + } + if (result == null && source.isRotateSupported) { + result = decodeInternal(reader, source.rotateCounterClockwise()) + } + } catch (e: Exception) { + LogUtils.w(e.message) + } finally { + reader.reset() + } + return result + } + + private fun decodeInternal(reader: MultiFormatReader, source: LuminanceSource): Result? { + var result: Result? = null + try { + try { + //采用HybridBinarizer解析 + result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + } catch (e: Exception) { + } + if (result == null) { + //如果没有解析成功,再采用GlobalHistogramBinarizer解析一次 + result = reader.decodeWithState(BinaryBitmap(GlobalHistogramBinarizer(source))) + } + } catch (e: Exception) { + } + return result + } + + + /** + * 压缩图片 + * @param path + * @return + */ + private fun compressBitmap(path: String?, reqWidth: Int, reqHeight: Int): Bitmap { + if (reqWidth > 0 && reqHeight > 0) { //都大于进行判断是否压缩 + val newOpts = BitmapFactory.Options() + // 开始读入图片,此时把options.inJustDecodeBounds 设回true了 + newOpts.inJustDecodeBounds = true //获取原始图片大小 + BitmapFactory.decodeFile(path, newOpts) // 此时返回bm为空 + val width = newOpts.outWidth.toFloat() + val height = newOpts.outHeight.toFloat() + // 缩放比,由于是固定比例缩放,只用高或者宽其中一个数据进行计算即可 + var wSize = 1 // wSize=1表示不缩放 + if (width > reqWidth) { // 如果宽度大的话根据宽度固定大小缩放 + wSize = (width / reqWidth).toInt() + } + var hSize = 1 // wSize=1表示不缩放 + if (height > reqHeight) { // 如果高度高的话根据宽度固定大小缩放 + hSize = (height / reqHeight).toInt() + } + var size = max(wSize, hSize) + if (size <= 0) size = 1 + newOpts.inSampleSize = size // 设置缩放比例 + // 重新读入图片,注意此时已经把options.inJustDecodeBounds 设回false了 + newOpts.inJustDecodeBounds = false + return BitmapFactory.decodeFile(path, newOpts) + } + return BitmapFactory.decodeFile(path) + } + + + /** + * 获取RGBLuminanceSource + * @param bitmap + * @return + */ + private fun getRGBLuminanceSource(bitmap: Bitmap): RGBLuminanceSource { + val width = bitmap.width + val height = bitmap.height + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + return RGBLuminanceSource(width, height, pixels) + } + + /** + * 生成条形码 + * @param content + * @param format + * @param desiredWidth + * @param desiredHeight + * @param hints + * @param isShowText + * @param textSize + * @param codeColor + * @return + */ + fun createBarCode( + content: String?, + desiredWidth: Int, + desiredHeight: Int, + format: BarcodeFormat = BarcodeFormat.CODE_128, + hints: Map? = null, + isShowText: Boolean = true, + textSize: Int = 40, + @ColorInt codeColor: Int = Color.BLACK + ): Bitmap? { + if (TextUtils.isEmpty(content)) { + return null + } + val writer = MultiFormatWriter() + try { + val result = writer.encode( + content, format, desiredWidth, + desiredHeight, hints + ) + val width = result.width + val height = result.height + val pixels = IntArray(width * height) + // All are 0, or black, by default + for (y in 0 until height) { + val offset = y * width + for (x in 0 until width) { + pixels[offset + x] = if (result[x, y]) codeColor else Color.WHITE + } + } + val bitmap = Bitmap.createBitmap( + width, height, + Bitmap.Config.ARGB_8888 + ) + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + return if (isShowText) { + addCode(bitmap, content, textSize, codeColor, textSize / 2) + } else bitmap + } catch (e: WriterException) { + LogUtils.w(e.message) + } + return null + } + + /** + * 条形码下面添加文本信息 + * @param src + * @param code + * @param textSize + * @param textColor + * @return + */ + private fun addCode( + src: Bitmap?, + code: String?, + textSize: Int, + @ColorInt textColor: Int, + offset: Int + ): Bitmap? { + if (src == null) { + return null + } + if (TextUtils.isEmpty(code)) { + return src + } + + //获取图片的宽高 + val srcWidth = src.width + val srcHeight = src.height + if (srcWidth <= 0 || srcHeight <= 0) { + return null + } + var bitmap = Bitmap.createBitmap( + srcWidth, + srcHeight + textSize + offset * 2, + Bitmap.Config.ARGB_8888 + ) + try { + val canvas = Canvas(bitmap!!) + canvas.drawBitmap(src, 0f, 0f, null) + val paint = TextPaint() + paint.textSize = textSize.toFloat() + paint.color = textColor + paint.textAlign = Paint.Align.CENTER + canvas.drawText( + code!!, + (srcWidth / 2).toFloat(), + (srcHeight + textSize / 2 + offset).toFloat(), + paint + ) + canvas.save() + canvas.restore() + } catch (e: Exception) { + bitmap = null + LogUtils.w(e.message) + } + return bitmap + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/RealPathUtil.kt b/app/src/main/java/io/legado/app/utils/RealPathUtil.kt new file mode 100644 index 000000000..c502c97b9 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/RealPathUtil.kt @@ -0,0 +1,176 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException + +@Suppress("unused") +object RealPathUtil { + /** + * Method for return file path of Gallery image + * @return path of the selected image file from gallery + */ + private var filePathUri: Uri? = null + + @Suppress("DEPRECATION") + fun getPath(context: Context, uri: Uri): String? { + //check here to KITKAT or new version + @SuppressLint("ObsoleteSdkInt") + val isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + filePathUri = uri + // DocumentProvider + if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":").toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + return Environment.getExternalStorageDirectory().toString() + "/" + split[1] + } + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), + java.lang.Long.valueOf(id) + ) + //return getDataColumn(context, uri, null, null); + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":").toTypedArray() + val type = split[0] + var contentUri: Uri? = null + when (type) { + "image" -> { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + "video" -> { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + "audio" -> { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + } + val selection = "_id=?" + val selectionArgs = arrayOf( + split[1] + ) + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } else if ("content".equals( + uri.scheme, + ignoreCase = true + ) + ) { // Return the remote address + return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn( + context, + uri, + null, + null + ) + } else if ("file".equals(uri.scheme, ignoreCase = true)) { + return uri.path + } + return null + } + + /** + * Get the value of the data column for this Uri. This is useful for + * MediaStore Uris, and other file-based ContentProviders. + * + * @param context The context. + * @param uri The Uri to query. + * @param selection (Optional) Filter used in the query. + * @param selectionArgs (Optional) Selection arguments used in the query. + * @return The value of the _data column, which is typically a file path. + */ + private fun getDataColumn( + context: Context, uri: Uri?, selection: String?, + selectionArgs: Array? + ): String? { + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf( + column + ) + try { + cursor = + context.contentResolver.query(uri!!, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndexOrThrow(column) + return cursor.getString(index) + } + } catch (e: IllegalArgumentException) { + Timber.e(e) + val file = File(context.cacheDir, "tmp") + val filePath = file.absolutePath + var input: FileInputStream? = null + var output: FileOutputStream? = null + try { + val pfd = + context.contentResolver.openFileDescriptor(filePathUri!!, "r") + ?: return null + val fd = pfd.fileDescriptor + input = FileInputStream(fd) + output = FileOutputStream(filePath) + var read: Int + val bytes = ByteArray(4096) + while (input.read(bytes).also { read = it } != -1) { + output.write(bytes, 0, read) + } + return File(filePath).absolutePath + } catch (ignored: IOException) { + Timber.e(ignored) + } finally { + input?.close() + output?.close() + } + } finally { + cursor?.close() + } + return null + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + private fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.authority + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/Snackbars.kt b/app/src/main/java/io/legado/app/utils/Snackbars.kt new file mode 100644 index 000000000..5e4e28951 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/Snackbars.kt @@ -0,0 +1,167 @@ +package io.legado.app.utils + +import android.view.View +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +@JvmName("snackbar2") +fun View.snackbar( + @StringRes message: Int +) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +@JvmName("longSnackbar2") +fun View.longSnackbar( + @StringRes message: Int +) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text resource. + */ +@JvmName("indefiniteSnackbar2") +fun View.indefiniteSnackbar( + @StringRes message: Int +) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +@JvmName("snackbar2") +fun View.snackbar( + message: CharSequence +) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text. + */ +@JvmName("longSnackbar2") +fun View.longSnackbar( + message: CharSequence +) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text. + */ +@JvmName("indefiniteSnackbar2") +fun View.indefiniteSnackbar( + message: CharSequence +) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +@JvmName("snackbar2") +fun View.snackbar( + message: Int, + @StringRes actionText: + Int, action: (View) -> Unit +) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +@JvmName("longSnackbar2") +fun View.longSnackbar( + @StringRes message: Int, + @StringRes actionText: Int, + action: (View) -> Unit +) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text resource. + */ +@JvmName("indefiniteSnackbar2") +fun View.indefiniteSnackbar( + @StringRes message: Int, + @StringRes actionText: Int, + action: (View) -> Unit +) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .setAction(actionText, action) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +@JvmName("snackbar2") +fun View.snackbar( + message: CharSequence, + actionText: CharSequence, + action: (View) -> Unit +) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text. + */ +@JvmName("longSnackbar2") +fun View.longSnackbar( + message: CharSequence, + actionText: CharSequence, + action: (View) -> Unit +) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text. + */ +@JvmName("indefiniteSnackbar2") +fun View.indefiniteSnackbar( + message: CharSequence, + actionText: CharSequence, + action: (View) -> Unit +) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .setAction(actionText, action) + .apply { show() } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/StringExtensions.kt b/app/src/main/java/io/legado/app/utils/StringExtensions.kt new file mode 100644 index 000000000..d026812e8 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/StringExtensions.kt @@ -0,0 +1,87 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.icu.text.Collator +import android.icu.util.ULocale +import android.net.Uri +import java.io.File +import java.util.* + +fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim() + +fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true + +fun String.parseToUri(): Uri { + return if (isContentScheme()) { + Uri.parse(this) + } else { + Uri.fromFile(File(this)) + } +} + +fun String?.isAbsUrl() = + this?.let { + it.startsWith("http://", true) || it.startsWith("https://", true) + } ?: false + +fun String?.isJson(): Boolean = + this?.run { + val str = this.trim() + when { + str.startsWith("{") && str.endsWith("}") -> true + str.startsWith("[") && str.endsWith("]") -> true + else -> false + } + } ?: false + +fun String?.isJsonObject(): Boolean = + this?.run { + val str = this.trim() + str.startsWith("{") && str.endsWith("}") + } ?: false + +fun String?.isJsonArray(): Boolean = + this?.run { + val str = this.trim() + str.startsWith("[") && str.endsWith("]") + } ?: false + +fun String?.isXml(): Boolean = + this?.run { + val str = this.trim() + str.startsWith("<") && str.endsWith(">") + } ?: false + +fun String.splitNotBlank(vararg delimiter: String): Array = run { + this.split(*delimiter).map { it.trim() }.filterNot { it.isBlank() }.toTypedArray() +} + +fun String.splitNotBlank(regex: Regex, limit: Int = 0): Array = run { + this.split(regex, limit).map { it.trim() }.filterNot { it.isBlank() }.toTypedArray() +} + +fun String.cnCompare(other: String): Int { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + Collator.getInstance(ULocale.SIMPLIFIED_CHINESE).compare(this, other) + } else { + java.text.Collator.getInstance(Locale.CHINA).compare(this, other) + } +} + +/** + * 将字符串拆分为单个字符,包含emoji + */ +fun String.toStringArray(): Array { + var codePointIndex = 0 + return try { + Array(codePointCount(0, length)) { + val start = codePointIndex + codePointIndex = offsetByCodePoints(start, 1) + substring(start, codePointIndex) + } + } catch (e: Exception) { + split("").toTypedArray() + } +} + diff --git a/app/src/main/java/io/legado/app/utils/StringUtils.kt b/app/src/main/java/io/legado/app/utils/StringUtils.kt new file mode 100644 index 000000000..43440fa86 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/StringUtils.kt @@ -0,0 +1,316 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.text.TextUtils.isEmpty +import timber.log.Timber +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Matcher +import java.util.regex.Pattern +import kotlin.math.abs + +@Suppress("unused", "MemberVisibilityCanBePrivate") +object StringUtils { + private const val HOUR_OF_DAY = 24 + private const val DAY_OF_YESTERDAY = 2 + private const val TIME_UNIT = 60 + private val ChnMap = chnMap + + private val chnMap: HashMap + get() { + val map = HashMap() + var cnStr = "零一二三四五六七八九十" + var c = cnStr.toCharArray() + for (i in 0..10) { + map[c[i]] = i + } + cnStr = "〇壹贰叁肆伍陆柒捌玖拾" + c = cnStr.toCharArray() + for (i in 0..10) { + map[c[i]] = i + } + map['两'] = 2 + map['百'] = 100 + map['佰'] = 100 + map['千'] = 1000 + map['仟'] = 1000 + map['万'] = 10000 + map['亿'] = 100000000 + return map + } + + /** + * 将日期转换成昨天、今天、明天 + */ + fun dateConvert(source: String, pattern: String): String { + @SuppressLint("SimpleDateFormat") + val format = SimpleDateFormat(pattern) + val calendar = Calendar.getInstance() + kotlin.runCatching { + val date = format.parse(source) ?: return "" + val curTime = calendar.timeInMillis + calendar.time = date + //将MISC 转换成 sec + val difSec = abs((curTime - date.time) / 1000) + val difMin = difSec / 60 + val difHour = difMin / 60 + val difDate = difHour / 60 + val oldHour = calendar.get(Calendar.HOUR) + //如果没有时间 + if (oldHour == 0) { + //比日期:昨天今天和明天 + return when { + difDate == 0L -> "今天" + difDate < DAY_OF_YESTERDAY -> "昨天" + else -> { + @SuppressLint("SimpleDateFormat") + val convertFormat = SimpleDateFormat("yyyy-MM-dd") + convertFormat.format(date) + } + } + } + + return when { + difSec < TIME_UNIT -> difSec.toString() + "秒前" + difMin < TIME_UNIT -> difMin.toString() + "分钟前" + difHour < HOUR_OF_DAY -> difHour.toString() + "小时前" + difDate < DAY_OF_YESTERDAY -> "昨天" + else -> { + @SuppressLint("SimpleDateFormat") + val convertFormat = SimpleDateFormat("yyyy-MM-dd") + convertFormat.format(date) + } + } + }.onFailure { + Timber.e(it) + } + return "" + } + + /** + * 首字母大写 + */ + @SuppressLint("DefaultLocale") + fun toFirstCapital(str: String): String { + return str.substring(0, 1).uppercase(Locale.getDefault()) + str.substring(1) + } + + /** + * 将文本中的半角字符,转换成全角字符 + */ + fun halfToFull(input: String): String { + val c = input.toCharArray() + for (i in c.indices) { + if (c[i].code == 32) + //半角空格 + { + c[i] = 12288.toChar() + continue + } + //根据实际情况,过滤不需要转换的符号 + //if (c[i] == 46) //半角点号,不转换 + // continue; + + if (c[i].code in 33..126) + //其他符号都转换为全角 + c[i] = (c[i].code + 65248).toChar() + } + return String(c) + } + + /** + * 字符串全角转换为半角 + */ + fun fullToHalf(input: String): String { + val c = input.toCharArray() + for (i in c.indices) { + if (c[i].code == 12288) + //全角空格 + { + c[i] = 32.toChar() + continue + } + + if (c[i].code in 65281..65374) + c[i] = (c[i].code - 65248).toChar() + } + return String(c) + } + + /** + * 中文大写数字转数字 + */ + fun chineseNumToInt(chNum: String): Int { + var result = 0 + var tmp = 0 + var billion = 0 + val cn = chNum.toCharArray() + + // "一零二五" 形式 + if (cn.size > 1 && chNum.matches("^[〇零一二三四五六七八九壹贰叁肆伍陆柒捌玖]$".toRegex())) { + for (i in cn.indices) { + cn[i] = (48 + ChnMap[cn[i]]!!).toChar() + } + return Integer.parseInt(String(cn)) + } + + // "一千零二十五", "一千二" 形式 + return kotlin.runCatching { + for (i in cn.indices) { + val tmpNum = ChnMap[cn[i]]!! + when { + tmpNum == 100000000 -> { + result += tmp + result *= tmpNum + billion = billion * 100000000 + result + result = 0 + tmp = 0 + } + tmpNum == 10000 -> { + result += tmp + result *= tmpNum + tmp = 0 + } + tmpNum >= 10 -> { + if (tmp == 0) + tmp = 1 + result += tmpNum * tmp + tmp = 0 + } + else -> { + tmp = if (i >= 2 && i == cn.size - 1 && ChnMap[cn[i - 1]]!! > 10) + tmpNum * ChnMap[cn[i - 1]]!! / 10 + else + tmp * 10 + tmpNum + } + } + } + result += tmp + billion + result + }.getOrDefault(-1) + } + + /** + * 字符串转数字 + */ + fun stringToInt(str: String?): Int { + if (str != null) { + val num = fullToHalf(str).replace("\\s+".toRegex(), "") + return kotlin.runCatching { + Integer.parseInt(num) + }.getOrElse { + chineseNumToInt(num) + } + } + return -1 + } + + /** + * 是否包含数字 + */ + fun isContainNumber(company: String): Boolean { + val p = Pattern.compile("[0-9]+") + val m = p.matcher(company) + return m.find() + } + + /** + * 是否数字 + */ + fun isNumeric(str: String): Boolean { + val pattern = Pattern.compile("-?[0-9]+") + val isNum = pattern.matcher(str) + return isNum.matches() + } + + fun wordCountFormat(wc: String?): String { + if (wc == null) return "" + var wordsS = "" + if (isNumeric(wc)) { + val words: Int = wc.toInt() + if (words > 0) { + wordsS = words.toString() + "字" + if (words > 10000) { + val df = DecimalFormat("#.#") + wordsS = df.format(words * 1.0f / 10000f.toDouble()) + "万字" + } + } + } else { + wordsS = wc + } + return wordsS + } + + /** + * 移除字符串首尾空字符的高效方法(利用ASCII值判断,包括全角空格) + */ + fun trim(s: String): String { + if (isEmpty(s)) return "" + var start = 0 + val len = s.length + var end = len - 1 + while (start < end && (s[start].code <= 0x20 || s[start] == ' ')) { + ++start + } + while (start < end && (s[end].code <= 0x20 || s[end] == ' ')) { + --end + } + if (end < len) ++end + return if (start > 0 || end < len) s.substring(start, end) else s + } + + /** + * 重复字符串 + */ + fun repeat(str: String, n: Int): String { + val stringBuilder = StringBuilder() + for (i in 0 until n) { + stringBuilder.append(str) + } + return stringBuilder.toString() + } + + /** + * 移除UTF头 + */ + fun removeUTFCharacters(data: String?): String? { + if (data == null) return null + val p = Pattern.compile("\\\\u(\\p{XDigit}{4})") + val m = p.matcher(data) + val buf = StringBuffer(data.length) + while (m.find()) { + val ch = Integer.parseInt(m.group(1)!!, 16).toChar().toString() + m.appendReplacement(buf, Matcher.quoteReplacement(ch)) + } + m.appendTail(buf) + return buf.toString() + } + + fun byteToHexString(bytes: ByteArray?): String { + if (bytes == null) return "" + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) { + val hex = 0xff and b.toInt() + if (hex < 16) { + sb.append('0') + } + sb.append(Integer.toHexString(hex)) + } + return sb.toString() + } + + fun hexStringToByte(hexString: String): ByteArray { + val hexStr = hexString.replace(" ", "") + val len = hexStr.length + val bytes = ByteArray(len / 2) + var i = 0 + while (i < len) { + // 两位一组,表示一个字节,把这样表示的16进制字符串,还原成一个字节 + bytes[i / 2] = ((Character.digit(hexString[i], 16) shl 4) + + Character.digit(hexString[i + 1], 16)).toByte() + i += 2 + } + return bytes + } +} diff --git a/app/src/main/java/io/legado/app/utils/SystemUtils.kt b/app/src/main/java/io/legado/app/utils/SystemUtils.kt new file mode 100644 index 000000000..1b6df2df6 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/SystemUtils.kt @@ -0,0 +1,33 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context.POWER_SERVICE +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings + + +@Suppress("unused") +object SystemUtils { + + fun ignoreBatteryOptimization(activity: Activity) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) return + + val powerManager = activity.getSystemService(POWER_SERVICE) as PowerManager + val hasIgnored = powerManager.isIgnoringBatteryOptimizations(activity.packageName) + // 判断当前APP是否有加入电池优化的白名单,如果没有,弹出加入电池优化的白名单的设置对话框。 + if (!hasIgnored) { + try { + @SuppressLint("BatteryLife") + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + intent.data = Uri.parse("package:" + activity.packageName) + activity.startActivity(intent) + } catch (ignored: Throwable) { + } + + } + } + +} diff --git a/app/src/main/java/io/legado/app/utils/ThrowableExtensions.kt b/app/src/main/java/io/legado/app/utils/ThrowableExtensions.kt new file mode 100644 index 000000000..409152490 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ThrowableExtensions.kt @@ -0,0 +1,11 @@ +package io.legado.app.utils + +val Throwable.msg: String + get() { + val stackTrace = stackTraceToString() + val lMsg = this.localizedMessage ?: "noErrorMsg" + return when { + stackTrace.isNotEmpty() -> stackTrace + else -> lMsg + } + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ToastUtils.kt b/app/src/main/java/io/legado/app/utils/ToastUtils.kt new file mode 100644 index 000000000..7a5f2cc83 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ToastUtils.kt @@ -0,0 +1,66 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.content.Context +import android.widget.Toast +import androidx.fragment.app.Fragment + +private var toast: Toast? = null + +fun Context.toastOnUi(message: Int) { + runOnUI { + if (toast == null) { + toast = Toast.makeText(this, message, Toast.LENGTH_SHORT) + } else { + toast?.setText(message) + toast?.duration = Toast.LENGTH_SHORT + } + toast?.show() + } +} + +fun Context.toastOnUi(message: CharSequence?) { + runOnUI { + if (toast == null) { + toast = Toast.makeText(this, message, Toast.LENGTH_SHORT) + } else { + toast?.setText(message) + toast?.duration = Toast.LENGTH_SHORT + } + toast?.show() + } +} + +fun Context.longToastOnUi(message: Int) { + runOnUI { + if (toast == null) { + toast = Toast.makeText(this, message, Toast.LENGTH_LONG) + } else { + toast?.setText(message) + toast?.duration = Toast.LENGTH_LONG + } + toast?.show() + } +} + +fun Context.longToastOnUi(message: CharSequence?) { + runOnUI { + if (toast == null) { + toast = Toast.makeText(this, message, Toast.LENGTH_LONG) + } else { + toast?.setText(message) + toast?.duration = Toast.LENGTH_LONG + } + toast?.show() + } +} + + +fun Fragment.toastOnUi(message: Int) = requireActivity().toastOnUi(message) + +fun Fragment.toastOnUi(message: CharSequence) = requireActivity().toastOnUi(message) + +fun Fragment.longToast(message: Int) = requireContext().longToastOnUi(message) + +fun Fragment.longToast(message: CharSequence) = requireContext().longToastOnUi(message) diff --git a/app/src/main/java/io/legado/app/utils/ToolBarExtensions.kt b/app/src/main/java/io/legado/app/utils/ToolBarExtensions.kt new file mode 100644 index 000000000..3b7629671 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ToolBarExtensions.kt @@ -0,0 +1,21 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.os.Build +import android.widget.Toolbar +import androidx.core.content.ContextCompat +import io.legado.app.R + +/** + * 设置toolBar更多图标颜色 + */ +fun Toolbar.setMoreIconColor(color: Int) { + val moreIcon = ContextCompat.getDrawable(context, R.drawable.ic_more) + if (moreIcon != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + moreIcon.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) + overflowIcon = moreIcon + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/UIUtils.kt b/app/src/main/java/io/legado/app/utils/UIUtils.kt new file mode 100644 index 000000000..059f94a07 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/UIUtils.kt @@ -0,0 +1,27 @@ +package io.legado.app.utils + +import android.content.Context +import io.legado.app.R +import io.legado.app.constant.Theme +import io.legado.app.lib.theme.primaryTextColor + +@Suppress("unused") +object UIUtils { + + fun getMenuColor( + context: Context, + theme: Theme = Theme.Auto, + requiresOverflow: Boolean = false + ): Int { + val defaultTextColor = context.getCompatColor(R.color.primaryText) + if (requiresOverflow) + return defaultTextColor + val primaryTextColor = context.primaryTextColor + return when (theme) { + Theme.Dark -> context.getCompatColor(R.color.md_white_1000) + Theme.Light -> context.getCompatColor(R.color.md_black_1000) + else -> primaryTextColor + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/UTF8BOMFighter.kt b/app/src/main/java/io/legado/app/utils/UTF8BOMFighter.kt new file mode 100644 index 000000000..3f502f8b6 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/UTF8BOMFighter.kt @@ -0,0 +1,31 @@ +package io.legado.app.utils + +@Suppress("unused") +object UTF8BOMFighter { + private val UTF8_BOM_BYTES = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + + fun removeUTF8BOM(xmlText: String): String { + val bytes = xmlText.toByteArray() + val containsBOM = (bytes.size > 3 + && bytes[0] == UTF8_BOM_BYTES[0] + && bytes[1] == UTF8_BOM_BYTES[1] + && bytes[2] == UTF8_BOM_BYTES[2]) + if (containsBOM) { + return String(bytes, 3, bytes.size - 3) + } + return xmlText + } + + fun removeUTF8BOM(bytes: ByteArray): ByteArray { + val containsBOM = (bytes.size > 3 + && bytes[0] == UTF8_BOM_BYTES[0] + && bytes[1] == UTF8_BOM_BYTES[1] + && bytes[2] == UTF8_BOM_BYTES[2]) + if (containsBOM) { + val copy = ByteArray(bytes.size - 3) + System.arraycopy(bytes, 3, copy, 0, bytes.size - 3) + return copy + } + return bytes + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/UriExtensions.kt b/app/src/main/java/io/legado/app/utils/UriExtensions.kt new file mode 100644 index 000000000..70717fe3b --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/UriExtensions.kt @@ -0,0 +1,143 @@ +package io.legado.app.utils + +import android.content.Context +import android.net.Uri +import androidx.appcompat.app.AppCompatActivity +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import io.legado.app.R +import io.legado.app.lib.permission.Permissions +import io.legado.app.lib.permission.PermissionsCompat +import io.legado.app.model.NoStackTraceException +import timber.log.Timber +import java.io.File + +fun Uri.isContentScheme() = this.scheme == "content" + +/** + * 读取URI + */ +fun AppCompatActivity.readUri(uri: Uri?, success: (name: String, bytes: ByteArray) -> Unit) { + uri ?: return + try { + if (uri.isContentScheme()) { + val doc = DocumentFile.fromSingleUri(this, uri) + doc ?: throw NoStackTraceException("未获取到文件") + val name = doc.name ?: throw NoStackTraceException("未获取到文件名") + val fileBytes = DocumentUtils.readBytes(this, doc.uri) + success.invoke(name, fileBytes) + } else { + PermissionsCompat.Builder(this) + .addPermissions( + Permissions.READ_EXTERNAL_STORAGE, + Permissions.WRITE_EXTERNAL_STORAGE + ) + .rationale(R.string.bg_image_per) + .onGranted { + RealPathUtil.getPath(this, uri)?.let { path -> + val imgFile = File(path) + success.invoke(imgFile.name, imgFile.readBytes()) + } + } + .request() + } + } catch (e: Exception) { + Timber.e(e) + toastOnUi(e.localizedMessage ?: "read uri error") + } +} + +/** + * 读取URI + */ +fun Fragment.readUri(uri: Uri?, success: (name: String, bytes: ByteArray) -> Unit) { + uri ?: return + try { + if (uri.isContentScheme()) { + val doc = DocumentFile.fromSingleUri(requireContext(), uri) + doc ?: throw NoStackTraceException("未获取到文件") + val name = doc.name ?: throw NoStackTraceException("未获取到文件名") + val fileBytes = DocumentUtils.readBytes(requireContext(), doc.uri) + success.invoke(name, fileBytes) + } else { + PermissionsCompat.Builder(this) + .addPermissions( + Permissions.READ_EXTERNAL_STORAGE, + Permissions.WRITE_EXTERNAL_STORAGE + ) + .rationale(R.string.bg_image_per) + .onGranted { + RealPathUtil.getPath(requireContext(), uri)?.let { path -> + val imgFile = File(path) + success.invoke(imgFile.name, imgFile.readBytes()) + } + } + .request() + } + } catch (e: Exception) { + Timber.e(e) + toastOnUi(e.localizedMessage ?: "read uri error") + } +} + +@Throws(Exception::class) +fun Uri.readBytes(context: Context): ByteArray { + return if (this.isContentScheme()) { + DocumentUtils.readBytes(context, this) + } else { + val path = RealPathUtil.getPath(context, this) + if (path?.isNotEmpty() == true) { + File(path).readBytes() + } else { + throw NoStackTraceException("获取文件真实地址失败\n${this.path}") + } + } +} + +@Throws(Exception::class) +fun Uri.readText(context: Context): String { + readBytes(context).let { + return String(it) + } +} + +@Throws(Exception::class) +fun Uri.writeBytes( + context: Context, + byteArray: ByteArray +): Boolean { + if (this.isContentScheme()) { + return DocumentUtils.writeBytes(context, byteArray, this) + } else { + val path = RealPathUtil.getPath(context, this) + if (path?.isNotEmpty() == true) { + File(path).writeBytes(byteArray) + return true + } + } + return false +} + +@Throws(Exception::class) +fun Uri.writeText(context: Context, text: String): Boolean { + return writeBytes(context, text.toByteArray()) +} + +fun Uri.writeBytes( + context: Context, + fileName: String, + byteArray: ByteArray +): Boolean { + if (this.isContentScheme()) { + DocumentFile.fromTreeUri(context, this)?.let { pDoc -> + DocumentUtils.createFileIfNotExist(pDoc, fileName)?.let { + return it.uri.writeBytes(context, byteArray) + } + } + } else { + FileUtils.createFileWithReplace(path + File.separatorChar + fileName) + .writeBytes(byteArray) + return true + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ViewExtensions.kt b/app/src/main/java/io/legado/app/utils/ViewExtensions.kt new file mode 100644 index 000000000..05f96defe --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ViewExtensions.kt @@ -0,0 +1,177 @@ +@file:Suppress("unused") + +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.text.Html +import android.view.View +import android.view.View.* +import android.view.inputmethod.InputMethodManager +import android.widget.* +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.menu.MenuPopupHelper +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.get +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager.widget.ViewPager +import io.legado.app.help.AppConfig +import io.legado.app.lib.theme.TintHelper +import splitties.init.appCtx +import timber.log.Timber +import java.lang.reflect.Field + + +private tailrec fun getCompatActivity(context: Context?): AppCompatActivity? { + return when (context) { + is AppCompatActivity -> context + is androidx.appcompat.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) + is android.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) + else -> null + } +} + +val View.activity: AppCompatActivity? + get() = getCompatActivity(context) + +fun View.hideSoftInput() = run { + val imm = appCtx.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(this.windowToken, 0) +} + +fun View.disableAutoFill() = run { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + } +} + +fun View.applyTint( + @ColorInt color: Int, + isDark: Boolean = AppConfig.isNightTheme(context) +) { + TintHelper.setTintAuto(this, color, false, isDark) +} + +fun View.applyBackgroundTint( + @ColorInt color: Int, + isDark: Boolean = AppConfig.isNightTheme +) { + if (background == null) { + setBackgroundColor(color) + } else { + TintHelper.setTintAuto(this, color, true, isDark) + } +} + +fun RecyclerView.setEdgeEffectColor(@ColorInt color: Int) { + edgeEffectFactory = object : RecyclerView.EdgeEffectFactory() { + override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect { + val edgeEffect = super.createEdgeEffect(view, direction) + edgeEffect.color = color + return edgeEffect + } + } +} + +fun ViewPager.setEdgeEffectColor(@ColorInt color: Int) { + try { + val clazz = ViewPager::class.java + for (name in arrayOf("mLeftEdge", "mRightEdge")) { + val field = clazz.getDeclaredField(name) + field.isAccessible = true + val edge = field.get(this) + (edge as EdgeEffect).color = color + } + } catch (ignored: Exception) { + } +} + +fun EditText.disableEdit() { + keyListener = null +} + +fun View.gone() { + if (visibility != GONE) { + visibility = GONE + } +} + +fun View.invisible() { + if (visibility != INVISIBLE) { + visibility = INVISIBLE + } +} + +fun View.visible() { + if (visibility != VISIBLE) { + visibility = VISIBLE + } +} + +fun View.visible(visible: Boolean) { + if (visible && visibility != VISIBLE) { + visibility = VISIBLE + } else if (!visible && visibility == VISIBLE) { + visibility = INVISIBLE + } +} + +fun View.screenshot(): Bitmap? { + return runCatching { + val screenshot = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val c = Canvas(screenshot) + c.translate(-scrollX.toFloat(), -scrollY.toFloat()) + draw(c) + screenshot + }.getOrNull() +} + +fun SeekBar.progressAdd(int: Int) { + progress += int +} + +fun RadioGroup.getIndexById(id: Int): Int { + for (i in 0 until this.childCount) { + if (id == get(i).id) { + return i + } + } + return 0 +} + +fun RadioGroup.getCheckedIndex(): Int { + for (i in 0 until this.childCount) { + if (checkedRadioButtonId == get(i).id) { + return i + } + } + return 0 +} + +fun RadioGroup.checkByIndex(index: Int) { + check(get(index).id) +} + +fun TextView.setHtml(html: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + text = Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT) + } else { + @Suppress("DEPRECATION") + text = Html.fromHtml(html) + } +} + +@SuppressLint("RestrictedApi") +fun PopupMenu.show(x: Int, y: Int) { + kotlin.runCatching { + val field: Field = this.javaClass.getDeclaredField("mPopup") + field.isAccessible = true + (field.get(this) as MenuPopupHelper).show(x, y) + }.onFailure { + Timber.e(it) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ZipUtils.kt b/app/src/main/java/io/legado/app/utils/ZipUtils.kt new file mode 100644 index 000000000..ebfcdc13e --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ZipUtils.kt @@ -0,0 +1,393 @@ +package io.legado.app.utils + +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.* +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +@Suppress("unused", "BlockingMethodInNonBlockingContext", "MemberVisibilityCanBePrivate") +object ZipUtils { + + /** + * Zip the files. + * + * @param srcFiles The source of files. + * @param zipFilePath The path of ZIP file. + * @return `true`: success

    `false`: fail + * @throws IOException if an I/O error has occurred + */ + suspend fun zipFiles( + srcFiles: Collection, + zipFilePath: String + ): Boolean { + return zipFiles(srcFiles, zipFilePath, null) + } + + /** + * Zip the files. + * + * @param srcFilePaths The paths of source files. + * @param zipFilePath The path of ZIP file. + * @param comment The comment. + * @return `true`: success

    `false`: fail + * @throws IOException if an I/O error has occurred + */ + suspend fun zipFiles( + srcFilePaths: Collection?, + zipFilePath: String?, + comment: String? + ): Boolean = withContext(IO) { + if (srcFilePaths == null || zipFilePath == null) return@withContext false + ZipOutputStream(FileOutputStream(zipFilePath)).use { + for (srcFile in srcFilePaths) { + if (!zipFile(getFileByPath(srcFile)!!, "", it, comment)) + return@withContext false + } + return@withContext true + } + } + + /** + * Zip the files. + * + * @param srcFiles The source of files. + * @param zipFile The ZIP file. + * @param comment The comment. + * @return `true`: success

    `false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + @JvmOverloads + fun zipFiles( + srcFiles: Collection?, + zipFile: File?, + comment: String? = null + ): Boolean { + if (srcFiles == null || zipFile == null) return false + ZipOutputStream(FileOutputStream(zipFile)).use { + for (srcFile in srcFiles) { + if (!zipFile(srcFile, "", it, comment)) return false + } + return true + } + } + + /** + * Zip the file. + * + * @param srcFilePath The path of source file. + * @param zipFilePath The path of ZIP file. + * @return `true`: success

    `false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun zipFile( + srcFilePath: String, + zipFilePath: String + ): Boolean { + return zipFile(getFileByPath(srcFilePath), getFileByPath(zipFilePath), null) + } + + /** + * Zip the file. + * + * @param srcFilePath The path of source file. + * @param zipFilePath The path of ZIP file. + * @param comment The comment. + * @return `true`: success

    `false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun zipFile( + srcFilePath: String, + zipFilePath: String, + comment: String + ): Boolean { + return zipFile(getFileByPath(srcFilePath), getFileByPath(zipFilePath), comment) + } + + /** + * Zip the file. + * + * @param srcFile The source of file. + * @param zipFile The ZIP file. + * @param comment The comment. + * @return `true`: success

    `false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + @JvmOverloads + fun zipFile( + srcFile: File?, + zipFile: File?, + comment: String? = null + ): Boolean { + if (srcFile == null || zipFile == null) return false + ZipOutputStream(FileOutputStream(zipFile)).use { zos -> + return zipFile(srcFile, "", zos, comment) + } + } + + @Throws(IOException::class) + private fun zipFile( + srcFile: File, + rootPath: String, + zos: ZipOutputStream, + comment: String? + ): Boolean { + var rootPath1 = rootPath + if (!srcFile.exists()) return true + rootPath1 = rootPath1 + (if (isSpace(rootPath1)) "" else File.separator) + srcFile.name + if (srcFile.isDirectory) { + val fileList = srcFile.listFiles() + if (fileList == null || fileList.isEmpty()) { + val entry = ZipEntry("$rootPath1/") + entry.comment = comment + zos.putNextEntry(entry) + zos.closeEntry() + } else { + for (file in fileList) { + if (!zipFile(file, rootPath1, zos, comment)) return false + } + } + } else { + BufferedInputStream(FileInputStream(srcFile)).use { `is` -> + val entry = ZipEntry(rootPath1) + entry.comment = comment + zos.putNextEntry(entry) + zos.write(`is`.readBytes()) + zos.closeEntry() + } + } + return true + } + + /** + * Unzip the file. + * + * @param zipFilePath The path of ZIP file. + * @param destDirPath The path of destination directory. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFile(zipFilePath: String, destDirPath: String): List? { + return unzipFileByKeyword(zipFilePath, destDirPath, null) + } + + /** + * Unzip the file. + * + * @param zipFile The ZIP file. + * @param destDir The destination directory. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFile( + zipFile: File, + destDir: File + ): List? { + return unzipFileByKeyword(zipFile, destDir, null) + } + + /** + * Unzip the file by keyword. + * + * @param zipFilePath The path of ZIP file. + * @param destDirPath The path of destination directory. + * @param keyword The keyboard. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFileByKeyword( + zipFilePath: String, + destDirPath: String, + keyword: String? + ): List? { + return unzipFileByKeyword( + getFileByPath(zipFilePath), + getFileByPath(destDirPath), + keyword + ) + } + + /** + * Unzip the file by keyword. + * + * @param zipFile The ZIP file. + * @param destDir The destination directory. + * @param keyword The keyboard. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFileByKeyword( + zipFile: File?, + destDir: File?, + keyword: String? + ): List? { + if (zipFile == null || destDir == null) return null + val files = ArrayList() + val zip = ZipFile(zipFile) + val entries = zip.entries() + zip.use { + if (isSpace(keyword)) { + while (entries.hasMoreElements()) { + val entry = entries.nextElement() as ZipEntry + val entryName = entry.name + if (entryName.contains("../")) { + Timber.e("entryName: $entryName is dangerous!") + continue + } + if (!unzipChildFile(destDir, files, zip, entry, entryName)) return files + } + } else { + while (entries.hasMoreElements()) { + val entry = entries.nextElement() as ZipEntry + val entryName = entry.name + if (entryName.contains("../")) { + Timber.e("entryName: $entryName is dangerous!") + continue + } + if (entryName.contains(keyword!!)) { + if (!unzipChildFile(destDir, files, zip, entry, entryName)) return files + } + } + } + } + return files + } + + @Throws(IOException::class) + private fun unzipChildFile( + destDir: File, + files: MutableList, + zip: ZipFile, + entry: ZipEntry, + name: String + ): Boolean { + val file = File(destDir, name) + files.add(file) + if (entry.isDirectory) { + return createOrExistsDir(file) + } else { + if (!createOrExistsFile(file)) return false + BufferedInputStream(zip.getInputStream(entry)).use { `in` -> + BufferedOutputStream(FileOutputStream(file)).use { out -> + out.write(`in`.readBytes()) + } + } + } + return true + } + + /** + * Return the files' path in ZIP file. + * + * @param zipFilePath The path of ZIP file. + * @return the files' path in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getFilesPath(zipFilePath: String): List? { + return getFilesPath(getFileByPath(zipFilePath)) + } + + /** + * Return the files' path in ZIP file. + * + * @param zipFile The ZIP file. + * @return the files' path in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getFilesPath(zipFile: File?): List? { + if (zipFile == null) return null + val paths = ArrayList() + val zip = ZipFile(zipFile) + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entryName = (entries.nextElement() as ZipEntry).name + if (entryName.contains("../")) { + Timber.e("entryName: $entryName is dangerous!") + paths.add(entryName) + } else { + paths.add(entryName) + } + } + zip.close() + return paths + } + + /** + * Return the files' comment in ZIP file. + * + * @param zipFilePath The path of ZIP file. + * @return the files' comment in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getComments(zipFilePath: String): List? { + return getComments(getFileByPath(zipFilePath)) + } + + /** + * Return the files' comment in ZIP file. + * + * @param zipFile The ZIP file. + * @return the files' comment in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getComments(zipFile: File?): List? { + if (zipFile == null) return null + val comments = ArrayList() + val zip = ZipFile(zipFile) + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() as ZipEntry + comments.add(entry.comment) + } + zip.close() + return comments + } + + private fun createOrExistsDir(file: File?): Boolean { + return file != null && if (file.exists()) file.isDirectory else file.mkdirs() + } + + private fun createOrExistsFile(file: File?): Boolean { + if (file == null) return false + if (file.exists()) return file.isFile + if (!createOrExistsDir(file.parentFile)) return false + return try { + file.createNewFile() + } catch (e: IOException) { + Timber.e(e) + false + } + } + + private fun getFileByPath(filePath: String): File? { + return if (isSpace(filePath)) null else File(filePath) + } + + private fun isSpace(s: String?): Boolean { + if (s == null) return true + var i = 0 + val len = s.length + while (i < len) { + if (!Character.isWhitespace(s[i])) { + return false + } + ++i + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/viewbindingdelegate/ActivityViewBindings.kt b/app/src/main/java/io/legado/app/utils/viewbindingdelegate/ActivityViewBindings.kt new file mode 100644 index 000000000..321181022 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/viewbindingdelegate/ActivityViewBindings.kt @@ -0,0 +1,22 @@ +@file:Suppress("RedundantVisibilityModifier", "unused") + +package io.legado.app.utils.viewbindingdelegate + +import android.view.LayoutInflater +import androidx.core.app.ComponentActivity +import androidx.viewbinding.ViewBinding + +/** + * Create new [ViewBinding] associated with the [ComponentActivity] + */ +@JvmName("viewBindingActivity") +inline fun ComponentActivity.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T, + setContentView: Boolean = false +) = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + val binding = bindingInflater.invoke(layoutInflater) + if (setContentView) { + setContentView(binding.root) + } + binding +} diff --git a/app/src/main/java/io/legado/app/utils/viewbindingdelegate/FragmentViewBindings.kt b/app/src/main/java/io/legado/app/utils/viewbindingdelegate/FragmentViewBindings.kt new file mode 100644 index 000000000..cae870edb --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/viewbindingdelegate/FragmentViewBindings.kt @@ -0,0 +1,54 @@ +@file:Suppress("RedundantVisibilityModifier", "unused") +@file:JvmName("ReflectionFragmentViewBindings") + +package io.legado.app.utils.viewbindingdelegate + +import android.view.View +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding + +private class FragmentViewBindingProperty( + viewBinder: (F) -> T +) : ViewBindingProperty(viewBinder) { + + override fun getLifecycleOwner(thisRef: F) = thisRef.viewLifecycleOwner +} + +/** + * Create new [ViewBinding] associated with the [Fragment] + */ +@JvmName("viewBindingFragment") +public fun Fragment.viewBinding(viewBinder: (F) -> T): ViewBindingProperty { + return FragmentViewBindingProperty(viewBinder) +} + +/** + * Create new [ViewBinding] associated with the [Fragment] + * + * @param vbFactory Function that create new instance of [ViewBinding]. `MyViewBinding::bind` can be used + * @param viewProvider Provide a [View] from the Fragment. By default call [Fragment.requireView] + */ +@JvmName("viewBindingFragment") +public inline fun Fragment.viewBinding( + crossinline vbFactory: (View) -> T, + crossinline viewProvider: (F) -> View = Fragment::requireView +): ViewBindingProperty { + return viewBinding { fragment: F -> vbFactory(viewProvider(fragment)) } +} + +/** + * Create new [ViewBinding] associated with the [Fragment] + * + * @param vbFactory Function that create new instance of [ViewBinding]. `MyViewBinding::bind` can be used + * @param viewBindingRootId Root view's id that will be used as root for the view binding + */ +@JvmName("viewBindingFragment") +public inline fun Fragment.viewBinding( + crossinline vbFactory: (View) -> T, + @IdRes viewBindingRootId: Int +): ViewBindingProperty { + return viewBinding(vbFactory) { fragment: Fragment -> + fragment.requireView().findViewById(viewBindingRootId) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/viewbindingdelegate/ViewBindingProperty.kt b/app/src/main/java/io/legado/app/utils/viewbindingdelegate/ViewBindingProperty.kt new file mode 100644 index 000000000..10a23ce3c --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/viewbindingdelegate/ViewBindingProperty.kt @@ -0,0 +1,57 @@ +@file:Suppress("RedundantVisibilityModifier") + +package io.legado.app.utils.viewbindingdelegate + +import android.os.Handler +import android.os.Looper +import androidx.annotation.MainThread +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +public abstract class ViewBindingProperty( + private val viewBinder: (R) -> T +) : ReadOnlyProperty { + + private var viewBinding: T? = null + private val lifecycleObserver = ClearOnDestroyLifecycleObserver() + private var thisRef: R? = null + + protected abstract fun getLifecycleOwner(thisRef: R): LifecycleOwner + + @MainThread + public override fun getValue(thisRef: R, property: KProperty<*>): T { + viewBinding?.let { return it } + + this.thisRef = thisRef + val lifecycle = getLifecycleOwner(thisRef).lifecycle + if (lifecycle.currentState == Lifecycle.State.DESTROYED) { + mainHandler.post { viewBinding = null } + } else { + lifecycle.addObserver(lifecycleObserver) + } + return viewBinder(thisRef).also { viewBinding = it } + } + + @MainThread + public fun clear() { + val thisRef = thisRef ?: return + this.thisRef = null + getLifecycleOwner(thisRef).lifecycle.removeObserver(lifecycleObserver) + mainHandler.post { viewBinding = null } + } + + private inner class ClearOnDestroyLifecycleObserver : DefaultLifecycleObserver { + + @MainThread + override fun onDestroy(owner: LifecycleOwner): Unit = clear() + } + + private companion object { + + private val mainHandler = Handler(Looper.getMainLooper()) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/web/HttpServer.kt b/app/src/main/java/io/legado/app/web/HttpServer.kt new file mode 100644 index 000000000..ccab3a9f5 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/HttpServer.kt @@ -0,0 +1,102 @@ +package io.legado.app.web + +import android.graphics.Bitmap +import com.google.gson.Gson +import fi.iki.elonen.NanoHTTPD +import io.legado.app.api.ReturnData +import io.legado.app.api.controller.BookController +import io.legado.app.api.controller.BookSourceController +import io.legado.app.api.controller.RssSourceController +import io.legado.app.web.utils.AssetsWeb +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.* + + +class HttpServer(port: Int) : NanoHTTPD(port) { + private val assetsWeb = AssetsWeb("web") + + + override fun serve(session: IHTTPSession): Response { + var returnData: ReturnData? = null + val ct = ContentType(session.headers["content-type"]).tryUTF8() + session.headers["content-type"] = ct.contentTypeHeader + var uri = session.uri + + try { + when (session.method) { + Method.OPTIONS -> { + val response = newFixedLengthResponse("") + response.addHeader("Access-Control-Allow-Methods", "POST") + response.addHeader("Access-Control-Allow-Headers", "content-type") + response.addHeader("Access-Control-Allow-Origin", session.headers["origin"]) + //response.addHeader("Access-Control-Max-Age", "3600"); + return response + } + Method.POST -> { + val files = HashMap() + session.parseBody(files) + val postData = files["postData"] + + returnData = when (uri) { + "/saveBookSource" -> BookSourceController.saveSource(postData) + "/saveBookSources" -> BookSourceController.saveSources(postData) + "/deleteBookSources" -> BookSourceController.deleteSources(postData) + "/saveBook" -> BookController.saveBook(postData) + "/addLocalBook" -> BookController.addLocalBook(session.parameters) + "/saveRssSource" -> RssSourceController.saveSource(postData) + "/saveRssSources" -> RssSourceController.saveSources(postData) + "/deleteRssSources" -> RssSourceController.deleteSources(postData) + else -> null + } + } + Method.GET -> { + val parameters = session.parameters + + returnData = when (uri) { + "/getBookSource" -> BookSourceController.getSource(parameters) + "/getBookSources" -> BookSourceController.sources + "/getBookshelf" -> BookController.bookshelf + "/getChapterList" -> BookController.getChapterList(parameters) + "/refreshToc" -> BookController.refreshToc(parameters) + "/getBookContent" -> BookController.getBookContent(parameters) + "/cover" -> BookController.getCover(parameters) + "/getRssSource" -> RssSourceController.getSource(parameters) + "/getRssSources" -> RssSourceController.sources + else -> null + } + } + else -> Unit + } + + if (returnData == null) { + if (uri.endsWith("/")) + uri += "index.html" + return assetsWeb.getResponse(uri) + } + + val response = if (returnData.data is Bitmap) { + val outputStream = ByteArrayOutputStream() + (returnData.data as Bitmap).compress(Bitmap.CompressFormat.PNG, 100, outputStream) + val byteArray = outputStream.toByteArray() + outputStream.close() + val inputStream = ByteArrayInputStream(byteArray) + newFixedLengthResponse( + Response.Status.OK, + "image/png", + inputStream, + byteArray.size.toLong() + ) + } else { + newFixedLengthResponse(Gson().toJson(returnData)) + } + response.addHeader("Access-Control-Allow-Methods", "GET, POST") + response.addHeader("Access-Control-Allow-Origin", session.headers["origin"]) + return response + } catch (e: Exception) { + return newFixedLengthResponse(e.message) + } + + } + +} diff --git a/app/src/main/java/io/legado/app/web/ReadMe.md b/app/src/main/java/io/legado/app/web/ReadMe.md new file mode 100644 index 000000000..aee2e2dc6 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/ReadMe.md @@ -0,0 +1,5 @@ +# web服务 + +* controller 数据操作 +* HttpServer http服务 +* WebSocketServer 持续通讯服务 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/web/WebSocketServer.kt b/app/src/main/java/io/legado/app/web/WebSocketServer.kt new file mode 100644 index 000000000..45fd04bd4 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/WebSocketServer.kt @@ -0,0 +1,20 @@ +package io.legado.app.web + +import fi.iki.elonen.NanoWSD +import io.legado.app.web.socket.BookSourceDebugWebSocket +import io.legado.app.web.socket.RssSourceDebugWebSocket + +class WebSocketServer(port: Int) : NanoWSD(port) { + + override fun openWebSocket(handshake: IHTTPSession): WebSocket? { + return when (handshake.uri) { + "/bookSourceDebug" -> { + BookSourceDebugWebSocket(handshake) + } + "/rssSourceDebug" -> { + RssSourceDebugWebSocket(handshake) + } + else -> null + } + } +} diff --git a/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt new file mode 100644 index 000000000..62abbdec2 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt @@ -0,0 +1,95 @@ +package io.legado.app.web.socket + + +import fi.iki.elonen.NanoHTTPD +import fi.iki.elonen.NanoWSD +import io.legado.app.R +import io.legado.app.data.appDb +import io.legado.app.model.Debug +import io.legado.app.utils.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import splitties.init.appCtx +import timber.log.Timber +import java.io.IOException + + +class BookSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : + NanoWSD.WebSocket(handshakeRequest), + CoroutineScope by MainScope(), + Debug.Callback { + + private val notPrintState = arrayOf(10, 20, 30, 40) + + override fun onOpen() { + launch(IO) { + kotlin.runCatching { + while (isOpen) { + ping("ping".toByteArray()) + delay(30000) + } + } + } + } + + override fun onClose( + code: NanoWSD.WebSocketFrame.CloseCode, + reason: String, + initiatedByRemote: Boolean + ) { + cancel() + Debug.cancelDebug(true) + } + + override fun onMessage(message: NanoWSD.WebSocketFrame) { + launch(IO) { + kotlin.runCatching { + if (!message.textPayload.isJson()) { + send("数据必须为Json格式") + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + return@launch + } + val debugBean = GSON.fromJsonObject>(message.textPayload) + if (debugBean != null) { + val tag = debugBean["tag"] + val key = debugBean["key"] + if (tag.isNullOrBlank() || key.isNullOrBlank()) { + send(appCtx.getString(R.string.cannot_empty)) + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + return@launch + } + appDb.bookSourceDao.getBookSource(tag)?.let { + Debug.callback = this@BookSourceDebugWebSocket + Debug.startDebug(this, it, key) + } + } + } + } + } + + override fun onPong(pong: NanoWSD.WebSocketFrame) { + + } + + override fun onException(exception: IOException) { + Debug.cancelDebug(true) + } + + override fun printLog(state: Int, msg: String) { + if (state in notPrintState) { + return + } + runOnIO { + runCatching { + send(msg) + if (state == -1 || state == 1000) { + Debug.cancelDebug(true) + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + } + }.onFailure { + Timber.e(it) + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt new file mode 100644 index 000000000..73a1f87b2 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt @@ -0,0 +1,94 @@ +package io.legado.app.web.socket + + +import fi.iki.elonen.NanoHTTPD +import fi.iki.elonen.NanoWSD +import io.legado.app.R +import io.legado.app.data.appDb +import io.legado.app.model.Debug +import io.legado.app.utils.* +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import splitties.init.appCtx +import timber.log.Timber +import java.io.IOException + + +class RssSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : + NanoWSD.WebSocket(handshakeRequest), + CoroutineScope by MainScope(), + Debug.Callback { + + private val notPrintState = arrayOf(10, 20, 30, 40) + + override fun onOpen() { + launch(IO) { + kotlin.runCatching { + while (isOpen) { + ping("ping".toByteArray()) + delay(30000) + } + } + } + } + + override fun onClose( + code: NanoWSD.WebSocketFrame.CloseCode, + reason: String, + initiatedByRemote: Boolean + ) { + cancel() + Debug.cancelDebug(true) + } + + override fun onMessage(message: NanoWSD.WebSocketFrame) { + launch(IO) { + kotlin.runCatching { + if (!message.textPayload.isJson()) { + send("数据必须为Json格式") + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + return@launch + } + val debugBean = GSON.fromJsonObject>(message.textPayload) + if (debugBean != null) { + val tag = debugBean["tag"] + if (tag.isNullOrBlank()) { + send(appCtx.getString(R.string.cannot_empty)) + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + return@launch + } + appDb.rssSourceDao.getByKey(tag)?.let { + Debug.callback = this@RssSourceDebugWebSocket + Debug.startDebug(this, it) + } + } + } + } + } + + override fun onPong(pong: NanoWSD.WebSocketFrame) { + + } + + override fun onException(exception: IOException) { + Debug.cancelDebug(true) + } + + override fun printLog(state: Int, msg: String) { + if (state in notPrintState) { + return + } + runOnIO { + runCatching { + send(msg) + if (state == -1 || state == 1000) { + Debug.cancelDebug(true) + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + } + }.onFailure { + Timber.e(it) + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt b/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt new file mode 100644 index 000000000..22e375555 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt @@ -0,0 +1,44 @@ +package io.legado.app.web.utils + +import android.content.res.AssetManager +import android.text.TextUtils +import fi.iki.elonen.NanoHTTPD +import splitties.init.appCtx +import java.io.File +import java.io.IOException + + +class AssetsWeb(rootPath: String) { + private val assetManager: AssetManager = appCtx.assets + private var rootPath = "web" + + init { + if (!TextUtils.isEmpty(rootPath)) { + this.rootPath = rootPath + } + } + + @Throws(IOException::class) + fun getResponse(path: String): NanoHTTPD.Response { + var path1 = path + path1 = (rootPath + path1).replace("/+".toRegex(), File.separator) + val inputStream = assetManager.open(path1) + return NanoHTTPD.newChunkedResponse( + NanoHTTPD.Response.Status.OK, + getMimeType(path1), + inputStream + ) + } + + private fun getMimeType(path: String): String { + val suffix = path.substring(path.lastIndexOf(".")) + return when { + suffix.equals(".html", ignoreCase = true) + || suffix.equals(".htm", ignoreCase = true) -> "text/html" + suffix.equals(".js", ignoreCase = true) -> "text/javascript" + suffix.equals(".css", ignoreCase = true) -> "text/css" + suffix.equals(".ico", ignoreCase = true) -> "image/x-icon" + else -> "text/html" + } + } +} diff --git a/app/src/main/res/anim/anim_none.xml b/app/src/main/res/anim/anim_none.xml new file mode 100644 index 000000000..fe9ddac3b --- /dev/null +++ b/app/src/main/res/anim/anim_none.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_bottom_in.xml b/app/src/main/res/anim/anim_readbook_bottom_in.xml new file mode 100644 index 000000000..48fe0c0aa --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_bottom_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_bottom_out.xml b/app/src/main/res/anim/anim_readbook_bottom_out.xml new file mode 100644 index 000000000..2e91beb95 --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_bottom_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_top_in.xml b/app/src/main/res/anim/anim_readbook_top_in.xml new file mode 100644 index 000000000..e36360bc0 --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_top_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_top_out.xml b/app/src/main/res/anim/anim_readbook_top_out.xml new file mode 100644 index 000000000..0b8acbceb --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_top_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_image.xml b/app/src/main/res/color/selector_image.xml new file mode 100644 index 000000000..ffc800f9f --- /dev/null +++ b/app/src/main/res/color/selector_image.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_chapter_item_divider.xml b/app/src/main/res/drawable/bg_chapter_item_divider.xml new file mode 100644 index 000000000..38db10eaf --- /dev/null +++ b/app/src/main/res/drawable/bg_chapter_item_divider.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_edit.xml b/app/src/main/res/drawable/bg_edit.xml new file mode 100644 index 000000000..9653cca5e --- /dev/null +++ b/app/src/main/res/drawable/bg_edit.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_find_book_group.xml b/app/src/main/res/drawable/bg_find_book_group.xml new file mode 100644 index 000000000..b00a89b22 --- /dev/null +++ b/app/src/main/res/drawable/bg_find_book_group.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_gradient.xml b/app/src/main/res/drawable/bg_gradient.xml new file mode 100644 index 000000000..6c2f91672 --- /dev/null +++ b/app/src/main/res/drawable/bg_gradient.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_img_border.xml b/app/src/main/res/drawable/bg_img_border.xml new file mode 100644 index 000000000..07783726c --- /dev/null +++ b/app/src/main/res/drawable/bg_img_border.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_popup_menu.xml b/app/src/main/res/drawable/bg_popup_menu.xml new file mode 100644 index 000000000..b51849bcd --- /dev/null +++ b/app/src/main/res/drawable/bg_popup_menu.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_prefs_color.xml b/app/src/main/res/drawable/bg_prefs_color.xml new file mode 100644 index 000000000..beb057eab --- /dev/null +++ b/app/src/main/res/drawable/bg_prefs_color.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_searchview.xml b/app/src/main/res/drawable/bg_searchview.xml new file mode 100644 index 000000000..beee4af1e --- /dev/null +++ b/app/src/main/res/drawable/bg_searchview.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_shadow_bottom.xml b/app/src/main/res/drawable/bg_shadow_bottom.xml new file mode 100644 index 000000000..68063fd27 --- /dev/null +++ b/app/src/main/res/drawable/bg_shadow_bottom.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_shadow_bottom_night.xml b/app/src/main/res/drawable/bg_shadow_bottom_night.xml new file mode 100644 index 000000000..039ec1696 --- /dev/null +++ b/app/src/main/res/drawable/bg_shadow_bottom_night.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_shadow_top.xml b/app/src/main/res/drawable/bg_shadow_top.xml new file mode 100644 index 000000000..98f1df214 --- /dev/null +++ b/app/src/main/res/drawable/bg_shadow_top.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_shadow_top_night.xml b/app/src/main/res/drawable/bg_shadow_top_night.xml new file mode 100644 index 000000000..2ef90a767 --- /dev/null +++ b/app/src/main/res/drawable/bg_shadow_top_night.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_textfield_search.xml b/app/src/main/res/drawable/bg_textfield_search.xml new file mode 100644 index 000000000..8069a07d8 --- /dev/null +++ b/app/src/main/res/drawable/bg_textfield_search.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_bubble.xml b/app/src/main/res/drawable/fastscroll_bubble.xml new file mode 100644 index 000000000..f72b616ae --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_bubble.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/fastscroll_handle.xml b/app/src/main/res/drawable/fastscroll_handle.xml new file mode 100644 index 000000000..8671a01cf --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_handle.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/fastscroll_track.xml b/app/src/main/res/drawable/fastscroll_track.xml new file mode 100644 index 000000000..854768787 --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_track.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 000000000..b4f63fa80 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_online.xml b/app/src/main/res/drawable/ic_add_online.xml new file mode 100644 index 000000000..d11efea01 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_online.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrange.xml b/app/src/main/res/drawable/ic_arrange.xml new file mode 100644 index 000000000..416dbf843 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrange.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..2d68f797b --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 000000000..19a41e8db --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 000000000..df5ff10d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_up.xml b/app/src/main/res/drawable/ic_arrow_drop_up.xml new file mode 100644 index 000000000..ed31816be --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 000000000..72bcbdb79 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_author.xml b/app/src/main/res/drawable/ic_author.xml new file mode 100644 index 000000000..00d53f351 --- /dev/null +++ b/app/src/main/res/drawable/ic_author.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_auto_page.xml b/app/src/main/res/drawable/ic_auto_page.xml new file mode 100644 index 000000000..bec2a48f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_page.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_auto_page_stop.xml b/app/src/main/res/drawable/ic_auto_page_stop.xml new file mode 100644 index 000000000..6a6c74535 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_page_stop.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml new file mode 100644 index 000000000..200bb7081 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_close.xml b/app/src/main/res/drawable/ic_baseline_close.xml new file mode 100644 index 000000000..b8af066c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_book_has.xml b/app/src/main/res/drawable/ic_book_has.xml new file mode 100644 index 000000000..a920e50ae --- /dev/null +++ b/app/src/main/res/drawable/ic_book_has.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_book_last.xml b/app/src/main/res/drawable/ic_book_last.xml new file mode 100644 index 000000000..82b0842d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_last.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 000000000..551bee2c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bottom_books.xml b/app/src/main/res/drawable/ic_bottom_books.xml new file mode 100644 index 000000000..807aaaf30 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_books.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bottom_books_e.xml b/app/src/main/res/drawable/ic_bottom_books_e.xml new file mode 100644 index 000000000..04d36c375 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_books_e.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_books_s.xml b/app/src/main/res/drawable/ic_bottom_books_s.xml new file mode 100644 index 000000000..8f5f72542 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_books_s.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_explore.xml b/app/src/main/res/drawable/ic_bottom_explore.xml new file mode 100644 index 000000000..439f69ba9 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_explore.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bottom_explore_e.xml b/app/src/main/res/drawable/ic_bottom_explore_e.xml new file mode 100644 index 000000000..47e4bde9d --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_explore_e.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_explore_s.xml b/app/src/main/res/drawable/ic_bottom_explore_s.xml new file mode 100644 index 000000000..6dc6e4322 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_explore_s.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bottom_person.xml b/app/src/main/res/drawable/ic_bottom_person.xml new file mode 100644 index 000000000..50053997a --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_person.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bottom_person_e.xml b/app/src/main/res/drawable/ic_bottom_person_e.xml new file mode 100644 index 000000000..d01128391 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_person_e.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_person_s.xml b/app/src/main/res/drawable/ic_bottom_person_s.xml new file mode 100644 index 000000000..3ff7fc8c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_person_s.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_rss_feed.xml b/app/src/main/res/drawable/ic_bottom_rss_feed.xml new file mode 100644 index 000000000..4cd080b7f --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_rss_feed.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bottom_rss_feed_e.xml b/app/src/main/res/drawable/ic_bottom_rss_feed_e.xml new file mode 100644 index 000000000..c1c4ba0e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_rss_feed_e.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_rss_feed_s.xml b/app/src/main/res/drawable/ic_bottom_rss_feed_s.xml new file mode 100644 index 000000000..a73c01430 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_rss_feed_s.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness.xml b/app/src/main/res/drawable/ic_brightness.xml new file mode 100644 index 000000000..ac37ba7d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_brightness_auto.xml b/app/src/main/res/drawable/ic_brightness_auto.xml new file mode 100644 index 000000000..6ea7b953e --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_auto.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml new file mode 100644 index 000000000..ac83c8c90 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cfg_about.xml b/app/src/main/res/drawable/ic_cfg_about.xml new file mode 100644 index 000000000..42e3d30d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_about.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_cfg_backup.xml b/app/src/main/res/drawable/ic_cfg_backup.xml new file mode 100644 index 000000000..9c647c009 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_backup.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cfg_donate.xml b/app/src/main/res/drawable/ic_cfg_donate.xml new file mode 100644 index 000000000..9d5bb4788 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_donate.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cfg_other.xml b/app/src/main/res/drawable/ic_cfg_other.xml new file mode 100644 index 000000000..a3d26b642 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_other.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cfg_replace.xml b/app/src/main/res/drawable/ic_cfg_replace.xml new file mode 100644 index 000000000..53c9d1d04 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_replace.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cfg_source.xml b/app/src/main/res/drawable/ic_cfg_source.xml new file mode 100644 index 000000000..e71bb109d --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_source.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cfg_theme.xml b/app/src/main/res/drawable/ic_cfg_theme.xml new file mode 100644 index 000000000..2fe949d19 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_theme.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cfg_web.xml b/app/src/main/res/drawable/ic_cfg_web.xml new file mode 100644 index 000000000..7a34ae004 --- /dev/null +++ b/app/src/main/res/drawable/ic_cfg_web.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chapter_list.xml b/app/src/main/res/drawable/ic_chapter_list.xml new file mode 100644 index 000000000..d64d58dbf --- /dev/null +++ b/app/src/main/res/drawable/ic_chapter_list.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..620bc9133 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_source.xml b/app/src/main/res/drawable/ic_check_source.xml new file mode 100644 index 000000000..cbd10fd70 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_source.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_clear_all.xml b/app/src/main/res/drawable/ic_clear_all.xml new file mode 100644 index 000000000..dfa860c00 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_all.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 000000000..cdf136c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cursor_left.xml b/app/src/main/res/drawable/ic_cursor_left.xml new file mode 100644 index 000000000..1656763c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_cursor_left.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cursor_right.xml b/app/src/main/res/drawable/ic_cursor_right.xml new file mode 100644 index 000000000..99734ea19 --- /dev/null +++ b/app/src/main/res/drawable/ic_cursor_right.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_daytime.xml b/app/src/main/res/drawable/ic_daytime.xml new file mode 100644 index 000000000..60921c1dc --- /dev/null +++ b/app/src/main/res/drawable/ic_daytime.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_divider.xml b/app/src/main/res/drawable/ic_divider.xml new file mode 100644 index 000000000..21a88f21a --- /dev/null +++ b/app/src/main/res/drawable/ic_divider.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 000000000..bf296eceb --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_line.xml b/app/src/main/res/drawable/ic_download_line.xml new file mode 100644 index 000000000..c882860c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_line.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 000000000..2d249dde4 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exchange.xml b/app/src/main/res/drawable/ic_exchange.xml new file mode 100644 index 000000000..00fa9cc95 --- /dev/null +++ b/app/src/main/res/drawable/ic_exchange.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exchange_order.xml b/app/src/main/res/drawable/ic_exchange_order.xml new file mode 100644 index 000000000..2beeb7441 --- /dev/null +++ b/app/src/main/res/drawable/ic_exchange_order.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_expand_less_24dp.xml b/app/src/main/res/drawable/ic_expand_less_24dp.xml new file mode 100644 index 000000000..f70feed92 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more_24dp.xml b/app/src/main/res/drawable/ic_expand_more_24dp.xml new file mode 100644 index 000000000..5d10f3768 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 000000000..78d6a76dd --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml new file mode 100644 index 000000000..13fcad8cf --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml new file mode 100644 index 000000000..ea721ad89 --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_rewind.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_find_replace.xml b/app/src/main/res/drawable/ic_find_replace.xml new file mode 100644 index 000000000..07bbfc39c --- /dev/null +++ b/app/src/main/res/drawable/ic_find_replace.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 000000000..cb2a84ca4 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_folder_open.xml b/app/src/main/res/drawable/ic_folder_open.xml new file mode 100644 index 000000000..4235565f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_groups.xml b/app/src/main/res/drawable/ic_groups.xml new file mode 100644 index 000000000..e15e9af8e --- /dev/null +++ b/app/src/main/res/drawable/ic_groups.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 000000000..32838437b --- /dev/null +++ b/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 000000000..095e0f7ab --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 000000000..bfd8e5c65 --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_import.xml b/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 000000000..dc493329e --- /dev/null +++ b/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_interface_setting.xml b/app/src/main/res/drawable/ic_interface_setting.xml new file mode 100644 index 000000000..139b00724 --- /dev/null +++ b/app/src/main/res/drawable/ic_interface_setting.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher1.xml b/app/src/main/res/drawable/ic_launcher1.xml new file mode 100644 index 000000000..18c829634 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher1.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher1_b.xml b/app/src/main/res/drawable/ic_launcher1_b.xml new file mode 100644 index 000000000..c5b40eb91 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher1_b.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher2.xml b/app/src/main/res/drawable/ic_launcher2.xml new file mode 100644 index 000000000..bead863ac --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher2.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher3.xml b/app/src/main/res/drawable/ic_launcher3.xml new file mode 100644 index 000000000..a79648990 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher3.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher4.xml b/app/src/main/res/drawable/ic_launcher4.xml new file mode 100644 index 000000000..99f23d4f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher4.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher5.xml b/app/src/main/res/drawable/ic_launcher5.xml new file mode 100644 index 000000000..29435836b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher5.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher5_b.xml b/app/src/main/res/drawable/ic_launcher5_b.xml new file mode 100644 index 000000000..9a5153369 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher5_b.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher6.xml b/app/src/main/res/drawable/ic_launcher6.xml new file mode 100644 index 000000000..1bb711f8f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher6.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher7.xml b/app/src/main/res/drawable/ic_launcher7.xml new file mode 100644 index 000000000..983784aef --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher7.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher7_b.xml b/app/src/main/res/drawable/ic_launcher7_b.xml new file mode 100644 index 000000000..76214f1e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher7_b.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 000000000..8a6004bd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 000000000..b0357ce02 --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..7b7f19554 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_network_check.xml b/app/src/main/res/drawable/ic_network_check.xml new file mode 100644 index 000000000..9d88ef1ae --- /dev/null +++ b/app/src/main/res/drawable/ic_network_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_cloud_24.xml b/app/src/main/res/drawable/ic_outline_cloud_24.xml new file mode 100644 index 000000000..c8b092ead --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_cloud_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_24dp.xml b/app/src/main/res/drawable/ic_pause_24dp.xml new file mode 100644 index 000000000..193030b12 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_outline_24dp.xml b/app/src/main/res/drawable/ic_pause_outline_24dp.xml new file mode 100644 index 000000000..11f30c38d --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_outline_24dp.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_play_24dp.xml b/app/src/main/res/drawable/ic_play_24dp.xml new file mode 100644 index 000000000..4250d72cb --- /dev/null +++ b/app/src/main/res/drawable/ic_play_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_outline_24dp.xml b/app/src/main/res/drawable/ic_play_outline_24dp.xml new file mode 100644 index 000000000..deb25e9f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_outline_24dp.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_read_aloud.xml b/app/src/main/res/drawable/ic_read_aloud.xml new file mode 100644 index 000000000..7978d2196 --- /dev/null +++ b/app/src/main/res/drawable/ic_read_aloud.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_reduce.xml b/app/src/main/res/drawable/ic_reduce.xml new file mode 100644 index 000000000..90eec6f30 --- /dev/null +++ b/app/src/main/res/drawable/ic_reduce.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml new file mode 100644 index 000000000..fc7f629d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml new file mode 100644 index 000000000..9e4f8dfcc --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml new file mode 100644 index 000000000..d9f75ea6d --- /dev/null +++ b/app/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 000000000..042962ee5 --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_scan.xml b/app/src/main/res/drawable/ic_scan.xml new file mode 100644 index 000000000..2ca607005 --- /dev/null +++ b/app/src/main/res/drawable/ic_scan.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_scoring.xml b/app/src/main/res/drawable/ic_scoring.xml new file mode 100644 index 000000000..683b68107 --- /dev/null +++ b/app/src/main/res/drawable/ic_scoring.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen.xml b/app/src/main/res/drawable/ic_screen.xml new file mode 100644 index 000000000..4a641e517 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..b915218e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/drawable/ic_search_hint.xml b/app/src/main/res/drawable/ic_search_hint.xml new file mode 100644 index 000000000..42de64c26 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_hint.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..1d4297767 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..df884e817 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 000000000..eae4da1bf --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 000000000..abb9944fc --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 000000000..a2ebf3532 --- /dev/null +++ b/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_border.xml b/app/src/main/res/drawable/ic_star_border.xml new file mode 100644 index 000000000..c452e47ec --- /dev/null +++ b/app/src/main/res/drawable/ic_star_border.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop_black_24dp.xml b/app/src/main/res/drawable/ic_stop_black_24dp.xml new file mode 100644 index 000000000..025a8b82e --- /dev/null +++ b/app/src/main/res/drawable/ic_stop_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_storage_black_24dp.xml b/app/src/main/res/drawable/ic_storage_black_24dp.xml new file mode 100644 index 000000000..b11623929 --- /dev/null +++ b/app/src/main/res/drawable/ic_storage_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_time_add_24dp.xml b/app/src/main/res/drawable/ic_time_add_24dp.xml new file mode 100644 index 000000000..66afd8f8f --- /dev/null +++ b/app/src/main/res/drawable/ic_time_add_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_timer_black_24dp.xml b/app/src/main/res/drawable/ic_timer_black_24dp.xml new file mode 100644 index 000000000..df5ad4e8a --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_black_24dp.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_toc.xml b/app/src/main/res/drawable/ic_toc.xml new file mode 100644 index 000000000..c64161dc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_toc.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_translate.xml b/app/src/main/res/drawable/ic_translate.xml new file mode 100644 index 000000000..ae2a4dde7 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 000000000..2490a577d --- /dev/null +++ b/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_view_quilt.xml b/app/src/main/res/drawable/ic_view_quilt.xml new file mode 100644 index 000000000..88b5317e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_quilt.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 000000000..c71061582 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 000000000..5d604f823 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_web_outline.xml b/app/src/main/res/drawable/ic_web_outline.xml new file mode 100644 index 000000000..adaeb5644 --- /dev/null +++ b/app/src/main/res/drawable/ic_web_outline.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_web_service_noti.xml b/app/src/main/res/drawable/ic_web_service_noti.xml new file mode 100644 index 000000000..ab93b7528 --- /dev/null +++ b/app/src/main/res/drawable/ic_web_service_noti.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icon_read_book.png b/app/src/main/res/drawable/icon_read_book.png new file mode 100644 index 000000000..016b8cf16 Binary files /dev/null and b/app/src/main/res/drawable/icon_read_book.png differ diff --git a/app/src/main/res/drawable/image_cover_default.jpg b/app/src/main/res/drawable/image_cover_default.jpg new file mode 100644 index 000000000..590e7847a Binary files /dev/null and b/app/src/main/res/drawable/image_cover_default.jpg differ diff --git a/app/src/main/res/drawable/image_legado.png b/app/src/main/res/drawable/image_legado.png new file mode 100644 index 000000000..6d101d806 Binary files /dev/null and b/app/src/main/res/drawable/image_legado.png differ diff --git a/app/src/main/res/drawable/image_rss.jpg b/app/src/main/res/drawable/image_rss.jpg new file mode 100644 index 000000000..a152df506 Binary files /dev/null and b/app/src/main/res/drawable/image_rss.jpg differ diff --git a/app/src/main/res/drawable/image_rss_article.jpg b/app/src/main/res/drawable/image_rss_article.jpg new file mode 100644 index 000000000..471b5650f Binary files /dev/null and b/app/src/main/res/drawable/image_rss_article.jpg differ diff --git a/app/src/main/res/drawable/recyclerview_divider_horizontal.xml b/app/src/main/res/drawable/recyclerview_divider_horizontal.xml new file mode 100644 index 000000000..2d2d95362 --- /dev/null +++ b/app/src/main/res/drawable/recyclerview_divider_horizontal.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recyclerview_divider_vertical.xml b/app/src/main/res/drawable/recyclerview_divider_vertical.xml new file mode 100644 index 000000000..cf8d2882a --- /dev/null +++ b/app/src/main/res/drawable/recyclerview_divider_vertical.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_btn_accent_bg.xml b/app/src/main/res/drawable/selector_btn_accent_bg.xml new file mode 100644 index 000000000..d06cfe139 --- /dev/null +++ b/app/src/main/res/drawable/selector_btn_accent_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_common_bg.xml b/app/src/main/res/drawable/selector_common_bg.xml new file mode 100644 index 000000000..2174a7333 --- /dev/null +++ b/app/src/main/res/drawable/selector_common_bg.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_fillet_btn_bg.xml b/app/src/main/res/drawable/selector_fillet_btn_bg.xml new file mode 100644 index 000000000..431a579d3 --- /dev/null +++ b/app/src/main/res/drawable/selector_fillet_btn_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_tv_black.xml b/app/src/main/res/drawable/selector_tv_black.xml new file mode 100644 index 000000000..fbbeed245 --- /dev/null +++ b/app/src/main/res/drawable/selector_tv_black.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_card_view.xml b/app/src/main/res/drawable/shape_card_view.xml new file mode 100644 index 000000000..d49a2d5c1 --- /dev/null +++ b/app/src/main/res/drawable/shape_card_view.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fillet_btn.xml b/app/src/main/res/drawable/shape_fillet_btn.xml new file mode 100644 index 000000000..83ccf2776 --- /dev/null +++ b/app/src/main/res/drawable/shape_fillet_btn.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fillet_btn_press.xml b/app/src/main/res/drawable/shape_fillet_btn_press.xml new file mode 100644 index 000000000..b0c46f6f5 --- /dev/null +++ b/app/src/main/res/drawable/shape_fillet_btn_press.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml b/app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml new file mode 100644 index 000000000..215f52826 --- /dev/null +++ b/app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_radius_10dp.xml b/app/src/main/res/drawable/shape_radius_10dp.xml new file mode 100644 index 000000000..3680c9e7e --- /dev/null +++ b/app/src/main/res/drawable/shape_radius_10dp.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_radius_1dp.xml b/app/src/main/res/drawable/shape_radius_1dp.xml new file mode 100644 index 000000000..d8e0c3f87 --- /dev/null +++ b/app/src/main/res/drawable/shape_radius_1dp.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_space_divider.xml b/app/src/main/res/drawable/shape_space_divider.xml new file mode 100644 index 000000000..954360298 --- /dev/null +++ b/app/src/main/res/drawable/shape_space_divider.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_text_cursor.xml b/app/src/main/res/drawable/shape_text_cursor.xml new file mode 100644 index 000000000..71871e0e0 --- /dev/null +++ b/app/src/main/res/drawable/shape_text_cursor.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_translucent_card.xml b/app/src/main/res/drawable/shape_translucent_card.xml new file mode 100644 index 000000000..827bc335d --- /dev/null +++ b/app/src/main/res/drawable/shape_translucent_card.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_book_info.xml b/app/src/main/res/layout-land/activity_book_info.xml new file mode 100644 index 000000000..47503f120 --- /dev/null +++ b/app/src/main/res/layout-land/activity_book_info.xml @@ -0,0 +1,419 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml new file mode 100644 index 000000000..670b2b221 --- /dev/null +++ b/app/src/main/res/layout/activity_about.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_arrange_book.xml b/app/src/main/res/layout/activity_arrange_book.xml new file mode 100644 index 000000000..1be7bdb41 --- /dev/null +++ b/app/src/main/res/layout/activity_arrange_book.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_audio_play.xml b/app/src/main/res/layout/activity_audio_play.xml new file mode 100644 index 000000000..d649914a6 --- /dev/null +++ b/app/src/main/res/layout/activity_audio_play.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_info.xml b/app/src/main/res/layout/activity_book_info.xml new file mode 100644 index 000000000..8f9b01219 --- /dev/null +++ b/app/src/main/res/layout/activity_book_info.xml @@ -0,0 +1,398 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_info_edit.xml b/app/src/main/res/layout/activity_book_info_edit.xml new file mode 100644 index 000000000..8b685b653 --- /dev/null +++ b/app/src/main/res/layout/activity_book_info_edit.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_read.xml b/app/src/main/res/layout/activity_book_read.xml new file mode 100644 index 000000000..703c0ef5e --- /dev/null +++ b/app/src/main/res/layout/activity_book_read.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_search.xml b/app/src/main/res/layout/activity_book_search.xml new file mode 100644 index 000000000..4996fd3ff --- /dev/null +++ b/app/src/main/res/layout/activity_book_search.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_book_source.xml b/app/src/main/res/layout/activity_book_source.xml new file mode 100644 index 000000000..21f662944 --- /dev/null +++ b/app/src/main/res/layout/activity_book_source.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_source_edit.xml b/app/src/main/res/layout/activity_book_source_edit.xml new file mode 100644 index 000000000..3057d43a6 --- /dev/null +++ b/app/src/main/res/layout/activity_book_source_edit.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_cache_book.xml b/app/src/main/res/layout/activity_cache_book.xml new file mode 100644 index 000000000..c499af731 --- /dev/null +++ b/app/src/main/res/layout/activity_cache_book.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_chapter_list.xml b/app/src/main/res/layout/activity_chapter_list.xml new file mode 100644 index 000000000..9bceaa108 --- /dev/null +++ b/app/src/main/res/layout/activity_chapter_list.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_config.xml b/app/src/main/res/layout/activity_config.xml new file mode 100644 index 000000000..f0312ee9c --- /dev/null +++ b/app/src/main/res/layout/activity_config.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_donate.xml b/app/src/main/res/layout/activity_donate.xml new file mode 100644 index 000000000..522b6af92 --- /dev/null +++ b/app/src/main/res/layout/activity_donate.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_explore_show.xml b/app/src/main/res/layout/activity_explore_show.xml new file mode 100644 index 000000000..05b3d96df --- /dev/null +++ b/app/src/main/res/layout/activity_explore_show.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_import_book.xml b/app/src/main/res/layout/activity_import_book.xml new file mode 100644 index 000000000..7e3ee8997 --- /dev/null +++ b/app/src/main/res/layout/activity_import_book.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..ecd80ee3d --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_qrcode_capture.xml b/app/src/main/res/layout/activity_qrcode_capture.xml new file mode 100644 index 000000000..379109187 --- /dev/null +++ b/app/src/main/res/layout/activity_qrcode_capture.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_read_record.xml b/app/src/main/res/layout/activity_read_record.xml new file mode 100644 index 000000000..9d94c5c8f --- /dev/null +++ b/app/src/main/res/layout/activity_read_record.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_replace_edit.xml b/app/src/main/res/layout/activity_replace_edit.xml new file mode 100644 index 000000000..2a4370beb --- /dev/null +++ b/app/src/main/res/layout/activity_replace_edit.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_replace_rule.xml b/app/src/main/res/layout/activity_replace_rule.xml new file mode 100644 index 000000000..eeb2a26f9 --- /dev/null +++ b/app/src/main/res/layout/activity_replace_rule.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_rss_artivles.xml b/app/src/main/res/layout/activity_rss_artivles.xml new file mode 100644 index 000000000..27a0dff9d --- /dev/null +++ b/app/src/main/res/layout/activity_rss_artivles.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_favorites.xml b/app/src/main/res/layout/activity_rss_favorites.xml new file mode 100644 index 000000000..098af211a --- /dev/null +++ b/app/src/main/res/layout/activity_rss_favorites.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_read.xml b/app/src/main/res/layout/activity_rss_read.xml new file mode 100644 index 000000000..6eb28b4f8 --- /dev/null +++ b/app/src/main/res/layout/activity_rss_read.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_source.xml b/app/src/main/res/layout/activity_rss_source.xml new file mode 100644 index 000000000..703eea64f --- /dev/null +++ b/app/src/main/res/layout/activity_rss_source.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_source_edit.xml b/app/src/main/res/layout/activity_rss_source_edit.xml new file mode 100644 index 000000000..d839b5170 --- /dev/null +++ b/app/src/main/res/layout/activity_rss_source_edit.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rule_sub.xml b/app/src/main/res/layout/activity_rule_sub.xml new file mode 100644 index 000000000..b4704f5eb --- /dev/null +++ b/app/src/main/res/layout/activity_rule_sub.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search_content.xml b/app/src/main/res/layout/activity_search_content.xml new file mode 100644 index 000000000..9a4a5da87 --- /dev/null +++ b/app/src/main/res/layout/activity_search_content.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_source_debug.xml b/app/src/main/res/layout/activity_source_debug.xml new file mode 100644 index 000000000..4061274ec --- /dev/null +++ b/app/src/main/res/layout/activity_source_debug.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_source_login.xml b/app/src/main/res/layout/activity_source_login.xml new file mode 100644 index 000000000..ea051953b --- /dev/null +++ b/app/src/main/res/layout/activity_source_login.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_translucence.xml b/app/src/main/res/layout/activity_translucence.xml new file mode 100644 index 000000000..143f44d2a --- /dev/null +++ b/app/src/main/res/layout/activity_translucence.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_txt_toc_rule.xml b/app/src/main/res/layout/activity_txt_toc_rule.xml new file mode 100644 index 000000000..7ac6263aa --- /dev/null +++ b/app/src/main/res/layout/activity_txt_toc_rule.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_web_view.xml b/app/src/main/res/layout/activity_web_view.xml new file mode 100644 index 000000000..6eb28b4f8 --- /dev/null +++ b/app/src/main/res/layout/activity_web_view.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml new file mode 100644 index 000000000..707978c92 --- /dev/null +++ b/app/src/main/res/layout/activity_welcome.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_auto_read.xml b/app/src/main/res/layout/dialog_auto_read.xml new file mode 100644 index 000000000..69c013134 --- /dev/null +++ b/app/src/main/res/layout/dialog_auto_read.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_book_group_edit.xml b/app/src/main/res/layout/dialog_book_group_edit.xml new file mode 100644 index 000000000..fcaecd0e3 --- /dev/null +++ b/app/src/main/res/layout/dialog_book_group_edit.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_book_group_picker.xml b/app/src/main/res/layout/dialog_book_group_picker.xml new file mode 100644 index 000000000..95317f693 --- /dev/null +++ b/app/src/main/res/layout/dialog_book_group_picker.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_bookmark.xml b/app/src/main/res/layout/dialog_bookmark.xml new file mode 100644 index 000000000..a7bb5bf5e --- /dev/null +++ b/app/src/main/res/layout/dialog_bookmark.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_bookshelf_config.xml b/app/src/main/res/layout/dialog_bookshelf_config.xml new file mode 100644 index 000000000..14a436dfe --- /dev/null +++ b/app/src/main/res/layout/dialog_bookshelf_config.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_change_cover.xml b/app/src/main/res/layout/dialog_change_cover.xml new file mode 100644 index 000000000..2dee2288c --- /dev/null +++ b/app/src/main/res/layout/dialog_change_cover.xml @@ -0,0 +1,29 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_change_source.xml b/app/src/main/res/layout/dialog_change_source.xml new file mode 100644 index 000000000..d72fab249 --- /dev/null +++ b/app/src/main/res/layout/dialog_change_source.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_click_action_config.xml b/app/src/main/res/layout/dialog_click_action_config.xml new file mode 100644 index 000000000..af1c00029 --- /dev/null +++ b/app/src/main/res/layout/dialog_click_action_config.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_code_view.xml b/app/src/main/res/layout/dialog_code_view.xml new file mode 100644 index 000000000..411ac4a8c --- /dev/null +++ b/app/src/main/res/layout/dialog_code_view.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_custom_group.xml b/app/src/main/res/layout/dialog_custom_group.xml new file mode 100644 index 000000000..7f9933964 --- /dev/null +++ b/app/src/main/res/layout/dialog_custom_group.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_dict.xml b/app/src/main/res/layout/dialog_dict.xml new file mode 100644 index 000000000..7eed5f326 --- /dev/null +++ b/app/src/main/res/layout/dialog_dict.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_direct_link_upload_config.xml b/app/src/main/res/layout/dialog_direct_link_upload_config.xml new file mode 100644 index 000000000..2fcebd28e --- /dev/null +++ b/app/src/main/res/layout/dialog_direct_link_upload_config.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_download_choice.xml b/app/src/main/res/layout/dialog_download_choice.xml new file mode 100644 index 000000000..8e60ea030 --- /dev/null +++ b/app/src/main/res/layout/dialog_download_choice.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_edit_text.xml b/app/src/main/res/layout/dialog_edit_text.xml new file mode 100644 index 000000000..c0666dbe6 --- /dev/null +++ b/app/src/main/res/layout/dialog_edit_text.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_file_chooser.xml b/app/src/main/res/layout/dialog_file_chooser.xml new file mode 100644 index 000000000..efe1db737 --- /dev/null +++ b/app/src/main/res/layout/dialog_file_chooser.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_font_select.xml b/app/src/main/res/layout/dialog_font_select.xml new file mode 100644 index 000000000..1920055f7 --- /dev/null +++ b/app/src/main/res/layout/dialog_font_select.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_http_tts_edit.xml b/app/src/main/res/layout/dialog_http_tts_edit.xml new file mode 100644 index 000000000..2d2f42264 --- /dev/null +++ b/app/src/main/res/layout/dialog_http_tts_edit.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_image_blurring.xml b/app/src/main/res/layout/dialog_image_blurring.xml new file mode 100644 index 000000000..b9d20e55e --- /dev/null +++ b/app/src/main/res/layout/dialog_image_blurring.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_login.xml b/app/src/main/res/layout/dialog_login.xml new file mode 100644 index 000000000..8a1e3b13e --- /dev/null +++ b/app/src/main/res/layout/dialog_login.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_number_picker.xml b/app/src/main/res/layout/dialog_number_picker.xml new file mode 100644 index 000000000..f54fd0a00 --- /dev/null +++ b/app/src/main/res/layout/dialog_number_picker.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_page_key.xml b/app/src/main/res/layout/dialog_page_key.xml new file mode 100644 index 000000000..51f5094e2 --- /dev/null +++ b/app/src/main/res/layout/dialog_page_key.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_photo_view.xml b/app/src/main/res/layout/dialog_photo_view.xml new file mode 100644 index 000000000..410ac99e5 --- /dev/null +++ b/app/src/main/res/layout/dialog_photo_view.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/app/src/main/res/layout/dialog_progressbar_view.xml b/app/src/main/res/layout/dialog_progressbar_view.xml new file mode 100644 index 000000000..fdc2d7a61 --- /dev/null +++ b/app/src/main/res/layout/dialog_progressbar_view.xml @@ -0,0 +1,36 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_aloud.xml b/app/src/main/res/layout/dialog_read_aloud.xml new file mode 100644 index 000000000..f93ebc4f6 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_aloud.xml @@ -0,0 +1,385 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_bg_text.xml b/app/src/main/res/layout/dialog_read_bg_text.xml new file mode 100644 index 000000000..73faa0c78 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_bg_text.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_book_style.xml b/app/src/main/res/layout/dialog_read_book_style.xml new file mode 100644 index 000000000..25506b417 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_book_style.xml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_padding.xml b/app/src/main/res/layout/dialog_read_padding.xml new file mode 100644 index 000000000..ab08bf930 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_padding.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_recycler_view.xml b/app/src/main/res/layout/dialog_recycler_view.xml new file mode 100644 index 000000000..82feaf44e --- /dev/null +++ b/app/src/main/res/layout/dialog_recycler_view.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_rule_sub_edit.xml b/app/src/main/res/layout/dialog_rule_sub_edit.xml new file mode 100644 index 000000000..fe67f3046 --- /dev/null +++ b/app/src/main/res/layout/dialog_rule_sub_edit.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_text_view.xml b/app/src/main/res/layout/dialog_text_view.xml new file mode 100644 index 000000000..df50b0e81 --- /dev/null +++ b/app/src/main/res/layout/dialog_text_view.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_tip_config.xml b/app/src/main/res/layout/dialog_tip_config.xml new file mode 100644 index 000000000..2cd7353ab --- /dev/null +++ b/app/src/main/res/layout/dialog_tip_config.xml @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_toc_regex.xml b/app/src/main/res/layout/dialog_toc_regex.xml new file mode 100644 index 000000000..2712cabf4 --- /dev/null +++ b/app/src/main/res/layout/dialog_toc_regex.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_toc_regex_edit.xml b/app/src/main/res/layout/dialog_toc_regex_edit.xml new file mode 100644 index 000000000..52f152187 --- /dev/null +++ b/app/src/main/res/layout/dialog_toc_regex_edit.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_update.xml b/app/src/main/res/layout/dialog_update.xml new file mode 100644 index 000000000..4f9fdef42 --- /dev/null +++ b/app/src/main/res/layout/dialog_update.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_wait.xml b/app/src/main/res/layout/dialog_wait.xml new file mode 100644 index 000000000..bc490a506 --- /dev/null +++ b/app/src/main/res/layout/dialog_wait.xml @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bookmark.xml b/app/src/main/res/layout/fragment_bookmark.xml new file mode 100644 index 000000000..fab36a60b --- /dev/null +++ b/app/src/main/res/layout/fragment_bookmark.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_books.xml b/app/src/main/res/layout/fragment_books.xml new file mode 100644 index 000000000..8da85f913 --- /dev/null +++ b/app/src/main/res/layout/fragment_books.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_bookshelf.xml b/app/src/main/res/layout/fragment_bookshelf.xml new file mode 100644 index 000000000..91b638e02 --- /dev/null +++ b/app/src/main/res/layout/fragment_bookshelf.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bookshelf1.xml b/app/src/main/res/layout/fragment_bookshelf1.xml new file mode 100644 index 000000000..35ce510ec --- /dev/null +++ b/app/src/main/res/layout/fragment_bookshelf1.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chapter_list.xml b/app/src/main/res/layout/fragment_chapter_list.xml new file mode 100644 index 000000000..3d8943d79 --- /dev/null +++ b/app/src/main/res/layout/fragment_chapter_list.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_explore.xml b/app/src/main/res/layout/fragment_explore.xml new file mode 100644 index 000000000..1b473573e --- /dev/null +++ b/app/src/main/res/layout/fragment_explore.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_my_config.xml b/app/src/main/res/layout/fragment_my_config.xml new file mode 100644 index 000000000..6cb3cdf4c --- /dev/null +++ b/app/src/main/res/layout/fragment_my_config.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_rss.xml b/app/src/main/res/layout/fragment_rss.xml new file mode 100644 index 000000000..f5a66f4c7 --- /dev/null +++ b/app/src/main/res/layout/fragment_rss.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_rss_articles.xml b/app/src/main/res/layout/fragment_rss_articles.xml new file mode 100644 index 000000000..867cf055e --- /dev/null +++ b/app/src/main/res/layout/fragment_rss_articles.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_web_view_login.xml b/app/src/main/res/layout/fragment_web_view_login.xml new file mode 100644 index 000000000..ed20ab520 --- /dev/null +++ b/app/src/main/res/layout/fragment_web_view_login.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_1line_text_and_del.xml b/app/src/main/res/layout/item_1line_text_and_del.xml new file mode 100644 index 000000000..85af06345 --- /dev/null +++ b/app/src/main/res/layout/item_1line_text_and_del.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_app_log.xml b/app/src/main/res/layout/item_app_log.xml new file mode 100644 index 000000000..341e16881 --- /dev/null +++ b/app/src/main/res/layout/item_app_log.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_arrange_book.xml b/app/src/main/res/layout/item_arrange_book.xml new file mode 100644 index 000000000..15e33e291 --- /dev/null +++ b/app/src/main/res/layout/item_arrange_book.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bg_image.xml b/app/src/main/res/layout/item_bg_image.xml new file mode 100644 index 000000000..4bf9cf5fe --- /dev/null +++ b/app/src/main/res/layout/item_bg_image.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_book_group_manage.xml b/app/src/main/res/layout/item_book_group_manage.xml new file mode 100644 index 000000000..d5d907f71 --- /dev/null +++ b/app/src/main/res/layout/item_book_group_manage.xml @@ -0,0 +1,33 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_book_source.xml b/app/src/main/res/layout/item_book_source.xml new file mode 100644 index 000000000..adc4c5b71 --- /dev/null +++ b/app/src/main/res/layout/item_book_source.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookmark.xml b/app/src/main/res/layout/item_bookmark.xml new file mode 100644 index 000000000..362acff10 --- /dev/null +++ b/app/src/main/res/layout/item_bookmark.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookshelf_grid.xml b/app/src/main/res/layout/item_bookshelf_grid.xml new file mode 100644 index 000000000..b2e059414 --- /dev/null +++ b/app/src/main/res/layout/item_bookshelf_grid.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_bookshelf_grid_group.xml b/app/src/main/res/layout/item_bookshelf_grid_group.xml new file mode 100644 index 000000000..b2e059414 --- /dev/null +++ b/app/src/main/res/layout/item_bookshelf_grid_group.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_bookshelf_list.xml b/app/src/main/res/layout/item_bookshelf_list.xml new file mode 100644 index 000000000..ea382784d --- /dev/null +++ b/app/src/main/res/layout/item_bookshelf_list.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookshelf_list_group.xml b/app/src/main/res/layout/item_bookshelf_list_group.xml new file mode 100644 index 000000000..ea382784d --- /dev/null +++ b/app/src/main/res/layout/item_bookshelf_list_group.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_change_source.xml b/app/src/main/res/layout/item_change_source.xml new file mode 100644 index 000000000..fa04abeda --- /dev/null +++ b/app/src/main/res/layout/item_change_source.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_chapter_list.xml b/app/src/main/res/layout/item_chapter_list.xml new file mode 100644 index 000000000..285af05ba --- /dev/null +++ b/app/src/main/res/layout/item_chapter_list.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_cover.xml b/app/src/main/res/layout/item_cover.xml new file mode 100644 index 000000000..1b88c854a --- /dev/null +++ b/app/src/main/res/layout/item_cover.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml new file mode 100644 index 000000000..e32ed3419 --- /dev/null +++ b/app/src/main/res/layout/item_download.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_file_filepicker.xml b/app/src/main/res/layout/item_file_filepicker.xml new file mode 100644 index 000000000..fb4626dac --- /dev/null +++ b/app/src/main/res/layout/item_file_filepicker.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_fillet_text.xml b/app/src/main/res/layout/item_fillet_text.xml new file mode 100644 index 000000000..3fb4ebbd7 --- /dev/null +++ b/app/src/main/res/layout/item_fillet_text.xml @@ -0,0 +1,18 @@ + + diff --git a/app/src/main/res/layout/item_find_book.xml b/app/src/main/res/layout/item_find_book.xml new file mode 100644 index 000000000..b27c04cba --- /dev/null +++ b/app/src/main/res/layout/item_find_book.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_font.xml b/app/src/main/res/layout/item_font.xml new file mode 100644 index 000000000..0e08f17bc --- /dev/null +++ b/app/src/main/res/layout/item_font.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_group_manage.xml b/app/src/main/res/layout/item_group_manage.xml new file mode 100644 index 000000000..183243cb6 --- /dev/null +++ b/app/src/main/res/layout/item_group_manage.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_group_select.xml b/app/src/main/res/layout/item_group_select.xml new file mode 100644 index 000000000..a4514b535 --- /dev/null +++ b/app/src/main/res/layout/item_group_select.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_http_tts.xml b/app/src/main/res/layout/item_http_tts.xml new file mode 100644 index 000000000..47cb00990 --- /dev/null +++ b/app/src/main/res/layout/item_http_tts.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_icon_preference.xml b/app/src/main/res/layout/item_icon_preference.xml new file mode 100644 index 000000000..543a0841c --- /dev/null +++ b/app/src/main/res/layout/item_icon_preference.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_import_book.xml b/app/src/main/res/layout/item_import_book.xml new file mode 100644 index 000000000..cf272c698 --- /dev/null +++ b/app/src/main/res/layout/item_import_book.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_log.xml b/app/src/main/res/layout/item_log.xml new file mode 100644 index 000000000..7beaede03 --- /dev/null +++ b/app/src/main/res/layout/item_log.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_path_filepicker.xml b/app/src/main/res/layout/item_path_filepicker.xml new file mode 100644 index 000000000..c6c47c49e --- /dev/null +++ b/app/src/main/res/layout/item_path_filepicker.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_read_record.xml b/app/src/main/res/layout/item_read_record.xml new file mode 100644 index 000000000..e3cd01e07 --- /dev/null +++ b/app/src/main/res/layout/item_read_record.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_read_style.xml b/app/src/main/res/layout/item_read_style.xml new file mode 100644 index 000000000..28a861f81 --- /dev/null +++ b/app/src/main/res/layout/item_read_style.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_replace_rule.xml b/app/src/main/res/layout/item_replace_rule.xml new file mode 100644 index 000000000..32680eac7 --- /dev/null +++ b/app/src/main/res/layout/item_replace_rule.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_rss.xml b/app/src/main/res/layout/item_rss.xml new file mode 100644 index 000000000..448710b37 --- /dev/null +++ b/app/src/main/res/layout/item_rss.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article.xml b/app/src/main/res/layout/item_rss_article.xml new file mode 100644 index 000000000..508350eb3 --- /dev/null +++ b/app/src/main/res/layout/item_rss_article.xml @@ -0,0 +1,51 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article_1.xml b/app/src/main/res/layout/item_rss_article_1.xml new file mode 100644 index 000000000..32fc80cea --- /dev/null +++ b/app/src/main/res/layout/item_rss_article_1.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article_2.xml b/app/src/main/res/layout/item_rss_article_2.xml new file mode 100644 index 000000000..83a935c26 --- /dev/null +++ b/app/src/main/res/layout/item_rss_article_2.xml @@ -0,0 +1,54 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_source.xml b/app/src/main/res/layout/item_rss_source.xml new file mode 100644 index 000000000..7ba0b43fa --- /dev/null +++ b/app/src/main/res/layout/item_rss_source.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rule_sub.xml b/app/src/main/res/layout/item_rule_sub.xml new file mode 100644 index 000000000..1c8cf4852 --- /dev/null +++ b/app/src/main/res/layout/item_rule_sub.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search.xml b/app/src/main/res/layout/item_search.xml new file mode 100644 index 000000000..eb0289863 --- /dev/null +++ b/app/src/main/res/layout/item_search.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_list.xml b/app/src/main/res/layout/item_search_list.xml new file mode 100644 index 000000000..2dcaf1436 --- /dev/null +++ b/app/src/main/res/layout/item_search_list.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_source_edit.xml b/app/src/main/res/layout/item_source_edit.xml new file mode 100644 index 000000000..cd14a6fe4 --- /dev/null +++ b/app/src/main/res/layout/item_source_edit.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_source_import.xml b/app/src/main/res/layout/item_source_import.xml new file mode 100644 index 000000000..126ba43d2 --- /dev/null +++ b/app/src/main/res/layout/item_source_import.xml @@ -0,0 +1,46 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_text.xml b/app/src/main/res/layout/item_text.xml new file mode 100644 index 000000000..b8d2bcc52 --- /dev/null +++ b/app/src/main/res/layout/item_text.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/layout/item_theme_config.xml b/app/src/main/res/layout/item_theme_config.xml new file mode 100644 index 000000000..bd7410a34 --- /dev/null +++ b/app/src/main/res/layout/item_theme_config.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_toc_regex.xml b/app/src/main/res/layout/item_toc_regex.xml new file mode 100644 index 000000000..93496c4b8 --- /dev/null +++ b/app/src/main/res/layout/item_toc_regex.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_txt_toc_rule.xml b/app/src/main/res/layout/item_txt_toc_rule.xml new file mode 100644 index 000000000..9db02db4e --- /dev/null +++ b/app/src/main/res/layout/item_txt_toc_rule.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/popup_action_menu.xml b/app/src/main/res/layout/popup_action_menu.xml new file mode 100644 index 000000000..b44b71355 --- /dev/null +++ b/app/src/main/res/layout/popup_action_menu.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/popup_keyboard_tool.xml b/app/src/main/res/layout/popup_keyboard_tool.xml new file mode 100644 index 000000000..649534d76 --- /dev/null +++ b/app/src/main/res/layout/popup_keyboard_tool.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/view_book_page.xml b/app/src/main/res/layout/view_book_page.xml new file mode 100644 index 000000000..95fd4f1f3 --- /dev/null +++ b/app/src/main/res/layout/view_book_page.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_detail_seek_bar.xml b/app/src/main/res/layout/view_detail_seek_bar.xml new file mode 100644 index 000000000..8ff8a3e2f --- /dev/null +++ b/app/src/main/res/layout/view_detail_seek_bar.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_dynamic.xml b/app/src/main/res/layout/view_dynamic.xml new file mode 100644 index 000000000..3602c6392 --- /dev/null +++ b/app/src/main/res/layout/view_dynamic.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_error.xml b/app/src/main/res/layout/view_error.xml new file mode 100644 index 000000000..05f28f0a9 --- /dev/null +++ b/app/src/main/res/layout/view_error.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_fastscroller.xml b/app/src/main/res/layout/view_fastscroller.xml new file mode 100644 index 000000000..d2c3b71d5 --- /dev/null +++ b/app/src/main/res/layout/view_fastscroller.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_icon.xml b/app/src/main/res/layout/view_icon.xml new file mode 100644 index 000000000..989476275 --- /dev/null +++ b/app/src/main/res/layout/view_icon.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_load_more.xml b/app/src/main/res/layout/view_load_more.xml new file mode 100644 index 000000000..ed419aa0a --- /dev/null +++ b/app/src/main/res/layout/view_load_more.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_loading.xml b/app/src/main/res/layout/view_loading.xml new file mode 100644 index 000000000..73781d947 --- /dev/null +++ b/app/src/main/res/layout/view_loading.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_preference.xml b/app/src/main/res/layout/view_preference.xml new file mode 100644 index 000000000..602619d51 --- /dev/null +++ b/app/src/main/res/layout/view_preference.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_preference_category.xml b/app/src/main/res/layout/view_preference_category.xml new file mode 100644 index 000000000..afc45f119 --- /dev/null +++ b/app/src/main/res/layout/view_preference_category.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_read_menu.xml b/app/src/main/res/layout/view_read_menu.xml new file mode 100644 index 000000000..9b5a91876 --- /dev/null +++ b/app/src/main/res/layout/view_read_menu.xml @@ -0,0 +1,489 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_refresh_recycler.xml b/app/src/main/res/layout/view_refresh_recycler.xml new file mode 100644 index 000000000..2f2685c4e --- /dev/null +++ b/app/src/main/res/layout/view_refresh_recycler.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_search.xml b/app/src/main/res/layout/view_search.xml new file mode 100644 index 000000000..96ff311da --- /dev/null +++ b/app/src/main/res/layout/view_search.xml @@ -0,0 +1,17 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_search_menu.xml b/app/src/main/res/layout/view_search_menu.xml new file mode 100644 index 000000000..d5d311cfb --- /dev/null +++ b/app/src/main/res/layout/view_search_menu.xml @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_select_action_bar.xml b/app/src/main/res/layout/view_select_action_bar.xml new file mode 100644 index 000000000..325438865 --- /dev/null +++ b/app/src/main/res/layout/view_select_action_bar.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_tab_layout.xml b/app/src/main/res/layout/view_tab_layout.xml new file mode 100644 index 000000000..5efc6cd26 --- /dev/null +++ b/app/src/main/res/layout/view_tab_layout.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_tab_layout_min.xml b/app/src/main/res/layout/view_tab_layout_min.xml new file mode 100644 index 000000000..badf89a01 --- /dev/null +++ b/app/src/main/res/layout/view_tab_layout_min.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_title_bar.xml b/app/src/main/res/layout/view_title_bar.xml new file mode 100644 index 000000000..b19cdc8c9 --- /dev/null +++ b/app/src/main/res/layout/view_title_bar.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/view_title_bar_dark.xml b/app/src/main/res/layout/view_title_bar_dark.xml new file mode 100644 index 000000000..72e7fa3b3 --- /dev/null +++ b/app/src/main/res/layout/view_title_bar_dark.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/menu/about.xml b/app/src/main/res/menu/about.xml new file mode 100644 index 000000000..f13fe31ae --- /dev/null +++ b/app/src/main/res/menu/about.xml @@ -0,0 +1,18 @@ + +

    + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/app_log.xml b/app/src/main/res/menu/app_log.xml new file mode 100644 index 000000000..3d17348bb --- /dev/null +++ b/app/src/main/res/menu/app_log.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/app_update.xml b/app/src/main/res/menu/app_update.xml new file mode 100644 index 000000000..1e6a13fd2 --- /dev/null +++ b/app/src/main/res/menu/app_update.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/arrange_book.xml b/app/src/main/res/menu/arrange_book.xml new file mode 100644 index 000000000..625bbbfe5 --- /dev/null +++ b/app/src/main/res/menu/arrange_book.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/arrange_book_sel.xml b/app/src/main/res/menu/arrange_book_sel.xml new file mode 100644 index 000000000..880569d40 --- /dev/null +++ b/app/src/main/res/menu/arrange_book_sel.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/audio_play.xml b/app/src/main/res/menu/audio_play.xml new file mode 100644 index 000000000..f21032067 --- /dev/null +++ b/app/src/main/res/menu/audio_play.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/backup_restore.xml b/app/src/main/res/menu/backup_restore.xml new file mode 100644 index 000000000..02b6acbd0 --- /dev/null +++ b/app/src/main/res/menu/backup_restore.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/menu/book_cache.xml b/app/src/main/res/menu/book_cache.xml new file mode 100644 index 000000000..f76ab76f8 --- /dev/null +++ b/app/src/main/res/menu/book_cache.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_group_manage.xml b/app/src/main/res/menu/book_group_manage.xml new file mode 100644 index 000000000..6c558b1e9 --- /dev/null +++ b/app/src/main/res/menu/book_group_manage.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_info.xml b/app/src/main/res/menu/book_info.xml new file mode 100644 index 000000000..99b8c39ca --- /dev/null +++ b/app/src/main/res/menu/book_info.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_info_edit.xml b/app/src/main/res/menu/book_info_edit.xml new file mode 100644 index 000000000..652d0076e --- /dev/null +++ b/app/src/main/res/menu/book_info_edit.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_read.xml b/app/src/main/res/menu/book_read.xml new file mode 100644 index 000000000..d96cf8140 --- /dev/null +++ b/app/src/main/res/menu/book_read.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/book_read_record.xml b/app/src/main/res/menu/book_read_record.xml new file mode 100644 index 000000000..6ba105d38 --- /dev/null +++ b/app/src/main/res/menu/book_read_record.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_read_source.xml b/app/src/main/res/menu/book_read_source.xml new file mode 100644 index 000000000..a83f99b00 --- /dev/null +++ b/app/src/main/res/menu/book_read_source.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/menu/book_search.xml b/app/src/main/res/menu/book_search.xml new file mode 100644 index 000000000..0a8ce4992 --- /dev/null +++ b/app/src/main/res/menu/book_search.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/menu/book_source.xml b/app/src/main/res/menu/book_source.xml new file mode 100644 index 000000000..dd43aec84 --- /dev/null +++ b/app/src/main/res/menu/book_source.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/book_source_debug.xml b/app/src/main/res/menu/book_source_debug.xml new file mode 100644 index 000000000..ec7878377 --- /dev/null +++ b/app/src/main/res/menu/book_source_debug.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_source_item.xml b/app/src/main/res/menu/book_source_item.xml new file mode 100644 index 000000000..acc6a6ed8 --- /dev/null +++ b/app/src/main/res/menu/book_source_item.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_source_sel.xml b/app/src/main/res/menu/book_source_sel.xml new file mode 100644 index 000000000..f73160d90 --- /dev/null +++ b/app/src/main/res/menu/book_source_sel.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/book_toc.xml b/app/src/main/res/menu/book_toc.xml new file mode 100644 index 000000000..d5c6dadf0 --- /dev/null +++ b/app/src/main/res/menu/book_toc.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/change_cover.xml b/app/src/main/res/menu/change_cover.xml new file mode 100644 index 000000000..a2a2407a1 --- /dev/null +++ b/app/src/main/res/menu/change_cover.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/change_source.xml b/app/src/main/res/menu/change_source.xml new file mode 100644 index 000000000..5e42ff079 --- /dev/null +++ b/app/src/main/res/menu/change_source.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/change_source_item.xml b/app/src/main/res/menu/change_source_item.xml new file mode 100644 index 000000000..823f1e506 --- /dev/null +++ b/app/src/main/res/menu/change_source_item.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/code_edit.xml b/app/src/main/res/menu/code_edit.xml new file mode 100644 index 000000000..05397ae61 --- /dev/null +++ b/app/src/main/res/menu/code_edit.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/content_select_action.xml b/app/src/main/res/menu/content_select_action.xml new file mode 100644 index 000000000..dda5324dd --- /dev/null +++ b/app/src/main/res/menu/content_select_action.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/explore_item.xml b/app/src/main/res/menu/explore_item.xml new file mode 100644 index 000000000..2b69e6063 --- /dev/null +++ b/app/src/main/res/menu/explore_item.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/file_chooser.xml b/app/src/main/res/menu/file_chooser.xml new file mode 100644 index 000000000..3fc6040f1 --- /dev/null +++ b/app/src/main/res/menu/file_chooser.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/font_select.xml b/app/src/main/res/menu/font_select.xml new file mode 100644 index 000000000..44a366ef8 --- /dev/null +++ b/app/src/main/res/menu/font_select.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/group_manage.xml b/app/src/main/res/menu/group_manage.xml new file mode 100644 index 000000000..90784e23e --- /dev/null +++ b/app/src/main/res/menu/group_manage.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/import_book.xml b/app/src/main/res/menu/import_book.xml new file mode 100644 index 000000000..7c6816f00 --- /dev/null +++ b/app/src/main/res/menu/import_book.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/menu/import_book_sel.xml b/app/src/main/res/menu/import_book_sel.xml new file mode 100644 index 000000000..1985d09cd --- /dev/null +++ b/app/src/main/res/menu/import_book_sel.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/import_replace.xml b/app/src/main/res/menu/import_replace.xml new file mode 100644 index 000000000..ead4b715e --- /dev/null +++ b/app/src/main/res/menu/import_replace.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/import_source.xml b/app/src/main/res/menu/import_source.xml new file mode 100644 index 000000000..ead4b715e --- /dev/null +++ b/app/src/main/res/menu/import_source.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_bnv.xml b/app/src/main/res/menu/main_bnv.xml new file mode 100644 index 000000000..9648c00ff --- /dev/null +++ b/app/src/main/res/menu/main_bnv.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/main_bookshelf.xml b/app/src/main/res/menu/main_bookshelf.xml new file mode 100644 index 000000000..854a657b9 --- /dev/null +++ b/app/src/main/res/menu/main_bookshelf.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_explore.xml b/app/src/main/res/menu/main_explore.xml new file mode 100644 index 000000000..5d429a443 --- /dev/null +++ b/app/src/main/res/menu/main_explore.xml @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_my.xml b/app/src/main/res/menu/main_my.xml new file mode 100644 index 000000000..02b6acbd0 --- /dev/null +++ b/app/src/main/res/menu/main_my.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/menu/main_rss.xml b/app/src/main/res/menu/main_rss.xml new file mode 100644 index 000000000..983d102b8 --- /dev/null +++ b/app/src/main/res/menu/main_rss.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/qr_code_scan.xml b/app/src/main/res/menu/qr_code_scan.xml new file mode 100644 index 000000000..00bb7ec15 --- /dev/null +++ b/app/src/main/res/menu/qr_code_scan.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/replace_edit.xml b/app/src/main/res/menu/replace_edit.xml new file mode 100644 index 000000000..652d0076e --- /dev/null +++ b/app/src/main/res/menu/replace_edit.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/replace_rule.xml b/app/src/main/res/menu/replace_rule.xml new file mode 100644 index 000000000..9b49c60db --- /dev/null +++ b/app/src/main/res/menu/replace_rule.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/replace_rule_item.xml b/app/src/main/res/menu/replace_rule_item.xml new file mode 100644 index 000000000..77c3a18cd --- /dev/null +++ b/app/src/main/res/menu/replace_rule_item.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/replace_rule_sel.xml b/app/src/main/res/menu/replace_rule_sel.xml new file mode 100644 index 000000000..24ea4d598 --- /dev/null +++ b/app/src/main/res/menu/replace_rule_sel.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/rss_articles.xml b/app/src/main/res/menu/rss_articles.xml new file mode 100644 index 000000000..ae808fb6f --- /dev/null +++ b/app/src/main/res/menu/rss_articles.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_main_item.xml b/app/src/main/res/menu/rss_main_item.xml new file mode 100644 index 000000000..19dc2f30a --- /dev/null +++ b/app/src/main/res/menu/rss_main_item.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_read.xml b/app/src/main/res/menu/rss_read.xml new file mode 100644 index 000000000..893cbfe1d --- /dev/null +++ b/app/src/main/res/menu/rss_read.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_source.xml b/app/src/main/res/menu/rss_source.xml new file mode 100644 index 000000000..e5836f7d6 --- /dev/null +++ b/app/src/main/res/menu/rss_source.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/rss_source_debug.xml b/app/src/main/res/menu/rss_source_debug.xml new file mode 100644 index 000000000..a23296632 --- /dev/null +++ b/app/src/main/res/menu/rss_source_debug.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_source_item.xml b/app/src/main/res/menu/rss_source_item.xml new file mode 100644 index 000000000..77c3a18cd --- /dev/null +++ b/app/src/main/res/menu/rss_source_item.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_source_sel.xml b/app/src/main/res/menu/rss_source_sel.xml new file mode 100644 index 000000000..252927165 --- /dev/null +++ b/app/src/main/res/menu/rss_source_sel.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/search_view.xml b/app/src/main/res/menu/search_view.xml new file mode 100644 index 000000000..0b897362d --- /dev/null +++ b/app/src/main/res/menu/search_view.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/source_edit.xml b/app/src/main/res/menu/source_edit.xml new file mode 100644 index 000000000..f4dd24240 --- /dev/null +++ b/app/src/main/res/menu/source_edit.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/source_login.xml b/app/src/main/res/menu/source_login.xml new file mode 100644 index 000000000..cc7db0395 --- /dev/null +++ b/app/src/main/res/menu/source_login.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/source_sub_item.xml b/app/src/main/res/menu/source_sub_item.xml new file mode 100644 index 000000000..f852b2bcf --- /dev/null +++ b/app/src/main/res/menu/source_sub_item.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/source_subscription.xml b/app/src/main/res/menu/source_subscription.xml new file mode 100644 index 000000000..52bf86d0d --- /dev/null +++ b/app/src/main/res/menu/source_subscription.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/speak_engine.xml b/app/src/main/res/menu/speak_engine.xml new file mode 100644 index 000000000..6956a1ebc --- /dev/null +++ b/app/src/main/res/menu/speak_engine.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/speak_engine_edit.xml b/app/src/main/res/menu/speak_engine_edit.xml new file mode 100644 index 000000000..87b1092ad --- /dev/null +++ b/app/src/main/res/menu/speak_engine_edit.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/theme_config.xml b/app/src/main/res/menu/theme_config.xml new file mode 100644 index 000000000..5033089ac --- /dev/null +++ b/app/src/main/res/menu/theme_config.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/theme_list.xml b/app/src/main/res/menu/theme_list.xml new file mode 100644 index 000000000..bd7f6194d --- /dev/null +++ b/app/src/main/res/menu/theme_list.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/txt_toc_regex.xml b/app/src/main/res/menu/txt_toc_regex.xml new file mode 100644 index 000000000..2eb4a2b4c --- /dev/null +++ b/app/src/main/res/menu/txt_toc_regex.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/txt_toc_rule_item.xml b/app/src/main/res/menu/txt_toc_rule_item.xml new file mode 100644 index 000000000..77c3a18cd --- /dev/null +++ b/app/src/main/res/menu/txt_toc_rule_item.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/web_view.xml b/app/src/main/res/menu/web_view.xml new file mode 100644 index 000000000..281e17243 --- /dev/null +++ b/app/src/main/res/menu/web_view.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7a68d4392 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher1.xml b/app/src/main/res/mipmap-anydpi-v26/launcher1.xml new file mode 100644 index 000000000..b3aa68018 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher1.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher2.xml b/app/src/main/res/mipmap-anydpi-v26/launcher2.xml new file mode 100644 index 000000000..5de236bd2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher2.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher3.xml b/app/src/main/res/mipmap-anydpi-v26/launcher3.xml new file mode 100644 index 000000000..8234460dd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher3.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher4.xml b/app/src/main/res/mipmap-anydpi-v26/launcher4.xml new file mode 100644 index 000000000..6eb41f1a7 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher4.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher5.xml b/app/src/main/res/mipmap-anydpi-v26/launcher5.xml new file mode 100644 index 000000000..f26ce23e3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher5.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher6.xml b/app/src/main/res/mipmap-anydpi-v26/launcher6.xml new file mode 100644 index 000000000..4ba689a93 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/launcher6.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..d2c418293 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..2d18ac346 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..4b44e0bba Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..df9bfebb8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..6d101d806 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/raw/silent_sound.mp3 b/app/src/main/res/raw/silent_sound.mp3 new file mode 100644 index 000000000..48755f29c Binary files /dev/null and b/app/src/main/res/raw/silent_sound.mp3 differ diff --git a/app/src/main/res/values-es-rES/arrays.xml b/app/src/main/res/values-es-rES/arrays.xml new file mode 100644 index 000000000..991dc30c9 --- /dev/null +++ b/app/src/main/res/values-es-rES/arrays.xml @@ -0,0 +1,115 @@ + + + + Texto + Audio + + + + Pestaña + Carpeta + + + + @string/indent_0 + @string/indent_1 + @string/indent_2 + @string/indent_3 + @string/indent_4 + + + + .txt + .json + .xml + + + + @string/jf_convert_o + @string/jf_convert_j + @string/jf_convert_f + + + + Adaptar do sistema + Tema Claro + Tema Oscuro + Tema E-Ink + + + + Autom. + Oscuro + Claro + Adaptado + + + + Predeterminado + 1 min + 2 min + 3 min + Siempre + + + + @string/screen_unspecified + @string/screen_portrait + @string/screen_landscape + @string/screen_sensor + + + + íconePrincipal + ícone1 + ícone2 + ícone3 + ícone4 + ícone5 + ícone6 + + + + Desativado + Tradicional a Simplificado + Simplificado a Tradicional + + + + Fuente predeterminado + Fuente Serif + Fuente Monoespaciado + + + + En blanco + Título + Tiempo + Batería + Páginas + Avance + Páginas y avance + Nombre del libro + Tiempo y Batería + + + + Normal + Oscuro + Claro + + + + Autom. + Chino simplificado + Chino tradicional + Inglés + + + + FuenteLibro + FuenteRSS + SustituirRegla + + + diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml new file mode 100644 index 000000000..34c9d2811 --- /dev/null +++ b/app/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,913 @@ + + + + Legado + Legado·buscador + Legado necesita de acceso de almacenamineto para buscar y leer libros. Por favor, diríjase a los "Ajustes de la aplicación" para conceder el "Permiso de almacenamineto". + + + Inicio + Restaurar + Importar datos de Legado + Crear carpeta + Crea una carpeta de respaldo bajo el nombre de Legado. + Respaldo de caché de libros sin conexión + Exporta localmente lo respalda para su exportación + Respaldar para + Por favor, seleccione una carpeta de respaldo. + Importar desde legado + Importar datos de Github + Reemplazo + Enviar + + Aviso + Cancelar + Confirmar + Ir a Ajustes + No se puede ir a los ajustes. + + Reintentar + Cargando + Advertencia + Editar + Borrar + Borrar todo + Reemplazar + Reemplazo + Configurar reglas de reemplazo + No disponible + Activar + Buscar reemplazo + Estantería + Favoritos + Favorito + en Favoritos + No está en Favoritos + Suscripción + Todo + Lecturas recientes + Última lectura + Novedades + La estantería está vacía. ¡Busque libros o añádalos desde una fuente pública! + Buscar + Descargar + Lista + Grid-3 + Grid-4 + Grid-5 + Grid-6 + Layout + Vista + Biblioteca + Importar libros + Fuente de libros + Gestionar fuentes + Crear/Importar/Editar/Gestionar fuentes de libros + Ajustes + Ajustes de Temas + Configuraciones relacionadas a los temas visuales + Otros ajustes + Algunas configuraciones relacionadas a su funcionamiento + Acerca de + Donaciones + Sair + Todavía se guardó. ¿Desea continuar editando? + Tipos de libros + Versión + Local + Buscar + Origen: %s + Origen: %s + Título + Última vez: %s + ¿Desea añadir %s a su Estantería? + %s archivos(s) de texto en total + Cargando… + Reintentar + Servicio Web + Fuente de edición web y lectura de libros + Editar fuentes de libros en la web + Caché sin conexión + Caché sin conexión + precarga el o los capítulos(s) seleccionado(s) en Almacenamiento en caché + Cambiar origen + \u3000\u3000 Este es una aplicación de lectura bajo software libre, desarrollado en Kotlin, en que podrás participar. ¡Síguelos en la cuenta oficial de WeChat! + Legado (YueDu 3.0) disponible para descargar en:\n https://play.google.com/store/apps/details?id=io.legado.play.release + Versión %s + Verificación en segundo plano + podrá utilizar con libertad cuando se verifique la fuente del libro + Actualización automática + Actualiza los libros automáticamente al abrir la App + Descarga automática + Baja los últimos capítulos automaticamente al actualizar los libros + Respaldo y restauración + Ajustes de WebDav + Ajustes de WebDave importación de datos anteriores + Respaldar + Restaurar + El respaldo necesita de permisos de almacenamineto + La restauración necesita de permisos de almacenamineto + Aceptar + Cancelar + Confirmar respaldo + Los nuevos archivos de respaldo serán reemplazados con los anteriores.\n Carpeta de respaldo: YueDu + Confirmar restauración + La restauración de los datos de la estantería reemplazará a los datos de la actual. + Respaldo completado + Error de respaldo\n%s + Restaurando + Restauración completada + Restauración fallida + Orientación de pantalla + Auto(sensor) + Paisaje + Retrato + Adaptado del sistema + Descargo de responsabilidad + %d capítulos + Interfaz + Brillo + Capítulos + Próximo + Anterior + Ocultar barra de estado + Oculta la barra de navegación del sistema durante lectura + Voz + Hablando + Clic para abrir la lectura + Reproducir + Reproduciendo + Clic para abrir la reproducción + Pausar + Regresar + Actualizar + Iniciar + Detener + Pausar + Continuar + Cronómetro + Voz pausada + Hablando (%d min restantes) + Reproduciendo (%d min restantes) + Ocultar botones virtuales durante lectura + Oculta barra de navegación + Color de barra de navegación + Evaluación + E-mail + Error al abrir + Error al compartir + Sin capítulos + Añadir Url + Añadir Url de libro + Segundo plano + Autor + Autor: %s + Voz detelendia + Limpiar caché + Se limpió la caché + Guardar + Editar fuente + Editar fuente de libro + Desactivar fuente de libro + Añadir fuente de libro + Añadir fuente de suscripción + Añadir libros + Buscar + Copiar fuente + Pegar fuente + Descripción de las reglas de fuente + Comprobar actualizaciones + Digitalizar código QR + Digitalizar imágenes locales + Descripción de las reglas + Compartir + Compartir vía + Adaptar del sistema + Añadir + Importar fuentes del libro + Importar localmente + Importar en línea + Reemplazo + Editar regla de reemplazo + Modelo + Reemplazo + Portada + Libro + Botones de volumen para pasar página + Toque la pantalla para pasar página + Animación de hojeada + Animación de hojeada (libro) + Mantener pantalla encendida + Regresar + Menú + Ajuste + Barra de deslizamiento + Limpiar la caché borrará todos los capítulos guardados. ¿Está seguro de limpiarlos? + Compartir fuentes de libros + Nombre de regla + La regla está vacía o es incompatible con los lineamientos de Regex. + Acción de selección + Seleccionar todo + Selecionar todo (%1$d/%2$d) + Dejar de seleccionar todo (%1$d/%2$d) + Modo oscuro + Página de bienvenida + Iniciar descarga + Cancelar descarga + Excluir de descarga + Descargando %1$d/%2$d + Importar libro(s) seleccionado(s) + Número de tareas en simultáneo + Cambiar ícono + Quitar + Iniciar lectura + Cargando… + Error cargando, toque para volver a iniciar + Descripción de libro + Descripción:%s + Descripción: sin introducción + Abrir libro externo + Origen: %s + Importar reglas replace + Importar reglas en línea + Intervalo de actualizaciones + Lista de recientes + Fecha de actualización + Título del libro + Ordenar manualmente + Estrategia de lectura + Tipografía + Quitar selecionados + ¿Desea quitarlos? + Limpiar fuente + Descubrir + Descubrir + Sin contenido. Diríjase al Administrador de fuentes para añadirlo. + Quitar todo + Historial de búsqueda + Limpiar + Mostrar título de libro en texto + Sincronización de fuentes de libros + Sin último capítulo. + Mostrar tiempo y batería + Divisor de pantalla + Oscurecer ícono de barra de estado + Contenido + Copiar + Bajar todo + Este es un texto de prueba, \n\u3000\u3000 sólo para mostrar el resultado + Color de fondo (mantén pulsado para personalizar) + Barra de estado inmersiva + %d capítulo(s) restante(s) + Ninguno seleccionado + Mantén pulsado para introducir el valor de color + Cargando… + Preparando + Preparando algo más + Favoritos + Añadir a Favoritos + Quitar + Tiempo límite de carga + Siga:%s + Copiado con éxito + Organización de estantería + Esto borrará a todos los libros. Por favor, tenga cuidado. + Buscar fuentes de libros + Buscar fuentes de suscripción + Búsqueda (%d fuentes en total) + Capítulos (%d) + Negrito + Fuente + Texto + Página inicial + Derecha + Izquierda + Parte inferior + Parte superior + Espaciado + Espacio superior + Espacio inferior + Espacio derecho + Espacio izquiedo + Verificar fuentes del libro + Verificar fuente seleccionada + %1$s Avance %2$d/%3$d + ¡Por favor, instale y seleccione un TTS en chino! + ¡Error al inicializar TTS! + Conversión a simplificada + Desactivado + Simplificado a tradicional + Tradicional a simplificado + Modo de hojeada + %1$d elementos + Almacenamiento: + Adicionar ao Estante + Añadir a Estantería (%1$d) + %1$d libros añadidos con éxito + Por favor, coloque sus archivos de fuente al almacenamiento raíz y vuelve a seleccionar + Fuente predeterminada + Seleccionar fuentes + Tamaño de texto + Espacio entre líneas + Espacio entre párrafos + Encima + Selection To Top + Debajo + Selection To Bottom + Autoexpandir Descubrir + Expande por defecto la primera parte de Descubrir. + Líneas actuales %s + Velocidad de voz + Deslizamiento automático + Impedir deslizamiento automático + Velocidad de deslizamiento automático + Información sobre libro + Editar información del libro + Establecer la estantería como página de inicio + Ir automáticamente a la lista de Recientes + Objeto sustituto. El nombre del libro o la URL de origen están disponibles + Grupos + Carpeta de caché + Selector de archivos del sistema + Nueva versión + Descargar actualizaciones + Botones de volumen para pasar las páginas mientras lee + Ajuste de margen + Activar actualizaciones + Desactivar actualizaciones + Invertir + Buscar libro por nombre o autor + Nombre del libro, autor, URL + Preguntas frecuentes + Mostrar todos los buscados + Mostrar la fuente de búsqueda si Descubrir fue cerrado + Actualizar capítulos + Capítulos de Txt Regex + Codificación de texto + Orden ascendente/descendente + Ordenar + Ordenar automáticamente + Ordenar manualmente + Ordenar por nombre + Ir al principio + Ir al final + Leído: %s + Awaiting update + Preparando un poco más + Listo + Todo + Preparando actualización de libros + Preparando más actualizaciones de libros + Libros concluidos + Libros locales + El color de la barra de estado se vuelve transparente + barra de navegación de inmersión + La barra de navegación se vuelve transparente + Agregar a la estantería + Seguir leyendo + Carpeta de portada + Portada + Diapositiva + Simulación + Desplazado + Ninguno + La fuente de este libro utiliza funciones avanzadas, para desbloquear diríjase a Donaciones y toque el código de búsqueda del sobre rojo de Alipay para recibir ese sobre. + Actualiza el último capítulo después del cambio de fuente en segundo plano + si está habilitado, la actualización comenzará 1 minuto después de que se abra la aplicación + Ocultar automáticamente la barra de herramientas + La barra de herramientas se ocultará automáticamente cuando deslice la Librería + Iniciar sesión + Iniciar sesión% s + Completado + La fuente actual no se ha configurado con una dirección de inicio de sesión + Sin página anterior + Sin página siguiente + + + 源名称(sourceName) + 源URL(sourceUrl) + 源分组(sourceGroup) + 自定义源分组 + 输入自定义源分组名称 + 并发率(concurrentRate) + 分类Url(sortUrl) + 登录URL(loginUrl) + 登录UI(loginUi) + 登录检查JS(loginCheckJs) + 源注释(sourceComment) + 搜索地址(url) + 发现地址规则(url) + 书籍列表规则(bookList) + 书名规则(name) + 详情页url规则(bookUrl) + 作者规则(author) + 分类规则(kind) + 简介规则(intro) + 封面规则(coverUrl) + 最新章节规则(lastChapter) + 字数规则(wordCount) + 书籍URL正则(bookUrlPattern) + 预处理规则(bookInfoInit) + 目录URL规则(tocUrl) + 允许修改书名作者(canReName) + 目录下一页规则(nextTocUrl) + 目录列表规则(chapterList) + 章节名称规则(ChapterName) + 章节URL规则(chapterUrl) + VIP标识(isVip) + 更新时间(ChapterInfo) + 正文规则(content) + 正文下一页URL规则(nextContentUrl) + WebViewJs(webJs) + 资源正则(sourceRegex) + 替换规则(replaceRegex) + 图片样式(imageStyle) + + 图标(sourceIcon) + 列表规则(ruleArticles) + 列表下一页规则(ruleArticles) + 标题规则(ruleTitle) + guid规则(ruleGuid) + 时间规则(rulePubDate) + 类别规则(ruleCategories) + 描述规则(ruleDescription) + 图片url规则(ruleImage) + 内容规则(ruleContent) + 样式(style) + 链接规则(ruleLink) + 校验关键字(checkKeyWord) + 操作(actions) + 购买标识(isPay) + + + + Sin fuentes + Error al obtener la información del libro + Error al obtener el contenido + Error al obtener la lista de capítulos + Error al acceder al sitio:% s + Error al leer el archivo + Error al cargar la lista de capítulos + Error al obtener los datos + Error al cargar\n%s + Sin conexión + Tiempo de espera de conexión en línea + Error en el procesamiento de datos + + + Encabezado HTTP + Fuente de depuración + Importar desde código QR + Compartir fuentes seleccionadas + Escanear código QR + Toque para mostrar el menú cuando esté seleccionado + Tema + Tipo de tema + Seleccione el tema que prefiera + Únase al grupo QQ + Para establecer la imagen de fondo requiere del permiso de almacenamiento + Ingrese la dirección de origen del libro + Borrar archivo + Archivo borrado + ¿Está seguro de que desea borrar este archivo? + Carpeta + Importación inteligente + Descubrir + Cambiar modo de visualización + La importación de libros locales requiere permiso de almacenamiento + Tema nocturno + E-Ink + Optimización para dispositivos E-ink + Esta aplicación requiere permiso de almacenamiento para respaldar la información del libro + Toque nuevamente para salir de la aplicación + La importación de libros locales requiere permiso de almacenamiento + La conexión en línea no está disponible + + No + Aceptar + ¿Está seguro que desea borrar? + ¿Está seguro de que desea borra %s? + Está seguro de que desea eliminar todos los libros? + ¿También desea eliminar los capítulos de libros descargados? + Escanear el código QR requiere permiso de la cámara + La voz se está ejecutando, no se puede pasar las páginas automáticamente + Codificación de entrada + Capítulos de Txt Regex + Para abrir libros locales, se requiere permiso de almacenamiento + Libro sin nombre + Ingrese la URL de la regla de reemplazo + La lista de búsqueda se obtuvo con éxito%d + el nombre y la URL no pueden estar vacíos + Galería + obtenga sobres rojos de AliPay + Sin dirección de actualización + Abra la página de inicio, volverá automáticamente después de completarse + Después de iniciar sesión correctamente, toque el icono en la esquina superior derecha para probar el acceso a la página de inicio + Capítulo + Para + Usando Regex + Sangría + Ninguno + Sangría con 1 carácter + Sangría con 2 carácter + Sangría con 3 carácter + Sangría con 4 carácter + Seleccione una carpeta + Seleccione un archivo + Sin Descubrir, puede agregarlo a Fuentes de libros + Restaurar valores predeterminados + La carpeta de caché personalizada requiere permiso de almacenamiento + Negro + Sin contenido + Cambiando fuente, espere + Capítulos sin contenido + Espaciado de palabras + + Básico + Buscar + Descubrir + Información + Capítulos + Contenido + + Modo E-Ink + Elimina animaciones y optimiza la experiencia para libros en papel electrónico + Servicio web + Puerto web + Puerto actual %s + Compartir código QR + Compartir cadenas + Compartir Wifi + Conceda el permiso de almacenamiento + Rebobinado rápido + Avance rápido + Anterior + Siguiente + Música + Audio + Activar + Activar js + Cargar URL base + Todas las fuentes + El contenido introducido no puede estar vacío + Limpiar la caché de búsqueda + Editar búsqueda + Cambie el icono de software que se muestra en el escritorio + Ayuda + Mío + Lecturas + %d%% + %d min + Leer por páginas + Auto-Brightness %s + Motor de voz + Imágenes de fondo + Color de fondo + Color del texto + Seleccione una foto + Gestión de grupo + Selección de grupo + Edición de grupo + Mover al grupo + Añadir a grupos + Quitar de grupos + Nuevo reemplazo + Grupo + Grupo: %s + Capítulos: %s + Activar Descubrir + Desactivar Descubrir + Activar selección + Desactivar selección + Exportar seleccionados + Exportar + Cargar capítulos + Cargar detalles del libro + TTS + Contraseña de WebDav + Ingrese su contraseña de WebDav + Ingrese la dirección de su servidor + Dirección del servidor WebDav + Cuenta WebDav + Ingrese su cuenta WebDav + Fuente de suscripción + Editar fuente de suscripción + Filtro + Buscar fuentes de Descubrir + Ubicación actual: + Búsqueda precisa + Iniciando servicio + Vacío + Selección de archivos + Selección de carpetas + ¡Finalizado! + Error en la dirección de Uri + Actualizar portada + Cambiar fuente + Imagen local + Tipo: + Fondo + Importando + Exportando + Defina qué botones pasan de página + Botón de página anterior + Botón de página siguiente + Primero agregue este libro a Estantería + Sin grupo + Oración anterior + Siguiente oración + Otra carpeta + Demasiadas palabras para crear un código QR + Compartir fuentes de suscripción + Compartir fuentes de libros + Cambio automático al modo oscuro + Sigue el modo oscuro del sistema + Atrás + Tono de voz en línea + (%1$d/%2$d) + Mostrar suscripción + Servicio detenido + Iniciando el servicio\nComprobando la barra de notificaciones para más detalles + Carpeta predeterminada + Selector de carpetas del sistema + Selector de carpetas de aplicaciones + Selector de archivos de la aplicación + En Android 10+ no puede leer ni guardar archivos debido a restricciones de permisos + Mantenga pulsado para mostrar Legado·Búsqueda en el menú de operaciones + Buscar en pantalla de operación de texto + Registro + Registro + Conversión a simplificada + El ícono es un ícono vectorial, que no era compatible antes de Android 8.0 + Speech settings + Start page + Long Tap to select text + Header + Content + Footer + Select end + Select start + Shared layout + Browser + Import default rules + Name + Regex + More menu + Minus + Plus + System typeface + Delete source file + Default-1 + Default-2 + Default-3 + Title + Left + Center + Hide + Add to Group + Save image + No default path + Group settings + View Chapters + Navigation bar shadow + Current shadow size(elevation): %s + Default + Main menu + Tap to grant permission + Legado needs Storage permission, please tap the "Grant Permission" button below, or go to "Settings"-"Application Permissions"-to open the required permission. If the permission is still not work, please tap "Select Folder" in the upper right corner to use the system folder picker. + The selected text cannot be spoken in full text speech + Extend to cutout + Updating Chapters + + Headset buttons are always available + Headset buttons are available even exit the app. + Contributors + Contact + License + Other + 开源阅读 + Follow WeChat Official Accounts + WeChat + Supporting me will be appreciated + Official Accounts[开源阅读] + Changing source + Tap to join + Middle + Information + Switch Layout + Text font weight switching + Full screen gestures support + Disable return key + + + Primario + Resalte + Color de fondo + Color de la barra de navegación + Día + Día, primario + Día, resalte + Día, color de fondo + Día, color de la barra de navegación + Noche + Noche, primario + Noche, resalte + Noche, color de fondo + Noche, color de la barra de navegación + Cambiar la fuente automáticamente + Texto justificado + Texto alineado en la parte inferior + Velocidad de desplazamiento automático + Ordenar por URL + Respalde en simultáneo vía local y WebDav + Restaure primero desde WebDAV, haga clic en restaurar desde la ubicación del respaldo + Seleccione una carpeta de respaldo heredada + Activado + Desactivado + Iniciando descarga + Este libro ya está en la lista de descargas + Haga clic para abrir + Siga [开源阅读] para obtener ayuda haciendo clic en los anuncios + Código de sugerencia de WeChat + AliPay + Sobre rojo de AliPay, código de búsqueda + 537954522 Haga clic para copiar + Código QR del sobre rojo de AliPay + Código QR de AliPay + Código QR de la Colección QQ + gedoor, Invinciblelee, etc. Para obtener más detalles, vaya a Github + Limpiar la caché de libros y fuentes descargados + Portada predeterminada + Lista de descartados + Ignore parte del contenido durante la restauración + Leer ajustes de interfaz + Nombre del grupo + Sección de notas + Activar regla de reemplazo por defecto + Para libros recién agregados + Seleccione el archivo de restauración + ¡El fondo diurno no puede ser demasiado oscuro! + ¡El fondo inferior diurno no puede ser demasiado oscuro! + ¡El fondo nocturno no puede ser demasiado brillante! + ¡El fondo inferior nocturno no puede ser demasiado brillante! + Debe haber un contraste entre el color de resalte y el de fondo + Debe haber un contraste entre el resalte y el color del texto + Formato incorrecto + Error + Mostrar widget de brillo + Idioma + Importar fuente RSS + Su donación mejora esta aplicación + Cuenta oficial de Wechat [开源阅读软件] + Leer historial + Leer resumen del historial + TTS local + Recuento de subprocesos + Tiempo total de lectura + Deseleccionar todo + Importar + Exportar + Guardar configuración del tema + Guardar configuración del tema del día + Guardar configuración del tema nocturno + Lista de temas + Guardar, importar, compartir tema + Cambiar tema predeterminado + Ordenar por hora de actualización + Contenido de búsqueda + ¡Siga [开源阅读] en WeChat para obtener más feeds RSS! + Actualmente no hay una fuente para Descubrir, siga el número público [开源 阅读] para agregar una fuente de libro a través de Descubrir. + 将焦点放到输入框按下物理按键会自动录入键值,多个按键会自动用英文逗号隔开. + Nombre del tema + Limpiar automáticamente los datos de búsqueda caducados + Historial de búsqueda de más de un día + Volver a segmentar + Formato de nombre: + Haga clic en el icono de carpeta en la esquina superior derecha y seleccione la carpeta + Búsqueda inteligente + Nombre de archivo importado + No hay libros + Conservar el nombre original + El control de la pantalla táctil + Cerrar + Página siguiente + Página anterior + Sin acciones + Título + Mostrar/ocultar + pie de página encabezado + Suscripción de reglsa + 添加大佬们提供的规则导入地址\n添加后点击可导入规则 + Obtener progreso en la nube + El progreso actual excede el progreso de la nube. ¿Quieres sincronizar? + Progreso de lectura sincronizado + Sincronizar el progreso de la lectura al entrar y salir de la pantalla de lectura + No se pudo marcar + URL única + Exportar lista de libros + Importar lista de libros + Descarga anterior + Descargue% s capítulos antes + Está habilitado + Imagen de fondo + Copiar URL del libro + Copiar URL del capítulo + Carpeta de exportación + Codificación de texto exportado + Exportar a WebDav + Contenido inverso + Depurar + Registro de fallos + Uso de ramas chinas personalizadas + Formato de imagen + Sistema TTS + Formato de exportación + Verificar por autor + Esta URL ha sido reemplazada + Frecuencia de actualización de pantalla alta + Utilice la frecuencia de actualización de pantalla más alta + Exportar todo + Listo + Mostrar no leídos + Mostrar siempre la portada predeterminada + Mostrar siempre la cobertura predeterminada, no mostrar la cobertura de la red + Código fuente de búsqueda + Código fuente del libro + Código fuente de los capítulos + Código fuente del contenido + Listar el código fuente + Tamaño de fuente + Borde superior + Borde inferior + Mostrar + Ocultar + Ocultar cuando se muestra la barra de estado + TOC inverso + Mostrar + Formato + Formato de grupo + Exportar nombre de archivo + Restablecer + URL nula + 字典 + 未知错误 + end + 关闭替换分组/开启添加分组 + 媒体按钮•上一首|下一首 + 上一段|下一段/上一章|下一章 + 及时翻页,翻页时会停顿一下 + La fuente del libro de cheques muestra un mensaje de depuración + Muestra los pasos y el tiempo de la solicitud de red durante la verificación de la fuente del libro + No export chapter names + Autobackup failed\n%s + Background image blurring + Blurring radius + Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring + 需登录 + 使用Cronet网络组件 + 上传URL + 下载URL规则 + Ordenar por tiempo de respuesta + 导出成功 + 路径 + 直链上传规则 + 用于导出书源书单时生成直链url + 拷贝播放Url + 设置源变量 + 设置书籍变量 + 注释 + 封面设置 + 设置默认封面样式 + 显示书名 + 封面上显示书名 + 显示作者 + 封面上显示作者 + 朗读上一段 + 朗读下一段 + 待下载 + 下载完成 + 下载失败 + 下载中 + 未知状态 + 禁用源 + 删除源 + 购买 + 平板/横屏双页 + 浏览器打开 + 拷贝url + 打开方式 + 是否使用外部浏览器打开? + 查看 + 打开 + 删除登录头 + 查看登录头 + 登录头 + 字体大小 + 当前字体大小:%.1f + search result + 语速减 + 语速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + + diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml new file mode 100644 index 000000000..8bbace29d --- /dev/null +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -0,0 +1,916 @@ + + + + Legado + Legado·search + Legado needs storage access to find and read books. please go "App Settings" to allow "Storage permission". + + + Home + Restore + Import Legado data + Create a subfolder + Create a folder named Legado as the backup folder. + Offline cache book backup + Export to local and back up to the exports directory under the legado folder + Backup to + Please select a backup path. + Import legacy data + Import github data + Replacement + Send + + Prompt + Cancel + Confirm + Go to Settings + Cannot jump to Settings. + + Retry + Loading + Warning + Edit + Delete + Delete all + Replace + Replacement + Configure replacement rules + Not available now + Enable + Search Replacement + Bookshelf + Favorites + Favorite + in Favorites + Not in Favorites + Subscription + All + Recent reading + Last reading + What\'s new + The bookshelf is still empty. Search for books or add them from discovery! \n if you use it for the first time, please pay attention to the public account [开源阅读] to get the book source! + Search + Download + List + Grid-3 + Grid-4 + Grid-5 + Grid-6 + Layout + View + Library + Import books + Book sources + Sources management + Create/Import/Edit/Manage Book sources + Settings + Theme settings + Some settings related to interface or color + Other settings + Some function-related settings + About + Donations + Exit + It has not been saved. Do you want to continue editing? + Book styles + Version + Local + Search + Origin: %s + Origin: %s + Title + Latest: %s + Would you like to add %s to your Bookshelf? + %s text file(s) in total + Loading… + Retry + Web service + Web edit source and read book + Edit book sources on the web + Offline cache + Offline cache + cache the selected chapter(s) to Storage + Change Origin + + \u3000\u3000 This is an open source reading software newly developed by Kotlin, welcome to join us. Follow the WeChat Official Account [开源阅读]! + + + Legado (YueDu 3.0) download link:\n https://play.google.com/store/apps/details?id=io.legado.play.release + + Version %s + Background-verification + you can operate freely when verifying the book source + Auto-refresh + Update books automatically when opening the software + Auto-download + Download the latest chapters automatically when updating books + Backup and restore + WebDav settings + WebDav settings/Import legacy data + Backup + Restore + Backup needs storage permission + Restore needs storage permission + OK + Cancel + Backup confirmation + The new backup files will replace the original.\n Backup folder: YueDu + Restore confirmation + Restoring the bookshelf data will overwrite the current Bookshelf. + Backup succeed + Backup failed\n%s + Restoring + Restore succeed + Backup failed + Screen orientation + Auto(sensor) + Landscape + Portrait + Follow system + Disclaimer + %d chapters + Interface + Brightness + Chapters + Next + Prior + Hide status bar + Hide system navigation bar when reading + Speech + Speaking + Click to open the Reading + Play + Playing + Click to open the Playing + Pause + Back + Refresh + Start + Stop + Pause + Resume + Timer + Speak Paused + Speaking(%d min left) + Playing(%d min left) + Hide virtual buttons when reading + Hide navigation bar + Navigation bar color + Rating + Email + Open failed + Share failed + No chapters + Add url + Add book url + Background + Author + Author: %s + Speak Stopped + Clear cache + Cache cleared + Save + Edit source + Edit Book source + Disable Book source + Add Book source + Add subscription source + Add books + Scan + Copy source + Paste source + Source rules description + Check for Updates + Scan QR code + Scan local images + Rules description + Share + Share to + Follow system + Add + Import book sources + Import local + Import online + Replacement + Edit replacement rule + Pattern + Replacement + Cover + Book + Volume keys to turn page + Tap screen to turn page + Flip animation + Flip animation (book) + Keep screen awake + Back + Menu + Adjust + Scroll bar + Clearing the cache will delete all saved chapters. Are you sure to delete it? + Book sources sharing + Rule name + Pattern rule is empty or does not conform the regex specification. + Selection action + Select all + Select all(%1$d/%2$d) + Cancel select all(%1$d/%2$d) + Dark mode + Welcome page + Download start + Download cancel + No download + Downloaded %1$d/%2$d + Import selected book(s) + Number of concurrent tasks + Change icon + Remove + Start reading + Loading… + Load failed, tap to retry + Book description + Description:%s + Description: no introduction + Open external book + Origin: %s + Import replace rules + Import online rules + Check interval for updates + By recent list + By update time + By book title + By sort manually + Reading strategy + Typesetting + Delete selected + Are you sure to delete? + Default font + Discovery + Discovery + No content.Go to Sources management to add it! + Delete all + Search history + Clear + Display book title on text + Book Sources sync + No latest chapter. + Display time and battery + Display divider + Darken the status bar\'s icon color + Content + Copy + Download all + This is a test text, \n\u3000\u3000 just to show you the effect + Color and background (long tap to customize) + Immersive status bar + %d chapter(s) left + No selected + Long tap to input color value + Loading… + Awaiting + Awaiting more + Bookmarks + Add to Bookmarks + Delete + Loading timeout + Follow:%s + Copied successfully + Bookshelf arrangement + It will delete all books. Be careful,please. + Search book sources + Search subscription sources + Search( %d sources in total) + Chapters(%d) + Bold + Font + Text + Home page + Right + Left + Bottom + Top + Padding + Padding top + Padding bottom + Padding left + Padding right + Check book sources + Check the selected source + %1$s Progress %2$d/%3$d + Please install and select Chinese TTS! + TTS initialization failed! + Simplified conversion + Off + Simplified to traditional + Traditional to simplified + Flipping mode + %1$d items + Storage: + Add to Bookshelf + Add to Bookshelf(%1$d) + %1$d books added successfully + Please put the font files in the Fonts folder of the storage root directory and reselect + Default font + Select fonts + Text size + Line spacing + Paragraph spacing + To Top + Selection To Top + To Bottom + Selection To Bottom + Auto expand Discovery + Default expand the first Discovery. + Current threads %s + Speech rate + Auto scroll + Stop Auto scroll + Auto scroll speed + Book information + Edit book information + Use Bookshelf as start page + Auto jump to Recent list + Replacement object. Book name or source url is available + Groups + Cache path + System file picker + New version + Download updates + Volume keys to turn page when reading + Margin adjustment + Enable update + Disable update + Inverse + Search book name/author + Book name,Author,URL + FAQ + Display all Discovery + Display the selected origin\'s Discovery if closed + Update chapters + Txt Chapters Regex + Text encoding + Ascending/Descending order + Sort + Sort automatically + Sort manually + Sort by name + Scroll to the top + Scroll to the bottom + Read: %s + Awaiting update + Awaiting more + Finished + All + Awaiting update books + Awaiting more chapters books + Finished books + Local books + The status bar color becomes transparent + immersion navigation bar + The navigation bar becomes transparent + Add to Bookshelf + Continue reading + Cover path + Cover + Slide + Simulation + Scroll + None + This book source uses advanced features, please go to the Donations and tap the Alipay red envelope search code to receive the red envelope,then you can use it. + Update the latest chapter after changed origin in the background + if enabled,the update will start 1 minute later when the software is run + Auto hide ToolBar + The toolbar will be hidden automatically when scroll the Bookshelf + Login + Login%s + Success + The current source has not configured with a login address + No prior page + No next page + + + 源名称(sourceName) + 源URL(sourceUrl) + 源分组(sourceGroup) + 自定义源分组 + 输入自定义源分组名称 + 并发率(concurrentRate) + 分类Url(sortUrl) + 登录URL(loginUrl) + 登录UI(loginUi) + 登录检查JS(loginCheckJs) + 源注释(sourceComment) + 搜索地址(url) + 发现地址规则(url) + 书籍列表规则(bookList) + 书名规则(name) + 详情页url规则(bookUrl) + 作者规则(author) + 分类规则(kind) + 简介规则(intro) + 封面规则(coverUrl) + 最新章节规则(lastChapter) + 字数规则(wordCount) + 书籍URL正则(bookUrlPattern) + 预处理规则(bookInfoInit) + 目录URL规则(tocUrl) + 允许修改书名作者(canReName) + 目录下一页规则(nextTocUrl) + 目录列表规则(chapterList) + 章节名称规则(ChapterName) + 章节URL规则(chapterUrl) + VIP标识(isVip) + 更新时间(ChapterInfo) + 正文规则(content) + 正文下一页URL规则(nextContentUrl) + WebViewJs(webJs) + 资源正则(sourceRegex) + 替换规则(replaceRegex) + 图片样式(imageStyle) + + 图标(sourceIcon) + 列表规则(ruleArticles) + 列表下一页规则(ruleArticles) + 标题规则(ruleTitle) + guid规则(ruleGuid) + 时间规则(rulePubDate) + 类别规则(ruleCategories) + 描述规则(ruleDescription) + 图片url规则(ruleImage) + 内容规则(ruleContent) + 样式(style) + 链接规则(ruleLink) + 校验关键字(checkKeyWord) + 操作(actions) + 购买标识(isPay) + + + + No source + Failed to obtain book information + Failed to obtain content + Failed to obtain chapters list + Failed to access website:%s + Failed to read file + Failed to load chapters list + Failed to get data + Failed to load\n%s + No network + Network connection timeout + Data parsing failed + + + HTTP Header + Debug source + Import from QR code + Share selected sources + Scan QR code + Tap to display Menu when selected + Theme + Theme mode + Select a theme you want + Join QQ group + Set the background image requires storage permission + Input book source address + Delete file + Deleted file + Are you sure to delete this file? + Directory + Intelligent import + Discovery + Switch display styles + Import local books requires storage permission + Night Theme + E-Ink + Optimization for E-ink devices + This software requires storage permission to store the book information to be backed up + Tap again to exit the program + Import local books requires storage permission + Network connection is not available + Yes + No + OK + Are you sure to delete it? + Are you sure to delete %s? + Are you sure to delete all books? + Do you want to delete the downloaded book chapters at the same time? + Scan QR code requires Camera permissions + Speech is running, cannot turn pages automatically + Input encoding + Txt Chapters Regex + Open local books requires storage permission + No bookName + Input replacement rule URL + Search list obtained successfully%d + name and URL cannot be empty + Gallery + get AliPay red envelopes + No update address + Opening the homepage, it will return to start page automatically after success + After successful login, please tap the icon on the upper right corner to test the homepage access + Chapter + To + Using Regex + Indent + None + Indent with 1 chars + Indent with 2 chars + Indent with 3 chars + Indent with 4 chars + Select a folder + Select a file + No Discovery, you can add it in BookSource + Restore default + Custom cache path requires Storage permission + Black + No content + Changing source, wait please + Chapters is empty + Word spacing + + Basic + Search + Discovery + Information + Chapters + Content + + E-Ink mode + Remove animations and optimize the experience of using E-paper books + Web service + Web port + Current port %s + QR code sharing + Strings sharing + Wifi sharing + Please grant Storage Permission + Speed down + Speed up + Prior + Next + Music + Audio + Enable + Enable js + Load BaseUrl + All Sources + The input content cannot be empty + Clear Discovery cache + Edit Discovery + Switch the software icon displayed on the desktop + Help + Me + Read + %d%% + %d min + Auto-Brightness %s + Speak by pages + Speak Engine + Background images + Background color + Text color + Select a picture + Group management + Group selection + Group editing + Move to group + Add to Groups + Remove from Groups + New replacement + Group + Group: %s + Chapters: %s + Enable Discovery + Disable Discovery + Enable selected + Disable selected + Export selected + Export + Load chapters + Load book detail + TTS + WebDav password + Input you WebDav authorized password + Input you server address + WebDav server address + WebDav account + Input your WebDav account + Subscription source + Edit Subscription source + Filter + Search Discovery sources + Current location: + Precise search + Starting service + Empty + File selection + Folder selection + I AM OVER! + Uri To Path failed + Refresh cover + Change origin + Local image + Type: + Background + Importing + Exporting + Set page-turning buttons + Page up button + Page down button + Add this book to Bookshelf first + Ungrouped + Prior sentence + Next sentence + Other folder + There are too many words to create a QR code + Subscription sources sharing + Book sources sharing + Automatic switching dark mode + Following system dark mode + Go back + Online Speech tone + (%1$d/%2$d) + Display Subscription + Service stopped + Starting service\nChecking notification bar for details + Default path + System folder picker + App folder picker + App file picker + Android 10+ unable to read and write file due to permission restrictions + Long tap to display Legado·Search in the operation menu + Text operation display Search + Log + Log + Simplified conversion + The icon is a vector icon, which was not supported before Android 8.0 + Speech settings + Start page + Long Tap to select text + Header + Content + Footer + Select end + Select start + Shared layout + Browser + Import default rules + Name + Regex + More menu + Minus + Plus + System typeface + Delete source file + Default-1 + Default-2 + Default-3 + Title + Left + Center + Hide + Add to Group + Save image + No default path + Group settings + View Chapters + Navigation bar shadow + Current shadow size(elevation): %s + Default + Main menu + Tap to grant permission + Legado needs Storage permission, please tap the "Grant Permission" button below, or go to "Settings"-"Application Permissions"-to open the required permission. If the permission is still not work, please tap "Select Folder" in the upper right corner to use the system folder picker. + The selected text cannot be spoken in full text speech + Extend to cutout + Updating Chapters + Headset buttons are always available + Headset buttons are available even exit the app. + Contributors + Contact + License + Other + 开源阅读 + Follow WeChat Official Accounts + WeChat + Supporting me will be appreciated + Official Accounts[开源阅读] + Changing source + Tap to join + Middle + Information + Switch Layout + Text font weight switching + Full screen gestures support + Disable return key + + + Primary + Accent + Background color + NavBar color + Day + Day,Primary + Day,Accent + Day,Background color + Day,NavBar color + Night + Night,Primary + Night,Accent + Night,Background color + Night,NavBar color + Change source automatically + Text justified + Text align bottom + Auto scroll speed + Sort by URL + Backup the local and WebDav simultaneously + Restore from WebDAV first, Restore form the local backup on long click + Select a legacy backup folder + Enabled + Disabled + Starting download + This book is already in Download list + Click to open + Follow [开源阅读] to support me by clicking on ads + WeChat Tipping Code + AliPay + AliPay red envelope search code + 537954522 Click to copy + AliPay red envelope QR code + AliPay QR code + QQ Collection QR code + gedoor,Invinciblelee etc. Checking in github for details + Clear the cache of the downloaded books and fonts + Default cover + Bypass list + Ignore some contents while restoring + Reading interface settings + Group name + Remarks section + Enable replace rule by default + For new added books + Select restore file + Day background can not be too dark! + Day bottom can not be too dark! + Night background can not be too bright! + Night bottom can not be too bright! + Need Difference between accent and background color + Need Difference between accent and text color + Wrong format + Error + Show brightness widget + Language + Import rss source + Your donation makes this app better + Wechat official account [开源阅读软件] + Read record + Read record summary + Local TTS + Thread count + Total read time + Unselect all + Import + Export + Save theme config + Save day theme config + Save night theme config + Theme list + Save, Import, Share theme + Switch default theme + Sort by update time + Search content + Follow Wechat official account [开源阅读] to get Subscription source + Empty now,Follow Wechat official account [开源阅读] to get Discovery source + 将焦点放到输入框按下物理按键会自动录入键值,多个按键会自动用英文逗号隔开. + Theme name + "Clear expired search histories automatically " + Search histories more than one day + Re-segment + Style name: + Click the folder icon in the upper right corner and select the folder + Intelligent scanning + Imported-file name + No books + Keep the original name + Screen touch control + Close + Next page + Prior page + None + Title + Show/Hide + footer header + Rule Subscription + 添加大佬们提供的规则导入地址\n添加后点击可导入规则 + Pull the cloud progress + The current progress exceeds the cloud progress. Do you want to synchronize? + Synchronous reading progress + Synchronize reading progress when entering / exiting the reading interface + Failed to create bookmark + Single URL + Export the list of books + Import the list of books + Download in advance + Download %s chapters in advance + Is enabled + Background image + Copy book URL + Copy chapters URL + Export to a folder + Exported text coding + Export to WebDav + Reverse content + Debug + Crash log + Custom Chinese line feed + Style of Images + System tts + Exported file format + Check by author + This URL has subscribed + High screen refresh rate + Use maximum screen refresh rate + Export all + Finished + Show unread flag + Always show default cover + Always show the default cover, do not show the network cover + Search source code + Book source code + Chapters source code + Content source code + List source code + Font size + Margin top + Marigin bottom + Show + Hide + Hide when status bar show + Reverse toc + 显示发现 + 样式 + 分组样式 + 导出文件名 + 重置 + url为空 + 字典 + 未知错误 + No export chapter names + end + 关闭替换分组/开启添加分组 + 媒体按钮•上一首|下一首 + 上一段|下一段/上一章|下一章 + 及时翻页,翻页时会停顿一下 + Check book source shows debug message + Show network status and timestamp during source checking + Autobackup failed\n%s + Background image blurring + Blurring radius + Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring + 需登录 + 使用Cronet网络组件 + 上传URL + 下载URL规则 + Sort by respond time + 导出成功 + 路径 + 直链上传规则 + 用于导出书源书单时生成直链url + 拷贝播放Url + 设置源变量 + 设置书籍变量 + 注释 + 封面设置 + 设置默认封面样式 + 显示书名 + 封面上显示书名 + 显示作者 + 封面上显示作者 + 朗读上一段 + 朗读下一段 + 待下载 + 下载完成 + 下载失败 + 下载中 + 未知状态 + 禁用源 + 删除源 + 购买 + 平板/横屏双页 + 浏览器打开 + 拷贝url + 打开方式 + 是否使用外部浏览器打开? + 查看 + 打开 + 删除登录头 + 查看登录头 + 登录头 + 字体大小 + 当前字体大小:%.1f + search result + 语速减 + 语速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 000000000..bc6e12b19 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,42 @@ + + + @color/md_blue_grey_600 + @color/md_blue_grey_700 + @color/md_deep_orange_800 + + @color/md_dark_disabled + + @color/md_grey_900 + @color/md_grey_850 + @color/md_grey_800 + #10303030 + + #69000000 + + #10ffffff + #20ffffff + #30ffffff + #50ffffff + + #363636 + + #634D4D4D + #63686868 + #63C7C7C7 + + #66666666 + + #737373 + #565656 + + #ffffffff + #b3ffffff + #B3B3B3 + #b7b7b7 + + + #303030 + + + #222222 + diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..aa2b4349a --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/arrays.xml b/app/src/main/res/values-pt-rBR/arrays.xml new file mode 100644 index 000000000..132358ee0 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/arrays.xml @@ -0,0 +1,115 @@ + + + + Texto + Áudio + + + + Aba + Pasta + + + + @string/indent_0 + @string/indent_1 + @string/indent_2 + @string/indent_3 + @string/indent_4 + + + + .txt + .json + .xml + + + + @string/jf_convert_o + @string/jf_convert_j + @string/jf_convert_f + + + + Adaptar do sistema + Tema Claro + Tema Escuro + Tema E-Ink + + + + Autom. + Escuro + Claro + Adaptado + + + + Padrão + 1 min + 2 min + 3 min + Sempre + + + + @string/screen_unspecified + @string/screen_portrait + @string/screen_landscape + @string/screen_sensor + + + + íconePrincipal + ícone1 + ícone2 + ícone3 + ícone4 + ícone5 + ícone6 + + + + Desativado + Tradicional a Simplificado + Simplificado a Tradicional + + + + Fonte padrão + Fonte Serif + Fonte Monospaced + + + + Em branco + Título + Tempo + Bateria + Páginas + Progresso + Páginas e progresso + Nome do livro + Tempo e Bateria + + + + Normal + Negrito + Claro + + + + Autom. + Chinês simplificado + Chinês tradicional + Inglês + + + + FonteLivro + FonteRSS + SubstituirRegra + + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 000000000..a060275a7 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,916 @@ + + + + Legado + Legado·pesquisa + Legado precisa de acesso ao armazenamento para encontrar e ler livros. Por favor, vá às "Configurações do App" para conceder "Permissão de armazenamento". + + + Início + Restaurar + Importar dados ao Legado + Criar uma subpasta + Criar uma pasta de Backup com o nome Legado. + Backup do cache dos livros para leitura off-line + Exportar localmente e fazer Backup à pasta de exportação + Backup para + Por favor, selecione uma pasta de Backup. + Importar dados de Legado + Importar dados de Github + Substituição + Enviar + + Aviso + Cancelar + Confirmar + Vá até às Configurações + Não foi possível abrir Configurações. + + Tentar novamente + Carregando + Aviso + Editar + Excluir + Excluir tudo + Substituir + Substituição + Configurar regras de substituição + Indisponível + Ativar + Procurar o substituto + Estante + Favoritos + Favorito + em Favoritos + Não está em Favoritos + Assinatura + Tudo + Leitura recente + Última leitura + Novidades + A estante está vazia. Procure por livros ou adicione-os via descoberta! \n se você o usa pela primeira vez, por favor, preste atenção à conta pública [开源阅读] para obter a fonte do livro! + Pesquisar + Download + Lista + Grade-3 + Grade-4 + Grade-5 + Grade-6 + Layout + Vista + Biblioteca + Importar livros + Fontes de livros + Gerenciar fontes + Criar/Importar/Editar/Gerenciar fontes de livros + Configurações + Configurações de Temas + Alguns ajustes relacionados à interface ou cor + Outras configurações + Algumas configurações relacionadas à funcionamento + Sobre + Doações + Sair + Não foi salvo. Você quer continuar editando? + Tipos de livros + Versão + Local + Procurar + Origem: %s + Origem: %s + Título + Último: %s + Você gostaria de adicionar %s à sua Estante? + %s arquivo(s) de texto em total + Carregando… + Tentar novamente + Serviço Web + Fonte de edição web e livro lidos + Editar fontes de livros na web + Cache off-line + Cache off-line + salvar o(s) capítulo(s) selecionado(s) no Armazenamento em cache + Alterar a origem + + \u3000\u3000 Este é um App de leitura de software livre, desenvolvido em Kotlin, você é bem-vindo a participar em projeto. Siga a conta oficial de WeChat [开源阅读]! + + + Legado (YueDu 3.0) link de download:\n https://play.google.com/store/apps/details?id=io.legado.play.release + + Versão %s + Verificação em segundo plano + Poderá usar a vontade ao verificar a fonte do livro + Atualização automática + Atualizar os livros automaticamente ao abrir o App + Download automático + Baixar automaticamente os últimos capítulos ao atualizar os livros + Backup e restauração + Configurações de WebDav + Configurações/importação WebDav de dados Legado + Fazer Backup + Restaurar + O Backup precisa de permissão de armazenamento + Para restaurar é necessária a permissão de armazenamento + OK + Cancelar + Confirmação do Backup + Os novos arquivos de Backup substituirão os originais.\n Pasta de Backup: YueDu + Confirmação da Restauração + A restauração dos dados da estante irá substituir a estante atual. + Backup bem sucedido + O Backup falhou\n%s + Restaurando + Restauração bem sucedida + A restauração falhou + Orientação da tela + Auto(sensor) + Paisagem + Retrato + Adaptar do sistema + Isenção de responsabilidade + %d capítulos + Interface + Brilho + Capítulos + Próximo + Anterior + Ocultar barra de status + Ocultar a barra de navegação do sistema durante leitura + Voz + Falando + Clique para abrir a leitura + Reproduzir + Reproduzindo + Clique para abrir a reprodução + Pausar + Anterior + Atualizar + Começar + Parar + Pausar + Retomar + Timer + Voz pausada + Falando(%d min restantes) + Reproduzindo(%d min restantes) + Esconder botões virtuais durante a leitura + Ocultar a barra de navegação + Cor da barra de navegação + Avaliação + E-mail + Falha ao abrir + Falha ao compartilhar + Sem capítulos + Adicionar Url + Adicionar Url de livro + Segundo plano + Autor + Autor: %s + Voz parada + Limpar o cache + O cache foi removido + Salvar + Editar fonte + Editar fonte do livro + Desativar fonte do livro + Adicionar fonte de livro + Adicionar fonte de assinatura + Adicionar livros + Pesquisar + Copiar a fonte + Colar a fonte + A descrição de fonte das regras + Verificar por atualizações + Digitalizar código QR + Digitalizar imagens locais + Descrição das regras + Compartilhar + Compartilhar via + Adaptar do sistema + Adicionar + Importar as fontes de livro + Importar localmente + Importar on-line + Substituição + Editar regra de substituição + Modelo + Substituição + Capa + Livro + Botões de volume para virar páginas + Toque a tela para virar página + Virar animação + Virar animação (livro) + Manter a tela ligada + Voltar + Menu + Ajuste + Barra de deslize + Ao limpar o cache você excluirá todos os capítulos salvos. Você tem certeza disso? + Compartilhamento de fontes de livros + Nome da regra + A regra padrão está vazia ou não está em conformidade com as especificações de Regex. + Ação de seleção + Selecionar tudo + Selecionar tudo(%1$d/%2$d) + Cancelar a seleção de todos(%1$d/%2$d) + Modo escuro + Página de boas-vindas + Iníciar o download + Cancelar o download + Nenhum download + Baixado %1$d/%2$d + Importar livro(s) selecionado(s) + Número de tarefas simultâneas + Alterar o ícone + Excluir + Começar a leitura + Carregando… + Falha ao carregar, toque para tentar novamente + Descrição do livro + Descrição:%s + Descrição: sem introdução + Abrir o livro externo + Origem: %s + Import replace rule + Importar regras on-line + Intervalo de atualizações + Por recentes + Por data de atualização + Por título do livro + Ordenar manualmente + Estratégia de leitura + Tipografia + Excluir selecionados + Você quer excluir? + Fonte padrão + Descoberta + Descoberta + Sem conteúdo. Vá ao Gerenciador de fontes para adicioná-lo! + Excluir tudo + Histórico de busca + Limpar + Mostrar título do livro no texto + Sincronização de fontes dos livros + Não há último capítulo. + Mostrar o tempo e a bateria + Divisor da tela + Escurecer a cor do ícone na barra de status + Conteúdo + Copiar + Baixar tudo + Este é um texto de teste, \n\u3000\u3000 apenas para mostrar o resultado + Cor e fundo (toque longo para personalizar) + Barra de status imersiva + %d capítulo(s) restante(s) + Nenhum selecionado + Clique longo, para introduzir o valor da cor + Carregando… + Aguardando + Quase lá + Favoritos + Adicionar aos Favoritos + Excluir + Tempo limite de carregamento + Siga:%s + Copiado com sucesso + A organização da estante + Isto excluirá todos os livros. Por favor, tenha cuidado. + Pesquisar fontes de livros + Buscar fontes de assinatura + Pesquisa( %d fontes no total) + Capítulos(%d) + Negrito + Fonte + Texto + Página inicial + Direita + Esquerda + Parte inferior + Parte superior + Com espaço + Espaço superior + Espaço inferior + Espaço da esquerda + Espaço da direita + Verificar fontes do livro + Verificar a fonte selecionada + %1$s Progresso %2$d/%3$d + Favor instalar e selecionar o TTS chinês! + A inicialização do TTS falhou! + Conversão simplificada + Desligado + Simplificado ao tradicional + Tradicional ao simplificado + Modo de virar + %1$d itens + Armazenamento: + Adicionar ao Estante + Adicionar ao Estante(%1$d) + %1$d livros adicionados com sucesso + Favor colocar os arquivos de fontes na pasta Root de Fontes no armazenamento e selecionar novamente + Fonte padrão + Selecionar fontes + Tamanho do texto + Espaço entre linhas + Espaço entre parágrafos + Na parte superior + Seleção até o topo + Na parte inferior + Seleção até o final + Expansão automática de Descoberta + A expansão padrão da primeira Descoberta. + Linhas atuais %s + Velocidade da voz + Deslize automático + Impedir deslize automático + Velocidade automática ao deslizar + Informações sobre livro + Editar informações do livro + Definir a Estante como página inicial + Vá automaticamente à lista de Recentes + Objeto substituto. Nome do livro ou Url de origem estão disponíveis + Grupos + Pasta de Cache + Seletor de arquivos do sistema + Nova versão + Baixar atualizações + Botões de volume para virar páginas durante leitura + Ajuste de margem + Ativar atualizações + Desativar atualizações + Inverso + Pesquisar livro por nome/autor + Nome do livro,Autor,URL + FAQ + Mostrar todas as Descobertas + Mostrar a fonte da Descoberta selecionada se encerrado + Atualizar os capítulos + Capítulos de Txt Regex + Codificação de texto + Ordem ascendente/descendente + Ordenar + Ordenar automaticamente + Ordenar manualmente + Ordenar por nome + Vá ao topo + Vá ao final + Ler: %s + Aguardando atualização + Quase lá + Concluído + Tudo + Aguardando atualização dos livros + Aguardando mais capítulos de livros + Livros concluídos + Livros locais + A cor da barra de status fica transparente + barra de navegação de imersão + A barra de navegação fica transparente + Adicionar à Estante + Continuar lendo + Pasta das capas + Capa + Deslizar + Simulação + Deslizar + Nenhum + Esta fonte de livro usa recursos avançados, por favor vá até Doações e toque no código de busca do envelope vermelho Alipay para receber o envelope vermelho, depois você pode usá-lo. + Atualizar o último capítulo após a alteração de origem em segundo plano + se ativado, a atualização será iniciada 1 minuto depois o App for aberto + Auto esconder a barra de ferramentas + A barra de ferramentas será escondida automaticamente ao deslizar a Estante + Login + Login%s + Sucesso + A fonte atual não foi configurada com um endereço de login + Nenhuma página anterior + Nenhuma página seguinte + + + 源名称(Nome da fonte) + 源URL(fonteUrl) + 源分组(fonteGrupo) + 自定义源分组 + 输入自定义源分组名称 + 并发率(taxaSimultânea) + 分类Url(ordenarUrl) + 登录URL(loginUrl) + 登UI(loginIU) + 登录检查JS(loginVerifJs) + 源注释(fonteComentário) + 搜索地址(url) + 发现地址规则(url) + 书籍列表规则(ListaLivros) + 书名规则(nome) + 详情页url规则(livroUrl) + 作者规则(autor) + 分类规则(tipo) + 简介规则(intro) + 封面规则(capaUrl) + 最新章节规则(últimoCapítulo) + 字数规则(contagemPalavras) + 书籍URL正则(LivroModeloUrl) + 预处理规则(livroInfoInit) + 目录URL规则(tocUrl) + 允许修改书名作者(podeRenomear) + 目录下一页规则(proxTocUrl) + 目录列表规则(capítuloLista) + 章节名称规则(capítuloNome) + 章节URL规则(capítuloUrl) + VIP标识(éVip) + 更新时间(capítuloInfo) + 正文规则(conteúdo) + 正文下一页URL规则(proxConteúdoUrl) + WebViewJs(webJs) + 资源正则(fonteRegex) + 替换规则(substRegex) + 图片样式(imagemFormato) + + 图标(fonteÍcone) + 列表规则(regrasArtigos) + 列表下一页规则(regrasArtigos) + 标题规则(regrasTítulo) + guid规则(regraGuid) + 时间规则(regraPubData) + 类别规则(regraCategorias) + 描述规则(regraDescrição) + 图片url规则(regraImagem) + 内容规则(regraConteúdo) + 样式(formato) + 链接规则(regraLink) + 校验关键字(checkKeyWord) + 操作(actions) + 购买标识(isPay) + + + + Nenhum fonte + Falha ao obter informações sobre os livros + Falha ao obter o conteúdo + Falha ao obter a lista de capítulos + Falha ao acessar o site:%s + Falha na leitura do arquivo + Falha ao carregar a lista de capítulos + Falha ao obter dados + Falha ao carregar\n%s + Sem internet + Tempo limite da conexão à rede + O processamento de dados falhou + + + Cabeçalho HTTP + Fonte de depuração + Importar de código QR + Compartilhar fontes selecionadas + Digitalizar código QR + Toque para exibir o menu quando for selecionado + Tema + Tipo de tema + Selecionar tema que você preferir + Junte-se ao grupo QQ + Para definir a imagem de fundo é necessária permissão de armazenamento + Digite o endereço da fonte de livro + Excluir arquivo + Arquivo excluído + Tem certeza que quer excluir este arquivo? + Pasta + Importação inteligente + Descoberta + Alterar o modo de exibição + Importação de livros locais requer permissão de armazenamento + Tema Noturno + E-Ink + Otimização para dispositivos E-ink + Este App requer permissão de armazenamento para salvar as informações do livro no Backup + Toque novamente para sair do App + Importação de livros locais requer permissão de armazenamento + A conexão de internet não está disponível + Sim + Não + OK + Realmente quer excluir? + Tem certeza que quer excluir %s? + Você realmente quer excluir todos os livros? + Você também quer excluir os capítulos de livros baixados? + Para digitalizar o código QR é necessário ter permissão da câmera + A voz está em execução, não pode virar páginas automaticamente + Codificação de entrada + Capítulos de Txt Regex + Para abrir livros locais é necessária permissão de armazenamento + Livro sem nome + URL da regra de substituição de entrada + A lista procurada obtida com sucesso%d + nome e URL não podem estar vazios + Galeria + obter envelopes vermelhos de AliPay + Nenhuma atualização no endereço + Abrindo a página inicial, ela voltará automaticamente após o sucesso + Após o login bem sucedido, por favor toque no ícone no canto superior direito para testar o acesso à página inicial + Capítulo + Para + Usando Regex + Indentação + Nenhum + Indentação com 1 caract. + Indentação com 2 caract. + Indentação com 3 caract. + Indentação com 4 caract. + Selecionar uma pasta + Selecionar um arquivo + Nenhuma Descoberta, você pode adicioná-la em Fontes de Livros + Restaurar padrão + A pasta personalizada de cache requer permissão de armazenamento + Preto + Sem conteúdo + Alteração de fonte, aguarde por favor + Os capítulos sem conteúdo + Espaço entre palavras + + Básico + Pesquisar + Descoberta + Informação + Capítulos + Conteúdo + + Modo E-Ink + Remover animações e otimizar a experiência com livros E-papel + Serviço Web + Porta Web + Porta atual %s + Compartilhar o código QR + Compartilhar sequências + Compartilhar Wifi + Por favor, conceda permissão de armazenamento + Retroceder rápido + Avançar rápido + Anterior + Próximo + Música + Áudio + Ativar + Ativar js + Carregar a Url-base + Todas as fontes + O conteúdo de entrada não pode estar vazio + Limpar o cache da Descoberta + Editar Descoberta + Alternar o ícone do software exibido na área de trabalho + Ajuda + Minhas + Leituras + %d%% + %d min + Brilho autom. %s + Ler páginas em voz alta + Mecanismo de voz + Imagens de fundo + Cor de fundo + Cor de texto + Selecione uma foto + Administração de grupo + Seleção de grupo + Edição de grupo + Mover ao grupo + Adicionar aos grupos + Remover dos grupos + Novo substituto + Grupo + Grupo: %s + Capítulos: %s + Ativar a Descoberta + Desativar a Descoberta + Ativar selecionados + Desativar selecionados + Exportar selecionados + Exportar + Carregar os capítulos + Carregar os detalhes do livro + TTS + Senha WebDav + Digite sua senha de autorização para WebDav + Digite o endereço do seu servidor + Endereço do servidor WebDav + Conta WebDav + Digite sua conta WebDav + Fonte de assinatura + Editar a fonte de assinatura + Filtrar + Pesquisar as fontes da Descoberta + Localização atual: + Busca precisa + Iniciação do serviço + Vazio + Seletor de arquivos + Seletor de pastas + Concluído! + Endereço Uri falhou + Atualizar capa + Alterar a fonte + Imagem local + Tipo: + Segundo plano + Importando + Exportando + Definir que botões viram as páginas + Botão de página anterior + Botão de página seguinte + Primeiro adicionar este livro à Estante + Sem grupo + Frase anterior + Frase seguinte + Outra pasta + Demasiadas palavras para criar um código QR + Compartilhar fontes de assinatura + Compartilhar fontes de livros + Alteração automática ao modo escuro + Seguir o modo escuro do sistema + Voltar + Tom de voz online + (%1$d/%2$d) + Exibir assinatura + Serviço parou + Iniciando o serviço\nVerificando a barra de notificações para detalhes + Pasta padrão + Seletor de pastas do sistema + Seletor de pastas do App + Seletor de arquivos do App + Android 10+ não é capaz de ler e salvar arquivos devido a restrições de permissão + Clique longo para exibir o Legado·Pesquisa no menu de operação + Visualização da operação de texto na Pesquisa + Log + Log + Conversão simplificada + O ícone é um ícone vetorial, que não era suportado antes de Android 8.0 + Configurações da voz + Página inicial + Clique longo para selecionar o texto + Cabeçalho + Conteúdo + Rodapé + Selecionar o fim + Selecionar o início + Layout compartilhado + Navegador + Importar regras padrão + Nome + Regex + Mais menu + Menos + Mais + Tamanho e tipo da fonte + Excluir arquivo fonte + Padrão-1 + Padrão-2 + Padrão-3 + Título + Esquerda + Centro + Esconder + Adicionar ao grupo + Salvar imagem + Nenhum pasta padrão + Configurações de grupo + Ver os capítulos + Sombra na barra de navegação + Tamanho da sombra atual(elevação): %s + Padrão + Menu principal + Toque para conceder permissão + Legado precisa de permissão de armazenamento, por favor toque abaixo no botão "Conceder Permissão", ou vá às "Configurações"-"Permissões do Apps"-para abrir a permissão necessária. Se a permissão não funcionar, por favor, toque no "Selecionar Pasta" no canto superior direito para usar o seletor de pastas do sistema. + O texto selecionado não pode ser falado como texto completo + Ampliar para recortar + Atualização de capítulos + Os botões de fones de ouvido estão sempre disponíveis + Os botões de fones de ouvido estão disponíveis mesmo ao sair do App. + Colaboradores + Contato + Licença + Outros + 开源阅读 + Siga a conta oficial de WeChat + WeChat + Apoio ao meu trabalho é bem-vindo + Conta oficial[开源阅读] + Alteração de fonte + Toque para participar + Médio + Informação + Alterar o layout + Alteração de tamanho da fonte de texto + Suporte aos gestos em tela completa + 禁用返回键 + + + Primário + Destaque + Cor de fundo + Cor de NavBar + Dia + Dia,Principal + Dia,Destaque + Dia,Cor de fundo + Dia,Cor de NavBar + Noite + Noite,Principal + Noite,Destaque + Noite,Cor de fundo + Noite,Cor de NavBar + Alterar a fonte automaticamente + Texto justificado + Texto alinhado com parte inferior + Velocidade de rolagem automática + Ordenar por URL + Faça backup local e de WebDav simultaneamente + Primeiro restaurar de WebDAV, ao clicar longo restaurar do local backup + Selecione uma pasta Legado de Backup + Ativado + Desativado + Iniciando o download + Este livro já está na lista de download + Clique para abrir + Siga [开源阅读] para ajudar, clicando nos anúncios + Código de gorjeta no WeChat + AliPay + Envelope vermelho de AliPay, código de busca + 537954522 Clique para copiar + Envelope vermelho de AliPay código QR + AliPay código QR + Coleção QQ Código QR + gedoor,Invinciblelee etc. Para mais detalhes vá ao Github + Limpar o cache dos livros e fontes baixados + Capa padrão + Lista de ignorados + Ignore alguns conteúdos durante restauração + Configuração da interface de leitura + Nome de grupo + Seção de observações + Ativar regra de substituição por padrão + Para livros recém adicionados + Selecione o arquivo de restauração + O fundo diurno não pode ser muito escuro! + A parte inferior diurno não pode ser muito escuro! + O fundo noturno não pode ser muito brilhante! + A parte inferior noturno não pode ser muito brilhante! + Tem que haver diferença entre destaque e cor de fundo + Tem que haver diferença entre destaque e cor de texto + Formato incorreto + Erro + Mostrar o widget de brilho + Idioma + Importar fonte de RSS + Sua doação tornará este App ainda melhor + Conta oficial de Wechat [开源阅读软件] + Histórico de leitura + Leia o resumo do histórico + TTS local + Contagem de tópicos + Tempo total da leitura + Desmarcar todos + Importar + Exportar + Salvar configuração do tema + Salvar a configuração do tema diurno + Salvar a configuração do tema noturno + Lista de temas + Salvar, Importar, Compartilhar tema + Alterar o tema padrão + Ordenar por tempo de atualização + Pesquisar conteúdo + Siga [开源阅读] no WeChat para mais fontes de RSS! + Atualmente não há nenhuma fonte, siga o número público [开源阅读] para adicionar uma fonte de livro via Descoberta + 将焦点放到输入框按下物理按键会自动录入键值,多个按键会自动用英文逗号隔开. + Nome de tema + Excluir automaticamente dados vencidos da busca + Histórico de busca mais de um dia + Re-segmentar + Formato do nome: + Clique no ícone da pasta no canto superior direito e selecione a pasta + Busca inteligente + Nome do arquivo importado + Sem livros + Manter o nome original + O controle de toque da tela + Fechar + Próxima página + Página anterior + Nenhuma Ação + Título + Mostrar/ocultar + rodapé cabeçalho + Assinatura de regras + 添加大佬们提供的规则导入地址\n添加后点击可导入规则 + Obter o progresso da nuvem + O progresso atual excede o progresso da nuvem. Você quer sincronizar? + Progresso de leitura sincronizada + Sincronizar o progresso da leitura ao entrar e sair da tela de leitura + Falha ao marcar como favorito + URL única + Exportar a lista de livros + Importar a lista de livros + Baixar antecipadamente + Baixar %s capítulos antecipadamente + Está ativado + Imagem de fundo + Copiar a URL do livro + Copiar a URL dos capítulos + Exportar a pasta + Codificação de texto exportada + Exportar para WebDav + Conteúdo reverso + Depuração + Registro de falhas + Utilização de layout chinesas personalizadas + Formato de imagem + Sistema TTS + Formato de exportação + Verificar por autor + Esta URL já foi assinada + Alta taxa de atualização da tela + Use a maior taxa de atualização da tela + Exportar tudo + Concluído + Mostrar não lido + Sempre mostrar capa padrão + Sempre mostrar capa padrão, não mostrar capa da rede + Procurar código fonte + Código fonte do livro + Código fonte dos capítulos + Código fonte do conteúdo + Listar código fonte + Tamanho de letra + Borda superior + Borda inferior + Mostrar + Ocultar + Ocultar quando a barra de status é mostrada + TOC reverso + Mostrar + Formato + Formato de grupo + Exportar o nome do arquivo + Resetar + Nenhuma url + Dicionários + Erro desconhecido + fim + Desativar substituir agrupamento / Ativar adicionar agrupamento + Botões de mídia - Anterior|Próximo + Anterior|Próximo Parágrafo/Anterior|Próximo Capítulo + Virar as páginas durante tempo, com uma pausa ao virar as páginas + Marcando a fonte do livro mostra uma mensagem de depuração + Mostrar o status da rede com a data e hora durante a verificação da fonte + Não há nomes de capítulos de exportação + Auto-Backup falhou\n%s + Desfocagem da imagem de fundo + Raio da desfocagem + Desativado quando 0, Ativado entre 1 e 25\n Quanto maior o raio, mais forte o efeito de desfocagem + Login necessário + Usando componentes de rede Cronet + 上传URL + 下载URL规则 + Classificar por tempo de resposta + 导出成功 + 路径 + 直链上传规则 + 用于导出书源书单时生成直链url + 拷贝播放Url + 设置源变量 + 设置书籍变量 + 注释 + 封面设置 + 设置默认封面样式 + 显示书名 + 封面上显示书名 + 显示作者 + 封面上显示作者 + 朗读上一段 + 朗读下一段 + 待下载 + 下载完成 + 下载失败 + 下载中 + 未知状态 + 禁用源 + 删除源 + 购买 + 平板/横屏双页 + 浏览器打开 + 拷贝url + 打开方式 + 是否使用外部浏览器打开? + 查看 + 打开 + 删除登录头 + 查看登录头 + 登录头 + 字体大小 + 当前字体大小:%.1f + search result + 语速减 + 语速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + + diff --git a/app/src/main/res/values-zh-rHK/arrays.xml b/app/src/main/res/values-zh-rHK/arrays.xml new file mode 100644 index 000000000..a16e661f9 --- /dev/null +++ b/app/src/main/res/values-zh-rHK/arrays.xml @@ -0,0 +1,79 @@ + + + + + 文本 + 音頻 + + + + 標籤 + 文件夾 + + + + 跟隨系統 + 亮色主題 + 暗色主題 + E-Ink(墨水螢幕) + + + + 自動 + 黑色 + 白色 + 跟隨背景 + + + + 默認 + 1分鐘 + 2分鐘 + 3分鐘 + 常亮 + + + + 關閉 + 繁體轉簡體 + 簡體轉繁體 + + + + 系統默認字體 + 系統襯線字體 + 系統等寬字體 + + + + + 標題 + 時間 + 電量 + 頁數 + 進度 + 頁數同埋進度 + 書名 + 時間同埋電量 + + + + 正常 + 粗體 + 細體 + + + + 跟隨系統 + 簡體中文 + 繁體中文 + 英文 + + + + 書源 + 訂閲源 + 替換規則 + + + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml new file mode 100644 index 000000000..04aa47e15 --- /dev/null +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -0,0 +1,913 @@ + + + 閲讀 + 閲讀·搜尋 + 閲讀需要訪問存儲卡權限,請前往「設定」—「應用程式權限」—開啟所需要的權限 + + + Home + 還原 + 導入閲讀數據 + 創建子文件夾 + 創建 legado 文件夾作爲備份路徑 + 離線緩存書籍備份 + 導出本地同時備份到legado文件夾下exports目錄 + 備份路徑 + 導入舊版數據 + 導入 Github 數據 + 淨化替換 + Send + + 提示 + 取消 + 確認 + 去設定 + 無法轉跳至設定介面 + + 點擊重試 + 正在加載 + 提醒 + 編輯 + 刪除 + 替換 + 替換淨化 + 配置替換淨化規則 + 暫無 + 啟用 + 替換淨化-搜尋 + 書架 + 收藏夾 + 收藏 + 已收藏 + 未收藏 + 訂閲 + 全部 + 最近閲讀 + 最後閲讀 + 更新日誌 + 書架還空著,先去搜索書籍或從發現裏添加吧!\n如果初次使用請先關註公眾號[开源阅读]獲取書源! + 搜尋 + 下載 + 列表 + 網格三列 + 網格四列 + 網格五列 + 網格六列 + 書架佈局 + 視圖 + 書城 + 添加本地 + 書源 + 書源管理 + 新建/導入/編輯/管理書源 + 設定 + 主題設定 + 同主題/顏色相關的一些設定 + 其它設定 + 與功能相關的一些設定 + 關於 + 捐贈 + 退出 + 尚未保存,是否繼續編輯 + 閲讀樣式設定 + 版本 + 本地 + 搜尋 + 來源: %s + 最近: %s + 書名 + 最新: %s + 是否將《%s》放入書架? + 共 %s 個 Text 文件 + 載入中… + 重試 + Web 服務 + 瀏覽器寫源,看書 + web 編輯書源 + 離線緩存 + 離線緩存 + 緩存已選擇的章節到本地 + 換源 + + \u3000\u3000這是一款使用 Kotlin 全新開發的開源的閲讀應用程式,歡迎你的加入。關注公眾號[legado-top]! + + + 閲讀3.0下載地址:\nhttps://play.google.com/store/apps/details?id=io.legado.play.release + + Version %s + 後臺校驗書源 + 打開后可以在校驗書源時自由操作 + 自動刷新 + 打開程式時自動更新書輯 + 自動下載最新章節 + 更新書輯時自動下載最新章節 + 備份與還原 + WebDav 設定 + WebDav 設定/還原舊版本數據 + 備份 + 還原 + 備份請給予存儲權限 + 還原請給予存儲權限 + 確認 + 取消 + 確認備份嗎? + 新備份會覆蓋原有備份。\n備份路徑YueDu + 確認還原嗎? + 還原成功會覆蓋原有書架。 + 備份成功 + 備份失敗\n%s + 正在還原 + 還原成功 + 還原失敗 + 屏幕方向 + 跟隨傳感器 + 橫向 + 豎向 + 跟隨系統 + 免責聲明 + 共%d章 + 介面 + 亮度 + 目錄 + 下一章 + 上一章 + 隱藏狀態欄 + 閲讀介面隱藏狀態欄 + 朗讀 + 正在朗讀 + 點擊打開閲讀介面 + 播放 + 正在播放 + 點擊打開播放介面 + 播放暫停 + 返回 + 刷新 + 開始 + 停止 + 暫停 + 繼續 + 定時 + 朗讀暫停 + 讀緊(剩餘 %d 分鐘) + 播緊(剩餘 %d 分鐘) + 閲讀介面隱藏導航欄 + 隱藏導航欄 + 導航欄顏色 + 評分 + 發送電子郵件 + 無法打開 + 分享失敗 + 無章節 + 添加網址 + 添加書輯網址 + 背景 + 作者 + 作者: %s + 朗讀停止 + 清理緩存 + 成功清理緩存 + 保存 + 編輯源 + 編輯書源 + 禁用書源 + 新建書源 + 新建訂閲源 + 添加書輯 + 掃描 + 拷貝源 + 粘帖源 + 源規則説明 + 檢查更新 + 掃描 QR Code + 掃描本地圖片 + 規則説明 + 分享 + 應用程式分享 + 跟隨系統 + 添加 + 導入書源 + 本地導入 + 網絡導入 + 替換淨化 + 替換規則編輯 + 替換規則 + 替換為 + 封面 + + 音量鍵翻頁 + 點擊翻頁 + 翻頁動畫 + 翻頁動畫(本書) + 屏幕超時 + 返回 + 菜單 + 調節 + 滾動條 + 清除緩存會刪除所有已保存的章節,確認是否清除? + 書源共享 + 規則替換名稱 + 替換規則為空或不滿足正則表達式要求 + 選擇操作 + 全選 + 全選 (%1$d/%2$d) + 取消全選 (%1$d/%2$d) + 深色模式 + 啟動頁 + 開始下載 + 取消下載 + 暫無任務 + 已下載 %1$d/%2$d + 導入選擇書輯 + 更新/搜尋線程數,太多會卡頓 + 切換圖標 + 刪除書輯 + 開始閲讀 + 數據載入中… + 載入失敗,點擊重試 + 內容簡介 + 簡介: %s + 簡介: 暫無簡介 + 打開外部書籍 + 來源: %s + 導入替換規則 + 導入在線規則 + 檢查更新間隔 + 按閲讀時間 + 按更新時間 + 按書名 + 手動排序 + 閲讀方式 + 排版 + 刪除所選 + 是否確認刪除? + 默認字體 + 發現 + 發現管理 + 沒有內容,去書源裏自定義吧! + 刪除所有 + 搜索歷史 + 清除 + 正文顯示標題 + 書源同步 + 無最新章節信息 + 顯示時間和電量 + 顯示分隔線 + 深色狀態欄圖標 + 內容 + 拷貝內容 + 一鍵緩存 + 這是一段測試文字\n\u3000\u3000只是讓你看看效果的 + 文字顏色和背景(長按自定義) + 沉浸式狀態欄 + 還剩 %d 章未下載 + 仲未揀 + 長按輸入顏色值 + 加載中… + 追更區 + 養肥區 + 書籤 + 添加書籤 + 刪除 + 加載超時 + 關注: %s + 拷貝咗 + 整理書架 + 這將會刪除所有書籍,請謹慎操作。 + 搜索書源 + 搜索訂閲源 + 搜索(共 %d 個書源) + 目錄(%d) + 加粗 + 字體 + 文字 + 軟件主頁 + + + + + 邊距 + 上邊距 + 下邊距 + 左邊距 + 右邊距 + 校驗書源 + 校驗所選 + %1$s 進度 %2$d/%3$d + 請安裝並選擇中文 TTS! + TTS 初始化失敗! + 簡繁轉換 + 關閉 + 簡轉繁 + 繁轉簡 + 翻頁模式 + %1$d 項 + 存儲咭: + 加入書架 + 加入書架 (%1$d) + 成功添加 %1$d 本書 + 請將字體文件放到 SD 根目錄 Fonts 文件夾下重新選擇 + 默認字體 + 選擇字體 + 字號 + 行距 + 段距 + 置頂 + 置頂所選 + 置底 + 置底所選 + 自動展開發現 + 默認展開第一組發現 + 當前線程數 %s + 朗讀語速 + 自動翻頁 + 停止自動翻頁 + 自動翻頁間隔 + 書籍信息 + 書籍信息編輯 + 默認打開書架 + 自動跳轉最近閲讀 + 替換範圍,選填書名或者書源url + 分組 + 內容緩存路徑 + 系統文件選擇器 + 新版本 + 下載更新 + 朗讀時音量鍵翻頁 + Tip 邊距跟隨邊距調整 + 允許更新 + 禁止更新 + 反選 + 搜索書名、作者 + 書名、作者、URL + 常見問題 + 顯示所有發現 + 關閉則只顯示勾選源的發現 + 更新目錄 + TXT目錄正則 + 設置編碼 + 倒序-順序 + 排序 + 智能排序 + 手動排序 + 名稱排序 + 滾動到頂部 + 滾動到底部 + 已讀: %s + 追更 + 養肥 + 完結 + 所有書籍 + 追更書籍 + 養肥書籍 + 完結書籍 + 本地書籍 + 狀態欄顏色透明 + 沉浸式导航栏 + 导航栏颜色透明 + 放入書架 + 繼續閲讀 + 封面地址 + 覆蓋 + 滑動 + 仿真 + 滾動 + 無動畫 + 此書源使用了高級功能,請到捐贈裏點擊支付寶紅包搜索碼領取紅包開啟。 + 後台更新換源最新章節 + 開啟則會在軟件打開 1 分鐘後開始更新 + 書架 ToolBar 自動隱藏 + 滾動書架時 ToolBar 自動隱藏與顯示 + 登錄 + 登錄 %s + 成功 + 當前源沒有配置登陸地址 + 沒有上一頁 + 沒有下一頁 + + + 源名稱 (sourceName) + 源URL (sourceUrl) + 源分組 (sourceGroup) + 分類 Url(sortUrl) + 並發率(concurrentRate) + 登錄 URL(loginUrl) + 登錄UI(loginUi) + 登錄檢查JS(loginCheckJs) + 源注釋(sourceComment) + 搜索地址 (url) + 發現地址規則 (url) + 書籍列表規則 (bookList) + 書名規則 (name) + 詳情頁 url 規則 (bookUrl) + 作者規則 (author) + 分類規則 (kind) + 簡介規則 (intro) + 封面規則 (coverUrl) + 最新章節規則 (lastChapter) + 字數規則 (wordCount) + 書籍 URL 正則 (bookUrlPattern) + 預處理規則 (bookInfoInit) + 目錄 URL 規則 (tocUrl) + 允許修改書名作者 (canReName) + 目錄下一頁規則 (nextTocUrl) + 目錄列表規則 (chapterList) + 章節名稱規則 (ChapterName) + 章節 URL 規則 (chapterUrl) + VIP 標識 (isVip) + 更新時間 (ChapterInfo) + 正文規則 (content) + 正文下一頁 URL 規則 (nextContentUrl) + WebViewJs (webJs) + 資源正則 (sourceRegex) + 圖標 (sourceIcon) + 列表規則 (ruleArticles) + 列表下一頁規則 (ruleArticles) + 標題規則 (ruleTitle) + guid 規則 (ruleGuid) + 時間規則 (rulePubDate) + 類別規則 (ruleCategories) + 描述規則 (ruleDescription) + 圖片 url 規則 (ruleImage) + 內容規則 (ruleContent) + 樣式 (style) + 鏈接規則 (ruleLink) + 校驗關鍵字(checkKeyWord) + 操作(actions) + 購買標誌(isPay) + + + + 沒有書源 + 書籍信息獲取失敗 + 內容獲取失敗 + 目錄獲取失敗 + 訪問網站失敗: %s + 文件讀取失敗 + 加載目錄失敗 + 獲取數據失敗! + 加載失敗\n%s + 沒有網絡 + 網絡連接超時 + 數據解析失敗 + + + 請求頭 (header) + 調試源 + 二維碼導入 + 掃描二維碼 + 選中時點擊可彈出菜單 + 主題 + 主題模式 + 選擇主題模式 + 加入QQ羣 + 獲取背景圖片需存儲權限 + 輸入書源網址 + 刪除文件 + 刪除文件成功 + 確定刪除文件嗎? + 手機目錄 + 智能導入 + 發現 + 切換顯示樣式 + 導入本地書籍需存儲權限 + 夜間模式 + E-Ink 模式 + 電子墨水屏模式 + 本軟件需要存儲權限來存儲備份書籍信息 + 再按一次退出程式 + 導入本地書籍需存儲權限 + 網絡連接不可用 + + 唔係 + 確認 + 是否確認刪除? + 是否確認刪除 %s? + 是否刪除全部書籍? + 是否同時刪除已下載的書籍目錄? + 掃描二維碼需相機權限 + 朗讀正在運行,不能自動翻頁 + 輸入編碼 + TXT 目錄規則 + 打開外部書籍需獲取存儲權限 + 未獲取到書名 + 輸入替換規則網址 + 搜索列表獲取成功%d + 名稱和 URL 不能為空 + 圖庫 + 領支付寶紅包 + 沒有獲取到更新地址 + 正在打開首頁,成功自動返回主界面 + 登錄成功後請點擊右上角圖標進行首頁訪問測試 + + + 使用正則表達式 + 縮進 + 無縮進 + 一字符縮進 + 二字符縮進 + 三字符縮進 + 四字符縮進 + 選擇文件夾 + 選擇文件 + 沒有發現,可以在書源裏添加。 + 恢復默認 + 自定義緩存路徑需要存儲權限 + 黑色 + 文章內容為空 + 正在換源請等待… + 目錄列表為空 + 字距 + + 基本 + 搜索 + 發現 + 詳情 + 目錄 + 正文 + + E-Ink 模式 + 去除動畫,優化電紙書使用體驗 + Web 服務 + web 端口 + 當前端口 %s + 二維碼分享 + 字符串分享 + wifi 分享 + 請給於存儲權限 + 減速 + 加速 + 上一個 + 下一個 + 音樂 + 音頻 + 啟用 + 啟用 JS + 加載 BaseUrl + 全部書源 + 輸入不能為空 + 清空發現緩存 + 編輯發現 + 切換軟件顯示在桌面的圖標 + 幫助 + 我的 + 閲讀 + %d%% + %d 分鐘 + 自動亮度 %s + 按頁朗讀 + 朗讀引擎 + 背景圖片 + 背景顏色 + 文字顏色 + 選擇圖片 + 分組管理 + 分組選擇 + 編輯分組 + 移入分組 + 添加分組 + 移除分組 + 新建替換 + 分組 + 分組: %s + 目錄: %s + 啟用發現 + 禁用發現 + 啟用所選 + 禁用所選 + 導出所選 + 導出 + 加載目錄 + 加載詳情頁 + TTS + WebDav 密碼 + 輸入你的 WebDav 授權密碼 + 輸入你的服務器地址 + WebDav 服務器地址 + WebDav 賬號 + 輸入你的 WebDav 賬號 + 訂閲源 + 編輯訂閲源 + 篩選 + 篩選發現 + 當前位置: + 精準搜索 + 正在啟動服務 + + 文件選擇 + 文件夾選擇 + 我是有底線的 + Uri 轉 Path 失敗 + 刷新封面 + 封面換源 + 選擇本地圖片 + 類型: + 後台 + 正在導入 + 正在導出 + 自定義翻頁按鍵 + 上一頁按鍵 + 下一頁按鍵 + 先將書籍加入書架 + 未分組 + 上一句 + 下一句 + 其它目錄 + 文字太多,生成二維碼失敗 + 分享RSS源 + 分享書源 + 自動切換夜間模式 + 夜間模式跟隨系統 + 上級 + 在線朗讀音色 + (%1$d/%2$d) + 顯示訂閲 + 服務已停止 + 正在啟動服務\n具體信息查看通知欄 + 默認路徑 + 系統文件夾選擇器 + 自帶文件夾選擇器 + 自帶文件選擇器 + Android10 以上因權限限制可能無法讀寫文件 + 長按文字在操作菜單中顯示閲讀·搜索 + 文字操作顯示搜索 + 記錄日誌 + 日誌 + 中文簡繁體轉換 + 圖標為矢量圖標,Android8.0 以前不支持 + 朗讀設置 + 主界面 + 長按選擇文本 + 頁眉 + 正文 + 頁腳 + 文本選擇結束位置 + 文本選擇開始位置 + 共用佈局 + 瀏覽器 + 導入默認規則 + 名稱 + 正則 + 更多菜單 + + + 系統內置字體樣式 + 刪除源文件 + 預設一 + 預設二 + 預設三 + 標題 + 靠左 + 居中 + 隱藏 + 加入分組 + 保存圖片 + 沒有默認路徑 + 設置分組 + 查看目錄 + 導航欄陰影 + 當前陰影大小 (elevation): %s + 默認 + 主菜單 + 點擊授予權限 + 閲讀需要訪問存儲卡權限,請點擊下方的"授予權限"按鈕,或前往「設定」—「應用程式權限」—打開所需權限。如果授予權限後仍然不正常,請點擊右上角的「選擇文件夾」,使用系統文件夾選擇器。 + 全文朗讀中不能朗讀選中文字 + 擴展到劉海 + 更新目錄中 + 全程響應耳機按鍵 + 即使退出軟件也響應耳機按鍵 + 開發人員 + 聯繫我們 + 開源許可 + 關注公眾號 + WeChat + 你的支持是我更新的動力 + 公眾號[开源阅读] + 正在自動換源 + 點擊加入 + + 信息 + 切換佈局 + 全面屏手勢優化 + 禁用返回鍵 + + + 主色調 + 強調色 + 背景色 + 導航欄顏色 + 白天 + 白天,主色調 + 白天,強調色 + 白天,背景色 + 白天,導航欄顏色 + 夜間 + 夜間,主色調 + 夜間,強調色 + 夜間,背景色 + 夜間,導航欄顏色 + 自動換源 + 文字兩端對齊 + 自動翻頁速度 + 地址排序 + 文章字體轉換 + 請選擇備份路徑 + 其它 + legado-top + 本地和WebDav壹起備份 + 優先從WebDav恢復,長按從本地恢復 + 選擇舊版備份文件夾 + 已啓用 + 已禁用 + 文字底部對齊 + 正在啟動下載 + 該書已在下載列表 + 點擊打開 + 關注[legado-top]點擊廣告支持我 + 微信讚賞碼 + 支付寶 + 支付寶紅包搜索碼 + 537954522 點擊複製 + 支付寶紅包二維碼 + 支付寶收款二維碼 + QQ收款二維碼 + gedoor,Invinciblelee等,詳情請在github中查看 + 清除已下載書籍和字體緩存 + 默認封面 + 恢復忽略列表 + 恢復時忽略一些內容不恢復,方便不同手機配置不同 + 閱讀界面設置 + 圖片樣式 (imageStyle) + 替換規則 (replaceRegex) + 分組名稱 + 備註內容 + 默認啟用替換淨化 + 新加入書架的書是否啟用替換淨化 + 選擇恢復文件 + 白天背景不能太暗 + 白天底欄不能太暗 + 夜間背景不能太亮 + 夜間底欄不能太亮 + 強調色不能和背景顏色相似 + 強調色不能和文字顏色相似 + 格式不對 + 錯誤 + 顯示亮度調節控制項 + 語言 + 匯入訂閱源 + 您嘅支援喺我更新嘅動力 + 公眾號[开源阅读软件] + 閲讀記錄 + 閱讀時間記錄 + 本地TTS + 線程數 + 總閲讀時間 + 全部唔要 + 刪除所有 + 導入 + 導出 + 儲存主題配置 + 儲存白天主題配置以供使用同埋分享 + 儲存夜間主題配置以供使用同埋分享 + 主題列表 + 使用儲存主題,匯入,分享主題 + 切換默認主題 + 分享選中源 + 更新時間排序 + 全文搜索 + 關注公眾號[开源阅读]獲取訂閲源! + 目前沒有發現源,關注公眾號[开源阅读]添加包含發現的書源! + 將焦點放到輸入框按下物理按鍵會自動輸入鍵值,多個按鍵會自動用英文逗號隔開. + 主題名 + 自動清除過期搜尋資料 + 超過一天的搜尋資料 + 重新分段 + 樣式名稱: + 點擊右上角資料夾圖示,選擇資料夾 + 智能掃描 + 導入文件名 + 複製書籍URL + 複製目錄URL + 冇書 + 保留原名 + 點擊區域設定 + 閂咗 + 下一頁 + 上一頁 + 無操作 + 正文標題 + 顯示/隱藏 + 頁眉頁腳 + 規則訂閱 + 添加大佬們提供的規則匯入地址 添加後點擊可匯入規則 + 拉取雲端進度 + 目前進度超過雲端進度,係咪同步? + 同步閱讀進度 + 進入退出閱讀介面時同步閱讀進度 + 建立書籤失敗 + 單URL + 導出書單 + 導入書單 + 預下載 + 預先下載%s章正文 + 係咪啟用 + 背景圖片 + 背景圖片虛化 + 虛化半徑 + 0為停用,啓用範圍1~25\n半徑數值越大,虛化效果越高 + 導出資料夾 + 導出編碼 + 導出到WebDav + 反轉內容 + 調試 + 崩潰日誌 + 使用自訂中文分行 + 圖片樣式 + 系統TTS + 導出格式 + 校驗作者 + 搜尋源碼 + 書輯源碼 + 目錄源碼 + 正文源碼 + 列表源碼 + 此url訂閱咗 + 高刷 + 使用螢幕最高刷新率 + 導出所有 + 完成 + 顯示未讀標誌 + 總是使用默認封面 + 總是使用默認封面,唔顯示網路封面 + 字號 + 上邊距 + 下邊距 + 顯示 + 隱藏 + 狀態欄顯示時隱藏 + 自訂源分組 + 輸入自訂源分組名稱 + 反轉目錄 + 顯示發現 + 樣式 + 分組樣式 + 導出文件名 + 重置 + url為空 + 字典 + 未知錯誤 + TXT不導出章節名 + end + 關閉替換分組/開啟添加分組 + 媒體按鈕•上一首|下一首 + 上一段|下一段/上一章|下一章 + 及時翻頁,翻頁時會停頓一下 + 校驗書源顯示詳細信息 + 書源校驗時顯示網絡請求步驟和時間 + 需登錄 + 使用Cronet網絡組件 + 上傳URL + 下載URL規則 + 響應時間排序 + 導出成功 + 路徑 + 直鏈上傳規則 + 用於導出書源書單時生成直鏈url + 複製播放Url + 設置源變量 + 設置書籍變量 + 注釋 + 封面設置 + 設置默認封面樣式 + 顯示書名 + 封面上顯示書名 + 顯示作者 + 封面上顯示作者 + 朗讀上一段 + 朗讀下一段 + 待下載 + 下載完成 + 下載失敗 + 下載中 + 未知狀態 + 禁用源 + 刪除源 + 購買 + 平板/橫屏雙頁 + 瀏覽器打開 + 複製url + 打開方式 + 是否使用外部瀏覽器打開? + 查看 + 搜索結果 + 打開 + 刪除登錄頭 + 查看登錄頭 + 登錄頭 + 字體大小 + 當前字亂大小:%.1f + 語速减 + 語速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + + diff --git a/app/src/main/res/values-zh-rTW/arrays.xml b/app/src/main/res/values-zh-rTW/arrays.xml new file mode 100644 index 000000000..d88800ca3 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/arrays.xml @@ -0,0 +1,94 @@ + + + + 文字 + 音訊 + + + + 標籤 + 文件夾 + + + + .txt + .json + .xml + + + + 跟隨系統 + 亮色主題 + 暗色主題 + E-Ink(墨水屏) + + + + 自動 + 黑色 + 白色 + 跟隨背景 + + + + 預設 + 1分鐘 + 2分鐘 + 3分鐘 + 常亮 + + + + iconMain + icon1 + icon2 + icon3 + icon4 + icon5 + icon6 + + + + 關閉 + 繁體轉簡體 + 簡體轉繁體 + + + + 系統預設字體 + 系統襯線字體 + 系統等寬字體 + + + + + 標題 + 時間 + 電量 + 頁數 + 進度 + 頁數及進度 + 書名 + 時間及電量 + + + + 正常 + 粗體 + 細體 + + + + 跟隨系統 + 簡體中文 + 繁體中文 + 英文 + + + + 書源 + 訂閲源 + 替換規則 + + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..82e3646b4 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,915 @@ + + + 閱讀 + 閱讀·搜尋 + 閱讀需要存取記憶卡權限,請前往「設定」—「應用程式權限」—打開所需權限 + + + 備份 + 復原 + 匯入閱讀資料 + 建立子資料夾 + 建立legado資料夾作為備份資料夾 + 離線快取書籍備份 + 匯出本機同時備份到legado資料夾下exports目錄 + 備份路徑 + 請選擇備份路徑 + 匯入舊版資料 + 匯入Github資料 + 淨化取代 + 傳送 + + 提示 + 取消 + 確定 + 去設定 + 無法跳轉至設定介面 + + 點擊重試 + 正在載入 + 提醒 + 編輯 + 刪除 + 刪除所有 + 取代 + 取代淨化 + 配置取代淨化規則 + 暫無 + 啟用 + 取代淨化-搜尋 + 書架 + 收藏夾 + 收藏 + 已收藏 + 未收藏 + 訂閱 + 全部 + 最近閱讀 + 最後閱讀 + 更新日誌 + 書架還空著,先去搜尋書籍或從發現裡添加吧!\n如果初次使用請先關注公眾號[开源阅读]獲取書源! + 搜尋 + 下載 + 列表 + 網格三列 + 網格四列 + 網格五列 + 網格六列 + 書架布局 + 檢視 + 書城 + 新增本機 + 書源 + 書源管理 + 建立/匯入/編輯/管理書源 + 設定 + 主題設定 + 與介面/顏色相關的一些設定 + 其它設定 + 與功能相關的一些設定 + 關於 + 捐贈 + 退出 + 尚未儲存,是否繼續編輯 + 閱讀樣式設定 + 版本 + 本機 + 搜尋 + 來源: %s + 最近: %s + 書名 + 最新: %s + 是否將《%s》放入書架? + 共%s個Text文件 + 載入中… + 重試 + Web 服務 + 瀏覽器寫源,看書 + web編輯書源 + 離線快取 + 離線快取 + 快取選擇的章節到本機 + 換源 + + \u3000\u3000這是一款使用Kotlin全新開發的開源的閱讀軟體,歡迎您的加入。關注公眾號[legado-top]! + + + 閱讀3.0下載網址:\nhttps://play.google.com/store/apps/details?id=io.legado.play.release + + Version %s + 後臺校驗書源 + 打開後可以在校驗書源時自由操作 + 自動重新整理 + 打開軟體時自動更新書籍 + 自動下載最新章節 + 更新書籍時自動下載最新章節 + 備份與復原 + WebDav設定 + WebDav設定/匯入舊版本資料 + 備份 + 復原 + 備份請給予儲存權限 + 復原請給予儲存權限 + 確認 + 取消 + 確認備份嗎? + 新備份會取代原有備份。\n備份資料夾YueDu + 確認復原嗎? + 復原書架會覆蓋現有書架。 + 備份成功 + 備份失敗\n%s + 正在復原 + 復原成功 + 復原失敗 + 螢幕方向 + 跟隨感測器 + 橫向 + 豎向 + 跟隨系統 + 免責聲明 + 共%d章 + 介面 + 亮度 + 目錄 + 下一章 + 上一章 + 隱藏狀態欄 + 閱讀介面隱藏狀態欄 + 朗讀 + 正在朗讀 + 點擊打開閱讀介面 + 播放 + 正在播放 + 點擊打開播放介面 + 播放暫停 + 返回 + 重新整理 + 開始 + 停止 + 暫停 + 繼續 + 定時 + 朗讀暫停 + 正在朗讀(還剩%d分鐘) + 正在播放(還剩%d分鐘) + 閱讀介面隱藏虛擬按鍵 + 隱藏導航欄 + 導航欄顏色 + 評分 + 發送郵件 + 無法打開 + 分享失敗 + 無章節 + 新增網址 + 新增書籍網址 + 背景 + 作者 + 作者: %s + 朗讀停止 + 清理快取 + 成功清理快取 + 儲存 + 編輯源 + 編輯書源 + 禁用書源 + 建立書源 + 建立訂閱源 + 新增書籍 + 掃描 + 複製源 + 貼上源 + 源規則說明 + 檢查更新 + 掃描二維碼 + 掃描本機圖片 + 規則說明 + 分享 + 軟體分享 + 跟隨系統 + 新增 + 匯入書源 + 本機匯入 + 網路匯入 + 取代淨化 + 取代規則編輯 + 取代規則 + 取代為 + 封面 + + 音量鍵翻頁 + 點擊翻頁 + 翻頁動畫 + 翻頁動畫(本書) + 螢幕超時 + 返回 + 選單 + 調節 + 滾動條 + 清除快取會刪除所有已儲存章節,是否確認刪除? + 書源共享 + 取代規則名稱 + 取代規則為空或者不滿足正規表示式要求 + 選擇操作 + 全選 + 全選(%1$d/%2$d) + 取消全選(%1$d/%2$d) + 深色模式 + 啟動頁 + 開始下載 + 取消下載 + 暫無任務 + 已下載 %1$d/%2$d + 匯入選擇書籍 + 更新和搜尋執行緒數,太多會卡頓 + 切換圖示 + 刪除書籍 + 開始閱讀 + 載入資料中… + 載入失敗,點擊重試 + 內容簡介 + 簡介:%s + 簡介: 暫無簡介 + 打開外部書籍 + 來源: %s + 匯入替換規則 + 匯入線上規則 + 檢查更新間隔 + 按閱讀時間 + 按更新時間 + 按書名 + 手動排序 + 閱讀方式 + 排版 + 刪除所選 + 是否確認刪除? + 預設字體 + 發現 + 發現管理 + 沒有內容,去書源裡自訂吧! + 刪除所有 + 搜尋歷史 + 清除 + 正文顯示標題 + 書源同步 + 無最新章節訊息 + 顯示時間和電量 + 顯示分隔線 + 深色狀態欄圖示 + 內容 + 複製內容 + 一鍵快取 + 這是一段測試文字\n\u3000\u3000只是讓你看看效果的 + 文字顏色和背景(長按自訂) + 沉浸式狀態欄 + 還剩%d章未下載 + 沒有選擇 + 長按輸入顏色值 + 載入中… + 追更區 + 養肥區 + 書籤 + 添加書籤 + 刪除 + 載入超時 + 關注:%s + 已複製 + 整理書架 + 這將會刪除所有書籍,請謹慎操作。 + 搜尋書源 + 搜尋訂閱源 + 搜尋(共%d個書源) + 目錄(%d) + 加粗 + 字體 + 文字 + 軟體首頁 + + + + + 邊距 + 上邊距 + 下邊距 + 左邊距 + 右邊距 + 校驗書源 + 校驗所選 + %1$s 進度 %2$d/%3$d + 請安裝並選擇中文TTS! + TTS初始化失敗! + 簡繁轉換 + 關閉 + 簡轉繁 + 繁轉簡 + 翻頁模式 + %1$d 項 + 記憶卡: + 加入書架 + 加入書架(%1$d) + 成功添加%1$d本書 + 請將字體檔案放到SD根目錄Fonts資料夾下重新選擇 + 預設字體 + 選擇字體 + 字號 + 行距 + 段距 + 置頂 + 置頂所選 + 置底 + 置底所選 + 自動展開發現 + 預設展開第一組發現 + 目前執行緒數 %s + 朗讀語速 + 自動翻頁 + 停止自動翻頁 + 自動翻頁間隔 + 書籍訊息 + 書籍訊息編輯 + 預設打開書架 + 自動跳轉最近閱讀 + 取代範圍,選填書名或者書源url + 分組 + 內容快取路徑 + 系統檔案選擇器 + 新版本 + 下載更新 + 朗讀時音量鍵翻頁 + Tip邊距跟隨邊距調整 + 允許更新 + 禁止更新 + 反選 + 搜尋書名、作者 + 書名、作者、URL + 常見問題 + 顯示所有發現 + 關閉則只顯示勾選源的發現 + 更新目錄 + TXT目錄正則 + 設定編碼 + 倒序-順序 + 排序 + 智慧排序 + 手動排序 + 名稱排序 + 滾動到頂部 + 滾動到底部 + 已讀: %s + 追更 + 養肥 + 完結 + 所有書籍 + 追更書籍 + 養肥書籍 + 完結書籍 + 本機書籍 + 狀態欄顏色透明 + 沉浸式導航欄 + 導航欄顏色透明 + 放入書架 + 繼續閱讀 + 封面地址 + 覆蓋 + 滑動 + 模擬 + 滾動 + 無動畫 + 此書源使用了進階功能,請到捐贈裡點擊支付寶紅包搜尋碼領取紅包開啟。 + 後台更新換源最新章節 + 開啟則會在軟體打開1分鐘後開始更新 + 書架ToolBar自動隱藏 + 滾動書架時ToolBar自動隱藏與顯示 + 登入 + 登入%s + 成功 + 目前源沒有配置登入地址 + 沒有上一頁 + 沒有下一頁 + + + 源名稱(sourceName) + 源URL(sourceUrl) + 源分組(sourceGroup) + 自訂源分組 + 輸入自訂源分組名稱 + 並發率(concurrentRate) + 分類Url(sortUrl) + 登入URL(loginUrl) + 登入UI(loginUi) + 登入檢查JS(loginCheckJs) + 源注釋(sourceComment) + 搜尋地址(url) + 發現地址規則(url) + 書籍列表規則(bookList) + 書名規則(name) + 詳情頁url規則(bookUrl) + 作者規則(author) + 分類規則(kind) + 簡介規則(intro) + 封面規則(coverUrl) + 最新章節規則(lastChapter) + 字數規則(wordCount) + 書籍URL正則(bookUrlPattern) + 預處理規則(bookInfoInit) + 目錄URL規則(tocUrl) + 允許修改書名作者(canReName) + 目錄下一頁規則(nextTocUrl) + 目錄列表規則(chapterList) + 章節名稱規則(ChapterName) + 章節URL規則(chapterUrl) + VIP標識(isVip) + 更新時間(ChapterInfo) + 正文規則(content) + 正文下一頁URL規則(nextContentUrl) + WebViewJs(webJs) + 圖片樣式(imageStyle) + 取代規則(replaceRegex) + 資源正則(sourceRegex) + + 圖示(sourceIcon) + 列表規則(ruleArticles) + 列表下一頁規則(ruleArticles) + 標題規則(ruleTitle) + guid規則(ruleGuid) + 時間規則(rulePubDate) + 類別規則(ruleCategories) + 描述規則(ruleDescription) + 圖片url規則(ruleImage) + 內容規則(ruleContent) + 樣式(style) + 連結規則(ruleLink) + 校驗關鍵字(checkKeyWord) + 操作(actions) + 購買標誌(isPay) + + + + 沒有書源 + 書籍訊息獲取失敗 + 內容獲取失敗 + 目錄獲取失敗 + 瀏覽網站失敗:%s + 文件讀取失敗 + 載入目錄失敗 + 獲取資料失敗! + 載入失敗\n%s + 沒有網路 + 網路連接超時 + 資料解析失敗 + + + 請求頭(header) + 除錯源 + 二維碼匯入 + 分享選取源 + 掃描二維碼 + 選中時點擊可彈出選單 + 主題 + 主題模式 + 選擇主題模式 + 加入QQ群 + 獲取背景圖片需儲存權限 + 輸入書源網址 + 刪除文件 + 刪除文件成功 + 確定刪除文件嗎? + 手機目錄 + 智慧匯入 + 發現 + 切換顯示樣式 + 匯入本機書籍需儲存權限 + 夜間模式 + E-Ink 模式 + 電子墨水屏模式 + 本軟體需要儲存權限來儲存備份書籍訊息 + 再按一次退出程式 + 匯入本機書籍需儲存權限 + 網路連接不可用 + + + 確認 + 是否確認刪除? + 是否確認刪除 %s? + 是否刪除全部書籍? + 是否同時刪除已下載的書籍目錄? + 掃描二維碼需相機權限 + 朗讀正在執行,不能自動翻頁 + 輸入編碼 + TXT目錄規則 + 打開外部書籍需獲取儲存權限 + 未獲取到書名 + 輸入取代規則網址 + 搜尋列表獲取成功%d + 名稱和URL不能為空 + 圖庫 + 領支付寶紅包 + 沒有獲取到更新地址 + 正在打開首頁,成功自動返回主介面 + 登入成功後請點擊右上角圖示進行首頁訪問測試 + + + 使用正規表示式 + 縮排 + 無縮排 + 一字元縮排 + 二字元縮排 + 三字元縮排 + 四字元縮排 + 選擇資料夾 + 選擇文件 + 沒有發現,可以在書源裡添加。 + 復原預設 + 自訂快取路徑需要儲存權限 + 黑色 + 文章內容為空 + 正在換源請等待… + 目錄列表為空 + 字距 + + 基本 + 搜尋 + 發現 + 詳情 + 目錄 + 正文 + + E-Ink 模式 + 去除動畫,最佳化電紙書使用體驗 + Web服務 + web埠 + 目前埠 %s + 二維碼分享 + 字串分享 + wifi分享 + 請給於儲存權限 + 減速 + 加速 + 上一個 + 下一個 + 音樂 + 音訊 + 啟用 + 啟用JS + 載入BaseUrl + 全部書源 + 輸入不能為空 + 清空發現快取 + 編輯發現 + 切換軟體顯示在桌面的圖示 + 幫助 + 我的 + 閱讀 + %d%% + %d分鐘 + 自動亮度%s + 按頁朗讀 + 朗讀引擎 + 背景圖片 + 背景顏色 + 文字顏色 + 選擇圖片 + 分組管理 + 分組選擇 + 編輯分組 + 移入分組 + 添加分組 + 移除分組 + 建立取代 + 分組 + 分組: %s + 目錄: %s + 啟用發現 + 禁用發現 + 啟用所選 + 禁用所選 + 匯出所選 + 匯出 + 載入目錄 + 載入詳情頁 + 文字轉語音 + WebDav 密碼 + 輸入你的WebDav授權密碼 + 輸入你的伺服器地址 + WebDav 伺服器地址 + WebDav 帳號 + 輸入你的WebDav帳號 + 訂閱源 + 編輯訂閱源 + 篩選 + 篩選發現 + 目前位置: + 精準搜尋 + 正在啟動服務 + + 文件選擇 + 資料夾選擇 + 我是有底線的 + Uri轉Path失敗 + 重新整理封面 + 封面換源 + 選擇本機圖片 + 類型: + 後台 + 正在匯入 + 正在匯出 + 自訂翻頁按鍵 + 上一頁按鍵 + 下一頁按鍵 + 先將書籍加入書架 + 未分組 + 上一句 + 下一句 + 其它目錄 + 文字太多,生成二維碼失敗 + 分享RSS源 + 分享書源 + 自動切換夜間模式 + 夜間模式跟隨系統 + 上級 + 線上朗讀音色 + (%1$d/%2$d) + 顯示訂閱 + 服務已停止 + 正在啟動服務\n具體訊息查看通知欄 + 預設路徑 + 系統資料夾選擇器 + 自帶資料夾選擇器 + 自帶資料夾選擇器 + Android10以上因權限限制可能無法讀寫文件 + 長按文字在操作選單中顯示閱讀·搜尋 + 文字操作顯示搜尋 + 記錄日誌 + 日誌 + 中文簡繁體轉換 + 圖示為向量圖示,Android8.0以前不支援 + 朗讀設定 + 主介面 + 長按選擇文字 + 頁首 + 正文 + 頁尾 + 文字選擇結束位置 + 文字選擇開始位置 + 共用布局 + 瀏覽器 + 匯入預設規則 + 名稱 + 正則 + 更多選單 + + + 系統內建字體樣式 + 刪除來源文件 + 預設一 + 預設二 + 預設三 + 標題 + 靠左 + 居中 + 隱藏 + 加入分組 + 儲存圖片 + 沒有預設路徑 + 設定分組 + 查看目錄 + 導航欄陰影 + 目前陰影大小(elevation): %s + 預設 + 主選單 + 點擊授予權限 + 閱讀需要存取記憶卡權限,請點擊下方的"授予權限"按鈕,或前往“設定”—“應用權限”—打開所需權限。如果授予權限後仍然不正常,請點擊右上角的“選擇資料夾”,使用系統資料夾選擇器。 + 全文朗讀中不能朗讀選中文字 + 擴展到瀏海 + 更新目錄中 + 全程響應耳機按鍵 + 即使退出軟體也響應耳機按鍵 + 開發人員 + 聯繫我們 + 開源許可 + 其它 + legado-top + 關注公眾號 + 微信 + 您的支援是我更新的動力 + 公眾號[开源阅读] + 正在自動換源 + 點擊加入 + + 訊息 + 切換布局 + 文章字重轉換 + 全面屏手勢最佳化 + 禁用返回鍵 + + + 主色調 + 強調色 + 背景色 + 底部操作欄顏色 + 白天 + 白天,主色調 + 白天,強調色 + 白天,背景色 + 白天,底欄色 + 夜間 + 夜間,主色調 + 夜間,強調色 + 夜間,背景色 + 夜間,底欄色 + 自動換源 + 文字兩端對齊 + 文字底部對齊 + 自動翻頁速度 + 地址排序 + 本機和 WebDav 一起備份 + 優先從 WebDav 復原,長按從本機復原 + 選擇舊版備份資料夾 + 已啟用 + 已禁用 + 正在啟動下載 + 該書已在下載列表 + 點擊打開 + 關注[legado-top]點擊廣告支持我 + 微信讚賞碼 + 支付寶 + 支付寶紅包搜尋碼 + 537954522 點擊複製 + 支付寶紅包二維碼 + 支付寶收款二維碼 + QQ收款二維碼 + gedoor,Invinciblelee等,詳情請在github中查看 + 清除已下載書籍和字體快取 + 預設封面 + 復原忽略列表 + 復原時忽略一些內容不復原,方便不同手機配置不同 + 閱讀介面設定 + 分組名稱 + 備註內容 + 預設啟用取代淨化 + 新加入書架的書是否啟用取代淨化 + 選擇復原文件 + 白天背景不能太暗 + 白天底欄不能太暗 + 夜間背景不能太亮 + 夜間底欄不能太亮 + 強調色不能和背景顏色相似 + 強調色不能和文字顏色相似 + 格式不對 + 錯誤 + 顯示亮度調節控制項 + 語言 + 匯入訂閱源 + 您的支援是我更新的動力 + 公眾號[开源阅读软件] + 閱讀記錄 + 閱讀時間記錄 + 本機TTS + 執行緒數 + 總閱讀時間 + 全不選 + 匯入 + 匯出 + 儲存主題配置 + 儲存白天主題配置以供呼叫和分享 + 儲存夜間主題配置以供呼叫和分享 + 主題列表 + 使用儲存主題,匯入,分享主題 + 切換預設主題 + 更新時間排序 + 全文搜尋 + 關注公眾號[开源阅读]獲取訂閱源! + 目前沒有發現源,關注公眾號[开源阅读]添加包含發現的書源! + 將焦點放到輸入框按下物理按鍵會自動輸入鍵值,多個按鍵會自動用英文逗號隔開. + 主題名稱 + 自動清除過期搜尋資料 + 超過一天的搜尋資料 + 重新分段 + 樣式名稱: + 點擊右上角資料夾圖示,選擇資料夾 + 智慧掃描 + 匯入檔案名 + 複製書籍URL + 複製目錄URL + 沒有書籍 + 保留原名 + 點擊區域設定 + 關閉 + 下一頁 + 上一頁 + 無操作 + 正文標題 + 顯示/隱藏 + 頁首頁尾 + 規則訂閱 + 添加大佬們提供的規則匯入地址\n添加後點擊可匯入規則 + 拉取雲端進度 + 目前進度超過雲端進度,是否同步? + 同步閱讀進度 + 進入退出閱讀介面時同步閱讀進度 + 建立書籤失敗 + 單URL + 匯出書單 + 匯入書單 + 預下載 + 預先下載%s章正文 + 是否啟用 + 背景圖片 + 背景圖片虛化 + 虛化半徑 + 0為停用,啓用範圍1~25\n半徑數值越大,虛化效果越高 + 匯出資料夾 + 匯出編碼 + TXT不匯出章節名 + 匯出到WebDav + 反轉內容 + 除錯 + 當機日誌 + 使用自訂中文分行 + 圖片樣式 + 系統TTS + 匯出格式 + 校驗作者 + 搜尋原始碼 + 書籍原始碼 + 目錄原始碼 + 正文原始碼 + 列表原始碼 + 此url已訂閱 + 高刷 + 使用螢幕最高刷新率 + 匯出所有 + 完成 + 顯示未讀標誌 + 總是使用預設封面 + 總是顯示預設封面,不顯示網路封面 + 字號 + 上邊距 + 下邊距 + 顯示 + 隱藏 + 狀態欄顯示時隱藏 + 反轉目錄 + 顯示發現 + 樣式 + 分組樣式 + 匯出檔案名 + 重設 + url為空 + 字典 + 未知錯誤 + 自動備份失敗\n%s + 結束 + 關閉取代分組/開啟添加分組 + 媒體按鈕•上一首|下一首 + 上一段|下一段/上一章|下一章 + 及時翻頁,翻頁時會停頓一下 + 校驗書源顯示詳細資訊 + 書源校驗時顯示網路請求步驟和時間 + 需登入 + 使用Cronet網路元件 + 上傳URL + 下載URL規則 + 反應時間排序 + 匯出成功 + 路徑 + 直鏈上傳規則 + 用於匯出書源書單時生成直鏈url + 複製播放Url + 設定源變數 + 設定書籍變數 + 注釋 + 封面設置 + 設置默認封面樣式 + 顯示書名 + 封面上顯示書名 + 顯示作者 + 封面上顯示作者 + 朗讀上一段 + 朗讀下一段 + 待下載 + 下載完成 + 下載失敗 + 下載中 + 未知狀態 + 禁用源 + 刪除源 + 購買 + 平板/橫屏雙頁 + 瀏覽器打開 + 複製url + 打開方式 + 是否使用外部瀏覽器打開? + 查看 + 打開 + 刪除登錄頭 + 查看登錄頭 + 登錄頭 + 字體大小 + 當前字體大小:%.1f + 語速减 + 語速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + 搜索結果 + + diff --git a/app/src/main/res/values-zh/arrays.xml b/app/src/main/res/values-zh/arrays.xml new file mode 100644 index 000000000..dd54deede --- /dev/null +++ b/app/src/main/res/values-zh/arrays.xml @@ -0,0 +1,94 @@ + + + + 文本 + 音频 + + + + 标签 + 文件夹 + + + + .txt + .json + .xml + + + + 跟随系统 + 亮色主题 + 暗色主题 + E-Ink(墨水屏) + + + + 自动 + 黑色 + 白色 + 跟随背景 + + + + 默认 + 1分钟 + 2分钟 + 3分钟 + 常亮 + + + + iconMain + icon1 + icon2 + icon3 + icon4 + icon5 + icon6 + + + + 关闭 + 繁体转简体 + 简体转繁体 + + + + 系统默认字体 + 系统衬线字体 + 系统等宽字体 + + + + + 标题 + 时间 + 电量 + 页数 + 进度 + 页数及进度 + 书名 + 时间及电量 + + + + 正常 + 粗体 + 细体 + + + + 跟随系统 + 简体中文 + 繁体中文 + 英文 + + + + 书源 + 订阅源 + 替换规则 + + + \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 000000000..7c5c12665 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,915 @@ + + + 阅读 + 阅读·搜索 + 阅读需要访问存储卡权限,请前往“设置”—“应用权限”—打开所需权限 + + + Home + 恢复 + 导入阅读数据 + 创建子文件夹 + 创建legado文件夹作为备份文件夹 + 离线缓存书籍备份 + 导出本地同时备份到legado文件夹下exports目录 + 备份路径 + 请选择备份路径 + 导入旧版数据 + 导入Github数据 + 净化替换 + Send + + 提示 + 取消 + 确定 + 去设置 + 无法跳转至设置界面 + + 点击重试 + 正在加载 + 提醒 + 编辑 + 删除 + 删除所有 + 替换 + 替换净化 + 配置替换净化规则 + 暂无 + 启用 + 替换净化-搜索 + 书架 + 收藏夹 + 收藏 + 已收藏 + 未收藏 + 订阅 + 全部 + 最近阅读 + 最后阅读 + 更新日志 + 书架还空着,先去搜索书籍或从发现里添加吧!\n初次使用请关注公众号[开源阅读]获取书源和教程! + 搜索 + 下载 + 列表 + 网格三列 + 网格四列 + 网格五列 + 网格六列 + 书架布局 + 视图 + 书城 + 添加本地 + 书源 + 书源管理 + 新建/导入/编辑/管理书源 + 设置 + 主题设置 + 与界面/颜色相关的一些设置 + 其它设置 + 与功能相关的一些设置 + 关于 + 捐赠 + 退出 + 尚未保存,是否继续编辑 + 阅读样式设置 + 版本 + 本地 + 搜索 + 来源: %s + 最近: %s + 书名 + 最新: %s + 是否将《%s》放入书架? + 共%s个Text文件 + 加载中… + 重试 + Web 服务 + 浏览器写源,看书 + web编辑书源 + 离线缓存 + 离线缓存 + 缓存选择的章节到本地 + 换源 + + \u3000\u3000这是一款使用Kotlin全新开发的开源的阅读软件,欢迎您的加入。关注公众号[开源阅读]! + + + 阅读3.0下载地址:\nhttps://www.coolapk.com/apk/256030 + + Version %s + 后台校验书源 + 打开后可以在校验书源时自由操作 + 自动刷新 + 打开软件时自动更新书籍 + 自动下载最新章节 + 更新书籍时自动下载最新章节 + 备份与恢复 + WebDav设置 + WebDav设置/导入旧版本数据 + 备份 + 恢复 + 备份请给与存储权限 + 恢复请给与存储权限 + 确认 + 取消 + 确认备份吗? + 新备份会替换原有备份。\n备份文件夹YueDu + 确认恢复吗? + 恢复书架会覆盖现有书架。 + 备份成功 + 备份失败\n%s + 正在恢复 + 恢复成功 + 恢复失败 + 屏幕方向 + 跟随传感器 + 横向 + 竖向 + 跟随系统 + 免责声明 + 共%d章 + 界面 + 亮度 + 目录 + 下一章 + 上一章 + 隐藏状态栏 + 阅读界面隐藏状态栏 + 朗读 + 正在朗读 + 点击打开阅读界面 + 播放 + 正在播放 + 点击打开播放界面 + 播放暂停 + 返回 + 刷新 + 开始 + 停止 + 暂停 + 继续 + 定时 + 朗读暂停 + 正在朗读(还剩%d分钟) + 正在播放(还剩%d分钟) + 阅读界面隐藏虚拟按键 + 隐藏导航栏 + 导航栏颜色 + 评分 + 发送邮件 + 无法打开 + 分享失败 + 无章节 + 添加网址 + 添加书籍网址 + 背景 + 作者 + 作者: %s + 朗读停止 + 清理缓存 + 成功清理缓存 + 保存 + 编辑源 + 编辑书源 + 禁用书源 + 新建书源 + 新建订阅源 + 添加书籍 + 扫描 + 拷贝源 + 粘贴源 + 源规则说明 + 检查更新 + 扫描二维码 + 扫描本地图片 + 规则说明 + 分享 + 软件分享 + 跟随系统 + 添加 + 导入书源 + 本地导入 + 网络导入 + 替换净化 + 替换规则编辑 + 替换规则 + 替换为 + 封面 + + 音量键翻页 + 点击翻页 + 翻页动画 + 翻页动画(本书) + 屏幕超时 + 返回 + 菜单 + 调节 + 滚动条 + 清除缓存会删除所有已保存章节,是否确认删除? + 书源共享 + 替换规则名称 + 替换规则为空或者不满足正则表达式要求 + 选择操作 + 全选 + 全选(%1$d/%2$d) + 取消全选(%1$d/%2$d) + 深色模式 + 启动页 + 开始下载 + 取消下载 + 暂无任务 + 已下载 %1$d/%2$d + 导入选择书籍 + 更新和搜索线程数,太多会卡顿 + 切换图标 + 删除书籍 + 开始阅读 + 加载数据中… + 加载失败,点击重试 + 内容简介 + 简介:%s + 简介: 暂无简介 + 打开外部书籍 + 来源: %s + 导入替换规则 + 导入在线规则 + 检查更新间隔 + 按阅读时间 + 按更新时间 + 按书名 + 手动排序 + 阅读方式 + 排版 + 删除所选 + 是否确认删除? + 默认字体 + 发现 + 发现管理 + 没有内容,去书源里自定义吧! + 删除所有 + 搜索历史 + 清除 + 正文显示标题 + 书源同步 + 无最新章节信息 + 显示时间和电量 + 显示分隔线 + 深色状态栏图标 + 内容 + 拷贝内容 + 一键缓存 + 这是一段测试文字\n\u3000\u3000只是让你看看效果的 + 文字颜色和背景(长按自定义) + 沉浸式状态栏 + 还剩%d章未下载 + 没有选择 + 长按输入颜色值 + 加载中… + 追更区 + 养肥区 + 书签 + 添加书签 + 删除 + 加载超时 + 关注:%s + 已拷贝 + 整理书架 + 这将会删除所有书籍,请谨慎操作。 + 搜索书源 + 搜索订阅源 + 搜索(共%d个书源) + 目录(%d) + 加粗 + 字体 + 文字 + 软件主页 + + + + + 边距 + 上边距 + 下边距 + 左边距 + 右边距 + 校验书源 + 校验所选 + %1$s 进度 %2$d/%3$d + 请安装并选择中文TTS! + TTS初始化失败! + 简繁转换 + 关闭 + 简转繁 + 繁转简 + 翻页模式 + %1$d 项 + 存储卡: + 加入书架 + 加入书架(%1$d) + 成功添加%1$d本书 + 请将字体文件放到SD根目录Fonts文件夹下重新选择 + 默认字体 + 选择字体 + 字号 + 行距 + 段距 + 置顶 + 置顶所选 + 置底 + 置底所选 + 自动展开发现 + 默认展开第一组发现 + 当前线程数 %s + 朗读语速 + 自动翻页 + 停止自动翻页 + 自动翻页间隔 + 书籍信息 + 书籍信息编辑 + 默认打开书架 + 自动跳转最近阅读 + 替换范围,选填书名或者书源url + 分组 + 内容缓存路径 + 系统文件选择器 + 新版本 + 下载更新 + 朗读时音量键翻页 + Tip边距跟随边距调整 + 允许更新 + 禁止更新 + 反选 + 搜索书名、作者 + 书名、作者、URL + 常见问题 + 显示所有发现 + 关闭则只显示勾选源的发现 + 更新目录 + Txt目录正则 + 设置编码 + 倒序-顺序 + 排序 + 智能排序 + 手动排序 + 名称排序 + 滚动到顶部 + 滚动到底部 + 已读: %s + 追更 + 养肥 + 完结 + 所有书籍 + 追更书籍 + 养肥书籍 + 完结书籍 + 本地书籍 + 状态栏颜色透明 + 沉浸式导航栏 + 导航栏颜色透明 + 放入书架 + 继续阅读 + 封面地址 + 覆盖 + 滑动 + 仿真 + 滚动 + 无动画 + 此书源使用了高级功能,请到捐赠里点击支付宝红包搜索码领取红包开启。 + 后台更新换源最新章节 + 开启则会在软件打开1分钟后开始更新 + 书架ToolBar自动隐藏 + 滚动书架时ToolBar自动隐藏与显示 + 登录 + 登录%s + 成功 + 当前源没有配置登陆地址 + 没有上一页 + 没有下一页 + + + 源名称(sourceName) + 源URL(sourceUrl) + 源分组(sourceGroup) + 自定义源分组 + 输入自定义源分组名称 + 并发率(concurrentRate) + 分类Url(sortUrl) + 登录URL(loginUrl) + 登录UI(loginUi) + 登录检查JS(loginCheckJs) + 源注释(sourceComment) + 搜索地址(url) + 发现地址规则(url) + 书籍列表规则(bookList) + 书名规则(name) + 详情页url规则(bookUrl) + 作者规则(author) + 分类规则(kind) + 简介规则(intro) + 封面规则(coverUrl) + 最新章节规则(lastChapter) + 字数规则(wordCount) + 书籍URL正则(bookUrlPattern) + 预处理规则(bookInfoInit) + 目录URL规则(tocUrl) + 允许修改书名作者(canReName) + 目录下一页规则(nextTocUrl) + 目录列表规则(chapterList) + 章节名称规则(ChapterName) + 章节URL规则(chapterUrl) + VIP标识(isVip) + 更新时间(ChapterInfo) + 正文规则(content) + 正文下一页URL规则(nextContentUrl) + WebViewJs(webJs) + 图片样式(imageStyle) + 替换规则(replaceRegex) + 资源正则(sourceRegex) + + 图标(sourceIcon) + 列表规则(ruleArticles) + 列表下一页规则(ruleArticles) + 标题规则(ruleTitle) + guid规则(ruleGuid) + 时间规则(rulePubDate) + 类别规则(ruleCategories) + 描述规则(ruleDescription) + 图片url规则(ruleImage) + 内容规则(ruleContent) + 样式(style) + 链接规则(ruleLink) + 校验关键字(checkKeyWord) + 操作(actions) + 购买标识(isPay) + + + + 没有书源 + 书籍信息获取失败 + 内容获取失败 + 目录获取失败 + 访问网站失败:%s + 文件读取失败 + 加载目录失败 + 获取数据失败! + 加载失败\n%s + 没有网络 + 网络连接超时 + 数据解析失败 + + + 请求头(header) + 调试源 + 二维码导入 + 分享选中源 + 扫描二维码 + 选中时点击可弹出菜单 + 主题 + 主题模式 + 选择主题模式 + 加入QQ群 + 获取背景图片需存储权限 + 输入书源网址 + 删除文件 + 删除文件成功 + 确定删除文件吗? + 手机目录 + 智能导入 + 发现 + 切换显示样式 + 导入本地书籍需存储权限 + 夜间模式 + E-Ink 模式 + 电子墨水屏模式 + 本软件需要存储权限来存储备份书籍信息 + 再按一次退出程序 + 导入本地书籍需存储权限 + 网络连接不可用 + + + 确认 + 是否确认删除? + 是否确认删除 %s? + 是否删除全部书籍? + 是否同时删除已下载的书籍目录? + 扫描二维码需相机权限 + 朗读正在运行,不能自动翻页 + 输入编码 + TXT目录规则 + 打开外部书籍需获取存储权限 + 未获取到书名 + 输入替换规则网址 + 搜索列表获取成功%d + 名称和URL不能为空 + 图库 + 领支付宝红包 + 没有获取到更新地址 + 正在打开首页,成功自动返回主界面 + 登录成功后请点击右上角图标进行首页访问测试 + + + 使用正则表达式 + 缩进 + 无缩进 + 一字符缩进 + 二字符缩进 + 三字符缩进 + 四字符缩进 + 选择文件夹 + 选择文件 + 没有发现,可以在书源里添加。 + 恢复默认 + 自定义缓存路径需要存储权限 + 黑色 + 文章内容为空 + 正在换源请等待… + 目录列表为空 + 字距 + + 基本 + 搜索 + 发现 + 详情 + 目录 + 正文 + + E-Ink 模式 + 去除动画,优化电纸书使用体验 + Web服务 + web端口 + 当前端口 %s + 二维码分享 + 字符串分享 + wifi分享 + 请给于存储权限 + 减速 + 加速 + 上一个 + 下一个 + 音乐 + 音频 + 启用 + 启用JS + 加载BaseUrl + 全部书源 + 输入不能为空 + 清空发现缓存 + 编辑发现 + 切换软件显示在桌面的图标 + 帮助 + 我的 + 阅读 + %d%% + %d分钟 + 自动亮度%s + 按页朗读 + 朗读引擎 + 背景图片 + 背景颜色 + 文字颜色 + 选择图片 + 分组管理 + 分组选择 + 编辑分组 + 移入分组 + 添加分组 + 移除分组 + 新建替换 + 分组 + 分组: %s + 目录: %s + 启用发现 + 禁用发现 + 启用所选 + 禁用所选 + 导出所选 + 导出 + 加载目录 + 加载详情页 + TTS + WebDav 密码 + 输入你的WebDav授权密码 + 输入你的服务器地址 + WebDav 服务器地址 + WebDav 账号 + 输入你的WebDav账号 + 订阅源 + 编辑订阅源 + 筛选 + 筛选发现 + 当前位置: + 精准搜索 + 正在启动服务 + + 文件选择 + 文件夹选择 + 我是有底线的 + Uri转Path失败 + 刷新封面 + 封面换源 + 选择本地图片 + 类型: + 后台 + 正在导入 + 正在导出 + 自定义翻页按键 + 上一页按键 + 下一页按键 + 先将书籍加入书架 + 未分组 + 上一句 + 下一句 + 其它目录 + 文字太多,生成二维码失败 + 分享RSS源 + 分享书源 + 自动切换夜间模式 + 夜间模式跟随系统 + 上级 + 在线朗读音色 + (%1$d/%2$d) + 显示订阅 + 服务已停止 + 正在启动服务\n具体信息查看通知栏 + 默认路径 + 系统文件夹选择器 + 自带文件夹选择器 + 自带文件选择器 + Android10以上因权限限制可能无法读写文件 + 长按文字在操作菜单中显示阅读·搜索 + 文字操作显示搜索 + 记录日志 + 日志 + 中文简繁体转换 + 图标为矢量图标,Android8.0以前不支持 + 朗读设置 + 主界面 + 长按选择文本 + 页眉 + 正文 + 页脚 + 文本选择结束位置 + 文本选择开始位置 + 共用布局 + 浏览器 + 导入默认规则 + 名称 + 正则 + 更多菜单 + + + 系统内置字体样式 + 删除源文件 + 预设一 + 预设二 + 预设三 + 标题 + 靠左 + 居中 + 隐藏 + 加入分组 + 保存图片 + 没有默认路径 + 设置分组 + 查看目录 + 导航栏阴影 + 当前阴影大小(elevation): %s + 默认 + 主菜单 + 点击授予权限 + 阅读需要访问存储卡权限,请点击下方的"授予权限"按钮,或前往“设置”—“应用权限”—打开所需权限。如果授予权限后仍然不正常,请点击右上角的“选择文件夹”,使用系统文件夹选择器。 + 全文朗读中不能朗读选中文字 + 扩展到刘海 + 更新目录中 + 全程响应耳机按键 + 即使退出软件也响应耳机按键 + 开发人员 + 联系我们 + 开源许可 + 其它 + 开源阅读 + 关注公众号 + 微信 + 您的支持是我更新的动力 + 公众号[开源阅读] + 正在自动换源 + 点击加入 + + 信息 + 切换布局 + 文章字重切换 + 全面屏手势优化 + 禁用返回键 + + + 主色调 + 强调色 + 背景色 + 底部操作栏颜色 + 白天 + 白天,主色调 + 白天,强调色 + 白天,背景色 + 白天,底栏色 + 夜间 + 夜间,主色调 + 夜间,强调色 + 夜间,背景色 + 夜间,底栏色 + 自动换源 + 文字两端对齐 + 文字底部对齐 + 自动翻页速度 + 地址排序 + 本地和WebDav一起备份 + 优先从WebDav恢复,长按从本地恢复 + 选择旧版备份文件夹 + 已启用 + 已禁用 + 正在启动下载 + 该书已在下载列表 + 点击打开 + 关注[开源阅读]点击广告支持我 + 微信赞赏码 + 支付宝 + 支付宝红包搜索码 + 537954522 点击复制 + 支付宝红包二维码 + 支付宝收款二维码 + QQ收款二维码 + gedoor,Invinciblelee等,详情请在github中查看 + 清除已下载书籍和字体缓存 + 默认封面 + 恢复忽略列表 + 恢复时忽略一些内容不恢复,方便不同手机配置不同 + 阅读界面设置 + 分组名称 + 备注内容 + 默认启用替换净化 + 新加入书架的书是否启用替换净化 + 选择恢复文件 + 白天背景不能太暗 + 白天底栏不能太暗 + 夜间背景不能太亮 + 夜间底栏不能太亮 + 强调色不能和背景颜色相似 + 强调色不能和文字颜色相似 + 格式不对 + 错误 + 显示亮度调节控件 + 语言 + 导入订阅源 + 您的支持是我更新的动力 + 公众号[开源阅读软件] + 阅读记录 + 阅读时间记录 + 本地TTS + 线程数 + 总阅读时间 + 全不选 + 导入 + 导出 + 保存主题配置 + 保存白天主题配置以供调用和分享 + 保存夜间主题配置以供调用和分享 + 主题列表 + 使用保存主题,导入,分享主题 + 切换默认主题 + 更新时间排序 + 全文搜索 + 搜索结果 + 关注公众号[开源阅读]获取订阅源! + 当前没有发现源,关注公众号[开源阅读]添加带发现的书源! + 将焦点放到输入框按下物理按键会自动录入键值,多个按键会自动用英文逗号隔开. + 主题名称 + 自动清除过期搜索数据 + 超过一天的搜索数据 + 重新分段 + 样式名称: + 点击右上角文件夹图标,选择文件夹 + 智能扫描 + 导入文件名 + 拷贝书籍URL + 拷贝目录URL + 没有书籍 + 保留原名 + 点击区域设置 + 关闭 + 下一页 + 上一页 + 无操作 + 正文标题 + 显示/隐藏 + 页眉页脚 + 规则订阅 + 添加大佬们提供的规则导入地址\n添加后点击可导入规则 + 拉取云端进度 + 当前进度超过云端进度,是否同步? + 同步阅读进度 + 进入退出阅读界面时同步阅读进度 + 创建书签失败 + 单URL + 导出书单 + 导入书单 + 预下载 + 预先下载%s章正文 + 是否启用 + 背景图片 + 背景图片虚化 + 虚化半径 + 0为停用,启用范围1~25\n半径数值越大,虚化效果越高 + 导出文件夹 + 导出编码 + TXT不导出章节名 + 导出到WebDav + 反转内容 + 调试 + 崩溃日志 + 使用自定义中文分行 + 图片样式 + 系统TTS + 导出格式 + 校验作者 + 搜索源码 + 书籍源码 + 目录源码 + 正文源码 + 列表源码 + 此url已订阅 + 高刷 + 使用屏幕最高刷新率 + 导出所有 + 完成 + 显示未读标志 + 总是使用默认封面 + 总是显示默认封面,不显示网络封面 + 字号 + 上边距 + 下边距 + 显示 + 隐藏 + 状态栏显示时隐藏 + 反转目录 + 显示发现 + 样式 + 分组样式 + 导出文件名 + 重置 + url为空 + 字典 + 未知错误 + 自动备份失败\n%s + 结束 + 关闭替换分组/开启添加分组 + 媒体按钮•上一首|下一首 + 上一段|下一段/上一章|下一章 + 及时翻页,翻页时会停顿一下 + 校验显示详细信息 + 书源校验时显示网络请求步骤和时间 + 需登录 + 使用Cronet网络组件 + 上传URL + 下载URL规则 + 响应时间排序 + 导出成功 + 路径 + 直链上传规则 + 用于导出书源书单时生成直链url + 拷贝播放Url + 设置源变量 + 设置书籍变量 + 注释 + 封面设置 + 设置默认封面样式 + 显示书名 + 封面上显示书名 + 显示作者 + 封面上显示作者 + 朗读上一段 + 朗读下一段 + 待下载 + 下载完成 + 下载失败 + 下载中 + 未知状态 + 禁用源 + 删除源 + 购买 + 平板/横屏双页 + 浏览器打开 + 拷贝url + 打开方式 + 是否使用外部浏览器打开? + 查看 + 打开 + 删除登录头 + 查看登录头 + 登录头 + 字体大小 + 当前字体大小:%.1f + 语速减 + 语速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + + diff --git a/app/src/main/res/values/array_values.xml b/app/src/main/res/values/array_values.xml new file mode 100644 index 000000000..3793a651c --- /dev/null +++ b/app/src/main/res/values/array_values.xml @@ -0,0 +1,43 @@ + + + + + ic_launcher + launcher1 + launcher2 + launcher3 + launcher4 + launcher5 + launcher6 + + + + 0 + 60 + 120 + 180 + -1 + + + + 0 + 1 + 2 + 3 + + + + 0 + 1 + 2 + 3 + + + + auto + zh + tw + en + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 000000000..2633c8003 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,115 @@ + + + + Text + Audio + + + + Tab + Folder + + + + @string/indent_0 + @string/indent_1 + @string/indent_2 + @string/indent_3 + @string/indent_4 + + + + .txt + .json + .xml + + + + @string/jf_convert_o + @string/jf_convert_j + @string/jf_convert_f + + + + Follow system + Bright mode + Dark mode + E-Ink mode + + + + Auto + Black + White + Following + + + + Default + 1 min + 2 min + 3 min + Always + + + + @string/screen_unspecified + @string/screen_portrait + @string/screen_landscape + @string/screen_sensor + + + + iconMain + icon1 + icon2 + icon3 + icon4 + icon5 + icon6 + + + + Off + Traditional to Simplified + Simplified to Traditional + + + + Default font + Serif font + Monospaced font + + + + Blank + Heading + Time + Battery + Pages + Progress + Pages and progress + Book name + Time and Battery + + + + Normal + Bold + Light + + + + Auto + Simplified_Chinese + Traditional_Chinese + English + + + + BookSource + RssSource + ReplaceRule + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 000000000..fd11b974c --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..81726a580 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,114 @@ + + + @color/md_light_blue_600 + @color/md_light_blue_700 + @color/md_pink_800 + + @color/md_light_disabled + + #66666666 + + #FF578FCC + + #eb4333 + #439b53 + + #00000000 + + @color/md_grey_50 + @color/md_grey_100 + @color/md_grey_200 + #7fffffff + + #00000000 + + #10000000 + #20000000 + #30000000 + #50000000 + + #d3321b + + #63ACACAC + #63858585 + #632C2C2C + + #737373 + #adadad + + #de000000 + #8a000000 + #8A2C2C2C + #dfdfdf + #383838 + + + #efefef + + #23000000 + #EEEEEE + #aaaaaaaa + + + #8fe0e0e0 + + #19000000 + #f4f4f4 + + + #99343434 + #000000 + #ffffff + + + #ffffffff + #efe0e0e0 + + + + + + #1F000000 + #1F000000 + + #43000000 + #43000000 + #43000000 + + #61000000 + + #8A000000 + #8A000000 + #8A000000 + + #DE000000 + + #FFFAFAFA + + #FFBDBDBD + + #E8E8E8 + + + + #1AFFFFFF + + #1F000000 + + #4DFFFFFF + #4DFFFFFF + #4DFFFFFF + #4DFFFFFF + + #B3FFFFFF + #B3FFFFFF + #B3FFFFFF + + #FFFFFFFF + + #FFBDBDBD + + #FF424242 + + #202020 + diff --git a/app/src/main/res/values/colors_material_design.xml b/app/src/main/res/values/colors_material_design.xml new file mode 100644 index 000000000..a00ddac9e --- /dev/null +++ b/app/src/main/res/values/colors_material_design.xml @@ -0,0 +1,338 @@ + + + + + + + @color/md_grey_50 + + #1F000000 + + #61000000 + + #8A000000 + #8A000000 + + #DE000000 + + @color/md_grey_300 + @color/md_grey_100 + @color/md_white_1000 + @color/md_white_1000 + + + @color/md_grey_850 + + #1FFFFFFF + + #4DFFFFFF + + #B3FFFFFF + #B3FFFFFF + + #FFFFFFFF + + @color/md_black_1000 + @color/md_grey_900 + @color/md_grey_800 + @color/md_grey_800 + + + #FFEBEE + #FFCDD2 + #EF9A9A + #E57373 + #EF5350 + #F44336 + #E53935 + #D32F2F + #C62828 + #B71C1C + #FF8A80 + #FF5252 + #FF1744 + #D50000 + + + #FCE4EC + #F8BBD0 + #F48FB1 + #F06292 + #EC407A + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + #FF80AB + #FF4081 + #F50057 + #C51162 + + + #F3E5F5 + #E1BEE7 + #CE93D8 + #BA68C8 + #AB47BC + #9C27B0 + #8E24AA + #7B1FA2 + #6A1B9A + #4A148C + #EA80FC + #E040FB + #D500F9 + #AA00FF + + + #EDE7F6 + #D1C4E9 + #B39DDB + #9575CD + #7E57C2 + #673AB7 + #5E35B1 + #512DA8 + #4527A0 + #311B92 + #B388FF + #7C4DFF + #651FFF + #6200EA + + + #E8EAF6 + #C5CAE9 + #9FA8DA + #7986CB + #5C6BC0 + #3F51B5 + #3949AB + #303F9F + #283593 + #1A237E + #8C9EFF + #536DFE + #3D5AFE + #304FFE + + + #E3F2FD + #BBDEFB + #90CAF9 + #64B5F6 + #42A5F5 + #2196F3 + #1E88E5 + #1976D2 + #1565C0 + #0D47A1 + #82B1FF + #448AFF + #2979FF + #2962FF + + + #E1F5FE + #B3E5FC + #81D4FA + #4FC3F7 + #29B6F6 + #03A9F4 + #039BE5 + #0288D1 + #0277BD + #01579B + #80D8FF + #40C4FF + #00B0FF + #0091EA + + + #E0F7FA + #B2EBF2 + #80DEEA + #4DD0E1 + #26C6DA + #00BCD4 + #00ACC1 + #0097A7 + #00838F + #006064 + #84FFFF + #18FFFF + #00E5FF + #00B8D4 + + + #E0F2F1 + #B2DFDB + #80CBC4 + #4DB6AC + #26A69A + #009688 + #00897B + #00796B + #00695C + #004D40 + #A7FFEB + #64FFDA + #1DE9B6 + #00BFA5 + + + #E8F5E9 + #C8E6C9 + #A5D6A7 + #81C784 + #66BB6A + #4CAF50 + #43A047 + #388E3C + #2E7D32 + #1B5E20 + #B9F6CA + #69F0AE + #00E676 + #00C853 + + + #F1F8E9 + #DCEDC8 + #C5E1A5 + #AED581 + #9CCC65 + #8BC34A + #7CB342 + #689F38 + #558B2F + #33691E + #CCFF90 + #B2FF59 + #76FF03 + #64DD17 + + + #F9FBE7 + #F0F4C3 + #E6EE9C + #DCE775 + #D4E157 + #CDDC39 + #C0CA33 + #AFB42B + #9E9D24 + #827717 + #F4FF81 + #EEFF41 + #C6FF00 + #AEEA00 + + + #FFFDE7 + #FFF9C4 + #FFF59D + #FFF176 + #FFEE58 + #FFEB3B + #FDD835 + #FBC02D + #F9A825 + #F57F17 + #FFFF8D + #FFFF00 + #FFEA00 + #FFD600 + + + #FFF8E1 + #FFECB3 + #FFE082 + #FFD54F + #FFCA28 + #FFC107 + #FFB300 + #FFA000 + #FF8F00 + #FF6F00 + #FFE57F + #FFD740 + #FFC400 + #FFAB00 + + + #FFF3E0 + #FFE0B2 + #FFCC80 + #FFB74D + #FFA726 + #FF9800 + #FB8C00 + #F57C00 + #EF6C00 + #E65100 + #FFD180 + #FFAB40 + #FF9100 + #FF6D00 + + + #FBE9E7 + #FFCCBC + #FFAB91 + #FF8A65 + #FF7043 + #FF5722 + #F4511E + #E64A19 + #D84315 + #BF360C + #FF9E80 + #FF6E40 + #FF3D00 + #DD2C00 + + + #EFEBE9 + #D7CCC8 + #BCAAA4 + #A1887F + #8D6E63 + #795548 + #6D4C41 + #5D4037 + #4E342E + #3E2723 + + + #FAFAFA + #F5F5F5 + #EEEEEE + #E0E0E0 + #BDBDBD + #9E9E9E + #757575 + #616161 + #424242 + #303030 + #212121 + + + #ECEFF1 + #CFD8DC + #B0BEC5 + #90A4AE + #78909C + #607D8B + #546E7A + #455A64 + #37474F + #263238 + + + #000000 + #FFFFFF + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 000000000..f7ecf2030 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,39 @@ + + + 16dp + 16dp + 8dp + 176dp + 16dp + + 14sp + 16sp + 18sp + + 24dp + + 0.8dp + 10dp + + 18sp + + 4dp + + 44dp + 88dp + 48sp + 16dp + + 40dp + 8dp + 0dp + + 2dp + + 8dp + 8dp + 8dp + 8dp + + 8dp + \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 000000000..c80e30395 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/non_translat.xml b/app/src/main/res/values/non_translat.xml new file mode 100644 index 000000000..a60fa193b --- /dev/null +++ b/app/src/main/res/values/non_translat.xml @@ -0,0 +1,22 @@ + + + bookshelf_px + 开源阅读 + + https://alanskycn.gitee.io/teachme/ + https://github.com/gedoor/legado + https://github.com/gedoor/legado/graphs/contributors + https://gedoor.github.io/MyBookshelf/disclaimer.html + https://gedoor.github.io/MyBookshelf/ + https://github.com/gedoor/legado/releases/latest + https://api.github.com/repos/gedoor/legado/releases/latest + https://t.me/legado_channels + https://discord.gg/qDE52P5xGW + + http://%1$s:%2$d + GitHub + 【%s】 + 🔒%s + 🔓%s + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..279aa914b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,916 @@ + + + + Legado + Legado·search + Legado needs storage access to find and read books. please go "App Settings" to allow "Storage permission". + + + Home + Restore + Import Legado data + Create a subfolder + Create a folder named Legado as the backup folder. + Offline cache book backup + Export to local and back up to the exports directory under the legado folder + Backup to + Please select a backup path. + Import legacy data + Import github data + Replacement + Send + + Prompt + Cancel + Confirm + Go to Settings + Cannot jump to Settings. + + Retry + Loading + Warning + Edit + Delete + Delete all + Replace + Replacement + Configure replacement rules + Not available now + Enable + Search Replacement + Bookshelf + Favorites + Favorite + in Favorites + Not in Favorites + Subscription + All + Recent reading + Last reading + What\'s new + The bookshelf is still empty. Search for books or add them from discovery! \n if you use it for the first time, please pay attention to the public account [开源阅读] to get the book source! + Search + Download + List + Grid-3 + Grid-4 + Grid-5 + Grid-6 + Layout + View + Library + Import books + Book sources + Sources management + Create/Import/Edit/Manage Book sources + Settings + Theme settings + Some settings related to interface or color + Other settings + Some function-related settings + About + Donations + Exit + It has not been saved. Do you want to continue editing? + Book styles + Version + Local + Search + Origin: %s + Origin: %s + Title + Latest: %s + Would you like to add %s to your Bookshelf? + %s text file(s) in total + Loading… + Retry + Web service + Web edit source and read book + Edit book sources on the web + Offline cache + Offline cache + cache the selected chapter(s) to Storage + Change Origin + + \u3000\u3000 This is an open source reading software newly developed by Kotlin, welcome to join us. Follow the WeChat Official Account [开源阅读]! + + + Legado (YueDu 3.0) download link:\n https://play.google.com/store/apps/details?id=io.legado.play.release + + Version %s + Background-verification + you can operate freely when verifying the book source + Auto-refresh + Update books automatically when opening the software + Auto-download + Download the latest chapters automatically when updating books + Backup and restore + WebDav settings + WebDav settings/Import legacy data + Backup + Restore + Backup needs storage permission + Restore needs storage permission + OK + Cancel + Backup confirmation + The new backup files will replace the original.\n Backup folder: YueDu + Restore confirmation + Restoring the bookshelf data will overwrite the current Bookshelf. + Backup succeed + Backup failed\n%s + Restoring + Restore succeed + Backup failed + Screen orientation + Auto(sensor) + Landscape + Portrait + Follow system + Disclaimer + %d chapters + Interface + Brightness + Chapters + Next + Prior + Hide status bar + Hide system navigation bar when reading + Speech + Speaking + Click to open the Reading + Play + Playing + Click to open the Playing + Pause + Back + Refresh + Start + Stop + Pause + Resume + Timer + Speak Paused + Speaking(%d min left) + Playing(%d min left) + Hide virtual buttons when reading + Hide navigation bar + Navigation bar color + Rating + Email + Open failed + Share failed + No chapters + Add url + Add book url + Background + Author + Author: %s + Speak Stopped + Clear cache + Cache cleared + Save + Edit source + Edit Book source + Disable Book source + Add Book source + Add subscription source + Add books + Scan + Copy source + Paste source + Source rules description + Check for Updates + Scan QR code + Scan local images + Rules description + Share + Share to + Follow system + Add + Import book sources + Import local + Import online + Replacement + Edit replacement rule + Pattern + Replacement + Cover + Book + Volume keys to turn page + Tap screen to turn page + Flip animation + Flip animation (book) + Keep screen awake + Back + Menu + Adjust + Scroll bar + Clearing the cache will delete all saved chapters. Are you sure to delete it? + Book sources sharing + Rule name + Pattern rule is empty or does not conform the regex specification. + Selection action + Select all + Select all(%1$d/%2$d) + Cancel select all(%1$d/%2$d) + Dark mode + Welcome page + Download start + Download cancel + No download + Downloaded %1$d/%2$d + Import selected book(s) + Number of concurrent tasks + Change icon + Remove + Start reading + Loading… + Load failed, tap to retry + Book description + Description:%s + Description: no introduction + Open external book + Origin: %s + Import replace rules + Import online rules + Check interval for updates + By recent list + By update time + By book title + By sort manually + Reading strategy + Typesetting + Delete selected + Are you sure to delete? + Default font + Discovery + Discovery + No content.Go to Sources management to add it! + Delete all + Search history + Clear + Display book title on text + Book Sources sync + No latest chapter. + Display time and battery + Display divider + Darken the status bar\'s icon color + Content + Copy + Download all + This is a test text, \n\u3000\u3000 just to show you the effect + Color and background (long tap to customize) + Immersive status bar + %d chapter(s) left + No selected + Long tap to input color value + Loading… + Awaiting + Awaiting more + Bookmarks + Add to Bookmarks + Delete + Loading timeout + Follow:%s + Copied successfully + Bookshelf arrangement + It will delete all books. Be careful,please. + Search book sources + Search subscription sources + Search( %d sources in total) + Chapters(%d) + Bold + Font + Text + Home page + Right + Left + Bottom + Top + Padding + Padding top + Padding bottom + Padding left + Padding right + Check book sources + Check the selected source + %1$s Progress %2$d/%3$d + Please install and select Chinese TTS! + TTS initialization failed! + Simplified conversion + Off + Simplified to traditional + Traditional to simplified + Flipping mode + %1$d items + Storage: + Add to Bookshelf + Add to Bookshelf(%1$d) + %1$d books added successfully + Please put the font files in the Fonts folder of the storage root directory and reselect + Default font + Select fonts + Text size + Line spacing + Paragraph spacing + To Top + Selection To Top + To Bottom + Selection To Bottom + Auto expand Discovery + Default expand the first Discovery. + Current threads %s + Speech rate + Auto scroll + Stop Auto scroll + Auto scroll speed + Book information + Edit book information + Use Bookshelf as start page + Auto jump to Recent list + Replacement object. Book name or source url is available + Groups + Cache path + System file picker + New version + Download updates + Volume keys to turn page when reading + Margin adjustment + Enable update + Disable update + Inverse + Search book name/author + Book name,Author,URL + FAQ + Display all Discovery + Display the selected origin\'s Discovery if closed + Update chapters + Txt Chapters Regex + Text encoding + Ascending/Descending order + Sort + Sort automatically + Sort manually + Sort by name + Scroll to the top + Scroll to the bottom + Read: %s + Awaiting update + Awaiting more + Finished + All + Awaiting update books + Awaiting more chapters books + Finished books + Local books + The status bar color becomes transparent + immersion navigation bar + The navigation bar becomes transparent + Add to Bookshelf + Continue reading + Cover path + Cover + Slide + Simulation + Scroll + None + This book source uses advanced features, please go to the Donations and tap the Alipay red envelope search code to receive the red envelope,then you can use it. + Update the latest chapter after changed origin in the background + if enabled,the update will start 1 minute later when the software is run + Auto hide ToolBar + The toolbar will be hidden automatically when scroll the Bookshelf + Login + Login%s + Success + The current source has not configured with a login address + No prior page + No next page + + + 源名称(sourceName) + 源URL(sourceUrl) + 源分组(sourceGroup) + 自定义源分组 + 输入自定义源分组名称 + 并发率(concurrentRate) + 分类Url(sortUrl) + 登录URL(loginUrl) + Login UI(loginUi) + 登录检查JS(loginCheckJs) + 源注释(sourceComment) + 搜索地址(url) + 发现地址规则(url) + 书籍列表规则(bookList) + 书名规则(name) + 详情页url规则(bookUrl) + 作者规则(author) + 分类规则(kind) + 简介规则(intro) + 封面规则(coverUrl) + 最新章节规则(lastChapter) + 字数规则(wordCount) + 书籍URL正则(bookUrlPattern) + 预处理规则(bookInfoInit) + 目录URL规则(tocUrl) + 允许修改书名作者(canReName) + 目录下一页规则(nextTocUrl) + 目录列表规则(chapterList) + 章节名称规则(ChapterName) + 章节URL规则(chapterUrl) + VIP标识(isVip) + 更新时间(ChapterInfo) + 正文规则(content) + 正文下一页URL规则(nextContentUrl) + WebViewJs(webJs) + 资源正则(sourceRegex) + 替换规则(replaceRegex) + 图片样式(imageStyle) + + 图标(sourceIcon) + 列表规则(ruleArticles) + 列表下一页规则(ruleArticles) + 标题规则(ruleTitle) + guid规则(ruleGuid) + 时间规则(rulePubDate) + 类别规则(ruleCategories) + 描述规则(ruleDescription) + 图片url规则(ruleImage) + 内容规则(ruleContent) + 样式(style) + 链接规则(ruleLink) + 校验关键字(checkKeyWord) + 操作(actions) + 购买标识(isPay) + + + + No source + Failed to obtain book information + Failed to obtain content + Failed to obtain chapters list + Failed to access website:%s + Failed to read file + Failed to load chapters list + Failed to get data + Failed to load\n%s + No network + Network connection timeout + Data parsing failed + + + HTTP Header + Debug source + Import from QR code + Share selected sources + Scan QR code + Tap to display Menu when selected + Theme + Theme mode + Select a theme you want + Join QQ group + Set the background image requires storage permission + Input book source address + Delete file + Deleted file + Are you sure to delete this file? + Directory + Intelligent import + Discovery + Switch display styles + Import local books requires storage permission + Night Theme + E-Ink + Optimization for E-ink devices + This software requires storage permission to store the book information to be backed up + Tap again to exit the program + Import local books requires storage permission + Network connection is not available + Yes + No + OK + Are you sure to delete it? + Are you sure to delete %s? + Are you sure to delete all books? + Do you want to delete the downloaded book chapters at the same time? + Scan QR code requires Camera permissions + Speech is running, cannot turn pages automatically + Input encoding + Txt Chapters Regex + Open local books requires storage permission + No bookName + Input replacement rule URL + Search list obtained successfully%d + name and URL cannot be empty + Gallery + get AliPay red envelopes + No update address + Opening the homepage, it will return to start page automatically after success + After successful login, please tap the icon on the upper right corner to test the homepage access + Chapter + To + Using Regex + Indent + None + Indent with 1 chars + Indent with 2 chars + Indent with 3 chars + Indent with 4 chars + Select a folder + Select a file + No Discovery, you can add it in BookSource + Restore default + Custom cache path requires Storage permission + Black + No content + Changing source, wait please + Chapters is empty + Word spacing + + Basic + Search + Discovery + Information + Chapters + Content + + E-Ink mode + Remove animations and optimize the experience of using E-paper books + Web service + Web port + Current port %s + QR code sharing + Strings sharing + Wifi sharing + Please grant Storage Permission + Speed down + Speed up + Prior + Next + Music + Audio + Enable + Enable js + Load BaseUrl + All Sources + The input content cannot be empty + Clear Discovery cache + Edit Discovery + Switch the software icon displayed on the desktop + Help + Me + Read + %d%% + %d min + Auto-Brightness %s + Speak by pages + Speak Engine + Background images + Background color + Text color + Select a picture + Group management + Group selection + Group editing + Move to group + Add to Groups + Remove from Groups + New replacement + Group + Group: %s + Chapters: %s + Enable Discovery + Disable Discovery + Enable selected + Disable selected + Export selected + Export + Load chapters + Load book detail + TTS + WebDav password + Input you WebDav authorized password + Input you server address + WebDav server address + WebDav account + Input your WebDav account + Subscription source + Edit Subscription source + Filter + Search Discovery sources + Current location: + Precise search + Starting service + Empty + File selection + Folder selection + I AM OVER! + Uri To Path failed + Refresh cover + Change origin + Local image + Type: + Background + Importing + Exporting + Set page-turning buttons + Page up button + Page down button + Add this book to Bookshelf first + Ungrouped + Prior sentence + Next sentence + Other folder + There are too many words to create a QR code + Subscription sources sharing + Book sources sharing + Automatic switching dark mode + Following system dark mode + Go back + Online Speech tone + (%1$d/%2$d) + Display Subscription + Service stopped + Starting service\nChecking notification bar for details + Default path + System folder picker + App folder picker + App file picker + Android 10+ unable to read and write file due to permission restrictions + Long tap to display Legado·Search in the operation menu + Text operation display Search + Log + Log + Simplified conversion + The icon is a vector icon, which was not supported before Android 8.0 + Speech settings + Start page + Long Tap to select text + Header + Content + Footer + Select end + Select start + Shared layout + Browser + Import default rules + Name + Regex + More menu + Minus + Plus + System typeface + Delete source file + Default-1 + Default-2 + Default-3 + Title + Left + Center + Hide + Add to Group + Save image + No default path + Group settings + View Chapters + Navigation bar shadow + Current shadow size(elevation): %s + Default + Main menu + Tap to grant permission + Legado needs Storage permission, please tap the "Grant Permission" button below, or go to "Settings"-"Application Permissions"-to open the required permission. If the permission is still not work, please tap "Select Folder" in the upper right corner to use the system folder picker. + The selected text cannot be spoken in full text speech + Extend to cutout + Updating Chapters + Headset buttons are always available + Headset buttons are available even exit the app. + Contributors + Contact + License + Other + 开源阅读 + Follow WeChat Official Accounts + WeChat + Supporting me will be appreciated + Official Accounts[开源阅读] + Changing source + Tap to join + Middle + Information + Switch Layout + Text font weight switching + Full screen gestures support + Disable return key + + + Primary + Accent + Background color + NavBar color + Day + Day,Primary + Day,Accent + Day,Background color + Day,NavBar color + Night + Night,Primary + Night,Accent + Night,Background color + Night,NavBar color + Change source automatically + Text justified + Text align bottom + Auto scroll speed + Sort by URL + Backup the local and WebDav simultaneously + Restore from WebDAV first, Restore form the local backup on long click + Select a legacy backup folder + Enabled + Disabled + Starting download + This book is already in Download list + Click to open + Follow [开源阅读] to support me by clicking on ads + WeChat Tipping Code + AliPay + AliPay red envelope search code + 537954522 Click to copy + AliPay red envelope QR code + AliPay QR code + QQ Collection QR code + gedoor,Invinciblelee etc. Checking in github for details + Clear the cache of the downloaded books and fonts + Default cover + Bypass list + Ignore some contents while restoring + Reading interface settings + Group name + Remarks section + Enable replace rule by default + For new added books + Select restore file + Day background can not be too dark! + Day bottom can not be too dark! + Night background can not be too bright! + Night bottom can not be too bright! + Need Difference between accent and background color + Need Difference between accent and text color + Wrong format + Error + Show brightness widget + Language + Import rss source + Your donation makes this app better + Wechat official account [开源阅读软件] + Read record + Read record summary + Local TTS + Thread count + Total read time + Unselect all + Import + Export + Save theme config + Save day theme config + Save night theme config + Theme list + Save, Import, Share theme + Switch default theme + Sort by update time + Sort by respond time + Search content + Follow Wechat official account [开源阅读] to get Subscription source + Empty now,Follow Wechat official account [开源阅读] to get Discovery source + 将焦点放到输入框按下物理按键会自动录入键值,多个按键会自动用英文逗号隔开. + Theme name + "Clear expired search histories automatically " + Search histories more than one day + Re-segment + Style name: + Click the folder icon in the upper right corner and select the folder + Intelligent scanning + Imported-file name + No books + Keep the original name + Screen touch control + Close + Next page + Prior page + None + Title + Show/Hide + footer header + Rule Subscription + 添加大佬们提供的规则导入地址\n添加后点击可导入规则 + Pull the cloud progress + The current progress exceeds the cloud progress. Do you want to synchronize? + Synchronous reading progress + Synchronize reading progress when entering / exiting the reading interface + Failed to create bookmark + Single URL + Export the list of books + Import the list of books + Download in advance + Download %s chapters in advance + Is enabled + Background image + Background image blurring + Blurring radius + Disabled when 0, enable range from 1 to 25\nThe greater the radius, the stronger the effect of blurring + Copy book URL + Copy chapters URL + Export folder + Exported text coding + Export to WebDav + Reverse content + Debug + Crash log + Custom Chinese line feed + Style of Images + System tts + Exported file format + Check by author + This URL has subscribed + High screen refresh rate + Use maximum screen refresh rate + Export all + Finished + Show unread flag + Always show default cover + Always show the default cover, do not show the network cover + Search source code + Book source code + Chapters source code + Content source code + List source code + Font size + Margin top + Marigin bottom + Show + Hide + Hide when status bar show + Reverse toc + Show + Style + Group style + Export file name + Reset + Null url + dict + unknown error + No export chapter names + Autobackup failed\n%s + end + 关闭替换分组/开启添加分组 + 媒体按钮•上一首|下一首 + 上一段|下一段/上一章|下一章 + 及时翻页,翻页时会停顿一下 + Check book source shows debug message + Show network status and timestamp during source checking + need login + use Cronet access network + upload url + 下载URL规则 + export success + path + 直链上传规则 + 用于导出书源书单时生成直链url + 拷贝播放Url + 设置源变量 + 设置书籍变量 + summary + cover config + 设置默认封面样式 + show name + 封面上显示书名 + show author + 封面上显示作者 + 朗读上一段 + 朗读下一段 + wait download + download success + download failure + downloading + unknown + disable source + delete source + pay + 平板/横屏双页 + open in browser + copy url + open function + 是否使用外部浏览器打开? + see + open + del login header + show login header + login header + font scale + font scale:%.1f + search result + 语速减 + 语速加 + 打开系统文件夹选择器出错,自动打开应用文件夹选择器 + 展开文本选择菜单 + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..c397d6ed4 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,132 @@ + + + //**************************************************************Theme******************************************************************************// + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + //*******************Widget Style**********************************// + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/about.xml b/app/src/main/res/xml/about.xml new file mode 100644 index 000000000..e961053bd --- /dev/null +++ b/app/src/main/res/xml/about.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/donate.xml b/app/src/main/res/xml/donate.xml new file mode 100644 index 000000000..1b4d7af60 --- /dev/null +++ b/app/src/main/res/xml/donate.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..779108dbf --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..99b392d5a --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_config_aloud.xml b/app/src/main/res/xml/pref_config_aloud.xml new file mode 100644 index 000000000..b61573d91 --- /dev/null +++ b/app/src/main/res/xml/pref_config_aloud.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_config_backup.xml b/app/src/main/res/xml/pref_config_backup.xml new file mode 100644 index 000000000..6a57dcc87 --- /dev/null +++ b/app/src/main/res/xml/pref_config_backup.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_config_cover.xml b/app/src/main/res/xml/pref_config_cover.xml new file mode 100644 index 000000000..c48c27d8f --- /dev/null +++ b/app/src/main/res/xml/pref_config_cover.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_config_other.xml b/app/src/main/res/xml/pref_config_other.xml new file mode 100644 index 000000000..ab979fc40 --- /dev/null +++ b/app/src/main/res/xml/pref_config_other.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_config_read.xml b/app/src/main/res/xml/pref_config_read.xml new file mode 100644 index 000000000..4b26da4b7 --- /dev/null +++ b/app/src/main/res/xml/pref_config_read.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_config_theme.xml b/app/src/main/res/xml/pref_config_theme.xml new file mode 100644 index 000000000..602235647 --- /dev/null +++ b/app/src/main/res/xml/pref_config_theme.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml new file mode 100644 index 000000000..e4e3d3cee --- /dev/null +++ b/app/src/main/res/xml/pref_main.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 000000000..2e6e4f6cb --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/spen_remote_actions.xml b/app/src/main/res/xml/spen_remote_actions.xml new file mode 100644 index 000000000..f38cfebe8 --- /dev/null +++ b/app/src/main/res/xml/spen_remote_actions.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/io/legado/app/ExampleUnitTest.kt b/app/src/test/java/io/legado/app/ExampleUnitTest.kt new file mode 100644 index 000000000..0ee23db52 --- /dev/null +++ b/app/src/test/java/io/legado/app/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package io.legado.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/avd.bat b/avd.bat new file mode 100644 index 000000000..9c6c97af0 --- /dev/null +++ b/avd.bat @@ -0,0 +1 @@ +emulator -avd %1 -dns-server 8.8.8.8 -no-snapshot-load \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..fa6f2ecf4 --- /dev/null +++ b/build.gradle @@ -0,0 +1,31 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '1.6.10' + repositories { + google() + mavenCentral() + maven { url 'https://plugins.gradle.org/m2/' } + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } + } + dependencies { + classpath 'com.android.tools.build:gradle:7.0.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'de.timfreiheit.resourceplaceholders:placeholders:0.4' + classpath 'de.undercouch:gradle-download-task:4.1.2' + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://jitpack.io' } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/epublib/.gitignore b/epublib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/epublib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/epublib/build.gradle b/epublib/build.gradle new file mode 100644 index 000000000..44439e495 --- /dev/null +++ b/epublib/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'com.android.library' +} + +android { + compileSdkVersion 31 + buildToolsVersion '31.0.0' + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 31 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + android { + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation "androidx.annotation:annotation:1.3.0" +} \ No newline at end of file diff --git a/epublib/consumer-rules.pro b/epublib/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/epublib/proguard-rules.pro b/epublib/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/epublib/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/epublib/src/main/AndroidManifest.xml b/epublib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f8f3d7bea --- /dev/null +++ b/epublib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/epublib/src/main/java/me/ag2s/epublib/Constants.java b/epublib/src/main/java/me/ag2s/epublib/Constants.java new file mode 100644 index 000000000..d9f873e41 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/Constants.java @@ -0,0 +1,13 @@ +package me.ag2s.epublib; + + +public interface Constants { + + String CHARACTER_ENCODING = "UTF-8"; + String DOCTYPE_XHTML = ""; + String NAMESPACE_XHTML = "http://www.w3.org/1999/xhtml"; + String EPUB_GENERATOR_NAME = "Ag2S EpubLib"; + String EPUB_DUOKAN_NAME = "DK-SONGTI"; + char FRAGMENT_SEPARATOR_CHAR = '#'; + String DEFAULT_TOC_ID = "toc"; +} diff --git a/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationEvent.java b/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationEvent.java new file mode 100644 index 000000000..9b52e01cb --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationEvent.java @@ -0,0 +1,158 @@ +package me.ag2s.epublib.browsersupport; + +import java.util.EventObject; + +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.util.StringUtil; + +/** + * Used to tell NavigationEventListener just what kind of navigation action + * the user just did. + * + * @author paul + * + */ +@SuppressWarnings("unused") +public class NavigationEvent extends EventObject { + + private static final long serialVersionUID = -6346750144308952762L; + + private Resource oldResource; + private int oldSpinePos; + private Navigator navigator; + private EpubBook oldBook; + private int oldSectionPos; + private String oldFragmentId; + + public NavigationEvent(Object source) { + super(source); + } + + public NavigationEvent(Object source, Navigator navigator) { + super(source); + this.navigator = navigator; + this.oldBook = navigator.getBook(); + this.oldFragmentId = navigator.getCurrentFragmentId(); + this.oldSectionPos = navigator.getCurrentSectionPos(); + this.oldResource = navigator.getCurrentResource(); + this.oldSpinePos = navigator.getCurrentSpinePos(); + } + + /** + * The previous position within the section. + * + * @return The previous position within the section. + */ + public int getOldSectionPos() { + return oldSectionPos; + } + + public Navigator getNavigator() { + return navigator; + } + + public String getOldFragmentId() { + return oldFragmentId; + } + + // package + void setOldFragmentId(String oldFragmentId) { + this.oldFragmentId = oldFragmentId; + } + + public EpubBook getOldBook() { + return oldBook; + } + + // package + void setOldPagePos(int oldPagePos) { + this.oldSectionPos = oldPagePos; + } + + public int getCurrentSectionPos() { + return navigator.getCurrentSectionPos(); + } + + public int getOldSpinePos() { + return oldSpinePos; + } + + public int getCurrentSpinePos() { + return navigator.getCurrentSpinePos(); + } + + public String getCurrentFragmentId() { + return navigator.getCurrentFragmentId(); + } + + public boolean isBookChanged() { + if (oldBook == null) { + return true; + } + return oldBook != navigator.getBook(); + } + + public boolean isSpinePosChanged() { + return getOldSpinePos() != getCurrentSpinePos(); + } + + public boolean isFragmentChanged() { + return StringUtil.equals(getOldFragmentId(), getCurrentFragmentId()); + } + + public Resource getOldResource() { + return oldResource; + } + + public Resource getCurrentResource() { + return navigator.getCurrentResource(); + } + + public void setOldResource(Resource oldResource) { + this.oldResource = oldResource; + } + + + public void setOldSpinePos(int oldSpinePos) { + this.oldSpinePos = oldSpinePos; + } + + + public void setNavigator(Navigator navigator) { + this.navigator = navigator; + } + + + public void setOldBook(EpubBook oldBook) { + this.oldBook = oldBook; + } + + public EpubBook getCurrentBook() { + return getNavigator().getBook(); + } + + public boolean isResourceChanged() { + return oldResource != getCurrentResource(); + } + + @SuppressWarnings("NullableProblems") + public String toString() { + return StringUtil.toString( + "oldSectionPos", oldSectionPos, + "oldResource", oldResource, + "oldBook", oldBook, + "oldFragmentId", oldFragmentId, + "oldSpinePos", oldSpinePos, + "currentPagePos", getCurrentSectionPos(), + "currentResource", getCurrentResource(), + "currentBook", getCurrentBook(), + "currentFragmentId", getCurrentFragmentId(), + "currentSpinePos", getCurrentSpinePos() + ); + } + + public boolean isSectionPosChanged() { + return oldSectionPos != getCurrentSectionPos(); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationEventListener.java b/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationEventListener.java new file mode 100644 index 000000000..a856347ce --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationEventListener.java @@ -0,0 +1,18 @@ +package me.ag2s.epublib.browsersupport; + +/** + * Implemented by classes that want to be notified if the user moves to + * another location in the book. + * + * @author paul + * + */ +public interface NavigationEventListener { + + /** + * Called whenever the user navigates to another position in the book. + * + * @param navigationEvent f + */ + void navigationPerformed(NavigationEvent navigationEvent); +} \ No newline at end of file diff --git a/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationHistory.java b/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationHistory.java new file mode 100644 index 000000000..710f0240c --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/browsersupport/NavigationHistory.java @@ -0,0 +1,207 @@ +package me.ag2s.epublib.browsersupport; + +import java.util.ArrayList; +import java.util.List; + +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Resource; + +/** + * A history of the user's locations with the epub. + * + * @author paul.siegmann + */ +public class NavigationHistory implements NavigationEventListener { + + public static final int DEFAULT_MAX_HISTORY_SIZE = 1000; + private static final long DEFAULT_HISTORY_WAIT_TIME = 1000; + + private static class Location { + + private String href; + + public Location(String href) { + super(); + this.href = href; + } + + @SuppressWarnings("unused") + public void setHref(String href) { + this.href = href; + } + + public String getHref() { + return href; + } + } + + private long lastUpdateTime = 0; + private List locations = new ArrayList<>(); + private final Navigator navigator; + private int currentPos = -1; + private int currentSize = 0; + private int maxHistorySize = DEFAULT_MAX_HISTORY_SIZE; + private long historyWaitTime = DEFAULT_HISTORY_WAIT_TIME; + + public NavigationHistory(Navigator navigator) { + this.navigator = navigator; + navigator.addNavigationEventListener(this); + initBook(navigator.getBook()); + } + + public int getCurrentPos() { + return currentPos; + } + + + public int getCurrentSize() { + return currentSize; + } + + public void initBook(EpubBook book) { + if (book == null) { + return; + } + locations = new ArrayList<>(); + currentPos = -1; + currentSize = 0; + if (navigator.getCurrentResource() != null) { + addLocation(navigator.getCurrentResource().getHref()); + } + } + + /** + * If the time between a navigation event is less than the historyWaitTime + * then the new location is not added to the history. + * + * When a user is rapidly viewing many pages using the slider we do not + * want all of them to be added to the history. + * + * @return the time we wait before adding the page to the history + */ + public long getHistoryWaitTime() { + return historyWaitTime; + } + + public void setHistoryWaitTime(long historyWaitTime) { + this.historyWaitTime = historyWaitTime; + } + + public void addLocation(Resource resource) { + if (resource == null) { + return; + } + addLocation(resource.getHref()); + } + + /** + * Adds the location after the current position. + * If the currentposition is not the end of the list then the elements + * between the current element and the end of the list will be discarded. + * + * Does nothing if the new location matches the current location. + *
    + * If this nr of locations becomes larger then the historySize then the + * first item(s) will be removed. + *v + * @param location d + */ + public void addLocation(Location location) { + // do nothing if the new location matches the current location + if (!(locations.isEmpty()) && + location.getHref().equals(locations.get(currentPos).getHref())) { + return; + } + currentPos++; + if (currentPos != currentSize) { + locations.set(currentPos, location); + } else { + locations.add(location); + checkHistorySize(); + } + currentSize = currentPos + 1; + } + + /** + * Removes all elements that are too much for the maxHistorySize + * out of the history. + */ + private void checkHistorySize() { + while (locations.size() > maxHistorySize) { + locations.remove(0); + currentSize--; + currentPos--; + } + } + + public void addLocation(String href) { + addLocation(new Location(href)); + } + + private String getLocationHref(int pos) { + if (pos < 0 || pos >= locations.size()) { + return null; + } + return locations.get(currentPos).getHref(); + } + + /** + * Moves the current positions delta positions. + * + * move(-1) to go one position back in history.
    + * move(1) to go one position forward.
    发 + * + * @param delta f + * + * @return Whether we actually moved. If the requested value is illegal + * it will return false, true otherwise. + */ + public boolean move(int delta) { + if (((currentPos + delta) < 0) + || ((currentPos + delta) >= currentSize)) { + return false; + } + currentPos += delta; + navigator.gotoResource(getLocationHref(currentPos), this); + return true; + } + + + /** + * If this is not the source of the navigationEvent then the addLocation + * will be called with the href of the currentResource in the navigationEvent. + */ + @Override + public void navigationPerformed(NavigationEvent navigationEvent) { + if (this == navigationEvent.getSource()) { + return; + } + if (navigationEvent.getCurrentResource() == null) { + return; + } + + if ((System.currentTimeMillis() - this.lastUpdateTime) > historyWaitTime) { + // if the user scrolled rapidly through the pages then the last page + // will not be added to the history. We fix that here: + addLocation(navigationEvent.getOldResource()); + + addLocation(navigationEvent.getCurrentResource().getHref()); + } + lastUpdateTime = System.currentTimeMillis(); + } + + public String getCurrentHref() { + if (currentPos < 0 || currentPos >= locations.size()) { + return null; + } + return locations.get(currentPos).getHref(); + } + + public void setMaxHistorySize(int maxHistorySize) { + this.maxHistorySize = maxHistorySize; + } + + public int getMaxHistorySize() { + return maxHistorySize; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/browsersupport/Navigator.java b/epublib/src/main/java/me/ag2s/epublib/browsersupport/Navigator.java new file mode 100644 index 000000000..84319e65e --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/browsersupport/Navigator.java @@ -0,0 +1,220 @@ +package me.ag2s.epublib.browsersupport; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Resource; + +/** + * A helper class for epub browser applications. + *

    + * It helps moving from one resource to the other, from one resource + * to the other and keeping other elements of the application up-to-date + * by calling the NavigationEventListeners. + * + * @author paul + */ +public class Navigator implements Serializable { + + private static final long serialVersionUID = 1076126986424925474L; + private EpubBook book; + private int currentSpinePos; + private Resource currentResource; + private int currentPagePos; + private String currentFragmentId; + + private final List eventListeners = new ArrayList<>(); + + public Navigator() { + this(null); + } + + public Navigator(EpubBook book) { + this.book = book; + this.currentSpinePos = 0; + if (book != null) { + this.currentResource = book.getCoverPage(); + } + this.currentPagePos = 0; + } + + private synchronized void handleEventListeners( + NavigationEvent navigationEvent) { + for (int i = 0; i < eventListeners.size(); i++) { + NavigationEventListener navigationEventListener = eventListeners.get(i); + navigationEventListener.navigationPerformed(navigationEvent); + } + } + + public boolean addNavigationEventListener( + NavigationEventListener navigationEventListener) { + return this.eventListeners.add(navigationEventListener); + } + + public boolean removeNavigationEventListener( + NavigationEventListener navigationEventListener) { + return this.eventListeners.remove(navigationEventListener); + } + + public int gotoFirstSpineSection(Object source) { + return gotoSpineSection(0, source); + } + + public int gotoPreviousSpineSection(Object source) { + return gotoPreviousSpineSection(0, source); + } + + public int gotoPreviousSpineSection(int pagePos, Object source) { + if (currentSpinePos < 0) { + return gotoSpineSection(0, pagePos, source); + } else { + return gotoSpineSection(currentSpinePos - 1, pagePos, source); + } + } + + public boolean hasNextSpineSection() { + return (currentSpinePos < (book.getSpine().size() - 1)); + } + + public boolean hasPreviousSpineSection() { + return (currentSpinePos > 0); + } + + public int gotoNextSpineSection(Object source) { + if (currentSpinePos < 0) { + return gotoSpineSection(0, source); + } else { + return gotoSpineSection(currentSpinePos + 1, source); + } + } + + public int gotoResource(String resourceHref, Object source) { + Resource resource = book.getResources().getByHref(resourceHref); + return gotoResource(resource, source); + } + + + public int gotoResource(Resource resource, Object source) { + return gotoResource(resource, 0, null, source); + } + + public int gotoResource(Resource resource, String fragmentId, Object source) { + return gotoResource(resource, 0, fragmentId, source); + } + + public int gotoResource(Resource resource, int pagePos, Object source) { + return gotoResource(resource, pagePos, null, source); + } + + public int gotoResource(Resource resource, int pagePos, String fragmentId, + Object source) { + if (resource == null) { + return -1; + } + NavigationEvent navigationEvent = new NavigationEvent(source, this); + this.currentResource = resource; + this.currentSpinePos = book.getSpine().getResourceIndex(currentResource); + this.currentPagePos = pagePos; + this.currentFragmentId = fragmentId; + handleEventListeners(navigationEvent); + + return currentSpinePos; + } + + public int gotoResourceId(String resourceId, Object source) { + return gotoSpineSection(book.getSpine().findFirstResourceById(resourceId), + source); + } + + public int gotoSpineSection(int newSpinePos, Object source) { + return gotoSpineSection(newSpinePos, 0, source); + } + + /** + * Go to a specific section. + * Illegal spine positions are silently ignored. + * + * @param newSpinePos f + * @param source f + * @return The current position within the spine + */ + public int gotoSpineSection(int newSpinePos, int newPagePos, Object source) { + if (newSpinePos == currentSpinePos) { + return currentSpinePos; + } + if (newSpinePos < 0 || newSpinePos >= book.getSpine().size()) { + return currentSpinePos; + } + NavigationEvent navigationEvent = new NavigationEvent(source, this); + currentSpinePos = newSpinePos; + currentPagePos = newPagePos; + currentResource = book.getSpine().getResource(currentSpinePos); + handleEventListeners(navigationEvent); + return currentSpinePos; + } + + public int gotoLastSpineSection(Object source) { + return gotoSpineSection(book.getSpine().size() - 1, source); + } + + public void gotoBook(EpubBook book, Object source) { + NavigationEvent navigationEvent = new NavigationEvent(source, this); + this.book = book; + this.currentFragmentId = null; + this.currentPagePos = 0; + this.currentResource = null; + this.currentSpinePos = book.getSpine().getResourceIndex(currentResource); + handleEventListeners(navigationEvent); + } + + /** + * The current position within the spine. + * + * @return something < 0 if the current position is not within the spine. + */ + public int getCurrentSpinePos() { + return currentSpinePos; + } + + public Resource getCurrentResource() { + return currentResource; + } + + /** + * Sets the current index and resource without calling the eventlisteners. + * + * If you want the eventListeners called use gotoSection(index); + * + * @param currentIndex f + */ + public void setCurrentSpinePos(int currentIndex) { + this.currentSpinePos = currentIndex; + this.currentResource = book.getSpine().getResource(currentIndex); + } + + public EpubBook getBook() { + return book; + } + + /** + * Sets the current index and resource without calling the eventlisteners. + * + * If you want the eventListeners called use gotoSection(index); + * + */ + public int setCurrentResource(Resource currentResource) { + this.currentSpinePos = book.getSpine().getResourceIndex(currentResource); + this.currentResource = currentResource; + return currentSpinePos; + } + + public String getCurrentFragmentId() { + return currentFragmentId; + } + + public int getCurrentSectionPos() { + return currentPagePos; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/browsersupport/package-info.java b/epublib/src/main/java/me/ag2s/epublib/browsersupport/package-info.java new file mode 100644 index 000000000..1ca74f1e4 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/browsersupport/package-info.java @@ -0,0 +1,7 @@ +/** + * Provides classes that help make an epub reader application. + * + * These classes have no dependencies on graphic toolkits, they're purely + * to help with the browsing/navigation logic. + */ +package me.ag2s.epublib.browsersupport; diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Author.java b/epublib/src/main/java/me/ag2s/epublib/domain/Author.java new file mode 100644 index 000000000..a604a5d08 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Author.java @@ -0,0 +1,89 @@ +package me.ag2s.epublib.domain; + + + +import me.ag2s.epublib.util.StringUtil; + +import java.io.Serializable; + +/** + * Represents one of the authors of the book + * + * @author paul + */ +public class Author implements Serializable { + + private static final long serialVersionUID = 6663408501416574200L; + + private String firstname; + private String lastname; + private Relator relator = Relator.AUTHOR; + + public Author(String singleName) { + this("", singleName); + } + + public Author(String firstname, String lastname) { + this.firstname = firstname; + this.lastname = lastname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + + @Override + @SuppressWarnings("NullableProblems") + public String toString() { + return this.lastname + ", " + this.firstname; + } + + public int hashCode() { + return StringUtil.hashCode(firstname, lastname); + } + + public boolean equals(Object authorObject) { + if (!(authorObject instanceof Author)) { + return false; + } + Author other = (Author) authorObject; + return StringUtil.equals(firstname, other.firstname) + && StringUtil.equals(lastname, other.lastname); + } + + /** + * 设置贡献者的角色 + * + * @param code 角色编号 + */ + + public void setRole(String code) { + Relator result = Relator.byCode(code); + if (result == null) { + result = Relator.AUTHOR; + } + this.relator = result; + } + + public Relator getRelator() { + return relator; + } + + + public void setRelator(Relator relator) { + this.relator = relator; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Date.java b/epublib/src/main/java/me/ag2s/epublib/domain/Date.java new file mode 100644 index 000000000..8e25aba5d --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Date.java @@ -0,0 +1,112 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import me.ag2s.epublib.epub.PackageDocumentBase; + +/** + * A Date used by the book's metadata. + *

    + * Examples: creation-date, modification-date, etc + * + * @author paul + */ +public class Date implements Serializable { + + private static final long serialVersionUID = 7533866830395120136L; + + public enum Event { + PUBLICATION("publication"), + MODIFICATION("modification"), + CREATION("creation"); + + private final String value; + + Event(String v) { + value = v; + } + + public static Event fromValue(String v) { + for (Event c : Event.values()) { + if (c.value.equals(v)) { + return c; + } + } + return null; + } + + @Override + @SuppressWarnings("NullableProblems") + public String toString() { + return value; + } + } + + + private Event event; + private String dateString; + + public Date() { + this(new java.util.Date(), Event.CREATION); + } + + public Date(java.util.Date date) { + this(date, (Event) null); + } + + public Date(String dateString) { + this(dateString, (Event) null); + } + + public Date(java.util.Date date, Event event) { + this((new SimpleDateFormat(PackageDocumentBase.dateFormat, Locale.US)).format(date), + event); + } + + public Date(String dateString, Event event) { + this.dateString = dateString; + this.event = event; + } + + public Date(java.util.Date date, String event) { + this((new SimpleDateFormat(PackageDocumentBase.dateFormat, Locale.US)).format(date), + event); + } + + public Date(String dateString, String event) { + this(checkDate(dateString), Event.fromValue(event)); + this.dateString = dateString; + } + + private static String checkDate(String dateString) { + if (dateString == null) { + throw new IllegalArgumentException( + "Cannot create a date from a blank string"); + } + return dateString; + } + + public String getValue() { + return dateString; + } + + public Event getEvent() { + return event; + } + + public void setEvent(Event event) { + this.event = event; + } + + @Override + @SuppressWarnings("NullableProblems") + public String toString() { + if (event == null) { + return dateString; + } + return "" + event + ":" + dateString; + } +} + diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/EpubBook.java b/epublib/src/main/java/me/ag2s/epublib/domain/EpubBook.java new file mode 100644 index 000000000..bae1ede47 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/EpubBook.java @@ -0,0 +1,323 @@ +package me.ag2s.epublib.domain; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Representation of a Book. + *

    + * All resources of a Book (html, css, xml, fonts, images) are represented + * as Resources. See getResources() for access to these.
    + * A Book as 3 indexes into these Resources, as per the epub specification.
    + *

    + *
    Spine
    + *
    these are the Resources to be shown when a user reads the book from + * start to finish.
    + *
    Table of Contents
    + *
    The table of contents. Table of Contents references may be in a + * different order and contain different Resources than the spine, and often do. + *
    Guide
    + *
    The Guide has references to a set of special Resources like the + * cover page, the Glossary, the copyright page, etc. + *
    + *

    + * The complication is that these 3 indexes may and usually do point to + * different pages. + * A chapter may be split up in 2 pieces to fit it in to memory. Then the + * spine will contain both pieces, but the Table of Contents only the first. + *

    + * The Content page may be in the Table of Contents, the Guide, but not + * in the Spine. + * Etc. + *

    + *

    + * Please see the illustration at: doc/schema.svg + * + * @author paul + * @author jake + */ +public class EpubBook implements Serializable { + + private static final long serialVersionUID = 2068355170895770100L; + + private Resources resources = new Resources(); + private Metadata metadata = new Metadata(); + private Spine spine = new Spine(); + private TableOfContents tableOfContents = new TableOfContents(); + private final Guide guide = new Guide(); + private Resource opfResource; + private Resource ncxResource; + private Resource coverImage; + + + private String version = "2.0"; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public boolean isEpub3() { + return this.version.startsWith("3."); + } + + @SuppressWarnings("UnusedReturnValue") + public TOCReference addSection( + TOCReference parentSection, String sectionTitle, Resource resource) { + return addSection(parentSection, sectionTitle, resource, null); + } + + /** + * Adds the resource to the table of contents of the book as a child + * section of the given parentSection + * + * @param parentSection parentSection + * @param sectionTitle sectionTitle + * @param resource resource + * @param fragmentId fragmentId + * @return The table of contents + */ + public TOCReference addSection( + TOCReference parentSection, String sectionTitle, Resource resource, + String fragmentId) { + getResources().add(resource); + if (spine.findFirstResourceById(resource.getId()) < 0) { + spine.addSpineReference(new SpineReference(resource)); + } + return parentSection.addChildSection( + new TOCReference(sectionTitle, resource, fragmentId)); + } + + public TOCReference addSection(String title, Resource resource) { + return addSection(title, resource, null); + } + + /** + * Adds a resource to the book's set of resources, table of contents and + * if there is no resource with the id in the spine also adds it to the spine. + * + * @param title title + * @param resource resource + * @param fragmentId fragmentId + * @return The table of contents + */ + public TOCReference addSection( + String title, Resource resource, String fragmentId) { + getResources().add(resource); + TOCReference tocReference = tableOfContents + .addTOCReference(new TOCReference(title, resource, fragmentId)); + if (spine.findFirstResourceById(resource.getId()) < 0) { + spine.addSpineReference(new SpineReference(resource)); + } + return tocReference; + } + + @SuppressWarnings("unused") + public void generateSpineFromTableOfContents() { + Spine spine = new Spine(tableOfContents); + + // in case the tocResource was already found and assigned + spine.setTocResource(this.spine.getTocResource()); + + this.spine = spine; + } + + /** + * The Book's metadata (titles, authors, etc) + * + * @return The Book's metadata (titles, authors, etc) + */ + public Metadata getMetadata() { + return metadata; + } + + public void setMetadata(Metadata metadata) { + this.metadata = metadata; + } + + + public void setResources(Resources resources) { + this.resources = resources; + } + + @SuppressWarnings("unused") + public Resource addResource(Resource resource) { + return resources.add(resource); + } + + /** + * The collection of all images, chapters, sections, xhtml files, + * stylesheets, etc that make up the book. + * + * @return The collection of all images, chapters, sections, xhtml files, + * stylesheets, etc that make up the book. + */ + public Resources getResources() { + return resources; + } + + + /** + * The sections of the book that should be shown if a user reads the book + * from start to finish. + * + * @return The Spine + */ + public Spine getSpine() { + return spine; + } + + + public void setSpine(Spine spine) { + this.spine = spine; + } + + + /** + * The Table of Contents of the book. + * + * @return The Table of Contents of the book. + */ + public TableOfContents getTableOfContents() { + return tableOfContents; + } + + + public void setTableOfContents(TableOfContents tableOfContents) { + this.tableOfContents = tableOfContents; + } + + /** + * The book's cover page as a Resource. + * An XHTML document containing a link to the cover image. + * + * @return The book's cover page as a Resource + */ + public Resource getCoverPage() { + Resource coverPage = guide.getCoverPage(); + if (coverPage == null) { + coverPage = spine.getResource(0); + } + return coverPage; + } + + + public void setCoverPage(Resource coverPage) { + if (coverPage == null) { + return; + } + if (resources.notContainsByHref(coverPage.getHref())) { + resources.add(coverPage); + } + guide.setCoverPage(coverPage); + } + + /** + * Gets the first non-blank title from the book's metadata. + * + * @return the first non-blank title from the book's metadata. + */ + public String getTitle() { + return getMetadata().getFirstTitle(); + } + + + /** + * The book's cover image. + * + * @return The book's cover image. + */ + public Resource getCoverImage() { + return coverImage; + } + + public void setCoverImage(Resource coverImage) { + if (coverImage == null) { + return; + } + if (resources.notContainsByHref(coverImage.getHref())) { + resources.add(coverImage); + } + this.coverImage = coverImage; + } + + /** + * The guide; contains references to special sections of the book like + * colophon, glossary, etc. + * + * @return The guide; contains references to special sections of the book + * like colophon, glossary, etc. + */ + public Guide getGuide() { + return guide; + } + + /** + * All Resources of the Book that can be reached via the Spine, the + * TableOfContents or the Guide. + *

    + * Consists of a list of "reachable" resources: + *

      + *
    • The coverpage
    • + *
    • The resources of the Spine that are not already in the result
    • + *
    • The resources of the Table of Contents that are not already in the + * result
    • + *
    • The resources of the Guide that are not already in the result
    • + *
    + * To get all html files that make up the epub file use + * {@link #getResources()} + * + * @return All Resources of the Book that can be reached via the Spine, + * the TableOfContents or the Guide. + */ + public List getContents() { + Map result = new LinkedHashMap<>(); + addToContentsResult(getCoverPage(), result); + + for (SpineReference spineReference : getSpine().getSpineReferences()) { + addToContentsResult(spineReference.getResource(), result); + } + + for (Resource resource : getTableOfContents().getAllUniqueResources()) { + addToContentsResult(resource, result); + } + + for (GuideReference guideReference : getGuide().getReferences()) { + addToContentsResult(guideReference.getResource(), result); + } + + return new ArrayList<>(result.values()); + } + + private static void addToContentsResult(Resource resource, + Map allReachableResources) { + if (resource != null && (!allReachableResources + .containsKey(resource.getHref()))) { + allReachableResources.put(resource.getHref(), resource); + } + } + + public Resource getOpfResource() { + return opfResource; + } + + public void setOpfResource(Resource opfResource) { + this.opfResource = opfResource; + } + + public void setNcxResource(Resource ncxResource) { + this.ncxResource = ncxResource; + } + + public Resource getNcxResource() { + return ncxResource; + } +} + diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/EpubResourceProvider.java b/epublib/src/main/java/me/ag2s/epublib/domain/EpubResourceProvider.java new file mode 100644 index 000000000..5b38e39f6 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/EpubResourceProvider.java @@ -0,0 +1,33 @@ +package me.ag2s.epublib.domain; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * @author jake + */ +public class EpubResourceProvider implements LazyResourceProvider { + + private final String epubFilename; + + /** + * @param epubFilename the file name for the epub we're created from. + */ + public EpubResourceProvider(String epubFilename) { + this.epubFilename = epubFilename; + } + + @Override + public InputStream getResourceStream(String href) throws IOException { + ZipFile zipFile = new ZipFile(epubFilename); + ZipEntry zipEntry = zipFile.getEntry(href); + if (zipEntry == null) { + zipFile.close(); + throw new IllegalStateException( + "Cannot find entry " + href + " in epub file " + epubFilename); + } + return new ResourceInputStream(zipFile.getInputStream(zipEntry), zipFile); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/FileResourceProvider.java b/epublib/src/main/java/me/ag2s/epublib/domain/FileResourceProvider.java new file mode 100644 index 000000000..e273712d6 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/FileResourceProvider.java @@ -0,0 +1,44 @@ +package me.ag2s.epublib.domain; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * 用于创建epub,添加大文件(如大量图片)时容易OOM,使用LazyResource,避免OOM. + * + */ + +public class FileResourceProvider implements LazyResourceProvider { + //需要导入资源的父目录 + String dir; + + /** + * 创建一个文件夹里面文件夹的LazyResourceProvider,用于LazyResource。 + * @param parentDir 文件的目录 + */ + public FileResourceProvider(String parentDir) { + this.dir = parentDir; + } + + /** + * 创建一个文件夹里面文件夹的LazyResourceProvider,用于LazyResource。 + * @param parentFile 文件夹 + */ + @SuppressWarnings("unused") + public FileResourceProvider(File parentFile) { + this.dir = parentFile.getPath(); + } + + /** + * 根据子文件名href,再父目录下读取文件获取FileInputStream + * @param href 子文件名href + * @return 对应href的FileInputStream + * @throws IOException 抛出IOException + */ + @Override + public InputStream getResourceStream(String href) throws IOException { + return new FileInputStream(new File(dir, href)); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Guide.java b/epublib/src/main/java/me/ag2s/epublib/domain/Guide.java new file mode 100644 index 000000000..57c0467a9 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Guide.java @@ -0,0 +1,128 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * The guide is a selection of special pages of the book. + * Examples of these are the cover, list of illustrations, etc. + * + * It is an optional part of an epub, and support for the various types + * of references varies by reader. + * + * The only part of this that is heavily used is the cover page. + * + * @author paul + * + */ +public class Guide implements Serializable { + + /** + * + */ + private static final long serialVersionUID = -6256645339915751189L; + + public static final String DEFAULT_COVER_TITLE = GuideReference.COVER; + + private List references = new ArrayList<>(); + private static final int COVERPAGE_NOT_FOUND = -1; + private static final int COVERPAGE_UNITIALIZED = -2; + + private int coverPageIndex = -1; + + public List getReferences() { + return references; + } + + public void setReferences(List references) { + this.references = references; + uncheckCoverPage(); + } + + private void uncheckCoverPage() { + coverPageIndex = COVERPAGE_UNITIALIZED; + } + + public GuideReference getCoverReference() { + checkCoverPage(); + if (coverPageIndex >= 0) { + return references.get(coverPageIndex); + } + return null; + } + @SuppressWarnings("UnusedReturnValue") + public int setCoverReference(GuideReference guideReference) { + if (coverPageIndex >= 0) { + references.set(coverPageIndex, guideReference); + } else { + references.add(0, guideReference); + coverPageIndex = 0; + } + return coverPageIndex; + } + + private void checkCoverPage() { + if (coverPageIndex == COVERPAGE_UNITIALIZED) { + initCoverPage(); + } + } + + + private void initCoverPage() { + int result = COVERPAGE_NOT_FOUND; + for (int i = 0; i < references.size(); i++) { + GuideReference guideReference = references.get(i); + if (guideReference.getType().equals(GuideReference.COVER)) { + result = i; + break; + } + } + coverPageIndex = result; + } + + /** + * The coverpage of the book. + * + * @return The coverpage of the book. + */ + public Resource getCoverPage() { + GuideReference guideReference = getCoverReference(); + if (guideReference == null) { + return null; + } + return guideReference.getResource(); + } + + public void setCoverPage(Resource coverPage) { + GuideReference coverpageGuideReference = new GuideReference(coverPage, + GuideReference.COVER, DEFAULT_COVER_TITLE); + setCoverReference(coverpageGuideReference); + } + + @SuppressWarnings("UnusedReturnValue") + public ResourceReference addReference(GuideReference reference) { + this.references.add(reference); + uncheckCoverPage(); + return reference; + } + + /** + * A list of all GuideReferences that have the given + * referenceTypeName (ignoring case). + * + * @param referenceTypeName referenceTypeName + * @return A list of all GuideReferences that have the given + * referenceTypeName (ignoring case). + */ + public List getGuideReferencesByType( + String referenceTypeName) { + List result = new ArrayList<>(); + for (GuideReference guideReference : references) { + if (referenceTypeName.equalsIgnoreCase(guideReference.getType())) { + result.add(guideReference); + } + } + return result; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/GuideReference.java b/epublib/src/main/java/me/ag2s/epublib/domain/GuideReference.java new file mode 100644 index 000000000..89bd3ff12 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/GuideReference.java @@ -0,0 +1,102 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.util.StringUtil; +import java.io.Serializable; + + +/** + * These are references to elements of the book's guide. + * + * @see Guide + * + * @author paul + * + */ +public class GuideReference extends TitledResourceReference + implements Serializable { + + private static final long serialVersionUID = -316179702440631834L; + + /** + * the book cover(s), jacket information, etc. + */ + public static final String COVER = "cover"; + + /** + * human-readable page with title, author, publisher, and other metadata + */ + public static String TITLE_PAGE = "title-page"; + + /** + * Human-readable table of contents. + * Not to be confused the epub file table of contents + * + */ + public static String TOC = "toc"; + + /** + * back-of-book style index + */ + public static String INDEX = "index"; + public static String GLOSSARY = "glossary"; + public static String ACKNOWLEDGEMENTS = "acknowledgements"; + public static String BIBLIOGRAPHY = "bibliography"; + public static String COLOPHON = "colophon"; + public static String COPYRIGHT_PAGE = "copyright-page"; + public static String DEDICATION = "dedication"; + + /** + * an epigraph is a phrase, quotation, or poem that is set at the + * beginning of a document or component. + * + * source: http://en.wikipedia.org/wiki/Epigraph_%28literature%29 + */ + public static String EPIGRAPH = "epigraph"; + + public static String FOREWORD = "foreword"; + + /** + * list of illustrations + */ + public static String LOI = "loi"; + + /** + * list of tables + */ + public static String LOT = "lot"; + public static String NOTES = "notes"; + public static String PREFACE = "preface"; + + /** + * A page of content (e.g. "Chapter 1") + */ + public static String TEXT = "text"; + + private String type; + + public GuideReference(Resource resource) { + this(resource, null); + } + + public GuideReference(Resource resource, String title) { + super(resource, title); + } + + public GuideReference(Resource resource, String type, String title) { + this(resource, type, title, null); + } + + public GuideReference(Resource resource, String type, String title, + String fragmentId) { + super(resource, title, fragmentId); + this.type = StringUtil.isNotBlank(type) ? type.toLowerCase() : null; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Identifier.java b/epublib/src/main/java/me/ag2s/epublib/domain/Identifier.java new file mode 100644 index 000000000..ccb46186f --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Identifier.java @@ -0,0 +1,130 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.util.StringUtil; +import java.io.Serializable; +import java.util.List; +import java.util.UUID; + +/** + * A Book's identifier. + * + * Defaults to a random UUID and scheme "UUID" + * + * @author paul + */ +public class Identifier implements Serializable { + + private static final long serialVersionUID = 955949951416391810L; + @SuppressWarnings("unused") + public interface Scheme { + + String UUID = "UUID"; + String ISBN = "ISBN"; + String URL = "URL"; + String URI = "URI"; + } + + private boolean bookId = false; + private String scheme; + private String value; + + /** + * Creates an Identifier with as value a random UUID and scheme "UUID" + */ + public Identifier() { + this(Scheme.UUID, UUID.randomUUID().toString()); + } + + + public Identifier(String scheme, String value) { + this.scheme = scheme; + this.value = value; + } + + /** + * The first identifier for which the bookId is true is made the + * bookId identifier. + * + * If no identifier has bookId == true then the first bookId identifier + * is written as the primary. + * + * @param identifiers i + * @return The first identifier for which the bookId is true is made + * the bookId identifier. + */ + public static Identifier getBookIdIdentifier(List identifiers) { + if (identifiers == null || identifiers.isEmpty()) { + return null; + } + + Identifier result = null; + for (Identifier identifier : identifiers) { + if (identifier.isBookId()) { + result = identifier; + break; + } + } + + if (result == null) { + result = identifiers.get(0); + } + + return result; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + + public void setBookId(boolean bookId) { + this.bookId = bookId; + } + + + /** + * This bookId property allows the book creator to add multiple ids and + * tell the epubwriter which one to write out as the bookId. + * + * The Dublin Core metadata spec allows multiple identifiers for a Book. + * The epub spec requires exactly one identifier to be marked as the book id. + * + * @return whether this is the unique book id. + */ + public boolean isBookId() { + return bookId; + } + + public int hashCode() { + return StringUtil.defaultIfNull(scheme).hashCode() ^ StringUtil + .defaultIfNull(value).hashCode(); + } + + public boolean equals(Object otherIdentifier) { + if (!(otherIdentifier instanceof Identifier)) { + return false; + } + return StringUtil.equals(scheme, ((Identifier) otherIdentifier).scheme) + && StringUtil.equals(value, ((Identifier) otherIdentifier).value); + } + @SuppressWarnings("NullableProblems") + @Override + public String toString() { + if (StringUtil.isBlank(scheme)) { + return "" + value; + } + return "" + scheme + ":" + value; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/LazyResource.java b/epublib/src/main/java/me/ag2s/epublib/domain/LazyResource.java new file mode 100644 index 000000000..a0faf070e --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/LazyResource.java @@ -0,0 +1,143 @@ +package me.ag2s.epublib.domain; + +import android.util.Log; + +import me.ag2s.epublib.util.IOUtil; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * A Resource that loads its data only on-demand from a EPUB book file. + * This way larger books can fit into memory and can be opened faster. + */ +public class LazyResource extends Resource { + + private static final long serialVersionUID = 5089400472352002866L; + private final String TAG= getClass().getName(); + + private final LazyResourceProvider resourceProvider; + private final long cachedSize; + + /** + * Creates a lazy resource, when the size is unknown. + * + * @param resourceProvider The resource provider loads data on demand. + * @param href The resource's href within the epub. + */ + public LazyResource(LazyResourceProvider resourceProvider, String href) { + this(resourceProvider, -1, href); + } + public LazyResource(LazyResourceProvider resourceProvider, String href, String originalHref) { + this(resourceProvider, -1, href, originalHref); + } + + /** + * Creates a Lazy resource, by not actually loading the data for this entry. + * + * The data will be loaded on the first call to getData() + * + * @param resourceProvider The resource provider loads data on demand. + * @param size The size of this resource. + * @param href The resource's href within the epub. + */ + public LazyResource( + LazyResourceProvider resourceProvider, long size, String href) { + super(null, null, href, MediaTypes.determineMediaType(href)); + this.resourceProvider = resourceProvider; + this.cachedSize = size; + } + public LazyResource( + LazyResourceProvider resourceProvider, long size, String href, String originalHref) { + super(null, null, href, originalHref, MediaTypes.determineMediaType(href)); + this.resourceProvider = resourceProvider; + this.cachedSize = size; + } + + /** + * Gets the contents of the Resource as an InputStream. + * + * @return The contents of the Resource. + * + * @throws IOException IOException + */ + public InputStream getInputStream() throws IOException { + if (isInitialized()) { + return new ByteArrayInputStream(getData()); + } else { + return resourceProvider.getResourceStream(this.originalHref); + } + } + + /** + * Initializes the resource by loading its data into memory. + * + * @throws IOException IOException + */ + public void initialize() throws IOException { + getData(); + } + + /** + * The contents of the resource as a byte[] + * + * If this resource was lazy-loaded and the data was not yet loaded, + * it will be loaded into memory at this point. + * This included opening the zip file, so expect a first load to be slow. + * + * @return The contents of the resource + */ + public byte[] getData() throws IOException { + + if (data == null) { + + Log.d(TAG, "Initializing lazy resource: " + this.getHref()); + + InputStream in = resourceProvider.getResourceStream(this.originalHref); + byte[] readData = IOUtil.toByteArray(in, (int) this.cachedSize); + if (readData == null) { + throw new IOException( + "Could not load the contents of resource: " + this.getHref()); + } else { + this.data = readData; + } + + in.close(); + } + + return data; + } + + /** + * Tells this resource to release its cached data. + * + * If this resource was not lazy-loaded, this is a no-op. + */ + public void close() { + if (this.resourceProvider != null) { + this.data = null; + } + } + + /** + * Returns if the data for this resource has been loaded into memory. + * + * @return true if data was loaded. + */ + public boolean isInitialized() { + return data != null; + } + + /** + * Returns the size of this resource in bytes. + * + * @return the size. + */ + public long getSize() { + if (data != null) { + return data.length; + } + + return cachedSize; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/LazyResourceProvider.java b/epublib/src/main/java/me/ag2s/epublib/domain/LazyResourceProvider.java new file mode 100644 index 000000000..09b60e596 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/LazyResourceProvider.java @@ -0,0 +1,12 @@ +package me.ag2s.epublib.domain; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @author jake + */ +public interface LazyResourceProvider { + + InputStream getResourceStream(String href) throws IOException; +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/ManifestItemProperties.java b/epublib/src/main/java/me/ag2s/epublib/domain/ManifestItemProperties.java new file mode 100644 index 000000000..dd21eead5 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/ManifestItemProperties.java @@ -0,0 +1,21 @@ +package me.ag2s.epublib.domain; +@SuppressWarnings("unused") +public enum ManifestItemProperties implements ManifestProperties { + COVER_IMAGE("cover-image"), + MATHML("mathml"), + NAV("nav"), + REMOTE_RESOURCES("remote-resources"), + SCRIPTED("scripted"), + SVG("svg"), + SWITCH("switch"); + + private final String name; + + ManifestItemProperties(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/ManifestItemRefProperties.java b/epublib/src/main/java/me/ag2s/epublib/domain/ManifestItemRefProperties.java new file mode 100644 index 000000000..b67d63f91 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/ManifestItemRefProperties.java @@ -0,0 +1,16 @@ +package me.ag2s.epublib.domain; +@SuppressWarnings("unused") +public enum ManifestItemRefProperties implements ManifestProperties { + PAGE_SPREAD_LEFT("page-spread-left"), + PAGE_SPREAD_RIGHT("page-spread-right"); + + private final String name; + + ManifestItemRefProperties(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/ManifestProperties.java b/epublib/src/main/java/me/ag2s/epublib/domain/ManifestProperties.java new file mode 100644 index 000000000..26892d7b0 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/ManifestProperties.java @@ -0,0 +1,6 @@ +package me.ag2s.epublib.domain; + +public interface ManifestProperties { + + String getName(); +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/MediaType.java b/epublib/src/main/java/me/ag2s/epublib/domain/MediaType.java new file mode 100644 index 000000000..9a6817996 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/MediaType.java @@ -0,0 +1,73 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collection; + +/** + * MediaType is used to tell the type of content a resource is. + * + * Examples of mediatypes are image/gif, text/css and application/xhtml+xml + * + * All allowed mediaTypes are maintained bye the MediaTypeService. + * + * @see MediaTypes + * + * @author paul + */ +public class MediaType implements Serializable { + + private static final long serialVersionUID = -7256091153727506788L; + private final String name; + private final String defaultExtension; + private final Collection extensions; + + public MediaType(String name, String defaultExtension) { + this(name, defaultExtension, new String[]{defaultExtension}); + } + + public MediaType(String name, String defaultExtension, + String[] extensions) { + this(name, defaultExtension, Arrays.asList(extensions)); + } + + public int hashCode() { + if (name == null) { + return 0; + } + return name.hashCode(); + } + + public MediaType(String name, String defaultExtension, + Collection mextensions) { + super(); + this.name = name; + this.defaultExtension = defaultExtension; + this.extensions = mextensions; + } + + public String getName() { + return name; + } + + + public String getDefaultExtension() { + return defaultExtension; + } + + + public Collection getExtensions() { + return extensions; + } + + public boolean equals(Object otherMediaType) { + if (!(otherMediaType instanceof MediaType)) { + return false; + } + return name.equals(((MediaType) otherMediaType).getName()); + } + @SuppressWarnings("NullableProblems") + public String toString() { + return name; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/MediaTypes.java b/epublib/src/main/java/me/ag2s/epublib/domain/MediaTypes.java new file mode 100644 index 000000000..61d51ad64 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/MediaTypes.java @@ -0,0 +1,94 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.util.StringUtil; +import java.util.HashMap; +import java.util.Map; + + +/** + * Manages mediatypes that are used by epubs + * + * @author paul + */ +public class MediaTypes { + + public static final MediaType XHTML = new MediaType("application/xhtml+xml", + ".xhtml", new String[]{".htm", ".html", ".xhtml"}); + public static final MediaType EPUB = new MediaType("application/epub+zip", + ".epub"); + public static final MediaType NCX = new MediaType("application/x-dtbncx+xml", + ".ncx"); + + public static final MediaType JAVASCRIPT = new MediaType("text/javascript", + ".js"); + public static final MediaType CSS = new MediaType("text/css", ".css"); + + // images + public static final MediaType JPG = new MediaType("image/jpeg", ".jpg", + new String[]{".jpg", ".jpeg"}); + public static final MediaType PNG = new MediaType("image/png", ".png"); + public static final MediaType GIF = new MediaType("image/gif", ".gif"); + + public static final MediaType SVG = new MediaType("image/svg+xml", ".svg"); + + // fonts + public static final MediaType TTF = new MediaType( + "application/x-truetype-font", ".ttf"); + public static final MediaType OPENTYPE = new MediaType( + "application/vnd.ms-opentype", ".otf"); + public static final MediaType WOFF = new MediaType("application/font-woff", + ".woff"); + + // audio + public static final MediaType MP3 = new MediaType("audio/mpeg", ".mp3"); + public static final MediaType OGG = new MediaType("audio/ogg", ".ogg"); + + // video + public static final MediaType MP4 = new MediaType("video/mp4", ".mp4"); + + public static final MediaType SMIL = new MediaType("application/smil+xml", + ".smil"); + public static final MediaType XPGT = new MediaType( + "application/adobe-page-template+xml", ".xpgt"); + public static final MediaType PLS = new MediaType("application/pls+xml", + ".pls"); + + public static final MediaType[] mediaTypes = new MediaType[]{ + XHTML, EPUB, JPG, PNG, GIF, CSS, SVG, TTF, NCX, XPGT, OPENTYPE, WOFF, + SMIL, PLS, JAVASCRIPT, MP3, MP4, OGG + }; + + public static final Map mediaTypesByName = new HashMap<>(); + + static { + for (MediaType mediaType : mediaTypes) { + mediaTypesByName.put(mediaType.getName(), mediaType); + } + } + + public static boolean isBitmapImage(MediaType mediaType) { + return mediaType == JPG || mediaType == PNG || mediaType == GIF; + } + + /** + * Gets the MediaType based on the file extension. + * Null of no matching extension found. + * + * @param filename filename + * @return the MediaType based on the file extension. + */ + public static MediaType determineMediaType(String filename) { + for (MediaType mediaType : mediaTypesByName.values()) { + for (String extension : mediaType.getExtensions()) { + if (StringUtil.endsWithIgnoreCase(filename, extension)) { + return mediaType; + } + } + } + return null; + } + + public static MediaType getMediaTypeByName(String mediaTypeName) { + return mediaTypesByName.get(mediaTypeName); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Metadata.java b/epublib/src/main/java/me/ag2s/epublib/domain/Metadata.java new file mode 100644 index 000000000..71c995dbe --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Metadata.java @@ -0,0 +1,241 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.util.StringUtil; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; + +/** + * A Book's collection of Metadata. + * In the future it should contain all Dublin Core attributes, for now + * it contains a set of often-used ones. + * + * @author paul + */ +public class Metadata implements Serializable { + + private static final long serialVersionUID = -2437262888962149444L; + + public static final String DEFAULT_LANGUAGE = "en"; + + private boolean autoGeneratedId;//true; + private List authors = new ArrayList<>(); + private List contributors = new ArrayList<>(); + private List dates = new ArrayList<>(); + private String language = DEFAULT_LANGUAGE; + private Map otherProperties = new HashMap<>(); + private List rights = new ArrayList<>(); + private List titles = new ArrayList<>(); + private List identifiers = new ArrayList<>(); + private List subjects = new ArrayList<>(); + private String format = MediaTypes.EPUB.getName(); + private List types = new ArrayList<>(); + private List descriptions = new ArrayList<>(); + private List publishers = new ArrayList<>(); + private Map metaAttributes = new HashMap<>(); + + public Metadata() { + identifiers.add(new Identifier()); + autoGeneratedId = true; + } + + @SuppressWarnings("unused") + public boolean isAutoGeneratedId() { + return autoGeneratedId; + } + + /** + * Metadata properties not hard-coded like the author, title, etc. + * + * @return Metadata properties not hard-coded like the author, title, etc. + */ + public Map getOtherProperties() { + return otherProperties; + } + + public void setOtherProperties(Map otherProperties) { + this.otherProperties = otherProperties; + } + + @SuppressWarnings("unused") + public Date addDate(Date date) { + this.dates.add(date); + return date; + } + + public List getDates() { + return dates; + } + + public void setDates(List dates) { + this.dates = dates; + } + + @SuppressWarnings("UnusedReturnValue") + public Author addAuthor(Author author) { + authors.add(author); + return author; + } + + public List getAuthors() { + return authors; + } + + public void setAuthors(List authors) { + this.authors = authors; + } + + @SuppressWarnings("UnusedReturnValue") + public Author addContributor(Author contributor) { + contributors.add(contributor); + return contributor; + } + + public List getContributors() { + return contributors; + } + + public void setContributors(List contributors) { + this.contributors = contributors; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public List getSubjects() { + return subjects; + } + + public void setSubjects(List subjects) { + this.subjects = subjects; + } + + public void setRights(List rights) { + this.rights = rights; + } + + public List getRights() { + return rights; + } + + + /** + * Gets the first non-blank title of the book. + * Will return "" if no title found. + * + * @return the first non-blank title of the book. + */ + public String getFirstTitle() { + if (titles == null || titles.isEmpty()) { + return ""; + } + for (String title : titles) { + if (StringUtil.isNotBlank(title)) { + return title; + } + } + return ""; + } + + public String addTitle(String title) { + this.titles.add(title); + return title; + } + + public void setTitles(List titles) { + this.titles = titles; + } + + public List getTitles() { + return titles; + } + + @SuppressWarnings("UnusedReturnValue") + public String addPublisher(String publisher) { + this.publishers.add(publisher); + return publisher; + } + + public void setPublishers(List publishers) { + this.publishers = publishers; + } + + public List getPublishers() { + return publishers; + } + + @SuppressWarnings("UnusedReturnValue") + public String addDescription(String description) { + this.descriptions.add(description); + return description; + } + + public void setDescriptions(List descriptions) { + this.descriptions = descriptions; + } + + public List getDescriptions() { + return descriptions; + } + + @SuppressWarnings("unused") + public Identifier addIdentifier(Identifier identifier) { + if (autoGeneratedId && (!(identifiers.isEmpty()))) { + identifiers.set(0, identifier); + } else { + identifiers.add(identifier); + } + autoGeneratedId = false; + return identifier; + } + + public void setIdentifiers(List identifiers) { + this.identifiers = identifiers; + autoGeneratedId = false; + } + + public List getIdentifiers() { + return identifiers; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getFormat() { + return format; + } + + @SuppressWarnings("UnusedReturnValue") + public String addType(String type) { + this.types.add(type); + return type; + } + + public List getTypes() { + return types; + } + + public void setTypes(List types) { + this.types = types; + } + + @SuppressWarnings("unused") + public String getMetaAttribute(String name) { + return metaAttributes.get(name); + } + + public void setMetaAttributes(Map metaAttributes) { + this.metaAttributes = metaAttributes; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Relator.java b/epublib/src/main/java/me/ag2s/epublib/domain/Relator.java new file mode 100644 index 000000000..b9637588d --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Relator.java @@ -0,0 +1,1144 @@ +package me.ag2s.epublib.domain; + + +/** + * A relator denotes which role a certain individual had in the creation/modification of the ebook. + * + * Examples are 'creator', 'blurb writer', etc. + * + * This is contains the complete Library of Concress relator list. + * + * @see MARC Code List for Relators + * + * @author paul + */ +public enum Relator { + + /** + * Use for a person or organization who principally exhibits acting skills in a musical or dramatic presentation or entertainment. + */ + ACTOR("act", "Actor"), + + /** + * Use for a person or organization who 1) reworks a musical composition, usually for a different medium, or 2) rewrites novels or stories for motion pictures or other audiovisual medium. + */ + ADAPTER("adp", "Adapter"), + + /** + * Use for a person or organization that reviews, examines and interprets data or information in a specific area. + */ + ANALYST("anl", "Analyst"), + + /** + * Use for a person or organization who draws the two-dimensional figures, manipulates the three dimensional objects and/or also programs the computer to move objects and images for the purpose of animated film processing. Animation cameras, stands, celluloid screens, transparencies and inks are some of the tools of the animator. + */ + ANIMATOR("anm", "Animator"), + + /** + * Use for a person who writes manuscript annotations on a printed item. + */ + ANNOTATOR("ann", "Annotator"), + + /** + * Use for a person or organization responsible for the submission of an application or who is named as eligible for the results of the processing of the application (e.g., bestowing of rights, reward, title, position). + */ + APPLICANT("app", "Applicant"), + + /** + * Use for a person or organization who designs structures or oversees their construction. + */ + ARCHITECT("arc", "Architect"), + + /** + * Use for a person or organization who transcribes a musical composition, usually for a different medium from that of the original; in an arrangement the musical substance remains essentially unchanged. + */ + ARRANGER("arr", "Arranger"), + + /** + * Use for a person (e.g., a painter or sculptor) who makes copies of works of visual art. + */ + ART_COPYIST("acp", "Art copyist"), + + /** + * Use for a person (e.g., a painter) or organization who conceives, and perhaps also implements, an original graphic design or work of art, if specific codes (e.g., [egr], [etr]) are not desired. For book illustrators, prefer Illustrator [ill]. + */ + ARTIST("art", "Artist"), + + /** + * Use for a person responsible for controlling the development of the artistic style of an entire production, including the choice of works to be presented and selection of senior production staff. + */ + ARTISTIC_DIRECTOR("ard", "Artistic director"), + + /** + * Use for a person or organization to whom a license for printing or publishing has been transferred. + */ + ASSIGNEE("asg", "Assignee"), + + /** + * Use for a person or organization associated with or found in an item or collection, which cannot be determined to be that of a Former owner [fmo] or other designated relator indicative of provenance. + */ + ASSOCIATED_NAME("asn", "Associated name"), + + /** + * Use for an author, artist, etc., relating him/her to a work for which there is or once was substantial authority for designating that person as author, creator, etc. of the work. + */ + ATTRIBUTED_NAME("att", "Attributed name"), + + /** + * Use for a person or organization in charge of the estimation and public auctioning of goods, particularly books, artistic works, etc. + */ + AUCTIONEER("auc", "Auctioneer"), + + /** + * Use for a person or organization chiefly responsible for the intellectual or artistic content of a work, usually printed text. This term may also be used when more than one person or body bears such responsibility. + */ + AUTHOR("aut", "Author"), + + /** + * Use for a person or organization whose work is largely quoted or extracted in works to which he or she did not contribute directly. Such quotations are found particularly in exhibition catalogs, collections of photographs, etc. + */ + AUTHOR_IN_QUOTATIONS_OR_TEXT_EXTRACTS("aqt", + "Author in quotations or text extracts"), + + /** + * Use for a person or organization responsible for an afterword, postface, colophon, etc. but who is not the chief author of a work. + */ + AUTHOR_OF_AFTERWORD_COLOPHON_ETC("aft", + "Author of afterword, colophon, etc."), + + /** + * Use for a person or organization responsible for the dialog or spoken commentary for a screenplay or sound recording. + */ + AUTHOR_OF_DIALOG("aud", "Author of dialog"), + + /** + * Use for a person or organization responsible for an introduction, preface, foreword, or other critical introductory matter, but who is not the chief author. + */ + AUTHOR_OF_INTRODUCTION_ETC("aui", "Author of introduction, etc."), + + /** + * Use for a person or organization responsible for a motion picture screenplay, dialog, spoken commentary, etc. + */ + AUTHOR_OF_SCREENPLAY_ETC("aus", "Author of screenplay, etc."), + + /** + * Use for a person or organization responsible for a work upon which the work represented by the catalog record is based. This may be appropriate for adaptations, sequels, continuations, indexes, etc. + */ + BIBLIOGRAPHIC_ANTECEDENT("ant", "Bibliographic antecedent"), + + /** + * Use for a person or organization responsible for the binding of printed or manuscript materials. + */ + BINDER("bnd", "Binder"), + + /** + * Use for a person or organization responsible for the binding design of a book, including the type of binding, the type of materials used, and any decorative aspects of the binding. + */ + BINDING_DESIGNER("bdd", "Binding designer"), + + /** + * Use for the named entity responsible for writing a commendation or testimonial for a work, which appears on or within the publication itself, frequently on the back or dust jacket of print publications or on advertising material for all media. + */ + BLURB_WRITER("blw", "Blurb writer"), + + /** + * Use for a person or organization responsible for the entire graphic design of a book, including arrangement of type and illustration, choice of materials, and process used. + */ + BOOK_DESIGNER("bkd", "Book designer"), + + /** + * Use for a person or organization responsible for the production of books and other print media, if specific codes (e.g., [bkd], [egr], [tyd], [prt]) are not desired. + */ + BOOK_PRODUCER("bkp", "Book producer"), + + /** + * Use for a person or organization responsible for the design of flexible covers designed for or published with a book, including the type of materials used, and any decorative aspects of the bookjacket. + */ + BOOKJACKET_DESIGNER("bjd", "Bookjacket designer"), + + /** + * Use for a person or organization responsible for the design of a book owner's identification label that is most commonly pasted to the inside front cover of a book. + */ + BOOKPLATE_DESIGNER("bpd", "Bookplate designer"), + + /** + * Use for a person or organization who makes books and other bibliographic materials available for purchase. Interest in the materials is primarily lucrative. + */ + BOOKSELLER("bsl", "Bookseller"), + + /** + * Use for a person or organization who writes in an artistic hand, usually as a copyist and or engrosser. + */ + CALLIGRAPHER("cll", "Calligrapher"), + + /** + * Use for a person or organization responsible for the creation of maps and other cartographic materials. + */ + CARTOGRAPHER("ctg", "Cartographer"), + + /** + * Use for a censor, bowdlerizer, expurgator, etc., official or private. + */ + CENSOR("cns", "Censor"), + + /** + * Use for a person or organization who composes or arranges dances or other movements (e.g., "master of swords") for a musical or dramatic presentation or entertainment. + */ + CHOREOGRAPHER("chr", "Choreographer"), + + /** + * Use for a person or organization who is in charge of the images captured for a motion picture film. The cinematographer works under the supervision of a director, and may also be referred to as director of photography. Do not confuse with videographer. + */ + CINEMATOGRAPHER("cng", "Cinematographer"), + + /** + * Use for a person or organization for whom another person or organization is acting. + */ + CLIENT("cli", "Client"), + + /** + * Use for a person or organization that takes a limited part in the elaboration of a work of another person or organization that brings complements (e.g., appendices, notes) to the work. + */ + COLLABORATOR("clb", "Collaborator"), + + /** + * Use for a person or organization who has brought together material from various sources that has been arranged, described, and cataloged as a collection. A collector is neither the creator of the material nor a person to whom manuscripts in the collection may have been addressed. + */ + COLLECTOR("col", "Collector"), + + /** + * Use for a person or organization responsible for the production of photographic prints from film or other colloid that has ink-receptive and ink-repellent surfaces. + */ + COLLOTYPER("clt", "Collotyper"), + + /** + * Use for the named entity responsible for applying color to drawings, prints, photographs, maps, moving images, etc. + */ + COLORIST("clr", "Colorist"), + + /** + * Use for a person or organization who provides interpretation, analysis, or a discussion of the subject matter on a recording, motion picture, or other audiovisual medium. + */ + COMMENTATOR("cmm", "Commentator"), + + /** + * Use for a person or organization responsible for the commentary or explanatory notes about a text. For the writer of manuscript annotations in a printed book, use Annotator [ann]. + */ + COMMENTATOR_FOR_WRITTEN_TEXT("cwt", "Commentator for written text"), + + /** + * Use for a person or organization who produces a work or publication by selecting and putting together material from the works of various persons or bodies. + */ + COMPILER("com", "Compiler"), + + /** + * Use for the party who applies to the courts for redress, usually in an equity proceeding. + */ + COMPLAINANT("cpl", "Complainant"), + + /** + * Use for a complainant who takes an appeal from one court or jurisdiction to another to reverse the judgment, usually in an equity proceeding. + */ + COMPLAINANT_APPELLANT("cpt", "Complainant-appellant"), + + /** + * Use for a complainant against whom an appeal is taken from one court or jurisdiction to another to reverse the judgment, usually in an equity proceeding. + */ + COMPLAINANT_APPELLEE("cpe", "Complainant-appellee"), + + /** + * Use for a person or organization who creates a musical work, usually a piece of music in manuscript or printed form. + */ + COMPOSER("cmp", "Composer"), + + /** + * Use for a person or organization responsible for the creation of metal slug, or molds made of other materials, used to produce the text and images in printed matter. + */ + COMPOSITOR("cmt", "Compositor"), + + /** + * Use for a person or organization responsible for the original idea on which a work is based, this includes the scientific author of an audio-visual item and the conceptor of an advertisement. + */ + CONCEPTOR("ccp", "Conceptor"), + + /** + * Use for a person who directs a performing group (orchestra, chorus, opera, etc.) in a musical or dramatic presentation or entertainment. + */ + CONDUCTOR("cnd", "Conductor"), + + /** + * Use for the named entity responsible for documenting, preserving, or treating printed or manuscript material, works of art, artifacts, or other media. + */ + CONSERVATOR("con", "Conservator"), + + /** + * Use for a person or organization relevant to a resource, who is called upon for professional advice or services in a specialized field of knowledge or training. + */ + CONSULTANT("csl", "Consultant"), + + /** + * Use for a person or organization relevant to a resource, who is engaged specifically to provide an intellectual overview of a strategic or operational task and by analysis, specification, or instruction, to create or propose a cost-effective course of action or solution. + */ + CONSULTANT_TO_A_PROJECT("csp", "Consultant to a project"), + + /** + * Use for the party who opposes, resists, or disputes, in a court of law, a claim, decision, result, etc. + */ + CONTESTANT("cos", "Contestant"), + + /** + * Use for a contestant who takes an appeal from one court of law or jurisdiction to another to reverse the judgment. + */ + CONTESTANT_APPELLANT("cot", "Contestant-appellant"), + + /** + * Use for a contestant against whom an appeal is taken from one court of law or jurisdiction to another to reverse the judgment. + */ + CONTESTANT_APPELLEE("coe", "Contestant-appellee"), + + /** + * Use for the party defending a claim, decision, result, etc. being opposed, resisted, or disputed in a court of law. + */ + CONTESTEE("cts", "Contestee"), + + /** + * Use for a contestee who takes an appeal from one court or jurisdiction to another to reverse the judgment. + */ + CONTESTEE_APPELLANT("ctt", "Contestee-appellant"), + + /** + * Use for a contestee against whom an appeal is taken from one court or jurisdiction to another to reverse the judgment. + */ + CONTESTEE_APPELLEE("cte", "Contestee-appellee"), + + /** + * Use for a person or organization relevant to a resource, who enters into a contract with another person or organization to perform a specific task. + */ + CONTRACTOR("ctr", "Contractor"), + + /** + * Use for a person or organization one whose work has been contributed to a larger work, such as an anthology, serial publication, or other compilation of individual works. Do not use if the sole function in relation to a work is as author, editor, compiler or translator. + */ + CONTRIBUTOR("ctb", "Contributor"), + + /** + * Use for a person or organization listed as a copyright owner at the time of registration. Copyright can be granted or later transferred to another person or organization, at which time the claimant becomes the copyright holder. + */ + COPYRIGHT_CLAIMANT("cpc", "Copyright claimant"), + + /** + * Use for a person or organization to whom copy and legal rights have been granted or transferred for the intellectual content of a work. The copyright holder, although not necessarily the creator of the work, usually has the exclusive right to benefit financially from the sale and use of the work to which the associated copyright protection applies. + */ + COPYRIGHT_HOLDER("cph", "Copyright holder"), + + /** + * Use for a person or organization who is a corrector of manuscripts, such as the scriptorium official who corrected the work of a scribe. For printed matter, use Proofreader. + */ + CORRECTOR("crr", "Corrector"), + + /** + * Use for a person or organization who was either the writer or recipient of a letter or other communication. + */ + CORRESPONDENT("crp", "Correspondent"), + + /** + * Use for a person or organization who designs or makes costumes, fixes hair, etc., for a musical or dramatic presentation or entertainment. + */ + COSTUME_DESIGNER("cst", "Costume designer"), + + /** + * Use for a person or organization responsible for the graphic design of a book cover, album cover, slipcase, box, container, etc. For a person or organization responsible for the graphic design of an entire book, use Book designer; for book jackets, use Bookjacket designer. + */ + COVER_DESIGNER("cov", "Cover designer"), + + /** + * Use for a person or organization responsible for the intellectual or artistic content of a work. + */ + CREATOR("cre", "Creator"), + + /** + * Use for a person or organization responsible for conceiving and organizing an exhibition. + */ + CURATOR_OF_AN_EXHIBITION("cur", "Curator of an exhibition"), + + /** + * Use for a person or organization who principally exhibits dancing skills in a musical or dramatic presentation or entertainment. + */ + DANCER("dnc", "Dancer"), + + /** + * Use for a person or organization that submits data for inclusion in a database or other collection of data. + */ + DATA_CONTRIBUTOR("dtc", "Data contributor"), + + /** + * Use for a person or organization responsible for managing databases or other data sources. + */ + DATA_MANAGER("dtm", "Data manager"), + + /** + * Use for a person or organization to whom a book, manuscript, etc., is dedicated (not the recipient of a gift). + */ + DEDICATEE("dte", "Dedicatee"), + + /** + * Use for the author of a dedication, which may be a formal statement or in epistolary or verse form. + */ + DEDICATOR("dto", "Dedicator"), + + /** + * Use for the party defending or denying allegations made in a suit and against whom relief or recovery is sought in the courts, usually in a legal action. + */ + DEFENDANT("dfd", "Defendant"), + + /** + * Use for a defendant who takes an appeal from one court or jurisdiction to another to reverse the judgment, usually in a legal action. + */ + DEFENDANT_APPELLANT("dft", "Defendant-appellant"), + + /** + * Use for a defendant against whom an appeal is taken from one court or jurisdiction to another to reverse the judgment, usually in a legal action. + */ + DEFENDANT_APPELLEE("dfe", "Defendant-appellee"), + + /** + * Use for the organization granting a degree for which the thesis or dissertation described was presented. + */ + DEGREE_GRANTOR("dgg", "Degree grantor"), + + /** + * Use for a person or organization executing technical drawings from others' designs. + */ + DELINEATOR("dln", "Delineator"), + + /** + * Use for an entity depicted or portrayed in a work, particularly in a work of art. + */ + DEPICTED("dpc", "Depicted"), + + /** + * Use for a person or organization placing material in the physical custody of a library or repository without transferring the legal title. + */ + DEPOSITOR("dpt", "Depositor"), + + /** + * Use for a person or organization responsible for the design if more specific codes (e.g., [bkd], [tyd]) are not desired. + */ + DESIGNER("dsr", "Designer"), + + /** + * Use for a person or organization who is responsible for the general management of a work or who supervises the production of a performance for stage, screen, or sound recording. + */ + DIRECTOR("drt", "Director"), + + /** + * Use for a person who presents a thesis for a university or higher-level educational degree. + */ + DISSERTANT("dis", "Dissertant"), + + /** + * Use for the name of a place from which a resource, e.g., a serial, is distributed. + */ + DISTRIBUTION_PLACE("dbp", "Distribution place"), + + /** + * Use for a person or organization that has exclusive or shared marketing rights for an item. + */ + DISTRIBUTOR("dst", "Distributor"), + + /** + * Use for a person or organization who is the donor of a book, manuscript, etc., to its present owner. Donors to previous owners are designated as Former owner [fmo] or Inscriber [ins]. + */ + DONOR("dnr", "Donor"), + + /** + * Use for a person or organization who prepares artistic or technical drawings. + */ + DRAFTSMAN("drm", "Draftsman"), + + /** + * Use for a person or organization to which authorship has been dubiously or incorrectly ascribed. + */ + DUBIOUS_AUTHOR("dub", "Dubious author"), + + /** + * Use for a person or organization who prepares for publication a work not primarily his/her own, such as by elucidating text, adding introductory or other critical matter, or technically directing an editorial staff. + */ + EDITOR("edt", "Editor"), + + /** + * Use for a person responsible for setting up a lighting rig and focusing the lights for a production, and running the lighting at a performance. + */ + ELECTRICIAN("elg", "Electrician"), + + /** + * Use for a person or organization who creates a duplicate printing surface by pressure molding and electrodepositing of metal that is then backed up with lead for printing. + */ + ELECTROTYPER("elt", "Electrotyper"), + + /** + * Use for a person or organization that is responsible for technical planning and design, particularly with construction. + */ + ENGINEER("eng", "Engineer"), + + /** + * Use for a person or organization who cuts letters, figures, etc. on a surface, such as a wooden or metal plate, for printing. + */ + ENGRAVER("egr", "Engraver"), + + /** + * Use for a person or organization who produces text or images for printing by subjecting metal, glass, or some other surface to acid or the corrosive action of some other substance. + */ + ETCHER("etr", "Etcher"), + + /** + * Use for the name of the place where an event such as a conference or a concert took place. + */ + EVENT_PLACE("evp", "Event place"), + + /** + * Use for a person or organization in charge of the description and appraisal of the value of goods, particularly rare items, works of art, etc. + */ + EXPERT("exp", "Expert"), + + /** + * Use for a person or organization that executed the facsimile. + */ + FACSIMILIST("fac", "Facsimilist"), + + /** + * Use for a person or organization that manages or supervises the work done to collect raw data or do research in an actual setting or environment (typically applies to the natural and social sciences). + */ + FIELD_DIRECTOR("fld", "Field director"), + + /** + * Use for a person or organization who is an editor of a motion picture film. This term is used regardless of the medium upon which the motion picture is produced or manufactured (e.g., acetate film, video tape). + */ + FILM_EDITOR("flm", "Film editor"), + + /** + * Use for a person or organization who is identified as the only party or the party of the first part. In the case of transfer of right, this is the assignor, transferor, licensor, grantor, etc. Multiple parties can be named jointly as the first party + */ + FIRST_PARTY("fpy", "First party"), + + /** + * Use for a person or organization who makes or imitates something of value or importance, especially with the intent to defraud. + */ + FORGER("frg", "Forger"), + + /** + * Use for a person or organization who owned an item at any time in the past. Includes those to whom the material was once presented. A person or organization giving the item to the present owner is designated as Donor [dnr] + */ + FORMER_OWNER("fmo", "Former owner"), + + /** + * Use for a person or organization that furnished financial support for the production of the work. + */ + FUNDER("fnd", "Funder"), + + /** + * Use for a person responsible for geographic information system (GIS) development and integration with global positioning system data. + */ + GEOGRAPHIC_INFORMATION_SPECIALIST("gis", "Geographic information specialist"), + + /** + * Use for a person or organization in memory or honor of whom a book, manuscript, etc. is donated. + */ + HONOREE("hnr", "Honoree"), + + /** + * Use for a person who is invited or regularly leads a program (often broadcast) that includes other guests, performers, etc. (e.g., talk show host). + */ + HOST("hst", "Host"), + + /** + * Use for a person or organization responsible for the decoration of a work (especially manuscript material) with precious metals or color, usually with elaborate designs and motifs. + */ + ILLUMINATOR("ilu", "Illuminator"), + + /** + * Use for a person or organization who conceives, and perhaps also implements, a design or illustration, usually to accompany a written text. + */ + ILLUSTRATOR("ill", "Illustrator"), + + /** + * Use for a person who signs a presentation statement. + */ + INSCRIBER("ins", "Inscriber"), + + /** + * Use for a person or organization who principally plays an instrument in a musical or dramatic presentation or entertainment. + */ + INSTRUMENTALIST("itr", "Instrumentalist"), + + /** + * Use for a person or organization who is interviewed at a consultation or meeting, usually by a reporter, pollster, or some other information gathering agent. + */ + INTERVIEWEE("ive", "Interviewee"), + + /** + * Use for a person or organization who acts as a reporter, pollster, or other information gathering agent in a consultation or meeting involving one or more individuals. + */ + INTERVIEWER("ivr", "Interviewer"), + + /** + * Use for a person or organization who first produces a particular useful item, or develops a new process for obtaining a known item or result. + */ + INVENTOR("inv", "Inventor"), + + /** + * Use for an institution that provides scientific analyses of material samples. + */ + LABORATORY("lbr", "Laboratory"), + + /** + * Use for a person or organization that manages or supervises work done in a controlled setting or environment. + */ + LABORATORY_DIRECTOR("ldr", "Laboratory director"), + + /** + * Use for a person or organization whose work involves coordinating the arrangement of existing and proposed land features and structures. + */ + LANDSCAPE_ARCHITECT("lsa", "Landscape architect"), + + /** + * Use to indicate that a person or organization takes primary responsibility for a particular activity or endeavor. Use with another relator term or code to show the greater importance this person or organization has regarding that particular role. If more than one relator is assigned to a heading, use the Lead relator only if it applies to all the relators. + */ + LEAD("led", "Lead"), + + /** + * Use for a person or organization permitting the temporary use of a book, manuscript, etc., such as for photocopying or microfilming. + */ + LENDER("len", "Lender"), + + /** + * Use for the party who files a libel in an ecclesiastical or admiralty case. + */ + LIBELANT("lil", "Libelant"), + + /** + * Use for a libelant who takes an appeal from one ecclesiastical court or admiralty to another to reverse the judgment. + */ + LIBELANT_APPELLANT("lit", "Libelant-appellant"), + + /** + * Use for a libelant against whom an appeal is taken from one ecclesiastical court or admiralty to another to reverse the judgment. + */ + LIBELANT_APPELLEE("lie", "Libelant-appellee"), + + /** + * Use for a party against whom a libel has been filed in an ecclesiastical court or admiralty. + */ + LIBELEE("lel", "Libelee"), + + /** + * Use for a libelee who takes an appeal from one ecclesiastical court or admiralty to another to reverse the judgment. + */ + LIBELEE_APPELLANT("let", "Libelee-appellant"), + + /** + * Use for a libelee against whom an appeal is taken from one ecclesiastical court or admiralty to another to reverse the judgment. + */ + LIBELEE_APPELLEE("lee", "Libelee-appellee"), + + /** + * Use for a person or organization who is a writer of the text of an opera, oratorio, etc. + */ + LIBRETTIST("lbt", "Librettist"), + + /** + * Use for a person or organization who is an original recipient of the right to print or publish. + */ + LICENSEE("lse", "Licensee"), + + /** + * Use for person or organization who is a signer of the license, imprimatur, etc. + */ + LICENSOR("lso", "Licensor"), + + /** + * Use for a person or organization who designs the lighting scheme for a theatrical presentation, entertainment, motion picture, etc. + */ + LIGHTING_DESIGNER("lgd", "Lighting designer"), + + /** + * Use for a person or organization who prepares the stone or plate for lithographic printing, including a graphic artist creating a design directly on the surface from which printing will be done. + */ + LITHOGRAPHER("ltg", "Lithographer"), + + /** + * Use for a person or organization who is a writer of the text of a song. + */ + LYRICIST("lyr", "Lyricist"), + + /** + * Use for a person or organization that makes an artifactual work (an object made or modified by one or more persons). Examples of artifactual works include vases, cannons or pieces of furniture. + */ + MANUFACTURER("mfr", "Manufacturer"), + + /** + * Use for the named entity responsible for marbling paper, cloth, leather, etc. used in construction of a resource. + */ + MARBLER("mrb", "Marbler"), + + /** + * Use for a person or organization performing the coding of SGML, HTML, or XML markup of metadata, text, etc. + */ + MARKUP_EDITOR("mrk", "Markup editor"), + + /** + * Use for a person or organization primarily responsible for compiling and maintaining the original description of a metadata set (e.g., geospatial metadata set). + */ + METADATA_CONTACT("mdc", "Metadata contact"), + + /** + * Use for a person or organization responsible for decorations, illustrations, letters, etc. cut on a metal surface for printing or decoration. + */ + METAL_ENGRAVER("mte", "Metal-engraver"), + + /** + * Use for a person who leads a program (often broadcast) where topics are discussed, usually with participation of experts in fields related to the discussion. + */ + MODERATOR("mod", "Moderator"), + + /** + * Use for a person or organization that supervises compliance with the contract and is responsible for the report and controls its distribution. Sometimes referred to as the grantee, or controlling agency. + */ + MONITOR("mon", "Monitor"), + + /** + * Use for a person who transcribes or copies musical notation + */ + MUSIC_COPYIST("mcp", "Music copyist"), + + /** + * Use for a person responsible for basic music decisions about a production, including coordinating the work of the composer, the sound editor, and sound mixers, selecting musicians, and organizing and/or conducting sound for rehearsals and performances. + */ + MUSICAL_DIRECTOR("msd", "Musical director"), + + /** + * Use for a person or organization who performs music or contributes to the musical content of a work when it is not possible or desirable to identify the function more precisely. + */ + MUSICIAN("mus", "Musician"), + + /** + * Use for a person who is a speaker relating the particulars of an act, occurrence, or course of events. + */ + NARRATOR("nrt", "Narrator"), + + /** + * Use for a person or organization responsible for opposing a thesis or dissertation. + */ + OPPONENT("opn", "Opponent"), + + /** + * Use for a person or organization responsible for organizing a meeting for which an item is the report or proceedings. + */ + ORGANIZER_OF_MEETING("orm", "Organizer of meeting"), + + /** + * Use for a person or organization performing the work, i.e., the name of a person or organization associated with the intellectual content of the work. This category does not include the publisher or personal affiliation, or sponsor except where it is also the corporate author. + */ + ORIGINATOR("org", "Originator"), + + /** + * Use for relator codes from other lists which have no equivalent in the MARC list or for terms which have not been assigned a code. + */ + OTHER("oth", "Other"), + + /** + * Use for a person or organization that currently owns an item or collection. + */ + OWNER("own", "Owner"), + + /** + * Use for a person or organization responsible for the production of paper, usually from wood, cloth, or other fibrous material. + */ + PAPERMAKER("ppm", "Papermaker"), + + /** + * Use for a person or organization that applied for a patent. + */ + PATENT_APPLICANT("pta", "Patent applicant"), + + /** + * Use for a person or organization that was granted the patent referred to by the item. + */ + PATENT_HOLDER("pth", "Patent holder"), + + /** + * Use for a person or organization responsible for commissioning a work. Usually a patron uses his or her means or influence to support the work of artists, writers, etc. This includes those who commission and pay for individual works. + */ + PATRON("pat", "Patron"), + + /** + * Use for a person or organization who exhibits musical or acting skills in a musical or dramatic presentation or entertainment, if specific codes for those functions ([act], [dnc], [itr], [voc], etc.) are not used. If specific codes are used, [prf] is used for a person whose principal skill is not known or specified. + */ + PERFORMER("prf", "Performer"), + + /** + * Use for an authority (usually a government agency) that issues permits under which work is accomplished. + */ + PERMITTING_AGENCY("pma", "Permitting agency"), + + /** + * Use for a person or organization responsible for taking photographs, whether they are used in their original form or as reproductions. + */ + PHOTOGRAPHER("pht", "Photographer"), + + /** + * Use for the party who complains or sues in court in a personal action, usually in a legal proceeding. + */ + PLAINTIFF("ptf", "Plaintiff"), + + /** + * Use for a plaintiff who takes an appeal from one court or jurisdiction to another to reverse the judgment, usually in a legal proceeding. + */ + PLAINTIFF_APPELLANT("ptt", "Plaintiff-appellant"), + + /** + * Use for a plaintiff against whom an appeal is taken from one court or jurisdiction to another to reverse the judgment, usually in a legal proceeding. + */ + PLAINTIFF_APPELLEE("pte", "Plaintiff-appellee"), + + /** + * Use for a person or organization responsible for the production of plates, usually for the production of printed images and/or text. + */ + PLATEMAKER("plt", "Platemaker"), + + /** + * Use for a person or organization who prints texts, whether from type or plates. + */ + PRINTER("prt", "Printer"), + + /** + * Use for a person or organization who prints illustrations from plates. + */ + PRINTER_OF_PLATES("pop", "Printer of plates"), + + /** + * Use for a person or organization who makes a relief, intaglio, or planographic printing surface. + */ + PRINTMAKER("prm", "Printmaker"), + + /** + * Use for a person or organization primarily responsible for performing or initiating a process, such as is done with the collection of metadata sets. + */ + PROCESS_CONTACT("prc", "Process contact"), + + /** + * Use for a person or organization responsible for the making of a motion picture, including business aspects, management of the productions, and the commercial success of the work. + */ + PRODUCER("pro", "Producer"), + + /** + * Use for a person responsible for all technical and business matters in a production. + */ + PRODUCTION_MANAGER("pmn", "Production manager"), + + /** + * Use for a person or organization associated with the production (props, lighting, special effects, etc.) of a musical or dramatic presentation or entertainment. + */ + PRODUCTION_PERSONNEL("prd", "Production personnel"), + + /** + * Use for a person or organization responsible for the creation and/or maintenance of computer program design documents, source code, and machine-executable digital files and supporting documentation. + */ + PROGRAMMER("prg", "Programmer"), + + /** + * Use for a person or organization with primary responsibility for all essential aspects of a project, or that manages a very large project that demands senior level responsibility, or that has overall responsibility for managing projects, or provides overall direction to a project manager. + */ + PROJECT_DIRECTOR("pdr", "Project director"), + + /** + * Use for a person who corrects printed matter. For manuscripts, use Corrector [crr]. + */ + PROOFREADER("pfr", "Proofreader"), + + /** + * Use for the name of the place where a resource is published. + */ + PUBLICATION_PLACE("pup", "Publication place"), + + /** + * Use for a person or organization that makes printed matter, often text, but also printed music, artwork, etc. available to the public. + */ + PUBLISHER("pbl", "Publisher"), + + /** + * Use for a person or organization who presides over the elaboration of a collective work to ensure its coherence or continuity. This includes editors-in-chief, literary editors, editors of series, etc. + */ + PUBLISHING_DIRECTOR("pbd", "Publishing director"), + + /** + * Use for a person or organization who manipulates, controls, or directs puppets or marionettes in a musical or dramatic presentation or entertainment. + */ + PUPPETEER("ppt", "Puppeteer"), + + /** + * Use for a person or organization to whom correspondence is addressed. + */ + RECIPIENT("rcp", "Recipient"), + + /** + * Use for a person or organization who supervises the technical aspects of a sound or video recording session. + */ + RECORDING_ENGINEER("rce", "Recording engineer"), + + /** + * Use for a person or organization who writes or develops the framework for an item without being intellectually responsible for its content. + */ + REDACTOR("red", "Redactor"), + + /** + * Use for a person or organization who prepares drawings of architectural designs (i.e., renderings) in accurate, representational perspective to show what the project will look like when completed. + */ + RENDERER("ren", "Renderer"), + + /** + * Use for a person or organization who writes or presents reports of news or current events on air or in print. + */ + REPORTER("rpt", "Reporter"), + + /** + * Use for an agency that hosts data or material culture objects and provides services to promote long term, consistent and shared use of those data or objects. + */ + REPOSITORY("rps", "Repository"), + + /** + * Use for a person who directed or managed a research project. + */ + RESEARCH_TEAM_HEAD("rth", "Research team head"), + + /** + * Use for a person who participated in a research project but whose role did not involve direction or management of it. + */ + RESEARCH_TEAM_MEMBER("rtm", "Research team member"), + + /** + * Use for a person or organization responsible for performing research. + */ + RESEARCHER("res", "Researcher"), + + /** + * Use for the party who makes an answer to the courts pursuant to an application for redress, usually in an equity proceeding. + */ + RESPONDENT("rsp", "Respondent"), + + /** + * Use for a respondent who takes an appeal from one court or jurisdiction to another to reverse the judgment, usually in an equity proceeding. + */ + RESPONDENT_APPELLANT("rst", "Respondent-appellant"), + + /** + * Use for a respondent against whom an appeal is taken from one court or jurisdiction to another to reverse the judgment, usually in an equity proceeding. + */ + RESPONDENT_APPELLEE("rse", "Respondent-appellee"), + + /** + * Use for a person or organization legally responsible for the content of the published material. + */ + RESPONSIBLE_PARTY("rpy", "Responsible party"), + + /** + * Use for a person or organization, other than the original choreographer or director, responsible for restaging a choreographic or dramatic work and who contributes minimal new content. + */ + RESTAGER("rsg", "Restager"), + + /** + * Use for a person or organization responsible for the review of a book, motion picture, performance, etc. + */ + REVIEWER("rev", "Reviewer"), + + /** + * Use for a person or organization responsible for parts of a work, often headings or opening parts of a manuscript, that appear in a distinctive color, usually red. + */ + RUBRICATOR("rbr", "Rubricator"), + + /** + * Use for a person or organization who is the author of a motion picture screenplay. + */ + SCENARIST("sce", "Scenarist"), + + /** + * Use for a person or organization who brings scientific, pedagogical, or historical competence to the conception and realization on a work, particularly in the case of audio-visual items. + */ + SCIENTIFIC_ADVISOR("sad", "Scientific advisor"), + + /** + * Use for a person who is an amanuensis and for a writer of manuscripts proper. For a person who makes pen-facsimiles, use Facsimilist [fac]. + */ + SCRIBE("scr", "Scribe"), + + /** + * Use for a person or organization who models or carves figures that are three-dimensional representations. + */ + SCULPTOR("scl", "Sculptor"), + + /** + * Use for a person or organization who is identified as the party of the second part. In the case of transfer of right, this is the assignee, transferee, licensee, grantee, etc. Multiple parties can be named jointly as the second party. + */ + SECOND_PARTY("spy", "Second party"), + + /** + * Use for a person or organization who is a recorder, redactor, or other person responsible for expressing the views of a organization. + */ + SECRETARY("sec", "Secretary"), + + /** + * Use for a person or organization who translates the rough sketches of the art director into actual architectural structures for a theatrical presentation, entertainment, motion picture, etc. Set designers draw the detailed guides and specifications for building the set. + */ + SET_DESIGNER("std", "Set designer"), + + /** + * Use for a person whose signature appears without a presentation or other statement indicative of provenance. When there is a presentation statement, use Inscriber [ins]. + */ + SIGNER("sgn", "Signer"), + + /** + * Use for a person or organization who uses his/her/their voice with or without instrumental accompaniment to produce music. A performance may or may not include actual words. + */ + SINGER("sng", "Singer"), + + /** + * Use for a person who produces and reproduces the sound score (both live and recorded), the installation of microphones, the setting of sound levels, and the coordination of sources of sound for a production. + */ + SOUND_DESIGNER("sds", "Sound designer"), + + /** + * Use for a person who participates in a program (often broadcast) and makes a formalized contribution or presentation generally prepared in advance. + */ + SPEAKER("spk", "Speaker"), + + /** + * Use for a person or organization that issued a contract or under the auspices of which a work has been written, printed, published, etc. + */ + SPONSOR("spn", "Sponsor"), + + /** + * Use for a person who is in charge of everything that occurs on a performance stage, and who acts as chief of all crews and assistant to a director during rehearsals. + */ + STAGE_MANAGER("stm", "Stage manager"), + + /** + * Use for an organization responsible for the development or enforcement of a standard. + */ + STANDARDS_BODY("stn", "Standards body"), + + /** + * Use for a person or organization who creates a new plate for printing by molding or copying another printing surface. + */ + STEREOTYPER("str", "Stereotyper"), + + /** + * Use for a person relaying a story with creative and/or theatrical interpretation. + */ + STORYTELLER("stl", "Storyteller"), + + /** + * Use for a person or organization that supports (by allocating facilities, staff, or other resources) a project, program, meeting, event, data objects, material culture objects, or other entities capable of support. + */ + SUPPORTING_HOST("sht", "Supporting host"), + + /** + * Use for a person or organization who does measurements of tracts of land, etc. to determine location, forms, and boundaries. + */ + SURVEYOR("srv", "Surveyor"), + + /** + * Use for a person who, in the context of a resource, gives instruction in an intellectual subject or demonstrates while teaching physical skills. + */ + TEACHER("tch", "Teacher"), + + /** + * Use for a person who is ultimately in charge of scenery, props, lights and sound for a production. + */ + TECHNICAL_DIRECTOR("tcd", "Technical director"), + + /** + * Use for a person under whose supervision a degree candidate develops and presents a thesis, mémoire, or text of a dissertation. + */ + THESIS_ADVISOR("ths", "Thesis advisor"), + + /** + * Use for a person who prepares a handwritten or typewritten copy from original material, including from dictated or orally recorded material. For makers of pen-facsimiles, use Facsimilist [fac]. + */ + TRANSCRIBER("trc", "Transcriber"), + + /** + * Use for a person or organization who renders a text from one language into another, or from an older form of a language into the modern form. + */ + TRANSLATOR("trl", "Translator"), + + /** + * Use for a person or organization who designed the type face used in a particular item. + */ + TYPE_DESIGNER("tyd", "Type designer"), + + /** + * Use for a person or organization primarily responsible for choice and arrangement of type used in an item. If the typographer is also responsible for other aspects of the graphic design of a book (e.g., Book designer [bkd]), codes for both functions may be needed. + */ + TYPOGRAPHER("tyg", "Typographer"), + + /** + * Use for the name of a place where a university that is associated with a resource is located, for example, a university where an academic dissertation or thesis was presented. + */ + UNIVERSITY_PLACE("uvp", "University place"), + + /** + * Use for a person or organization in charge of a video production, e.g. the video recording of a stage production as opposed to a commercial motion picture. The videographer may be the camera operator or may supervise one or more camera operators. Do not confuse with cinematographer. + */ + VIDEOGRAPHER("vdg", "Videographer"), + + /** + * Use for a person or organization who principally exhibits singing skills in a musical or dramatic presentation or entertainment. + */ + VOCALIST("voc", "Vocalist"), + + /** + * Use for a person who verifies the truthfulness of an event or action. + */ + WITNESS("wit", "Witness"), + + /** + * Use for a person or organization who makes prints by cutting the image in relief on the end-grain of a wood block. + */ + WOOD_ENGRAVER("wde", "Wood-engraver"), + + /** + * Use for a person or organization who makes prints by cutting the image in relief on the plank side of a wood block. + */ + WOODCUTTER("wdc", "Woodcutter"), + + /** + * Use for a person or organization who writes significant material which accompanies a sound recording or other audiovisual material. + */ + WRITER_OF_ACCOMPANYING_MATERIAL("wam", "Writer of accompanying material"); + + private final String code; + private final String name; + + Relator(String code, String name) { + this.code = code; + this.name = name; + } + + public String getCode() { + return code; + } + + public String getName() { + return name; + } + + public static Relator byCode(String code) { + for (Relator relator : Relator.values()) { + if (relator.getCode().equalsIgnoreCase(code)) { + return relator; + } + } + return null; + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Resource.java b/epublib/src/main/java/me/ag2s/epublib/domain/Resource.java new file mode 100644 index 000000000..2349c9e91 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Resource.java @@ -0,0 +1,339 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.util.IOUtil; +import me.ag2s.epublib.util.StringUtil; +import me.ag2s.epublib.util.commons.io.XmlStreamReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.Serializable; + +/** + * Represents a resource that is part of the epub. + * A resource can be a html file, image, xml, etc. + * + * @author paul + * + */ +public class Resource implements Serializable { + + private static final long serialVersionUID = 1043946707835004037L; + private String id; + private String title; + private String href; + + + + private String properties; + protected final String originalHref; + private MediaType mediaType; + private String inputEncoding; + protected byte[] data; + + /** + * Creates an empty Resource with the given href. + * + * Assumes that if the data is of a text type (html/css/etc) then the encoding will be UTF-8 + * + * @param href The location of the resource within the epub. Example: "chapter1.html". + */ + public Resource(String href) { + this(null, new byte[0], href, MediaTypes.determineMediaType(href)); + } + + /** + * Creates a Resource with the given data and MediaType. + * The href will be automatically generated. + * + * Assumes that if the data is of a text type (html/css/etc) then the encoding will be UTF-8 + * + * @param data The Resource's contents + * @param mediaType The MediaType of the Resource + */ + public Resource(byte[] data, MediaType mediaType) { + this(null, data, null, mediaType); + } + + /** + * Creates a resource with the given data at the specified href. + * The MediaType will be determined based on the href extension. + * + * Assumes that if the data is of a text type (html/css/etc) then the encoding will be UTF-8 + * + * @see MediaTypes#determineMediaType(String) + * + * @param data The Resource's contents + * @param href The location of the resource within the epub. Example: "chapter1.html". + */ + public Resource(byte[] data, String href) { + this(null, data, href, MediaTypes.determineMediaType(href), + Constants.CHARACTER_ENCODING); + } + + /** + * Creates a resource with the data from the given Reader at the specified href. + * The MediaType will be determined based on the href extension. + * + * @see MediaTypes#determineMediaType(String) + * + * @param in The Resource's contents + * @param href The location of the resource within the epub. Example: "cover.jpg". + */ + public Resource(Reader in, String href) throws IOException { + this(null, IOUtil.toByteArray(in, Constants.CHARACTER_ENCODING), href, + MediaTypes.determineMediaType(href), + Constants.CHARACTER_ENCODING); + } + + /** + * Creates a resource with the data from the given InputStream at the specified href. + * The MediaType will be determined based on the href extension. + * + * @see MediaTypes#determineMediaType(String) + * + * Assumes that if the data is of a text type (html/css/etc) then the encoding will be UTF-8 + * + * It is recommended to us the {@link #Resource(Reader, String)} method for creating textual + * (html/css/etc) resources to prevent encoding problems. + * Use this method only for binary Resources like images, fonts, etc. + * + * + * @param in The Resource's contents + * @param href The location of the resource within the epub. Example: "cover.jpg". + */ + public Resource(InputStream in, String href) throws IOException { + this(null, IOUtil.toByteArray(in), href, + MediaTypes.determineMediaType(href)); + } + + /** + * Creates a resource with the given id, data, mediatype at the specified href. + * Assumes that if the data is of a text type (html/css/etc) then the encoding will be UTF-8 + * + * @param id The id of the Resource. Internal use only. Will be auto-generated if it has a null-value. + * @param data The Resource's contents + * @param href The location of the resource within the epub. Example: "chapter1.html". + * @param mediaType The resources MediaType + */ + public Resource(String id, byte[] data, String href, MediaType mediaType) { + this(id, data, href, mediaType, Constants.CHARACTER_ENCODING); + } + public Resource(String id, byte[] data, String href, String originalHref, MediaType mediaType) { + this(id, data, href, originalHref, mediaType, Constants.CHARACTER_ENCODING); + } + + + /** + * Creates a resource with the given id, data, mediatype at the specified href. + * If the data is of a text type (html/css/etc) then it will use the given inputEncoding. + * + * @param id The id of the Resource. Internal use only. Will be auto-generated if it has a null-value. + * @param data The Resource's contents + * @param href The location of the resource within the epub. Example: "chapter1.html". + * @param mediaType The resources MediaType + * @param inputEncoding If the data is of a text type (html/css/etc) then it will use the given inputEncoding. + */ + public Resource(String id, byte[] data, String href, MediaType mediaType, + String inputEncoding) { + this.id = id; + this.href = href; + this.originalHref = href; + this.mediaType = mediaType; + this.inputEncoding = inputEncoding; + this.data = data; + } + public Resource(String id, byte[] data, String href, String originalHref, MediaType mediaType, + String inputEncoding) { + this.id = id; + this.href = href; + this.originalHref = originalHref; + this.mediaType = mediaType; + this.inputEncoding = inputEncoding; + this.data = data; + } + + /** + * Gets the contents of the Resource as an InputStream. + * + * @return The contents of the Resource. + * + * @throws IOException IOException + */ + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(getData()); + } + + /** + * The contents of the resource as a byte[] + * + * @return The contents of the resource + */ + public byte[] getData() throws IOException { + return data; + } + + /** + * Tells this resource to release its cached data. + * + * If this resource was not lazy-loaded, this is a no-op. + */ + public void close() { + } + + /** + * Sets the data of the Resource. + * If the data is a of a different type then the original data then make sure to change the MediaType. + * + * @param data the data of the Resource + */ + public void setData(byte[] data) { + this.data = data; + } + + /** + * Returns the size of this resource in bytes. + * + * @return the size. + */ + public long getSize() { + return data.length; + } + + /** + * If the title is found by scanning the underlying html document then it is cached here. + * + * @return the title + */ + public String getTitle() { + return title; + } + + /** + * Sets the Resource's id: Make sure it is unique and a valid identifier. + * + * @param id Resource's id + */ + public void setId(String id) { + this.id = id; + } + + /** + * The resources Id. + * + * Must be both unique within all the resources of this book and a valid identifier. + * @return The resources Id. + */ + public String getId() { + return id; + } + + /** + * The location of the resource within the contents folder of the epub file. + * + * Example:
    + * images/cover.jpg
    + * content/chapter1.xhtml
    + * + * @return The location of the resource within the contents folder of the epub file. + */ + public String getHref() { + return href; + } + + /** + * Sets the Resource's href. + * + * @param href Resource's href. + */ + public void setHref(String href) { + this.href = href; + } + + /** + * The character encoding of the resource. + * Is allowed to be null for non-text resources like images. + * + * @return The character encoding of the resource. + */ + public String getInputEncoding() { + return inputEncoding; + } + + /** + * Sets the Resource's input character encoding. + * + * @param encoding Resource's input character encoding. + */ + public void setInputEncoding(String encoding) { + this.inputEncoding = encoding; + } + + /** + * Gets the contents of the Resource as Reader. + * + * Does all sorts of smart things (courtesy of apache commons io XMLStreamREader) to handle encodings, byte order markers, etc. + * + * @return the contents of the Resource as Reader. + * @throws IOException IOException + */ + public Reader getReader() throws IOException { + return new XmlStreamReader(new ByteArrayInputStream(getData()), + getInputEncoding()); + } + + /** + * Gets the hashCode of the Resource's href. + * + */ + public int hashCode() { + return href.hashCode(); + } + + /** + * Checks to see of the given resourceObject is a resource and whether its href is equal to this one. + * + * @return whether the given resourceObject is a resource and whether its href is equal to this one. + */ + public boolean equals(Object resourceObject) { + if (!(resourceObject instanceof Resource)) { + return false; + } + return href.equals(((Resource) resourceObject).getHref()); + } + + /** + * This resource's mediaType. + * + * @return This resource's mediaType. + */ + public MediaType getMediaType() { + return mediaType; + } + + public void setMediaType(MediaType mediaType) { + this.mediaType = mediaType; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getProperties() { + return properties; + } + + public void setProperties(String properties) { + this.properties = properties; + } + @SuppressWarnings("NullableProblems") + public String toString() { + return StringUtil.toString("id", id, + "title", title, + "encoding", inputEncoding, + "mediaType", mediaType, + "href", href, + "size", (data == null ? 0 : data.length)); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/ResourceInputStream.java b/epublib/src/main/java/me/ag2s/epublib/domain/ResourceInputStream.java new file mode 100644 index 000000000..d6aa68f86 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/ResourceInputStream.java @@ -0,0 +1,36 @@ +package me.ag2s.epublib.domain; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipFile; + +/** + * A wrapper class for closing a ZipFile object when the InputStream derived + * from it is closed. + * + * @author ttopalov + */ +public class ResourceInputStream extends FilterInputStream { + + private final ZipFile zipFile; + + /** + * Constructor. + * + * @param in + * The InputStream object. + * @param zipFile + * The ZipFile object. + */ + public ResourceInputStream(InputStream in, ZipFile zipFile) { + super(in); + this.zipFile = zipFile; + } + + @Override + public void close() throws IOException { + super.close(); + zipFile.close(); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/ResourceReference.java b/epublib/src/main/java/me/ag2s/epublib/domain/ResourceReference.java new file mode 100644 index 000000000..f09c97643 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/ResourceReference.java @@ -0,0 +1,43 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; + +public class ResourceReference implements Serializable { + + private static final long serialVersionUID = 2596967243557743048L; + + protected Resource resource; + + public ResourceReference(Resource resource) { + this.resource = resource; + } + + + public Resource getResource() { + return resource; + } + + /** + * Besides setting the resource it also sets the fragmentId to null. + * + * @param resource resource + */ + public void setResource(Resource resource) { + this.resource = resource; + } + + + /** + * The id of the reference referred to. + * + * null of the reference is null or has a null id itself. + * + * @return The id of the reference referred to. + */ + public String getResourceId() { + if (resource != null) { + return resource.getId(); + } + return null; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Resources.java b/epublib/src/main/java/me/ag2s/epublib/domain/Resources.java new file mode 100644 index 000000000..27d512700 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Resources.java @@ -0,0 +1,402 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.util.StringUtil; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * All the resources that make up the book. + * XHTML files, images and epub xml documents must be here. + * + * @author paul + */ +public class Resources implements Serializable { + + private static final long serialVersionUID = 2450876953383871451L; + private static final String IMAGE_PREFIX = "image_"; + private static final String ITEM_PREFIX = "item_"; + private int lastId = 1; + + private Map resources = new HashMap<>(); + + /** + * Adds a resource to the resources. + *

    + * Fixes the resources id and href if necessary. + * + * @param resource resource + * @return the newly added resource + */ + public Resource add(Resource resource) { + fixResourceHref(resource); + fixResourceId(resource); + this.resources.put(resource.getHref(), resource); + return resource; + } + + /** + * Checks the id of the given resource and changes to a unique identifier if it isn't one already. + * + * @param resource resource + */ + public void fixResourceId(Resource resource) { + String resourceId = resource.getId(); + + // first try and create a unique id based on the resource's href + if (StringUtil.isBlank(resource.getId())) { + resourceId = StringUtil.substringBeforeLast(resource.getHref(), '.'); + resourceId = StringUtil.substringAfterLast(resourceId, '/'); + } + + resourceId = makeValidId(resourceId, resource); + + // check if the id is unique. if not: create one from scratch + if (StringUtil.isBlank(resourceId) || containsId(resourceId)) { + resourceId = createUniqueResourceId(resource); + } + resource.setId(resourceId); + } + + /** + * Check if the id is a valid identifier. if not: prepend with valid identifier + * + * @param resource resource + * @return a valid id + */ + private String makeValidId(String resourceId, Resource resource) { + if (StringUtil.isNotBlank(resourceId) && !Character + .isJavaIdentifierStart(resourceId.charAt(0))) { + resourceId = getResourceItemPrefix(resource) + resourceId; + } + return resourceId; + } + + private String getResourceItemPrefix(Resource resource) { + String result; + if (MediaTypes.isBitmapImage(resource.getMediaType())) { + result = IMAGE_PREFIX; + } else { + result = ITEM_PREFIX; + } + return result; + } + + /** + * Creates a new resource id that is guaranteed to be unique for this set of Resources + * + * @param resource resource + * @return a new resource id that is guaranteed to be unique for this set of Resources + */ + private String createUniqueResourceId(Resource resource) { + int counter = lastId; + if (counter == Integer.MAX_VALUE) { + if (resources.size() == Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "Resources contains " + Integer.MAX_VALUE + + " elements: no new elements can be added"); + } else { + counter = 1; + } + } + String prefix = getResourceItemPrefix(resource); + String result = prefix + counter; + while (containsId(result)) { + result = prefix + (++counter); + } + lastId = counter; + return result; + } + + /** + * Whether the map of resources already contains a resource with the given id. + * + * @param id id + * @return Whether the map of resources already contains a resource with the given id. + */ + public boolean containsId(String id) { + if (StringUtil.isBlank(id)) { + return false; + } + for (Resource resource : resources.values()) { + if (id.equals(resource.getId())) { + return true; + } + } + return false; + } + + /** + * Gets the resource with the given id. + * + * @param id id + * @return null if not found + */ + public Resource getById(String id) { + if (StringUtil.isBlank(id)) { + return null; + } + for (Resource resource : resources.values()) { + if (id.equals(resource.getId())) { + return resource; + } + } + return null; + } + + public Resource getByProperties(String properties) { + if (StringUtil.isBlank(properties)) { + return null; + } + for (Resource resource : resources.values()) { + if (properties.equals(resource.getProperties())) { + return resource; + } + } + return null; + } + + /** + * Remove the resource with the given href. + * + * @param href href + * @return the removed resource, null if not found + */ + public Resource remove(String href) { + return resources.remove(href); + } + + private void fixResourceHref(Resource resource) { + if (StringUtil.isNotBlank(resource.getHref()) + && !resources.containsKey(resource.getHref())) { + return; + } + if (StringUtil.isBlank(resource.getHref())) { + if (resource.getMediaType() == null) { + throw new IllegalArgumentException( + "Resource must have either a MediaType or a href"); + } + int i = 1; + String href = createHref(resource.getMediaType(), i); + while (resources.containsKey(href)) { + href = createHref(resource.getMediaType(), (++i)); + } + resource.setHref(href); + } + } + + private String createHref(MediaType mediaType, int counter) { + if (MediaTypes.isBitmapImage(mediaType)) { + return IMAGE_PREFIX + counter + mediaType.getDefaultExtension(); + } else { + return ITEM_PREFIX + counter + mediaType.getDefaultExtension(); + } + } + + + public boolean isEmpty() { + return resources.isEmpty(); + } + + /** + * The number of resources + * + * @return The number of resources + */ + public int size() { + return resources.size(); + } + + /** + * The resources that make up this book. + * Resources can be xhtml pages, images, xml documents, etc. + * + * @return The resources that make up this book. + */ + @SuppressWarnings("unused") + public Map getResourceMap() { + return resources; + } + + public Collection getAll() { + return resources.values(); + } + + + /** + * Whether there exists a resource with the given href + * + * @param href href + * @return Whether there exists a resource with the given href + */ + public boolean notContainsByHref(String href) { + if (StringUtil.isBlank(href)) { + return true; + } else { + return !resources.containsKey( + StringUtil.substringBefore(href, Constants.FRAGMENT_SEPARATOR_CHAR)); + } + } + /** + * Whether there exists a resource with the given href + * + * @param href href + * @return Whether there exists a resource with the given href + */ + @SuppressWarnings("unused") + public boolean containsByHref(String href) { + return !notContainsByHref(href); + } + + /** + * Sets the collection of Resources to the given collection of resources + * + * @param resources resources + */ + public void set(Collection resources) { + this.resources.clear(); + addAll(resources); + } + + /** + * Adds all resources from the given Collection of resources to the existing collection. + * + * @param resources resources + */ + public void addAll(Collection resources) { + for (Resource resource : resources) { + fixResourceHref(resource); + this.resources.put(resource.getHref(), resource); + } + } + + /** + * Sets the collection of Resources to the given collection of resources + * + * @param resources A map with as keys the resources href and as values the Resources + */ + public void set(Map resources) { + this.resources = new HashMap<>(resources); + } + + + /** + * First tries to find a resource with as id the given idOrHref, if that + * fails it tries to find one with the idOrHref as href. + * + * @param idOrHref idOrHref + * @return the found Resource + */ + public Resource getByIdOrHref(String idOrHref) { + Resource resource = getById(idOrHref); + if (resource == null) { + resource = getByHref(idOrHref); + } + return resource; + } + + + /** + * Gets the resource with the given href. + * If the given href contains a fragmentId then that fragment id will be ignored. + * + * @param href href + * @return null if not found. + */ + public Resource getByHref(String href) { + if (StringUtil.isBlank(href)) { + return null; + } + href = StringUtil.substringBefore(href, Constants.FRAGMENT_SEPARATOR_CHAR); + return resources.get(href); + } + + /** + * Gets the first resource (random order) with the give mediatype. + *

    + * Useful for looking up the table of contents as it's supposed to be the only resource with NCX mediatype. + * + * @param mediaType mediaType + * @return the first resource (random order) with the give mediatype. + */ + public Resource findFirstResourceByMediaType(MediaType mediaType) { + return findFirstResourceByMediaType(resources.values(), mediaType); + } + + /** + * Gets the first resource (random order) with the give mediatype. + *

    + * Useful for looking up the table of contents as it's supposed to be the only resource with NCX mediatype. + * + * @param mediaType mediaType + * @return the first resource (random order) with the give mediatype. + */ + public static Resource findFirstResourceByMediaType( + Collection resources, MediaType mediaType) { + for (Resource resource : resources) { + if (resource.getMediaType() == mediaType) { + return resource; + } + } + return null; + } + + /** + * All resources that have the given MediaType. + * + * @param mediaType mediaType + * @return All resources that have the given MediaType. + */ + public List getResourcesByMediaType(MediaType mediaType) { + List result = new ArrayList<>(); + if (mediaType == null) { + return result; + } + for (Resource resource : getAll()) { + if (resource.getMediaType() == mediaType) { + result.add(resource); + } + } + return result; + } + + /** + * All Resources that match any of the given list of MediaTypes + * + * @param mediaTypes mediaType + * @return All Resources that match any of the given list of MediaTypes + */ + @SuppressWarnings("unused") + public List getResourcesByMediaTypes(MediaType[] mediaTypes) { + List result = new ArrayList<>(); + if (mediaTypes == null) { + return result; + } + + // this is the fastest way of doing this according to + // http://stackoverflow.com/questions/1128723/in-java-how-can-i-test-if-an-array-contains-a-certain-value + List mediaTypesList = Arrays.asList(mediaTypes); + for (Resource resource : getAll()) { + if (mediaTypesList.contains(resource.getMediaType())) { + result.add(resource); + } + } + return result; + } + + + /** + * All resource hrefs + * + * @return all resource hrefs + */ + public Collection getAllHrefs() { + return resources.keySet(); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/Spine.java b/epublib/src/main/java/me/ag2s/epublib/domain/Spine.java new file mode 100644 index 000000000..293837086 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/Spine.java @@ -0,0 +1,191 @@ +package me.ag2s.epublib.domain; + +import me.ag2s.epublib.util.StringUtil; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * The spine sections are the sections of the book in the order in which the book should be read. + * + * This contrasts with the Table of Contents sections which is an index into the Book's sections. + * + * @see TableOfContents + * + * @author paul + */ +public class Spine implements Serializable { + + private static final long serialVersionUID = 3878483958947357246L; + private Resource tocResource; + private List spineReferences; + + public Spine() { + this(new ArrayList<>()); + } + + /** + * Creates a spine out of all the resources in the table of contents. + * + * @param tableOfContents tableOfContents + */ + public Spine(TableOfContents tableOfContents) { + this.spineReferences = createSpineReferences( + tableOfContents.getAllUniqueResources()); + } + + public Spine(List spineReferences) { + this.spineReferences = spineReferences; + } + + public static List createSpineReferences( + Collection resources) { + List result = new ArrayList<>( + resources.size()); + for (Resource resource : resources) { + result.add(new SpineReference(resource)); + } + return result; + } + + public List getSpineReferences() { + return spineReferences; + } + + public void setSpineReferences(List spineReferences) { + this.spineReferences = spineReferences; + } + + /** + * Gets the resource at the given index. + * Null if not found. + * + * @param index index + * @return the resource at the given index. + */ + public Resource getResource(int index) { + if (index < 0 || index >= spineReferences.size()) { + return null; + } + return spineReferences.get(index).getResource(); + } + + /** + * Finds the first resource that has the given resourceId. + * + * Null if not found. + * + * @param resourceId resourceId + * @return the first resource that has the given resourceId. + */ + public int findFirstResourceById(String resourceId) { + if (StringUtil.isBlank(resourceId)) { + return -1; + } + + for (int i = 0; i < spineReferences.size(); i++) { + SpineReference spineReference = spineReferences.get(i); + if (resourceId.equals(spineReference.getResourceId())) { + return i; + } + } + return -1; + } + + /** + * Adds the given spineReference to the spine references and returns it. + * + * @param spineReference spineReference + * @return the given spineReference + */ + public SpineReference addSpineReference(SpineReference spineReference) { + if (spineReferences == null) { + this.spineReferences = new ArrayList<>(); + } + spineReferences.add(spineReference); + return spineReference; + } + + /** + * Adds the given resource to the spine references and returns it. + * + * @return the given spineReference + */ + @SuppressWarnings("unused") + public SpineReference addResource(Resource resource) { + return addSpineReference(new SpineReference(resource)); + } + + /** + * The number of elements in the spine. + * + * @return The number of elements in the spine. + */ + public int size() { + return spineReferences.size(); + } + + /** + * As per the epub file format the spine officially maintains a reference to the Table of Contents. + * The epubwriter will look for it here first, followed by some clever tricks to find it elsewhere if not found. + * Put it here to be sure of the expected behaviours. + * + * @param tocResource tocResource + */ + public void setTocResource(Resource tocResource) { + this.tocResource = tocResource; + } + + /** + * The resource containing the XML for the tableOfContents. + * When saving an epub file this resource needs to be in this place. + * + * @return The resource containing the XML for the tableOfContents. + */ + public Resource getTocResource() { + return tocResource; + } + + /** + * The position within the spine of the given resource. + * + * @param currentResource currentResource + * @return something < 0 if not found. + * + */ + public int getResourceIndex(Resource currentResource) { + if (currentResource == null) { + return -1; + } + return getResourceIndex(currentResource.getHref()); + } + + /** + * The first position within the spine of a resource with the given href. + * + * @return something < 0 if not found. + * + */ + public int getResourceIndex(String resourceHref) { + int result = -1; + if (StringUtil.isBlank(resourceHref)) { + return result; + } + for (int i = 0; i < spineReferences.size(); i++) { + if (resourceHref.equals(spineReferences.get(i).getResource().getHref())) { + result = i; + break; + } + } + return result; + } + + /** + * Whether the spine has any references + * @return Whether the spine has any references + */ + public boolean isEmpty() { + return spineReferences.isEmpty(); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/SpineReference.java b/epublib/src/main/java/me/ag2s/epublib/domain/SpineReference.java new file mode 100644 index 000000000..135e4e6bf --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/SpineReference.java @@ -0,0 +1,52 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; + + +/** + * A Section of a book. + * Represents both an item in the package document and a item in the index. + * + * @author paul + */ +public class SpineReference extends ResourceReference implements Serializable { + + private static final long serialVersionUID = -7921609197351510248L; + private boolean linear;//default = true; + + public SpineReference(Resource resource) { + this(resource, true); + } + + + public SpineReference(Resource resource, boolean linear) { + super(resource); + this.linear = linear; + } + + /** + * Linear denotes whether the section is Primary or Auxiliary. + * Usually the cover page has linear set to false and all the other sections + * have it set to true. + *

    + * It's an optional property that readers may also ignore. + * + *

    primary or auxiliary is useful for Reading Systems which + * opt to present auxiliary content differently than primary content. + * For example, a Reading System might opt to render auxiliary content in + * a popup window apart from the main window which presents the primary + * content. (For an example of the types of content that may be considered + * auxiliary, refer to the example below and the subsequent discussion.)
    + * + * @return whether the section is Primary or Auxiliary. + * @see OPF Spine specification + */ + public boolean isLinear() { + return linear; + } + + public void setLinear(boolean linear) { + this.linear = linear; + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/TOCReference.java b/epublib/src/main/java/me/ag2s/epublib/domain/TOCReference.java new file mode 100644 index 000000000..5fe9c71bb --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/TOCReference.java @@ -0,0 +1,56 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * An item in the Table of Contents. + * + * @see TableOfContents + * + * @author paul + */ +public class TOCReference extends TitledResourceReference + implements Serializable { + + private static final long serialVersionUID = 5787958246077042456L; + private List children; + private static final Comparator COMPARATOR_BY_TITLE_IGNORE_CASE = (tocReference1, tocReference2) -> String.CASE_INSENSITIVE_ORDER.compare(tocReference1.getTitle(), tocReference2.getTitle()); + @Deprecated + public TOCReference() { + this(null, null, null); + } + + public TOCReference(String name, Resource resource) { + this(name, resource, null); + } + + public TOCReference(String name, Resource resource, String fragmentId) { + this(name, resource, fragmentId, new ArrayList<>()); + } + + public TOCReference(String title, Resource resource, String fragmentId, + List children) { + super(resource, title, fragmentId); + this.children = children; + } + @SuppressWarnings("unused") + public static Comparator getComparatorByTitleIgnoreCase() { + return COMPARATOR_BY_TITLE_IGNORE_CASE; + } + + public List getChildren() { + return children; + } + + public TOCReference addChildSection(TOCReference childSection) { + this.children.add(childSection); + return childSection; + } + + public void setChildren(List children) { + this.children = children; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/TableOfContents.java b/epublib/src/main/java/me/ag2s/epublib/domain/TableOfContents.java new file mode 100644 index 000000000..7692d258d --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/TableOfContents.java @@ -0,0 +1,264 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The table of contents of the book. + * The TableOfContents is a tree structure at the root it is a list of TOCReferences, each if which may have as children another list of TOCReferences. + *

    + * The table of contents is used by epub as a quick index to chapters and sections within chapters. + * It may contain duplicate entries, may decide to point not to certain chapters, etc. + *

    + * See the spine for the complete list of sections in the order in which they should be read. + * + * @author paul + * @see Spine + */ +public class TableOfContents implements Serializable { + + private static final long serialVersionUID = -3147391239966275152L; + + public static final String DEFAULT_PATH_SEPARATOR = "/"; + + private List tocReferences; + + public TableOfContents() { + this(new ArrayList<>()); + } + + public TableOfContents(List tocReferences) { + this.tocReferences = tocReferences; + } + + public List getTocReferences() { + return tocReferences; + } + + public void setTocReferences(List tocReferences) { + this.tocReferences = tocReferences; + } + + /** + * Calls addTOCReferenceAtLocation after splitting the path using the DEFAULT_PATH_SEPARATOR. + * + * @return the new TOCReference + */ + @SuppressWarnings("unused") + public TOCReference addSection(Resource resource, String path) { + return addSection(resource, path, DEFAULT_PATH_SEPARATOR); + } + + /** + * Calls addTOCReferenceAtLocation after splitting the path using the given pathSeparator. + * + * @param resource resource + * @param path path + * @param pathSeparator pathSeparator + * @return the new TOCReference + */ + public TOCReference addSection(Resource resource, String path, + String pathSeparator) { + String[] pathElements = path.split(pathSeparator); + return addSection(resource, pathElements); + } + + /** + * Finds the first TOCReference in the given list that has the same title as the given Title. + * + * @param title title + * @param tocReferences tocReferences + * @return null if not found. + */ + private static TOCReference findTocReferenceByTitle(String title, + List tocReferences) { + for (TOCReference tocReference : tocReferences) { + if (title.equals(tocReference.getTitle())) { + return tocReference; + } + } + return null; + } + + /** + * Adds the given Resources to the TableOfContents at the location specified by the pathElements. + *

    + * Example: + * Calling this method with a Resource and new String[] {"chapter1", "paragraph1"} will result in the following: + *

      + *
    • a TOCReference with the title "chapter1" at the root level.
      + * If this TOCReference did not yet exist it will have been created and does not point to any resource
    • + *
    • A TOCReference that has the title "paragraph1". This TOCReference will be the child of TOCReference "chapter1" and + * will point to the given Resource
    • + *
    + * + * @param resource resource + * @param pathElements pathElements + * @return the new TOCReference + */ + public TOCReference addSection(Resource resource, String[] pathElements) { + if (pathElements == null || pathElements.length == 0) { + return null; + } + TOCReference result = null; + List currentTocReferences = this.tocReferences; + for (String currentTitle : pathElements) { + result = findTocReferenceByTitle(currentTitle, currentTocReferences); + if (result == null) { + result = new TOCReference(currentTitle, null); + currentTocReferences.add(result); + } + currentTocReferences = result.getChildren(); + } + result.setResource(resource); + return result; + } + + /** + * Adds the given Resources to the TableOfContents at the location specified by the pathElements. + *

    + * Example: + * Calling this method with a Resource and new int[] {0, 0} will result in the following: + *

      + *
    • a TOCReference at the root level.
      + * If this TOCReference did not yet exist it will have been created with a title of "" and does not point to any resource
    • + *
    • A TOCReference that points to the given resource and is a child of the previously created TOCReference.
      + * If this TOCReference didn't exist yet it will be created and have a title of ""
    • + *
    + * + * @param resource resource + * @param pathElements pathElements + * @return the new TOCReference + */ + @SuppressWarnings("unused") + public TOCReference addSection(Resource resource, int[] pathElements, + String sectionTitlePrefix, String sectionNumberSeparator) { + if (pathElements == null || pathElements.length == 0) { + return null; + } + TOCReference result = null; + List currentTocReferences = this.tocReferences; + for (int i = 0; i < pathElements.length; i++) { + int currentIndex = pathElements[i]; + if (currentIndex > 0 && currentIndex < (currentTocReferences.size() + - 1)) { + result = currentTocReferences.get(currentIndex); + } else { + result = null; + } + if (result == null) { + paddTOCReferences(currentTocReferences, pathElements, i, + sectionTitlePrefix, sectionNumberSeparator); + result = currentTocReferences.get(currentIndex); + } + currentTocReferences = result.getChildren(); + } + result.setResource(resource); + return result; + } + + private void paddTOCReferences(List currentTocReferences, + int[] pathElements, int pathPos, String sectionPrefix, + String sectionNumberSeparator) { + for (int i = currentTocReferences.size(); i <= pathElements[pathPos]; i++) { + String sectionTitle = createSectionTitle(pathElements, pathPos, i, + sectionPrefix, + sectionNumberSeparator); + currentTocReferences.add(new TOCReference(sectionTitle, null)); + } + } + + private String createSectionTitle(int[] pathElements, int pathPos, + int lastPos, + String sectionPrefix, String sectionNumberSeparator) { + StringBuilder title = new StringBuilder(sectionPrefix); + for (int i = 0; i < pathPos; i++) { + if (i > 0) { + title.append(sectionNumberSeparator); + } + title.append(pathElements[i] + 1); + } + if (pathPos > 0) { + title.append(sectionNumberSeparator); + } + title.append(lastPos + 1); + return title.toString(); + } + + public TOCReference addTOCReference(TOCReference tocReference) { + if (tocReferences == null) { + tocReferences = new ArrayList<>(); + } + tocReferences.add(tocReference); + return tocReference; + } + + /** + * All unique references (unique by href) in the order in which they are referenced to in the table of contents. + * + * @return All unique references (unique by href) in the order in which they are referenced to in the table of contents. + */ + public List getAllUniqueResources() { + Set uniqueHrefs = new HashSet<>(); + List result = new ArrayList<>(); + getAllUniqueResources(uniqueHrefs, result, tocReferences); + return result; + } + + private static void getAllUniqueResources( + Set uniqueHrefs, + List result, + List tocReferences + ) { + for (TOCReference tocReference : tocReferences) { + Resource resource = tocReference.getResource(); + if (resource != null && !uniqueHrefs.contains(resource.getHref())) { + uniqueHrefs.add(resource.getHref()); + result.add(resource); + } + getAllUniqueResources(uniqueHrefs, result, tocReference.getChildren()); + } + } + + /** + * The total number of references in this table of contents. + * + * @return The total number of references in this table of contents. + */ + public int size() { + return getTotalSize(tocReferences); + } + + private static int getTotalSize(Collection tocReferences) { + int result = tocReferences.size(); + for (TOCReference tocReference : tocReferences) { + result += getTotalSize(tocReference.getChildren()); + } + return result; + } + + /** + * The maximum depth of the reference tree + * + * @return The maximum depth of the reference tree + */ + public int calculateDepth() { + return calculateDepth(tocReferences, 0); + } + + private int calculateDepth(List tocReferences, + int currentDepth) { + int maxChildDepth = 0; + for (TOCReference tocReference : tocReferences) { + int childDepth = calculateDepth(tocReference.getChildren(), 1); + if (childDepth > maxChildDepth) { + maxChildDepth = childDepth; + } + } + return currentDepth + maxChildDepth; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/domain/TitledResourceReference.java b/epublib/src/main/java/me/ag2s/epublib/domain/TitledResourceReference.java new file mode 100644 index 000000000..e7fe769ad --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/domain/TitledResourceReference.java @@ -0,0 +1,91 @@ +package me.ag2s.epublib.domain; + +import java.io.Serializable; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.util.StringUtil; + +public class TitledResourceReference extends ResourceReference + implements Serializable { + + private static final long serialVersionUID = 3918155020095190080L; + private String fragmentId; + private String title; + + /** + * 这会使title为null + * + * @param resource resource + */ + @Deprecated + @SuppressWarnings("unused") + public TitledResourceReference(Resource resource) { + this(resource, null); + } + + public TitledResourceReference(Resource resource, String title) { + this(resource, title, null); + } + + public TitledResourceReference(Resource resource, String title, + String fragmentId) { + super(resource); + this.title = title; + this.fragmentId = fragmentId; + } + + public String getFragmentId() { + return fragmentId; + } + + public void setFragmentId(String fragmentId) { + this.fragmentId = fragmentId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + + /** + * If the fragmentId is blank it returns the resource href, otherwise + * it returns the resource href + '#' + the fragmentId. + * + * @return If the fragmentId is blank it returns the resource href, + * otherwise it returns the resource href + '#' + the fragmentId. + */ + public String getCompleteHref() { + if (StringUtil.isBlank(fragmentId)) { + return resource.getHref(); + } else { + return resource.getHref() + Constants.FRAGMENT_SEPARATOR_CHAR + + fragmentId; + } + } + + @Override + public Resource getResource() { + //resource为null时不设置标题 + if(this.resource!=null&&this.title!=null){ + resource.setTitle(title); + } + + return resource; + } + + public void setResource(Resource resource, String fragmentId) { + super.setResource(resource); + this.fragmentId = fragmentId; + } + + /** + * Sets the resource to the given resource and sets the fragmentId to null. + */ + public void setResource(Resource resource) { + setResource(resource, null); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/BookProcessor.java b/epublib/src/main/java/me/ag2s/epublib/epub/BookProcessor.java new file mode 100644 index 000000000..d45b9d891 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/BookProcessor.java @@ -0,0 +1,20 @@ +package me.ag2s.epublib.epub; + +import me.ag2s.epublib.domain.EpubBook; + +/** + * Post-processes a book. + * + * Can be used to clean up a book after reading or before writing. + * + * @author paul + */ +public interface BookProcessor { + + /** + * A BookProcessor that returns the input book unchanged. + */ + BookProcessor IDENTITY_BOOKPROCESSOR = book -> book; + + EpubBook processBook(EpubBook book); +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/BookProcessorPipeline.java b/epublib/src/main/java/me/ag2s/epublib/epub/BookProcessorPipeline.java new file mode 100644 index 000000000..35256370c --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/BookProcessorPipeline.java @@ -0,0 +1,73 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import me.ag2s.epublib.domain.EpubBook; + +/** + * A book processor that combines several other bookprocessors + *

    + * Fixes coverpage/coverimage. + * Cleans up the XHTML. + * + * @author paul.siegmann + */ +@SuppressWarnings("unused declaration") +public class BookProcessorPipeline implements BookProcessor { + + private static final String TAG= BookProcessorPipeline.class.getName(); + private List bookProcessors; + + public BookProcessorPipeline() { + this(null); + } + + public BookProcessorPipeline(List bookProcessingPipeline) { + this.bookProcessors = bookProcessingPipeline; + } + + @Override + public EpubBook processBook(EpubBook book) { + if (bookProcessors == null) { + return book; + } + for (BookProcessor bookProcessor : bookProcessors) { + try { + book = bookProcessor.processBook(book); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + } + return book; + } + + public void addBookProcessor(BookProcessor bookProcessor) { + if (this.bookProcessors == null) { + bookProcessors = new ArrayList<>(); + } + this.bookProcessors.add(bookProcessor); + } + + public void addBookProcessors(Collection bookProcessors) { + if (this.bookProcessors == null) { + this.bookProcessors = new ArrayList<>(); + } + this.bookProcessors.addAll(bookProcessors); + } + + + public List getBookProcessors() { + return bookProcessors; + } + + + public void setBookProcessingPipeline( + List bookProcessingPipeline) { + this.bookProcessors = bookProcessingPipeline; + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/DOMUtil.java b/epublib/src/main/java/me/ag2s/epublib/epub/DOMUtil.java new file mode 100644 index 000000000..e6ae05b3d --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/DOMUtil.java @@ -0,0 +1,177 @@ +package me.ag2s.epublib.epub; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.w3c.dom.Text; + +import java.util.ArrayList; +import java.util.List; + +import me.ag2s.epublib.util.StringUtil; + +/** + * Utility methods for working with the DOM. + * + * @author paul + */ +// package +class DOMUtil { + + /** + * First tries to get the attribute value by doing an getAttributeNS on the element, if that gets an empty element it does a getAttribute without namespace. + * + * @param element element + * @param namespace namespace + * @param attribute attribute + * @return String Attribute + */ + public static String getAttribute(Element element, String namespace, + String attribute) { + String result = element.getAttributeNS(namespace, attribute); + if (StringUtil.isEmpty(result)) { + result = element.getAttribute(attribute); + } + return result; + } + + /** + * Gets all descendant elements of the given parentElement with the given namespace and tagname and returns their text child as a list of String. + * + * @param parentElement parentElement + * @param namespace namespace + * @param tagName tagName + * @return List + */ + public static List getElementsTextChild(Element parentElement, + String namespace, String tagName) { + NodeList elements = parentElement + .getElementsByTagNameNS(namespace, tagName); + //ArrayList 初始化时指定长度提高性能 + List result = new ArrayList<>(elements.getLength()); + for (int i = 0; i < elements.getLength(); i++) { + result.add(getTextChildrenContent((Element) elements.item(i))); + } + return result; + } + + /** + * Finds in the current document the first element with the given namespace and elementName and with the given findAttributeName and findAttributeValue. + * It then returns the value of the given resultAttributeName. + * + * @param document document + * @param namespace namespace + * @param elementName elementName + * @param findAttributeName findAttributeName + * @param findAttributeValue findAttributeValue + * @param resultAttributeName resultAttributeName + * @return String value + */ + public static String getFindAttributeValue(Document document, + String namespace, String elementName, String findAttributeName, + String findAttributeValue, String resultAttributeName) { + NodeList metaTags = document.getElementsByTagNameNS(namespace, elementName); + for (int i = 0; i < metaTags.getLength(); i++) { + Element metaElement = (Element) metaTags.item(i); + if (findAttributeValue + .equalsIgnoreCase(metaElement.getAttribute(findAttributeName)) + && StringUtil + .isNotBlank(metaElement.getAttribute(resultAttributeName))) { + return metaElement.getAttribute(resultAttributeName); + } + } + return null; + } + + /** + * Gets the first element that is a child of the parentElement and has the given namespace and tagName + * + * @param parentElement parentElement + * @param namespace namespace + * @param tagName tagName + * @return Element + */ + public static NodeList getElementsByTagNameNS(Element parentElement, + String namespace, String tagName) { + NodeList nodes = parentElement.getElementsByTagNameNS(namespace, tagName); + if (nodes.getLength() != 0) { + return nodes; + } + nodes = parentElement.getElementsByTagName(tagName); + if (nodes.getLength() == 0) { + return null; + } + return nodes; + } + /** + * Gets the first element that is a child of the parentElement and has the given namespace and tagName + * + * @param parentElement parentElement + * @param namespace namespace + * @param tagName tagName + * @return Element + */ + public static NodeList getElementsByTagNameNS(Document parentElement, + String namespace, String tagName) { + NodeList nodes = parentElement.getElementsByTagNameNS(namespace, tagName); + if (nodes.getLength() != 0) { + return nodes; + } + nodes = parentElement.getElementsByTagName(tagName); + if (nodes.getLength() == 0) { + return null; + } + return nodes; + } + + /** + * Gets the first element that is a child of the parentElement and has the given namespace and tagName + * + * @param parentElement parentElement + * @param namespace namespace + * @param tagName tagName + * @return Element + */ + public static Element getFirstElementByTagNameNS(Element parentElement, + String namespace, String tagName) { + NodeList nodes = parentElement.getElementsByTagNameNS(namespace, tagName); + if (nodes.getLength() != 0) { + return (Element) nodes.item(0); + } + nodes = parentElement.getElementsByTagName(tagName); + if (nodes.getLength() == 0) { + return null; + } + return (Element) nodes.item(0); + } + + /** + * The contents of all Text nodes that are children of the given parentElement. + * The result is trim()-ed. + *

    + * The reason for this more complicated procedure instead of just returning the data of the firstChild is that + * when the text is Chinese characters then on Android each Characater is represented in the DOM as + * an individual Text node. + * + * @param parentElement parentElement + * @return String value + */ + public static String getTextChildrenContent(Element parentElement) { + if (parentElement == null) { + return null; + } + StringBuilder result = new StringBuilder(); + NodeList childNodes = parentElement.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if ((node == null) || + (node.getNodeType() != Node.TEXT_NODE)) { + continue; + } + result.append(((Text) node).getData()); + } + return result.toString().trim(); + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/EpubProcessorSupport.java b/epublib/src/main/java/me/ag2s/epublib/epub/EpubProcessorSupport.java new file mode 100644 index 000000000..dd741cc4c --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/EpubProcessorSupport.java @@ -0,0 +1,139 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import me.ag2s.epublib.Constants; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.UnsupportedEncodingException; +import java.io.Writer; +import java.net.URL; +import java.util.Objects; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.xml.sax.EntityResolver; +import org.xml.sax.InputSource; +import org.xmlpull.v1.XmlPullParserFactory; +import org.xmlpull.v1.XmlSerializer; + +/** + * Various low-level support methods for reading/writing epubs. + * + * @author paul.siegmann + */ +public class EpubProcessorSupport { + + private static final String TAG = EpubProcessorSupport.class.getName(); + + protected static DocumentBuilderFactory documentBuilderFactory; + + static { + init(); + } + + static class EntityResolverImpl implements EntityResolver { + + private String previousLocation; + + @Override + public InputSource resolveEntity(String publicId, String systemId) + throws IOException { + String resourcePath; + if (systemId.startsWith("http:")) { + URL url = new URL(systemId); + resourcePath = "dtd/" + url.getHost() + url.getPath(); + previousLocation = resourcePath + .substring(0, resourcePath.lastIndexOf('/')); + } else { + resourcePath = + previousLocation + systemId.substring(systemId.lastIndexOf('/')); + } + + if (Objects.requireNonNull(this.getClass().getClassLoader()).getResource(resourcePath) == null) { + throw new RuntimeException( + "remote resource is not cached : [" + systemId + + "] cannot continue"); + } + + InputStream in = Objects.requireNonNull(EpubProcessorSupport.class.getClassLoader()) + .getResourceAsStream(resourcePath); + return new InputSource(in); + } + } + + + private static void init() { + EpubProcessorSupport.documentBuilderFactory = DocumentBuilderFactory + .newInstance(); + documentBuilderFactory.setNamespaceAware(true); + documentBuilderFactory.setValidating(false); + } + + public static XmlSerializer createXmlSerializer(OutputStream out) + throws UnsupportedEncodingException { + return createXmlSerializer( + new OutputStreamWriter(out, Constants.CHARACTER_ENCODING)); + } + + public static XmlSerializer createXmlSerializer(Writer out) { + XmlSerializer result = null; + try { + /* + * Disable XmlPullParserFactory here before it doesn't work when + * building native image using GraalVM + */ + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + factory.setValidating(true); + result = factory.newSerializer(); + + //result = new KXmlSerializer(); + result.setFeature( + "http://xmlpull.org/v1/doc/features.html#indent-output", true); + result.setOutput(out); + } catch (Exception e) { + Log.e(TAG, + "When creating XmlSerializer: " + e.getClass().getName() + ": " + e + .getMessage()); + } + return result; + } + + /** + * Gets an EntityResolver that loads dtd's and such from the epub4j classpath. + * In order to enable the loading of relative urls the given EntityResolver contains the previousLocation. + * Because of a new EntityResolver is created every time this method is called. + * Fortunately the EntityResolver created uses up very little memory per instance. + * + * @return an EntityResolver that loads dtd's and such from the epub4j classpath. + */ + public static EntityResolver getEntityResolver() { + return new EntityResolverImpl(); + } + + @SuppressWarnings("unused") + public DocumentBuilderFactory getDocumentBuilderFactory() { + return documentBuilderFactory; + } + + /** + * Creates a DocumentBuilder that looks up dtd's and schema's from epub4j's classpath. + * + * @return a DocumentBuilder that looks up dtd's and schema's from epub4j's classpath. + */ + public static DocumentBuilder createDocumentBuilder() { + DocumentBuilder result = null; + try { + result = documentBuilderFactory.newDocumentBuilder(); + result.setEntityResolver(getEntityResolver()); + } catch (ParserConfigurationException e) { + Log.e(TAG, e.getMessage()); + } + return result; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/EpubReader.java b/epublib/src/main/java/me/ag2s/epublib/epub/EpubReader.java new file mode 100644 index 000000000..78fd6ab94 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/EpubReader.java @@ -0,0 +1,173 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.MediaType; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.domain.Resources; +import me.ag2s.epublib.util.ResourceUtil; +import me.ag2s.epublib.util.StringUtil; + +/** + * Reads an epub file. + * + * @author paul + */ +@SuppressWarnings("ALL") +public class EpubReader { + + private static final String TAG = EpubReader.class.getName(); + private final BookProcessor bookProcessor = BookProcessor.IDENTITY_BOOKPROCESSOR; + + public EpubBook readEpub(InputStream in) throws IOException { + return readEpub(in, Constants.CHARACTER_ENCODING); + } + + public EpubBook readEpub(ZipInputStream in) throws IOException { + return readEpub(in, Constants.CHARACTER_ENCODING); + } + + public EpubBook readEpub(ZipFile zipfile) throws IOException { + return readEpub(zipfile, Constants.CHARACTER_ENCODING); + } + + /** + * Read epub from inputstream + * + * @param in the inputstream from which to read the epub + * @param encoding the encoding to use for the html files within the epub + * @return the Book as read from the inputstream + * @throws IOException IOException + */ + public EpubBook readEpub(InputStream in, String encoding) throws IOException { + return readEpub(new ZipInputStream(in), encoding); + } + + + /** + * Reads this EPUB without loading any resources into memory. + * + * @param zipFile the file to load + * @param encoding the encoding for XHTML files + * @return this Book without loading all resources into memory. + * @throws IOException IOException + */ + public EpubBook readEpubLazy(ZipFile zipFile, String encoding) + throws IOException { + return readEpubLazy(zipFile, encoding, + Arrays.asList(MediaTypes.mediaTypes)); + } + + public EpubBook readEpub(ZipInputStream in, String encoding) throws IOException { + return readEpub(ResourcesLoader.loadResources(in, encoding)); + } + + public EpubBook readEpub(ZipFile in, String encoding) throws IOException { + return readEpub(ResourcesLoader.loadResources(in, encoding)); + } + + /** + * Reads this EPUB without loading all resources into memory. + * + * @param zipFile the file to load + * @param encoding the encoding for XHTML files + * @param lazyLoadedTypes a list of the MediaType to load lazily + * @return this Book without loading all resources into memory. + * @throws IOException IOException + */ + public EpubBook readEpubLazy(ZipFile zipFile, String encoding, + List lazyLoadedTypes) throws IOException { + Resources resources = ResourcesLoader + .loadResources(zipFile, encoding, lazyLoadedTypes); + return readEpub(resources); + } + + public EpubBook readEpub(Resources resources) { + return readEpub(resources, new EpubBook()); + } + + public EpubBook readEpub(Resources resources, EpubBook result) { + if (result == null) { + result = new EpubBook(); + } + handleMimeType(result, resources); + String packageResourceHref = getPackageResourceHref(resources); + Resource packageResource = processPackageResource(packageResourceHref, + result, resources); + result.setOpfResource(packageResource); + Resource ncxResource = processNcxResource(packageResource, result); + result.setNcxResource(ncxResource); + result = postProcessBook(result); + return result; + } + + + private EpubBook postProcessBook(EpubBook book) { + if (bookProcessor != null) { + book = bookProcessor.processBook(book); + } + return book; + } + + private Resource processNcxResource(Resource packageResource, EpubBook book) { + Log.d(TAG, "OPF:getHref()" + packageResource.getHref()); + if (book.isEpub3()) { + return NCXDocumentV3.read(book, this); + } else { + return NCXDocumentV2.read(book, this); + } + + } + + private Resource processPackageResource(String packageResourceHref, EpubBook book, + Resources resources) { + Resource packageResource = resources.remove(packageResourceHref); + try { + PackageDocumentReader.read(packageResource, this, book, resources); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + return packageResource; + } + + private String getPackageResourceHref(Resources resources) { + String defaultResult = "OEBPS/content.opf"; + String result = defaultResult; + + Resource containerResource = resources.remove("META-INF/container.xml"); + if (containerResource == null) { + return result; + } + try { + Document document = ResourceUtil.getAsDocument(containerResource); + Element rootFileElement = (Element) ((Element) document + .getDocumentElement().getElementsByTagName("rootfiles").item(0)) + .getElementsByTagName("rootfile").item(0); + result = rootFileElement.getAttribute("full-path"); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + if (StringUtil.isBlank(result)) { + result = defaultResult; + } + return result; + } + + private void handleMimeType(EpubBook result, Resources resources) { + resources.remove("mimetype"); + //result.setResources(resources); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/EpubWriter.java b/epublib/src/main/java/me/ag2s/epublib/epub/EpubWriter.java new file mode 100644 index 000000000..c421d5a9f --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/EpubWriter.java @@ -0,0 +1,190 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.util.IOUtil; + +/** + * Generates an epub file. Not thread-safe, single use object. + * + * @author paul + */ +public class EpubWriter { + + private static final String TAG= EpubWriter.class.getName(); + + // package + static final String EMPTY_NAMESPACE_PREFIX = ""; + + private BookProcessor bookProcessor; + + public EpubWriter() { + this(BookProcessor.IDENTITY_BOOKPROCESSOR); + } + + + public EpubWriter(BookProcessor bookProcessor) { + this.bookProcessor = bookProcessor; + } + + + public void write(EpubBook book, OutputStream out) throws IOException { + book = processBook(book); + ZipOutputStream resultStream = new ZipOutputStream(out); + writeMimeType(resultStream); + writeContainer(resultStream); + initTOCResource(book); + writeResources(book, resultStream); + writePackageDocument(book, resultStream); + resultStream.close(); + } + + private EpubBook processBook(EpubBook book) { + if (bookProcessor != null) { + book = bookProcessor.processBook(book); + } + return book; + } + + private void initTOCResource(EpubBook book) { + Resource tocResource; + try { + if (book.isEpub3()) { + tocResource = NCXDocumentV3.createNCXResource(book); + } else { + tocResource = NCXDocumentV2.createNCXResource(book); + } + + Resource currentTocResource = book.getSpine().getTocResource(); + if (currentTocResource != null) { + book.getResources().remove(currentTocResource.getHref()); + } + book.getSpine().setTocResource(tocResource); + book.getResources().add(tocResource); + } catch (Exception ex) { + Log.e(TAG, + "Error writing table of contents: " + + ex.getClass().getName() + ": " + ex.getMessage(), ex); + } + } + + + private void writeResources(EpubBook book, ZipOutputStream resultStream) { + for (Resource resource : book.getResources().getAll()) { + writeResource(resource, resultStream); + } + } + + /** + * Writes the resource to the resultStream. + * + * @param resource resource + * @param resultStream resultStream + */ + private void writeResource(Resource resource, ZipOutputStream resultStream) { + if (resource == null) { + return; + } + try { + resultStream.putNextEntry(new ZipEntry("OEBPS/" + resource.getHref())); + InputStream inputStream = resource.getInputStream(); + + IOUtil.copy(inputStream, resultStream); + inputStream.close(); + } catch (Exception e) { + Log.e(TAG,e.getMessage(), e); + } + } + + + private void writePackageDocument(EpubBook book, ZipOutputStream resultStream) + throws IOException { + resultStream.putNextEntry(new ZipEntry("OEBPS/content.opf")); + XmlSerializer xmlSerializer = EpubProcessorSupport + .createXmlSerializer(resultStream); + PackageDocumentWriter.write(this, xmlSerializer, book); + xmlSerializer.flush(); +// String resultAsString = result.toString(); +// resultStream.write(resultAsString.getBytes(Constants.ENCODING)); + } + + /** + * Writes the META-INF/container.xml file. + * + * @param resultStream resultStream + * @throws IOException IOException + */ + private void writeContainer(ZipOutputStream resultStream) throws IOException { + resultStream.putNextEntry(new ZipEntry("META-INF/container.xml")); + Writer out = new OutputStreamWriter(resultStream); + out.write("\n"); + out.write( + "\n"); + out.write("\t\n"); + out.write( + "\t\t\n"); + out.write("\t\n"); + out.write(""); + out.flush(); + } + + /** + * Stores the mimetype as an uncompressed file in the ZipOutputStream. + * + * @param resultStream resultStream + * @throws IOException IOException + */ + private void writeMimeType(ZipOutputStream resultStream) throws IOException { + ZipEntry mimetypeZipEntry = new ZipEntry("mimetype"); + mimetypeZipEntry.setMethod(ZipEntry.STORED); + byte[] mimetypeBytes = MediaTypes.EPUB.getName().getBytes(); + mimetypeZipEntry.setSize(mimetypeBytes.length); + mimetypeZipEntry.setCrc(calculateCrc(mimetypeBytes)); + resultStream.putNextEntry(mimetypeZipEntry); + resultStream.write(mimetypeBytes); + } + + private long calculateCrc(byte[] data) { + CRC32 crc = new CRC32(); + crc.update(data); + return crc.getValue(); + } + + String getNcxId() { + return "ncx"; + } + + String getNcxHref() { + return "toc.ncx"; + } + + String getNcxMediaType() { + return MediaTypes.NCX.getName(); + } + + + @SuppressWarnings("unused") + public BookProcessor getBookProcessor() { + return bookProcessor; + } + + @SuppressWarnings("unused") + public void setBookProcessor(BookProcessor bookProcessor) { + this.bookProcessor = bookProcessor; + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/HtmlProcessor.java b/epublib/src/main/java/me/ag2s/epublib/epub/HtmlProcessor.java new file mode 100644 index 000000000..7780f6b47 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/HtmlProcessor.java @@ -0,0 +1,9 @@ +package me.ag2s.epublib.epub; + +import me.ag2s.epublib.domain.Resource; +import java.io.OutputStream; +@SuppressWarnings("unused") +public interface HtmlProcessor { + + void processHtmlResource(Resource resource, OutputStream out); +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/NCXDocumentV2.java b/epublib/src/main/java/me/ag2s/epublib/epub/NCXDocumentV2.java new file mode 100644 index 000000000..2bf47de51 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/NCXDocumentV2.java @@ -0,0 +1,343 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.Author; +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Identifier; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.domain.TOCReference; +import me.ag2s.epublib.domain.TableOfContents; +import me.ag2s.epublib.util.ResourceUtil; +import me.ag2s.epublib.util.StringUtil; + +/** + * Writes the ncx document as defined by namespace http://www.daisy.org/z3986/2005/ncx/ + * + * @author paul + */ +public class NCXDocumentV2 { + + public static final String NAMESPACE_NCX = "http://www.daisy.org/z3986/2005/ncx/"; + @SuppressWarnings("unused") + public static final String PREFIX_NCX = "ncx"; + public static final String NCX_ITEM_ID = "ncx"; + public static final String DEFAULT_NCX_HREF = "toc.ncx"; + public static final String PREFIX_DTB = "dtb"; + + private static final String TAG = NCXDocumentV2.class.getName(); + + private interface NCXTags { + + String ncx = "ncx"; + String meta = "meta"; + String navPoint = "navPoint"; + String navMap = "navMap"; + String navLabel = "navLabel"; + String content = "content"; + String text = "text"; + String docTitle = "docTitle"; + String docAuthor = "docAuthor"; + String head = "head"; + } + + private interface NCXAttributes { + + String src = "src"; + String name = "name"; + String content = "content"; + String id = "id"; + String playOrder = "playOrder"; + String clazz = "class"; + String version = "version"; + } + + private interface NCXAttributeValues { + + String chapter = "chapter"; + String version = "2005-1"; + + } + + @SuppressWarnings("unused") + public static Resource read(EpubBook book, EpubReader epubReader) { + Resource ncxResource = null; + if (book.getSpine().getTocResource() == null) { + Log.e(TAG, "Book does not contain a table of contents file"); + return null; + } + try { + ncxResource = book.getSpine().getTocResource(); + if (ncxResource == null) { + return null; + } + Log.d(TAG, ncxResource.getHref()); + Document ncxDocument = ResourceUtil.getAsDocument(ncxResource); + Element navMapElement = DOMUtil + .getFirstElementByTagNameNS(ncxDocument.getDocumentElement(), + NAMESPACE_NCX, NCXTags.navMap); + if (navMapElement == null) { + return null; + } + + TableOfContents tableOfContents = new TableOfContents( + readTOCReferences(navMapElement.getChildNodes(), book)); + book.setTableOfContents(tableOfContents); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + return ncxResource; + } + + static List readTOCReferences(NodeList navpoints, + EpubBook book) { + if (navpoints == null) { + return new ArrayList<>(); + } + List result = new ArrayList<>( + navpoints.getLength()); + for (int i = 0; i < navpoints.getLength(); i++) { + Node node = navpoints.item(i); + if (node.getNodeType() != Document.ELEMENT_NODE) { + continue; + } + if (!(node.getLocalName().equals(NCXTags.navPoint))) { + continue; + } + TOCReference tocReference = readTOCReference((Element) node, book); + result.add(tocReference); + } + return result; + } + + static TOCReference readTOCReference(Element navpointElement, EpubBook book) { + String label = readNavLabel(navpointElement); + //Log.d(TAG,"label:"+label); + String tocResourceRoot = StringUtil + .substringBeforeLast(book.getSpine().getTocResource().getHref(), '/'); + if (tocResourceRoot.length() == book.getSpine().getTocResource().getHref() + .length()) { + tocResourceRoot = ""; + } else { + tocResourceRoot = tocResourceRoot + "/"; + } + String reference = StringUtil + .collapsePathDots(tocResourceRoot + readNavReference(navpointElement)); + String href = StringUtil + .substringBefore(reference, Constants.FRAGMENT_SEPARATOR_CHAR); + String fragmentId = StringUtil + .substringAfter(reference, Constants.FRAGMENT_SEPARATOR_CHAR); + Resource resource = book.getResources().getByHref(href); + if (resource == null) { + Log.e(TAG, "Resource with href " + href + " in NCX document not found"); + } + Log.v(TAG, "label:" + label); + Log.v(TAG, "href:" + href); + Log.v(TAG, "fragmentId:" + fragmentId); + TOCReference result = new TOCReference(label, resource, fragmentId); + List childTOCReferences = readTOCReferences( + navpointElement.getChildNodes(), book); + result.setChildren(childTOCReferences); + return result; + } + + private static String readNavReference(Element navpointElement) { + Element contentElement = DOMUtil + .getFirstElementByTagNameNS(navpointElement, NAMESPACE_NCX, + NCXTags.content); + if (contentElement == null) { + return null; + } + String result = DOMUtil + .getAttribute(contentElement, NAMESPACE_NCX, NCXAttributes.src); + try { + result = URLDecoder.decode(result, Constants.CHARACTER_ENCODING); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, e.getMessage()); + } + return result; + } + + private static String readNavLabel(Element navpointElement) { + //Log.d(TAG,navpointElement.getTagName()); + Element navLabel = DOMUtil + .getFirstElementByTagNameNS(navpointElement, NAMESPACE_NCX, + NCXTags.navLabel); + assert navLabel != null; + return DOMUtil.getTextChildrenContent(DOMUtil + .getFirstElementByTagNameNS(navLabel, NAMESPACE_NCX, NCXTags.text)); + } + + @SuppressWarnings("unused") + public static void write(EpubWriter epubWriter, EpubBook book, + ZipOutputStream resultStream) throws IOException { + resultStream + .putNextEntry(new ZipEntry(book.getSpine().getTocResource().getHref())); + XmlSerializer out = EpubProcessorSupport.createXmlSerializer(resultStream); + write(out, book); + out.flush(); + } + + + /** + * Generates a resource containing an xml document containing the table of contents of the book in ncx format. + * + * @param xmlSerializer the serializer used + * @param book the book to serialize + * @throws IOException IOException + * @throws IllegalStateException IllegalStateException + * @throws IllegalArgumentException IllegalArgumentException + */ + public static void write(XmlSerializer xmlSerializer, EpubBook book) + throws IllegalArgumentException, IllegalStateException, IOException { + write(xmlSerializer, book.getMetadata().getIdentifiers(), book.getTitle(), + book.getMetadata().getAuthors(), book.getTableOfContents()); + } + + public static Resource createNCXResource(EpubBook book) + throws IllegalArgumentException, IllegalStateException, IOException { + return createNCXResource(book.getMetadata().getIdentifiers(), + book.getTitle(), book.getMetadata().getAuthors(), + book.getTableOfContents()); + } + + public static Resource createNCXResource(List identifiers, + String title, List authors, TableOfContents tableOfContents) + throws IllegalArgumentException, IllegalStateException, IOException { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + XmlSerializer out = EpubProcessorSupport.createXmlSerializer(data); + write(out, identifiers, title, authors, tableOfContents); + return new Resource(NCX_ITEM_ID, data.toByteArray(), + DEFAULT_NCX_HREF, MediaTypes.NCX); + } + + public static void write(XmlSerializer serializer, + List identifiers, String title, List authors, + TableOfContents tableOfContents) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startDocument(Constants.CHARACTER_ENCODING, false); + serializer.setPrefix(EpubWriter.EMPTY_NAMESPACE_PREFIX, NAMESPACE_NCX); + serializer.startTag(NAMESPACE_NCX, NCXTags.ncx); +// serializer.writeNamespace("ncx", NAMESPACE_NCX); +// serializer.attribute("xmlns", NAMESPACE_NCX); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.version, + NCXAttributeValues.version); + serializer.startTag(NAMESPACE_NCX, NCXTags.head); + + for (Identifier identifier : identifiers) { + writeMetaElement(identifier.getScheme(), identifier.getValue(), + serializer); + } + + writeMetaElement("generator", Constants.EPUB_GENERATOR_NAME, serializer); + writeMetaElement("depth", String.valueOf(tableOfContents.calculateDepth()), + serializer); + writeMetaElement("totalPageCount", "0", serializer); + writeMetaElement("maxPageNumber", "0", serializer); + + serializer.endTag(NAMESPACE_NCX, "head"); + + serializer.startTag(NAMESPACE_NCX, NCXTags.docTitle); + serializer.startTag(NAMESPACE_NCX, NCXTags.text); + // write the first title + serializer.text(StringUtil.defaultIfNull(title)); + serializer.endTag(NAMESPACE_NCX, NCXTags.text); + serializer.endTag(NAMESPACE_NCX, NCXTags.docTitle); + + for (Author author : authors) { + serializer.startTag(NAMESPACE_NCX, NCXTags.docAuthor); + serializer.startTag(NAMESPACE_NCX, NCXTags.text); + serializer.text(author.getLastname() + ", " + author.getFirstname()); + serializer.endTag(NAMESPACE_NCX, NCXTags.text); + serializer.endTag(NAMESPACE_NCX, NCXTags.docAuthor); + } + + serializer.startTag(NAMESPACE_NCX, NCXTags.navMap); + writeNavPoints(tableOfContents.getTocReferences(), 1, serializer); + serializer.endTag(NAMESPACE_NCX, NCXTags.navMap); + + serializer.endTag(NAMESPACE_NCX, "ncx"); + serializer.endDocument(); + } + + + private static void writeMetaElement(String dtbName, String content, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startTag(NAMESPACE_NCX, NCXTags.meta); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.name, + PREFIX_DTB + ":" + dtbName); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.content, + content); + serializer.endTag(NAMESPACE_NCX, NCXTags.meta); + } + + private static int writeNavPoints(List tocReferences, + int playOrder, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + for (TOCReference tocReference : tocReferences) { + if (tocReference.getResource() == null) { + playOrder = writeNavPoints(tocReference.getChildren(), playOrder, + serializer); + continue; + } + writeNavPointStart(tocReference, playOrder, serializer); + playOrder++; + if (!tocReference.getChildren().isEmpty()) { + playOrder = writeNavPoints(tocReference.getChildren(), playOrder, + serializer); + } + writeNavPointEnd(tocReference, serializer); + } + return playOrder; + } + + + private static void writeNavPointStart(TOCReference tocReference, + int playOrder, XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startTag(NAMESPACE_NCX, NCXTags.navPoint); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.id, + "navPoint-" + playOrder); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.playOrder, + String.valueOf(playOrder)); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.clazz, + NCXAttributeValues.chapter); + serializer.startTag(NAMESPACE_NCX, NCXTags.navLabel); + serializer.startTag(NAMESPACE_NCX, NCXTags.text); + serializer.text(tocReference.getTitle()); + serializer.endTag(NAMESPACE_NCX, NCXTags.text); + serializer.endTag(NAMESPACE_NCX, NCXTags.navLabel); + serializer.startTag(NAMESPACE_NCX, NCXTags.content); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, NCXAttributes.src, + tocReference.getCompleteHref()); + serializer.endTag(NAMESPACE_NCX, NCXTags.content); + } + @SuppressWarnings("unused") + private static void writeNavPointEnd(TOCReference tocReference, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.endTag(NAMESPACE_NCX, NCXTags.navPoint); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/NCXDocumentV3.java b/epublib/src/main/java/me/ag2s/epublib/epub/NCXDocumentV3.java new file mode 100644 index 000000000..845b999d1 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/NCXDocumentV3.java @@ -0,0 +1,461 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.List; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.Author; +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Identifier; +import me.ag2s.epublib.domain.MediaType; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.domain.TOCReference; +import me.ag2s.epublib.domain.TableOfContents; +import me.ag2s.epublib.util.ResourceUtil; +import me.ag2s.epublib.util.StringUtil; + +/** + * Writes the ncx document as defined by namespace http://www.daisy.org/z3986/2005/ncx/ + * + * @author Ag2S20150909 + */ + +public class NCXDocumentV3 { + public static final String NAMESPACE_XHTML = "http://www.w3.org/1999/xhtml"; + public static final String NAMESPACE_EPUB = "http://www.idpf.org/2007/ops"; + public static final String LANGUAGE = "en"; + @SuppressWarnings("unused") + public static final String PREFIX_XHTML = "html"; + public static final String NCX_ITEM_ID = "htmltoc"; + public static final String DEFAULT_NCX_HREF = "toc.xhtml"; + public static final String V3_NCX_PROPERTIES = "nav"; + public static final MediaType V3_NCX_MEDIATYPE = MediaTypes.XHTML; + + private static final String TAG = NCXDocumentV3.class.getName(); + + private interface XHTMLTgs { + String html = "html"; + String head = "head"; + String title = "title"; + String meta = "meta"; + String link = "link"; + String body = "body"; + String h1 = "h1"; + String h2 = "h2"; + String nav = "nav"; + String ol = "ol"; + String li = "li"; + String a = "a"; + String span = "span"; + } + + private interface XHTMLAttributes { + String xmlns = "xmlns"; + String xmlns_epub = "xmlns:epub"; + String lang = "lang"; + String xml_lang = "xml:lang"; + String rel = "rel"; + String type = "type"; + String epub_type = "epub:type";//nav的必须属性 + String id = "id"; + String role = "role"; + String href = "href"; + String http_equiv = "http-equiv"; + String content = "content"; + + } + + private interface XHTMLAttributeValues { + String Content_Type = "Content-Type"; + String HTML_UTF8 = "text/html; charset=utf-8"; + String lang = "en"; + String epub_type = "toc"; + String role_toc = "doc-toc"; + + } + + + /** + * 解析epub的目录文件 + * + * @param book Book + * @param epubReader epubreader + * @return Resource + */ + @SuppressWarnings("unused") + public static Resource read(EpubBook book, EpubReader epubReader) { + Resource ncxResource = null; + if (book.getSpine().getTocResource() == null) { + Log.e(TAG, "Book does not contain a table of contents file"); + return null; + } + try { + ncxResource = book.getSpine().getTocResource(); + if (ncxResource == null) { + return null; + } + //一些epub 3 文件没有按照epub3的标准使用删除掉ncx目录文件 + if (ncxResource.getHref().endsWith(".ncx")){ + Log.v(TAG,"该epub文件不标准,使用了epub2的目录文件"); + return NCXDocumentV2.read(book, epubReader); + } + Log.d(TAG, ncxResource.getHref()); + + Document ncxDocument = ResourceUtil.getAsDocument(ncxResource); + Log.d(TAG, ncxDocument.getNodeName()); + + Element navMapElement = (Element) ncxDocument.getElementsByTagName(XHTMLTgs.nav).item(0); + if(navMapElement==null){ + Log.d(TAG,"epub3目录文件未发现nav节点,尝试使用epub2的规则解析"); + return NCXDocumentV2.read(book, epubReader); + } + navMapElement = (Element) navMapElement.getElementsByTagName(XHTMLTgs.ol).item(0); + Log.d(TAG, navMapElement.getTagName()); + + TableOfContents tableOfContents = new TableOfContents( + readTOCReferences(navMapElement.getChildNodes(), book)); + Log.d(TAG, tableOfContents.toString()); + book.setTableOfContents(tableOfContents); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + return ncxResource; + } + + private static List doToc(Node n, EpubBook book) { + List result = new ArrayList<>(); + + if (n == null || n.getNodeType() != Document.ELEMENT_NODE) { + return result; + } else { + Element el = (Element) n; + NodeList nodeList = el.getElementsByTagName(XHTMLTgs.li); + for (int i = 0; i < nodeList.getLength(); i++) { + result.add(readTOCReference((Element) nodeList.item(i), book)); + } + } + return result; + } + + + static List readTOCReferences(NodeList navpoints, + EpubBook book) { + if (navpoints == null) { + return new ArrayList<>(); + } + //Log.d(TAG, "readTOCReferences:navpoints.getLength()" + navpoints.getLength()); + List result = new ArrayList<>(navpoints.getLength()); + for (int i = 0; i < navpoints.getLength(); i++) { + Node node = navpoints.item(i); + //如果该node是null,或者不是Element,跳出本次循环 + if (node == null || node.getNodeType() != Document.ELEMENT_NODE) { + continue; + } + + Element el = (Element) node; + //如果该Element的name为”li“,将其添加到目录结果 + if (el.getTagName().equals(XHTMLTgs.li)) { + result.add(readTOCReference(el, book)); + } + + } + + + return result; + } + + + static TOCReference readTOCReference(Element navpointElement, EpubBook book) { + //章节的名称 + String label = readNavLabel(navpointElement); + //Log.d(TAG, "label:" + label); + String tocResourceRoot = StringUtil + .substringBeforeLast(book.getSpine().getTocResource().getHref(), '/'); + if (tocResourceRoot.length() == book.getSpine().getTocResource().getHref() + .length()) { + tocResourceRoot = ""; + } else { + tocResourceRoot = tocResourceRoot + "/"; + } + + String reference = StringUtil + .collapsePathDots(tocResourceRoot + readNavReference(navpointElement)); + String href = StringUtil + .substringBefore(reference, Constants.FRAGMENT_SEPARATOR_CHAR); + String fragmentId = StringUtil + .substringAfter(reference, Constants.FRAGMENT_SEPARATOR_CHAR); + Resource resource = book.getResources().getByHref(href); + if (resource == null) { + Log.e(TAG, "Resource with href " + href + " in NCX document not found"); + } + Log.v(TAG, "label:" + label); + Log.v(TAG, "href:" + href); + Log.v(TAG, "fragmentId:" + fragmentId); + + //父级目录 + TOCReference result = new TOCReference(label, resource, fragmentId); + //解析子级目录 + List childTOCReferences = doToc(navpointElement, book); + //readTOCReferences( + //navpointElement.getChildNodes(), book); + result.setChildren(childTOCReferences); + return result; + } + + /** + * 获取目录节点的href + * + * @param navpointElement navpointElement + * @return String + */ + private static String readNavReference(Element navpointElement) { + //https://www.w3.org/publishing/epub/epub-packages.html#sec-package-nav + //父级节点必须是 "li" + //Log.d(TAG, "readNavReference:" + navpointElement.getTagName()); + + Element contentElement = DOMUtil + .getFirstElementByTagNameNS(navpointElement, "", XHTMLTgs.a); + if (contentElement == null) { + return null; + } + String result = DOMUtil + .getAttribute(contentElement, "", XHTMLAttributes.href); + try { + result = URLDecoder.decode(result, Constants.CHARACTER_ENCODING); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, e.getMessage()); + } + + return result; + + } + + /** + * 获取目录节点里面的章节名 + * + * @param navpointElement navpointElement + * @return String + */ + private static String readNavLabel(Element navpointElement) { + //https://www.w3.org/publishing/epub/epub-packages.html#sec-package-nav + //父级节点必须是 "li" + //Log.d(TAG, "readNavLabel:" + navpointElement.getTagName()); + String label; + Element labelElement = DOMUtil.getFirstElementByTagNameNS(navpointElement, "", "a"); + assert labelElement != null; + label = labelElement.getTextContent(); + if (StringUtil.isNotBlank(label)) { + return label; + } else { + labelElement = DOMUtil.getFirstElementByTagNameNS(navpointElement, "", "span"); + } + assert labelElement != null; + label = labelElement.getTextContent(); + //如果通过 a 标签无法获取章节列表,则是无href章节名 + return label; + + } + + public static Resource createNCXResource(EpubBook book) + throws IllegalArgumentException, IllegalStateException, IOException { + return createNCXResource(book.getMetadata().getIdentifiers(), + book.getTitle(), book.getMetadata().getAuthors(), + book.getTableOfContents()); + } + + public static Resource createNCXResource(List identifiers, + String title, List authors, TableOfContents tableOfContents) + throws IllegalArgumentException, IllegalStateException, IOException { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + XmlSerializer out = EpubProcessorSupport.createXmlSerializer(data); + write(out, identifiers, title, authors, tableOfContents); + + Resource resource = new Resource(NCX_ITEM_ID, data.toByteArray(), + DEFAULT_NCX_HREF, V3_NCX_MEDIATYPE); + resource.setProperties(V3_NCX_PROPERTIES); + return resource; + } + + /** + * Generates a resource containing an xml document containing the table of contents of the book in ncx format. + * + * @param xmlSerializer the serializer used + * @param book the book to serialize + * @throws IOException IOException + * @throws IllegalStateException IllegalStateException + * @throws IllegalArgumentException IllegalArgumentException + */ + public static void write(XmlSerializer xmlSerializer, EpubBook book) + throws IllegalArgumentException, IllegalStateException, IOException { + write(xmlSerializer, book.getMetadata().getIdentifiers(), book.getTitle(), + book.getMetadata().getAuthors(), book.getTableOfContents()); + } + + /** + * 写入 + * + * @param serializer serializer + * @param identifiers identifiers + * @param title title + * @param authors authors + * @param tableOfContents tableOfContents + */ + @SuppressWarnings("unused") + public static void write(XmlSerializer serializer, + List identifiers, String title, List authors, + TableOfContents tableOfContents) throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startDocument(Constants.CHARACTER_ENCODING, false); + serializer.setPrefix(EpubWriter.EMPTY_NAMESPACE_PREFIX, NAMESPACE_XHTML); + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.html); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, XHTMLAttributes.xmlns_epub, NAMESPACE_EPUB); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, XHTMLAttributes.xml_lang, XHTMLAttributeValues.lang); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, XHTMLAttributes.lang, LANGUAGE); + //写入头部head标签 + writeHead(title, serializer); + //body开始 + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.body); + //h1开始 + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.h1); + serializer.text(title); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.h1); + //h1关闭 + //nav开始 + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.nav); + serializer.attribute("", XHTMLAttributes.epub_type, XHTMLAttributeValues.epub_type); + serializer.attribute("", XHTMLAttributes.id, XHTMLAttributeValues.epub_type); + serializer.attribute("", XHTMLAttributes.role, XHTMLAttributeValues.role_toc); + //h2开始 + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.h2); + serializer.text("目录"); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.h2); + + + writeNavPoints(tableOfContents.getTocReferences(), 1, serializer); + + + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.nav); + + //body关闭 + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.body); + + + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.html); + serializer.endDocument(); + + } + + private static int writeNavPoints(List tocReferences, + int playOrder, + XmlSerializer serializer) throws IOException { + writeOlStart(serializer); + for (TOCReference tocReference : tocReferences) { + if (tocReference.getResource() == null) { + playOrder = writeNavPoints(tocReference.getChildren(), playOrder, + serializer); + continue; + } + + + writeNavPointStart(tocReference, serializer); + + playOrder++; + if (!tocReference.getChildren().isEmpty()) { + playOrder = writeNavPoints(tocReference.getChildren(), playOrder, + serializer); + } + + writeNavPointEnd(tocReference, serializer); + } + writeOlSEnd(serializer); + return playOrder; + } + + private static void writeNavPointStart(TOCReference tocReference, XmlSerializer serializer) throws IOException { + writeLiStart(serializer); + String title = tocReference.getTitle(); + String href = tocReference.getCompleteHref(); + if (StringUtil.isNotBlank(href)) { + writeLabel(title, href, serializer); + } else { + writeLabel(title, serializer); + } + } + + @SuppressWarnings("unused") + private static void writeNavPointEnd(TOCReference tocReference, + XmlSerializer serializer) throws IOException { + writeLiEnd(serializer); + } + + protected static void writeLabel(String title, String href, XmlSerializer serializer) throws IOException { + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.a); + serializer.attribute("", XHTMLAttributes.href, href); + //attribute必须在Text之前设置。 + serializer.text(title); + //serializer.attribute(NAMESPACE_XHTML, XHTMLAttributes.href, href); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.a); + } + + protected static void writeLabel(String title, XmlSerializer serializer) throws IOException { + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.span); + serializer.text(title); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.span); + } + + private static void writeLiStart(XmlSerializer serializer) throws IOException { + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.li); + Log.d(TAG, "writeLiStart"); + } + + private static void writeLiEnd(XmlSerializer serializer) throws IOException { + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.li); + Log.d(TAG, "writeLiEND"); + } + + private static void writeOlStart(XmlSerializer serializer) throws IOException { + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.ol); + Log.d(TAG, "writeOlStart"); + } + + private static void writeOlSEnd(XmlSerializer serializer) throws IOException { + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.ol); + Log.d(TAG, "writeOlEnd"); + } + + private static void writeHead(String title, XmlSerializer serializer) throws IOException { + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.head); + //title + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.title); + serializer.text(StringUtil.defaultIfNull(title)); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.title); + //link + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.link); + serializer.attribute("", XHTMLAttributes.rel, "stylesheet"); + serializer.attribute("", XHTMLAttributes.type, "text/css"); + serializer.attribute("", XHTMLAttributes.href, "css/style.css"); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.link); + + //meta + serializer.startTag(NAMESPACE_XHTML, XHTMLTgs.meta); + serializer.attribute("", XHTMLAttributes.http_equiv, XHTMLAttributeValues.Content_Type); + serializer.attribute("", XHTMLAttributes.content, XHTMLAttributeValues.HTML_UTF8); + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.meta); + + serializer.endTag(NAMESPACE_XHTML, XHTMLTgs.head); + } + + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentBase.java b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentBase.java new file mode 100644 index 000000000..a172ce32d --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentBase.java @@ -0,0 +1,97 @@ +package me.ag2s.epublib.epub; + + +/** + * Functionality shared by the PackageDocumentReader and the PackageDocumentWriter + * + * @author paul + * + */ +public class PackageDocumentBase { + + public static final String BOOK_ID_ID = "duokan-book-id"; + public static final String NAMESPACE_OPF = "http://www.idpf.org/2007/opf"; + public static final String NAMESPACE_DUBLIN_CORE = "http://purl.org/dc/elements/1.1/"; + public static final String PREFIX_DUBLIN_CORE = "dc"; + //public static final String PREFIX_OPF = "opf"; + //在EPUB3标准中,packge前面没有opf头,一些epub阅读器也不支持opf头。 + //Some Epub Reader not reconize op:packge,So just let it empty; + public static final String PREFIX_OPF = ""; + //添加 version 变量来区分Epub文件的版本 + //Add the version field to distinguish the version of EPUB file + public static final String version="version"; + public static final String dateFormat = "yyyy-MM-dd"; + + protected interface DCTags { + + String title = "title"; + String creator = "creator"; + String subject = "subject"; + String description = "description"; + String publisher = "publisher"; + String contributor = "contributor"; + String date = "date"; + String type = "type"; + String format = "format"; + String identifier = "identifier"; + String source = "source"; + String language = "language"; + String relation = "relation"; + String coverage = "coverage"; + String rights = "rights"; + } + + protected interface DCAttributes { + + String scheme = "scheme"; + String id = "id"; + } + + protected interface OPFTags { + + String metadata = "metadata"; + String meta = "meta"; + String manifest = "manifest"; + String packageTag = "package"; + String itemref = "itemref"; + String spine = "spine"; + String reference = "reference"; + String guide = "guide"; + String item = "item"; + } + + protected interface OPFAttributes { + + String uniqueIdentifier = "unique-identifier"; + String idref = "idref"; + String name = "name"; + String content = "content"; + String type = "type"; + String href = "href"; + String linear = "linear"; + String event = "event"; + String role = "role"; + String file_as = "file-as"; + String id = "id"; + String media_type = "media-type"; + String title = "title"; + String toc = "toc"; + String version = "version"; + String scheme = "scheme"; + String property = "property"; + //add for epub3 + /** + * add for epub3 + */ + String properties="properties"; + } + + protected interface OPFValues { + + String meta_cover = "cover"; + String reference_cover = "cover"; + String no = "no"; + String generator = "generator"; + String duokan = "duokan-body-font"; + } +} \ No newline at end of file diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentMetadataReader.java b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentMetadataReader.java new file mode 100644 index 000000000..86f3f95c0 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentMetadataReader.java @@ -0,0 +1,225 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; + +import me.ag2s.epublib.domain.Author; +import me.ag2s.epublib.domain.Date; +import me.ag2s.epublib.domain.Identifier; +import me.ag2s.epublib.domain.Metadata; +import me.ag2s.epublib.util.StringUtil; + +/** + * Reads the package document metadata. + *

    + * In its own separate class because the PackageDocumentReader became a bit large and unwieldy. + * + * @author paul + */ +// package +class PackageDocumentMetadataReader extends PackageDocumentBase { + + private static final String TAG = PackageDocumentMetadataReader.class.getName(); + + public static Metadata readMetadata(Document packageDocument) { + Metadata result = new Metadata(); + Element metadataElement = DOMUtil + .getFirstElementByTagNameNS(packageDocument.getDocumentElement(), + NAMESPACE_OPF, OPFTags.metadata); + if (metadataElement == null) { + Log.e(TAG, "Package does not contain element " + OPFTags.metadata); + return result; + } + result.setTitles(DOMUtil + .getElementsTextChild(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.title)); + result.setPublishers(DOMUtil + .getElementsTextChild(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.publisher)); + result.setDescriptions(DOMUtil + .getElementsTextChild(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.description)); + result.setRights(DOMUtil + .getElementsTextChild(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.rights)); + result.setTypes(DOMUtil + .getElementsTextChild(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.type)); + result.setSubjects(DOMUtil + .getElementsTextChild(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.subject)); + result.setIdentifiers(readIdentifiers(metadataElement)); + result.setAuthors(readCreators(metadataElement)); + result.setContributors(readContributors(metadataElement)); + result.setDates(readDates(metadataElement)); + result.setOtherProperties(readOtherProperties(metadataElement)); + result.setMetaAttributes(readMetaProperties(metadataElement)); + Element languageTag = DOMUtil + .getFirstElementByTagNameNS(metadataElement, NAMESPACE_DUBLIN_CORE, + DCTags.language); + if (languageTag != null) { + result.setLanguage(DOMUtil.getTextChildrenContent(languageTag)); + } + + return result; + } + + /** + * consumes meta tags that have a property attribute as defined in the standard. For example: + * <meta property="rendition:layout">pre-paginated</meta> + * + * @param metadataElement metadataElement + * @return Map + */ + private static Map readOtherProperties( + Element metadataElement) { + Map result = new HashMap<>(); + + NodeList metaTags = metadataElement.getElementsByTagName(OPFTags.meta); + for (int i = 0; i < metaTags.getLength(); i++) { + Node metaNode = metaTags.item(i); + Node property = metaNode.getAttributes() + .getNamedItem(OPFAttributes.property); + if (property != null) { + String name = property.getNodeValue(); + String value = metaNode.getTextContent(); + result.put(new QName(name), value); + } + } + + return result; + } + + /** + * consumes meta tags that have a property attribute as defined in the standard. For example: + * <meta property="rendition:layout">pre-paginated</meta> + * + * @param metadataElement metadataElement + * @return Map + */ + private static Map readMetaProperties( + Element metadataElement) { + Map result = new HashMap<>(); + + NodeList metaTags = metadataElement.getElementsByTagName(OPFTags.meta); + for (int i = 0; i < metaTags.getLength(); i++) { + Element metaElement = (Element) metaTags.item(i); + String name = metaElement.getAttribute(OPFAttributes.name); + String value = metaElement.getAttribute(OPFAttributes.content); + result.put(name, value); + } + + return result; + } + + private static String getBookIdId(Document document) { + Element packageElement = DOMUtil + .getFirstElementByTagNameNS(document.getDocumentElement(), + NAMESPACE_OPF, OPFTags.packageTag); + if (packageElement == null) { + return null; + } + return DOMUtil.getAttribute(packageElement, NAMESPACE_OPF, OPFAttributes.uniqueIdentifier); + + } + + private static List readCreators(Element metadataElement) { + return readAuthors(DCTags.creator, metadataElement); + } + + private static List readContributors(Element metadataElement) { + return readAuthors(DCTags.contributor, metadataElement); + } + + private static List readAuthors(String authorTag, + Element metadataElement) { + NodeList elements = metadataElement + .getElementsByTagNameNS(NAMESPACE_DUBLIN_CORE, authorTag); + List result = new ArrayList<>(elements.getLength()); + for (int i = 0; i < elements.getLength(); i++) { + Element authorElement = (Element) elements.item(i); + Author author = createAuthor(authorElement); + if (author != null) { + result.add(author); + } + } + return result; + + } + + private static List readDates(Element metadataElement) { + NodeList elements = metadataElement + .getElementsByTagNameNS(NAMESPACE_DUBLIN_CORE, DCTags.date); + List result = new ArrayList<>(elements.getLength()); + for (int i = 0; i < elements.getLength(); i++) { + Element dateElement = (Element) elements.item(i); + Date date; + try { + date = new Date(DOMUtil.getTextChildrenContent(dateElement), + DOMUtil.getAttribute(dateElement, NAMESPACE_OPF, OPFAttributes.event)); + result.add(date); + } catch (IllegalArgumentException e) { + Log.e(TAG, e.getMessage()); + } + } + return result; + + } + + private static Author createAuthor(Element authorElement) { + String authorString = DOMUtil.getTextChildrenContent(authorElement); + if (StringUtil.isBlank(authorString)) { + return null; + } + int spacePos = authorString.lastIndexOf(' '); + Author result; + if (spacePos < 0) { + result = new Author(authorString); + } else { + result = new Author(authorString.substring(0, spacePos), + authorString.substring(spacePos + 1)); + } + result.setRole( + DOMUtil.getAttribute(authorElement, NAMESPACE_OPF, OPFAttributes.role)); + return result; + } + + + private static List readIdentifiers(Element metadataElement) { + NodeList identifierElements = metadataElement + .getElementsByTagNameNS(NAMESPACE_DUBLIN_CORE, DCTags.identifier); + if (identifierElements.getLength() == 0) { + Log.e(TAG, "Package does not contain element " + DCTags.identifier); + return new ArrayList<>(); + } + String bookIdId = getBookIdId(metadataElement.getOwnerDocument()); + List result = new ArrayList<>( + identifierElements.getLength()); + for (int i = 0; i < identifierElements.getLength(); i++) { + Element identifierElement = (Element) identifierElements.item(i); + String schemeName = DOMUtil.getAttribute(identifierElement, NAMESPACE_OPF, DCAttributes.scheme); + String identifierValue = DOMUtil + .getTextChildrenContent(identifierElement); + if (StringUtil.isBlank(identifierValue)) { + continue; + } + Identifier identifier = new Identifier(schemeName, identifierValue); + if (identifierElement.getAttribute("id").equals(bookIdId)) { + identifier.setBookId(true); + } + result.add(identifier); + } + return result; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentMetadataWriter.java b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentMetadataWriter.java new file mode 100644 index 000000000..fc758bc03 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentMetadataWriter.java @@ -0,0 +1,187 @@ +package me.ag2s.epublib.epub; + +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.Author; +import me.ag2s.epublib.domain.Date; +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Identifier; +import me.ag2s.epublib.util.StringUtil; + +public class PackageDocumentMetadataWriter extends PackageDocumentBase { + + /** + * Writes the book's metadata. + * + * @param book book + * @param serializer serializer + * @throws IOException IOException + * @throws IllegalStateException IllegalStateException + * @throws IllegalArgumentException IllegalArgumentException + */ + public static void writeMetaData(EpubBook book, XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startTag(NAMESPACE_OPF, OPFTags.metadata); + serializer.setPrefix(PREFIX_DUBLIN_CORE, NAMESPACE_DUBLIN_CORE); + serializer.setPrefix(PREFIX_OPF, NAMESPACE_OPF); + + writeIdentifiers(book.getMetadata().getIdentifiers(), serializer); + writeSimpleMetdataElements(DCTags.title, book.getMetadata().getTitles(), + serializer); + writeSimpleMetdataElements(DCTags.subject, book.getMetadata().getSubjects(), + serializer); + writeSimpleMetdataElements(DCTags.description, + book.getMetadata().getDescriptions(), serializer); + writeSimpleMetdataElements(DCTags.publisher, + book.getMetadata().getPublishers(), serializer); + writeSimpleMetdataElements(DCTags.type, book.getMetadata().getTypes(), + serializer); + writeSimpleMetdataElements(DCTags.rights, book.getMetadata().getRights(), + serializer); + + // write authors + for (Author author : book.getMetadata().getAuthors()) { + serializer.startTag(NAMESPACE_DUBLIN_CORE, DCTags.creator); + serializer.attribute(NAMESPACE_OPF, OPFAttributes.role, + author.getRelator().getCode()); + serializer.attribute(NAMESPACE_OPF, OPFAttributes.file_as, + author.getLastname() + ", " + author.getFirstname()); + serializer.text(author.getFirstname() + " " + author.getLastname()); + serializer.endTag(NAMESPACE_DUBLIN_CORE, DCTags.creator); + } + + // write contributors + for (Author author : book.getMetadata().getContributors()) { + serializer.startTag(NAMESPACE_DUBLIN_CORE, DCTags.contributor); + serializer.attribute(NAMESPACE_OPF, OPFAttributes.role, + author.getRelator().getCode()); + serializer.attribute(NAMESPACE_OPF, OPFAttributes.file_as, + author.getLastname() + ", " + author.getFirstname()); + serializer.text(author.getFirstname() + " " + author.getLastname()); + serializer.endTag(NAMESPACE_DUBLIN_CORE, DCTags.contributor); + } + + // write dates + for (Date date : book.getMetadata().getDates()) { + serializer.startTag(NAMESPACE_DUBLIN_CORE, DCTags.date); + if (date.getEvent() != null) { + serializer.attribute(NAMESPACE_OPF, OPFAttributes.event, + date.getEvent().toString()); + } + serializer.text(date.getValue()); + serializer.endTag(NAMESPACE_DUBLIN_CORE, DCTags.date); + } + + // write language + if (StringUtil.isNotBlank(book.getMetadata().getLanguage())) { + serializer.startTag(NAMESPACE_DUBLIN_CORE, "language"); + serializer.text(book.getMetadata().getLanguage()); + serializer.endTag(NAMESPACE_DUBLIN_CORE, "language"); + } + + // write other properties + if (book.getMetadata().getOtherProperties() != null) { + for (Map.Entry mapEntry : book.getMetadata() + .getOtherProperties().entrySet()) { + serializer.startTag(mapEntry.getKey().getNamespaceURI(), OPFTags.meta); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, + OPFAttributes.property, mapEntry.getKey().getLocalPart()); + serializer.text(mapEntry.getValue()); + serializer.endTag(mapEntry.getKey().getNamespaceURI(), OPFTags.meta); + + } + } + + // write coverimage + if (book.getCoverImage() != null) { // write the cover image + serializer.startTag(NAMESPACE_OPF, OPFTags.meta); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.name, + OPFValues.meta_cover); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.content, + book.getCoverImage().getId()); + serializer.endTag(NAMESPACE_OPF, OPFTags.meta); + } + + // write generator + serializer.startTag(NAMESPACE_OPF, OPFTags.meta); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.name, + OPFValues.generator); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.content, + Constants.EPUB_GENERATOR_NAME); + serializer.endTag(NAMESPACE_OPF, OPFTags.meta); + + // write duokan + serializer.startTag(NAMESPACE_OPF, OPFTags.meta); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.name, + OPFValues.duokan); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.content, + Constants.EPUB_DUOKAN_NAME); + serializer.endTag(NAMESPACE_OPF, OPFTags.meta); + + serializer.endTag(NAMESPACE_OPF, OPFTags.metadata); + } + + private static void writeSimpleMetdataElements(String tagName, + List values, XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + for (String value : values) { + if (StringUtil.isBlank(value)) { + continue; + } + serializer.startTag(NAMESPACE_DUBLIN_CORE, tagName); + serializer.text(value); + serializer.endTag(NAMESPACE_DUBLIN_CORE, tagName); + } + } + + + /** + * Writes out the complete list of Identifiers to the package document. + * The first identifier for which the bookId is true is made the bookId identifier. + * If no identifier has bookId == true then the first bookId identifier is written as the primary. + * + * @param identifiers identifiers + * @param serializer serializer + * @throws IllegalStateException e + * @throws IllegalArgumentException e + * @ + */ + private static void writeIdentifiers(List identifiers, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + Identifier bookIdIdentifier = Identifier.getBookIdIdentifier(identifiers); + if (bookIdIdentifier == null) { + return; + } + + serializer.startTag(NAMESPACE_DUBLIN_CORE, DCTags.identifier); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, DCAttributes.id, + BOOK_ID_ID); + serializer.attribute(NAMESPACE_OPF, OPFAttributes.scheme, + bookIdIdentifier.getScheme()); + serializer.text(bookIdIdentifier.getValue()); + serializer.endTag(NAMESPACE_DUBLIN_CORE, DCTags.identifier); + + for (Identifier identifier : identifiers.subList(1, identifiers.size())) { + if (identifier == bookIdIdentifier) { + continue; + } + serializer.startTag(NAMESPACE_DUBLIN_CORE, DCTags.identifier); + serializer.attribute(NAMESPACE_OPF, "scheme", identifier.getScheme()); + serializer.text(identifier.getValue()); + serializer.endTag(NAMESPACE_DUBLIN_CORE, DCTags.identifier); + } + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentReader.java b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentReader.java new file mode 100644 index 000000000..06a44ba9b --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentReader.java @@ -0,0 +1,423 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Guide; +import me.ag2s.epublib.domain.GuideReference; +import me.ag2s.epublib.domain.MediaType; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.domain.Resources; +import me.ag2s.epublib.domain.Spine; +import me.ag2s.epublib.domain.SpineReference; +import me.ag2s.epublib.util.ResourceUtil; +import me.ag2s.epublib.util.StringUtil; + +/** + * Reads the opf package document as defined by namespace http://www.idpf.org/2007/opf + * + * @author paul + */ +public class PackageDocumentReader extends PackageDocumentBase { + + private static final String TAG = PackageDocumentReader.class.getName(); + private static final String[] POSSIBLE_NCX_ITEM_IDS = new String[]{"toc", + "ncx", "ncxtoc", "htmltoc"}; + + + public static void read( + Resource packageResource, EpubReader epubReader, EpubBook book, + Resources resources) + throws SAXException, IOException { + Document packageDocument = ResourceUtil.getAsDocument(packageResource); + String packageHref = packageResource.getHref(); + resources = fixHrefs(packageHref, resources); + readGuide(packageDocument, epubReader, book, resources); + + // Books sometimes use non-identifier ids. We map these here to legal ones + Map idMapping = new HashMap<>(); + String version = DOMUtil.getAttribute(packageDocument.getDocumentElement(), PREFIX_OPF, PackageDocumentBase.version); + + resources = readManifest(packageDocument, packageHref, epubReader, + resources, idMapping); + book.setResources(resources); + book.setVersion(version); + readCover(packageDocument, book); + book.setMetadata( + PackageDocumentMetadataReader.readMetadata(packageDocument)); + book.setSpine(readSpine(packageDocument, book.getResources(), idMapping)); + + // if we did not find a cover page then we make the first page of the book the cover page + if (book.getCoverPage() == null && book.getSpine().size() > 0) { + book.setCoverPage(book.getSpine().getResource(0)); + } + } + + /** + * Reads the manifest containing the resource ids, hrefs and mediatypes. + * + * @param packageDocument e + * @param packageHref e + * @param epubReader e + * @param resources e + * @param idMapping e + * @return a Map with resources, with their id's as key. + */ + @SuppressWarnings("unused") + private static Resources readManifest(Document packageDocument, + String packageHref, + EpubReader epubReader, Resources resources, + Map idMapping) { + Element manifestElement = DOMUtil + .getFirstElementByTagNameNS(packageDocument.getDocumentElement(), + NAMESPACE_OPF, OPFTags.manifest); + Resources result = new Resources(); + if (manifestElement == null) { + Log.e(TAG, + "Package document does not contain element " + OPFTags.manifest); + return result; + } + NodeList itemElements = manifestElement + .getElementsByTagNameNS(NAMESPACE_OPF, OPFTags.item); + for (int i = 0; i < itemElements.getLength(); i++) { + Element itemElement = (Element) itemElements.item(i); + String id = DOMUtil + .getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.id); + String href = DOMUtil + .getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.href); + + try { + href = URLDecoder.decode(href, Constants.CHARACTER_ENCODING); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, e.getMessage()); + } + String mediaTypeName = DOMUtil + .getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.media_type); + Resource resource = resources.remove(href); + if (resource == null) { + Log.e(TAG, "resource with href '" + href + "' not found"); + continue; + } + resource.setId(id); + //for epub3 + String properties = DOMUtil.getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.properties); + resource.setProperties(properties); + + MediaType mediaType = MediaTypes.getMediaTypeByName(mediaTypeName); + if (mediaType != null) { + resource.setMediaType(mediaType); + } + result.add(resource); + idMapping.put(id, resource.getId()); + } + return result; + } + + + /** + * Reads the book's guide. + * Here some more attempts are made at finding the cover page. + * + * @param packageDocument r + * @param epubReader r + * @param book r + * @param resources g + */ + @SuppressWarnings("unused") + private static void readGuide(Document packageDocument, + EpubReader epubReader, EpubBook book, Resources resources) { + Element guideElement = DOMUtil + .getFirstElementByTagNameNS(packageDocument.getDocumentElement(), + NAMESPACE_OPF, OPFTags.guide); + if (guideElement == null) { + return; + } + Guide guide = book.getGuide(); + NodeList guideReferences = guideElement + .getElementsByTagNameNS(NAMESPACE_OPF, OPFTags.reference); + for (int i = 0; i < guideReferences.getLength(); i++) { + Element referenceElement = (Element) guideReferences.item(i); + String resourceHref = DOMUtil + .getAttribute(referenceElement, NAMESPACE_OPF, OPFAttributes.href); + if (StringUtil.isBlank(resourceHref)) { + continue; + } + Resource resource = resources.getByHref(StringUtil + .substringBefore(resourceHref, Constants.FRAGMENT_SEPARATOR_CHAR)); + if (resource == null) { + Log.e(TAG, "Guide is referencing resource with href " + resourceHref + + " which could not be found"); + continue; + } + String type = DOMUtil + .getAttribute(referenceElement, NAMESPACE_OPF, OPFAttributes.type); + if (StringUtil.isBlank(type)) { + Log.e(TAG, "Guide is referencing resource with href " + resourceHref + + " which is missing the 'type' attribute"); + continue; + } + String title = DOMUtil + .getAttribute(referenceElement, NAMESPACE_OPF, OPFAttributes.title); + if (GuideReference.COVER.equalsIgnoreCase(type)) { + continue; // cover is handled elsewhere + } + GuideReference reference = new GuideReference(resource, type, title, + StringUtil + .substringAfter(resourceHref, Constants.FRAGMENT_SEPARATOR_CHAR)); + guide.addReference(reference); + } + } + + + /** + * Strips off the package prefixes up to the href of the packageHref. + *

    + * Example: + * If the packageHref is "OEBPS/content.opf" then a resource href like "OEBPS/foo/bar.html" will be turned into "foo/bar.html" + * + * @param packageHref f + * @param resourcesByHref g + * @return The stripped package href + */ + static Resources fixHrefs(String packageHref, + Resources resourcesByHref) { + int lastSlashPos = packageHref.lastIndexOf('/'); + if (lastSlashPos < 0) { + return resourcesByHref; + } + Resources result = new Resources(); + for (Resource resource : resourcesByHref.getAll()) { + if (StringUtil.isNotBlank(resource.getHref()) + && resource.getHref().length() > lastSlashPos) { + resource.setHref(resource.getHref().substring(lastSlashPos + 1)); + } + result.add(resource); + } + return result; + } + + /** + * Reads the document's spine, containing all sections in reading order. + * + * @param packageDocument b + * @param resources b + * @param idMapping b + * @return the document's spine, containing all sections in reading order. + */ + private static Spine readSpine(Document packageDocument, Resources resources, + Map idMapping) { + + Element spineElement = DOMUtil + .getFirstElementByTagNameNS(packageDocument.getDocumentElement(), + NAMESPACE_OPF, OPFTags.spine); + if (spineElement == null) { + Log.e(TAG, "Element " + OPFTags.spine + + " not found in package document, generating one automatically"); + return generateSpineFromResources(resources); + } + Spine result = new Spine(); + String tocResourceId = DOMUtil.getAttribute(spineElement, NAMESPACE_OPF, OPFAttributes.toc); + Log.v(TAG, tocResourceId); + result.setTocResource(findTableOfContentsResource(tocResourceId, resources)); + NodeList spineNodes = DOMUtil.getElementsByTagNameNS(packageDocument, NAMESPACE_OPF, OPFTags.itemref); + if (spineNodes == null) { + Log.e(TAG, "spineNodes is null"); + return result; + } + List spineReferences = new ArrayList<>(spineNodes.getLength()); + for (int i = 0; i < spineNodes.getLength(); i++) { + Element spineItem = (Element) spineNodes.item(i); + String itemref = DOMUtil.getAttribute(spineItem, NAMESPACE_OPF, OPFAttributes.idref); + if (StringUtil.isBlank(itemref)) { + Log.e(TAG, "itemref with missing or empty idref"); // XXX + continue; + } + String id = idMapping.get(itemref); + if (id == null) { + id = itemref; + } + + Resource resource = resources.getByIdOrHref(id); + if (resource == null) { + Log.e(TAG, "resource with id '" + id + "' not found"); + continue; + } + + SpineReference spineReference = new SpineReference(resource); + if (OPFValues.no.equalsIgnoreCase(DOMUtil + .getAttribute(spineItem, NAMESPACE_OPF, OPFAttributes.linear))) { + spineReference.setLinear(false); + } + spineReferences.add(spineReference); + } + result.setSpineReferences(spineReferences); + return result; + } + + /** + * Creates a spine out of all resources in the resources. + * The generated spine consists of all XHTML pages in order of their href. + * + * @param resources f + * @return a spine created out of all resources in the resources. + */ + private static Spine generateSpineFromResources(Resources resources) { + Spine result = new Spine(); + List resourceHrefs = new ArrayList<>(resources.getAllHrefs()); + Collections.sort(resourceHrefs, String.CASE_INSENSITIVE_ORDER); + for (String resourceHref : resourceHrefs) { + Resource resource = resources.getByHref(resourceHref); + if (resource.getMediaType() == MediaTypes.NCX) { + result.setTocResource(resource); + } else if (resource.getMediaType() == MediaTypes.XHTML) { + result.addSpineReference(new SpineReference(resource)); + } + } + return result; + } + + + /** + * The spine tag should contain a 'toc' attribute with as value the resource id of the table of contents resource. + *

    + * Here we try several ways of finding this table of contents resource. + * We try the given attribute value, some often-used ones and finally look through all resources for the first resource with the table of contents mimetype. + * + * @param tocResourceId g + * @param resources g + * @return the Resource containing the table of contents + */ + static Resource findTableOfContentsResource( + String tocResourceId, + Resources resources + ) { + Resource tocResource; + //一些epub3的文件为了兼容epub2,保留的epub2的目录文件,这里优先选择epub3的xml目录 + tocResource = resources.getByProperties("nav"); + if (tocResource != null) { + return tocResource; + } + + if (StringUtil.isNotBlank(tocResourceId)) { + tocResource = resources.getByIdOrHref(tocResourceId); + } + + if (tocResource != null) { + return tocResource; + } + + // get the first resource with the NCX mediatype + tocResource = resources.findFirstResourceByMediaType(MediaTypes.NCX); + + if (tocResource == null) { + for (String possibleNcxItemId : POSSIBLE_NCX_ITEM_IDS) { + tocResource = resources.getByIdOrHref(possibleNcxItemId); + if (tocResource != null) { + break; + } + tocResource = resources + .getByIdOrHref(possibleNcxItemId.toUpperCase()); + if (tocResource != null) { + break; + } + } + } + + + if (tocResource == null) { + Log.e(TAG, + "Could not find table of contents resource. Tried resource with id '" + + tocResourceId + "', " + Constants.DEFAULT_TOC_ID + ", " + + Constants.DEFAULT_TOC_ID.toUpperCase() + + " and any NCX resource."); + } + return tocResource; + } + + + /** + * Find all resources that have something to do with the coverpage and the cover image. + * Search the meta tags and the guide references + * + * @param packageDocument s + * @return all resources that have something to do with the coverpage and the cover image. + */ + // package + static Set findCoverHrefs(Document packageDocument) { + + Set result = new HashSet<>(); + + // try and find a meta tag with name = 'cover' and a non-blank id + String coverResourceId = DOMUtil + .getFindAttributeValue(packageDocument, NAMESPACE_OPF, + OPFTags.meta, OPFAttributes.name, OPFValues.meta_cover, + OPFAttributes.content); + + if (StringUtil.isNotBlank(coverResourceId)) { + String coverHref = DOMUtil + .getFindAttributeValue(packageDocument, NAMESPACE_OPF, + OPFTags.item, OPFAttributes.id, coverResourceId, + OPFAttributes.href); + if (StringUtil.isNotBlank(coverHref)) { + result.add(coverHref); + } else { + result.add( + coverResourceId); // maybe there was a cover href put in the cover id attribute + } + } + // try and find a reference tag with type is 'cover' and reference is not blank + String coverHref = DOMUtil + .getFindAttributeValue(packageDocument, NAMESPACE_OPF, + OPFTags.reference, OPFAttributes.type, OPFValues.reference_cover, + OPFAttributes.href); + if (StringUtil.isNotBlank(coverHref)) { + result.add(coverHref); + } + return result; + } + + /** + * Finds the cover resource in the packageDocument and adds it to the book if found. + * Keeps the cover resource in the resources map + * + * @param packageDocument s + * @param book x + */ + private static void readCover(Document packageDocument, EpubBook book) { + + Collection coverHrefs = findCoverHrefs(packageDocument); + for (String coverHref : coverHrefs) { + Resource resource = book.getResources().getByHref(coverHref); + if (resource == null) { + Log.e(TAG, "Cover resource " + coverHref + " not found"); + continue; + } + if (resource.getMediaType() == MediaTypes.XHTML) { + book.setCoverPage(resource); + } else if (MediaTypes.isBitmapImage(resource.getMediaType())) { + book.setCoverImage(resource); + } + } + } + + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentWriter.java b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentWriter.java new file mode 100644 index 000000000..73073db2a --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/PackageDocumentWriter.java @@ -0,0 +1,250 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import me.ag2s.epublib.BuildConfig; +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.EpubBook; +import me.ag2s.epublib.domain.Guide; +import me.ag2s.epublib.domain.GuideReference; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.domain.Spine; +import me.ag2s.epublib.domain.SpineReference; +import me.ag2s.epublib.util.StringUtil; + +/** + * Writes the opf package document as defined by namespace http://www.idpf.org/2007/opf + * + * @author paul + */ +public class PackageDocumentWriter extends PackageDocumentBase { + + private static final String TAG = PackageDocumentWriter.class.getName(); + + public static void write(EpubWriter epubWriter, XmlSerializer serializer, + EpubBook book) { + try { + serializer.startDocument(Constants.CHARACTER_ENCODING, false); + serializer.setPrefix(PREFIX_OPF, NAMESPACE_OPF); + serializer.setPrefix(PREFIX_DUBLIN_CORE, NAMESPACE_DUBLIN_CORE); + serializer.startTag(NAMESPACE_OPF, OPFTags.packageTag); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.version, + book.getVersion()); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, + OPFAttributes.uniqueIdentifier, BOOK_ID_ID); + + PackageDocumentMetadataWriter.writeMetaData(book, serializer); + + writeManifest(book, epubWriter, serializer); + writeSpine(book, epubWriter, serializer); + writeGuide(book, epubWriter, serializer); + + serializer.endTag(NAMESPACE_OPF, OPFTags.packageTag); + serializer.endDocument(); + serializer.flush(); + } catch (IOException e) { + if (BuildConfig.DEBUG) { + e.printStackTrace(); + } + } + } + + /** + * Writes the package's spine. + * + * @param book e + * @param epubWriter g + * @param serializer g + * @throws IOException g + * @throws IllegalStateException g + * @throws IllegalArgumentException 1@throws XMLStreamException + */ + @SuppressWarnings("unused") + private static void writeSpine(EpubBook book, EpubWriter epubWriter, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startTag(NAMESPACE_OPF, OPFTags.spine); + Resource tocResource = book.getSpine().getTocResource(); + String tocResourceId = tocResource.getId(); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.toc, + tocResourceId); + + if (book.getCoverPage() != null // there is a cover page + && book.getSpine().findFirstResourceById(book.getCoverPage().getId()) + < 0) { // cover page is not already in the spine + // write the cover html file + serializer.startTag(NAMESPACE_OPF, OPFTags.itemref); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.idref, + book.getCoverPage().getId()); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.linear, + "no"); + serializer.endTag(NAMESPACE_OPF, OPFTags.itemref); + } + writeSpineItems(book.getSpine(), serializer); + serializer.endTag(NAMESPACE_OPF, OPFTags.spine); + } + + + private static void writeManifest(EpubBook book, EpubWriter epubWriter, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startTag(NAMESPACE_OPF, OPFTags.manifest); + + serializer.startTag(NAMESPACE_OPF, OPFTags.item); + + //For EPUB3 + if (book.isEpub3()) { + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.properties, NCXDocumentV3.V3_NCX_PROPERTIES); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.id, NCXDocumentV3.NCX_ITEM_ID); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.href, NCXDocumentV3.DEFAULT_NCX_HREF); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.media_type, NCXDocumentV3.V3_NCX_MEDIATYPE.getName()); + } else { + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.id, + epubWriter.getNcxId()); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.href, epubWriter.getNcxHref()); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.media_type, epubWriter.getNcxMediaType()); + } + + serializer.endTag(NAMESPACE_OPF, OPFTags.item); + +// writeCoverResources(book, serializer); + + for (Resource resource : getAllResourcesSortById(book)) { + writeItem(book, resource, serializer); + } + + serializer.endTag(NAMESPACE_OPF, OPFTags.manifest); + } + + private static List getAllResourcesSortById(EpubBook book) { + List allResources = new ArrayList<>( + book.getResources().getAll()); + Collections.sort(allResources, (resource1, resource2) -> resource1.getId().compareToIgnoreCase(resource2.getId())); + return allResources; + } + + /** + * Writes a resources as an item element + * + * @param resource g + * @param serializer g + * @throws IOException g + * @throws IllegalStateException g + * @throws IllegalArgumentException 1@throws XMLStreamException + */ + private static void writeItem(EpubBook book, Resource resource, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + if (resource == null || + (resource.getMediaType() == MediaTypes.NCX + && book.getSpine().getTocResource() != null)) { + return; + } + if (StringUtil.isBlank(resource.getId())) { + Log.e(TAG, "resource id must not be empty (href: " + resource.getHref() + + ", mediatype:" + resource.getMediaType() + ")"); + return; + } + if (StringUtil.isBlank(resource.getHref())) { + Log.e(TAG, "resource href must not be empty (id: " + resource.getId() + + ", mediatype:" + resource.getMediaType() + ")"); + return; + } + if (resource.getMediaType() == null) { + Log.e(TAG, "resource mediatype must not be empty (id: " + resource.getId() + + ", href:" + resource.getHref() + ")"); + return; + } + serializer.startTag(NAMESPACE_OPF, OPFTags.item); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.id, + resource.getId()); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.href, + resource.getHref()); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.media_type, + resource.getMediaType().getName()); + serializer.endTag(NAMESPACE_OPF, OPFTags.item); + } + + /** + * List all spine references + * + * @throws IOException f + * @throws IllegalStateException f + * @throws IllegalArgumentException f + */ + @SuppressWarnings("unused") + private static void writeSpineItems(Spine spine, XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + for (SpineReference spineReference : spine.getSpineReferences()) { + serializer.startTag(NAMESPACE_OPF, OPFTags.itemref); + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.idref, + spineReference.getResourceId()); + if (!spineReference.isLinear()) { + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.linear, + OPFValues.no); + } + serializer.endTag(NAMESPACE_OPF, OPFTags.itemref); + } + } + + private static void writeGuide(EpubBook book, EpubWriter epubWriter, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + serializer.startTag(NAMESPACE_OPF, OPFTags.guide); + ensureCoverPageGuideReferenceWritten(book.getGuide(), epubWriter, + serializer); + for (GuideReference reference : book.getGuide().getReferences()) { + writeGuideReference(reference, serializer); + } + serializer.endTag(NAMESPACE_OPF, OPFTags.guide); + } + + @SuppressWarnings("unused") + private static void ensureCoverPageGuideReferenceWritten(Guide guide, + EpubWriter epubWriter, XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + if (!(guide.getGuideReferencesByType(GuideReference.COVER).isEmpty())) { + return; + } + Resource coverPage = guide.getCoverPage(); + if (coverPage != null) { + writeGuideReference( + new GuideReference(guide.getCoverPage(), GuideReference.COVER, + GuideReference.COVER), serializer); + } + } + + + private static void writeGuideReference(GuideReference reference, + XmlSerializer serializer) + throws IllegalArgumentException, IllegalStateException, IOException { + if (reference == null) { + return; + } + serializer.startTag(NAMESPACE_OPF, OPFTags.reference); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.type, + reference.getType()); + serializer.attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.href, + reference.getCompleteHref()); + if (StringUtil.isNotBlank(reference.getTitle())) { + serializer + .attribute(EpubWriter.EMPTY_NAMESPACE_PREFIX, OPFAttributes.title, + reference.getTitle()); + } + serializer.endTag(NAMESPACE_OPF, OPFTags.reference); + } +} \ No newline at end of file diff --git a/epublib/src/main/java/me/ag2s/epublib/epub/ResourcesLoader.java b/epublib/src/main/java/me/ag2s/epublib/epub/ResourcesLoader.java new file mode 100644 index 000000000..5da77b8d2 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/epub/ResourcesLoader.java @@ -0,0 +1,185 @@ +package me.ag2s.epublib.epub; + +import android.util.Log; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; + +import me.ag2s.epublib.domain.EpubResourceProvider; +import me.ag2s.epublib.domain.LazyResource; +import me.ag2s.epublib.domain.LazyResourceProvider; +import me.ag2s.epublib.domain.MediaType; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.domain.Resources; +import me.ag2s.epublib.util.CollectionUtil; +import me.ag2s.epublib.util.ResourceUtil; + + +/** + * Loads Resources from inputStreams, ZipFiles, etc + * + * @author paul + */ +public class ResourcesLoader { + + private static final String TAG = ResourcesLoader.class.getName(); + + + /** + * Loads the entries of the zipFile as resources. + *

    + * The MediaTypes that are in the lazyLoadedTypes will not get their + * contents loaded, but are stored as references to entries into the + * ZipFile and are loaded on demand by the Resource system. + * + * @param zipFile import epub zipfile + * @param defaultHtmlEncoding epub xhtml default encoding + * @param lazyLoadedTypes lazyLoadedTypes + * @return Resources + * @throws IOException IOException + */ + public static Resources loadResources(ZipFile zipFile, + String defaultHtmlEncoding, + List lazyLoadedTypes) throws IOException { + + LazyResourceProvider resourceProvider = + new EpubResourceProvider(zipFile.getName()); + + Resources result = new Resources(); + Enumeration entries = zipFile.entries(); + + while (entries.hasMoreElements()) { + ZipEntry zipEntry = entries.nextElement(); + + if (zipEntry == null || zipEntry.isDirectory()) { + continue; + } + + String href = zipEntry.getName(); + + Resource resource; + + if (shouldLoadLazy(href, lazyLoadedTypes)) { + resource = new LazyResource(resourceProvider, zipEntry.getSize(), href); + } else { + resource = ResourceUtil + .createResource(zipEntry, zipFile.getInputStream(zipEntry)); + /*掌上书苑有很多自制书OPF的nameSpace格式不标准,强制修复成正确的格式*/ + if (href.endsWith("opf")) { + String string = new String(resource.getData()).replace("smlns=\"", "xmlns=\""); + resource.setData(string.getBytes()); + } + + } + + if (resource.getMediaType() == MediaTypes.XHTML) { + resource.setInputEncoding(defaultHtmlEncoding); + } + result.add(resource); + } + + return result; + } + + /** + * Whether the given href will load a mediaType that is in the + * collection of lazilyLoadedMediaTypes. + * + * @param href href + * @param lazilyLoadedMediaTypes lazilyLoadedMediaTypes + * @return Whether the given href will load a mediaType that is + * in the collection of lazilyLoadedMediaTypes. + */ + private static boolean shouldLoadLazy(String href, + Collection lazilyLoadedMediaTypes) { + if (CollectionUtil.isEmpty(lazilyLoadedMediaTypes)) { + return false; + } + MediaType mediaType = MediaTypes.determineMediaType(href); + return lazilyLoadedMediaTypes.contains(mediaType); + } + + /** + * Loads all entries from the ZipInputStream as Resources. + *

    + * Loads the contents of all ZipEntries into memory. + * Is fast, but may lead to memory problems when reading large books + * on devices with small amounts of memory. + * + * @param zipInputStream zipInputStream + * @param defaultHtmlEncoding defaultHtmlEncoding + * @return Resources + * @throws IOException IOException + */ + public static Resources loadResources(ZipInputStream zipInputStream, + String defaultHtmlEncoding) throws IOException { + Resources result = new Resources(); + ZipEntry zipEntry; + do { + // get next valid zipEntry + zipEntry = getNextZipEntry(zipInputStream); + if ((zipEntry == null) || zipEntry.isDirectory()) { + continue; + } + String href = zipEntry.getName(); + + // store resource + Resource resource = ResourceUtil.createResource(zipEntry, zipInputStream); + ///*掌上书苑有很多自制书OPF的nameSpace格式不标准,强制修复成正确的格式*/ + if (href.endsWith("opf")) { + String string = new String(resource.getData()).replace("smlns=\"", "xmlns=\""); + resource.setData(string.getBytes()); + } + if (resource.getMediaType() == MediaTypes.XHTML) { + resource.setInputEncoding(defaultHtmlEncoding); + } + result.add(resource); + } while (zipEntry != null); + + return result; + } + + + private static ZipEntry getNextZipEntry(ZipInputStream zipInputStream) + throws IOException { + try { + return zipInputStream.getNextEntry(); + } catch (ZipException e) { + //see Issue #122 Infinite loop. + //when reading a file that is not a real zip archive or a zero length file, zipInputStream.getNextEntry() + //throws an exception and does not advance, so loadResources enters an infinite loop + //log.error("Invalid or damaged zip file.", e); + Log.e(TAG, e.getLocalizedMessage()); + try { + zipInputStream.closeEntry(); + } catch (Exception ignored) { + } + throw e; + } + } + + /** + * Loads all entries from the ZipInputStream as Resources. + *

    + * Loads the contents of all ZipEntries into memory. + * Is fast, but may lead to memory problems when reading large books + * on devices with small amounts of memory. + * + * @param zipFile zipFile + * @param defaultHtmlEncoding defaultHtmlEncoding + * @return Resources + * @throws IOException IOException + */ + public static Resources loadResources(ZipFile zipFile, String defaultHtmlEncoding) throws IOException { + List ls = new ArrayList<>(); + return loadResources(zipFile, defaultHtmlEncoding, ls); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/CollectionUtil.java b/epublib/src/main/java/me/ag2s/epublib/util/CollectionUtil.java new file mode 100644 index 000000000..e367fdc66 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/CollectionUtil.java @@ -0,0 +1,71 @@ +package me.ag2s.epublib.util; + +import java.util.Collection; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; + +public class CollectionUtil { + + /** + * Wraps an Enumeration around an Iterator + * @author paul.siegmann + * + * @param + */ + private static class IteratorEnumerationAdapter implements Enumeration { + + private final Iterator iterator; + + public IteratorEnumerationAdapter(Iterator iter) { + this.iterator = iter; + } + + @Override + public boolean hasMoreElements() { + return iterator.hasNext(); + } + + @Override + public T nextElement() { + return iterator.next(); + } + } + + /** + * Creates an Enumeration out of the given Iterator. + * @param g + * @param it g + * @return an Enumeration created out of the given Iterator. + */ + @SuppressWarnings("unused") + public static Enumeration createEnumerationFromIterator( + Iterator it) { + return new IteratorEnumerationAdapter<>(it); + } + + + /** + * Returns the first element of the list, null if the list is null or empty. + * + * @param f + * @param list f + * @return the first element of the list, null if the list is null or empty. + */ + public static T first(List list) { + if (list == null || list.isEmpty()) { + return null; + } + return list.get(0); + } + + /** + * Whether the given collection is null or has no elements. + * + * @param collection g + * @return Whether the given collection is null or has no elements. + */ + public static boolean isEmpty(Collection collection) { + return collection == null || collection.isEmpty(); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/IOUtil.java b/epublib/src/main/java/me/ag2s/epublib/util/IOUtil.java new file mode 100644 index 000000000..dcbda6830 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/IOUtil.java @@ -0,0 +1,962 @@ +package me.ag2s.epublib.util; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.StringWriter; +import java.io.Writer; +import java.net.HttpURLConnection; +import java.net.URLConnection; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.ReadableByteChannel; +import java.nio.charset.Charset; + +import me.ag2s.epublib.epub.PackageDocumentReader; +import me.ag2s.epublib.util.commons.io.IOConsumer; + +/** + * Most of the functions herein are re-implementations of the ones in + * apache io IOUtils. + *

    + * The reason for re-implementing this is that the functions are fairly simple + * and using my own implementation saves the inclusion of a 200Kb jar file. + */ +public class IOUtil { + private static final String TAG = IOUtil.class.getName(); + + /** + * Represents the end-of-file (or stream). + * + * @since 2.5 (made public) + */ + public static final int EOF = -1; + + + public static final int DEFAULT_BUFFER_SIZE = 1024 * 8; + private static final byte[] SKIP_BYTE_BUFFER = new byte[DEFAULT_BUFFER_SIZE]; + + // Allocated in the relevant skip method if necessary. + /* + * These buffers are static and are shared between threads. + * This is possible because the buffers are write-only - the contents are never read. + * + * N.B. there is no need to synchronize when creating these because: + * - we don't care if the buffer is created multiple times (the data is ignored) + * - we always use the same size buffer, so if it it is recreated it will still be OK + * (if the buffer size were variable, we would need to synch. to ensure some other thread + * did not create a smaller one) + */ + private static char[] SKIP_CHAR_BUFFER; + + /** + * Gets the contents of the Reader as a byte[], with the given character encoding. + * + * @param in g + * @param encoding g + * @return the contents of the Reader as a byte[], with the given character encoding. + * @throws IOException g + */ + public static byte[] toByteArray(Reader in, String encoding) + throws IOException { + StringWriter out = new StringWriter(); + copy(in, out); + out.flush(); + return out.toString().getBytes(encoding); + } + + /** + * Returns the contents of the InputStream as a byte[] + * + * @param in f + * @return the contents of the InputStream as a byte[] + * @throws IOException f + */ + public static byte[] toByteArray(InputStream in) throws IOException { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + copy(in, result); + result.flush(); + return result.toByteArray(); + } + + + /** + * Reads data from the InputStream, using the specified buffer size. + *

    + * This is meant for situations where memory is tight, since + * it prevents buffer expansion. + * + * @param in the stream to read data from + * @param size the size of the array to create + * @return the array, or null + * @throws IOException f + */ + public static byte[] toByteArray(InputStream in, int size) + throws IOException { + + try { + ByteArrayOutputStream result; + + if (size > 0) { + result = new ByteArrayOutputStream(size); + } else { + result = new ByteArrayOutputStream(); + } + + copy(in, result); + result.flush(); + return result.toByteArray(); + } catch (OutOfMemoryError error) { + //Return null so it gets loaded lazily. + return null; + } + + } + + + /** + * if totalNrRead < 0 then totalNrRead is returned, if + * (nrRead + totalNrRead) < Integer.MAX_VALUE then nrRead + totalNrRead + * is returned, -1 otherwise. + * + * @param nrRead f + * @param totalNrNread f + * @return if totalNrRead < 0 then totalNrRead is returned, if + * (nrRead + totalNrRead) < Integer.MAX_VALUE then nrRead + totalNrRead + * is returned, -1 otherwise. + */ + protected static int calcNewNrReadSize(int nrRead, int totalNrNread) { + if (totalNrNread < 0) { + return totalNrNread; + } + if (totalNrNread > (Integer.MAX_VALUE - nrRead)) { + return -1; + } else { + return (totalNrNread + nrRead); + } + } + + // + public static void copy(InputStream in, OutputStream result) throws IOException { + copy(in, result,DEFAULT_BUFFER_SIZE); + } + + /** + * Copies bytes from an InputStream to an OutputStream using an internal buffer of the + * given size. + *

    + * This method buffers the input internally, so there is no need to use a BufferedInputStream. + *

    + * + * @param input the InputStream to read from + * @param output the OutputStream to write to + * @param bufferSize the bufferSize used to copy from the input to the output + * @return the number of bytes copied. or {@code 0} if {@code input is null}. + * @throws NullPointerException if the output is null + * @throws IOException if an I/O error occurs + * @since 2.5 + */ + public static long copy(final InputStream input, final OutputStream output, final int bufferSize) + throws IOException { + return copyLarge(input, output, new byte[bufferSize]); + } + + /** + * Copies bytes from an InputStream to chars on a + * Writer using the default character encoding of the platform. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedInputStream. + *

    + * This method uses {@link InputStreamReader}. + * + * @param input the InputStream to read from + * @param output the Writer to write to + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 1.1 + * @deprecated 2.5 use {@link #copy(InputStream, Writer, Charset)} instead + */ + @Deprecated + public static void copy(final InputStream input, final Writer output) + throws IOException { + copy(input, output, Charset.defaultCharset()); + } + + /** + * Copies bytes from an InputStream to chars on a + * Writer using the specified character encoding. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedInputStream. + *

    + * This method uses {@link InputStreamReader}. + * + * @param input the InputStream to read from + * @param output the Writer to write to + * @param inputCharset the charset to use for the input stream, null means platform default + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.3 + */ + public static void copy(final InputStream input, final Writer output, final Charset inputCharset) + throws IOException { + final InputStreamReader in = new InputStreamReader(input, inputCharset.name()); + copy(in, output); + } + + /** + * Copies bytes from an InputStream to chars on a + * Writer using the specified character encoding. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedInputStream. + *

    + * Character encoding names can be found at + * IANA. + *

    + * This method uses {@link InputStreamReader}. + * + * @param input the InputStream to read from + * @param output the Writer to write to + * @param inputCharsetName the name of the requested charset for the InputStream, null means platform default + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io + * .UnsupportedEncodingException} in version 2.2 if the + * encoding is not supported. + * @since 1.1 + */ + public static void copy(final InputStream input, final Writer output, final String inputCharsetName) + throws IOException { + copy(input, output, Charset.forName(inputCharsetName)); + } + + /** + * Copies chars from a Reader to a Appendable. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + * Large streams (over 2GB) will return a chars copied value of + * -1 after the copy has completed since the correct + * number of chars cannot be returned as an int. For large streams + * use the copyLarge(Reader, Writer) method. + * + * @param input the Reader to read from + * @param output the Appendable to write to + * @return the number of characters copied, or -1 if > Integer.MAX_VALUE + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.7 + */ + public static long copy(final Reader input, final Appendable output) throws IOException { + return copy(input, output, CharBuffer.allocate(DEFAULT_BUFFER_SIZE)); + } + + /** + * Copies chars from a Reader to an Appendable. + *

    + * This method uses the provided buffer, so there is no need to use a + * BufferedReader. + *

    + * + * @param input the Reader to read from + * @param output the Appendable to write to + * @param buffer the buffer to be used for the copy + * @return the number of characters copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.7 + */ + public static long copy(final Reader input, final Appendable output, final CharBuffer buffer) throws IOException { + long count = 0; + int n; + while (EOF != (n = input.read(buffer))) { + buffer.flip(); + output.append(buffer, 0, n); + count += n; + } + return count; + } + + /** + * Copies chars from a Reader to bytes on an + * OutputStream using the default character encoding of the + * platform, and calling flush. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + * Due to the implementation of OutputStreamWriter, this method performs a + * flush. + *

    + * This method uses {@link OutputStreamWriter}. + * + * @param input the Reader to read from + * @param output the OutputStream to write to + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 1.1 + * @deprecated 2.5 use {@link #copy(Reader, OutputStream, Charset)} instead + */ + @Deprecated + public static void copy(final Reader input, final OutputStream output) + throws IOException { + copy(input, output, Charset.defaultCharset()); + } + + /** + * Copies chars from a Reader to bytes on an + * OutputStream using the specified character encoding, and + * calling flush. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + *

    + * Due to the implementation of OutputStreamWriter, this method performs a + * flush. + *

    + *

    + * This method uses {@link OutputStreamWriter}. + *

    + * + * @param input the Reader to read from + * @param output the OutputStream to write to + * @param outputCharset the charset to use for the OutputStream, null means platform default + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.3 + */ + public static void copy(final Reader input, final OutputStream output, final Charset outputCharset) + throws IOException { + final OutputStreamWriter out = new OutputStreamWriter(output, outputCharset.name()); + copy(input, out); + // XXX Unless anyone is planning on rewriting OutputStreamWriter, + // we have to flush here. + out.flush(); + } + + /** + * Copies chars from a Reader to bytes on an + * OutputStream using the specified character encoding, and + * calling flush. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + * Character encoding names can be found at + * IANA. + *

    + * Due to the implementation of OutputStreamWriter, this method performs a + * flush. + *

    + * This method uses {@link OutputStreamWriter}. + * + * @param input the Reader to read from + * @param output the OutputStream to write to + * @param outputCharsetName the name of the requested charset for the OutputStream, null means platform default + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link java.io + * .UnsupportedEncodingException} in version 2.2 if the + * encoding is not supported. + * @since 1.1 + */ + public static void copy(final Reader input, final OutputStream output, final String outputCharsetName) + throws IOException { + copy(input, output, Charset.forName(outputCharsetName)); + } + + /** + * Copies chars from a Reader to a Writer. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + * Large streams (over 2GB) will return a chars copied value of + * -1 after the copy has completed since the correct + * number of chars cannot be returned as an int. For large streams + * use the copyLarge(Reader, Writer) method. + * + * @param input the Reader to read from + * @param output the Writer to write to + * @return the number of characters copied, or -1 if > Integer.MAX_VALUE + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 1.1 + */ + public static int copy(final Reader input, final Writer output) throws IOException { + final long count = copyLarge(input, output); + if (count > Integer.MAX_VALUE) { + return -1; + } + return (int) count; + } + + /** + * Copies bytes from a large (over 2GB) InputStream to an + * OutputStream. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedInputStream. + *

    + *

    + * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}. + *

    + * + * @param input the InputStream to read from + * @param output the OutputStream to write to + * @return the number of bytes copied. or {@code 0} if {@code input is null}. + * @throws NullPointerException if the output is null + * @throws IOException if an I/O error occurs + * @since 1.3 + */ + public static long copyLarge(final InputStream input, final OutputStream output) + throws IOException { + return copy(input, output, DEFAULT_BUFFER_SIZE); + } + + /** + * Copies bytes from a large (over 2GB) InputStream to an + * OutputStream. + *

    + * This method uses the provided buffer, so there is no need to use a + * BufferedInputStream. + *

    + * + * @param input the InputStream to read from + * @param output the OutputStream to write to + * @param buffer the buffer to use for the copy + * @return the number of bytes copied. or {@code 0} if {@code input is null}. + * @throws IOException if an I/O error occurs + * @since 2.2 + */ + public static long copyLarge(final InputStream input, final OutputStream output, final byte[] buffer) + throws IOException { + long count = 0; + if (input != null) { + int n; + while (EOF != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + //input.close(); + } + return count; + } + + /** + * Copies some or all bytes from a large (over 2GB) InputStream to an + * OutputStream, optionally skipping input bytes. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedInputStream. + *

    + *

    + * Note that the implementation uses {@link #skip(InputStream, long)}. + * This means that the method may be considerably less efficient than using the actual skip implementation, + * this is done to guarantee that the correct number of characters are skipped. + *

    + * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}. + * + * @param input the InputStream to read from + * @param output the OutputStream to write to + * @param inputOffset : number of bytes to skip from input before copying + * -ve values are ignored + * @param length : number of bytes to copy. -ve means all + * @return the number of bytes copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.2 + */ + public static long copyLarge(final InputStream input, final OutputStream output, final long inputOffset, + final long length) throws IOException { + return copyLarge(input, output, inputOffset, length, new byte[DEFAULT_BUFFER_SIZE]); + } + + /** + * Copies some or all bytes from a large (over 2GB) InputStream to an + * OutputStream, optionally skipping input bytes. + *

    + * This method uses the provided buffer, so there is no need to use a + * BufferedInputStream. + *

    + *

    + * Note that the implementation uses {@link #skip(InputStream, long)}. + * This means that the method may be considerably less efficient than using the actual skip implementation, + * this is done to guarantee that the correct number of characters are skipped. + *

    + * + * @param input the InputStream to read from + * @param output the OutputStream to write to + * @param inputOffset : number of bytes to skip from input before copying + * -ve values are ignored + * @param length : number of bytes to copy. -ve means all + * @param buffer the buffer to use for the copy + * @return the number of bytes copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.2 + */ + public static long copyLarge(final InputStream input, final OutputStream output, + final long inputOffset, final long length, final byte[] buffer) throws IOException { + if (inputOffset > 0) { + skipFully(input, inputOffset); + } + if (length == 0) { + return 0; + } + final int bufferLength = buffer.length; + int bytesToRead = bufferLength; + if (length > 0 && length < bufferLength) { + bytesToRead = (int) length; + } + int read; + long totalRead = 0; + while (bytesToRead > 0 && EOF != (read = input.read(buffer, 0, bytesToRead))) { + output.write(buffer, 0, read); + totalRead += read; + if (length > 0) { // only adjust length if not reading to the end + // Note the cast must work because buffer.length is an integer + bytesToRead = (int) Math.min(length - totalRead, bufferLength); + } + } + return totalRead; + } + + /** + * Copies chars from a large (over 2GB) Reader to a Writer. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}. + * + * @param input the Reader to read from + * @param output the Writer to write to + * @return the number of characters copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 1.3 + */ + public static long copyLarge(final Reader input, final Writer output) throws IOException { + return copyLarge(input, output, new char[DEFAULT_BUFFER_SIZE]); + } + + /** + * Copies chars from a large (over 2GB) Reader to a Writer. + *

    + * This method uses the provided buffer, so there is no need to use a + * BufferedReader. + *

    + * + * @param input the Reader to read from + * @param output the Writer to write to + * @param buffer the buffer to be used for the copy + * @return the number of characters copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.2 + */ + public static long copyLarge(final Reader input, final Writer output, final char[] buffer) throws IOException { + long count = 0; + int n; + while (EOF != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + return count; + } + + /** + * Copies some or all chars from a large (over 2GB) InputStream to an + * OutputStream, optionally skipping input chars. + *

    + * This method buffers the input internally, so there is no need to use a + * BufferedReader. + *

    + * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}. + * + * @param input the Reader to read from + * @param output the Writer to write to + * @param inputOffset : number of chars to skip from input before copying + * -ve values are ignored + * @param length : number of chars to copy. -ve means all + * @return the number of chars copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.2 + */ + public static long copyLarge(final Reader input, final Writer output, final long inputOffset, final long length) + throws IOException { + return copyLarge(input, output, inputOffset, length, new char[DEFAULT_BUFFER_SIZE]); + } + + /** + * Copies some or all chars from a large (over 2GB) InputStream to an + * OutputStream, optionally skipping input chars. + *

    + * This method uses the provided buffer, so there is no need to use a + * BufferedReader. + *

    + * + * @param input the Reader to read from + * @param output the Writer to write to + * @param inputOffset : number of chars to skip from input before copying + * -ve values are ignored + * @param length : number of chars to copy. -ve means all + * @param buffer the buffer to be used for the copy + * @return the number of chars copied + * @throws NullPointerException if the input or output is null + * @throws IOException if an I/O error occurs + * @since 2.2 + */ + public static long copyLarge(final Reader input, final Writer output, final long inputOffset, final long length, + final char[] buffer) + throws IOException { + if (inputOffset > 0) { + skipFully(input, inputOffset); + } + if (length == 0) { + return 0; + } + int bytesToRead = buffer.length; + if (length > 0 && length < buffer.length) { + bytesToRead = (int) length; + } + int read; + long totalRead = 0; + while (bytesToRead > 0 && EOF != (read = input.read(buffer, 0, bytesToRead))) { + output.write(buffer, 0, read); + totalRead += read; + if (length > 0) { // only adjust length if not reading to the end + // Note the cast must work because buffer.length is an integer + bytesToRead = (int) Math.min(length - totalRead, buffer.length); + } + } + return totalRead; + } + + /** + * Skips bytes from an input byte stream. + * This implementation guarantees that it will read as many bytes + * as possible before giving up; this may not always be the case for + * skip() implementations in subclasses of {@link InputStream}. + *

    + * Note that the implementation uses {@link InputStream#read(byte[], int, int)} rather + * than delegating to {@link InputStream#skip(long)}. + * This means that the method may be considerably less efficient than using the actual skip implementation, + * this is done to guarantee that the correct number of bytes are skipped. + *

    + * + * @param input byte stream to skip + * @param toSkip number of bytes to skip. + * @return number of bytes actually skipped. + * @throws IOException if there is a problem reading the file + * @throws IllegalArgumentException if toSkip is negative + * @see InputStream#skip(long) + * @see IO-203 - Add skipFully() method for InputStreams + * @since 2.0 + */ + public static long skip(final InputStream input, final long toSkip) throws IOException { + if (toSkip < 0) { + throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip); + } + /* + * N.B. no need to synchronize access to SKIP_BYTE_BUFFER: - we don't care if the buffer is created multiple + * times (the data is ignored) - we always use the same size buffer, so if it it is recreated it will still be + * OK (if the buffer size were variable, we would need to synch. to ensure some other thread did not create a + * smaller one) + */ + long remain = toSkip; + while (remain > 0) { + // See https://issues.apache.org/jira/browse/IO-203 for why we use read() rather than delegating to skip() + final long n = input.read(SKIP_BYTE_BUFFER, 0, (int) Math.min(remain, SKIP_BYTE_BUFFER.length)); + if (n < 0) { // EOF + break; + } + remain -= n; + } + return toSkip - remain; + } + + /** + * Skips bytes from a ReadableByteChannel. + * This implementation guarantees that it will read as many bytes + * as possible before giving up. + * + * @param input ReadableByteChannel to skip + * @param toSkip number of bytes to skip. + * @return number of bytes actually skipped. + * @throws IOException if there is a problem reading the ReadableByteChannel + * @throws IllegalArgumentException if toSkip is negative + * @since 2.5 + */ + public static long skip(final ReadableByteChannel input, final long toSkip) throws IOException { + if (toSkip < 0) { + throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip); + } + final ByteBuffer skipByteBuffer = ByteBuffer.allocate((int) Math.min(toSkip, SKIP_BYTE_BUFFER.length)); + long remain = toSkip; + while (remain > 0) { + skipByteBuffer.position(0); + skipByteBuffer.limit((int) Math.min(remain, SKIP_BYTE_BUFFER.length)); + final int n = input.read(skipByteBuffer); + if (n == EOF) { + break; + } + remain -= n; + } + return toSkip - remain; + } + + /** + * Skips characters from an input character stream. + * This implementation guarantees that it will read as many characters + * as possible before giving up; this may not always be the case for + * skip() implementations in subclasses of {@link Reader}. + *

    + * Note that the implementation uses {@link Reader#read(char[], int, int)} rather + * than delegating to {@link Reader#skip(long)}. + * This means that the method may be considerably less efficient than using the actual skip implementation, + * this is done to guarantee that the correct number of characters are skipped. + *

    + * + * @param input character stream to skip + * @param toSkip number of characters to skip. + * @return number of characters actually skipped. + * @throws IOException if there is a problem reading the file + * @throws IllegalArgumentException if toSkip is negative + * @see Reader#skip(long) + * @see IO-203 - Add skipFully() method for InputStreams + * @since 2.0 + */ + public static long skip(final Reader input, final long toSkip) throws IOException { + if (toSkip < 0) { + throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip); + } + /* + * N.B. no need to synchronize this because: - we don't care if the buffer is created multiple times (the data + * is ignored) - we always use the same size buffer, so if it it is recreated it will still be OK (if the buffer + * size were variable, we would need to synch. to ensure some other thread did not create a smaller one) + */ + if (SKIP_CHAR_BUFFER == null) { + SKIP_CHAR_BUFFER = new char[SKIP_BYTE_BUFFER.length]; + } + long remain = toSkip; + while (remain > 0) { + // See https://issues.apache.org/jira/browse/IO-203 for why we use read() rather than delegating to skip() + final long n = input.read(SKIP_CHAR_BUFFER, 0, (int) Math.min(remain, SKIP_BYTE_BUFFER.length)); + if (n < 0) { // EOF + break; + } + remain -= n; + } + return toSkip - remain; + } + + /** + * Skips the requested number of bytes or fail if there are not enough left. + *

    + * This allows for the possibility that {@link InputStream#skip(long)} may + * not skip as many bytes as requested (most likely because of reaching EOF). + *

    + * Note that the implementation uses {@link #skip(InputStream, long)}. + * This means that the method may be considerably less efficient than using the actual skip implementation, + * this is done to guarantee that the correct number of characters are skipped. + *

    + * + * @param input stream to skip + * @param toSkip the number of bytes to skip + * @throws IOException if there is a problem reading the file + * @throws IllegalArgumentException if toSkip is negative + * @throws EOFException if the number of bytes skipped was incorrect + * @see InputStream#skip(long) + * @since 2.0 + */ + public static void skipFully(final InputStream input, final long toSkip) throws IOException { + if (toSkip < 0) { + throw new IllegalArgumentException("Bytes to skip must not be negative: " + toSkip); + } + final long skipped = skip(input, toSkip); + if (skipped != toSkip) { + throw new EOFException("Bytes to skip: " + toSkip + " actual: " + skipped); + } + } + + /** + * Skips the requested number of bytes or fail if there are not enough left. + * + * @param input ReadableByteChannel to skip + * @param toSkip the number of bytes to skip + * @throws IOException if there is a problem reading the ReadableByteChannel + * @throws IllegalArgumentException if toSkip is negative + * @throws EOFException if the number of bytes skipped was incorrect + * @since 2.5 + */ + public static void skipFully(final ReadableByteChannel input, final long toSkip) throws IOException { + if (toSkip < 0) { + throw new IllegalArgumentException("Bytes to skip must not be negative: " + toSkip); + } + final long skipped = skip(input, toSkip); + if (skipped != toSkip) { + throw new EOFException("Bytes to skip: " + toSkip + " actual: " + skipped); + } + } + + /** + * Skips the requested number of characters or fail if there are not enough left. + *

    + * This allows for the possibility that {@link Reader#skip(long)} may + * not skip as many characters as requested (most likely because of reaching EOF). + *

    + * Note that the implementation uses {@link #skip(Reader, long)}. + * This means that the method may be considerably less efficient than using the actual skip implementation, + * this is done to guarantee that the correct number of characters are skipped. + *

    + * + * @param input stream to skip + * @param toSkip the number of characters to skip + * @throws IOException if there is a problem reading the file + * @throws IllegalArgumentException if toSkip is negative + * @throws EOFException if the number of characters skipped was incorrect + * @see Reader#skip(long) + * @since 2.0 + */ + public static void skipFully(final Reader input, final long toSkip) throws IOException { + final long skipped = skip(input, toSkip); + if (skipped != toSkip) { + throw new EOFException("Chars to skip: " + toSkip + " actual: " + skipped); + } + } + + /** + * Returns the length of the given array in a null-safe manner. + * + * @param array an array or null + * @return the array length -- or 0 if the given array is null. + * @since 2.7 + */ + public static int length(final byte[] array) { + return array == null ? 0 : array.length; + } + + /** + * Returns the length of the given array in a null-safe manner. + * + * @param array an array or null + * @return the array length -- or 0 if the given array is null. + * @since 2.7 + */ + public static int length(final char[] array) { + return array == null ? 0 : array.length; + } + + /** + * Returns the length of the given CharSequence in a null-safe manner. + * + * @param csq a CharSequence or null + * @return the CharSequence length -- or 0 if the given CharSequence is null. + * @since 2.7 + */ + public static int length(final CharSequence csq) { + return csq == null ? 0 : csq.length(); + } + + /** + * Returns the length of the given array in a null-safe manner. + * + * @param array an array or null + * @return the array length -- or 0 if the given array is null. + * @since 2.7 + */ + public static int length(final Object[] array) { + return array == null ? 0 : array.length; + } + + /** + * Closes the given {@link Closeable} as a null-safe operation. + * + * @param closeable The resource to close, may be null. + * @throws IOException if an I/O error occurs. + * @since 2.7 + */ + public static void close(final Closeable closeable) throws IOException { + if (closeable != null) { + closeable.close(); + } + } + + /** + * Closes the given {@link Closeable} as a null-safe operation. + * + * @param closeables The resource(s) to close, may be null. + * @throws IOException if an I/O error occurs. + * @since 2.8.0 + */ + public static void close(final Closeable... closeables) throws IOException { + if (closeables != null) { + for (final Closeable closeable : closeables) { + close(closeable); + } + } + } + + /** + * Closes the given {@link Closeable} as a null-safe operation. + * + * @param closeable The resource to close, may be null. + * @param consumer Consume the IOException thrown by {@link Closeable#close()}. + * @throws IOException if an I/O error occurs. + * @since 2.7 + */ + public static void close(final Closeable closeable, final IOConsumer consumer) throws IOException { + if (closeable != null) { + try { + closeable.close(); + } catch (final IOException e) { + if (consumer != null) { + consumer.accept(e); + } + } + } + } + + /** + * Closes a URLConnection. + * + * @param conn the connection to close. + * @since 2.4 + */ + public static void close(final URLConnection conn) { + if (conn instanceof HttpURLConnection) { + ((HttpURLConnection) conn).disconnect(); + } + } + + @SuppressWarnings("unused") + public static String Stream2String(InputStream inputStream) { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + try { + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int length; + while ((length = inputStream.read(buffer)) != -1) { + result.write(buffer, 0, length); + } + return result.toString(); + } catch (Exception e) { + return e.getLocalizedMessage(); + } + + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/NoCloseOutputStream.java b/epublib/src/main/java/me/ag2s/epublib/util/NoCloseOutputStream.java new file mode 100644 index 000000000..15a3893b6 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/NoCloseOutputStream.java @@ -0,0 +1,33 @@ +package me.ag2s.epublib.util; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * OutputStream with the close() disabled. + * We write multiple documents to a ZipOutputStream. + * Some of the formatters call a close() after writing their data. + * We don't want them to do that, so we wrap regular OutputStreams in this NoCloseOutputStream. + * + * @author paul + */ +@SuppressWarnings("unused") +public class NoCloseOutputStream extends OutputStream { + + private final OutputStream outputStream; + + public NoCloseOutputStream(OutputStream outputStream) { + this.outputStream = outputStream; + } + + @Override + public void write(int b) throws IOException { + outputStream.write(b); + } + + /** + * A close() that does not call it's parent's close() + */ + public void close() { + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/NoCloseWriter.java b/epublib/src/main/java/me/ag2s/epublib/util/NoCloseWriter.java new file mode 100644 index 000000000..ad1da9c7c --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/NoCloseWriter.java @@ -0,0 +1,36 @@ +package me.ag2s.epublib.util; + +import java.io.IOException; +import java.io.Writer; + +/** + * Writer with the close() disabled. + * We write multiple documents to a ZipOutputStream. + * Some of the formatters call a close() after writing their data. + * We don't want them to do that, so we wrap regular Writers in this NoCloseWriter. + * + * @author paul + */ +@SuppressWarnings("unused") +public class NoCloseWriter extends Writer { + + private final Writer writer; + + public NoCloseWriter(Writer writer) { + this.writer = writer; + } + + @Override + public void close() { + } + + @Override + public void flush() throws IOException { + writer.flush(); + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + writer.write(cbuf, off, len); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/ResourceUtil.java b/epublib/src/main/java/me/ag2s/epublib/util/ResourceUtil.java new file mode 100644 index 000000000..ad8ecab91 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/ResourceUtil.java @@ -0,0 +1,175 @@ +package me.ag2s.epublib.util; + +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.xml.parsers.DocumentBuilder; + +import me.ag2s.epublib.Constants; +import me.ag2s.epublib.domain.MediaType; +import me.ag2s.epublib.domain.MediaTypes; +import me.ag2s.epublib.domain.Resource; +import me.ag2s.epublib.epub.EpubProcessorSupport; + +/** + * Various resource utility methods + * + * @author paul + */ +public class ResourceUtil { + /** + * 快速创建HTML类型的Resource + * + * @param title 章节的标题 + * @param txt 章节的正文 + * @param model html模板 + * @return 返回Resource + */ + public static Resource createChapterResource(String title, String txt, String model, String href) { + if (title.contains("\n")) { + title = "" + title.replaceFirst("\\s*\\n\\s*", "
    "); + } else { + title = title.replaceFirst("\\s+", "
    "); + if (title.contains("")) + title = "" + title; + } + String html = model.replace("{title}", title) + .replace("{content}", StringUtil.formatHtml(txt)); + return new Resource(html.getBytes(), href); + } + + public static Resource createPublicResource(String name, String author, String intro, String kind, String wordCount, String model, String href) { + String html = model.replace("{name}", name) + .replace("{author}", author) + .replace("{kind}", kind == null ? "" : kind) + .replace("{wordCount}", wordCount == null ? "" : wordCount) + .replace("{intro}", StringUtil.formatHtml(intro == null ? "" : intro)); + return new Resource(html.getBytes(), href); + } + + /** + * 快速从File创建Resource + * + * @param file File + * @return Resource + * @throws IOException IOException + */ + + @SuppressWarnings("unused") + public static Resource createResource(File file) throws IOException { + if (file == null) { + return null; + } + MediaType mediaType = MediaTypes.determineMediaType(file.getName()); + byte[] data = IOUtil.toByteArray(new FileInputStream(file)); + return new Resource(data, mediaType); + } + + + /** + * 创建一个只带标题的HTMl类型的Resource,常用于封面页,大卷页 + * + * @param title v + * @param href v + * @return a resource with as contents a html page with the given title. + */ + @SuppressWarnings("unused") + public static Resource createResource(String title, String href) { + String content = + "" + title + "

    " + title + + "

    "; + return new Resource(null, content.getBytes(), href, MediaTypes.XHTML, + Constants.CHARACTER_ENCODING); + } + + /** + * Creates a resource out of the given zipEntry and zipInputStream. + * + * @param zipEntry v + * @param zipInputStream v + * @return a resource created out of the given zipEntry and zipInputStream. + * @throws IOException v + */ + public static Resource createResource(ZipEntry zipEntry, + ZipInputStream zipInputStream) throws IOException { + return new Resource(zipInputStream, zipEntry.getName()); + + } + + public static Resource createResource(ZipEntry zipEntry, + InputStream zipInputStream) throws IOException { + return new Resource(zipInputStream, zipEntry.getName()); + + } + + /** + * Converts a given string from given input character encoding to the requested output character encoding. + * + * @param inputEncoding v + * @param outputEncoding v + * @param input v + * @return the string from given input character encoding converted to the requested output character encoding. + * @throws UnsupportedEncodingException v + */ + @SuppressWarnings("unused") + public static byte[] recode(String inputEncoding, String outputEncoding, + byte[] input) throws UnsupportedEncodingException { + return new String(input, inputEncoding).getBytes(outputEncoding); + } + + /** + * Gets the contents of the Resource as an InputSource in a null-safe manner. + */ + @SuppressWarnings("unused") + public static InputSource getInputSource(Resource resource) + throws IOException { + if (resource == null) { + return null; + } + Reader reader = resource.getReader(); + if (reader == null) { + return null; + } + return new InputSource(reader); + } + + + /** + * Reads parses the xml therein and returns the result as a Document + */ + public static Document getAsDocument(Resource resource) + throws SAXException, IOException { + return getAsDocument(resource, + EpubProcessorSupport.createDocumentBuilder()); + } + + /** + * Reads the given resources inputstream, parses the xml therein and returns the result as a Document + * + * @param resource v + * @param documentBuilder v + * @return the document created from the given resource + * @throws UnsupportedEncodingException v + * @throws SAXException v + * @throws IOException v + */ + public static Document getAsDocument(Resource resource, + DocumentBuilder documentBuilder) + throws UnsupportedEncodingException, SAXException, IOException { + InputSource inputSource = getInputSource(resource); + if (inputSource == null) { + return null; + } + return documentBuilder.parse(inputSource); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/StringUtil.java b/epublib/src/main/java/me/ag2s/epublib/util/StringUtil.java new file mode 100644 index 000000000..e9fff3136 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/StringUtil.java @@ -0,0 +1,291 @@ +package me.ag2s.epublib.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Various String utility functions. + *

    + * Most of the functions herein are re-implementations of the ones in apache + * commons StringUtils. The reason for re-implementing this is that the + * functions are fairly simple and using my own implementation saves the + * inclusion of a 200Kb jar file. + * + * @author paul.siegmann + */ +public class StringUtil { + + /** + * Changes a path containing '..', '.' and empty dirs into a path that + * doesn't. X/foo/../Y is changed into 'X/Y', etc. Does not handle invalid + * paths like "../". + * + * @param path path + * @return the normalized path + */ + public static String collapsePathDots(String path) { + String[] stringParts = path.split("/"); + List parts = new ArrayList<>(Arrays.asList(stringParts)); + for (int i = 0; i < parts.size() - 1; i++) { + String currentDir = parts.get(i); + if (currentDir.length() == 0 || currentDir.equals(".")) { + parts.remove(i); + i--; + } else if (currentDir.equals("..")) { + parts.remove(i - 1); + parts.remove(i - 1); + i -= 2; + } + } + StringBuilder result = new StringBuilder(); + if (path.startsWith("/")) { + result.append('/'); + } + for (int i = 0; i < parts.size(); i++) { + result.append(parts.get(i)); + if (i < (parts.size() - 1)) { + result.append('/'); + } + } + return result.toString(); + } + + /** + * Whether the String is not null, not zero-length and does not contain of + * only whitespace. + * + * @param text text + * @return Whether the String is not null, not zero-length and does not contain of + */ + public static boolean isNotBlank(String text) { + return !isBlank(text); + } + + /** + * Whether the String is null, zero-length and does contain only whitespace. + * + * @return Whether the String is null, zero-length and does contain only whitespace. + */ + public static boolean isBlank(String text) { + if (isEmpty(text)) { + return true; + } + for (int i = 0; i < text.length(); i++) { + if (!Character.isWhitespace(text.charAt(i))) { + return false; + } + } + return true; + } + + /** + * Whether the given string is null or zero-length. + * + * @param text the input for this method + * @return Whether the given string is null or zero-length. + */ + public static boolean isEmpty(String text) { + return (text == null) || (text.length() == 0); + } + + /** + * Whether the given source string ends with the given suffix, ignoring + * case. + * + * @param source source + * @param suffix suffix + * @return Whether the given source string ends with the given suffix, ignoring case. + */ + public static boolean endsWithIgnoreCase(String source, String suffix) { + if (isEmpty(suffix)) { + return true; + } + if (isEmpty(source)) { + return false; + } + if (suffix.length() > source.length()) { + return false; + } + return source.substring(source.length() - suffix.length()) + .toLowerCase().endsWith(suffix.toLowerCase()); + } + + /** + * If the given text is null return "", the original text otherwise. + * + * @param text text + * @return If the given text is null "", the original text otherwise. + */ + public static String defaultIfNull(String text) { + return defaultIfNull(text, ""); + } + + /** + * If the given text is null return "", the given defaultValue otherwise. + * + * @param text d + * @param defaultValue d + * @return If the given text is null "", the given defaultValue otherwise. + */ + public static String defaultIfNull(String text, String defaultValue) { + if (text == null) { + return defaultValue; + } + return text; + } + + /** + * Null-safe string comparator + * + * @param text1 d + * @param text2 d + * @return whether the two strings are equal + */ + public static boolean equals(String text1, String text2) { + if (text1 == null) { + return (text2 == null); + } + return text1.equals(text2); + } + + /** + * Pretty toString printer. + * + * @param keyValues d + * @return a string representation of the input values + */ + public static String toString(Object... keyValues) { + StringBuilder result = new StringBuilder(); + result.append('['); + for (int i = 0; i < keyValues.length; i += 2) { + if (i > 0) { + result.append(", "); + } + result.append(keyValues[i]); + result.append(": "); + Object value = null; + if ((i + 1) < keyValues.length) { + value = keyValues[i + 1]; + } + if (value == null) { + result.append(""); + } else { + result.append('\''); + result.append(value); + result.append('\''); + } + } + result.append(']'); + return result.toString(); + } + + public static int hashCode(String... values) { + int result = 31; + for (String value : values) { + result ^= String.valueOf(value).hashCode(); + } + return result; + } + + /** + * Gives the substring of the given text before the given separator. + *

    + * If the text does not contain the given separator then the given text is + * returned. + * + * @param text d + * @param separator d + * @return the substring of the given text before the given separator. + */ + public static String substringBefore(String text, char separator) { + if (isEmpty(text)) { + return text; + } + int sepPos = text.indexOf(separator); + if (sepPos < 0) { + return text; + } + return text.substring(0, sepPos); + } + + /** + * Gives the substring of the given text before the last occurrence of the + * given separator. + *

    + * If the text does not contain the given separator then the given text is + * returned. + * + * @param text d + * @param separator d + * @return the substring of the given text before the last occurrence of the given separator. + */ + public static String substringBeforeLast(String text, char separator) { + if (isEmpty(text)) { + return text; + } + int cPos = text.lastIndexOf(separator); + if (cPos < 0) { + return text; + } + return text.substring(0, cPos); + } + + /** + * Gives the substring of the given text after the last occurrence of the + * given separator. + *

    + * If the text does not contain the given separator then "" is returned. + * + * @param text d + * @param separator d + * @return the substring of the given text after the last occurrence of the given separator. + */ + public static String substringAfterLast(String text, char separator) { + if (isEmpty(text)) { + return text; + } + int cPos = text.lastIndexOf(separator); + if (cPos < 0) { + return ""; + } + return text.substring(cPos + 1); + } + + /** + * Gives the substring of the given text after the given separator. + *

    + * If the text does not contain the given separator then "" is returned. + * + * @param text the input text + * @param c the separator char + * @return the substring of the given text after the given separator. + */ + public static String substringAfter(String text, char c) { + if (isEmpty(text)) { + return text; + } + int cPos = text.indexOf(c); + if (cPos < 0) { + return ""; + } + return text.substring(cPos + 1); + } + + public static String formatHtml(String text) { + StringBuilder body = new StringBuilder(); + for (String s : text.split("\\r?\\n")) { + s = s.replaceAll("^\\s+|\\s+$", ""); + if (s.length() > 0) { + //段落为一张图片才认定为图片章节/漫画并启用多看单图优化,否则认定为普通文字夹杂着的图片文字。 + if (s.matches("(?i)^]+)/?>$")) { + body.append(s.replaceAll("(?i)^]+)/?>$", + "

    ")); + } else { + body.append("

    ").append(s).append("

    "); + } + } + } + return body.toString(); + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/commons/io/BOMInputStream.java b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/BOMInputStream.java new file mode 100644 index 000000000..ed299f591 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/BOMInputStream.java @@ -0,0 +1,412 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.ag2s.epublib.util.commons.io; + + + +import android.os.Build; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import me.ag2s.epublib.util.IOUtil; + +import static me.ag2s.epublib.util.IOUtil.EOF; + + +/** + * This class is used to wrap a stream that includes an encoded {@link ByteOrderMark} as its first bytes. + * + * This class detects these bytes and, if required, can automatically skip them and return the subsequent byte as the + * first byte in the stream. + * + * The {@link ByteOrderMark} implementation has the following pre-defined BOMs: + *
      + *
    • UTF-8 - {@link ByteOrderMark#UTF_8}
    • + *
    • UTF-16BE - {@link ByteOrderMark#UTF_16LE}
    • + *
    • UTF-16LE - {@link ByteOrderMark#UTF_16BE}
    • + *
    • UTF-32BE - {@link ByteOrderMark#UTF_32LE}
    • + *
    • UTF-32LE - {@link ByteOrderMark#UTF_32BE}
    • + *
    + * + * + *

    Example 1 - Detect and exclude a UTF-8 BOM

    + * + *
    + * BOMInputStream bomIn = new BOMInputStream(in);
    + * if (bomIn.hasBOM()) {
    + *     // has a UTF-8 BOM
    + * }
    + * 
    + * + *

    Example 2 - Detect a UTF-8 BOM (but don't exclude it)

    + * + *
    + * boolean include = true;
    + * BOMInputStream bomIn = new BOMInputStream(in, include);
    + * if (bomIn.hasBOM()) {
    + *     // has a UTF-8 BOM
    + * }
    + * 
    + * + *

    Example 3 - Detect Multiple BOMs

    + * + *
    + * BOMInputStream bomIn = new BOMInputStream(in,
    + *   ByteOrderMark.UTF_16LE, ByteOrderMark.UTF_16BE,
    + *   ByteOrderMark.UTF_32LE, ByteOrderMark.UTF_32BE
    + *   );
    + * if (bomIn.hasBOM() == false) {
    + *     // No BOM found
    + * } else if (bomIn.hasBOM(ByteOrderMark.UTF_16LE)) {
    + *     // has a UTF-16LE BOM
    + * } else if (bomIn.hasBOM(ByteOrderMark.UTF_16BE)) {
    + *     // has a UTF-16BE BOM
    + * } else if (bomIn.hasBOM(ByteOrderMark.UTF_32LE)) {
    + *     // has a UTF-32LE BOM
    + * } else if (bomIn.hasBOM(ByteOrderMark.UTF_32BE)) {
    + *     // has a UTF-32BE BOM
    + * }
    + * 
    + * + * @see ByteOrderMark + * @see Wikipedia - Byte Order Mark + * @since 2.0 + */ +public class BOMInputStream extends ProxyInputStream { + private final boolean include; + /** + * BOMs are sorted from longest to shortest. + */ + private final List boms; + private ByteOrderMark byteOrderMark; + private int[] firstBytes; + private int fbLength; + private int fbIndex; + private int markFbIndex; + private boolean markedAtStart; + + /** + * Constructs a new BOM InputStream that excludes a {@link ByteOrderMark#UTF_8} BOM. + * + * @param delegate + * the InputStream to delegate to + */ + @SuppressWarnings("unused") + public BOMInputStream(final InputStream delegate) { + this(delegate, false, ByteOrderMark.UTF_8); + } + + /** + * Constructs a new BOM InputStream that detects a a {@link ByteOrderMark#UTF_8} and optionally includes it. + * + * @param delegate + * the InputStream to delegate to + * @param include + * true to include the UTF-8 BOM or false to exclude it + */ + @SuppressWarnings("unused") + public BOMInputStream(final InputStream delegate, final boolean include) { + this(delegate, include, ByteOrderMark.UTF_8); + } + + /** + * Constructs a new BOM InputStream that excludes the specified BOMs. + * + * @param delegate + * the InputStream to delegate to + * @param boms + * The BOMs to detect and exclude + */ + @SuppressWarnings("unused") + public BOMInputStream(final InputStream delegate, final ByteOrderMark... boms) { + this(delegate, false, boms); + } + + /** + * Compares ByteOrderMark objects in descending length order. + */ + private static final Comparator ByteOrderMarkLengthComparator = (bom1, bom2) -> { + final int len1 = bom1.length(); + final int len2 = bom2.length(); + return Integer.compare(len2, len1); + }; + + /** + * Constructs a new BOM InputStream that detects the specified BOMs and optionally includes them. + * + * @param delegate + * the InputStream to delegate to + * @param include + * true to include the specified BOMs or false to exclude them + * @param boms + * The BOMs to detect and optionally exclude + */ + public BOMInputStream(final InputStream delegate, final boolean include, final ByteOrderMark... boms) { + super(delegate); + if (IOUtil.length(boms) == 0) { + throw new IllegalArgumentException("No BOMs specified"); + } + this.include = include; + final List list = Arrays.asList(boms); + // Sort the BOMs to match the longest BOM first because some BOMs have the same starting two bytes. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + list.sort(ByteOrderMarkLengthComparator); + } + this.boms = list; + + } + + /** + * Indicates whether the stream contains one of the specified BOMs. + * + * @return true if the stream has one of the specified BOMs, otherwise false if it does not + * @throws IOException + * if an error reading the first bytes of the stream occurs + */ + @SuppressWarnings("unused") + public boolean hasBOM() throws IOException { + return getBOM() != null; + } + + /** + * Indicates whether the stream contains the specified BOM. + * + * @param bom + * The BOM to check for + * @return true if the stream has the specified BOM, otherwise false if it does not + * @throws IllegalArgumentException + * if the BOM is not one the stream is configured to detect + * @throws IOException + * if an error reading the first bytes of the stream occurs + */ + @SuppressWarnings("unused") + public boolean hasBOM(final ByteOrderMark bom) throws IOException { + if (!boms.contains(bom)) { + throw new IllegalArgumentException("Stream not configure to detect " + bom); + } + getBOM(); + return byteOrderMark != null && byteOrderMark.equals(bom); + } + + /** + * Return the BOM (Byte Order Mark). + * + * @return The BOM or null if none + * @throws IOException + * if an error reading the first bytes of the stream occurs + */ + public ByteOrderMark getBOM() throws IOException { + if (firstBytes == null) { + fbLength = 0; + // BOMs are sorted from longest to shortest + final int maxBomSize = boms.get(0).length(); + firstBytes = new int[maxBomSize]; + // Read first maxBomSize bytes + for (int i = 0; i < firstBytes.length; i++) { + firstBytes[i] = in.read(); + fbLength++; + if (firstBytes[i] < 0) { + break; + } + } + // match BOM in firstBytes + byteOrderMark = find(); + if (byteOrderMark != null) { + if (!include) { + if (byteOrderMark.length() < firstBytes.length) { + fbIndex = byteOrderMark.length(); + } else { + fbLength = 0; + } + } + } + } + return byteOrderMark; + } + + /** + * Return the BOM charset Name - {@link ByteOrderMark#getCharsetName()}. + * + * @return The BOM charset Name or null if no BOM found + * @throws IOException + * if an error reading the first bytes of the stream occurs + * + */ + public String getBOMCharsetName() throws IOException { + getBOM(); + return byteOrderMark == null ? null : byteOrderMark.getCharsetName(); + } + + /** + * This method reads and either preserves or skips the first bytes in the stream. It behaves like the single-byte + * read() method, either returning a valid byte or -1 to indicate that the initial bytes have been + * processed already. + * + * @return the byte read (excluding BOM) or -1 if the end of stream + * @throws IOException + * if an I/O error occurs + */ + private int readFirstBytes() throws IOException { + getBOM(); + return fbIndex < fbLength ? firstBytes[fbIndex++] : EOF; + } + + /** + * Find a BOM with the specified bytes. + * + * @return The matched BOM or null if none matched + */ + private ByteOrderMark find() { + for (final ByteOrderMark bom : boms) { + if (matches(bom)) { + return bom; + } + } + return null; + } + + /** + * Check if the bytes match a BOM. + * + * @param bom + * The BOM + * @return true if the bytes match the bom, otherwise false + */ + private boolean matches(final ByteOrderMark bom) { + // if (bom.length() != fbLength) { + // return false; + // } + // firstBytes may be bigger than the BOM bytes + for (int i = 0; i < bom.length(); i++) { + if (bom.get(i) != firstBytes[i]) { + return false; + } + } + return true; + } + + // ---------------------------------------------------------------------------- + // Implementation of InputStream + // ---------------------------------------------------------------------------- + + /** + * Invokes the delegate's read() method, detecting and optionally skipping BOM. + * + * @return the byte read (excluding BOM) or -1 if the end of stream + * @throws IOException + * if an I/O error occurs + */ + @Override + public int read() throws IOException { + final int b = readFirstBytes(); + return b >= 0 ? b : in.read(); + } + + /** + * Invokes the delegate's read(byte[], int, int) method, detecting and optionally skipping BOM. + * + * @param buf + * the buffer to read the bytes into + * @param off + * The start offset + * @param len + * The number of bytes to read (excluding BOM) + * @return the number of bytes read or -1 if the end of stream + * @throws IOException + * if an I/O error occurs + */ + @Override + public int read(final byte[] buf, int off, int len) throws IOException { + int firstCount = 0; + int b = 0; + while (len > 0 && b >= 0) { + b = readFirstBytes(); + if (b >= 0) { + buf[off++] = (byte) (b & 0xFF); + len--; + firstCount++; + } + } + final int secondCount = in.read(buf, off, len); + return secondCount < 0 ? firstCount > 0 ? firstCount : EOF : firstCount + secondCount; + } + + /** + * Invokes the delegate's read(byte[]) method, detecting and optionally skipping BOM. + * + * @param buf + * the buffer to read the bytes into + * @return the number of bytes read (excluding BOM) or -1 if the end of stream + * @throws IOException + * if an I/O error occurs + */ + @Override + public int read(final byte[] buf) throws IOException { + return read(buf, 0, buf.length); + } + + /** + * Invokes the delegate's mark(int) method. + * + * @param readlimit + * read ahead limit + */ + @Override + public synchronized void mark(final int readlimit) { + markFbIndex = fbIndex; + markedAtStart = firstBytes == null; + in.mark(readlimit); + } + + /** + * Invokes the delegate's reset() method. + * + * @throws IOException + * if an I/O error occurs + */ + @Override + public synchronized void reset() throws IOException { + fbIndex = markFbIndex; + if (markedAtStart) { + firstBytes = null; + } + + in.reset(); + } + + /** + * Invokes the delegate's skip(long) method, detecting and optionally skipping BOM. + * + * @param n + * the number of bytes to skip + * @return the number of bytes to skipped or -1 if the end of stream + * @throws IOException + * if an I/O error occurs + */ + @Override + public long skip(final long n) throws IOException { + int skipped = 0; + while ((n > skipped) && (readFirstBytes() >= 0)) { + skipped++; + } + return in.skip(n - skipped) + skipped; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/commons/io/ByteOrderMark.java b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/ByteOrderMark.java new file mode 100644 index 000000000..3fdb6b8af --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/ByteOrderMark.java @@ -0,0 +1,194 @@ +package me.ag2s.epublib.util.commons.io; + +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.Serializable; +import java.util.Locale; + +/** + * Byte Order Mark (BOM) representation - see {@link BOMInputStream}. + * + * @see BOMInputStream + * @see Wikipedia: Byte Order Mark + * @see W3C: Autodetection of Character Encodings + * (Non-Normative) + * @since 2.0 + */ +public class ByteOrderMark implements Serializable { + + private static final long serialVersionUID = 1L; + + /** UTF-8 BOM */ + public static final ByteOrderMark UTF_8 = new ByteOrderMark("UTF-8", 0xEF, 0xBB, 0xBF); + + /** UTF-16BE BOM (Big-Endian) */ + public static final ByteOrderMark UTF_16BE = new ByteOrderMark("UTF-16BE", 0xFE, 0xFF); + + /** UTF-16LE BOM (Little-Endian) */ + public static final ByteOrderMark UTF_16LE = new ByteOrderMark("UTF-16LE", 0xFF, 0xFE); + + /** + * UTF-32BE BOM (Big-Endian) + * @since 2.2 + */ + public static final ByteOrderMark UTF_32BE = new ByteOrderMark("UTF-32BE", 0x00, 0x00, 0xFE, 0xFF); + + /** + * UTF-32LE BOM (Little-Endian) + * @since 2.2 + */ + public static final ByteOrderMark UTF_32LE = new ByteOrderMark("UTF-32LE", 0xFF, 0xFE, 0x00, 0x00); + + /** + * Unicode BOM character; external form depends on the encoding. + * @see Byte Order Mark (BOM) FAQ + * @since 2.5 + */ + @SuppressWarnings("unused") + public static final char UTF_BOM = '\uFEFF'; + + private final String charsetName; + private final int[] bytes; + + /** + * Construct a new BOM. + * + * @param charsetName The name of the charset the BOM represents + * @param bytes The BOM's bytes + * @throws IllegalArgumentException if the charsetName is null or + * zero length + * @throws IllegalArgumentException if the bytes are null or zero + * length + */ + public ByteOrderMark(final String charsetName, final int... bytes) { + if (charsetName == null || charsetName.isEmpty()) { + throw new IllegalArgumentException("No charsetName specified"); + } + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("No bytes specified"); + } + this.charsetName = charsetName; + this.bytes = new int[bytes.length]; + System.arraycopy(bytes, 0, this.bytes, 0, bytes.length); + } + + /** + * Return the name of the {@link java.nio.charset.Charset} the BOM represents. + * + * @return the character set name + */ + public String getCharsetName() { + return charsetName; + } + + /** + * Return the length of the BOM's bytes. + * + * @return the length of the BOM's bytes + */ + public int length() { + return bytes.length; + } + + /** + * The byte at the specified position. + * + * @param pos The position + * @return The specified byte + */ + public int get(final int pos) { + return bytes[pos]; + } + + /** + * Return a copy of the BOM's bytes. + * + * @return a copy of the BOM's bytes + */ + public byte[] getBytes() { + final byte[] copy = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) { + copy[i] = (byte)bytes[i]; + } + return copy; + } + + /** + * Indicates if this BOM's bytes equals another. + * + * @param obj The object to compare to + * @return true if the bom's bytes are equal, otherwise + * false + */ + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof ByteOrderMark)) { + return false; + } + final ByteOrderMark bom = (ByteOrderMark)obj; + if (bytes.length != bom.length()) { + return false; + } + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] != bom.get(i)) { + return false; + } + } + return true; + } + + /** + * Return the hashcode for this BOM. + * + * @return the hashcode for this BOM. + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + int hashCode = getClass().hashCode(); + for (final int b : bytes) { + hashCode += b; + } + return hashCode; + } + + /** + * Provide a String representation of the BOM. + * + * @return the length of the BOM's bytes + */ + @Override + @SuppressWarnings("NullableProblems") + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append(getClass().getSimpleName()); + builder.append('['); + builder.append(charsetName); + builder.append(": "); + for (int i = 0; i < bytes.length; i++) { + if (i > 0) { + builder.append(","); + } + builder.append("0x"); + builder.append(Integer.toHexString(0xFF & bytes[i]).toUpperCase(Locale.ROOT)); + } + builder.append(']'); + return builder.toString(); + } + +} \ No newline at end of file diff --git a/epublib/src/main/java/me/ag2s/epublib/util/commons/io/IOConsumer.java b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/IOConsumer.java new file mode 100644 index 000000000..b4e024f71 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/IOConsumer.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ag2s.epublib.util.commons.io; + +import java.io.IOException; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Like {@link Consumer} but throws {@link IOException}. + * + * @param the type of the input to the operations. + * @since 2.7 + */ +@FunctionalInterface +public interface IOConsumer { + + /** + * Performs this operation on the given argument. + * + * @param t the input argument + * @throws IOException if an I/O error occurs. + */ + void accept(T t) throws IOException; + + /** + * Returns a composed {@code IoConsumer} that performs, in sequence, this operation followed by the {@code after} + * operation. If performing either operation throws an exception, it is relayed to the caller of the composed + * operation. If performing this operation throws an exception, the {@code after} operation will not be performed. + * + * @param after the operation to perform after this operation + * @return a composed {@code Consumer} that performs in sequence this operation followed by the {@code after} + * operation + * @throws NullPointerException if {@code after} is null + */ + @SuppressWarnings("unused") + default IOConsumer andThen(final IOConsumer after) { + Objects.requireNonNull(after); + return (final T t) -> { + accept(t); + after.accept(t); + }; + } +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/commons/io/ProxyInputStream.java b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/ProxyInputStream.java new file mode 100644 index 000000000..3fe2415ab --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/ProxyInputStream.java @@ -0,0 +1,252 @@ +package me.ag2s.epublib.util.commons.io; + +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +import me.ag2s.epublib.util.IOUtil; + +import static me.ag2s.epublib.util.IOUtil.EOF; + + +/** + * A Proxy stream which acts as expected, that is it passes the method + * calls on to the proxied stream and doesn't change which methods are + * being called. + *

    + * It is an alternative base class to FilterInputStream + * to increase reusability, because FilterInputStream changes the + * methods being called, such as read(byte[]) to read(byte[], int, int). + *

    + *

    + * See the protected methods for ways in which a subclass can easily decorate + * a stream with custom pre-, post- or error processing functionality. + *

    + */ +public abstract class ProxyInputStream extends FilterInputStream { + + /** + * Constructs a new ProxyInputStream. + * + * @param proxy the InputStream to delegate to + */ + public ProxyInputStream(final InputStream proxy) { + super(proxy); + // the proxy is stored in a protected superclass variable named 'in' + } + + /** + * Invokes the delegate's read() method. + * + * @return the byte read or -1 if the end of stream + * @throws IOException if an I/O error occurs + */ + @Override + public int read() throws IOException { + try { + beforeRead(1); + final int b = in.read(); + afterRead(b != EOF ? 1 : EOF); + return b; + } catch (final IOException e) { + handleIOException(e); + return EOF; + } + } + + /** + * Invokes the delegate's read(byte[]) method. + * + * @param bts the buffer to read the bytes into + * @return the number of bytes read or EOF if the end of stream + * @throws IOException if an I/O error occurs + */ + @Override + public int read(final byte[] bts) throws IOException { + try { + beforeRead(IOUtil.length(bts)); + final int n = in.read(bts); + afterRead(n); + return n; + } catch (final IOException e) { + handleIOException(e); + return EOF; + } + } + + /** + * Invokes the delegate's read(byte[], int, int) method. + * + * @param bts the buffer to read the bytes into + * @param off The start offset + * @param len The number of bytes to read + * @return the number of bytes read or -1 if the end of stream + * @throws IOException if an I/O error occurs + */ + @Override + public int read(final byte[] bts, final int off, final int len) throws IOException { + try { + beforeRead(len); + final int n = in.read(bts, off, len); + afterRead(n); + return n; + } catch (final IOException e) { + handleIOException(e); + return EOF; + } + } + + /** + * Invokes the delegate's skip(long) method. + * + * @param ln the number of bytes to skip + * @return the actual number of bytes skipped + * @throws IOException if an I/O error occurs + */ + @Override + public long skip(final long ln) throws IOException { + try { + return in.skip(ln); + } catch (final IOException e) { + handleIOException(e); + return 0; + } + } + + /** + * Invokes the delegate's available() method. + * + * @return the number of available bytes + * @throws IOException if an I/O error occurs + */ + @Override + public int available() throws IOException { + try { + return super.available(); + } catch (final IOException e) { + handleIOException(e); + return 0; + } + } + + /** + * Invokes the delegate's close() method. + * + * @throws IOException if an I/O error occurs + */ + @Override + public void close() throws IOException { + IOUtil.close(in, this::handleIOException); + } + + /** + * Invokes the delegate's mark(int) method. + * + * @param readlimit read ahead limit + */ + @Override + public synchronized void mark(final int readlimit) { + in.mark(readlimit); + } + + /** + * Invokes the delegate's reset() method. + * + * @throws IOException if an I/O error occurs + */ + @Override + public synchronized void reset() throws IOException { + try { + in.reset(); + } catch (final IOException e) { + handleIOException(e); + } + } + + /** + * Invokes the delegate's markSupported() method. + * + * @return true if mark is supported, otherwise false + */ + @Override + public boolean markSupported() { + return in.markSupported(); + } + + /** + * Invoked by the read methods before the call is proxied. The number + * of bytes that the caller wanted to read (1 for the {@link #read()} + * method, buffer length for {@link #read(byte[])}, etc.) is given as + * an argument. + *

    + * Subclasses can override this method to add common pre-processing + * functionality without having to override all the read methods. + * The default implementation does nothing. + *

    + * Note this method is not called from {@link #skip(long)} or + * {@link #reset()}. You need to explicitly override those methods if + * you want to add pre-processing steps also to them. + * + * @param n number of bytes that the caller asked to be read + * @since 2.0 + */ + @SuppressWarnings("unused") + + protected void beforeRead(final int n) { + // no-op + } + + /** + * Invoked by the read methods after the proxied call has returned + * successfully. The number of bytes returned to the caller (or -1 if + * the end of stream was reached) is given as an argument. + *

    + * Subclasses can override this method to add common post-processing + * functionality without having to override all the read methods. + * The default implementation does nothing. + *

    + * Note this method is not called from {@link #skip(long)} or + * {@link #reset()}. You need to explicitly override those methods if + * you want to add post-processing steps also to them. + * + * @param n number of bytes read, or -1 if the end of stream was reached + * @since 2.0 + */ + @SuppressWarnings("unused") + protected void afterRead(final int n) { + // no-op + } + + /** + * Handle any IOExceptions thrown. + *

    + * This method provides a point to implement custom exception + * handling. The default behavior is to re-throw the exception. + * + * @param e The IOException thrown + * @throws IOException if an I/O error occurs + * @since 2.0 + */ + protected void handleIOException(final IOException e) throws IOException { + throw e; + } + +} diff --git a/epublib/src/main/java/me/ag2s/epublib/util/commons/io/XmlStreamReader.java b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/XmlStreamReader.java new file mode 100644 index 000000000..3499354be --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/XmlStreamReader.java @@ -0,0 +1,806 @@ +package me.ag2s.epublib.util.commons.io; + +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.text.MessageFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import me.ag2s.epublib.util.IOUtil; + + +/** + * Character stream that handles all the necessary Voodoo to figure out the + * charset encoding of the XML document within the stream. + *

    + * IMPORTANT: This class is not related in any way to the org.xml.sax.XMLReader. + * This one IS a character stream. + *

    + *

    + * All this has to be done without consuming characters from the stream, if not + * the XML parser will not recognized the document as a valid XML. This is not + * 100% true, but it's close enough (UTF-8 BOM is not handled by all parsers + * right now, XmlStreamReader handles it and things work in all parsers). + *

    + *

    + * The XmlStreamReader class handles the charset encoding of XML documents in + * Files, raw streams and HTTP streams by offering a wide set of constructors. + *

    + *

    + * By default the charset encoding detection is lenient, the constructor with + * the lenient flag can be used for a script (following HTTP MIME and XML + * specifications). All this is nicely explained by Mark Pilgrim in his blog, + * Determining the character encoding of a feed. + *

    + *

    + * Originally developed for ROME under + * Apache License 2.0. + *

    + * + * //@seerr XmlStreamWriter + * @since 2.0 + */ +public class XmlStreamReader extends Reader { + private static final int BUFFER_SIZE = IOUtil.DEFAULT_BUFFER_SIZE; + + private static final String UTF_8 = "UTF-8"; + + private static final String US_ASCII = "US-ASCII"; + + private static final String UTF_16BE = "UTF-16BE"; + + private static final String UTF_16LE = "UTF-16LE"; + + private static final String UTF_32BE = "UTF-32BE"; + + private static final String UTF_32LE = "UTF-32LE"; + + private static final String UTF_16 = "UTF-16"; + + private static final String UTF_32 = "UTF-32"; + + private static final String EBCDIC = "CP1047"; + + private static final ByteOrderMark[] BOMS = new ByteOrderMark[] { + ByteOrderMark.UTF_8, + ByteOrderMark.UTF_16BE, + ByteOrderMark.UTF_16LE, + ByteOrderMark.UTF_32BE, + ByteOrderMark.UTF_32LE + }; + + // UTF_16LE and UTF_32LE have the same two starting BOM bytes. + private static final ByteOrderMark[] XML_GUESS_BYTES = new ByteOrderMark[] { + new ByteOrderMark(UTF_8, 0x3C, 0x3F, 0x78, 0x6D), + new ByteOrderMark(UTF_16BE, 0x00, 0x3C, 0x00, 0x3F), + new ByteOrderMark(UTF_16LE, 0x3C, 0x00, 0x3F, 0x00), + new ByteOrderMark(UTF_32BE, 0x00, 0x00, 0x00, 0x3C, + 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x6D), + new ByteOrderMark(UTF_32LE, 0x3C, 0x00, 0x00, 0x00, + 0x3F, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x6D, 0x00, 0x00, 0x00), + new ByteOrderMark(EBCDIC, 0x4C, 0x6F, 0xA7, 0x94) + }; + + private final Reader reader; + + private final String encoding; + + private final String defaultEncoding; + + /** + * Returns the default encoding to use if none is set in HTTP content-type, + * XML prolog and the rules based on content-type are not adequate. + *

    + * If it is NULL the content-type based rules are used. + * + * @return the default encoding to use. + */ + public String getDefaultEncoding() { + return defaultEncoding; + } + + /** + * Creates a Reader for a File. + *

    + * It looks for the UTF-8 BOM first, if none sniffs the XML prolog charset, + * if this is also missing defaults to UTF-8. + *

    + * It does a lenient charset encoding detection, check the constructor with + * the lenient parameter for details. + * + * @param file File to create a Reader from. + * @throws IOException thrown if there is a problem reading the file. + */ + @SuppressWarnings("unused") + public XmlStreamReader(final File file) throws IOException { + this(new FileInputStream(Objects.requireNonNull(file))); + } + + /** + * Creates a Reader for a raw InputStream. + *

    + * It follows the same logic used for files. + *

    + * It does a lenient charset encoding detection, check the constructor with + * the lenient parameter for details. + * + * @param inputStream InputStream to create a Reader from. + * @throws IOException thrown if there is a problem reading the stream. + */ + public XmlStreamReader(final InputStream inputStream) throws IOException { + this(inputStream, true); + } + + /** + * Creates a Reader for a raw InputStream. + *

    + * It follows the same logic used for files. + *

    + * If lenient detection is indicated and the detection above fails as per + * specifications it then attempts the following: + *

    + * If the content type was 'text/html' it replaces it with 'text/xml' and + * tries the detection again. + *

    + * Else if the XML prolog had a charset encoding that encoding is used. + *

    + * Else if the content type had a charset encoding that encoding is used. + *

    + * Else 'UTF-8' is used. + *

    + * If lenient detection is indicated an XmlStreamReaderException is never + * thrown. + * + * @param inputStream InputStream to create a Reader from. + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @throws IOException thrown if there is a problem reading the stream. + * @throws XmlStreamReaderException thrown if the charset encoding could not + * be determined according to the specs. + */ + public XmlStreamReader(final InputStream inputStream, final boolean lenient) throws IOException { + this(inputStream, lenient, null); + } + + /** + * Creates a Reader for a raw InputStream. + *

    + * It follows the same logic used for files. + *

    + * If lenient detection is indicated and the detection above fails as per + * specifications it then attempts the following: + *

    + * If the content type was 'text/html' it replaces it with 'text/xml' and + * tries the detection again. + *

    + * Else if the XML prolog had a charset encoding that encoding is used. + *

    + * Else if the content type had a charset encoding that encoding is used. + *

    + * Else 'UTF-8' is used. + *

    + * If lenient detection is indicated an XmlStreamReaderException is never + * thrown. + * + * @param inputStream InputStream to create a Reader from. + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @param defaultEncoding The default encoding + * @throws IOException thrown if there is a problem reading the stream. + * @throws XmlStreamReaderException thrown if the charset encoding could not + * be determined according to the specs. + */ + public XmlStreamReader(final InputStream inputStream, final boolean lenient, final String defaultEncoding) + throws IOException { + Objects.requireNonNull(inputStream, "inputStream"); + this.defaultEncoding = defaultEncoding; + final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(inputStream, BUFFER_SIZE), false, BOMS); + final BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES); + this.encoding = doRawStream(bom, pis, lenient); + this.reader = new InputStreamReader(pis, encoding); + } + + /** + * Creates a Reader using the InputStream of a URL. + *

    + * If the URL is not of type HTTP and there is not 'content-type' header in + * the fetched data it uses the same logic used for Files. + *

    + * If the URL is a HTTP Url or there is a 'content-type' header in the + * fetched data it uses the same logic used for an InputStream with + * content-type. + *

    + * It does a lenient charset encoding detection, check the constructor with + * the lenient parameter for details. + * + * @param url URL to create a Reader from. + * @throws IOException thrown if there is a problem reading the stream of + * the URL. + */ + @SuppressWarnings("unused") + public XmlStreamReader(final URL url) throws IOException { + this(Objects.requireNonNull(url, "url").openConnection(), null); + } + + /** + * Creates a Reader using the InputStream of a URLConnection. + *

    + * If the URLConnection is not of type HttpURLConnection and there is not + * 'content-type' header in the fetched data it uses the same logic used for + * files. + *

    + * If the URLConnection is a HTTP Url or there is a 'content-type' header in + * the fetched data it uses the same logic used for an InputStream with + * content-type. + *

    + * It does a lenient charset encoding detection, check the constructor with + * the lenient parameter for details. + * + * @param conn URLConnection to create a Reader from. + * @param defaultEncoding The default encoding + * @throws IOException thrown if there is a problem reading the stream of + * the URLConnection. + */ + public XmlStreamReader(final URLConnection conn, final String defaultEncoding) throws IOException { + Objects.requireNonNull(conn, "conm"); + this.defaultEncoding = defaultEncoding; + final boolean lenient = true; + final String contentType = conn.getContentType(); + final InputStream inputStream = conn.getInputStream(); + final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(inputStream, BUFFER_SIZE), false, BOMS); + final BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES); + if (conn instanceof HttpURLConnection || contentType != null) { + this.encoding = processHttpStream(bom, pis, contentType, lenient); + } else { + this.encoding = doRawStream(bom, pis, lenient); + } + this.reader = new InputStreamReader(pis, encoding); + } + + /** + * Creates a Reader using an InputStream and the associated content-type + * header. + *

    + * First it checks if the stream has BOM. If there is not BOM checks the + * content-type encoding. If there is not content-type encoding checks the + * XML prolog encoding. If there is not XML prolog encoding uses the default + * encoding mandated by the content-type MIME type. + *

    + * It does a lenient charset encoding detection, check the constructor with + * the lenient parameter for details. + * + * @param inputStream InputStream to create the reader from. + * @param httpContentType content-type header to use for the resolution of + * the charset encoding. + * @throws IOException thrown if there is a problem reading the file. + */ + public XmlStreamReader(final InputStream inputStream, final String httpContentType) + throws IOException { + this(inputStream, httpContentType, true); + } + + /** + * Creates a Reader using an InputStream and the associated content-type + * header. This constructor is lenient regarding the encoding detection. + *

    + * First it checks if the stream has BOM. If there is not BOM checks the + * content-type encoding. If there is not content-type encoding checks the + * XML prolog encoding. If there is not XML prolog encoding uses the default + * encoding mandated by the content-type MIME type. + *

    + * If lenient detection is indicated and the detection above fails as per + * specifications it then attempts the following: + *

    + * If the content type was 'text/html' it replaces it with 'text/xml' and + * tries the detection again. + *

    + * Else if the XML prolog had a charset encoding that encoding is used. + *

    + * Else if the content type had a charset encoding that encoding is used. + *

    + * Else 'UTF-8' is used. + *

    + * If lenient detection is indicated an XmlStreamReaderException is never + * thrown. + * + * @param inputStream InputStream to create the reader from. + * @param httpContentType content-type header to use for the resolution of + * the charset encoding. + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @param defaultEncoding The default encoding + * @throws IOException thrown if there is a problem reading the file. + * @throws XmlStreamReaderException thrown if the charset encoding could not + * be determined according to the specs. + */ + public XmlStreamReader(final InputStream inputStream, final String httpContentType, + final boolean lenient, final String defaultEncoding) throws IOException { + Objects.requireNonNull(inputStream, "inputStream"); + this.defaultEncoding = defaultEncoding; + final BOMInputStream bom = new BOMInputStream(new BufferedInputStream(inputStream, BUFFER_SIZE), false, BOMS); + final BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES); + this.encoding = processHttpStream(bom, pis, httpContentType, lenient); + this.reader = new InputStreamReader(pis, encoding); + } + + /** + * Creates a Reader using an InputStream and the associated content-type + * header. This constructor is lenient regarding the encoding detection. + *

    + * First it checks if the stream has BOM. If there is not BOM checks the + * content-type encoding. If there is not content-type encoding checks the + * XML prolog encoding. If there is not XML prolog encoding uses the default + * encoding mandated by the content-type MIME type. + *

    + * If lenient detection is indicated and the detection above fails as per + * specifications it then attempts the following: + *

    + * If the content type was 'text/html' it replaces it with 'text/xml' and + * tries the detection again. + *

    + * Else if the XML prolog had a charset encoding that encoding is used. + *

    + * Else if the content type had a charset encoding that encoding is used. + *

    + * Else 'UTF-8' is used. + *

    + * If lenient detection is indicated an XmlStreamReaderException is never + * thrown. + * + * @param inputStream InputStream to create the reader from. + * @param httpContentType content-type header to use for the resolution of + * the charset encoding. + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @throws IOException thrown if there is a problem reading the file. + * @throws XmlStreamReaderException thrown if the charset encoding could not + * be determined according to the specs. + */ + public XmlStreamReader(final InputStream inputStream, final String httpContentType, + final boolean lenient) throws IOException { + this(inputStream, httpContentType, lenient, null); + } + + /** + * Returns the charset encoding of the XmlStreamReader. + * + * @return charset encoding. + */ + public String getEncoding() { + return encoding; + } + + /** + * Invokes the underlying reader's read(char[], int, int) method. + * @param buf the buffer to read the characters into + * @param offset The start offset + * @param len The number of bytes to read + * @return the number of characters read or -1 if the end of stream + * @throws IOException if an I/O error occurs + */ + @Override + public int read(final char[] buf, final int offset, final int len) throws IOException { + return reader.read(buf, offset, len); + } + + /** + * Closes the XmlStreamReader stream. + * + * @throws IOException thrown if there was a problem closing the stream. + */ + @Override + public void close() throws IOException { + reader.close(); + } + + /** + * Process the raw stream. + * + * @param bom BOMInputStream to detect byte order marks + * @param pis BOMInputStream to guess XML encoding + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @return the encoding to be used + * @throws IOException thrown if there is a problem reading the stream. + */ + private String doRawStream(final BOMInputStream bom, final BOMInputStream pis, final boolean lenient) + throws IOException { + final String bomEnc = bom.getBOMCharsetName(); + final String xmlGuessEnc = pis.getBOMCharsetName(); + final String xmlEnc = getXmlProlog(pis, xmlGuessEnc); + try { + return calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc); + } catch (final XmlStreamReaderException ex) { + if (lenient) { + return doLenientDetection(null, ex); + } + throw ex; + } + } + + /** + * Process a HTTP stream. + * + * @param bom BOMInputStream to detect byte order marks + * @param pis BOMInputStream to guess XML encoding + * @param httpContentType The HTTP content type + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @return the encoding to be used + * @throws IOException thrown if there is a problem reading the stream. + */ + private String processHttpStream(final BOMInputStream bom, final BOMInputStream pis, final String httpContentType, + final boolean lenient) throws IOException { + final String bomEnc = bom.getBOMCharsetName(); + final String xmlGuessEnc = pis.getBOMCharsetName(); + final String xmlEnc = getXmlProlog(pis, xmlGuessEnc); + try { + return calculateHttpEncoding(httpContentType, bomEnc, xmlGuessEnc, xmlEnc, lenient); + } catch (final XmlStreamReaderException ex) { + if (lenient) { + return doLenientDetection(httpContentType, ex); + } + throw ex; + } + } + + /** + * Do lenient detection. + * + * @param httpContentType content-type header to use for the resolution of + * the charset encoding. + * @param ex The thrown exception + * @return the encoding + * @throws IOException thrown if there is a problem reading the stream. + */ + private String doLenientDetection(String httpContentType, + XmlStreamReaderException ex) throws IOException { + if (httpContentType != null && httpContentType.startsWith("text/html")) { + httpContentType = httpContentType.substring("text/html".length()); + httpContentType = "text/xml" + httpContentType; + try { + return calculateHttpEncoding(httpContentType, ex.getBomEncoding(), + ex.getXmlGuessEncoding(), ex.getXmlEncoding(), true); + } catch (final XmlStreamReaderException ex2) { + ex = ex2; + } + } + String encoding = ex.getXmlEncoding(); + if (encoding == null) { + encoding = ex.getContentTypeEncoding(); + } + if (encoding == null) { + encoding = defaultEncoding == null ? UTF_8 : defaultEncoding; + } + return encoding; + } + + /** + * Calculate the raw encoding. + * + * @param bomEnc BOM encoding + * @param xmlGuessEnc XML Guess encoding + * @param xmlEnc XML encoding + * @return the raw encoding + * @throws IOException thrown if there is a problem reading the stream. + */ + String calculateRawEncoding(final String bomEnc, final String xmlGuessEnc, + final String xmlEnc) throws IOException { + + // BOM is Null + if (bomEnc == null) { + if (xmlGuessEnc == null || xmlEnc == null) { + return defaultEncoding == null ? UTF_8 : defaultEncoding; + } + if (xmlEnc.equals(UTF_16) && + (xmlGuessEnc.equals(UTF_16BE) || xmlGuessEnc.equals(UTF_16LE))) { + return xmlGuessEnc; + } + return xmlEnc; + } + + // BOM is UTF-8 + if (bomEnc.equals(UTF_8)) { + if (xmlGuessEnc != null && !xmlGuessEnc.equals(UTF_8)) { + final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + if (xmlEnc != null && !xmlEnc.equals(UTF_8)) { + final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + return bomEnc; + } + + // BOM is UTF-16BE or UTF-16LE + if (bomEnc.equals(UTF_16BE) || bomEnc.equals(UTF_16LE)) { + if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) { + final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + if (xmlEnc != null && !xmlEnc.equals(UTF_16) && !xmlEnc.equals(bomEnc)) { + final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + return bomEnc; + } + + // BOM is UTF-32BE or UTF-32LE + if (bomEnc.equals(UTF_32BE) || bomEnc.equals(UTF_32LE)) { + if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) { + final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + if (xmlEnc != null && !xmlEnc.equals(UTF_32) && !xmlEnc.equals(bomEnc)) { + final String msg = MessageFormat.format(RAW_EX_1, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + return bomEnc; + } + + // BOM is something else + final String msg = MessageFormat.format(RAW_EX_2, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, bomEnc, xmlGuessEnc, xmlEnc); + } + + + /** + * Calculate the HTTP encoding. + * + * @param httpContentType The HTTP content type + * @param bomEnc BOM encoding + * @param xmlGuessEnc XML Guess encoding + * @param xmlEnc XML encoding + * @param lenient indicates if the charset encoding detection should be + * relaxed. + * @return the HTTP encoding + * @throws IOException thrown if there is a problem reading the stream. + */ + String calculateHttpEncoding(final String httpContentType, + final String bomEnc, final String xmlGuessEnc, final String xmlEnc, + final boolean lenient) throws IOException { + + // Lenient and has XML encoding + if (lenient && xmlEnc != null) { + return xmlEnc; + } + + // Determine mime/encoding content types from HTTP Content Type + final String cTMime = getContentTypeMime(httpContentType); + final String cTEnc = getContentTypeEncoding(httpContentType); + final boolean appXml = isAppXml(cTMime); + final boolean textXml = isTextXml(cTMime); + + // Mime type NOT "application/xml" or "text/xml" + if (!appXml && !textXml) { + final String msg = MessageFormat.format(HTTP_EX_3, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + } + + // No content type encoding + if (cTEnc == null) { + if (appXml) { + return calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc); + } + return defaultEncoding == null ? US_ASCII : defaultEncoding; + } + + // UTF-16BE or UTF-16LE content type encoding + if (cTEnc.equals(UTF_16BE) || cTEnc.equals(UTF_16LE)) { + if (bomEnc != null) { + final String msg = MessageFormat.format(HTTP_EX_1, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + } + return cTEnc; + } + + // UTF-16 content type encoding + if (cTEnc.equals(UTF_16)) { + if (bomEnc != null && bomEnc.startsWith(UTF_16)) { + return bomEnc; + } + final String msg = MessageFormat.format(HTTP_EX_2, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + } + + // UTF-32BE or UTF-132E content type encoding + if (cTEnc.equals(UTF_32BE) || cTEnc.equals(UTF_32LE)) { + if (bomEnc != null) { + final String msg = MessageFormat.format(HTTP_EX_1, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + } + return cTEnc; + } + + // UTF-32 content type encoding + if (cTEnc.equals(UTF_32)) { + if (bomEnc != null && bomEnc.startsWith(UTF_32)) { + return bomEnc; + } + final String msg = MessageFormat.format(HTTP_EX_2, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + throw new XmlStreamReaderException(msg, cTMime, cTEnc, bomEnc, xmlGuessEnc, xmlEnc); + } + + return cTEnc; + } + + /** + * Returns MIME type or NULL if httpContentType is NULL. + * + * @param httpContentType the HTTP content type + * @return The mime content type + */ + static String getContentTypeMime(final String httpContentType) { + String mime = null; + if (httpContentType != null) { + final int i = httpContentType.indexOf(";"); + if (i >= 0) { + mime = httpContentType.substring(0, i); + } else { + mime = httpContentType; + } + mime = mime.trim(); + } + return mime; + } + + private static final Pattern CHARSET_PATTERN = Pattern + .compile("charset=[\"']?([.[^; \"']]*)[\"']?"); + + /** + * Returns charset parameter value, NULL if not present, NULL if + * httpContentType is NULL. + * + * @param httpContentType the HTTP content type + * @return The content type encoding (upcased) + */ + static String getContentTypeEncoding(final String httpContentType) { + String encoding = null; + if (httpContentType != null) { + final int i = httpContentType.indexOf(";"); + if (i > -1) { + final String postMime = httpContentType.substring(i + 1); + final Matcher m = CHARSET_PATTERN.matcher(postMime); + encoding = m.find() ? m.group(1) : null; + encoding = encoding != null ? encoding.toUpperCase(Locale.ROOT) : null; + } + } + return encoding; + } + + /** + * Pattern capturing the encoding of the "xml" processing instruction. + */ + public static final Pattern ENCODING_PATTERN = Pattern.compile( + "<\\?xml.*encoding[\\s]*=[\\s]*((?:\".[^\"]*\")|(?:'.[^']*'))", + Pattern.MULTILINE); + + /** + * Returns the encoding declared in the , NULL if none. + * + * @param inputStream InputStream to create the reader from. + * @param guessedEnc guessed encoding + * @return the encoding declared in the + * @throws IOException thrown if there is a problem reading the stream. + */ + private static String getXmlProlog(final InputStream inputStream, final String guessedEnc) + throws IOException { + String encoding = null; + if (guessedEnc != null) { + final byte[] bytes = new byte[BUFFER_SIZE]; + inputStream.mark(BUFFER_SIZE); + int offset = 0; + int max = BUFFER_SIZE; + int c = inputStream.read(bytes, offset, max); + int firstGT = -1; + String xmlProlog = ""; // avoid possible NPE warning (cannot happen; this just silences the warning) + while (c != -1 && firstGT == -1 && offset < BUFFER_SIZE) { + offset += c; + max -= c; + c = inputStream.read(bytes, offset, max); + xmlProlog = new String(bytes, 0, offset, guessedEnc); + firstGT = xmlProlog.indexOf('>'); + } + if (firstGT == -1) { + if (c == -1) { + throw new IOException("Unexpected end of XML stream"); + } + throw new IOException( + "XML prolog or ROOT element not found on first " + + offset + " bytes"); + } + final int bytesRead = offset; + if (bytesRead > 0) { + inputStream.reset(); + final BufferedReader bReader = new BufferedReader(new StringReader( + xmlProlog.substring(0, firstGT + 1))); + final StringBuffer prolog = new StringBuffer(); + String line; + while ((line = bReader.readLine()) != null) { + prolog.append(line); + } + final Matcher m = ENCODING_PATTERN.matcher(prolog); + if (m.find()) { + encoding = Objects.requireNonNull(m.group(1)).toUpperCase(Locale.ROOT); + encoding = encoding.substring(1, encoding.length() - 1); + } + } + } + return encoding; + } + + /** + * Indicates if the MIME type belongs to the APPLICATION XML family. + * + * @param mime The mime type + * @return true if the mime type belongs to the APPLICATION XML family, + * otherwise false + */ + static boolean isAppXml(final String mime) { + return mime != null && + (mime.equals("application/xml") || + mime.equals("application/xml-dtd") || + mime.equals("application/xml-external-parsed-entity") || + mime.startsWith("application/") && mime.endsWith("+xml")); + } + + /** + * Indicates if the MIME type belongs to the TEXT XML family. + * + * @param mime The mime type + * @return true if the mime type belongs to the TEXT XML family, + * otherwise false + */ + static boolean isTextXml(final String mime) { + return mime != null && + (mime.equals("text/xml") || + mime.equals("text/xml-external-parsed-entity") || + mime.startsWith("text/") && mime.endsWith("+xml")); + } + + private static final String RAW_EX_1 = + "Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] encoding mismatch"; + + private static final String RAW_EX_2 = + "Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] unknown BOM"; + + private static final String HTTP_EX_1 = + "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], BOM must be NULL"; + + private static final String HTTP_EX_2 = + "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], encoding mismatch"; + + private static final String HTTP_EX_3 = + "Invalid encoding, CT-MIME [{0}] CT-Enc [{1}] BOM [{2}] XML guess [{3}] XML prolog [{4}], Invalid MIME"; + +} \ No newline at end of file diff --git a/epublib/src/main/java/me/ag2s/epublib/util/commons/io/XmlStreamReaderException.java b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/XmlStreamReaderException.java new file mode 100644 index 000000000..a903279d4 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/epublib/util/commons/io/XmlStreamReaderException.java @@ -0,0 +1,139 @@ +package me.ag2s.epublib.util.commons.io; + +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.IOException; + +/** + * The XmlStreamReaderException is thrown by the XmlStreamReader constructors if + * the charset encoding can not be determined according to the XML 1.0 + * specification and RFC 3023. + *

    + * The exception returns the unconsumed InputStream to allow the application to + * do an alternate processing with the stream. Note that the original + * InputStream given to the XmlStreamReader cannot be used as that one has been + * already read. + *

    + * + * @since 2.0 + */ +public class XmlStreamReaderException extends IOException { + + private static final long serialVersionUID = 1L; + + private final String bomEncoding; + + private final String xmlGuessEncoding; + + private final String xmlEncoding; + + private final String contentTypeMime; + + private final String contentTypeEncoding; + + /** + * Creates an exception instance if the charset encoding could not be + * determined. + *

    + * Instances of this exception are thrown by the XmlStreamReader. + *

    + * + * @param msg message describing the reason for the exception. + * @param bomEnc BOM encoding. + * @param xmlGuessEnc XML guess encoding. + * @param xmlEnc XML prolog encoding. + */ + public XmlStreamReaderException(final String msg, final String bomEnc, + final String xmlGuessEnc, final String xmlEnc) { + this(msg, null, null, bomEnc, xmlGuessEnc, xmlEnc); + } + + /** + * Creates an exception instance if the charset encoding could not be + * determined. + *

    + * Instances of this exception are thrown by the XmlStreamReader. + *

    + * + * @param msg message describing the reason for the exception. + * @param ctMime MIME type in the content-type. + * @param ctEnc encoding in the content-type. + * @param bomEnc BOM encoding. + * @param xmlGuessEnc XML guess encoding. + * @param xmlEnc XML prolog encoding. + */ + public XmlStreamReaderException(final String msg, final String ctMime, final String ctEnc, + final String bomEnc, final String xmlGuessEnc, final String xmlEnc) { + super(msg); + contentTypeMime = ctMime; + contentTypeEncoding = ctEnc; + bomEncoding = bomEnc; + xmlGuessEncoding = xmlGuessEnc; + xmlEncoding = xmlEnc; + } + + /** + * Returns the BOM encoding found in the InputStream. + * + * @return the BOM encoding, null if none. + */ + public String getBomEncoding() { + return bomEncoding; + } + + /** + * Returns the encoding guess based on the first bytes of the InputStream. + * + * @return the encoding guess, null if it couldn't be guessed. + */ + public String getXmlGuessEncoding() { + return xmlGuessEncoding; + } + + /** + * Returns the encoding found in the XML prolog of the InputStream. + * + * @return the encoding of the XML prolog, null if none. + */ + public String getXmlEncoding() { + return xmlEncoding; + } + + /** + * Returns the MIME type in the content-type used to attempt determining the + * encoding. + * + * @return the MIME type in the content-type, null if there was not + * content-type or the encoding detection did not involve HTTP. + */ + public String getContentTypeMime() { + return contentTypeMime; + } + + /** + * Returns the encoding in the content-type used to attempt determining the + * encoding. + * + * @return the encoding in the content-type, null if there was not + * content-type, no encoding in it or the encoding detection did not + * involve HTTP. + */ + public String getContentTypeEncoding() { + return contentTypeEncoding; + } +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/domain/UmdBook.java b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdBook.java new file mode 100644 index 000000000..bdb5c294a --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdBook.java @@ -0,0 +1,81 @@ +package me.ag2s.umdlib.domain; + +import java.io.IOException; +import java.io.OutputStream; + +import me.ag2s.umdlib.tool.WrapOutputStream; + +public class UmdBook { + + public int getNum() { + return num; + } + + public void setNum(int num) { + this.num = num; + } + + private int num; + + + /** Header Part of UMD book */ + private UmdHeader header = new UmdHeader(); + /** + * Detail chapters Part of UMD book + * (include Titles & Contents of each chapter) + */ + private UmdChapters chapters = new UmdChapters(); + + /** Cover Part of UMD book (for example, and JPEG file) */ + private UmdCover cover = new UmdCover(); + + /** End Part of UMD book */ + private UmdEnd end = new UmdEnd(); + + /** + * Build the UMD file. + * @param os OutputStream + * @throws IOException + */ + public void buildUmd(OutputStream os) throws IOException { + WrapOutputStream wos = new WrapOutputStream(os); + + header.buildHeader(wos); + chapters.buildChapters(wos); + cover.buildCover(wos); + end.buildEnd(wos); + } + + public UmdHeader getHeader() { + return header; + } + + public void setHeader(UmdHeader header) { + this.header = header; + } + + public UmdChapters getChapters() { + return chapters; + } + + public void setChapters(UmdChapters chapters) { + this.chapters = chapters; + } + + public UmdCover getCover() { + return cover; + } + + public void setCover(UmdCover cover) { + this.cover = cover; + } + + public UmdEnd getEnd() { + return end; + } + + public void setEnd(UmdEnd end) { + this.end = end; + } + +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/domain/UmdChapters.java b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdChapters.java new file mode 100644 index 000000000..e0fb5691c --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdChapters.java @@ -0,0 +1,207 @@ +package me.ag2s.umdlib.domain; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.zip.DeflaterOutputStream; + +import me.ag2s.umdlib.tool.UmdUtils; +import me.ag2s.umdlib.tool.WrapOutputStream; + +/** + * It includes all titles and contents of each chapter in the UMD file. + * And the content has been compressed by zlib. + * + * @author Ray Liang (liangguanhui@qq.com) + * 2009-12-20 + */ +public class UmdChapters { + + private static final int DEFAULT_CHUNK_INIT_SIZE = 32768; + private int TotalContentLen; + + public List getTitles() { + return titles; + } + + private final List titles = new ArrayList<>(); + public List contentLengths = new ArrayList<>(); + public ByteArrayOutputStream contents = new ByteArrayOutputStream(); + + public void addTitle(String s){ + titles.add(UmdUtils.stringToUnicodeBytes(s)); + } + public void addTitle(byte[] s){ + titles.add(s); + } + public void addContentLength(Integer integer){ + contentLengths.add(integer); + } + public int getContentLength(int index){ + return contentLengths.get(index); + } + + public byte[] getContent(int index) { + int st=contentLengths.get(index); + byte[] b=contents.toByteArray(); + int end=index+1 chunkRbList = new ArrayList(); + + while(startPos < allContents.length) { + left = allContents.length - startPos; + len = Math.min(DEFAULT_CHUNK_INIT_SIZE, left); + + bos.reset(); + DeflaterOutputStream zos = new DeflaterOutputStream(bos); + zos.write(allContents, startPos, len); + zos.close(); + byte[] chunk = bos.toByteArray(); + + byte[] rb = UmdUtils.genRandomBytes(4); + wos.writeByte('$'); + wos.writeBytes(rb); // 4 random + chunkRbList.add(rb); + wos.writeInt(chunk.length + 9); + wos.write(chunk); + + // end of each chunk + wos.writeBytes('#', 0xF1, 0, 0, 0x15); + wos.write(zero16); + + startPos += len; + chunkCnt++; + } + + // end of all chunks + wos.writeBytes('#', 0x81, 0, 0x01, 0x09); + wos.writeBytes(0, 0, 0, 0); //random numbers + wos.write('$'); + wos.writeBytes(0, 0, 0, 0); //random numbers + wos.writeInt(chunkCnt * 4 + 9); + for (int i = chunkCnt - 1; i >= 0; i--) { + // random. They are as the same as random numbers in the begin of each chunk + // use desc order to output these random + wos.writeBytes(chunkRbList.get(i)); + } + } + + public void addChapter(String title, String content) { + titles.add(UmdUtils.stringToUnicodeBytes(title)); + byte[] b = UmdUtils.stringToUnicodeBytes(content); + contentLengths.add(b.length); + try { + contents.write(b); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void addFile(File f, String title) throws IOException { + byte[] temp = UmdUtils.readFile(f); + String s = new String(temp); + addChapter(title, s); + } + + public void addFile(File f) throws IOException { + String s = f.getName(); + int idx = s.lastIndexOf('.'); + if (idx >= 0) { + s = s.substring(0, idx); + } + addFile(f, s); + } + + public void clearChapters() { + titles.clear(); + contentLengths.clear(); + contents.reset(); + } + + public int getTotalContentLen() { + return TotalContentLen; + } + + public void setTotalContentLen(int totalContentLen) { + TotalContentLen = totalContentLen; + } +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/domain/UmdCover.java b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdCover.java new file mode 100644 index 000000000..b3c155300 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdCover.java @@ -0,0 +1,96 @@ +package me.ag2s.umdlib.domain; + + +import java.io.File; +import java.io.IOException; + +import me.ag2s.umdlib.tool.UmdUtils; +import me.ag2s.umdlib.tool.WrapOutputStream; + + +/** + * This is the cover part of the UMD file. + *

    + * NOTICE: if the "coverData" is empty, it will be skipped when building UMD file. + *

    + * There are 3 ways to load the image data: + *
      + *
    1. new constructor function of UmdCover.
    2. + *
    3. use UmdCover.load function.
    4. + *
    5. use UmdCover.initDefaultCover, it will generate a simple image with text.
    6. + *
    + * @author Ray Liang (liangguanhui@qq.com) + * 2009-12-20 + */ +public class UmdCover { + + private static int DEFAULT_COVER_WIDTH = 120; + private static int DEFAULT_COVER_HEIGHT = 160; + + private byte[] coverData; + + public UmdCover() { + } + + public UmdCover(byte[] coverData) { + this.coverData = coverData; + } + + public void load(File f) throws IOException { + this.coverData = UmdUtils.readFile(f); + } + + public void load(String fileName) throws IOException { + load(new File(fileName)); + } + + public void initDefaultCover(String title) throws IOException { +// BufferedImage img = new BufferedImage(DEFAULT_COVER_WIDTH, DEFAULT_COVER_HEIGHT, BufferedImage.TYPE_INT_RGB); +// Graphics g = img.getGraphics(); +// g.setColor(Color.BLACK); +// g.fillRect(0, 0, img.getWidth(), img.getHeight()); +// g.setColor(Color.WHITE); +// g.setFont(new Font("����", Font.PLAIN, 12)); +// +// FontMetrics fm = g.getFontMetrics(); +// int ascent = fm.getAscent(); +// int descent = fm.getDescent(); +// int strWidth = fm.stringWidth(title); +// int x = (img.getWidth() - strWidth) / 2; +// int y = (img.getHeight() - ascent - descent) / 2; +// g.drawString(title, x, y); +// g.dispose(); +// +// ByteArrayOutputStream baos = new ByteArrayOutputStream(); +// +// JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(baos); +// JPEGEncodeParam param = encoder.getDefaultJPEGEncodeParam(img); +// param.setQuality(0.5f, false); +// encoder.setJPEGEncodeParam(param); +// encoder.encode(img); +// +// coverData = baos.toByteArray(); + } + + public void buildCover(WrapOutputStream wos) throws IOException { + if (coverData == null || coverData.length == 0) { + return; + } + wos.writeBytes('#', 0x82, 0, 0x01, 0x0A, 0x01); + byte[] rb = UmdUtils.genRandomBytes(4); + wos.writeBytes(rb); //random numbers + wos.write('$'); + wos.writeBytes(rb); //random numbers + wos.writeInt(coverData.length + 9); + wos.write(coverData); + } + + public byte[] getCoverData() { + return coverData; + } + + public void setCoverData(byte[] coverData) { + this.coverData = coverData; + } + +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/domain/UmdEnd.java b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdEnd.java new file mode 100644 index 000000000..129712f49 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdEnd.java @@ -0,0 +1,20 @@ +package me.ag2s.umdlib.domain; + +import java.io.IOException; + +import me.ag2s.umdlib.tool.WrapOutputStream; + +/** + * End part of UMD book, nothing to be special + * + * @author Ray Liang (liangguanhui@qq.com) + * 2009-12-20 + */ +public class UmdEnd { + + public void buildEnd(WrapOutputStream wos) throws IOException { + wos.writeBytes('#', 0x0C, 0, 0x01, 0x09); + wos.writeInt(wos.getWritten() + 4); + } + +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/domain/UmdHeader.java b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdHeader.java new file mode 100644 index 000000000..7844646b2 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/domain/UmdHeader.java @@ -0,0 +1,165 @@ +package me.ag2s.umdlib.domain; + + +import androidx.annotation.NonNull; + +import java.io.IOException; + +import me.ag2s.umdlib.tool.UmdUtils; +import me.ag2s.umdlib.tool.WrapOutputStream; + +/** + * Header of UMD file. + * It includes a lot of properties of header. + * All the properties are String type. + * + * @author Ray Liang (liangguanhui@qq.com) + * 2009-12-20 + */ +public class UmdHeader { + public byte getUmdType() { + return umdType; + } + + public void setUmdType(byte umdType) { + this.umdType = umdType; + } + + private byte umdType; + private String title; + + private String author; + + private String year; + + private String month; + + private String day; + + private String bookType; + + private String bookMan; + + private String shopKeeper; + private final static byte B_type_umd = (byte) 0x01; + private final static byte B_type_title = (byte) 0x02; + private final static byte B_type_author = (byte) 0x03; + private final static byte B_type_year = (byte) 0x04; + private final static byte B_type_month = (byte) 0x05; + private final static byte B_type_day = (byte) 0x06; + private final static byte B_type_bookType = (byte) 0x07; + private final static byte B_type_bookMan = (byte) 0x08; + private final static byte B_type_shopKeeper = (byte) 0x09; + + public void buildHeader(WrapOutputStream wos) throws IOException { + wos.writeBytes(0x89, 0x9b, 0x9a, 0xde); // UMD file type flags + wos.writeByte('#'); + wos.writeBytes(0x01, 0x00, 0x00, 0x08); // Unknown + wos.writeByte(0x01); //0x01 is text type; while 0x02 is Image type. + wos.writeBytes(UmdUtils.genRandomBytes(2)); //random number + + // start properties output + buildType(wos, B_type_title, getTitle()); + buildType(wos, B_type_author, getAuthor()); + buildType(wos, B_type_year, getYear()); + buildType(wos, B_type_month, getMonth()); + buildType(wos, B_type_day, getDay()); + buildType(wos, B_type_bookType, getBookType()); + buildType(wos, B_type_bookMan, getBookMan()); + buildType(wos, B_type_shopKeeper, getShopKeeper()); + } + + public void buildType(WrapOutputStream wos, byte type, String content) throws IOException { + if (content == null || content.length() == 0) { + return; + } + + wos.writeBytes('#', type, 0, 0); + + byte[] temp = UmdUtils.stringToUnicodeBytes(content); + wos.writeByte(temp.length + 5); + wos.write(temp); + } + + + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getBookMan() { + return bookMan; + } + + public void setBookMan(String bookMan) { + this.bookMan = bookMan; + } + + public String getShopKeeper() { + return shopKeeper; + } + + public void setShopKeeper(String shopKeeper) { + this.shopKeeper = shopKeeper; + } + + public String getYear() { + return year; + } + + public void setYear(String year) { + this.year = year; + } + + public String getMonth() { + return month; + } + + public void setMonth(String month) { + this.month = month; + } + + public String getDay() { + return day; + } + + public void setDay(String day) { + this.day = day; + } + + public String getBookType() { + return bookType; + } + + public void setBookType(String bookType) { + this.bookType = bookType; + } + + @Override + @NonNull + public String toString() { + return "UmdHeader{" + + "umdType=" + umdType + + ", title='" + title + '\'' + + ", author='" + author + '\'' + + ", year='" + year + '\'' + + ", month='" + month + '\'' + + ", day='" + day + '\'' + + ", bookType='" + bookType + '\'' + + ", bookMan='" + bookMan + '\'' + + ", shopKeeper='" + shopKeeper + '\'' + + '}'; + } +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/tool/StreamReader.java b/epublib/src/main/java/me/ag2s/umdlib/tool/StreamReader.java new file mode 100644 index 000000000..ff9d9b845 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/tool/StreamReader.java @@ -0,0 +1,124 @@ +package me.ag2s.umdlib.tool; + + +import java.io.IOException; +import java.io.InputStream; + +public class StreamReader { + private InputStream is; + + public long getOffset() { + return offset; + } + + public void setOffset(long offset) { + this.offset = offset; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + private long offset; + private long size; + + private void incCount(int value) { + int temp = (int) (offset + value); + if (temp < 0) { + temp = Integer.MAX_VALUE; + } + offset = temp; + } + public StreamReader(InputStream inputStream) throws IOException { + this.is=inputStream; + //this.size=inputStream.getChannel().size(); + } + + public short readUint8() throws IOException { + byte[] b=new byte[1]; + is.read(b); + incCount(1); + return (short) ((b[0] & 0xFF)); + + } + + public byte readByte() throws IOException { + byte[] b=new byte[1]; + is.read(b); + incCount(1); + return b[0]; + } + public byte[] readBytes(int len) throws IOException { + if (len<1){ + System.out.println(len); + throw new IllegalArgumentException("Length must > 0: " + len); + } + byte[] b=new byte[len]; + is.read(b); + incCount(len); + return b; + } + public String readHex(int len) throws IOException { + if (len<1){ + System.out.println(len); + throw new IllegalArgumentException("Length must > 0: " + len); + } + byte[] b=new byte[len]; + is.read(b); + incCount(len); + return UmdUtils.toHex(b); + } + + public short readShort() throws IOException { + byte[] b=new byte[2]; + is.read(b); + incCount(2); + short x = (short) (((b[0] & 0xFF) << 8) | ((b[1] & 0xFF) << 0)); + return x; + } + public short readShortLe() throws IOException { + byte[] b=new byte[2]; + is.read(b); + incCount(2); + short x = (short) (((b[1] & 0xFF) << 8) | ((b[0] & 0xFF) << 0)); + return x; + } + public int readInt() throws IOException { + byte[] b=new byte[4]; + is.read(b); + incCount(4); + int x = ((b[0] & 0xFF) << 24) | ((b[1] & 0xFF) << 16) | + ((b[2] & 0xFF) << 8) | ((b[3] & 0xFF) << 0); + return x; + } + public int readIntLe() throws IOException { + byte[] b=new byte[4]; + is.read(b); + incCount(4); + int x = ((b[3] & 0xFF) << 24) | ((b[2] & 0xFF) << 16) | + ((b[1] & 0xFF) << 8) | ((b[0] & 0xFF) << 0); + return x; + } + public void skip(int len) throws IOException { + readBytes(len); + } + + + public byte[] read(byte[] b) throws IOException { + is.read(b); + incCount(b.length); + return b; + } + + public byte[] read(byte[] b, int off, int len) throws IOException { + is.read(b, off, len); + incCount(len); + return b; + } + + +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/tool/UmdUtils.java b/epublib/src/main/java/me/ag2s/umdlib/tool/UmdUtils.java new file mode 100644 index 000000000..695c619dd --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/tool/UmdUtils.java @@ -0,0 +1,153 @@ + +package me.ag2s.umdlib.tool; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Random; +import java.util.zip.InflaterInputStream; + + +public class UmdUtils { + + private static final int EOF = -1; + private static final int BUFFER_SIZE = 8 * 1024; + + + /** + * 将字符串编码成Unicode形式的byte[] + * @param s 要编码的字符串 + * @return 编码好的byte[] + */ + public static byte[] stringToUnicodeBytes(String s) { + if (s == null) { + throw new NullPointerException(); + } + + int len = s.length(); + byte[] ret = new byte[len * 2]; + int a, b, c; + for (int i = 0; i < len; i++) { + c = s.charAt(i); + a = c >> 8; + b = c & 0xFF; + if (a < 0) { + a += 0xFF; + } + if (b < 0) { + b += 0xFF; + } + ret[i * 2] = (byte) b; + ret[i * 2 + 1] = (byte) a; + } + return ret; + } + + /** + * 将编码成Unicode形式的byte[]解码成原始字符串 + * @param bytes 编码成Unicode形式的byte[] + * @return 原始字符串 + */ + public static String unicodeBytesToString(byte[] bytes){ + char[] s=new char[bytes.length/2]; + StringBuilder sb=new StringBuilder(); + int a,b,c; + for(int i=0;i= 0) { + baos.write(ch); + } + baos.flush(); + return baos.toByteArray(); + } + } + + private static final Random random = new Random(); + + public static byte[] genRandomBytes(int len) { + if (len <= 0) { + throw new IllegalArgumentException("Length must > 0: " + len); + } + byte[] ret = new byte[len]; + for (int i = 0; i < ret.length; i++) { + ret[i] = (byte) random.nextInt(256); + } + return ret; + } + +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/tool/WrapOutputStream.java b/epublib/src/main/java/me/ag2s/umdlib/tool/WrapOutputStream.java new file mode 100644 index 000000000..bac69ec29 --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/tool/WrapOutputStream.java @@ -0,0 +1,91 @@ +package me.ag2s.umdlib.tool; + +import java.io.IOException; +import java.io.OutputStream; + +public class WrapOutputStream extends OutputStream { + + private final OutputStream os; + private int written; + + public WrapOutputStream(OutputStream os) { + this.os = os; + } + + private void incCount(int value) { + int temp = written + value; + if (temp < 0) { + temp = Integer.MAX_VALUE; + } + written = temp; + } + + // it is different from the writeInt of DataOutputStream + public void writeInt(int v) throws IOException { + os.write((v >>> 0) & 0xFF); + os.write((v >>> 8) & 0xFF); + os.write((v >>> 16) & 0xFF); + os.write((v >>> 24) & 0xFF); + incCount(4); + } + + public void writeByte(byte b) throws IOException { + write(b); + } + + public void writeByte(int n) throws IOException { + write(n); + } + + public void writeBytes(byte ... bytes) throws IOException { + write(bytes); + } + + public void writeBytes(int ... vals) throws IOException { + for (int v : vals) { + write(v); + } + } + + public void write(byte[] b, int off, int len) throws IOException { + os.write(b, off, len); + incCount(len); + } + + public void write(byte[] b) throws IOException { + os.write(b); + incCount(b.length); + } + + public void write(int b) throws IOException { + os.write(b); + incCount(1); + } + + ///////////////////////////////////////////////// + + public void close() throws IOException { + os.close(); + } + + public void flush() throws IOException { + os.flush(); + } + + public boolean equals(Object obj) { + return os.equals(obj); + } + + public int hashCode() { + return os.hashCode(); + } + + public String toString() { + return os.toString(); + } + + public int getWritten() { + return written; + } + +} diff --git a/epublib/src/main/java/me/ag2s/umdlib/umd/UmdReader.java b/epublib/src/main/java/me/ag2s/umdlib/umd/UmdReader.java new file mode 100644 index 000000000..3a65da96f --- /dev/null +++ b/epublib/src/main/java/me/ag2s/umdlib/umd/UmdReader.java @@ -0,0 +1,226 @@ +package me.ag2s.umdlib.umd; + + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; + + +import me.ag2s.umdlib.domain.UmdBook; +import me.ag2s.umdlib.domain.UmdCover; +import me.ag2s.umdlib.domain.UmdHeader; +import me.ag2s.umdlib.tool.StreamReader; +import me.ag2s.umdlib.tool.UmdUtils; + +/** + * UMD格式的电子书解析 + * 格式规范参考: + * http://blog.sina.com.cn/s/blog_7c8dc2d501018o5d.html + * http://blog.sina.com.cn/s/blog_7c8dc2d501018o5l.html + * + */ + +public class UmdReader { + UmdBook book; + InputStream inputStream; + int _AdditionalCheckNumber; + int _TotalContentLen; + boolean end = false; + + + public synchronized UmdBook read(InputStream inputStream) throws Exception { + + book = new UmdBook(); + this.inputStream=inputStream; + StreamReader reader = new StreamReader(inputStream); + UmdHeader umdHeader = new UmdHeader(); + book.setHeader(umdHeader); + if (reader.readIntLe() != 0xde9a9b89) { + throw new IOException("Wrong header"); + } + short num1 = -1; + byte ch = reader.readByte(); + while (ch == 35) { + //int num2=reader.readByte(); + short segType = reader.readShortLe(); + byte segFlag = reader.readByte(); + short len = (short) (reader.readUint8() - 5); + + System.out.println("块标识:" + segType); + //short length1 = reader.readByte(); + readSection(segType, segFlag, len, reader, umdHeader); + + if ((int) segType == 241 || (int) segType == 10) { + segType = num1; + } + for (ch = reader.readByte(); ch == 36; ch = reader.readByte()) { + //int num3 = reader.readByte(); + System.out.println(ch); + int additionalCheckNumber = reader.readIntLe(); + int length2 = (reader.readIntLe() - 9); + readAdditionalSection(segType, additionalCheckNumber, length2, reader); + } + num1 = segType; + + } + System.out.println(book.getHeader().toString()); + return book; + + } + + private void readAdditionalSection(short segType, int additionalCheckNumber, int length, StreamReader reader) throws Exception { + switch (segType) { + case 14: + //this._TotalImageList.Add((object) Image.FromStream((Stream) new MemoryStream(reader.ReadBytes((int) length)))); + break; + case 15: + //this._TotalImageList.Add((object) Image.FromStream((Stream) new MemoryStream(reader.ReadBytes((int) length)))); + break; + case 129: + reader.readBytes(length); + break; + case 130: + //byte[] covers = reader.readBytes(length); + book.setCover(new UmdCover(reader.readBytes(length))); + //this._Book.Cover = BitmapImage.FromStream((Stream) new MemoryStream(reader.ReadBytes((int) length))); + break; + case 131: + System.out.println(length / 4); + book.setNum(length / 4); + for (int i = 0; i < length / 4; ++i) { + book.getChapters().addContentLength(reader.readIntLe()); + } + break; + case 132: + //System.out.println(length/4); + System.out.println(_AdditionalCheckNumber); + System.out.println(additionalCheckNumber); + if (this._AdditionalCheckNumber != additionalCheckNumber) { + System.out.println(length); + book.getChapters().contents.write(UmdUtils.decompress(reader.readBytes(length))); + book.getChapters().contents.flush(); + break; + } else { + for (int i = 0; i < book.getNum(); i++) { + short len = reader.readUint8(); + byte[] title = reader.readBytes(len); + //System.out.println(UmdUtils.unicodeBytesToString(title)); + book.getChapters().addTitle(title); + } + } + + + break; + default: + /*Console.WriteLine("未知内容"); + Console.WriteLine("Seg Type = " + (object) segType); + Console.WriteLine("Seg Len = " + (object) length); + Console.WriteLine("content = " + (object) reader.ReadBytes((int) length));*/ + break; + } + } + + public void readSection(short segType, byte segFlag, short length, StreamReader reader, UmdHeader header) throws IOException { + switch (segType) { + case 1://umd文件头 DCTS_CMD_ID_VERSION + header.setUmdType(reader.readByte()); + reader.readBytes(2);//Random 2 + System.out.println("UMD文件类型:" + header.getUmdType()); + break; + case 2://文件标题 DCTS_CMD_ID_TITLE + header.setTitle(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("文件标题:" + header.getTitle()); + break; + case 3://作者 + header.setAuthor(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("作者:" + header.getAuthor()); + break; + case 4://年 + header.setYear(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("年:" + header.getYear()); + break; + case 5://月 + header.setMonth(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("月:" + header.getMonth()); + break; + case 6://日 + header.setDay(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("日:" + header.getDay()); + break; + case 7://小说类型 + header.setBookType(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("小说类型:" + header.getBookType()); + break; + case 8://出版商 + header.setBookMan(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("出版商:" + header.getBookMan()); + break; + case 9:// 零售商 + header.setShopKeeper(UmdUtils.unicodeBytesToString(reader.readBytes(length))); + System.out.println("零售商:" + header.getShopKeeper()); + break; + case 10://CONTENT ID + System.out.println("CONTENT ID:" + reader.readHex(length)); + break; + case 11: + //内容长度 DCTS_CMD_ID_FILE_LENGTH + _TotalContentLen = reader.readIntLe(); + book.getChapters().setTotalContentLen(_TotalContentLen); + System.out.println("内容长度:" + _TotalContentLen); + break; + case 12://UMD文件结束 + end = true; + int num2 = reader.readIntLe(); + System.out.println("整个文件长度" + num2); + break; + case 13: + break; + case 14: + int num3 = (int) reader.readByte(); + break; + case 15: + reader.readBytes(length); + break; + case 129://正文 + case 131://章节偏移 + _AdditionalCheckNumber = reader.readIntLe(); + System.out.println("章节偏移:" + _AdditionalCheckNumber); + break; + case 132://章节标题,正文 + _AdditionalCheckNumber = reader.readIntLe(); + System.out.println("章节标题,正文:" + _AdditionalCheckNumber); + break; + case 130://封面(jpg) + int num4 = (int) reader.readByte(); + _AdditionalCheckNumber = reader.readIntLe(); + break; + case 135://页面偏移(Page Offset) + reader.readUint8();//fontSize 一字节 字体大小 + reader.readUint8();//screenWidth 屏幕宽度 + reader.readBytes(4);//BlockRandom 指向一个页面偏移数据块 + break; + case 240://CDS KEY + break; + case 241://许可证(LICENCE KEY) + //System.out.println("整个文件长度" + length); + System.out.println("许可证(LICENCE KEY):" + reader.readHex(16)); + break; + default: + if (length > 0) { + byte[] numArray = reader.readBytes(length); + } + + + } + } + + + @Override + @NonNull + public String toString() { + return "UmdReader{" + + "book=" + book + + '}'; + } +} diff --git a/epublib/src/main/resources/dtd/openebook.org/dtds/oeb-1.2/oeb12.ent b/epublib/src/main/resources/dtd/openebook.org/dtds/oeb-1.2/oeb12.ent new file mode 100644 index 000000000..f7b58d257 --- /dev/null +++ b/epublib/src/main/resources/dtd/openebook.org/dtds/oeb-1.2/oeb12.ent @@ -0,0 +1,1135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/openebook.org/dtds/oeb-1.2/oebpkg12.dtd b/epublib/src/main/resources/dtd/openebook.org/dtds/oeb-1.2/oebpkg12.dtd new file mode 100644 index 000000000..34cc2b10e --- /dev/null +++ b/epublib/src/main/resources/dtd/openebook.org/dtds/oeb-1.2/oebpkg12.dtd @@ -0,0 +1,390 @@ + + + + + + + + + +%OEBEntities; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.daisy.org/z3986/2005/ncx-2005-1.dtd b/epublib/src/main/resources/dtd/www.daisy.org/z3986/2005/ncx-2005-1.dtd new file mode 100644 index 000000000..b889c41a5 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.daisy.org/z3986/2005/ncx-2005-1.dtd @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/ruby/xhtml-ruby-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/ruby/xhtml-ruby-1.mod new file mode 100644 index 000000000..a44bb3fa9 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/ruby/xhtml-ruby-1.mod @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + + + + + + + + +]]> + +]]> + + + +]]> + + + + + + + + + + + + + +]]> + + + + + + +]]> + + + + + + +]]> +]]> + + + + + + + +]]> + + + + + + + + +]]> + + + + +]]> +]]> + + + + + + +]]> +]]> + + + + + + + + + + +]]> + + + + + +]]> + + + + + +]]> +]]> + + + + + +]]> + + + + + +]]> + + + + + +]]> +]]> +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-arch-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-arch-1.mod new file mode 100644 index 000000000..4a4fa6caa --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-arch-1.mod @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-attribs-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-attribs-1.mod new file mode 100644 index 000000000..104e57002 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-attribs-1.mod @@ -0,0 +1,142 @@ + + + + + + + + + +]]> + + + + +]]> + + + + +]]> + + + + + + + + +]]> + + + + + + + + + + + +]]> + + +]]> + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-base-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-base-1.mod new file mode 100644 index 000000000..dca21ca07 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-base-1.mod @@ -0,0 +1,53 @@ + + + + + + + + + + + + +]]> + + + +]]> + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-bdo-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-bdo-1.mod new file mode 100644 index 000000000..fcd67bf6b --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-bdo-1.mod @@ -0,0 +1,47 @@ + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkphras-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkphras-1.mod new file mode 100644 index 000000000..0eeb16419 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkphras-1.mod @@ -0,0 +1,164 @@ + + + + + + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + +]]> + + + +]]> + + + + +]]> + + + +]]> + + + + +]]> + + + +]]> + + + + +]]> + + + +]]> + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkpres-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkpres-1.mod new file mode 100644 index 000000000..30968bb7a --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkpres-1.mod @@ -0,0 +1,40 @@ + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkstruct-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkstruct-1.mod new file mode 100644 index 000000000..ab37c73c0 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-blkstruct-1.mod @@ -0,0 +1,57 @@ + + + + + + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-charent-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-charent-1.mod new file mode 100644 index 000000000..b1faf15cc --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-charent-1.mod @@ -0,0 +1,39 @@ + + + + + + + +%xhtml-lat1; + + +%xhtml-symbol; + + +%xhtml-special; + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-csismap-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-csismap-1.mod new file mode 100644 index 000000000..5977f038b --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-csismap-1.mod @@ -0,0 +1,114 @@ + + + + + + + + + + +]]> + + + + + + +]]> + + + + + + + + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-datatypes-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-datatypes-1.mod new file mode 100644 index 000000000..a2ea3ae85 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-datatypes-1.mod @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-datatypes-1.mod.1 b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-datatypes-1.mod.1 new file mode 100644 index 000000000..a2ea3ae85 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-datatypes-1.mod.1 @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-edit-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-edit-1.mod new file mode 100644 index 000000000..2d3d43f1c --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-edit-1.mod @@ -0,0 +1,66 @@ + + + + + + + + + + + + +]]> + + + +]]> + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-events-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-events-1.mod new file mode 100644 index 000000000..ad8a798cf --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-events-1.mod @@ -0,0 +1,135 @@ + + + + + + + + + + +]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-form-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-form-1.mod new file mode 100644 index 000000000..98b0b926a --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-form-1.mod @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-framework-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-framework-1.mod new file mode 100644 index 000000000..f37976a6c --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-framework-1.mod @@ -0,0 +1,97 @@ + + + + + + + + +%xhtml-arch.mod;]]> + + + +%xhtml-notations.mod;]]> + + + +%xhtml-datatypes.mod;]]> + + + +%xhtml-xlink.mod; + + + +%xhtml-qname.mod;]]> + + + +%xhtml-events.mod;]]> + + + +%xhtml-attribs.mod;]]> + + + +%xhtml-model.redecl; + + + +%xhtml-model.mod;]]> + + + +%xhtml-charent.mod;]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-hypertext-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-hypertext-1.mod new file mode 100644 index 000000000..85d8348fb --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-hypertext-1.mod @@ -0,0 +1,54 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-image-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-image-1.mod new file mode 100644 index 000000000..7eea4f9a2 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-image-1.mod @@ -0,0 +1,51 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlphras-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlphras-1.mod new file mode 100644 index 000000000..ebada109f --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlphras-1.mod @@ -0,0 +1,203 @@ + + + + + + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlpres-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlpres-1.mod new file mode 100644 index 000000000..3e41322c4 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlpres-1.mod @@ -0,0 +1,138 @@ + + + + + + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlstruct-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlstruct-1.mod new file mode 100644 index 000000000..4d6bd01a6 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlstruct-1.mod @@ -0,0 +1,62 @@ + + + + + + + + + + + + + +]]> + + + +]]> + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlstyle-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlstyle-1.mod new file mode 100644 index 000000000..6d526cd17 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-inlstyle-1.mod @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-lat1.ent b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-lat1.ent new file mode 100644 index 000000000..ffee223eb --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-lat1.ent @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-link-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-link-1.mod new file mode 100644 index 000000000..4a15f1dd8 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-link-1.mod @@ -0,0 +1,59 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-list-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-list-1.mod new file mode 100644 index 000000000..72bdb25c5 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-list-1.mod @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-meta-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-meta-1.mod new file mode 100644 index 000000000..d2f6d2c65 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-meta-1.mod @@ -0,0 +1,47 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-notations-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-notations-1.mod new file mode 100644 index 000000000..2da12d023 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-notations-1.mod @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-object-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-object-1.mod new file mode 100644 index 000000000..bee7aeb02 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-object-1.mod @@ -0,0 +1,60 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-param-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-param-1.mod new file mode 100644 index 000000000..4ba079160 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-param-1.mod @@ -0,0 +1,48 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-pres-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-pres-1.mod new file mode 100644 index 000000000..42a0d6dfe --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-pres-1.mod @@ -0,0 +1,38 @@ + + + + + + + + +%xhtml-inlpres.mod;]]> + + + +%xhtml-blkpres.mod;]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-qname-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-qname-1.mod new file mode 100644 index 000000000..35c180a68 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-qname-1.mod @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +]]> + + + + +%xhtml-qname-extra.mod; + + + + + + + + + +]]> + + + + + + + + + + + + + + + + + + + +]]> + + + + + + + +]]> + + + + +%xhtml-qname.redecl; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-script-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-script-1.mod new file mode 100644 index 000000000..0152ab02f --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-script-1.mod @@ -0,0 +1,67 @@ + + + + + + + + + + + + +]]> + + + +]]> + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-special.ent b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-special.ent new file mode 100644 index 000000000..ca358b2fe --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-special.ent @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-ssismap-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-ssismap-1.mod new file mode 100644 index 000000000..45da878fd --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-ssismap-1.mod @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-struct-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-struct-1.mod new file mode 100644 index 000000000..c826f0f07 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-struct-1.mod @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + +]]> + + + +]]> + + + + + + + +]]> + + + + + + +]]> + + + + + + + +]]> + + + +]]> + + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-style-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-style-1.mod new file mode 100644 index 000000000..dc85a9e6e --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-style-1.mod @@ -0,0 +1,48 @@ + + + + + + + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-symbol.ent b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-symbol.ent new file mode 100644 index 000000000..63c2abfa6 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-symbol.ent @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-symbol.ent.1 b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-symbol.ent.1 new file mode 100644 index 000000000..63c2abfa6 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-symbol.ent.1 @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-table-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-table-1.mod new file mode 100644 index 000000000..540b7346e --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-table-1.mod @@ -0,0 +1,333 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + + + + + + + +]]> + + + +]]> + + + + + + +]]> + + + +]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-text-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-text-1.mod new file mode 100644 index 000000000..a461e1e14 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml-text-1.mod @@ -0,0 +1,52 @@ + + + + + + + + +%xhtml-inlstruct.mod;]]> + + + +%xhtml-inlphras.mod;]]> + + + +%xhtml-blkstruct.mod;]]> + + + +%xhtml-blkphras.mod;]]> + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml11-model-1.mod b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml11-model-1.mod new file mode 100644 index 000000000..eb834f3d3 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml-modularization/DTD/xhtml11-model-1.mod @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent new file mode 100644 index 000000000..ffee223eb --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-special.ent b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-special.ent new file mode 100644 index 000000000..ca358b2fe --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-special.ent @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent new file mode 100644 index 000000000..63c2abfa6 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd new file mode 100644 index 000000000..2927b9ece --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd @@ -0,0 +1,978 @@ + + + + + +%HTMLlat1; + + +%HTMLsymbol; + + +%HTMLspecial; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd new file mode 100644 index 000000000..628f27ac5 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd @@ -0,0 +1,1201 @@ + + + + + +%HTMLlat1; + + +%HTMLsymbol; + + +%HTMLspecial; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml11/DTD/xhtml11.dtd b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml11/DTD/xhtml11.dtd new file mode 100644 index 000000000..2a999b5b3 --- /dev/null +++ b/epublib/src/main/resources/dtd/www.w3.org/TR/xhtml11/DTD/xhtml11.dtd @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +]]> + + + + + + +%xhtml-inlstyle.mod;]]> + + + + + + + +%xhtml-framework.mod;]]> + + + + +]]> + + + + +%xhtml-text.mod;]]> + + + + +%xhtml-hypertext.mod;]]> + + + + +%xhtml-list.mod;]]> + + + + + + +%xhtml-edit.mod;]]> + + + + +%xhtml-bdo.mod;]]> + + + + + + +%xhtml-ruby.mod;]]> + + + + +%xhtml-pres.mod;]]> + + + + +%xhtml-link.mod;]]> + + + + +%xhtml-meta.mod;]]> + + + + +%xhtml-base.mod;]]> + + + + +%xhtml-script.mod;]]> + + + + +%xhtml-style.mod;]]> + + + + +%xhtml-image.mod;]]> + + + + +%xhtml-csismap.mod;]]> + + + + +%xhtml-ssismap.mod;]]> + + + + +%xhtml-param.mod;]]> + + + + +%xhtml-object.mod;]]> + + + + +%xhtml-table.mod;]]> + + + + +%xhtml-form.mod;]]> + + + + +%xhtml-legacy.mod;]]> + + + + +%xhtml-struct.mod;]]> + + + diff --git a/epublib/src/main/resources/log4j.properties b/epublib/src/main/resources/log4j.properties new file mode 100644 index 000000000..bdfcdfe7f --- /dev/null +++ b/epublib/src/main/resources/log4j.properties @@ -0,0 +1,55 @@ +#------------------------------------------------------------------------------ +# +# The following properties set the logging levels and log appender. The +# log4j.rootCategory variable defines the default log level and one or more +# appenders. For the console, use 'S'. For the daily rolling file, use 'R'. +# For an HTML formatted log, use 'H'. +# +# To override the default (rootCategory) log level, define a property of the +# form (see below for available values): +# +# log4j.logger. = +# +# Available logger names: +# TODO +# +# Possible Log Levels: +# FATAL, ERROR, WARN, INFO, DEBUG +# +#------------------------------------------------------------------------------ +log4j.rootCategory=INFO, S + +#------------------------------------------------------------------------------ +# +# The following properties configure the console (stdout) appender. +# See http://logging.apache.org/log4j/docs/api/index.html for details. +# +#------------------------------------------------------------------------------ +log4j.appender.S = org.apache.log4j.ConsoleAppender +log4j.appender.S.layout = org.apache.log4j.PatternLayout +log4j.appender.S.layout.ConversionPattern = %d{yyyy-MM-dd HH:mm:ss} [%p] %l %m%n + +#------------------------------------------------------------------------------ +# +# The following properties configure the Daily Rolling File appender. +# See http://logging.apache.org/log4j/docs/api/index.html for details. +# +#------------------------------------------------------------------------------ +log4j.appender.R = org.apache.log4j.DailyRollingFileAppender +log4j.appender.R.File = logs/epublib.log +log4j.appender.R.Append = true +log4j.appender.R.DatePattern = '.'yyy-MM-dd +log4j.appender.R.layout = org.apache.log4j.PatternLayout +log4j.appender.R.layout.ConversionPattern = %d{yyyy-MM-dd HH:mm:ss} %c{1} [%p] %m%n + +#------------------------------------------------------------------------------ +# +# The following properties configure the Rolling File appender in HTML. +# See http://logging.apache.org/log4j/docs/api/index.html for details. +# +#------------------------------------------------------------------------------ +log4j.appender.H = org.apache.log4j.RollingFileAppender +log4j.appender.H.File = logs/epublib_log.html +log4j.appender.H.MaxFileSize = 100KB +log4j.appender.H.Append = false +log4j.appender.H.layout = org.apache.log4j.HTMLLayout \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..808c2c997 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +android.enableResourceOptimizations=true + +CronetVersion=96.0.4664.104 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..f6b961fd5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..4a88e2259 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri May 07 15:24:46 CST 2021 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..cccdd3d51 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/package.json b/package.json new file mode 100644 index 000000000..625712385 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "legado", + "version": "1.0.0", + "devDependencies": { + "cz-conventional-changelog": "^3.1.0" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "description": "[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/gedoor/legado.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/gedoor/legado/issues" + }, + "homepage": "https://github.com/gedoor/legado#readme" +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..6e5b9d8ee --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app',':epublib'