diff --git a/ios/InkNest.xcodeproj/project.pbxproj b/ios/InkNest.xcodeproj/project.pbxproj
index bdd00ce3..4b2bd106 100644
--- a/ios/InkNest.xcodeproj/project.pbxproj
+++ b/ios/InkNest.xcodeproj/project.pbxproj
@@ -379,14 +379,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-frameworks.sh\"\n";
@@ -401,8 +397,6 @@
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "[CP-User] [RNGoogleMobileAds] Configuration";
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_PROJECT_ABBREVIATION=\"RNGoogleMobileAds\"\n_JSON_ROOT=\"'react-native-google-mobile-ads'\"\n_JSON_FILE_NAME='app.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n_PACKAGE_JSON_NAME='package.json'\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -KU -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> ${_PROJECT_ABBREVIATION} build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -KU -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, app.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"google_mobile_ads_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.delay_app_measurement_init\n _DELAY_APP_MEASUREMENT=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"delay_app_measurement_init\")\n if [[ $_DELAY_APP_MEASUREMENT == \"true\" ]]; then\n _PLIST_ENTRY_KEYS+=(\"GADDelayAppMeasurementInit\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"YES\")\n fi\n\n # config.ios_app_id\n _IOS_APP_ID=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"ios_app_id\")\n if [[ $_IOS_APP_ID ]]; then\n _PLIST_ENTRY_KEYS+=(\"GADApplicationIdentifier\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_IOS_APP_ID\")\n fi\n\n # config.sk_ad_network_items\n _SK_AD_NETWORK_ITEMS=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"sk_ad_network_items\")\n if [[ $_SK_AD_NETWORK_ITEMS ]]; then\n _PLIST_ENTRY_KEYS+=(\"SKAdNetworkItems\")\n _PLIST_ENTRY_TYPES+=(\"array\")\n _PLIST_ENTRY_VALUES+=(\"\")\n\n oldifs=$IFS\n IFS=\"\n\"\n array=($(echo \"$_SK_AD_NETWORK_ITEMS\"))\n IFS=$oldifs\n for i in \"${!array[@]}\"; do\n _PLIST_ENTRY_KEYS+=(\"SKAdNetworkItems:$i:SKAdNetworkIdentifier\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"${array[i]}\") \n done\n fi\n\n # config.user_tracking_usage_description\n _USER_TRACKING_USAGE_DESCRIPTION=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"user_tracking_usage_description\")\n if [[ $_USER_TRACKING_USAGE_DESCRIPTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"NSUserTrackingUsageDescription\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_USER_TRACKING_USAGE_DESCRIPTION\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"google_mobile_ads_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A ${_JSON_FILE_NAME} file was not found, whilst this file is optional it is recommended to include it to auto-configure services.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nif ! [[ -f \"${_TARGET_PLIST}\" ]]; then\n echo \"error: unable to locate Info.plist to set properties. App will crash without GADApplicationIdentifier set.\"\n exit 1\nfi\n\nif ! [[ $_IOS_APP_ID ]]; then\n echo \"warning: ios_app_id key not found in react-native-google-mobile-ads key in app.json. App will crash without it.\"\n echo \" You can safely ignore this warning if you are using our Expo config plugin.\"\n exit 0\nfi\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- ${_PROJECT_ABBREVIATION} build script finished\"\n";
@@ -416,8 +410,6 @@
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "[CP-User] [RNFB] Core Configuration";
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n";
@@ -474,14 +466,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-frameworks.sh\"\n";
@@ -497,8 +485,6 @@
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "[CP-User] [RNFB] Crashlytics Configuration";
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n";
@@ -511,14 +497,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-resources-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-resources.sh\"\n";
@@ -532,14 +514,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-resources-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-resources.sh\"\n";
@@ -774,7 +752,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
- OTHER_LDFLAGS = "$(inherited) ";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ " ",
+ );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -854,7 +835,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
- OTHER_LDFLAGS = "$(inherited) ";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ " ",
+ );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
diff --git a/ios/Podfile b/ios/Podfile
index ace878eb..9c8e5616 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -64,5 +64,20 @@ target 'InkNest' do
:mac_catalyst_enabled => false,
# :ccache_enabled => true
)
+
+ # Patch fmt base.h for Apple Clang 21+ (Xcode 26) consteval compatibility.
+ # Apple Clang 21 (__apple_build_version__ >= 21000000) has stricter consteval
+ # enforcement that breaks fmt 11.x. fmt only excludes < 14000029 (Clang < 14).
+ fmt_base_h = File.join(installer.sandbox.root, 'fmt/include/fmt/base.h')
+ if File.exist?(fmt_base_h)
+ content = File.read(fmt_base_h)
+ old_check = '#elif defined(__apple_build_version__) && __apple_build_version__ < 14000029L'
+ new_check = '#elif defined(__apple_build_version__) && (__apple_build_version__ < 14000029L || __apple_build_version__ >= 21000000L)'
+ if content.include?(old_check) && !content.include?(new_check)
+ File.chmod(0644, fmt_base_h)
+ File.write(fmt_base_h, content.gsub(old_check, new_check))
+ puts '[Fix] Patched fmt/base.h for Apple Clang 21+ consteval compatibility'
+ end
+ end
end
end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index cdd39eef..1ac0e517 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -4152,6 +4152,6 @@ SPEC CHECKSUMS:
UnrarKit: 62f535c7a34ec52d2514b9b148f33dcfa9a9dc39
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
-PODFILE CHECKSUM: 508b472b779be51eb204147483558e3ddf0e43ea
+PODFILE CHECKSUM: b45710cb91af671d189404d2388ad71bac78490a
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/src/Components/Func/HomeFunc.js b/src/Components/Func/HomeFunc.js
index 51f09b59..480b3729 100644
--- a/src/Components/Func/HomeFunc.js
+++ b/src/Components/Func/HomeFunc.js
@@ -77,12 +77,12 @@ export const fetchComicsData = async (link, dispatch, baseUrl) => {
.replaceAll(',', '');
}
}
- dispatch(checkDownTime(response));
+ dispatch(checkDownTime(response, baseUrl));
return {data: comicsData, lastPage};
} catch (error) {
// console.log(link, 'link');
console.log('Error fetching or parsing data Home:', error);
- if (dispatch) dispatch(checkDownTime(error));
+ if (dispatch) dispatch(checkDownTime(error, baseUrl));
return [];
}
};
@@ -155,27 +155,27 @@ export const FetchAnimeData = async (link, dispatch, baseUrl) => {
// console.log(AnimaData, 'videos');
}
- dispatch(checkDownTime(response));
+ dispatch(checkDownTime(response, baseUrl));
return AnimaData;
} catch (error) {
console.log('Error fetching or parsing data Anime Home page: ', error);
- if (dispatch) dispatch(checkDownTime(error));
+ if (dispatch) dispatch(checkDownTime(error, baseUrl));
return [];
}
};
-export const checkServerDown = async (url, dispatch) => {
+export const checkServerDown = async (url, dispatch, sourceKey = null) => {
dispatch(fetchDataStart());
try {
const response = await APICaller.get(url);
//set DownTime to false
console.log(response.status, 'response');
- dispatch(checkDownTime(response));
+ dispatch(checkDownTime(response, sourceKey));
return false;
} catch (error) {
//set DownTime to true
console.log('Error checking server down:', error.message);
- dispatch(checkDownTime(error));
+ dispatch(checkDownTime(error, sourceKey));
return true;
}
};
diff --git a/src/Components/UIComp/DownTime.js b/src/Components/UIComp/DownTime.js
index 65f7ce64..9c3c4961 100644
--- a/src/Components/UIComp/DownTime.js
+++ b/src/Components/UIComp/DownTime.js
@@ -22,9 +22,9 @@ const DownTime = () => {
title="Retry"
onPress={() => {
if (animeActive) {
- checkServerDown(AnimeHostName[baseUrl], dispatch)
+ checkServerDown(AnimeHostName[baseUrl], dispatch, baseUrl)
} else {
- checkServerDown(ComicHostName[baseUrl], dispatch)
+ checkServerDown(ComicHostName[baseUrl], dispatch, baseUrl)
}
}}
textSize={20}
diff --git a/src/Components/UIComp/SourceStatusBanner.js b/src/Components/UIComp/SourceStatusBanner.js
new file mode 100644
index 00000000..8267a7fd
--- /dev/null
+++ b/src/Components/UIComp/SourceStatusBanner.js
@@ -0,0 +1,283 @@
+import React from 'react';
+import {View, Text, StyleSheet, TouchableOpacity} from 'react-native';
+import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
+import {SOURCE_STATUS, formatLastChecked} from '../../Utils/sourceStatus';
+
+/**
+ * Inline banner component to show source status errors
+ * Use this for immediate feedback when a source returns 403 or 500+
+ */
+const SourceStatusBanner = ({
+ sourceKey,
+ sourceName,
+ status,
+ statusCode,
+ lastChecked,
+ onDismiss,
+ onSwitchSource,
+ showActions = true,
+}) => {
+ if (!status || status === SOURCE_STATUS.WORKING) {
+ return null;
+ }
+
+ const getStatusConfig = () => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return {
+ icon: 'shield-alert',
+ color: '#FF9500',
+ backgroundColor: 'rgba(255, 149, 0, 0.15)',
+ title: 'Cloudflare Protection',
+ message: `${sourceName || sourceKey} is blocking requests due to cloudflare bot protection.`,
+ };
+ case SOURCE_STATUS.SERVER_DOWN:
+ return {
+ icon: 'server-off',
+ color: '#FF3B30',
+ backgroundColor: 'rgba(255, 59, 48, 0.15)',
+ title: 'Server Down',
+ message: `${sourceName || sourceKey} server is not responding.`,
+ };
+ default:
+ return {
+ icon: 'alert-circle',
+ color: '#8E8E93',
+ backgroundColor: 'rgba(142, 142, 147, 0.15)',
+ title: 'Source Error',
+ message: `${sourceName || sourceKey} is experiencing issues.`,
+ };
+ }
+ };
+
+ const config = getStatusConfig();
+
+ return (
+
+
+
+
+
+
+
+
+ {config.title}
+
+ {statusCode && (
+ ({statusCode})
+ )}
+
+ {config.message}
+ {lastChecked && (
+
+ Last checked: {formatLastChecked(lastChecked)}
+
+ )}
+
+
+ {showActions && (
+
+ {onSwitchSource && (
+
+
+
+ )}
+ {onDismiss && (
+
+
+
+ )}
+
+ )}
+
+ );
+};
+
+/**
+ * Compact version for use in headers or smaller spaces
+ */
+export const SourceStatusBadge = ({status, onPress}) => {
+ if (!status || status === SOURCE_STATUS.WORKING) {
+ return null;
+ }
+
+ const getColor = () => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return '#FF9500';
+ case SOURCE_STATUS.SERVER_DOWN:
+ return '#FF3B30';
+ default:
+ return '#8E8E93';
+ }
+ };
+
+ return (
+
+
+
+ {status === SOURCE_STATUS.CLOUDFLARE_PROTECTED ? '403' : 'Down'}
+
+
+ );
+};
+
+/**
+ * Toast-style notification for source errors
+ */
+export const SourceStatusToast = ({
+ visible,
+ sourceName,
+ status,
+ onDismiss,
+ autoHide = true,
+ duration = 5000,
+}) => {
+ React.useEffect(() => {
+ if (visible && autoHide && onDismiss) {
+ const timer = setTimeout(onDismiss, duration);
+ return () => clearTimeout(timer);
+ }
+ }, [visible, autoHide, duration, onDismiss]);
+
+ if (!visible) {
+ return null;
+ }
+
+ const getMessage = () => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return `${sourceName}: Cloudflare protection active (403)`;
+ case SOURCE_STATUS.SERVER_DOWN:
+ return `${sourceName}: Server is down`;
+ default:
+ return `${sourceName}: Error`;
+ }
+ };
+
+ const getColor = () => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return '#FF9500';
+ case SOURCE_STATUS.SERVER_DOWN:
+ return '#FF3B30';
+ default:
+ return '#8E8E93';
+ }
+ };
+
+ return (
+
+
+ {getMessage()}
+ {onDismiss && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 12,
+ borderRadius: 8,
+ marginHorizontal: 16,
+ marginVertical: 8,
+ },
+ iconContainer: {
+ marginRight: 12,
+ },
+ contentContainer: {
+ flex: 1,
+ },
+ titleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 2,
+ },
+ title: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ errorCode: {
+ fontSize: 12,
+ color: '#8E8E93',
+ marginLeft: 6,
+ },
+ message: {
+ fontSize: 13,
+ color: '#ABABAB',
+ lineHeight: 18,
+ },
+ lastChecked: {
+ fontSize: 11,
+ color: '#636366',
+ marginTop: 4,
+ },
+ actionsContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginLeft: 8,
+ },
+ switchButton: {
+ padding: 8,
+ marginRight: 4,
+ },
+ dismissButton: {
+ padding: 8,
+ },
+ // Badge styles
+ badgeContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 12,
+ borderWidth: 1,
+ gap: 4,
+ },
+ badgeText: {
+ fontSize: 11,
+ fontWeight: '600',
+ },
+ // Toast styles
+ toastContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#2C2C2E',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ marginHorizontal: 16,
+ borderLeftWidth: 4,
+ shadowColor: '#000',
+ shadowOffset: {width: 0, height: 2},
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5,
+ },
+ toastMessage: {
+ flex: 1,
+ fontSize: 14,
+ color: '#FFFFFF',
+ marginLeft: 12,
+ },
+ toastDismiss: {
+ padding: 4,
+ marginLeft: 8,
+ },
+});
+
+export default SourceStatusBanner;
\ No newline at end of file
diff --git a/src/Components/UIComp/SourceStatusNotification.js b/src/Components/UIComp/SourceStatusNotification.js
new file mode 100644
index 00000000..b8298448
--- /dev/null
+++ b/src/Components/UIComp/SourceStatusNotification.js
@@ -0,0 +1,287 @@
+import React from 'react';
+import {View, Text, StyleSheet, TouchableOpacity, ScrollView} from 'react-native';
+import {useSelector, useDispatch} from 'react-redux';
+import {
+ markSourceStatusNotificationRead,
+ removeSourceStatusNotification,
+ clearSourceStatusNotifications,
+} from '../../Redux/Reducers';
+import {SOURCE_STATUS, formatLastChecked} from '../../Utils/sourceStatus';
+import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
+
+/**
+ * Component to display source status notifications
+ * Shows which sources have 403 (Cloudflare) or 500+ (server down) errors
+ */
+const SourceStatusNotification = ({onClose}) => {
+ const dispatch = useDispatch();
+ const notifications = useSelector(
+ state => state.data.sourceStatusNotifications || [],
+ );
+
+ const unreadCount = notifications.filter(n => !n.read).length;
+
+ if (notifications.length === 0) {
+ return null;
+ }
+
+ const handleMarkAsRead = notificationId => {
+ dispatch(markSourceStatusNotificationRead(notificationId));
+ };
+
+ const handleDismiss = notificationId => {
+ dispatch(removeSourceStatusNotification(notificationId));
+ };
+
+ const handleClearAll = () => {
+ dispatch(clearSourceStatusNotifications());
+ if (onClose) {
+ onClose();
+ }
+ };
+
+ const getStatusIcon = status => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return 'shield-alert';
+ case SOURCE_STATUS.SERVER_DOWN:
+ return 'server-off';
+ default:
+ return 'alert-circle';
+ }
+ };
+
+ const getStatusColor = status => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return '#FF9500'; // Orange for Cloudflare
+ case SOURCE_STATUS.SERVER_DOWN:
+ return '#FF3B30'; // Red for server down
+ default:
+ return '#8E8E93';
+ }
+ };
+
+ const getStatusTitle = status => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return 'Cloudflare Protection Active';
+ case SOURCE_STATUS.SERVER_DOWN:
+ return 'Server Down';
+ default:
+ return 'Source Error';
+ }
+ };
+
+ const getStatusDescription = (status, sourceName) => {
+ switch (status) {
+ case SOURCE_STATUS.CLOUDFLARE_PROTECTED:
+ return `${sourceName} is blocking requests due to cloudflare bot protection. Try switching to another source.`;
+ case SOURCE_STATUS.SERVER_DOWN:
+ return `${sourceName} server is not responding. The source may be temporarily unavailable.`;
+ default:
+ return `${sourceName} is experiencing issues.`;
+ }
+ };
+
+ return (
+
+
+
+
+ Source Status
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+
+ Clear All
+
+
+
+
+ {notifications.map(notification => (
+ handleMarkAsRead(notification.id)}
+ activeOpacity={0.7}>
+
+
+
+
+
+
+
+ {notification.sourceName || notification.sourceKey}
+
+
+ {getStatusTitle(notification.status)}
+
+
+
+
+ {getStatusDescription(
+ notification.status,
+ notification.sourceName || notification.sourceKey,
+ )}
+
+
+
+
+ Last checked: {formatLastChecked(notification.timestamp)}
+
+ {notification.statusCode && (
+
+ Error: {notification.statusCode}
+
+ )}
+
+
+
+ handleDismiss(notification.id)}>
+
+
+
+ ))}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ backgroundColor: '#1C1C1E',
+ borderRadius: 12,
+ marginHorizontal: 16,
+ marginVertical: 8,
+ overflow: 'hidden',
+ maxHeight: 400,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: '#38383A',
+ },
+ headerLeft: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ headerTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ },
+ badge: {
+ backgroundColor: '#FF3B30',
+ borderRadius: 10,
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ minWidth: 20,
+ alignItems: 'center',
+ },
+ badgeText: {
+ color: '#FFFFFF',
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ clearButton: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ },
+ clearButtonText: {
+ color: '#0A84FF',
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ list: {
+ maxHeight: 320,
+ },
+ notificationItem: {
+ flexDirection: 'row',
+ padding: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#38383A',
+ },
+ unreadItem: {
+ backgroundColor: 'rgba(10, 132, 255, 0.1)',
+ },
+ iconContainer: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 12,
+ },
+ contentContainer: {
+ flex: 1,
+ },
+ titleRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 4,
+ },
+ sourceName: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ flex: 1,
+ },
+ statusText: {
+ fontSize: 12,
+ fontWeight: '500',
+ color: '#8E8E93',
+ marginLeft: 8,
+ },
+ description: {
+ fontSize: 13,
+ color: '#ABABAB',
+ lineHeight: 18,
+ marginBottom: 8,
+ },
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ timestamp: {
+ fontSize: 12,
+ color: '#636366',
+ },
+ errorCode: {
+ fontSize: 12,
+ color: '#FF3B30',
+ fontWeight: '500',
+ },
+ dismissButton: {
+ padding: 8,
+ marginLeft: 8,
+ },
+});
+
+export default SourceStatusNotification;
\ No newline at end of file
diff --git a/src/Constants/Navigation.js b/src/Constants/Navigation.js
index 042b8fc6..3b6be7de 100644
--- a/src/Constants/Navigation.js
+++ b/src/Constants/Navigation.js
@@ -30,4 +30,11 @@ export const NAVIGATION = {
Library: 'Library',
CreatePost: 'CreatePost',
PostDetail: 'PostDetail',
+ // Novel Navigation
+ novelHome: 'NovelHome',
+ novelDetails: 'NovelDetails',
+ novelReader: 'NovelReader',
+ novelSearch: 'NovelSearch',
+ novelLibrary: 'NovelLibrary',
+ novelViewAll: 'NovelViewAll',
};
diff --git a/src/InkNest-Externals b/src/InkNest-Externals
index fa5206a1..c90708aa 160000
--- a/src/InkNest-Externals
+++ b/src/InkNest-Externals
@@ -1 +1 @@
-Subproject commit fa5206a1bb7201f62dca39f9c37c4be3c5f9c245
+Subproject commit c90708aa5576720648ecfb753d738867d7e4d236
diff --git a/src/Navigation/AppNavigation.js b/src/Navigation/AppNavigation.js
index 39b2da11..c4a9861b 100644
--- a/src/Navigation/AppNavigation.js
+++ b/src/Navigation/AppNavigation.js
@@ -25,6 +25,14 @@ import {
import WebViewScreen from '../InkNest-Externals/Screens/Webview/WebViewScreen';
import WebSearchScreen from '../InkNest-Externals/Screens/Webview/WebSearchScreen';
import NotificationsScreen from '../Screens/Notifications/NotificationsScreen';
+import {
+ NovelHome,
+ NovelDetails,
+ NovelReader,
+ NovelSearch,
+ NovelLibrary,
+ NovelViewAll,
+} from '../Screens/Novel';
const Stack = createNativeStackNavigator();
@@ -79,6 +87,13 @@ export function AppNavigation() {
name={NAVIGATION.notifications}
component={NotificationsScreen}
/>
+ {/* Novel Screens */}
+
+
+
+
+
+
);
}
diff --git a/src/Navigation/index.js b/src/Navigation/index.js
index f1267a86..217751c7 100644
--- a/src/Navigation/index.js
+++ b/src/Navigation/index.js
@@ -240,7 +240,7 @@ export function RootNavigation() {
// We still persist to storage for background/offline safety,
// but we don't rely on it as the source of truth for the UI anymore.
// The 'consumeStoredNotifications' will pick this up if the app restarts.
- persistNotificationList(deduped).catch(() => { });
+ persistNotificationList(deduped);
dispatch(appendNotification(parsedPayload));
diff --git a/src/Redux/Actions/GlobalActions.js b/src/Redux/Actions/GlobalActions.js
index 356db3dd..8032f4e7 100644
--- a/src/Redux/Actions/GlobalActions.js
+++ b/src/Redux/Actions/GlobalActions.js
@@ -133,7 +133,8 @@ export const fetchComicDetails =
dispatch(WatchedData(watchedData));
dispatch(fetchDataSuccess({url: link, data: comicDetails}));
} catch (error) {
- handleAPIError(error, dispatch, crashlytics, 'fetchComicDetails');
+ const hostkey = getHostKeyFromUrl(link, ComicDetailPageClasses);
+ handleAPIError(error, dispatch, crashlytics, 'fetchComicDetails', hostkey);
dispatch(ClearError());
dispatch(fetchDataFailure('Not Found'));
goBack();
@@ -223,7 +224,7 @@ export const fetchComicBook =
return {url: originalComicBook, data};
}
} catch (error) {
- handleAPIError(error, dispatch, crashlytics, 'fetchComicBook');
+ handleAPIError(error, dispatch, crashlytics, 'fetchComicBook', hostkey);
dispatch(fetchDataFailure(error.message));
}
};
@@ -282,11 +283,11 @@ export const getAdvancedSearchFilters = (source = 'readcomicsonline') => async d
const filters = parseAdvancedSearchFilters($, config);
- dispatch(checkDownTime({filters}));
+ dispatch(checkDownTime({filters}, source));
return filters;
} catch (error) {
- handleAPIError(error, dispatch, crashlytics, 'getAdvancedSearchFilters');
+ handleAPIError(error, dispatch, crashlytics, 'getAdvancedSearchFilters', source);
dispatch(fetchDataFailure(error.message));
return null;
}
@@ -320,10 +321,10 @@ export const searchComic =
dispatch(fetchDataSuccess({url, data: formatted}));
dispatch(StopLoading());
dispatch(ClearError());
- dispatch(checkDownTime());
+ dispatch(checkDownTime(null, source));
return formatted;
} catch (error) {
- handleAPIError(error, dispatch, crashlytics, 'searchComic');
+ handleAPIError(error, dispatch, crashlytics, 'searchComic', source);
dispatch(fetchDataFailure(error.message));
return null;
}
diff --git a/src/Redux/Actions/NovelActions.js b/src/Redux/Actions/NovelActions.js
new file mode 100644
index 00000000..3a58f4af
--- /dev/null
+++ b/src/Redux/Actions/NovelActions.js
@@ -0,0 +1,263 @@
+import {Alert} from 'react-native';
+import crashlytics from '@react-native-firebase/crashlytics';
+
+// Redux actions
+import {
+ fetchDataStart,
+ fetchDataSuccess,
+ fetchDataFailure,
+ clearData,
+ StopLoading,
+ ClearError,
+ pushHistory,
+ updateData,
+} from '../Reducers';
+
+// Navigation
+import {goBack} from '../../Navigation/NavigationService';
+
+// Novel APIs
+import {
+ getNovelHome,
+ getNovelDetails,
+ getNovelChapter,
+ getNovelHostKeyFromLink,
+ NovelHomePageClasses,
+ NovelDetailPageClasses,
+ NovelChapterPageClasses,
+} from '../../Screens/Novel/APIs';
+
+// Utilities
+import {checkDownTime, handleAPIError} from './utils/errorHandlers';
+import {hasLoadedChapterData, buildWatchedData} from './utils/dataHelpers';
+
+// Re-export checkDownTime for backward compatibility
+export {checkDownTime};
+
+// ============================================
+// Action Type Constants
+// ============================================
+
+export const NOVEL_ACTION_TYPES = {
+ SWITCH_NOVEL_SOURCE: 'SWITCH_NOVEL_SOURCE',
+ FETCH_NOVEL_HOME_START: 'FETCH_NOVEL_HOME_START',
+ FETCH_NOVEL_HOME_SUCCESS: 'FETCH_NOVEL_HOME_SUCCESS',
+ FETCH_NOVEL_HOME_FAILURE: 'FETCH_NOVEL_HOME_FAILURE',
+ FETCH_NOVEL_DETAILS_START: 'FETCH_NOVEL_DETAILS_START',
+ FETCH_NOVEL_DETAILS_SUCCESS: 'FETCH_NOVEL_DETAILS_SUCCESS',
+ FETCH_NOVEL_DETAILS_FAILURE: 'FETCH_NOVEL_DETAILS_FAILURE',
+ FETCH_NOVEL_CHAPTER_START: 'FETCH_NOVEL_CHAPTER_START',
+ FETCH_NOVEL_CHAPTER_SUCCESS: 'FETCH_NOVEL_CHAPTER_SUCCESS',
+ FETCH_NOVEL_CHAPTER_FAILURE: 'FETCH_NOVEL_CHAPTER_FAILURE',
+ CLEAR_NOVEL_DATA: 'CLEAR_NOVEL_DATA',
+};
+
+// ============================================
+// Action Creators
+// ============================================
+
+/**
+ * Action creator to switch the active novel source.
+ *
+ * @param {string} sourceKey - The key identifying the novel source (e.g., 'novelfire').
+ * @returns {Object} The action object with type and payload.
+ */
+export const switchNovelSource = sourceKey => ({
+ type: NOVEL_ACTION_TYPES.SWITCH_NOVEL_SOURCE,
+ payload: sourceKey,
+});
+
+/**
+ * Action creator for handling watched data.
+ *
+ * @param {Object} data - The data to be processed and dispatched.
+ * @returns {Function} A thunk function that dispatches the pushHistory action.
+ */
+export const WatchedData = data => async (dispatch, getState) => {
+ dispatch(pushHistory(data));
+};
+
+// ============================================
+// Thunk Actions
+// ============================================
+
+/**
+ * Fetches novel home data for a given source and dispatches appropriate actions.
+ *
+ * @param {string} [hostKey='novelfire'] - The host key identifying the novel source.
+ * @returns {Function} A thunk function that performs the async operation.
+ */
+export const fetchNovelHome =
+ (hostKey = 'novelfire') =>
+ async (dispatch, getState) => {
+ dispatch(fetchDataStart());
+ dispatch({type: NOVEL_ACTION_TYPES.FETCH_NOVEL_HOME_START});
+
+ try {
+ const homeData = await getNovelHome(hostKey);
+
+ dispatch(StopLoading());
+ dispatch(ClearError());
+ dispatch(checkDownTime());
+ dispatch({
+ type: NOVEL_ACTION_TYPES.FETCH_NOVEL_HOME_SUCCESS,
+ payload: {hostKey, data: homeData},
+ });
+ } catch (error) {
+ handleAPIError(error, dispatch, crashlytics, 'fetchNovelHome', hostKey);
+ dispatch(ClearError());
+ dispatch({
+ type: NOVEL_ACTION_TYPES.FETCH_NOVEL_HOME_FAILURE,
+ payload: error.message || 'Failed to fetch novel home',
+ });
+ Alert.alert('Error', 'Failed to load novel home page');
+ }
+ };
+
+/**
+ * Fetches novel details from a given link and dispatches appropriate actions.
+ *
+ * @param {string} link - The URL of the novel to fetch details for.
+ * @param {string} [hostKey] - Optional host key. If not provided, will be extracted from link.
+ * @param {boolean} [refresh=false] - Whether to refresh the data even if it exists in the state.
+ * @returns {Function} A thunk function that performs the async operation.
+ */
+export const fetchNovelDetails =
+ (link, hostKey, refresh = false) =>
+ async (dispatch, getState) => {
+ dispatch(fetchDataStart());
+ dispatch({type: NOVEL_ACTION_TYPES.FETCH_NOVEL_DETAILS_START});
+
+ try {
+ // Determine host key from link if not provided
+ const resolvedHostKey =
+ hostKey || getNovelHostKeyFromLink(link, NovelDetailPageClasses);
+
+ // Check if we can use cached data
+ const state = getState();
+ const stateData = state?.data?.dataByUrl?.[link];
+ const hasUsableStateData = hasLoadedChapterData(stateData);
+ let watchedData = buildWatchedData(stateData, link);
+
+ if (!refresh && stateData && hasUsableStateData) {
+ dispatch(StopLoading());
+ dispatch(ClearError());
+ dispatch(checkDownTime());
+ dispatch(WatchedData(watchedData));
+ return;
+ }
+
+ // Fetch fresh data
+ const novelDetails = await getNovelDetails(link, resolvedHostKey);
+ watchedData = buildWatchedData(novelDetails, link);
+
+ // Update or save data based on refresh flag
+ if (refresh) {
+ dispatch(updateData({url: link, data: novelDetails}));
+ dispatch(StopLoading());
+ dispatch(WatchedData(watchedData));
+ return;
+ }
+
+ dispatch(WatchedData(watchedData));
+ dispatch(fetchDataSuccess({url: link, data: novelDetails}));
+ dispatch({
+ type: NOVEL_ACTION_TYPES.FETCH_NOVEL_DETAILS_SUCCESS,
+ payload: {link, data: novelDetails, hostKey: resolvedHostKey},
+ });
+ } catch (error) {
+ const resolvedHostKey =
+ hostKey || getNovelHostKeyFromLink(link, NovelDetailPageClasses);
+ handleAPIError(
+ error,
+ dispatch,
+ crashlytics,
+ 'fetchNovelDetails',
+ resolvedHostKey,
+ );
+ dispatch(ClearError());
+ dispatch(fetchDataFailure('Not Found'));
+ dispatch({
+ type: NOVEL_ACTION_TYPES.FETCH_NOVEL_DETAILS_FAILURE,
+ payload: error.message || 'Failed to fetch novel details',
+ });
+ goBack();
+ Alert.alert('Error', 'Novel not found');
+ }
+ };
+
+/**
+ * Fetches novel chapter content from a given link and dispatches appropriate actions.
+ *
+ * @param {string} link - The URL of the chapter to fetch.
+ * @param {string} [hostKey] - Optional host key. If not provided, will be extracted from link.
+ * @param {boolean} [refresh=false] - Whether to refresh the data even if it exists in the state.
+ * @returns {Function} A thunk function that performs the async operation.
+ */
+export const fetchNovelChapter =
+ (link, hostKey, refresh = false) =>
+ async (dispatch, getState) => {
+ dispatch(fetchDataStart());
+ dispatch({type: NOVEL_ACTION_TYPES.FETCH_NOVEL_CHAPTER_START});
+
+ try {
+ // Determine host key from link if not provided
+ const resolvedHostKey =
+ hostKey || getNovelHostKeyFromLink(link, NovelChapterPageClasses);
+
+ // Check if we can use cached data
+ const state = getState();
+ const stateData = state?.data?.dataByUrl?.[link];
+
+ if (!refresh && stateData && stateData.content) {
+ dispatch(StopLoading());
+ dispatch(ClearError());
+ dispatch(checkDownTime());
+ return;
+ }
+
+ // Fetch fresh data
+ const chapterData = await getNovelChapter(link, resolvedHostKey);
+
+ // Update or save data based on refresh flag
+ if (refresh) {
+ dispatch(updateData({url: link, data: chapterData}));
+ dispatch(StopLoading());
+ return;
+ }
+
+ dispatch(fetchDataSuccess({url: link, data: chapterData}));
+ dispatch({
+ type: NOVEL_ACTION_TYPES.FETCH_NOVEL_CHAPTER_SUCCESS,
+ payload: {link, data: chapterData, hostKey: resolvedHostKey},
+ });
+ } catch (error) {
+ const resolvedHostKey =
+ hostKey || getNovelHostKeyFromLink(link, NovelChapterPageClasses);
+ handleAPIError(
+ error,
+ dispatch,
+ crashlytics,
+ 'fetchNovelChapter',
+ resolvedHostKey,
+ );
+ dispatch(ClearError());
+ dispatch(fetchDataFailure('Not Found'));
+ dispatch({
+ type: NOVEL_ACTION_TYPES.FETCH_NOVEL_CHAPTER_FAILURE,
+ payload: error.message || 'Failed to fetch chapter',
+ });
+ goBack();
+ Alert.alert('Error', 'Chapter not found');
+ }
+ };
+
+/**
+ * Clears all novel-related data from the store.
+ *
+ * @returns {Function} A thunk function that dispatches the clear action.
+ */
+export const clearNovelData = () => dispatch => {
+ dispatch(clearData());
+ dispatch({type: NOVEL_ACTION_TYPES.CLEAR_NOVEL_DATA});
+};
\ No newline at end of file
diff --git a/src/Redux/Actions/parsers/novelChapterParser.js b/src/Redux/Actions/parsers/novelChapterParser.js
new file mode 100644
index 00000000..72ceecd7
--- /dev/null
+++ b/src/Redux/Actions/parsers/novelChapterParser.js
@@ -0,0 +1,1377 @@
+/**
+ * Novel chapter content parsing utilities
+ * Generic parser for extracting chapter content from various novel sources
+ */
+
+/**
+ * Cleans HTML entities and normalizes text
+ *
+ * @param {string} text - Text to clean
+ * @returns {string} - Cleaned text
+ */
+const cleanText = (text) => {
+ if (!text) {
+ return '';
+ }
+ return text
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim();
+};
+
+/**
+ * Base64 decode helper for React Native environments
+ * @param {string} str - Base64 encoded string
+ * @returns {string} - Decoded string
+ */
+const base64Decode = (str) => {
+ try {
+ // Try using the global atob if available
+ if (typeof atob !== 'undefined') {
+ return atob(str);
+ }
+ // Manual base64 decode as fallback
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
+ let output = '';
+ let i = 0;
+ str = str.replace(/[^A-Za-z0-9\+\/\=]/g, '');
+ while (i < str.length) {
+ const enc1 = chars.indexOf(str.charAt(i++));
+ const enc2 = chars.indexOf(str.charAt(i++));
+ const enc3 = chars.indexOf(str.charAt(i++));
+ const enc4 = chars.indexOf(str.charAt(i++));
+ const chr1 = (enc1 << 2) | (enc2 >> 4);
+ const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
+ const chr3 = ((enc3 & 3) << 6) | enc4;
+ output += String.fromCharCode(chr1);
+ if (enc3 !== 64) output += String.fromCharCode(chr2);
+ if (enc4 !== 64) output += String.fromCharCode(chr3);
+ }
+ // Handle UTF-8 encoding
+ return decodeURIComponent(escape(output));
+ } catch (e) {
+ console.log('[base64Decode] Failed to decode:', e.message);
+ return str;
+ }
+};
+
+/**
+ * Convert base64 string to Uint8Array
+ * @param {string} base64 - Base64 encoded string
+ * @returns {Uint8Array} - Byte array
+ */
+const base64ToUint8Array = (base64) => {
+ const binaryString = base64Decode(base64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes;
+};
+
+/**
+ * Decrypt WTR-Lab arr: format content
+ * Format: arr:iv:authTag:ciphertext (all base64 encoded)
+ * Uses AES-GCM decryption
+ * @param {string} encryptedData - The arr: prefixed encrypted string
+ * @returns {string} - Decrypted content
+ */
+const decryptWTRLabContent = (encryptedData) => {
+ try {
+ if (!encryptedData || !encryptedData.startsWith('arr:')) {
+ return null;
+ }
+
+ console.log('[decryptWTRLabContent] Attempting to decrypt arr: format');
+
+ // Parse the arr: format
+ const parts = encryptedData.substring(4).split(':');
+ console.log('[decryptWTRLabContent] Parts count:', parts.length);
+
+ if (parts.length < 2) {
+ console.log('[decryptWTRLabContent] Invalid arr: format - not enough parts');
+ return null;
+ }
+
+ // The format appears to be: arr:iv:authTag:ciphertext
+ // Or possibly: arr:keyId:iv:ciphertext
+ const ivBase64 = parts[0];
+ const authTagOrKeyBase64 = parts[1];
+ const ciphertextBase64 = parts.slice(2).join(':'); // In case ciphertext contains colons
+
+ console.log('[decryptWTRLabContent] IV (base64):', ivBase64.substring(0, 20));
+ console.log('[decryptWTRLabContent] Auth/Key (base64):', authTagOrKeyBase64.substring(0, 20));
+ console.log('[decryptWTRLabContent] Ciphertext (base64) length:', ciphertextBase64.length);
+
+ // Try to decode the parts
+ const iv = base64ToUint8Array(ivBase64);
+ const authTagOrKey = base64ToUint8Array(authTagOrKeyBase64);
+ const ciphertext = base64ToUint8Array(ciphertextBase64);
+
+ console.log('[decryptWTRLabContent] IV bytes:', iv.length);
+ console.log('[decryptWTRLabContent] Auth/Key bytes:', authTagOrKey.length);
+ console.log('[decryptWTRLabContent] Ciphertext bytes:', ciphertext.length);
+
+ // Check if Web Crypto API is available
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
+ console.log('[decryptWTRLabContent] Web Crypto API available');
+ // This would need to be async, so we'll return null for now
+ // and handle this in a different way
+ return null;
+ }
+
+ // Try simple XOR decryption (some sites use simple XOR)
+ // This is a fallback attempt
+ console.log('[decryptWTRLabContent] Trying simple decryption methods');
+
+ // Try decoding each part as UTF-8 directly
+ for (const part of parts) {
+ try {
+ const decoded = base64Decode(part);
+ if (decoded && decoded.length > 50 && /[a-zA-Z]/.test(decoded)) {
+ console.log('[decryptWTRLabContent] Found readable content in part:', decoded.substring(0, 100));
+ return decoded;
+ }
+ } catch (e) {
+ // Ignore decode errors
+ }
+ }
+
+ // Try combining all parts and decoding
+ const combined = parts.join('');
+ try {
+ const decoded = base64Decode(combined);
+ if (decoded && decoded.length > 50) {
+ console.log('[decryptWTRLabContent] Decoded combined parts:', decoded.substring(0, 100));
+ return decoded;
+ }
+ } catch (e) {
+ // Ignore
+ }
+
+ console.log('[decryptWTRLabContent] Could not decrypt - Web Crypto API not available or key unknown');
+ return null;
+ } catch (e) {
+ console.log('[decryptWTRLabContent] Decryption error:', e.message);
+ return null;
+ }
+};
+
+/**
+ * Extracts plain text content from a selector
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {string} contentSelector - CSS selector for content container
+ * @returns {string} - Extracted plain text
+ */
+export const extractChapterText = ($, contentSelector) => {
+ const paragraphs = [];
+ const container = $(contentSelector);
+
+ if (!container.length) {
+ return '';
+ }
+
+ // Extract all paragraph text
+ container.find('p').each((_, el) => {
+ const text = $(el).text().trim();
+ if (text && text.length > 0) {
+ paragraphs.push(cleanText(text));
+ }
+ });
+
+ // If no paragraphs found, try getting direct text content
+ if (paragraphs.length === 0) {
+ const directText = container.text().trim();
+ if (directText) {
+ paragraphs.push(cleanText(directText));
+ }
+ }
+
+ return paragraphs.join('\n\n');
+};
+
+/**
+ * Extracts images from chapter content
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @param {string} config.contentSelector - CSS selector for content container
+ * @param {string} config.imageSelector - CSS selector for images (default: 'img')
+ * @param {string} config.imageAttr - Attribute to get image URL (default: 'src')
+ * @returns {Array} - Array of image objects with src and optional caption
+ */
+export const extractChapterImages = ($, config) => {
+ const {
+ contentSelector,
+ imageSelector = 'img',
+ imageAttr = 'src',
+ } = config;
+
+ const images = [];
+ const seen = new Set();
+ const container = $(contentSelector);
+
+ if (!container.length) {
+ return images;
+ }
+
+ container.find(imageSelector).each((_, el) => {
+ // Try multiple attributes for lazy-loaded images
+ const src =
+ $(el).attr(imageAttr)?.trim() ||
+ $(el).attr('data-src')?.trim() ||
+ $(el).attr('data-lazy-src')?.trim() ||
+ $(el).attr('data-original')?.trim();
+
+ if (src && !seen.has(src)) {
+ seen.add(src);
+
+ // Get alt text or caption if available
+ const alt = $(el).attr('alt')?.trim() || '';
+ const caption = $(el).next('figcaption, .caption').text().trim() || '';
+
+ images.push({
+ src,
+ alt,
+ caption: caption || alt,
+ });
+ }
+ });
+
+ return images;
+};
+
+/**
+ * Parses chapter content using standard configuration
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @param {string} config.contentSelector - CSS selector for main content
+ * @param {string} config.titleSelector - CSS selector for chapter title
+ * @param {string} config.prevSelector - CSS selector for previous chapter link
+ * @param {string} config.nextSelector - CSS selector for next chapter link
+ * @param {boolean} config.extractImages - Whether to extract images (default: false)
+ * @param {string} config.paragraphSelector - CSS selector for paragraphs (default: 'p')
+ * @returns {Object} - Parsed chapter data
+ */
+export const parseNovelChapter = ($, config) => {
+ const {
+ contentSelector = '#content, .chapter-content, article, .content',
+ titleSelector = 'h1, h2, h3, h4, .chapter-title',
+ prevSelector = '.prev, .prevchap, a[rel="prev"]',
+ nextSelector = '.next, .nextchap, a[rel="next"]',
+ extractImages = false,
+ paragraphSelector = 'p',
+ excludeSelectors = [],
+ } = config;
+
+ const content = {
+ title: '',
+ paragraphs: [],
+ text: '',
+ images: [],
+ prevChapter: null,
+ nextChapter: null,
+ };
+
+ // Extract title
+ const titleEl = $(titleSelector).first();
+ content.title = titleEl.text().trim();
+
+ // Find content container
+ const container = $(contentSelector).first();
+
+ console.log('[parseNovelChapter] contentSelector:', contentSelector);
+ console.log('[parseNovelChapter] container found:', container.length);
+ console.log('[parseNovelChapter] title:', content.title);
+
+ if (container.length) {
+ // Remove excluded elements (ads, navigation, etc.)
+ if (excludeSelectors.length > 0) {
+ excludeSelectors.forEach((selector) => {
+ container.find(selector).remove();
+ });
+ }
+
+ // Extract paragraphs
+ container.find(paragraphSelector).each((_, el) => {
+ const text = $(el).text().trim();
+ if (text && text.length > 0) {
+ content.paragraphs.push(cleanText(text));
+ }
+ });
+
+ console.log('[parseNovelChapter] paragraphs found:', content.paragraphs.length);
+
+ // Join paragraphs into text
+ content.text = content.paragraphs.join('\n\n');
+
+ // Extract images if requested
+ if (extractImages) {
+ content.images = extractChapterImages($, {
+ contentSelector,
+ imageSelector: 'img',
+ imageAttr: 'src',
+ });
+ }
+ }
+
+ // Extract navigation links
+ const prevEl = $(prevSelector).first();
+ const nextEl = $(nextSelector).first();
+
+ // Check for disabled state
+ const isPrevDisabled = prevEl.hasClass('disabled') ||
+ prevEl.hasClass('isDisabled') ||
+ prevEl.attr('disabled');
+
+ const isNextDisabled = nextEl.hasClass('disabled') ||
+ nextEl.hasClass('isDisabled') ||
+ nextEl.attr('disabled');
+
+ if (!isPrevDisabled) {
+ const prevHref = prevEl.attr('href');
+ if (prevHref && !prevHref.toLowerCase().startsWith('javascript')) {
+ content.prevChapter = prevHref;
+ }
+ }
+
+ if (!isNextDisabled) {
+ const nextHref = nextEl.attr('href');
+ if (nextHref && !nextHref.toLowerCase().startsWith('javascript')) {
+ content.nextChapter = nextHref;
+ }
+ }
+
+ return content;
+};
+
+/**
+ * Parses chapter content in paginated format
+ * Some sites split chapters across multiple pages
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @returns {Object} - Parsed chapter data with pagination info
+ */
+export const parsePaginatedChapter = ($, config) => {
+ const {
+ // contentSelector is available for future use
+ paginationSelector = '.pagination, .page-nav',
+ currentPageSelector = '.current, .active',
+ } = config;
+
+ const baseContent = parseNovelChapter($, { ...config, extractImages: true });
+
+ // Extract pagination info
+ const pagination = {
+ currentPage: 1,
+ totalPages: 1,
+ hasNext: false,
+ hasPrev: false,
+ pages: [],
+ };
+
+ const paginationEl = $(paginationSelector);
+
+ if (paginationEl.length) {
+ // Find current page
+ const currentEl = paginationEl.find(currentPageSelector).first();
+ if (currentEl.length) {
+ pagination.currentPage = parseInt(currentEl.text().trim(), 10) || 1;
+ }
+
+ // Find all page links
+ paginationEl.find('a').each((_, el) => {
+ const href = $(el).attr('href');
+ const pageNum = parseInt($(el).text().trim(), 10);
+ if (href && pageNum) {
+ pagination.pages.push({ number: pageNum, url: href });
+ pagination.totalPages = Math.max(pagination.totalPages, pageNum);
+ }
+ });
+
+ // Check for next/prev
+ pagination.hasNext = pagination.currentPage < pagination.totalPages;
+ pagination.hasPrev = pagination.currentPage > 1;
+ }
+
+ return {
+ ...baseContent,
+ pagination,
+ };
+};
+
+/**
+ * Parses chapter content with HTML preserved
+ * Useful for chapters with complex formatting
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @returns {Object} - Parsed chapter data with HTML content
+ */
+export const parseChapterWithHtml = ($, config) => {
+ const { contentSelector = '#content, .chapter-content, article' } = config;
+
+ const baseContent = parseNovelChapter($, config);
+
+ // Get raw HTML content
+ const container = $(contentSelector).first();
+ const htmlContent = container.html() || '';
+
+ return {
+ ...baseContent,
+ html: htmlContent,
+ };
+};
+
+/**
+ * Creates the chapter data object for storage
+ *
+ * @param {Object} parsedData - Parsed data from parser functions
+ * @param {Object} metadata - Additional metadata (chapter number, link, etc.)
+ * @returns {Object} - Complete chapter data object
+ */
+export const createChapterData = (parsedData, metadata = {}) => ({
+ title: parsedData.title || '',
+ content: parsedData.text || '',
+ paragraphs: parsedData.paragraphs || [],
+ images: parsedData.images || [],
+ prevChapter: parsedData.prevChapter || null,
+ nextChapter: parsedData.nextChapter || null,
+ chapterNumber: metadata.chapterNumber || 0,
+ link: metadata.link || '',
+ lastReadPosition: 0,
+ downloadedAt: new Date().toISOString(),
+});
+
+/**
+ * Parses NovelFire chapter format specifically
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @returns {Object} - Parsed chapter data
+ */
+export const parseNovelFireChapter = ($, config = {}) => {
+ return parseNovelChapter($, {
+ contentSelector: '#content, .chapter-content',
+ titleSelector: 'h4, h1.chapter-title, .chapter-title',
+ prevSelector: '.prevchap, a.prev',
+ nextSelector: '.nextchap, a.next',
+ excludeSelectors: ['.ads', '.advertisement', '.ad-container'],
+ ...config,
+ });
+};
+
+/**
+ * Parses ReadLightNovel chapter format
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @returns {Object} - Parsed chapter data
+ */
+export const parseReadLightNovelChapter = ($, config = {}) => {
+ return parseNovelChapter($, {
+ contentSelector: '.chapter-content, .text-content',
+ titleSelector: 'h1, h2.chapter-title',
+ prevSelector: '.prev a, a[rel="prev"]',
+ nextSelector: '.next a, a[rel="next"]',
+ excludeSelectors: ['.ads', '.ad-block'],
+ ...config,
+ });
+};
+
+const normalizeWTRLabParagraphs = rawContent => {
+ if (Array.isArray(rawContent)) {
+ return rawContent
+ .flatMap(entry => {
+ if (typeof entry === 'string') {
+ return entry;
+ }
+
+ if (entry && typeof entry === 'object') {
+ return (
+ entry.text ||
+ entry.content ||
+ entry.body ||
+ entry.value ||
+ ''
+ );
+ }
+
+ return '';
+ })
+ .map(text => cleanText(String(text || '').trim()))
+ .filter(Boolean);
+ }
+
+ if (typeof rawContent !== 'string' || rawContent.length === 0) {
+ return [];
+ }
+
+ const normalizedText = rawContent
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n\n')
+ .replace(/<\/div>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\\n/g, '\n')
+ .replace(/\r\n/g, '\n');
+
+ return normalizedText
+ .split(/\n\n+|\n+/)
+ .map(paragraph => cleanText(paragraph.trim()))
+ .filter(Boolean);
+};
+
+/**
+ * WTR-Lab specific chapter parser
+ * WTR-Lab has a unique structure where content may be in different containers
+ *
+ * @param {Object} $ - Cheerio instance
+ * @param {Object} config - Parser configuration
+ * @param {Object} readerApiData - Data from the WTR-Lab reader API (optional)
+ * @param {Object} chapterMetadata - Chapter metadata from __NEXT_DATA__ (optional)
+ * @returns {Object} - Parsed chapter data
+ */
+export const parseWTRLabChapter = ($, config = {}, readerApiData = null, chapterMetadata = null) => {
+ console.log('[parseWTRLabChapter] Starting WTR-Lab chapter parsing');
+ console.log('[parseWTRLabChapter] readerApiData:', readerApiData ? 'provided' : 'not provided');
+ console.log('[parseWTRLabChapter] chapterMetadata:', chapterMetadata ? 'provided' : 'not provided');
+
+ const content = {
+ title: '',
+ paragraphs: [],
+ text: '',
+ images: [],
+ prevChapter: null,
+ nextChapter: null,
+ };
+
+ // If we have reader API data, use it directly
+ if (readerApiData) {
+ console.log('[parseWTRLabChapter] Processing reader API data');
+ console.log('[parseWTRLabChapter] readerApiData keys:', Object.keys(readerApiData));
+
+ // Log the full response for debugging
+ console.log('[parseWTRLabChapter] Full readerApiData:', JSON.stringify(readerApiData).substring(0, 2000));
+
+ // The reader API returns: { success, chapter, data: { data: { body: "arr:..." } }, tasks }
+ // The body field contains encrypted content with format: arr:base64:base64:base64...
+ let chapterText = null;
+
+ // Check for nested data.data.body structure (WTR-Lab specific)
+ if (readerApiData.data?.data?.body) {
+ console.log('[parseWTRLabChapter] Found data.data.body field');
+ const bodyValue = readerApiData.data.data.body;
+ console.log(
+ '[parseWTRLabChapter] body value type:',
+ Array.isArray(bodyValue) ? 'array' : typeof bodyValue,
+ );
+ console.log('[parseWTRLabChapter] body value preview:', String(bodyValue).substring(0, 200));
+
+ // Check if it's the arr: encrypted format
+ if (typeof bodyValue === 'string' && bodyValue.startsWith('arr:')) {
+ console.log('[parseWTRLabChapter] Found arr: encrypted format');
+
+ // Try to decrypt the arr: format
+ const decrypted = decryptWTRLabContent(bodyValue);
+ if (decrypted) {
+ console.log('[parseWTRLabChapter] Successfully decrypted arr: content');
+ chapterText = decrypted;
+ } else {
+ // Fallback: try to decode each segment
+ console.log('[parseWTRLabChapter] Decryption failed, trying segment decoding');
+ const segments = bodyValue.substring(4).split(':');
+ console.log('[parseWTRLabChapter] Number of segments:', segments.length);
+
+ // Try to decode each segment
+ const decodedParts = [];
+ for (let i = 0; i < segments.length; i++) {
+ try {
+ const decoded = base64Decode(segments[i]);
+ console.log(`[parseWTRLabChapter] Segment ${i} decoded length:`, decoded.length);
+ console.log(`[parseWTRLabChapter] Segment ${i} preview:`, decoded.substring(0, 100));
+ decodedParts.push(decoded);
+ } catch (e) {
+ console.log(`[parseWTRLabChapter] Failed to decode segment ${i}:`, e.message);
+ }
+ }
+
+ // The content might be in one of the decoded segments
+ for (const part of decodedParts) {
+ if (part && part.length > 100 && !part.includes('\x00') && /[a-zA-Z]{10,}/.test(part)) {
+ console.log('[parseWTRLabChapter] Found text-like content in decoded segment');
+ chapterText = part;
+ break;
+ }
+ }
+
+ // If no text found, try combining all decoded parts
+ if (!chapterText && decodedParts.length > 0) {
+ chapterText = decodedParts.join('');
+ }
+ }
+ } else {
+ // Not arr: format, use directly. AI mode now returns an array of paragraphs.
+ chapterText = bodyValue;
+ }
+ }
+
+ // Check for data.data.content as fallback
+ if (!chapterText && readerApiData.data?.data?.content) {
+ console.log('[parseWTRLabChapter] Found data.data.content field');
+ chapterText = readerApiData.data.data.content;
+ }
+
+ // Check for 'data' field which might contain the content
+ if (!chapterText && readerApiData.data) {
+ console.log('[parseWTRLabChapter] Found data field, type:', typeof readerApiData.data);
+ if (typeof readerApiData.data === 'string') {
+ chapterText = readerApiData.data;
+ } else if (typeof readerApiData.data === 'object') {
+ // Data might be an object with content inside
+ chapterText = readerApiData.data.content ||
+ readerApiData.data.text ||
+ readerApiData.data.body ||
+ readerApiData.data.html ||
+ readerApiData.data.chapter_text;
+ console.log('[parseWTRLabChapter] data object keys:', Object.keys(readerApiData.data));
+ }
+ }
+
+ // Check other common field names
+ if (!chapterText) {
+ chapterText = readerApiData.content ||
+ readerApiData.text ||
+ readerApiData.body ||
+ readerApiData.html ||
+ readerApiData.chapter_text ||
+ readerApiData.chapter_content;
+ }
+
+ // Check for 'encrypted' or 'encoded' data that needs decoding
+ if (!chapterText && readerApiData.encrypted) {
+ console.log('[parseWTRLabChapter] Found encrypted field');
+ try {
+ const decoded = base64Decode(readerApiData.encrypted);
+ console.log('[parseWTRLabChapter] Decoded encrypted data length:', decoded.length);
+ chapterText = decoded;
+ } catch (e) {
+ console.log('[parseWTRLabChapter] Failed to decode encrypted data:', e.message);
+ }
+ }
+
+ // Check for 'encoded' field
+ if (!chapterText && readerApiData.encoded) {
+ console.log('[parseWTRLabChapter] Found encoded field');
+ try {
+ const decoded = base64Decode(readerApiData.encoded);
+ console.log('[parseWTRLabChapter] Decoded encoded data length:', decoded.length);
+ chapterText = decoded;
+ } catch (e) {
+ console.log('[parseWTRLabChapter] Failed to decode encoded data:', e.message);
+ }
+ }
+
+ // Check for 'raw' field
+ if (!chapterText && readerApiData.raw) {
+ console.log('[parseWTRLabChapter] Found raw field');
+ chapterText = readerApiData.raw;
+ }
+
+ // Check for 'result' field
+ if (!chapterText && readerApiData.result) {
+ console.log('[parseWTRLabChapter] Found result field');
+ if (typeof readerApiData.result === 'string') {
+ chapterText = readerApiData.result;
+ } else if (typeof readerApiData.result === 'object') {
+ chapterText = readerApiData.result.content ||
+ readerApiData.result.text ||
+ readerApiData.result.body ||
+ readerApiData.result.data;
+ }
+ }
+
+ // Check for 'chapter' field
+ if (!chapterText && readerApiData.chapter) {
+ console.log('[parseWTRLabChapter] Found chapter field');
+ if (typeof readerApiData.chapter === 'string') {
+ chapterText = readerApiData.chapter;
+ } else if (typeof readerApiData.chapter === 'object') {
+ chapterText = readerApiData.chapter.content ||
+ readerApiData.chapter.text ||
+ readerApiData.chapter.body ||
+ readerApiData.chapter.data;
+ }
+ }
+
+ // Check for 'html' field directly
+ if (!chapterText && readerApiData.html) {
+ console.log('[parseWTRLabChapter] Found html field');
+ chapterText = readerApiData.html;
+ }
+
+ // If we found chapter text, process it
+ const normalizedParagraphs = normalizeWTRLabParagraphs(chapterText);
+
+ if (normalizedParagraphs.length > 0) {
+ console.log(
+ '[parseWTRLabChapter] Found chapter text, paragraphs:',
+ normalizedParagraphs.length,
+ );
+ console.log(
+ '[parseWTRLabChapter] Chapter text preview:',
+ normalizedParagraphs.join('\n\n').substring(0, 300),
+ );
+
+ content.paragraphs = normalizedParagraphs;
+ content.text = content.paragraphs.join('\n\n');
+
+ console.log('[parseWTRLabChapter] Extracted from reader API - paragraphs:', content.paragraphs.length);
+ console.log('[parseWTRLabChapter] First paragraph preview:', content.paragraphs[0] ? content.paragraphs[0].substring(0, 100) : 'empty');
+ } else {
+ console.log('[parseWTRLabChapter] No chapter text found in reader API data');
+ console.log('[parseWTRLabChapter] Available fields:', Object.keys(readerApiData));
+
+ // Log all field values to find content
+ for (const [key, value] of Object.entries(readerApiData)) {
+ if (typeof value === 'string') {
+ console.log(`[parseWTRLabChapter] Field '${key}' (string):`, value.substring(0, 200));
+ } else if (typeof value === 'object' && value !== null) {
+ console.log(`[parseWTRLabChapter] Field '${key}' (object):`, JSON.stringify(value).substring(0, 200));
+ } else {
+ console.log(`[parseWTRLabChapter] Field '${key}':`, value);
+ }
+ }
+ }
+
+ // Get title from metadata or reader data
+ content.title = chapterMetadata?.title ||
+ readerApiData.title ||
+ readerApiData.chapter_title ||
+ content.title;
+
+ // If we found content, return it
+ if (content.paragraphs.length > 0) {
+ console.log('[parseWTRLabChapter] Successfully extracted from reader API');
+ return content;
+ }
+ }
+
+ // WTR-Lab is a Next.js app - try to extract content from __NEXT_DATA__
+ const nextDataScript = $('script#__NEXT_DATA__').html();
+ if (nextDataScript) {
+ try {
+ console.log('[parseWTRLabChapter] Found __NEXT_DATA__');
+ const nextData = JSON.parse(nextDataScript);
+ console.log('[parseWTRLabChapter] __NEXT_DATA__ keys:', Object.keys(nextData));
+
+ // Navigate through the Next.js data structure to find chapter content
+ const props = nextData?.props || {};
+ const pageProps = props?.pageProps || {};
+
+ console.log('[parseWTRLabChapter] pageProps keys:', Object.keys(pageProps));
+
+ // Log the FULL pageProps for debugging
+ console.log('[parseWTRLabChapter] FULL pageProps:', JSON.stringify(pageProps, null, 2).substring(0, 2000));
+
+ // WTR-Lab stores data in 'serie' key
+ const serie = pageProps?.serie || {};
+
+ console.log('[parseWTRLabChapter] serie keys:', Object.keys(serie));
+
+ // Log the FULL serie object for debugging
+ console.log('[parseWTRLabChapter] FULL serie:', JSON.stringify(serie, null, 2).substring(0, 3000));
+
+ // Look for chapter content in serie
+ if (serie) {
+ // Check for serie_data - this might contain the content
+ const serieData = serie?.serie_data || null;
+ if (serieData) {
+ console.log('[parseWTRLabChapter] serie_data keys:', Object.keys(serieData));
+ console.log('[parseWTRLabChapter] FULL serie_data:', JSON.stringify(serieData, null, 2).substring(0, 2000));
+
+ // Check for content in serie_data
+ if (serieData.data) {
+ console.log('[parseWTRLabChapter] serie_data.data keys:', Object.keys(serieData.data));
+ console.log('[parseWTRLabChapter] serie_data.data:', JSON.stringify(serieData.data, null, 2).substring(0, 2000));
+ }
+ }
+
+ // Check for chapter data
+ const chapterData = serie?.chapter || serie?.currentChapter || null;
+ console.log('[parseWTRLabChapter] chapterData:', chapterData ? 'found' : 'not found');
+
+ if (chapterData) {
+ console.log('[parseWTRLabChapter] chapterData keys:', Object.keys(chapterData));
+ console.log('[parseWTRLabChapter] FULL chapterData:', JSON.stringify(chapterData, null, 2));
+
+ // Extract title
+ content.title = chapterData.title || chapterData.name || chapterData.chapterTitle || '';
+
+ // Extract text content - WTR-Lab stores content in 'code' field
+ let chapterText = chapterData.code ||
+ chapterData.content ||
+ chapterData.text ||
+ chapterData.body ||
+ chapterData.html ||
+ '';
+
+ console.log('[parseWTRLabChapter] chapterText length:', chapterText ? chapterText.length : 0);
+ console.log('[parseWTRLabChapter] chapterText preview:', chapterText ? chapterText.substring(0, 200) : 'empty');
+
+ if (chapterText && typeof chapterText === 'string' && chapterText.length > 10) {
+ // Handle HTML entities and tags
+ chapterText = chapterText
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n\n')
+ .replace(/<\/div>/gi, '\n')
+ .replace(/<[^>]+>/g, '') // Remove remaining HTML tags
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\\n/g, '\n') // Handle escaped newlines
+ .replace(/\r\n/g, '\n');
+
+ // Split into paragraphs
+ const paragraphs = chapterText.split(/\n\n+|\n+/).filter(p => p.trim().length > 0);
+ content.paragraphs = paragraphs.map(p => cleanText(p.trim()));
+ content.text = content.paragraphs.join('\n\n');
+
+ console.log('[parseWTRLabChapter] Extracted from chapterData - paragraphs:', content.paragraphs.length);
+ console.log('[parseWTRLabChapter] First paragraph preview:', content.paragraphs[0] ? content.paragraphs[0].substring(0, 100) : 'empty');
+ return content; // Return early since we found content
+ }
+ }
+
+ // Check for API data fetched by Reader.js
+ if ($._wtrLabApiData) {
+ console.log('[parseWTRLabChapter] Found API data from Reader.js');
+ const apiData = $._wtrLabApiData;
+ console.log('[parseWTRLabChapter] API data keys:', Object.keys(apiData));
+
+ // Try to extract content from API response
+ let apiContent = apiData.content || apiData.text || apiData.body ||
+ apiData.code || apiData.data?.content || apiData.data?.text ||
+ apiData.chapter?.content || apiData.chapter?.text;
+
+ if (apiContent && typeof apiContent === 'string' && apiContent.length > 10) {
+ console.log('[parseWTRLabChapter] API content length:', apiContent.length);
+ console.log('[parseWTRLabChapter] API content preview:', apiContent.substring(0, 200));
+
+ // Handle HTML entities and tags
+ apiContent = apiContent
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n\n')
+ .replace(/<\/div>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\\n/g, '\n')
+ .replace(/\r\n/g, '\n');
+
+ const paragraphs = apiContent.split(/\n\n+|\n+/).filter(p => p.trim().length > 0);
+ content.paragraphs = paragraphs.map(p => cleanText(p.trim()));
+ content.text = content.paragraphs.join('\n\n');
+
+ // Get title from API if available
+ content.title = apiData.title || apiData.name || apiData.chapter?.title || content.title;
+
+ console.log('[parseWTRLabChapter] Extracted from API - paragraphs:', content.paragraphs.length);
+ return content;
+ }
+ }
+
+ // If no chapter data, check for chapters array and find current chapter
+ if (!content.text && serie?.chapters) {
+ console.log('[parseWTRLabChapter] Found chapters array, length:', serie.chapters.length);
+ }
+
+ // Check for content directly in serie
+ if (!content.text && serie?.content) {
+ console.log('[parseWTRLabChapter] Found content in serie');
+ let chapterText = serie.content;
+ if (typeof chapterText === 'string') {
+ chapterText = chapterText.replace(/
/gi, '\n').replace(/<\/p>/gi, '\n\n');
+ chapterText = chapterText.replace(/<[^>]+>/g, '');
+ const paragraphs = chapterText.split(/\n\n+|\n+/).filter(p => p.trim().length > 0);
+ content.paragraphs = paragraphs.map(p => cleanText(p.trim()));
+ content.text = content.paragraphs.join('\n\n');
+ }
+ }
+
+ // Extract title from serie if not found
+ if (!content.title && serie?.title) {
+ content.title = serie.title;
+ }
+ }
+
+ // Look for chapter content in various possible locations
+ if (!content.text) {
+ let chapterData = pageProps?.chapter ||
+ pageProps?.content ||
+ pageProps?.chapterContent ||
+ pageProps?.data?.chapter ||
+ null;
+
+ if (chapterData) {
+ console.log('[parseWTRLabChapter] Found chapterData keys:', Object.keys(chapterData));
+
+ // Extract title
+ content.title = chapterData.title || chapterData.name || chapterData.chapterTitle || '';
+
+ // Extract text content - WTR-Lab stores content in 'code' field
+ let chapterText = chapterData.code ||
+ chapterData.content ||
+ chapterData.text ||
+ chapterData.body ||
+ chapterData.html ||
+ '';
+
+ if (chapterText && typeof chapterText === 'string') {
+ // Handle HTML entities and tags
+ chapterText = chapterText
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n\n')
+ .replace(/<\/div>/gi, '\n')
+ .replace(/<[^>]+>/g, '') // Remove remaining HTML tags
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\\n/g, '\n') // Handle escaped newlines
+ .replace(/\r\n/g, '\n');
+
+ // Split into paragraphs
+ const paragraphs = chapterText.split(/\n\n+|\n+/).filter(p => p.trim().length > 0);
+ content.paragraphs = paragraphs.map(p => cleanText(p.trim()));
+ content.text = content.paragraphs.join('\n\n');
+
+ console.log('[parseWTRLabChapter] Extracted from __NEXT_DATA__ - paragraphs:', content.paragraphs.length);
+ }
+ }
+ }
+ } catch (e) {
+ console.log('[parseWTRLabChapter] Error parsing __NEXT_DATA__:', e.message);
+ }
+ }
+
+ // If we found content from __NEXT_DATA__, return it
+ if (content.text && content.paragraphs.length > 0) {
+ console.log('[parseWTRLabChapter] Successfully extracted from __NEXT_DATA__');
+ return content;
+ }
+
+ // Fallback: Try to extract from HTML directly
+ console.log('[parseWTRLabChapter] __NEXT_DATA__ not found or empty, trying HTML extraction');
+
+ // Extract title - WTR-Lab typically has title in h1 or h2
+ const titleSelectors = ['h1', 'h2', '.chapter-title', 'h3'];
+ for (const selector of titleSelectors) {
+ const titleEl = $(selector).first();
+ const titleText = titleEl.text().trim();
+ if (titleText && titleText.length > 5 && !titleText.toLowerCase().includes('wtr-lab')) {
+ content.title = titleText;
+ console.log('[parseWTRLabChapter] Found title:', content.title.substring(0, 50));
+ break;
+ }
+ }
+
+ // WTR-Lab content selectors to try - be more aggressive
+ const contentSelectors = [
+ // WTR-Lab specific - try these first
+ 'div[class*="chapter"]',
+ 'div[class*="content"]',
+ 'div[class*="reader"]',
+ 'div[class*="text"]',
+ 'div[class*="novel"]',
+ // Standard selectors
+ '.chapter-content',
+ 'article',
+ '.content',
+ '.chapter-body',
+ '.text-content',
+ 'main',
+ '#content',
+ '.chapter-text',
+ '.reader-content',
+ '.novel-content',
+ '.text-reader',
+ // Very broad selectors
+ 'section',
+ '.post',
+ '.entry-content',
+ '.post-content',
+ ];
+
+ let container = null;
+ let foundContent = false;
+
+ // Try each selector
+ for (const selector of contentSelectors) {
+ const elements = $(selector);
+ elements.each((i, el) => {
+ const $el = $(el);
+ const paragraphs = $el.find('p');
+ const text = $el.text().trim();
+ const divCount = $el.find('div').length;
+
+ // Log for debugging
+ if (text.length > 100) {
+ console.log('[parseWTRLabChapter] Selector:', selector, 'element:', i, 'text length:', text.length, 'paragraphs:', paragraphs.length, 'child divs:', divCount);
+ }
+
+ // Look for content with substantial text or paragraphs
+ // Also check if this div has direct text content (not just nested divs)
+ const directText = $el.clone().children().remove().end().text().trim();
+
+ if ((paragraphs.length >= 3 || text.length > 1000) && !foundContent) {
+ // Check if this looks like chapter content
+ const lowerText = text.toLowerCase();
+ const isNavigation = lowerText.includes('prev ch') && lowerText.includes('next ch');
+ const isFooter = lowerText.includes('copyright') && lowerText.includes('wtr-lab');
+
+ if (!isNavigation && !isFooter) {
+ container = $el;
+ console.log('[parseWTRLabChapter] Found content with selector:', selector, 'element:', i);
+ console.log('[parseWTRLabChapter] Content preview:', text.substring(0, 300));
+ foundContent = true;
+ return false; // Break the each loop
+ }
+ }
+ });
+
+ if (foundContent) break;
+ }
+
+ // If no container found with standard selectors, try to find all paragraphs in body
+ if (!foundContent) {
+ console.log('[parseWTRLabChapter] No standard container found, trying body paragraphs');
+
+ // Get all paragraphs from body, excluding navigation and footer
+ const allParagraphs = $('body').find('p').not('footer p').not('nav p').not('.footer p').not('.nav p');
+
+ console.log('[parseWTRLabChapter] Found body paragraphs:', allParagraphs.length);
+
+ // Log each paragraph for debugging
+ allParagraphs.each((i, el) => {
+ const text = $(el).text().trim();
+ if (text.length > 20) {
+ console.log('[parseWTRLabChapter] Paragraph', i, ':', text.substring(0, 100));
+ }
+ });
+
+ // Filter out short paragraphs and navigation text
+ // WTR-Lab has many short paragraphs, so use a lower threshold
+ const validParagraphs = [];
+ allParagraphs.each((_, el) => {
+ const text = $(el).text().trim();
+ // Accept paragraphs > 5 characters that aren't navigation
+ // WTR-Lab has short sentences like "hot.", "It's hot."
+ if (text.length > 5 &&
+ !text.toLowerCase().includes('prev ch') &&
+ !text.toLowerCase().includes('next ch') &&
+ !text.toLowerCase().match(/^chapter\s*\d+\s*\//) &&
+ !text.includes('Problematic ad?') &&
+ !text.includes('Become a contributor') &&
+ !text.includes('Copyright ©') &&
+ !text.includes('wtr-lab.com') &&
+ !text.toLowerCase().includes('report it here') &&
+ !text.toLowerCase().includes('disable popup')) {
+ validParagraphs.push(text);
+ }
+ });
+
+ console.log('[parseWTRLabChapter] Valid paragraphs after filtering:', validParagraphs.length);
+
+ if (validParagraphs.length >= 3) {
+ content.paragraphs = validParagraphs.map(p => cleanText(p));
+ content.text = content.paragraphs.join('\n\n');
+ foundContent = true;
+ }
+ }
+
+ // If still no content, try extracting text directly from body
+ // WTR-Lab might not use
tags
+ if (!foundContent) {
+ console.log('[parseWTRLabChapter] Trying to extract text directly from body');
+
+ // Get the raw HTML from body for inspection
+ const bodyHtml = $('body').html() || '';
+ console.log('[parseWTRLabChapter] Body HTML length for raw extraction:', bodyHtml.length);
+ console.log('[parseWTRLabChapter] Body HTML preview:', bodyHtml.substring(0, 1000));
+
+ // Get the body text BEFORE removing elements
+ const bodyTextBeforeCleanup = $('body').text();
+ console.log('[parseWTRLabChapter] Body text length before cleanup:', bodyTextBeforeCleanup.length);
+ console.log('[parseWTRLabChapter] Body text preview:', bodyTextBeforeCleanup.substring(0, 500));
+
+ // Check if the chapter content is actually in the body
+ const contentIndicators = ['Fang Xiaoluo', 'On the 23rd day', 'hot', 'It\'s hot', 'Traveling Through Time'];
+ for (const indicator of contentIndicators) {
+ if (bodyTextBeforeCleanup.includes(indicator)) {
+ console.log('[parseWTRLabChapter] Found indicator in body text:', indicator);
+ }
+ if (bodyHtml.includes(indicator)) {
+ console.log('[parseWTRLabChapter] Found indicator in body HTML:', indicator);
+ const idx = bodyHtml.indexOf(indicator);
+ console.log('[parseWTRLabChapter] Context around', indicator, ':', bodyHtml.substring(Math.max(0, idx - 200), idx + 300));
+ }
+ }
+
+ // Try to find the content container by looking for specific patterns
+ // WTR-Lab content might be in a specific div structure
+ console.log('[parseWTRLabChapter] Searching for content in all elements...');
+
+ // Search all elements for chapter content
+ $('*').each((i, el) => {
+ const $el = $(el);
+ const tagName = el.tagName?.toLowerCase();
+ const text = $el.text().trim();
+ const className = $el.attr('class') || '';
+
+ // Skip script, style, and navigation elements
+ if (['script', 'style', 'nav', 'footer', 'header'].includes(tagName)) {
+ return;
+ }
+
+ // Look for elements containing chapter content indicators
+ if (text.length > 100 && (
+ text.includes('Fang Xiaoluo') ||
+ text.includes('hot') ||
+ text.includes('Traveling Through Time')
+ )) {
+ console.log('[parseWTRLabChapter] Found content in element:', tagName, 'class:', className);
+ console.log('[parseWTRLabChapter] Element text length:', text.length);
+ console.log('[parseWTRLabChapter] Element text preview:', text.substring(0, 300));
+ }
+ });
+
+ // Split by newlines and filter
+ const lines = bodyTextBeforeCleanup.split(/\n+/);
+ const validLines = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // Filter out short lines, navigation, and common non-content text
+ // Be more lenient - accept lines > 5 chars that don't match navigation patterns
+ // WTR-Lab has short sentences like "hot.", "It's hot."
+ if (trimmed.length > 5 &&
+ !trimmed.toLowerCase().includes('prev ch') &&
+ !trimmed.toLowerCase().includes('next ch') &&
+ !trimmed.toLowerCase().match(/^\d+\s*\/\s*\d+/) &&
+ !trimmed.includes('Problematic ad?') &&
+ !trimmed.includes('Become a contributor') &&
+ !trimmed.includes('Copyright ©') &&
+ !trimmed.includes('wtr-lab.com') &&
+ !trimmed.toLowerCase().includes('intro') &&
+ !trimmed.toLowerCase().includes('about us') &&
+ !trimmed.toLowerCase().includes('contact us') &&
+ !trimmed.toLowerCase().includes('privacy policy') &&
+ !trimmed.toLowerCase().includes('terms of use') &&
+ !trimmed.toLowerCase().includes('cookie policy') &&
+ !trimmed.toLowerCase().includes('changelog') &&
+ !trimmed.toLowerCase().includes('dmca') &&
+ !trimmed.includes('ads by') &&
+ !trimmed.includes('Pubfuture') &&
+ !trimmed.toLowerCase().includes('report it here') &&
+ !trimmed.toLowerCase().includes('disable popup') &&
+ !trimmed.match(/^\[Image/i) &&
+ !trimmed.match(/^image$/i)) {
+ validLines.push(trimmed);
+ }
+ }
+
+ console.log('[parseWTRLabChapter] Valid lines from body:', validLines.length);
+ if (validLines.length > 0) {
+ console.log('[parseWTRLabChapter] First 3 lines:', validLines.slice(0, 3));
+ }
+
+ if (validLines.length >= 3) {
+ content.paragraphs = validLines.map(p => cleanText(p));
+ content.text = content.paragraphs.join('\n\n');
+ foundContent = true;
+ }
+ }
+
+ // If we found a container, extract paragraphs from it
+ if (container && !content.paragraphs.length) {
+ container.find('p').each((_, el) => {
+ const text = $(el).text().trim();
+ if (text && text.length > 10) {
+ content.paragraphs.push(cleanText(text));
+ }
+ });
+
+ // If still no paragraphs, try getting text directly
+ if (content.paragraphs.length === 0) {
+ const directText = container.text().trim();
+ if (directText && directText.length > 100) {
+ // Split by double newlines or common paragraph separators
+ const parts = directText.split(/\n\n+|\r\n\r\n+/);
+ parts.forEach(part => {
+ const cleaned = cleanText(part);
+ if (cleaned && cleaned.length > 10) {
+ content.paragraphs.push(cleaned);
+ }
+ });
+ }
+ }
+
+ content.text = content.paragraphs.join('\n\n');
+ }
+
+ // LAST RESORT: If still no content, try to extract from raw HTML
+ // This handles cases where cheerio parsing fails but content is in HTML
+ if (!foundContent || content.paragraphs.length < 3) {
+ console.log('[parseWTRLabChapter] Trying raw HTML extraction as last resort');
+
+ // Get the raw HTML from body
+ const bodyHtml = $('body').html() || '';
+ console.log('[parseWTRLabChapter] Body HTML length for raw extraction:', bodyHtml.length);
+
+ // Look for text between specific patterns
+ // WTR-Lab content is often in divs without specific classes
+ // Try to find text that looks like chapter content
+
+ // Method 1: Extract text between common patterns
+ // Remove script and style tags first
+ let cleanHtml = bodyHtml
+ .replace(/