├── .gitignore ├── Icon.png ├── preview ├── preview.jpg ├── preview2.png └── preview3.jpg ├── workspace.code-workspace ├── css ├── settings.css └── content_script.css ├── manifest.json ├── changelog.md ├── NHentaiAnalytics.sln ├── js ├── include.js ├── base64-string.js ├── content_script.js ├── settings.js └── lz-string.js ├── settings.html ├── README.md ├── LICENSE └── background.js /.gitignore: -------------------------------------------------------------------------------- 1 | /ImageSource 2 | *.suo 3 | -------------------------------------------------------------------------------- /Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wappenull/NHTracker/master/Icon.png -------------------------------------------------------------------------------- /preview/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wappenull/NHTracker/master/preview/preview.jpg -------------------------------------------------------------------------------- /preview/preview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wappenull/NHTracker/master/preview/preview2.png -------------------------------------------------------------------------------- /preview/preview3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wappenull/NHTracker/master/preview/preview3.jpg -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /css/settings.css: -------------------------------------------------------------------------------- 1 | img { 2 | width: 200px; 3 | } 4 | 5 | .tag { 6 | background-color: rgb(207, 207, 207); 7 | } 8 | 9 | .character { 10 | background-color: rgb(214, 108, 108); 11 | } 12 | 13 | .parody { 14 | background-color: rgb(185, 106, 159); 15 | } 16 | 17 | .artist { 18 | background-color: rgb(145, 146, 204); 19 | } 20 | 21 | .group { 22 | background-color: rgb(118, 204, 204); 23 | } 24 | 25 | .category { 26 | background-color: rgb(199, 193, 110); 27 | } 28 | 29 | .language { 30 | background-color: rgb(110, 192, 110); 31 | } 32 | 33 | .sub { 34 | border: solid 1px grey; 35 | padding: 10px; 36 | margin: 5px; 37 | } -------------------------------------------------------------------------------- /css/content_script.css: -------------------------------------------------------------------------------- 1 | .read.cover { 2 | opacity: .5; 3 | } 4 | 5 | .read.cover:hover { 6 | opacity: 1; 7 | } 8 | 9 | .ignore.cover { 10 | filter: brightness(0.2); 11 | } 12 | 13 | .ignore.cover:hover { 14 | filter: brightness(1); 15 | } 16 | 17 | .coverStatus { 18 | color: white; 19 | } 20 | 21 | .read.coverStatus { 22 | background-color: blue; 23 | } 24 | 25 | .fav.coverStatus { 26 | background-color: goldenrod; 27 | } 28 | 29 | .ignore.coverStatus { 30 | background-color: black; 31 | } 32 | 33 | .toread.coverStatus { 34 | background-color: green; 35 | } 36 | 37 | .coverButton { 38 | 39 | } 40 | 41 | .read.coverButton { 42 | width: 40%; 43 | } 44 | 45 | .ignore.coverButton { 46 | width: 35%; 47 | } 48 | 49 | .later.coverButton { 50 | width: 25%; 51 | } 52 | 53 | /* Hosts cover button */ 54 | .coverButtonRoot { 55 | display: flex; 56 | justify-content: space-between; 57 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NHentai Tracker", 3 | "version": "1.3.2", 4 | "description": "Keep track off book number you have visited. Show clue in browsing grid and search result.", 5 | "icons": { 6 | "64": "Icon.png" 7 | }, 8 | "action": {}, /* Required to access chrome.action */ 9 | "permissions": [ 10 | "storage", 11 | "activeTab", 12 | "scripting", 13 | "unlimitedStorage", /* this is for storage.local for large book db storage */ 14 | "downloads" /* For downloading exported database back */ 15 | ], 16 | "host_permissions": [ 17 | "*://nhentai.net/*", 18 | "*://i.nhentai.net/*" 19 | ], 20 | "content_scripts": [ 21 | { 22 | "matches": [ "*://nhentai.net/*" ], 23 | "css": [ "css/content_script.css" ], 24 | "js": [ "js/include.js", "js/content_script.js" ] 25 | } 26 | ], 27 | "author": "Wappen", 28 | "background": { 29 | "service_worker": "background.js" 30 | }, 31 | "options_ui": { 32 | "page": "settings.html", 33 | "open_in_tab": true 34 | }, 35 | "manifest_version": 3 36 | } -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | - 1.3.2 2 | - Fix export/import button to always wait for DB before dumping 3 | - Replace deprecated DOMSubtreeModified 4 | - 1.3.1 5 | - Fixed a bug I accidently introduced in 1.3.0 commit, oops 6 | - 1.3.0 7 | - Add support to purged book (404 Not Found) to still display dropdown status tool for setting book status 8 | - 1.2.4 9 | - Fix serious issue where database might randomly not be saved 10 | - Add fetch missing book info button 11 | - 1.2.3 12 | - Shorten auto save time, because sometimes background worker just unloaded by Chrome randomly in short period 13 | - 1.2.2 14 | - Fix if user set a book state when background worker is asleep will not saving any data. 15 | - 1.2.1 16 | - Fix refocusing page will update book grid into all unread state, this is due to background worker is rebooting 17 | - 1.2.0 18 | - New "ToRead" state which just a read later list. Mark a book to ToRead from their index page, find them later in extension dashboard. 19 | - Book list displayed in extension dashboard how have clickable link for their book number. 20 | - Dashboard book filter persist over refresh 21 | - 1.1.0 22 | - Migrate storage to all local because of very limited sync storage (100Kb). Data is migrated from previous version, and will be saved to local storage from now on. 23 | - Ability to import/export internal data. 24 | - 1.0.2 25 | - Fix book listing in setting page will display wrong line due to message racing 26 | - 1.0.1 27 | - Filter out book state 0 from displaying 28 | - 1.0.0 29 | - Initial release -------------------------------------------------------------------------------- /NHentaiAnalytics.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.31624.102 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{90C81E70-A49F-4F12-AE68-3555932CE838}" 7 | ProjectSection(SolutionItems) = preProject 8 | changelog.md = changelog.md 9 | manifest.json = manifest.json 10 | README.md = README.md 11 | EndProjectSection 12 | EndProject 13 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "css", "css", "{A62B923A-F0C8-4DC9-BF5F-AFA980CCE5C2}" 14 | ProjectSection(SolutionItems) = preProject 15 | css\content_script.css = css\content_script.css 16 | css\settings.css = css\settings.css 17 | EndProjectSection 18 | EndProject 19 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{27F16F12-1021-470A-BE25-350D5918E1F4}" 20 | ProjectSection(SolutionItems) = preProject 21 | background.js = background.js 22 | js\content_script.js = js\content_script.js 23 | js\include.js = js\include.js 24 | js\settings.js = js\settings.js 25 | EndProjectSection 26 | EndProject 27 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "html", "html", "{95F1A30C-AE2B-44EA-ABDA-58B04A7EC977}" 28 | ProjectSection(SolutionItems) = preProject 29 | settings.html = settings.html 30 | EndProjectSection 31 | EndProject 32 | Global 33 | GlobalSection(SolutionProperties) = preSolution 34 | HideSolutionNode = FALSE 35 | EndGlobalSection 36 | GlobalSection(NestedProjects) = preSolution 37 | {A62B923A-F0C8-4DC9-BF5F-AFA980CCE5C2} = {90C81E70-A49F-4F12-AE68-3555932CE838} 38 | {27F16F12-1021-470A-BE25-350D5918E1F4} = {90C81E70-A49F-4F12-AE68-3555932CE838} 39 | {95F1A30C-AE2B-44EA-ABDA-58B04A7EC977} = {90C81E70-A49F-4F12-AE68-3555932CE838} 40 | EndGlobalSection 41 | GlobalSection(ExtensibilityGlobals) = postSolution 42 | SolutionGuid = {74B83193-8FB3-40B8-BFC8-88D4E5BD05BA} 43 | EndGlobalSection 44 | EndGlobal 45 | -------------------------------------------------------------------------------- /js/include.js: -------------------------------------------------------------------------------- 1 | const STATE_PEEK = 0; 2 | const STATE_READ = 1; 3 | const STATE_TOREAD = 2; 4 | const STATE_IGNORE = 3; 5 | const STATE_FAV = 10; 6 | 7 | class Tag 8 | { 9 | constructor( id, name, category ) 10 | { 11 | this.id = id; 12 | this.name = name; 13 | this.category = category; 14 | } 15 | } 16 | 17 | class Doujinshi 18 | { 19 | constructor( id, image, name ) 20 | { 21 | this.id = id; 22 | this.image = image; 23 | this.name = name; 24 | this.tags = [];// TODO 25 | } 26 | } 27 | 28 | class PageLocation 29 | { 30 | constructor() 31 | { 32 | this.bookId = 0; 33 | this.isIndexPage = false; 34 | this.pageNumber = 0; 35 | } 36 | } 37 | 38 | // Support both index page and inside page 39 | function ParseBookNumberFromUrl( url ) 40 | { 41 | const IndexOrInsidePageUrlMatcher = /nhentai.net\/g\/([0-9]+)\/([0-9]+)?/; 42 | 43 | let output = new PageLocation(); 44 | if( url == null ) 45 | return output; 46 | 47 | let match = url.match( IndexOrInsidePageUrlMatcher ); 48 | if( match == null || match.length < 2 ) 49 | return output; 50 | 51 | output.bookId = match[1]; // First number capturing group 52 | if( match[2] != null ) 53 | { 54 | output.isIndexPage = false; 55 | output.pageNumber = parseInt( match[2] ); 56 | } 57 | else 58 | { 59 | output.isIndexPage = true; 60 | output.pageNumber = 0; 61 | } 62 | return output; 63 | } 64 | 65 | // Wrapper for sendMessage to wait for it response 66 | function SendMessagePromise( item, callback ) 67 | { 68 | return new Promise( ( resolve, reject ) => 69 | { 70 | chrome.runtime.sendMessage( item, response => 71 | { 72 | if( callback != null ) 73 | callback( response ); 74 | resolve(); 75 | } ); 76 | } ); 77 | } 78 | 79 | // Sleep promise for MS, 1000ms = 1 sec 80 | function sleep( ms ) 81 | { 82 | return new Promise( resolve => setTimeout( resolve, ms ) ); 83 | } 84 | 85 | // Merge key-value from object b to object a 86 | function MergeObject( a, b ) 87 | { 88 | if( a == null || b == null ) 89 | return; 90 | 91 | for( let key in b ) 92 | a[key] = b[key]; 93 | } -------------------------------------------------------------------------------- /settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

WAPPEN's NHentai Tracker Extension

9 |
v
10 |
Visit https://github.com/wappenull/NHTracker
11 |
12 |
13 |

14 | Storage control 15 |

16 | 17 |
18 | You are currently using storage for:
19 |
SYNC SPACE
20 |
LOCAL SPACE
21 | v1.1.0 Change note:
22 | Since v1.1.0 book state saving is done in local database, so no Google sync anymore.
23 | Because sync storage available for extension to save is only up to 100Kb, which is super small.
24 | Instead now you can import/export data to other device or for backup.
25 | Export
26 |
27 | Import
28 |
29 |
30 |
31 |
32 |

33 | Account control 34 |

35 | (Must be logged in, would take some time if you have like thousands) 36 |
37 | (Fill book info, name on book number that not yet has its name) 38 |
39 |
40 |
41 |

Manual hint

42 |
43 |

Google Takeout browsing history

44 | Inspect your own entire browsing history and populate "READ" book id by searching for
45 | URL pattern like 'nhentai.net/g/NNNNNNN/'
46 | Select BrowserHistory.json and submit it here
47 | It is processed using JavaScript locally in the extension, it is not upload to remote server anywhere, you can check the source code. This is a matter of trust, you and me :)
48 |
49 | 50 | 51 |
52 |

Using URL file per line

53 | Submit a text file where each line containing URL, the line could include other stuff but extension will search only the URL pattern in each line.
54 | You could export this from history tool or something else. History Trends Unlimited is good tool to export such data from your history.
55 |
56 | 57 | 58 |
59 |
60 |
61 |
62 |
STATUS TEXT
63 |
--
64 | Filter list: 65 | 71 |
72 |
If the book is displayed without name (only id) that's mean extension is not yet seen/stored meta data for that book. Go into book index page to make an update.
73 |
book display (If book list does not show up or refresh, that's mean background service broke, reload the extension off-on again)
74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # WAPPEN's NHentai Tracker Extension 3 | A Chrome extension to help you keep track of read book number. 4 | Display badge over all book covers. Never got lost in search result again! 5 | 6 | ![Preview](preview/preview.jpg) 7 | ![Preview](preview/preview2.png) 8 | ![Preview](preview/preview3.jpg) 9 | 10 | ### Features 11 | - Mark book as READ or IGNORED and display them over search result to quickly skim for new book to read. 12 | - Also auto mark as READ when you go into any of its page to read. 13 | - Mark as read later for reading list. 14 | - See [changelog.md](changelog.md) for change list. 15 | 16 | ### Chrome 17 | Chrome Store: **Sorry none yet!** It needs to pass some ~~fapping~~ err I mean QA testing first. 18 | 19 | ### Firefox 20 | I'm never intended to port to FF yet. Sooooorry! (CHECKMATE FF users!) 21 | 22 | ------ 23 | 24 | ### Installing 25 | - As this is not published to store, the only way to install is via developer mode. 26 | - Download this entire repo, top right of this web page 'Code' button -> download zip (.zip) 27 | - Unzip it to some folder, but dont unzip your pants yet, still some more steps to go. 28 | - Go into Chrome extension tab, enable developer mode (upper right). 29 | - Select load unpacked extension (upper left). 30 | - Point the to folder where you unzip it, where "manifest.json" is. (manifest.json wont show on dialog, that's ok, just select the folder) 31 | - Extension is installed as "NHentai Tracker". 32 | - If you want more help regarding above steps, search for something like **Chrome install unpacked extension**. 33 | - Unzip your pants, go to NHentai. 34 | - What you can do next: 35 | - Open extension option page by clicking extension badge. Import your favorite list. (Must be logged in) 36 | - (Advanced) Export past browsing history and import to extension to scan for past visit. Read more below. 37 | 38 | ### Pulling past visit from history 39 | Extension can do it in 2 ways, none of them are automated, so you need some work. 40 | - Google takeout (not much detailed history though, I pulled only a few out of it), to start, google something like "google export browsing history". 41 | - Or you export current Chrome history (note that [chrome will only hold up to 3 months of history locally](https://superuser.com/questions/364470/), which is stupid if you ask me) 42 | - I used this extension, [History Trends Unlimited](https://chrome.google.com/webstore/detail/history-trends-unlimited/pnmchffiealhkdloeffcdnbgdnedheme) which crunch history and further saving it longer than 3 months limit. 43 | Anyway, if you just installed this Unlimited extension, you still have no way to bring back your history beyond that 3 months anyway. 44 | - If you have history exported from **History Trends Unlimited**, scan it in NHTracker's setting page in the second file submit section. 45 | 46 | ### Upgrading 47 | - Upgrading from previous version, just redownload this again and unzip to same location. 48 | - In your chrome extension page, click the circle arrow (reload extension) and you are done. Notice the version number should changed. 49 | - If you have any NH tab already open you need to refresh them once for the new version to take effect. 50 | 51 | ------ 52 | 53 | ### Q&A 54 | **Why there are 'READ' and 'IGNORED' book state? What's the difference?** 55 | - The 'IGNORED' state is used to mark book as **"Checked that out but passed"** rather than **"I read that! (to the end!)"**. 56 | - Example usage is using IGNORE tag on the book you totally not interested in. 57 | - Or another usage is to mark them on another sibling books. (incompleted, reuploaded, low quality, bad translation, etc) 58 | - Ignored book will not show in read book listing. 59 | - Ignored bool will colored differently in search result. 60 | 61 | **I want XX YY ZZ feature! Make it!! plzzzz** 62 | - If it sounds good, pitch it to me via issue page. 63 | - Else you have to learn HTML+JavaScript and do it yourself. :P 64 | 65 | **What is it inspired from?** 66 | - I noticed that browser always mark visited link, like from blue to purple for an eon since internet started, but now in 2022 I have trouble looking at doujin search result, how comes? 67 | - So I searched chrome extension store and github for something like this, hoping that some gentleman would already made it. 68 | - But I found stuff like extension that let you highlight NH number and jump to the site (to save some steps and typing), or the other that when you highlight NH number it will popup and preview the doujin (in fear of stepping into degenerated doujin). 69 | - I was like WTF this is the best you people can think of??? What The Heck??? Why do you people so serious about those little number?? Just copy paste it and go to the site to check it out like a man!! 70 | - Ok I'm done ranting, that was how this extension was born. 71 | 72 | **Problems, HALP!** 73 | - Report it in Github issue. Thank you! 74 | - Or fix it yourself if you are a wizkid. 75 | 76 | ### Credit 77 | - To great myself. 78 | - Took code skeleton from https://github.com/Xwilarg/NHentaiAnalytics thanks! 79 | - Stackoverflow for making me able to go through JavaScript hell after not writing it for 5 years. At least it is better than Java shit. 80 | -------------------------------------------------------------------------------- /js/base64-string.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 Pieroxy 2 | // This work is free. You can redistribute it and/or modify it 3 | // under the terms of the WTFPL, Version 2 4 | // For more information see LICENSE.txt or http://www.wtfpl.net/ 5 | // 6 | // This lib is part of the lz-string project. 7 | // For more information, the home page: 8 | // http://pieroxy.net/blog/pages/lz-string/index.html 9 | // 10 | // Base64 compression / decompression for already compressed content (gif, png, jpg, mp3, ...) 11 | // version 1.4.1 12 | var Base64String = { 13 | 14 | compressToUTF16 : function (input) { 15 | var output = [], 16 | i,c, 17 | current, 18 | status = 0; 19 | 20 | input = this.compress(input); 21 | 22 | for (i=0 ; i> 1)+32)); 27 | current = (c & 1) << 14; 28 | break; 29 | case 1: 30 | output.push(String.fromCharCode((current + (c >> 2))+32)); 31 | current = (c & 3) << 13; 32 | break; 33 | case 2: 34 | output.push(String.fromCharCode((current + (c >> 3))+32)); 35 | current = (c & 7) << 12; 36 | break; 37 | case 3: 38 | output.push(String.fromCharCode((current + (c >> 4))+32)); 39 | current = (c & 15) << 11; 40 | break; 41 | case 4: 42 | output.push(String.fromCharCode((current + (c >> 5))+32)); 43 | current = (c & 31) << 10; 44 | break; 45 | case 5: 46 | output.push(String.fromCharCode((current + (c >> 6))+32)); 47 | current = (c & 63) << 9; 48 | break; 49 | case 6: 50 | output.push(String.fromCharCode((current + (c >> 7))+32)); 51 | current = (c & 127) << 8; 52 | break; 53 | case 7: 54 | output.push(String.fromCharCode((current + (c >> 8))+32)); 55 | current = (c & 255) << 7; 56 | break; 57 | case 8: 58 | output.push(String.fromCharCode((current + (c >> 9))+32)); 59 | current = (c & 511) << 6; 60 | break; 61 | case 9: 62 | output.push(String.fromCharCode((current + (c >> 10))+32)); 63 | current = (c & 1023) << 5; 64 | break; 65 | case 10: 66 | output.push(String.fromCharCode((current + (c >> 11))+32)); 67 | current = (c & 2047) << 4; 68 | break; 69 | case 11: 70 | output.push(String.fromCharCode((current + (c >> 12))+32)); 71 | current = (c & 4095) << 3; 72 | break; 73 | case 12: 74 | output.push(String.fromCharCode((current + (c >> 13))+32)); 75 | current = (c & 8191) << 2; 76 | break; 77 | case 13: 78 | output.push(String.fromCharCode((current + (c >> 14))+32)); 79 | current = (c & 16383) << 1; 80 | break; 81 | case 14: 82 | output.push(String.fromCharCode((current + (c >> 15))+32, (c & 32767)+32)); 83 | status = 0; 84 | break; 85 | } 86 | } 87 | output.push(String.fromCharCode(current + 32)); 88 | return output.join(''); 89 | }, 90 | 91 | 92 | decompressFromUTF16 : function (input) { 93 | var output = [], 94 | current,c, 95 | status=0, 96 | i = 0; 97 | 98 | while (i < input.length) { 99 | c = input.charCodeAt(i) - 32; 100 | 101 | switch (status++) { 102 | case 0: 103 | current = c << 1; 104 | break; 105 | case 1: 106 | output.push(String.fromCharCode(current | (c >> 14))); 107 | current = (c&16383) << 2; 108 | break; 109 | case 2: 110 | output.push(String.fromCharCode(current | (c >> 13))); 111 | current = (c&8191) << 3; 112 | break; 113 | case 3: 114 | output.push(String.fromCharCode(current | (c >> 12))); 115 | current = (c&4095) << 4; 116 | break; 117 | case 4: 118 | output.push(String.fromCharCode(current | (c >> 11))); 119 | current = (c&2047) << 5; 120 | break; 121 | case 5: 122 | output.push(String.fromCharCode(current | (c >> 10))); 123 | current = (c&1023) << 6; 124 | break; 125 | case 6: 126 | output.push(String.fromCharCode(current | (c >> 9))); 127 | current = (c&511) << 7; 128 | break; 129 | case 7: 130 | output.push(String.fromCharCode(current | (c >> 8))); 131 | current = (c&255) << 8; 132 | break; 133 | case 8: 134 | output.push(String.fromCharCode(current | (c >> 7))); 135 | current = (c&127) << 9; 136 | break; 137 | case 9: 138 | output.push(String.fromCharCode(current | (c >> 6))); 139 | current = (c&63) << 10; 140 | break; 141 | case 10: 142 | output.push(String.fromCharCode(current | (c >> 5))); 143 | current = (c&31) << 11; 144 | break; 145 | case 11: 146 | output.push(String.fromCharCode(current | (c >> 4))); 147 | current = (c&15) << 12; 148 | break; 149 | case 12: 150 | output.push(String.fromCharCode(current | (c >> 3))); 151 | current = (c&7) << 13; 152 | break; 153 | case 13: 154 | output.push(String.fromCharCode(current | (c >> 2))); 155 | current = (c&3) << 14; 156 | break; 157 | case 14: 158 | output.push(String.fromCharCode(current | (c >> 1))); 159 | current = (c&1) << 15; 160 | break; 161 | case 15: 162 | output.push(String.fromCharCode(current | c)); 163 | status=0; 164 | break; 165 | } 166 | 167 | 168 | i++; 169 | } 170 | 171 | return this.decompress(output.join('')); 172 | //return output; 173 | 174 | }, 175 | 176 | 177 | // private property 178 | _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", 179 | 180 | decompress : function (input) { 181 | var output = []; 182 | var chr1, chr2, chr3, enc1, enc2, enc3, enc4; 183 | var i = 1; 184 | var odd = input.charCodeAt(0) >> 8; 185 | 186 | while (i < input.length*2 && (i < input.length*2-1 || odd==0)) { 187 | 188 | if (i%2==0) { 189 | chr1 = input.charCodeAt(i/2) >> 8; 190 | chr2 = input.charCodeAt(i/2) & 255; 191 | if (i/2+1 < input.length) 192 | chr3 = input.charCodeAt(i/2+1) >> 8; 193 | else 194 | chr3 = NaN; 195 | } else { 196 | chr1 = input.charCodeAt((i-1)/2) & 255; 197 | if ((i+1)/2 < input.length) { 198 | chr2 = input.charCodeAt((i+1)/2) >> 8; 199 | chr3 = input.charCodeAt((i+1)/2) & 255; 200 | } else 201 | chr2=chr3=NaN; 202 | } 203 | i+=3; 204 | 205 | enc1 = chr1 >> 2; 206 | enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); 207 | enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); 208 | enc4 = chr3 & 63; 209 | 210 | if (isNaN(chr2) || (i==input.length*2+1 && odd)) { 211 | enc3 = enc4 = 64; 212 | } else if (isNaN(chr3) || (i==input.length*2 && odd)) { 213 | enc4 = 64; 214 | } 215 | 216 | output.push(this._keyStr.charAt(enc1)); 217 | output.push(this._keyStr.charAt(enc2)); 218 | output.push(this._keyStr.charAt(enc3)); 219 | output.push(this._keyStr.charAt(enc4)); 220 | } 221 | 222 | return output.join(''); 223 | }, 224 | 225 | compress : function (input) { 226 | var output = [], 227 | ol = 1, 228 | output_, 229 | chr1, chr2, chr3, 230 | enc1, enc2, enc3, enc4, 231 | i = 0, flush=false; 232 | 233 | input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); 234 | 235 | while (i < input.length) { 236 | 237 | enc1 = this._keyStr.indexOf(input.charAt(i++)); 238 | enc2 = this._keyStr.indexOf(input.charAt(i++)); 239 | enc3 = this._keyStr.indexOf(input.charAt(i++)); 240 | enc4 = this._keyStr.indexOf(input.charAt(i++)); 241 | 242 | chr1 = (enc1 << 2) | (enc2 >> 4); 243 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 244 | chr3 = ((enc3 & 3) << 6) | enc4; 245 | 246 | if (ol%2==0) { 247 | output_ = chr1 << 8; 248 | flush = true; 249 | 250 | if (enc3 != 64) { 251 | output.push(String.fromCharCode(output_ | chr2)); 252 | flush = false; 253 | } 254 | if (enc4 != 64) { 255 | output_ = chr3 << 8; 256 | flush = true; 257 | } 258 | } else { 259 | output.push(String.fromCharCode(output_ | chr1)); 260 | flush = false; 261 | 262 | if (enc3 != 64) { 263 | output_ = chr2 << 8; 264 | flush = true; 265 | } 266 | if (enc4 != 64) { 267 | output.push(String.fromCharCode(output_ | chr3)); 268 | flush = false; 269 | } 270 | } 271 | ol+=3; 272 | } 273 | 274 | if (flush) { 275 | output.push(String.fromCharCode(output_)); 276 | output = output.join(''); 277 | output = String.fromCharCode(output.charCodeAt(0)|256) + output.substring(1); 278 | } else { 279 | output = output.join(''); 280 | } 281 | 282 | return output; 283 | 284 | } 285 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /js/content_script.js: -------------------------------------------------------------------------------- 1 | /* Include ////////////////////////////////////////////////////*/ 2 | 3 | // Done via manifest 4 | 5 | /* Content script /////////////////////////////////////////////////*/ 6 | 7 | let g_IndexPageInfo = new PageLocation(); 8 | let g_BookList = {}; 9 | 10 | function ParseBookInfoFromIndexPage( doc, id ) 11 | { 12 | let info = new Doujinshi(); 13 | info.id = id; 14 | // Name node can fail if nhentai has error page 15 | let nameNode = doc.querySelector( "#info > h1 > span.pretty" ); 16 | if( nameNode == null ) 17 | return null; 18 | 19 | info.name = nameNode.innerText; 20 | info.image = doc.querySelector( "#cover > a > img" ).src; 21 | info.tags = []; // no for now 22 | 23 | return info; 24 | } 25 | 26 | // Check current book number for this URL 27 | async function CheckPageAndAdd() 28 | { 29 | // Check that it must be nhentai.net/g/NNNNNNN/ 30 | let page = ParseBookNumberFromUrl( location.href ); 31 | g_IndexPageInfo = page; 32 | 33 | let bookInfo = null; 34 | let state = 0; 35 | 36 | // If in the reading sub page 37 | if( page.pageNumber > 0 ) 38 | state = STATE_READ; 39 | 40 | // Snatch book info if this is index page 41 | if( page.isIndexPage ) 42 | { 43 | bookInfo = ParseBookInfoFromIndexPage( document, page.bookId ); 44 | if( bookInfo == null ) 45 | { 46 | // It could also be a purged 404 page 47 | // Display dropdown box to change status instead 48 | // continue down below 49 | } 50 | 51 | // Check if fav button is written 'Unfavorite' that's mean we fav this one 52 | let favTextNode = document.querySelector( "#favorite>.text" ); 53 | if( favTextNode != null ) 54 | { 55 | if( favTextNode.innerText.includes( "Unfav" ) ) 56 | state = STATE_FAV; // Change to fav state now 57 | 58 | // Also add a monitor, check for text change from NH doing instead, it is faster than guessing the delay 59 | // Using new MutationObserver as DOMSubtreeModified is deprecated 60 | //favTextNode.addEventListener( "DOMSubtreeModified", () => _OnFavStateChanged( favTextNode ) ); 61 | const config = { attributes: false, childList: true, subtree: true }; 62 | const callback = (mutationList, observer) => 63 | { 64 | // Assumes mutation is only subtree we want, I did not checked or iterate the list https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver 65 | for (const mutation of mutationList) 66 | { 67 | console.log(`The ${mutation.type} on ${mutation.target} was modified.`); 68 | } 69 | _OnFavStateChanged( favTextNode ) 70 | }; 71 | 72 | const observer = new MutationObserver(callback); 73 | 74 | // Start observing the target node for configured mutations 75 | observer.observe(favTextNode, config); 76 | } 77 | } 78 | 79 | // Note: Do not check for 'bookInfo != null' it can be null when reading page 1 of manga 80 | if( page.bookId > 0 ) 81 | { 82 | // Content script cannot access background page, must use messaging 83 | // Even if state == 0, it still update book info 84 | chrome.runtime.sendMessage( 85 | { 86 | cmd: "setbook", 87 | id: page.bookId, 88 | state: state, 89 | info: bookInfo 90 | } ); 91 | } 92 | 93 | // Acquire book state into global var 94 | await AcquireGlobalBookState(); 95 | 96 | if( page.isIndexPage ) 97 | { 98 | // index page also has recommendation below, write their state 99 | WriteGridResult( g_BookList, true ); 100 | 101 | let state = g_BookList[g_IndexPageInfo.bookId]; 102 | WriteIndexPageTool( state ); 103 | } 104 | else // Could be a search result or main page 105 | { 106 | WriteGridResult( g_BookList, true ); 107 | } 108 | 109 | g_CheckPageAndAddRunOnce = true; 110 | } 111 | 112 | function _OnFavStateChanged( favTextNode ) 113 | { 114 | if( g_IndexPageInfo == null ) 115 | return; 116 | 117 | let newState = STATE_READ; 118 | if( favTextNode.innerText.includes( "Unfav" ) ) 119 | newState = STATE_FAV; 120 | SetBookCoverState( g_IndexPageInfo.bookId, g_IndexPageCover, newState, true ); 121 | } 122 | 123 | async function AcquireGlobalBookState() 124 | { 125 | await SendMessagePromise( { cmd: "getbook" }, ( response ) => 126 | { 127 | g_BookList = response.books; 128 | } ); 129 | } 130 | 131 | // Check if current page has linking to another book 132 | // Write state if read or already in fav 133 | function WriteGridResult( database, sendBookInfoHint ) 134 | { 135 | if( sendBookInfoHint == null ) 136 | sendBookInfoHint = false; 137 | 138 | let allCovers = document.getElementsByClassName( 'cover' ); 139 | allCovers = Array.from( allCovers ); // Note: allCovers is dynamic array, it could expand if new cover class is added later, so take snapshot of it 140 | let c = allCovers.length; 141 | for( let i = 0; i < c; i++ ) 142 | { 143 | let cover = allCovers[i]; 144 | let page = ParseBookNumberFromUrl( cover.href ); 145 | if( page.bookId == 0 ) 146 | continue; 147 | 148 | if( sendBookInfoHint ) 149 | { 150 | // Auto send book hint to background service 151 | // In case it discover some book that does not seen meta data yet 152 | let bookInfo = ParseBookInfoFromCoverNode( cover, page.bookId ); 153 | chrome.runtime.sendMessage( { cmd: "bookinfohint", info: bookInfo } ); 154 | } 155 | 156 | let state = database[page.bookId]; 157 | DecorateCoverWithState( page.bookId, cover, state ); 158 | } 159 | } 160 | 161 | function CreateMarkButtonForCover( coverNode, state, bookId ) 162 | { 163 | let div = null; 164 | let needToshowButton = false; 165 | 166 | if( state == null || state == 0 ) 167 | needToshowButton = true; 168 | 169 | // Try existing one first 170 | div = coverNode.getElementsByClassName( "coverButtonRoot" )[0]; 171 | if( needToshowButton && div == null ) 172 | { 173 | // Create 174 | div = document.createElement( 'div' ); 175 | coverNode.insertAdjacentElement( 'afterbegin', div ); // As first child 176 | div.id = div.className = "coverButtonRoot"; 177 | 178 | let thisBookId = bookId; 179 | let CreateHeaderButton = function (root, text, className, setToState) 180 | { 181 | let b = document.createElement( "button" ); 182 | b.innerText = text; 183 | b.className = className; 184 | root.appendChild( b ); 185 | b.addEventListener( "click", ( e ) => 186 | { 187 | SetBookCoverState( thisBookId, coverNode, setToState ); 188 | div.remove(); // Eject entire div root 189 | e.preventDefault(); // Do not jump for link 190 | } ); 191 | }; 192 | 193 | CreateHeaderButton( div, "READ", "coverButton read", STATE_READ ); 194 | CreateHeaderButton( div, "Later", "coverButton later", STATE_TOREAD ); 195 | CreateHeaderButton( div, "Ignore", "coverButton ignore", STATE_IGNORE ); 196 | } 197 | else if( !needToshowButton && div != null ) 198 | { 199 | div.remove(); 200 | } 201 | } 202 | 203 | 204 | 205 | function ParseBookInfoFromCoverNode( coverNode, id ) 206 | { 207 | let info = new Doujinshi(); 208 | info.id = id; 209 | if( coverNode == null ) 210 | return null; 211 | 212 | // ParseBookInfoFromCoverNode will fail if called with cover in index page 213 | let captionNode = coverNode.querySelector( ".caption" ); 214 | if( captionNode == null ) 215 | return null; 216 | 217 | info.name = captionNode.innerText; 218 | info.image = coverNode.querySelector( "img" ).src; 219 | info.tags = []; // no for now 220 | 221 | return info; 222 | } 223 | 224 | function SetBookCoverState( bookId, coverNode, state, force ) 225 | { 226 | // Auto extract book info from cover 227 | let bookInfo = ParseBookInfoFromCoverNode( coverNode, bookId ); 228 | 229 | chrome.runtime.sendMessage( 230 | { 231 | cmd: "setbook", 232 | id: bookId, 233 | state: state, 234 | force: force, // Probably vis selector, override the state 235 | info: bookInfo 236 | } ); 237 | DecorateCoverWithState( bookId, coverNode, state ); // Mock local state 238 | } 239 | 240 | let g_IndexPageCover = null; 241 | function WriteIndexPageTool( state ) 242 | { 243 | g_IndexPageCover = document.querySelector( "#cover" ); 244 | 245 | // Or use

404 Not Found node for purged book 246 | let skipCoverEffect = false; 247 | if( g_IndexPageCover == null ) 248 | { 249 | // But create as sibling next to it instead 250 | let notFoundNode = document.querySelector(".error > h1") 251 | if( notFoundNode != null ) 252 | { 253 | g_IndexPageCover = notFoundNode.insertAdjacentElement( 'afterend', document.createElement( 'div' ) ); 254 | skipCoverEffect = true; 255 | } 256 | } 257 | 258 | if( !skipCoverEffect ) 259 | { 260 | // This is running for index page cover, which run separately from suggestion cover below the index page 261 | DecorateCoverWithState( g_IndexPageInfo.bookId, g_IndexPageCover, state ); // Index page cover never use dim 262 | } 263 | 264 | CreateBookStateSelector( g_IndexPageCover ); // Also add selector for user to override book state 265 | } 266 | 267 | function DecorateCoverWithState( bookId, cover, state ) 268 | { 269 | if( cover == null ) 270 | return; 271 | 272 | let useDimEffect = false; 273 | let modifyImgClassTo = "cover read"; // This is custom class injected by our css 274 | 275 | // Get existing or create new 276 | let header = cover.querySelector( "#coverStatus" ); 277 | if( header == null ) 278 | { 279 | header = document.createElement( "div" ); 280 | cover.insertAdjacentElement( 'afterbegin', header ); // Then insert it at the top of cover box 281 | header.id = "coverStatus"; 282 | } 283 | 284 | if( state === STATE_READ ) 285 | { 286 | header.className = "coverStatus read"; 287 | header.innerHTML = "READ"; 288 | useDimEffect = true; 289 | } 290 | else if( state === STATE_FAV ) 291 | { 292 | header.className = "coverStatus fav"; 293 | header.innerHTML = "IN FAVORITE"; 294 | useDimEffect = true; 295 | } 296 | else if( state === STATE_IGNORE ) 297 | { 298 | header.className = "coverStatus ignore"; 299 | header.innerHTML = "IGNORED"; 300 | modifyImgClassTo = "cover ignore"; 301 | useDimEffect = true; 302 | } 303 | else if( state === STATE_TOREAD ) 304 | { 305 | header.className = "coverStatus toread"; 306 | header.innerHTML = "TO READ"; 307 | useDimEffect = false; 308 | } 309 | else 310 | { 311 | // cover is not needed 312 | header.remove(); 313 | } 314 | 315 | CreateMarkButtonForCover( cover, state, bookId ); 316 | 317 | //// If gallery is already black listed, dont touch its style 318 | //let coverParent = cover.parentNode; 319 | //if( coverParent.className.includes( 'blacklisted' ) ) // Likely 'gallery blacklisted' 320 | // useDimEffect = false; 321 | 322 | // Hack: Index page will not dim cover 323 | if( cover === g_IndexPageCover ) 324 | useDimEffect = false; 325 | 326 | let img = cover.getElementsByTagName( 'img' )[0]; // Get img tag inside the cover root, which is cover image itself. 327 | if( img == null ) 328 | return; 329 | 330 | if( useDimEffect ) 331 | { 332 | // Modify image node inside not cover root 333 | // Because it will also modify our "READ" caption (bad) 334 | img.className = modifyImgClassTo; 335 | } 336 | else 337 | { 338 | // Restore its original class 339 | img.className = "lazyload"; 340 | } 341 | } 342 | 343 | function CreateBookStateSelector( coverNode ) 344 | { 345 | // Selector always persist at every state 346 | let selector = coverNode.querySelector( "#stateSelector" ); 347 | if( selector != null ) 348 | return; 349 | 350 | selector = document.createElement( "select" ); 351 | coverNode.insertAdjacentElement( "beforeend", selector ); // As last child 352 | selector.id = "stateSelector"; 353 | selector.style = "width:80%;"; 354 | selector.innerHTML = 355 | '' + 356 | `` + 357 | `` + 358 | `` + 359 | ``; 360 | selector.addEventListener( 'change', function () 361 | { 362 | let option = this.options[this.selectedIndex].value; 363 | option = parseInt( option ); 364 | if( Number.isNaN( option ) ) 365 | return; 366 | 367 | SetBookCoverState( g_IndexPageInfo.bookId, coverNode, option, true ); 368 | } ); 369 | } 370 | 371 | /* Page refocus service //////////////////////////////////////////////*/ 372 | 373 | let g_CheckPageAndAddRunOnce = false; 374 | 375 | window.addEventListener( "focus", OnPageRefocus ); 376 | async function OnPageRefocus() 377 | { 378 | if( !g_CheckPageAndAddRunOnce ) 379 | return; 380 | 381 | console.log( 'focus check, update all book grid again' ); 382 | await AcquireGlobalBookState(); 383 | WriteGridResult( g_BookList, false ); // Rewrite grid result again in case something is updated 384 | } 385 | 386 | /* Page init //////////////////////////////////////////////////////////*/ 387 | 388 | CheckPageAndAdd(); 389 | -------------------------------------------------------------------------------- /js/settings.js: -------------------------------------------------------------------------------- 1 | /* Include ////////////////////////////////////////////////////*/ 2 | 3 | // Dnoe via HTML 4 | 5 | /*///////////////////////////////////////////////////////////////////*/ 6 | 7 | //document.getElementById( "save" ).addEventListener( "click", function () 8 | //{ 9 | // chrome.runtime.sendMessage( { cmd: "save" } ); 10 | 11 | //} ); 12 | 13 | document.getElementById( "clearStorage" ).addEventListener( "click", function () 14 | { 15 | chrome.runtime.sendMessage( { cmd: "wipe" } ); 16 | setTimeout( RefreshPage, 300 ); 17 | } ); 18 | 19 | let g_StatusNode = document.getElementById( "nbDoujinshi" ); 20 | let g_StatusNode2 = document.getElementById( "statusText2" ); 21 | 22 | function SetStatusText( txt, append ) 23 | { 24 | if( append ) 25 | g_StatusNode.innerHTML += txt; 26 | else 27 | g_StatusNode.innerHTML = txt; 28 | } 29 | 30 | function SetStatusText2( txt, append ) 31 | { 32 | if( append ) 33 | g_StatusNode2.innerHTML += txt; 34 | else 35 | g_StatusNode2.innerHTML = txt; 36 | } 37 | 38 | async function WriteStorageInfoText() 39 | { 40 | let syncByte = await chrome.storage.sync.getBytesInUse(); 41 | document.getElementById( "syncSpace" ).innerText = `Sync storage ${( syncByte / 1024 ).toFixed( 2 )}K (v1.1.x does not use this anymore because of very limited space)`; 42 | 43 | let localByte = await chrome.storage.local.getBytesInUse(); 44 | document.getElementById( "localSpace" ).innerText = `Local storage ${( localByte / 1024 ).toFixed( 2 )}K`; 45 | } 46 | 47 | /* Favorite fetching ///////////////////////////////////////////////*/ 48 | 49 | document.getElementById( "update" ).addEventListener( "click", function () 50 | { 51 | _StartAnimateStatusText(); 52 | SetStatusText( "Fetching... (might take a while)" ); 53 | chrome.runtime.sendMessage( { cmd: "getfav" }, ( response ) => OnFavLoaded( response.succeed, response.reason ) ); 54 | 55 | } ); 56 | 57 | function OnFavLoaded( succeed, reason ) 58 | { 59 | _StopAnimateStatusText(); 60 | if( succeed ) 61 | { 62 | alert( `Done, ${reason} books fetched from favorite.` ); 63 | RefreshPage(); 64 | } 65 | else 66 | { 67 | SetStatusText( "Error due to " + reason ); 68 | } 69 | } 70 | 71 | let g_AnimateStatusText = false; 72 | function _StartAnimateStatusText() 73 | { 74 | g_AnimateStatusText = true; 75 | setTimeout( _AnimateStatusText, 500 ); 76 | } 77 | 78 | function _AnimateStatusText() 79 | { 80 | SetStatusText( "|", true ); 81 | if( g_AnimateStatusText ) // While not stop 82 | setTimeout( _AnimateStatusText, 250 ); 83 | } 84 | 85 | function _StopAnimateStatusText() 86 | { 87 | g_AnimateStatusText = false; 88 | } 89 | 90 | /* Missing book info filing /////////////////////////////////////////////*/ 91 | 92 | document.getElementById( "getmissinginfo" ).addEventListener( "click", function () 93 | { 94 | if( confirm( "This will fetch book with missing name.\nLeave it running until dialog box come up, do not press the button again.\nIf you have too many data too fetch, site might kick you, try again later." ) ) 95 | { 96 | GetMissingBookInfoOneByOne( ); 97 | } 98 | 99 | } ); 100 | 101 | async function GetMissingBookInfoOneByOne( ) 102 | { 103 | SetStatusText( "Fetching... (might take a while)" ); 104 | let working = true; 105 | while( working ) 106 | { 107 | await SendMessagePromise( { cmd: "getmissinginfo" }, ( response ) => { 108 | if( response.succeed ) 109 | { 110 | // if reason is non null, background has fetched something 111 | // if reason is null, background has no more to fetch 112 | let reason = response.reason; 113 | if( reason == null ) 114 | { 115 | // Done 116 | alert( "DONE, No more to fetch!" ); 117 | RefreshPage( ); 118 | working = false; 119 | } 120 | else 121 | { 122 | // 123 | SetStatusText( `Fetching... ${reason}` ); 124 | } 125 | } 126 | else 127 | { 128 | working = false; 129 | SetStatusText( "Error due to " + reason ); 130 | } 131 | } ); 132 | } 133 | 134 | } 135 | 136 | function OnMissingInfoLoaded( succeed, reason ) 137 | { 138 | 139 | } 140 | 141 | /* History submit service ////////////////////////////////////////////////*/ 142 | 143 | document.getElementById( "historySubmit" ).addEventListener( "submit", RunHistoryCheckUsingGoogleTakeOut ); 144 | document.getElementById( "historySubmit2" ).addEventListener( "submit", RunHistoryCheckUsingLineFile ); 145 | 146 | function _LoadFileFromFileElement( elementId, callback ) 147 | { 148 | const selectedFile = document.getElementById( elementId ).files[0]; 149 | if( selectedFile != null ) // selectedFile is file object 150 | { 151 | let reader = new FileReader(); 152 | reader.onload = function ( e ) 153 | { 154 | callback( e.target.result ); 155 | }; 156 | reader.readAsText( selectedFile ); 157 | } 158 | else 159 | { 160 | alert( "File not selected, dont be shy, select one!" ); 161 | } 162 | event.preventDefault(); // Do not reload the page 163 | } 164 | 165 | function RunHistoryCheckUsingGoogleTakeOut( event ) 166 | { 167 | _LoadFileFromFileElement( "historyFile", _ProcessHistoryGoogleTakeOut ); 168 | } 169 | 170 | function RunHistoryCheckUsingLineFile( event ) 171 | { 172 | _LoadFileFromFileElement( "historyFile2", _ProcessHistoryLineFile ); 173 | } 174 | 175 | /* 176 | * File look like this 177 | * { 178 | "Browser History": [ 179 | { 180 | "favicon_url": "https://nhentai.net/favicon.ico", 181 | "page_transition": "RELOAD", 182 | "title": "Asa Okitara Imouto ga Hadaka Apron Sugata datta node Hamete Mita | I Woke Up to my Naked Apron Sister and Tried Fucking Her - Ch.2 » nhentai: hentai doujinshi and manga", 183 | "url": "https://nhentai.net/g/394923/", 184 | "client_id": "xPuNMd8l0AefScLPuV8eYA==", 185 | "time_usec": 1647165548781917 186 | }, 187 | * */ 188 | function _ProcessHistoryGoogleTakeOut( text ) 189 | { 190 | var obj = null; 191 | try 192 | { 193 | obj = JSON.parse( text ); 194 | } catch( _ ) { } 195 | 196 | if( obj == null ) 197 | { 198 | alert( "Not valid JSON" ); 199 | return; 200 | } 201 | 202 | obj = obj["Browser History"]; // This resolve to an array 203 | if( obj == null ) 204 | { 205 | alert( "Not valid Google Browser History file" ); 206 | return; 207 | } 208 | 209 | let seen = {}; 210 | let c = obj.length; 211 | for( let i = 0; i < c; i++ ) 212 | { 213 | let entry = obj[i]; 214 | // Inspect this URL 215 | let page = ParseBookNumberFromUrl( entry.url ); 216 | if( page.bookId == 0 ) 217 | continue; 218 | 219 | // Write into this in case there is dupe URL 220 | seen[page.bookId] = 1; 221 | } 222 | 223 | alert( "Imported total " + Object.keys( seen ).length + " entire(s)." ); 224 | 225 | for( let bookId in seen ) 226 | { 227 | chrome.runtime.sendMessage( 228 | { 229 | cmd: "setbook", 230 | id: bookId, 231 | state: STATE_READ, 232 | info: null // Have no info at this moment 233 | } ); 234 | } 235 | 236 | sleep( 100 ).then( RefreshPage ); 237 | } 238 | 239 | function _ProcessHistoryLineFile( text ) 240 | { 241 | // read each line then scan for URL 242 | let lines = text.split( /\r?\n/ ); 243 | 244 | let seen = {}; 245 | let c = lines.length; 246 | for( let i = 0; i < c; i++ ) 247 | { 248 | let line = lines[i]; 249 | 250 | // Inspect this URL 251 | let page = ParseBookNumberFromUrl( line ); 252 | if( page.bookId == 0 ) 253 | continue; 254 | 255 | // Write into this in case there is dupe URL 256 | seen[page.bookId] = 1; 257 | } 258 | 259 | alert( "Imported total " + Object.keys( seen ).length + " entire(s)." ); 260 | 261 | for( let bookId in seen ) 262 | { 263 | chrome.runtime.sendMessage( 264 | { 265 | cmd: "setbook", 266 | id: bookId, 267 | state: STATE_READ, 268 | info: null // Have no info at this moment 269 | } ); 270 | } 271 | 272 | sleep( 100 ).then( RefreshPage ); 273 | } 274 | 275 | /* Book listing ////////////////////////////////////*/ 276 | 277 | bookDisplayType.addEventListener( 'change', function () 278 | { 279 | g_DisplayFilter = this.options[this.selectedIndex].value; 280 | 281 | // Also save this new option 282 | chrome.storage.local.set( { g_DisplayFilter } ); 283 | RefreshPage(); 284 | } ); 285 | 286 | let g_DisplayFilter = null; 287 | 288 | function RefreshPage() 289 | { 290 | // Write extension version 291 | { 292 | var manifestData = chrome.runtime.getManifest(); 293 | document.getElementById( "version" ).innerText = "v" + manifestData.version; 294 | } 295 | 296 | WriteStorageInfoText(); 297 | 298 | chrome.runtime.sendMessage( { cmd: "getbook" }, ( response ) => 299 | { 300 | let books = response.books; 301 | 302 | let allCount = 0; 303 | let favCount = 0; 304 | let ignoreCount = 0; 305 | let toreadCount = 0; 306 | 307 | for( let bookId in books ) 308 | { 309 | let state = books[bookId]; 310 | if( state == null || state === 0 ) 311 | continue; 312 | 313 | allCount++; 314 | if( state === STATE_FAV ) 315 | favCount++; 316 | else if( state === STATE_IGNORE ) 317 | ignoreCount++; 318 | else if( state === STATE_TOREAD ) 319 | toreadCount++; 320 | } 321 | 322 | SetStatusText( `You have read ${allCount} books, ${favCount} in favorite. (${toreadCount} in reading queue) (${ignoreCount} ignored)` ); 323 | 324 | DisplayReadBooks( books ); 325 | } ); 326 | } 327 | 328 | async function DisplayReadBooks( bookState ) 329 | { 330 | let root = document.getElementById( "bookDisplay" ); 331 | root.innerHTML = ''; // Clear all children 332 | 333 | // If this is first run, g_DisplayFilter will be null on page load 334 | // Check for previous value in storage 335 | if( g_DisplayFilter == null ) 336 | { 337 | // Ask from last time, or default to "default" filter 338 | let save = await chrome.storage.local.get( { g_DisplayFilter: "default" } ); 339 | g_DisplayFilter = save["g_DisplayFilter"]; 340 | bookDisplayType.value = g_DisplayFilter; 341 | } 342 | 343 | let allIds = Object.keys( bookState ); 344 | 345 | // Sort ascending, but convert to number first 346 | allIds = allIds.map( ( item ) => parseInt( item ) ); 347 | allIds.sort( ( a, b ) => a - b ); // Sort using number method 348 | 349 | let processLater = []; 350 | let c = allIds.length; 351 | for( let i = 0; i < c; i++ ) 352 | { 353 | let id = allIds[i]; 354 | if( id == null || id == 0 ) // ID zero is never shown 355 | continue; 356 | 357 | // Check with filter 358 | let state = bookState[id]; 359 | if( state == null || state === 0 ) 360 | continue; // Book can be in Unread (0) state by index page selector override, filter it out 361 | 362 | if( g_DisplayFilter === "fav" ) 363 | { 364 | if( state !== STATE_FAV ) 365 | continue; 366 | } 367 | else if( g_DisplayFilter === "toread" ) 368 | { 369 | if( state !== STATE_TOREAD ) 370 | continue; 371 | } 372 | else if( g_DisplayFilter === "ignore" ) 373 | { 374 | if( state !== STATE_IGNORE ) 375 | continue; 376 | } 377 | else // Default filter 378 | { 379 | // Filter only these 380 | if( state !== STATE_READ && state !== STATE_FAV ) 381 | continue; 382 | } 383 | 384 | let line = document.createElement( "div" ); 385 | root.appendChild( line ); 386 | 387 | let link = document.createElement( "a" ); 388 | line.appendChild( link ); 389 | link.textContent = id; 390 | link.href = "https://nhentai.net/g/" + id; 391 | link.target = "_blank"; // Open in new tab 392 | 393 | let text = document.createTextNode( "" ); 394 | line.appendChild( text ); 395 | processLater.push( { line: text, id, state } ); 396 | } 397 | 398 | _QueryBookInfoForList( processLater ); 399 | } 400 | 401 | let g_QueryJobId = 0; 402 | async function _QueryBookInfoForList( list ) 403 | { 404 | // While for loop is running on very long list, user could change filter option 405 | // Will use g_QueryJobId to check if this job can be terminated 406 | // Every new call to _QueryBookInfoForList will increment to new job id 407 | const thisJobId = ++g_QueryJobId; 408 | const c = list.length; 409 | let haveInfo = 0; 410 | for( let i = 0; i < c; i++ ) 411 | { 412 | if( g_QueryJobId != thisJobId ) break; 413 | 414 | let item = list[i]; 415 | await SendMessagePromise( { cmd: "getbookinfo", id: item.id }, ( response ) => 416 | { 417 | if( g_QueryJobId != thisJobId ) return; 418 | if( item.line == null ) return; // Maybe destroyed from switching 419 | if( response.bookInfo == null ) return; 420 | 421 | haveInfo++; 422 | _WriteBookLineInfo( item.line, response.bookInfo, item.state ); 423 | } ); 424 | 425 | SetStatusText2( `Book info available ${haveInfo}/${c}` ); 426 | } 427 | } 428 | 429 | function _WriteBookLineInfo( textNode, bookInfo, state ) 430 | { 431 | let txt = " " + bookInfo.name; 432 | if( state === STATE_FAV ) 433 | txt += " (FAVORITE)"; 434 | if( state === STATE_IGNORE ) 435 | txt += " (IGNORED)"; 436 | if( state === STATE_TOREAD ) 437 | txt += " (TO READ LATER)"; 438 | textNode.textContent = txt; 439 | } 440 | 441 | /* Import export service ///////////////////////////////////////////////*/ 442 | 443 | document.getElementById( "exportData" ).addEventListener( "click", ExportUserData ); 444 | document.getElementById( "importData" ).addEventListener( "click", ImportUserData ); 445 | 446 | function ExportUserData() 447 | { 448 | chrome.runtime.sendMessage( { cmd: "dump" }, ( response ) => 449 | { 450 | let books = response.books; // Book state 451 | let db = response.db; // Book info db 452 | let json = JSON.stringify( { books, db } ); 453 | let blob = new Blob( [json], { type: "text/plain" } ); 454 | var url = URL.createObjectURL( blob ); 455 | chrome.downloads.download( { 456 | url: url, 457 | filename: "NHTrackerData.json", 458 | saveAs: true // Download using save as dialog 459 | } ); 460 | } ); 461 | } 462 | 463 | function ImportUserData() 464 | { 465 | _LoadFileFromFileElement( "importFile", _ProcessImportUserData ); 466 | } 467 | 468 | function _ProcessImportUserData( text ) 469 | { 470 | try 471 | { 472 | let obj = JSON.parse( text ); 473 | if( obj.books != null && obj.db != null ) 474 | { 475 | chrome.runtime.sendMessage( { cmd: "importDump", books: obj.books, db: obj.db } ); 476 | alert( 477 | `Merged ${Object.keys(obj.books).length} books state, ${Object.keys(obj.db).length} DB entires.` + 478 | "\nNote that this is a merge operation, if you wish to start new, wipe data then import again." 479 | ); 480 | setTimeout( RefreshPage, 300 ); 481 | } 482 | else 483 | { 484 | throw false; 485 | } 486 | } 487 | catch( e ) 488 | { 489 | alert( "File is not valid NHTracker save file" ); 490 | } 491 | } 492 | 493 | /* Page init //////////////////////////////////////////////////////////*/ 494 | RefreshPage(); 495 | -------------------------------------------------------------------------------- /js/lz-string.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 Pieroxy 2 | // This work is free. You can redistribute it and/or modify it 3 | // under the terms of the WTFPL, Version 2 4 | // For more information see LICENSE.txt or http://www.wtfpl.net/ 5 | // 6 | // For more information, the home page: 7 | // http://pieroxy.net/blog/pages/lz-string/testing.html 8 | // 9 | // LZ-based compression algorithm, version 1.4.4 10 | var LZString = (function() { 11 | 12 | // private property 13 | var f = String.fromCharCode; 14 | var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; 15 | var keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$"; 16 | var baseReverseDic = {}; 17 | 18 | function getBaseValue(alphabet, character) { 19 | if (!baseReverseDic[alphabet]) { 20 | baseReverseDic[alphabet] = {}; 21 | for (var i=0 ; i>> 8; 66 | buf[i*2+1] = current_value % 256; 67 | } 68 | return buf; 69 | }, 70 | 71 | //decompress from uint8array (UCS-2 big endian format) 72 | decompressFromUint8Array:function (compressed) { 73 | if (compressed===null || compressed===undefined){ 74 | return LZString.decompress(compressed); 75 | } else { 76 | var buf=new Array(compressed.length/2); // 2 bytes per character 77 | for (var i=0, TotalLen=buf.length; i> 1; 159 | } 160 | } else { 161 | value = 1; 162 | for (i=0 ; i> 1; 184 | } 185 | } 186 | context_enlargeIn--; 187 | if (context_enlargeIn == 0) { 188 | context_enlargeIn = Math.pow(2, context_numBits); 189 | context_numBits++; 190 | } 191 | delete context_dictionaryToCreate[context_w]; 192 | } else { 193 | value = context_dictionary[context_w]; 194 | for (i=0 ; i> 1; 204 | } 205 | 206 | 207 | } 208 | context_enlargeIn--; 209 | if (context_enlargeIn == 0) { 210 | context_enlargeIn = Math.pow(2, context_numBits); 211 | context_numBits++; 212 | } 213 | // Add wc to the dictionary. 214 | context_dictionary[context_wc] = context_dictSize++; 215 | context_w = String(context_c); 216 | } 217 | } 218 | 219 | // Output the code for w. 220 | if (context_w !== "") { 221 | if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)) { 222 | if (context_w.charCodeAt(0)<256) { 223 | for (i=0 ; i> 1; 244 | } 245 | } else { 246 | value = 1; 247 | for (i=0 ; i> 1; 269 | } 270 | } 271 | context_enlargeIn--; 272 | if (context_enlargeIn == 0) { 273 | context_enlargeIn = Math.pow(2, context_numBits); 274 | context_numBits++; 275 | } 276 | delete context_dictionaryToCreate[context_w]; 277 | } else { 278 | value = context_dictionary[context_w]; 279 | for (i=0 ; i> 1; 289 | } 290 | 291 | 292 | } 293 | context_enlargeIn--; 294 | if (context_enlargeIn == 0) { 295 | context_enlargeIn = Math.pow(2, context_numBits); 296 | context_numBits++; 297 | } 298 | } 299 | 300 | // Mark the end of the stream 301 | value = 2; 302 | for (i=0 ; i> 1; 312 | } 313 | 314 | // Flush the last char 315 | while (true) { 316 | context_data_val = (context_data_val << 1); 317 | if (context_data_position == bitsPerChar-1) { 318 | context_data.push(getCharFromInt(context_data_val)); 319 | break; 320 | } 321 | else context_data_position++; 322 | } 323 | return context_data.join(''); 324 | }, 325 | 326 | decompress: function (compressed) { 327 | if (compressed == null) return ""; 328 | if (compressed == "") return null; 329 | return LZString._decompress(compressed.length, 32768, function(index) { return compressed.charCodeAt(index); }); 330 | }, 331 | 332 | _decompress: function (length, resetValue, getNextValue) { 333 | var dictionary = [], 334 | next, 335 | enlargeIn = 4, 336 | dictSize = 4, 337 | numBits = 3, 338 | entry = "", 339 | result = [], 340 | i, 341 | w, 342 | bits, resb, maxpower, power, 343 | c, 344 | data = {val:getNextValue(0), position:resetValue, index:1}; 345 | 346 | for (i = 0; i < 3; i += 1) { 347 | dictionary[i] = i; 348 | } 349 | 350 | bits = 0; 351 | maxpower = Math.pow(2,2); 352 | power=1; 353 | while (power!=maxpower) { 354 | resb = data.val & data.position; 355 | data.position >>= 1; 356 | if (data.position == 0) { 357 | data.position = resetValue; 358 | data.val = getNextValue(data.index++); 359 | } 360 | bits |= (resb>0 ? 1 : 0) * power; 361 | power <<= 1; 362 | } 363 | 364 | switch (next = bits) { 365 | case 0: 366 | bits = 0; 367 | maxpower = Math.pow(2,8); 368 | power=1; 369 | while (power!=maxpower) { 370 | resb = data.val & data.position; 371 | data.position >>= 1; 372 | if (data.position == 0) { 373 | data.position = resetValue; 374 | data.val = getNextValue(data.index++); 375 | } 376 | bits |= (resb>0 ? 1 : 0) * power; 377 | power <<= 1; 378 | } 379 | c = f(bits); 380 | break; 381 | case 1: 382 | bits = 0; 383 | maxpower = Math.pow(2,16); 384 | power=1; 385 | while (power!=maxpower) { 386 | resb = data.val & data.position; 387 | data.position >>= 1; 388 | if (data.position == 0) { 389 | data.position = resetValue; 390 | data.val = getNextValue(data.index++); 391 | } 392 | bits |= (resb>0 ? 1 : 0) * power; 393 | power <<= 1; 394 | } 395 | c = f(bits); 396 | break; 397 | case 2: 398 | return ""; 399 | } 400 | dictionary[3] = c; 401 | w = c; 402 | result.push(c); 403 | while (true) { 404 | if (data.index > length) { 405 | return ""; 406 | } 407 | 408 | bits = 0; 409 | maxpower = Math.pow(2,numBits); 410 | power=1; 411 | while (power!=maxpower) { 412 | resb = data.val & data.position; 413 | data.position >>= 1; 414 | if (data.position == 0) { 415 | data.position = resetValue; 416 | data.val = getNextValue(data.index++); 417 | } 418 | bits |= (resb>0 ? 1 : 0) * power; 419 | power <<= 1; 420 | } 421 | 422 | switch (c = bits) { 423 | case 0: 424 | bits = 0; 425 | maxpower = Math.pow(2,8); 426 | power=1; 427 | while (power!=maxpower) { 428 | resb = data.val & data.position; 429 | data.position >>= 1; 430 | if (data.position == 0) { 431 | data.position = resetValue; 432 | data.val = getNextValue(data.index++); 433 | } 434 | bits |= (resb>0 ? 1 : 0) * power; 435 | power <<= 1; 436 | } 437 | 438 | dictionary[dictSize++] = f(bits); 439 | c = dictSize-1; 440 | enlargeIn--; 441 | break; 442 | case 1: 443 | bits = 0; 444 | maxpower = Math.pow(2,16); 445 | power=1; 446 | while (power!=maxpower) { 447 | resb = data.val & data.position; 448 | data.position >>= 1; 449 | if (data.position == 0) { 450 | data.position = resetValue; 451 | data.val = getNextValue(data.index++); 452 | } 453 | bits |= (resb>0 ? 1 : 0) * power; 454 | power <<= 1; 455 | } 456 | dictionary[dictSize++] = f(bits); 457 | c = dictSize-1; 458 | enlargeIn--; 459 | break; 460 | case 2: 461 | return result.join(''); 462 | } 463 | 464 | if (enlargeIn == 0) { 465 | enlargeIn = Math.pow(2, numBits); 466 | numBits++; 467 | } 468 | 469 | if (dictionary[c]) { 470 | entry = dictionary[c]; 471 | } else { 472 | if (c === dictSize) { 473 | entry = w + w.charAt(0); 474 | } else { 475 | return null; 476 | } 477 | } 478 | result.push(entry); 479 | 480 | // Add w+entry[0] to the dictionary. 481 | dictionary[dictSize++] = w + entry.charAt(0); 482 | enlargeIn--; 483 | 484 | w = entry; 485 | 486 | if (enlargeIn == 0) { 487 | enlargeIn = Math.pow(2, numBits); 488 | numBits++; 489 | } 490 | 491 | } 492 | } 493 | }; 494 | return LZString; 495 | })(); 496 | 497 | if (typeof define === 'function' && define.amd) { 498 | define(function () { return LZString; }); 499 | } else if( typeof module !== 'undefined' && module != null ) { 500 | module.exports = LZString 501 | } else if( typeof angular !== 'undefined' && angular != null ) { 502 | angular.module('LZString', []) 503 | .factory('LZString', function () { 504 | return LZString; 505 | }); 506 | } -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | /* Include ////////////////////////////////////////////////////*/ 2 | 3 | try 4 | { 5 | importScripts( '/js/include.js', '/js/lz-string.js' ); 6 | } 7 | catch( e ) 8 | { 9 | console.error( e ); 10 | } 11 | 12 | /* Background worker ////////////////////////////////////////////*/ 13 | 14 | // All read book numbers 15 | // Key: string book number 16 | // Value: int book state, see STATE_READ 17 | let g_ReadBooks = {}; 18 | 19 | // Basic book data such as name or image 20 | // Get updated only in occasion that plugin come into contact with such info 21 | // Key: string book number 22 | // Value: Doujinshi object 23 | let g_BookDb = {}; 24 | 25 | chrome.action.onClicked.addListener( ( tab ) => 26 | { 27 | chrome.runtime.openOptionsPage(); 28 | } ); 29 | 30 | chrome.runtime.onInstalled.addListener( () => 31 | { 32 | 33 | } ); 34 | 35 | chrome.runtime.onSuspend.addListener( () => 36 | { 37 | // This never called somehow 38 | console.log( "Suspending event page" ); 39 | SaveDatabase(); 40 | } ); 41 | 42 | // We dont know when chrome will unload our background task, so save quickly 43 | const SAVE_INTERVAL = 4; 44 | function OnUpdateFunction() 45 | { 46 | SaveDatabase(); // Check check dirty flag inside 47 | setTimeout( OnUpdateFunction, SAVE_INTERVAL * 1000 ); // Recall this after some time 48 | } 49 | 50 | chrome.runtime.onMessage.addListener( _OnMessage ); 51 | 52 | function _OnMessage( request, sender, sendResponse ) 53 | { 54 | //console.log( "onMessage ", request, sender.tab ? "from a content script:" + sender.tab.url : "from the extension" ); 55 | 56 | // Note: As chrome background worker can be put into sleep (unload) 57 | // Most API will need to wait for DB to load first if message is sent from child content script just wake the worker 58 | if( request.cmd === "setbook" ) 59 | { 60 | _WriteBookStateFromRequestAsync( request ); 61 | } 62 | else if( request.cmd == "getbook" ) 63 | { 64 | _GetBookStateAsync( () => sendResponse( { books: g_ReadBooks } ) ); 65 | return true; 66 | } 67 | else if( request.cmd == "getfav" ) 68 | { 69 | LoadFavorites( ( succeed, reason ) => sendResponse( { succeed, reason } ) ); 70 | return true; // This is async request, it took long 71 | } 72 | else if( request.cmd == "getmissinginfo" ) 73 | { 74 | LoadMissingInfo( ( succeed, reason ) => sendResponse( { succeed, reason } ) ); 75 | return true; // This is async request, it took long 76 | } 77 | else if( request.cmd == "getbookinfo" ) 78 | { 79 | GetBookInfoAsync( request.id, ( bookInfo ) => sendResponse( { bookInfo } ) ); 80 | return true; 81 | } 82 | else if( request.cmd == "save" ) 83 | { 84 | g_BookStateDirty = true; 85 | g_DbStateDirty = true; 86 | SaveDatabase(); 87 | } 88 | else if( request.cmd == "bookinfohint" ) 89 | { 90 | _SaveBookInfoFromRequestAsync( request ); 91 | } 92 | else if( request.cmd == "wipe" ) 93 | { 94 | g_ReadBooks = {}; 95 | g_BookDb = {}; 96 | chrome.storage.local.clear(); 97 | chrome.storage.sync.clear(); 98 | SaveDatabase( true ); 99 | } 100 | else if( request.cmd == "dump" ) 101 | { 102 | _GetBookStateAsync( () => sendResponse( { books: g_ReadBooks, db: g_BookDb } ) ); 103 | return true; // This is async request, it took long 104 | } 105 | else if( request.cmd == "importDump" ) 106 | { 107 | _GetBookStateAsync( () => 108 | { 109 | MergeObject( g_ReadBooks, request.books ); 110 | MergeObject( g_BookDb, request.db ); 111 | SaveDatabase( true ); 112 | } ); 113 | return true; // This is async request, it took long 114 | } 115 | } 116 | 117 | async function _WriteBookStateFromRequestAsync( request ) 118 | { 119 | await WaitForDbToLoad(); 120 | if( request.info != null ) 121 | SetBookInfo( request.info ); 122 | SetCoverState( request.id, request.state, request.force ); 123 | } 124 | 125 | async function _GetBookStateAsync( callback ) 126 | { 127 | await WaitForDbToLoad(); 128 | callback(); 129 | } 130 | 131 | async function _SaveBookInfoFromRequestAsync( request ) 132 | { 133 | await WaitForDbToLoad(); 134 | if( request.info != null ) 135 | { 136 | // Only write if we have seen this book in state 137 | let bookInfo = request.info; 138 | let state = g_ReadBooks[bookInfo.id]; 139 | if( state != null && state > 0 ) 140 | SetBookInfo( request.info ); 141 | } 142 | } 143 | 144 | let g_DatabaseLoaded = 0; 145 | function InitDatabase() 146 | { 147 | if( g_DatabaseLoaded != 0 ) 148 | return; 149 | 150 | g_DatabaseLoaded++; 151 | let OnLoadBookState = function ( save ) 152 | { 153 | g_DatabaseLoaded++; 154 | if( save.books == null ) return; 155 | 156 | // Use merge 157 | console.log( "InitDatabase OnLoadBookState called" ); 158 | MergeObject( g_ReadBooks, save.books ); 159 | //console.log( 'InitDatabase books: ', Object.keys( g_ReadBooks ).length ); 160 | }; 161 | 162 | console.log( "Issue InitDatabase" ); 163 | SyncGetPartitioned( "books", OnLoadBookState ); // Migrate from 1.0.x, try in sync storage first 164 | SyncGetPartitioned( "books", OnLoadBookState, "local" ); // Then local for 1.1.x 165 | 166 | SyncGetPartitioned( "bookdb", 167 | function ( save ) 168 | { 169 | g_DatabaseLoaded++; 170 | if( save.bookdb == null ) return; 171 | g_BookDb = save.bookdb; 172 | //console.log( 'InitDatabase bookdb: ', Object.keys( g_BookDb ).length ); 173 | }, "local" ); 174 | } 175 | 176 | async function WaitForDbToLoad() 177 | { 178 | // Have to wait for DB to load 179 | while( g_DatabaseLoaded < 4 ) 180 | { 181 | console.log( "Stall to wait for g_DatabaseLoaded" ); 182 | await sleep( 50 ); 183 | } 184 | } 185 | 186 | async function _WaitForDbToLoadAndResponseAsync( callback ) 187 | { 188 | await WaitForDbToLoad(); 189 | callback(); 190 | } 191 | 192 | 193 | let g_BookStateDirty = false; 194 | let g_DbStateDirty = false; 195 | 196 | function SaveDatabase( force ) 197 | { 198 | if( force || g_BookStateDirty ) 199 | { 200 | SyncStorePartitioned( "books", g_ReadBooks, "local" ); // 1.1.x now save to local for unlimited storage 201 | console.log( "SaveDatabase ran" ); 202 | } 203 | 204 | if( force || g_DbStateDirty ) 205 | SyncStorePartitioned( "bookdb", g_BookDb, "local" ); 206 | 207 | g_BookStateDirty = false; 208 | g_DbStateDirty = false; 209 | } 210 | 211 | function SetCoverState( id, targetState, force ) 212 | { 213 | if( targetState === undefined ) 214 | targetState = STATE_READ; 215 | 216 | let state = g_ReadBooks[id]; 217 | if( force || _CanBookStateTranslateFrom( state, targetState ) ) // Write only if state translation is allowed 218 | { 219 | console.log( `Set book state ${id} from ${state} => ${targetState}` ); 220 | g_ReadBooks[id] = targetState; 221 | g_BookStateDirty = true; 222 | } 223 | } 224 | 225 | function _CanBookStateTranslateFrom( from, to ) 226 | { 227 | if( from === undefined ) 228 | return true; // If first state is blank, then it can turn into any 229 | 230 | // TOREAD is special state, it is actually has priority lower then READ (1) but since we cannot have value under 1 231 | // It is hacked for state transition here 232 | // It can turn into any state higher than 0 233 | if( from === STATE_TOREAD && to > 0 ) 234 | return true; 235 | 236 | // Else, generic rule, state must be higher in number only 237 | return to > from; 238 | } 239 | 240 | function SetBookInfo( bookInfo ) 241 | { 242 | // bookInfo object must have id, name field 243 | if( bookInfo == null || bookInfo.id == null || bookInfo.name == null ) 244 | { 245 | console.error( "SetBookInfo passes invalid argument bookInfo", bookInfo ); 246 | return; 247 | } 248 | 249 | console.log( "save book info for", bookInfo.id ); 250 | g_BookDb[bookInfo.id] = bookInfo; 251 | g_DbStateDirty = true; 252 | } 253 | 254 | // Request book db info, search cache or fire API. 255 | // callback with Doujinshi object 256 | async function GetBookInfoAsync( id, callback ) 257 | { 258 | await WaitForDbToLoad(); 259 | 260 | // Must wait for DB to load first 261 | if( g_BookDb[id] !== undefined ) 262 | { 263 | callback( g_BookDb[id] ); 264 | } 265 | 266 | // TODO: Fire nhentai API and cache 267 | callback( undefined ); 268 | } 269 | 270 | InitDatabase(); 271 | OnUpdateFunction(); // This will fire initial save cycle chain 272 | 273 | /* Helper for storage partitioning //////////////////////////////////*/ 274 | 275 | function _MakeStoreSegmentName( key, index ) 276 | { 277 | return key + "_" + index; 278 | } 279 | 280 | function SyncStorePartitioned( key, objectToStore, storageApi ) 281 | { 282 | let i = 0; 283 | let segmentName = ""; 284 | let storage = {}; 285 | let str = JSON.stringify( objectToStore ); 286 | 287 | if( storageApi === "local" ) 288 | storageApi = chrome.storage.local; 289 | else 290 | storageApi = chrome.storage.sync; 291 | 292 | // Technical note: if string contains a lot of escape character, re-json it will produce more escape character 293 | // Better gzip that 294 | str = LZString.compressToBase64( str ); 295 | const LIMIT = storageApi.QUOTA_BYTES_PER_ITEM - key.length - 16; // Reserve 16 byte for safer margin 296 | 297 | while( str.length > 0 ) 298 | { 299 | segmentName = _MakeStoreSegmentName( key, i ); 300 | let thisCycle = str.length; 301 | if( thisCycle > LIMIT ) 302 | thisCycle = LIMIT; 303 | storage[segmentName] = str.substr( 0, thisCycle ); 304 | str = str.substring( thisCycle, str.length ); // Next part 305 | i++; 306 | } 307 | 308 | // As how the fetching logic will work in reverse 309 | // Also make sure that next i+1 chunk remain saved as empty string to know that this is terminal 310 | // This is also required if new saving data size is shrinking from previous (has fewer chunk count) 311 | segmentName = _MakeStoreSegmentName( key, i ); 312 | storage[segmentName] = ""; 313 | 314 | // Store all chunks 315 | storageApi.set( storage ); 316 | } 317 | 318 | function SyncGetPartitioned( key, callback, storageApi ) 319 | { 320 | if( storageApi === "local" ) 321 | storageApi = chrome.storage.local; 322 | else 323 | storageApi = chrome.storage.sync; 324 | 325 | _SyncGetPartitionedInternal( key, 0, "", callback, storageApi ); 326 | } 327 | 328 | function _SyncGetPartitionedInternal( key, index, str, callback, storageApi ) 329 | { 330 | let segmentName = _MakeStoreSegmentName( key, index ); 331 | storageApi.get( segmentName, function ( elems ) 332 | { 333 | let data = elems[segmentName]; 334 | if( data === undefined || data === "" ) // Found terminal 335 | { 336 | try 337 | { 338 | str = LZString.decompressFromBase64( str ); 339 | 340 | // Send combined result 341 | // But return it as same format of which storage would do 342 | let obj = {}; 343 | obj[key] = JSON.parse( str ); 344 | callback( obj ); 345 | } 346 | catch( error ) 347 | { 348 | console.log( "Error in parsing data from storage", key, error ); 349 | callback( undefined ); 350 | } 351 | } 352 | else 353 | { 354 | // Deeper! 355 | _SyncGetPartitionedInternal( key, index + 1, str + data, callback, storageApi ); 356 | } 357 | } ); 358 | } 359 | 360 | /* Fetching favorite //////////////////////////////////////////////*/ 361 | 362 | let g_FavAdded = 0; 363 | 364 | function LoadFavorites( callback ) 365 | { 366 | g_FavAdded = 0; 367 | fetch( "https://nhentai.net/favorites/" ) 368 | .then( ( response ) => 369 | { 370 | if( response.status === 429 ) 371 | { 372 | //loadingCallback( undefined ); // Not logged in 373 | Promise.reject( new Error( "Not logged in" ) ); 374 | } 375 | else if( response.status === 200 ) 376 | { 377 | LoadFavoritePage( 1, callback ); 378 | } 379 | else 380 | { 381 | let errorMsg = `${response.status}: ${response.statusText}`; 382 | Promise.reject( new Error( errorMsg ) ); 383 | console.error( `Error while loading doujinshi count (${errorMsg})` ); 384 | } 385 | } ). 386 | catch( ( error ) => 387 | { 388 | console.error( `Error while loading doujinshi count (${error})` ); 389 | 390 | callback( false, error ); 391 | } ); 392 | } 393 | 394 | /// Load one page of favorite into storage 395 | function LoadFavoritePage( pageNumber, callback ) 396 | { 397 | let target = "https://nhentai.net/favorites/?page=" + pageNumber; 398 | console.log( "Fetch", target ); 399 | 400 | fetch( target ) 401 | .then( ( response ) => 402 | { 403 | if( response.status === 200 ) 404 | return response.text(); 405 | else 406 | Promise.reject( new Error( response.statusText ) ); 407 | } ) 408 | .then( ( text ) => 409 | { 410 | let books = GetDoujinshisFromHtml( text ); 411 | books.forEach( ( book ) => 412 | { 413 | g_FavAdded++; 414 | SetBookInfo( book ); 415 | SetCoverState( book.id, STATE_FAV ); 416 | } ); 417 | 418 | if( books.length > 0 ) 419 | LoadFavoritePage( pageNumber + 1, callback ); 420 | else 421 | callback( true, g_FavAdded ); 422 | } ) 423 | .catch( ( error ) => 424 | { 425 | console.error( "Error while loading favorites page " + pageNumber + " (Code " + error + ")." ); 426 | } ); 427 | 428 | } 429 | 430 | /// Get all doujinshis that are in a page, return an array of Doujinshi 431 | function GetDoujinshisFromHtml( html ) 432 | { 433 | let currDoujinshis = []; 434 | html.split( '