\n"
};
var binary_data =
{
"activate-icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAsCAYAAAAacYo8AAAACXBIWXMAAC4jAAAuIwF4pT92AAAGU2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yN1QwMjoyMjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6N2ZkYzUwY2ItYjgzMy1hNzQzLTllMjYtNzQ1NmM4NDFlNjM0IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MzMyMzRmNjktNjk2OS1jNjQ1LWI0MjgtYmM1NDUwYTM3NDAzIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDozMzIzNGY2OS02OTY5LWM2NDUtYjQyOC1iYzU0NTBhMzc0MDMiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjdUMDI6MjI6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTI3VDAyOjI2OjIwLTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PmQ/KUAAAAQhSURBVFiF7ZlNTxtHGMd/s/aaGGqDHRRLwQergko+lJhW4pJDqdQLpzSfoOHqU/sJGj5By8VXkk8QcuqlUtxjckBWLxxaqVYFVQnCdWiDYy/e6WG8Zr07a68X8xIpfwmtZp6Z2d8+fuaZF4SUkvdRxnUDRNV7Cx4HEEJMbsRKpwTMAc7TUb3316ScqEUd3gltIaW8GHil8zXwBbCGgg2rKvAc2KGcqIftdDHwSmcN+AZ4NF7HQO0AW5QT1VENo4FXOo+A74GCzizaDWg3EN1TOGsNGuNJZOI2cioL8eSwD9ignGhOBnwIsGgdIt4dIlqvwbaGj+O8fCqLTC+pj/Cr2YPfiQ6uJtoPqPj1ARtv9vyeHUMymcPOfAqGqTNvUE488fUZCV7pPEZ52SfRbmAcvYwMPKB4Evv2Z0gzrbP64IPBK5054BkaLzsKAy6TOWR60Qek7WuYdO/cD4r9FXf6dMDjA01UaLxgMP82PeXhwDML2JnlfvnjuzESpqBjSQ6ObNpk6ebXVagd76pGtoVxvIudu68bchtY8Vaer5xqAnqha8BGWOhufh07s0wmZbBaNFktmszPGqSnBfOzBvcW46wWVTzLZA45s9DvK6wTxH913bClHpsGXBm2PdA7lBMrKI8PB777Fd38er+8lI8Nbe/A25nlAS8bJ78HdfHNNcfj2576GiE93c2vg2EiWodhmvf1+Sc9z7vngG0h3h7omhd6K3RfDvimq64JPBy2CPjUi9FxFHP9KO45Id4FOuCBu+CA/4jaAAFUx9k7GEcvEda/YZtrNRDr7UZQs9LAewF63nVCY26cl040p4NaffUrcMldOM8qaoOzCTydHEVIeVbfML/gYB4vJx5PkiesjDd74RqqdaYG13gCerWnwkG0G+EzkmsFvRZwBxrb0s4PaaZGjhEf2SKCXu1ZTN8SLMwbZFLnvvnzsMvfDbtfjv31s7+zYQbtFuvuwqWAG//8yulskd/2TaDrtx/vBoaHTN4JGrbqLlwKuHh7QEy/Ao6UnM4HmZ67CzfqekJOZYNORXXviehSwN0brnEk54pBpk1vxURDJbb/U+S+dmY56BRU1R3hbkSoyI8KA/sVl5rAQ53hUiZnaBkm9mxxGPSXQbvUSODSTGGnlzBO9yOf8uXMAnZqKeicWUdtrWtB/aN53DCR6UW66UWEdQKt1+p51lLPINiprDqy3cpd6FIoOrgbxkyDmcZ7yy6sE7DPem1SQauhW3Xgu6CLIK+igteALdSpZA3NHj4gQ+i0AzwNC+woKnizl6KeAM4laAm4h7qmK6E/kDRRH10DfkGlumYUgMlkFXUIqU5krJC6EXk8ij6AX7WGg6sL0AcaS6E3Ia9Nozz+B/Ctpr4AvKDS0dmuRKOyytYIe21CHGNLfPjP8hXrf5SZd4NRInfBAAAAAElFTkSuQmCC",
"favorited_icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGbWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTFkYzgwNjgtMDM1Ny0xNzRlLTlmMDAtNThjYjk4NTQ4OWQ2IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6NzdlYmJlZGEtYjQ4Yy0yYzRkLTk2MTQtYmM3NmZmN2VjYTU5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0Ij4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSLimIUiIHBob3Rvc2hvcDpMYXllclRleHQ9IuKYhSIvPiA8L3JkZjpCYWc+IDwvcGhvdG9zaG9wOlRleHRMYXllcnM+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0IiBzdEV2dDp3aGVuPSIyMDE4LTA2LTIwVDAwOjU2OjI5LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMWRjODA2OC0wMzU3LTE3NGUtOWYwMC01OGNiOTg1NDg5ZDYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+LpZUDAAABwxJREFUeJzlm29sG2cdxz93tnOOHedP4yZuq7QpibsSBeKWsVWZyqAQaWKTJmBVEjQoSBOVKAiYqkkVlYbGEO94AWKiiGkvOmnrRMUfsSpNUatVtEQg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",
"noise-light.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAD1BMVEXo6Ojn5+fo6Ojn5+fn5+cbG+42AAAACXBIWXMAAAsTAAALEwEAmpwYAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yNlQyMzowMjo0My0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIyIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OWM1ZTI1ZTktNmM0Ny1lMTRkLWI1NmYtMWM3MWRhOTk3NzAxIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ODcxMzVhMGQtMTYwMy1hODRhLTliZWMtNGJhMDllZDE1NGQ2IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MTEzYjRiYTgtMmYzNC0zZTQ2LTgyZmUtNTM2ZDNlZDgzYTlmIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMTNiNGJhOC0yZjM0LTNlNDYtODJmZS01MzZkM2VkODNhOWYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjZUMjM6MDI6NDMtMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjljNWUyNWU5LTZjNDctZTE0ZC1iNTZmLTFjNzFkYTk5NzcwMSIgc3RFdnQ6d2hlbj0iMjAxOC0wNy0wMVQxNzoyMjozNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5BpB4MAAAWVUlEQVR4nKWb25osuXGrfyD4/k9cBHwRzDXy/rw9GvmuR+pVncWMA04cTq2C5I5wXU5HHRDgnmL11BVjMK4BMHY1cFAFTCU4OQVOjEd2ZVdIs3/JPZURwhpF2KoYUDUXMDkXJGlQVHxVixbXtqIpo5rpJArIBQtfh4FiEairACgUaaIgcHGYwQRcpVJ8B1oQnOJQdQD1BDuI1CkuwZf4GnRUVVKLW3D3b8oR/U4TI4JVFeFqptSFY4AWWWIa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",
"noise.png": "data:image/png;base64,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",
"page-icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFwmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjE2Mzg1YjEtMDhiNS1kMjRhLWI0MzItNzAzYjBmNDFjNjE5IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6YmNlMzU0ZTgtMDE3Zi1iMjRkLTg4MTYtOGZkZTZlYTgyZDg5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTMwVDAyOjAxOjE3LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2MTYzODViMS0wOGI1LWQyNGEtYjQzMi03MDNiMGY0MWM2MTkiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+1Ie7qQAAADpJREFUOI1j/P//PwMlgIki3dQwgAWNT8g/jNRwAYolAx8GowZgpgOMeCbVAAYGwomJoAEkuWLgAxEAc7EGJRNwU4UAAAAASUVORK5CYII=",
"play-button.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSIjNDQ0IiBzdHJva2U9IiNlZWUiPg0KICAgIDxwYXRoIGQ9Ik0yNiAxNiBMIDggNCBMIDggMjggTCAyNiAxNiIvPg0KPC9zdmc+DQoNCg==",
"refresh-icon.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMHB4IiBoZWlnaHQ9IjIwcHgiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBzdHlsZT0iZmlsbDogI2ZmZjsgZGlzcGxheTogYmxvY2s7Ij4NCiAgICAgICAgPHBhdGggZD0iTTQ3OS45NzEsMzIuMThjLTIxLjcyLDIxLjIxMS00Mi44OSw0My02NC41Miw2NC4zMDFjLTEuMDUsMS4yMy0yLjI2LTAuMTYtMy4wOS0wLjg1DQogICAgICAgICAgICAgICAgYy0yNC41MTEtMjMuOTgtNTQuNTgtNDIuMjgxLTg3LjIyMS01Mi44NGMtMzcuNi0xMi4xNi03OC40NDktMTQuMDctMTE3LjAyOS01LjU5Yy02OC42NywxNC42Ny0xMjguODExLDY0LjA1OS0xNTYuNDQsMTI4LjYwOQ0KICAgICAgICAgICAgICAgIGMwLjAzMSwwLjAxNCwwLjA2MiwwLjAyNSwwLjA5MywwLjAzOWMtMi4zLDQuNTM3LTMuNjA1LDkuNjY2LTMuNjA1LDE1LjFjMCwxOC40NzUsMTQuOTc3LDMzLjQ1MSwzMy40NTEsMzMuNDUxDQogICAgICAgICAgICAgICAgYzE1LjgzMSwwLDI5LjA4NC0xMS4wMDIsMzIuNTU1LTI1Ljc3M2MxOS43NTctNDEuOTc5LDU4LjgzMi03NC40NDUsMTAzLjk2Ny04NS41MjdjNTIuMi0xMy4xNywxMTEuMzcsMS4zMywxNDkuNCw0MC4wNDENCiAgICAgICAgICAgICAgICBjLTIyLjAzLDIxLjgzLTQ0LjM5MSw0My4zNC02Ni4zMyw2NS4yNmM1OS41Mi0wLjMyLDExOS4wNi0wLjE0MSwxNzguNTktMC4wOUM0ODAuMjkxLDE0OS42MTEsNDc5LjkzMSw5MC44OTEsNDc5Ljk3MSwzMi4xOHoiLz4NCiAgICAgICAgPHBhdGggZD0iTTQzMS42MDksMjk3LjVjLTE0LjYyLDAtMjcuMDQxLDkuMzgzLTMxLjU5MSwyMi40NTNjLTAuMDA5LTAuMDA0LTAuMDE5LTAuMDA4LTAuMDI3LTAuMDEyDQogICAgICAgICAgICAgICAgYy0xOS4xMSw0Mi41OS01Ny41Nyw3Ni4yMTktMTAyLjg0LDg4LjE4Yy01Mi43OTksMTQuMzExLTExMy40NSwwLjI5OS0xNTIuMTc5LTM5LjA1MWMyMS45Mi0yMS43Niw0NC4zNjktNDMuMDEsNjYuMTg5LTY0Ljg2OQ0KICAgICAgICAgICAgICAgIGMtNTkuNywwLjA0OS0xMTkuNDEsMC4wMjktMTc5LjExLDAuMDFjLTAuMTQsNTguNi0wLjE1OSwxMTcuMTg5LDAuMDExLDE3NS43ODljMjEuOTItMjEuOTEsNDMuNzUtNDMuOTEsNjUuNzktNjUuNjk5DQogICAgICAgICAgICAgICAgYzE0LjEwOSwxMy43ODksMjkuNzYsMjYuMDcsNDYuOTIsMzUuODY5YzU0LjczOSwzMS45NzEsMTIzLjM5OSwzOC42MDIsMTgzLjI5OSwxNy44OTENCiAgICAgICAgICAgICAgICBjNTcuNDc3LTE5LjI5NywxMDYuMDczLTYzLjE3OCwxMzEuMjEyLTExOC4zMThjMy42NDUtNS4zNTcsNS43NzYtMTEuODI0LDUuNzc2LTE4Ljc5M0M0NjUuMDYsMzEyLjQ3Nyw0NTAuMDgzLDI5Ny41LDQzMS42MDksMjk3LjUNCiAgICAgICAgICAgICAgICB6Ii8+DQo8L3N2Zz4NCg0K",
"regular_pixiv_icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MDA6MDEtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MDA6MDEtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ZDA2Zjg3ZjUtMzY1MC1lMzRmLWFjY2UtZTFjYzY4OGVkMjY4IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ZGU4MTZkODItNmVkNi1mNTQxLTg3MTEtMGZlZTZkN2EwMWI5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6Y2YxNWJkY2QtOGJhZi0wMjQwLWI4NTktM2VjMGI0MDJlZjE1Ij4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjZjE1YmRjZC04YmFmLTAyNDAtYjg1OS0zZWMwYjQwMmVmMTUiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDA6NTY6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmQwNmY4N2Y1LTM2NTAtZTM0Zi1hY2NlLWUxY2M2ODhlZDI2OCIgc3RFdnQ6d2hlbj0iMjAxOC0wNi0yMFQwMTowMDowMS0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7IMKLyAAAFYUlEQVR4nO3aWcgcRRAH8N9Oji/xTDzxQBMhAfXFCxVBxTx4IQQvRAQ1ICiKihJRUFBBQfFNUIyIgiLexgMUEVTwBI34oohK4n0gajzivbs+1Iy72ezMN7uzm/3G+IdhZ6d7aqqqq6uruqth5cN6sBVuwHnYtrexpvgNT+FifNvdkPR0PB8/4jL/HeFhPs7AN7gNjayhWwFP4A7M3pycbWY0cCHel8qeKeB8LJ8QU5PAUjxJKGBr3D5RdiaDk7AowZ029QVbCh5McMqkuZggDkowb9JcTBBztlTT/xdbvALGuea3uu6nU/QgfUeKUSogEyIRoec6vIxn8QG+x69oop32m4ftsATH4wQsxlRKb+zKaFj5cLvC+xmTTbyDG/ESfqjEE7vhElyA7Y1RGcMSzUb7IywTI3YIVqsmPGEdX+IqLBBR25qe744Mg06BlmDwLlwpEqci2nvjKJwoBFmAufgTP4mY/Gk8JxKVfvgQh2JXvIJ9jNAayiqgmf5eh5vxV06/+Tgd12MvwWieg2thPxGIZX7jIZGJru9D+xvhK07GYzp+pBLKeuf7hHA36C/87nhVOLl7dITPvpHY9Fu9z+fjbDGFnk//98Nq7ICfjWBKFCmghbXpx1boL/hWQvAvcHgXzWFHJnvvGPyS/vbD+pSvtTrWWemD3cjm+WnC5Iqc2tc4rIDWsJiV0nsBN+X0aQn+PlHBEnp9QFOM5v5iBIrQEOv4rGE/XhJXCMd5eU77EmERQ+1g9Y7aKuG5pxM+w8iXpT5IcKlwfnk8LK5CPMMyXNSn/YRhiY8QCR7Hwpz274SSBh6QRKzJi/FiT9tcEb7ePSjRMaElos083IoNgxLNtsQ+7nm+jZhXUyJgmQlIsCcOKOiz3IBWkODvnmdTYu98SljBTMPqgrYX8fsgxHqdYAOfCcFn4l5BgkUiQcrDKgNYQa+QD4gAYyYKn6GJcwrabzEA/90ddxenJ+Ne16uiIc4x8vAV/ihLLBHhbHZfBySmX/fXKTkNEpHTE5qrC+YpdtBvlyWUYBeR5q6oxtNmRUNx6PtWWUKzxZy/tipHE0CRBXxZlkhd5n0/NAraSqfIdVZAkadfUJZIXRXQFjtCeTiwLKG6KuBXkcTl4YiyhOqogBbem6bPUiVlq6MCiAOYPOykE9xNizoqoIFnCtpXGmAVqFtBVFOUu+WdSxClcKXzmbpZwCycVdB+kgELPuqkgKbYs/wtp72BRwwoU10U0MS7iqvZ7jbEDlYdfEBTbM4WBTcH4NxhiM90C2iKxGY3+fn9TgZIf3tRVQFzjPdw5Clx0Jq3rC0U+xhDF3lUUUAbV+soYBSKaKXXjzhYcQ3jErF7PVsFOapawE3C8RyJ13Ti81bXNR26+32OY0U2V2TWl4i6o8r7l6Nwgm1RuXGkWIp2EUflp4pR3FWEpnPS9kb6zl86VSKrRKVIUYZH+II3xAHJSFBVAVM2zsvbopLjyfTqRkNYXEN568iwEPeLc8qRFkxVIdQQh5KfisqOosMKQjlNcRJVRviGKLx6SyyDx6XPR7pyVSU2V5wn3CPOEteLRGW5MNdB5miCHdN3XxVHXG/qrP9jWbKrToGWmNsZthcjdZxOgdQG4a0/FxbzvZj7C8Uavif2SP/PtnHBZffvWDCOSDDpud82vRaVfK+2pbLTYUZGnTOSqc2J/xWgYp1dzdFM8PqkuZgg1iWiJmBLxYpE5NsPTZqTCWAtXsmc4JmiNqgXbflFy2wcuNQJG7AvnVWgLQKV3lrBBPfmEGmLkLVuK8lHIuT+k03r95eJGv41eFRsMV9TQOxo7Gzm1BLmoSmO044WGyn/ZrD/AKixCmbSPqIVAAAAAElFTkSuQmCC"
};
/* pako/lib/zlib/crc32.js, MIT license: https://github.com/nodeca/pako/ */
var crc32 = (function() {
// Use ordinary array, since untyped makes no boost here
function makeTable() {
var c, table = [];
for(var n =0; n < 256; n++){
c = n;
for(var k =0; k < 8; k++){
c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1));
}
table[n] = c;
}
return table;
}
// Create table on load. Just 255 signed longs. Not a problem.
var crcTable = makeTable();
return function(buf) {
var crc = 0;
var t = crcTable, end = buf.length;
crc = crc ^ (-1);
for (var i = 0; i < end; i++ ) {
crc = (crc >>> 8) ^ t[(crc ^ buf[i]) & 0xFF];
}
return (crc ^ (-1)); // >>> 0;
};
})();
var helpers = {
// Get and set values in localStorage.
//
// We don't use helpers.set_value/helpers.get_value since GreaseMonkey is inconsistent and changed
// these functions unnecessarily. We could polyfill those with this, but that would cause
// the storage to change if those functions are restored. Doing it this way also allows
// us to share settings if a user switches from GM to TM.
get_value: function(key, default_value)
{
key = "_ppixiv_" + key;
if(!(key in localStorage))
return default_value;
var result = localStorage[key];
return JSON.parse(result);
},
set_value: function(key, value)
{
key = "_ppixiv_" + key;
var value = JSON.stringify(value);
localStorage[key] = value;
},
// Preload an array of images.
preload_images: function(images)
{
// We don't need to add the element to the document for the images to load, which means
// we don't need to do a bunch of extra work to figure out when we can remove them.
var preload = document.createElement("div");
for(var i = 0; i < images.length; ++i)
{
var img = document.createElement("img");
img.src = images[i];
preload.appendChild(img);
}
},
move_children: function(parent, new_parent)
{
for(var child = parent.firstChild; child; )
{
var next = child.nextSibling;
new_parent.appendChild(child);
child = next;
}
},
remove_elements: function(parent)
{
for(var child = parent.firstChild; child; )
{
var next = child.nextElementSibling;
parent.removeChild(child);
child = next;
}
},
create_style: function(css)
{
var style = document.createElement("style");
style.textContent = css;
return style;
},
create_from_template: function(type)
{
var template = document.body.querySelector(type);
return template.firstElementChild.cloneNode(true);
},
// Fetch a simple data resource, and call callback with the result.
//
// In principle this is just a simple XHR. However, if we make two requests for the same
// resource before the first one finishes, browsers tend to be a little dumb and make a
// whole separate request, instead of waiting for the first to finish and then just serving
// the second out of cache. This causes duplicate requests when prefetching video ZIPs.
// This works around that problem by returning the existing XHR if one is already in progress.
_fetches: {},
fetch_resource: function(url, options)
{
if(this._fetches[url])
{
var request = this._fetches[url];
// Remember that another fetch was made for this resource.
request.fetch_count++;
if(options != null)
request.callers.push(options);
return request;
}
var request = helpers.send_pixiv_request({
"method": "GET",
"url": url,
"responseType": "arraybuffer",
"headers": {
"Accept": "application/json",
},
onload: function(data) {
// Once the request finishes, future requests can be done normally and should be served
// out of cache.
delete helpers._fetches[url];
// Call onloads.
for(var options of request.callers.slice())
{
try {
if(options.onload)
options.onload(data);
} catch(exc) {
console.error(exc);
}
}
},
onerror: function(e) {
console.error("Fetch failed");
for(var options of request.callers.slice())
{
try {
if(options.onerror)
options.onerror(e);
} catch(exc) {
console.error(exc);
}
}
},
onprogress: function(e) {
for(var options of request.callers.slice())
{
try {
if(options.onprogress)
options.onprogress(e);
} catch(exc) {
console.error(exc);
}
}
},
});
// Remember the number of times fetch_resource has been called on this URL.
request.fetch_count = 1;
request.callers = [];
request.callers.push(options);
this._fetches[url] = request;
// Override request.abort to reference count fetching, so we only cancel the load if
// every caller cancels.
//
// Note that this means you'll still receive events if the fetch isn't actually
// cancelled, so you should unregister event listeners if that's important.
var original_abort = request.abort;
request.abort = function()
{
// Remove this caller's callbacks, if any.
if(options != null)
{
var idx = request.callers.indexOf(options);
if(idx != -1)
request.callers.splice(idx, 1);
}
if(request.fetch_count == 0)
{
console.error("Fetch was aborted more times than it was started:", url);
return;
}
request.fetch_count--;
if(request.fetch_count > 0)
return;
original_abort.call(request);
};
return request;
},
// For some reason, only the mode=manga page actually has URLs to each page. Avoid
// having to load an extra page by deriving it from the first page's URL, which looks
// like:
//
// https://i.pximg.net/img-original/img/1234/12/12/12/12/12/12345678_p0.jpg
//
// Replace _p0 at the end with the page number.
//
// We can't tell the size of each image this way.
get_url_for_page: function(illust_data, page, key)
{
var url = illust_data.urls[key];
var match = /^(http.*)(_p)(0)(.*)/.exec(url);
if(match == null)
{
console.error("Couldn't parse URL: " + url);
return "";
}
return match[1] + match[2] + page.toString() + match[4];
},
// Prompt to save a blob to disk. For some reason, the really basic FileSaver API disappeared from
// the web.
save_blob: function(blob, filename)
{
var blobUrl = URL.createObjectURL(blob);
var a = document.createElement("a");
a.hidden = true;
document.body.appendChild(a);
a.href = blobUrl;
a.download = filename;
a.click();
// Clean up.
//
// If we revoke the URL now, or with a small timeout, Firefox sometimes just doesn't show
// the save dialog, and there's no way to know when we can, so just use a large timeout.
setTimeout(function() {
console.log("done");
window.URL.revokeObjectURL(blobUrl);
a.parentNode.removeChild(a);
}.bind(this), 1000);
},
// Return a Uint8Array containing a blank (black) image with the given dimensions and type.
create_blank_image: function(image_type, width, height)
{
var canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
var context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
var blank_frame = canvas.toDataURL(image_type, 1);
if(!blank_frame.startsWith("data:" + image_type))
throw "This browser doesn't support encoding " + image_type;
var binary = atob(blank_frame.slice(13 + image_type.length));
// This is completely stupid. Why is there no good way to go from a data URL to an ArrayBuffer?
var array = new Uint8Array(binary.length);
for(var i = 0; i < binary.length; ++i)
array[i] = binary.charCodeAt(i);
return array;
},
fetch_ugoira_metadata: function(illust_id, callback)
{
var url = "/ajax/illust/" + illust_id + "/ugoira_meta";
return helpers.get_request(url, {}, callback);
},
// Stop the underlying page from sending XHR requests, since we're not going to display any
// of it and it's just unneeded traffic. For some dumb reason, Pixiv sends error reports by
// creating an image, instead of using a normal API. Override window.Image too to stop it
// from sending error messages for this script.
//
// Firefox is now also bad and seems to have removed beforescriptexecute. The Web is not
// much of a dependable platform.
block_network_requests: function()
{
RealXMLHttpRequest = window.XMLHttpRequest;
window.Image = function() { };
dummy_fetch = function() { };
dummy_fetch.prototype.ok = true;
dummy_fetch.prototype.sent = function() { return this; }
window.fetch = function() { return new dummy_fetch(); }
window.XMLHttpRequest = function() { }
},
// Stop all scripts from running on the page. This only works in Firefox. This is a basic
// thing for a userscript to want to do, why can't you do it in Chrome?
block_all_scripts: function()
{
window.addEventListener("beforescriptexecute", function(e) {
e.stopPropagation();
e.preventDefault();
}, true);
},
add_style: function(css)
{
var head = document.getElementsByTagName('head')[0];
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
head.appendChild(style);
},
// Create a node from HTML.
create_node: function(html)
{
var temp = document.createElement("div");
temp.innerHTML = html;
return temp.firstElementChild;
},
// Set or unset a class.
set_class: function(element, className, enable)
{
if(element.classList.contains(className) == enable)
return;
if(enable)
element.classList.add(className);
else
element.classList.remove(className);
},
age_to_string: function(seconds)
{
var to_plural = function(label, places, value)
{
var factor = Math.pow(10, places);
var plural_value = Math.round(value * factor);
if(plural_value > 1)
label += "s";
return value.toFixed(places) + " " + label;
};
if(seconds < 60)
return to_plural("sec", 0, seconds);
var minutes = seconds / 60;
if(minutes < 60)
return to_plural("min", 0, minutes);
var hours = minutes / 60;
if(hours < 24)
return to_plural("hour", 0, hours);
var days = hours / 24;
if(days < 30)
return to_plural("day", 0, days);
var months = days / 30;
if(months < 12)
return to_plural("month", 0, months);
var years = months / 12;
return to_plural("year", 1, years);
},
get_extension: function(fn)
{
var parts = fn.split(".");
return parts[parts.length-1];
},
encode_query: function(data) {
var str = [];
for (var key in data)
{
if(!data.hasOwnProperty(key))
continue;
str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key]));
}
return str.join("&");
},
// Sending requests in user scripts is a nightmare:
// - In TamperMonkey you can simply use unsafeWindow.XMLHttpRequest. However, in newer versions
// of GreaseMonkey, the request will be sent, but event handlers (eg. load) will fail with a
// permissions error. (That doesn't make sense, since you can assign DOM events that way.)
// - window.XMLHttpRequest will work, but won't make the request as the window, so it will
// act like a cross-origin request. We have to use GM_xmlHttpRequest/GM.XMLHttpRequest instead.
// - But, we can't use that in TamperMonkey (at least in Chrome), since ArrayBuffer is incredibly
// slow. It seems to do its own slow buffer decoding: a 2 MB ArrayBuffer can take over half a
// second to decode. We need to use regular XHR with TamperMonkey.
// - GM_xmlhttpRequest in GreaseMonkey doesn't send a referer by default, and we need to set it
// manually. (TamperMonkey does send a referer by default.)
// send_request_gm: Send a request with GM_xmlhttpRequest.
//
// The returned object will have an abort method that might abort the request.
// (TamperMonkey provides abort, but GreaseMonkey doesn't.)
//
// Only the following options are supported:
//
// - headers
// - method
// - data
// - responseType
// - onload
// - onprogress
//
// The returned object will only have abort, which is a no-op in GM.
//
// onload will always be called (unless the request is aborted), so there's always just
// one place to put cleanup handlers when a request finishes.
//
// onload will be called with only resp.response and not the full response object. On
// error, onload(null) will be called rather than onerror.
//
// We use a limited interface since we have two implementations of this, one using XHR (for TM)
// and one using GM_xmlhttpRequest (for GM), and this prevents us from accidentally
// using a field that's only implemented with GM_xmlhttpRequest and breaking TM.
send_request_gm: function(user_options)
{
var options = {};
for(var key of ["url", "headers", "method", "data", "responseType", "onload", "onprogress"])
{
if(!(key in user_options))
continue;
// We'll override onload.
if(key == "onload")
{
options.real_onload = user_options.onload;
continue;
}
options[key] = user_options[key];
}
// Set the referer, or some requests will fail.
var url = new URL(document.location);
url.hash = "";
options.headers["Referer"] = url.toString();
options.onload = function(response)
{
if(options.real_onload)
{
try {
options.real_onload(response.response);
} catch(e) {
console.error(e);
}
}
};
// When is this ever called?
options.onerror = function(response)
{
console.log("Request failed:", response);
if(options.real_onload)
{
try {
options.real_onload(null);
} catch(e) {
console.error(e);
}
}
}
var actual_request = GM_xmlhttpRequest(options);
return {
abort: function()
{
// actual_request is null with newer, broken versions of GM, in which case
// we only pretend to cancel the request.
if(actual_request != null)
actual_request.abort();
// Remove real_onload, so if we can't actually cancel the request, we still
// won't call onload, since the caller is no longer expecting it.
delete options.real_onload;
},
};
},
// The same as send_request_gm, but with XHR.
send_request_xhr: function(options)
{
var xhr = new RealXMLHttpRequest();
xhr.open(options.method || "GET", options.url);
if(options.headers)
{
for(var key in options.headers)
xhr.setRequestHeader(key, options.headers[key]);
}
if(options.responseType)
xhr.responseType = options.responseType;
xhr.addEventListener("load", function(e) {
if(options.onload)
{
try {
options.onload(xhr.response);
} catch(exc) {
console.error(exc);
}
}
});
xhr.addEventListener("progress", function(e) {
if(options.onprogress)
{
try {
options.onprogress(e);
} catch(exc) {
console.error(exc);
}
}
});
if(options.method == "POST")
xhr.send(options.data);
else
xhr.send();
return {
abort: function()
{
console.log("cancel");
xhr.abort();
},
};
},
send_request: function(options)
{
// In GreaseMonkey, use send_request_gm. Otherwise, use send_request_xhr. If
// GM_info.scriptHandler doesn't exist, assume we're in GreaseMonkey, since
// TamperMonkey always defines it.
//
// (e also assume that if GM_info doesn't exist we're in GreaseMonkey, since it's
// GM that has a nasty habit of removing APIs that people are using, so if that
// happens we're probably in GM.
var greasemonkey = true;
try
{
greasemonkey = GM_info.scriptHandler == null || GM_info.scriptHandler == "Greasemonkey";
} catch(e) {
greasemonkey = true;
}
if(greasemonkey)
return helpers.send_request_gm(options);
else
return helpers.send_request_xhr(options);
},
// Send a request with the referer, cookie and CSRF token filled in.
send_pixiv_request: function(options)
{
if(options.headers == null)
options.headers = {};
// Only set x-csrf-token for requests to www.pixiv.net. It's only needed for API
// calls (not things like ugoira ZIPs), and the request will fail if we're in XHR
// mode and set headers, since it'll trigger CORS.
var hostname = new URL(options.url, document.location).hostname;
if(hostname == "www.pixiv.net")
options.headers["x-csrf-token"] = global_data.csrf_token;
return helpers.send_request(options);
},
// Why does Pixiv have 3 APIs?
rpc_post_request: function(url, data, callback)
{
return helpers.send_pixiv_request({
"method": "POST",
"url": url,
"data": helpers.encode_query(data),
"responseType": "json",
"headers": {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
onload: function(data) {
if(data && data.error)
console.error("Error in XHR request:", data.message)
if(callback)
callback(data);
},
onerror: function(e) {
console.error("Fetch failed");
if(callback)
callback({"error": true, "message": "XHR error"});
},
});
},
rpc_get_request: function(url, data, callback)
{
var params = new URLSearchParams();
for(var key in data)
params.set(key, data[key]);
var query = params.toString();
if(query != "")
url += "?" + query;
return helpers.send_pixiv_request({
"method": "GET",
"url": url,
"responseType": "json",
"headers": {
"Accept": "application/json",
},
onload: function(data) {
if(data && data.error)
console.error("Error in XHR request:", data.message)
if(callback)
callback(data);
},
onerror: function(result) {
console.error("Fetch failed");
if(callback)
callback({"error": true, "message": "XHR error"});
},
});
},
post_request: function(url, data, callback)
{
return helpers.send_pixiv_request({
"method": "POST",
"url": url,
"responseType": "json",
"data" :JSON.stringify(data),
"headers": {
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
onload: function(data) {
if(data && data.error)
console.error("Error in XHR request:", data.message)
if(callback)
callback(data);
},
onerror: function(e) {
console.error("Fetch failed");
if(callback)
callback({"error": true, "message": "XHR error"});
},
});
},
get_request: function(url, data, callback)
{
var params = new URLSearchParams();
for(var key in data)
params.set(key, data[key]);
var query = params.toString();
if(query != "")
url += "?" + query;
return helpers.send_pixiv_request({
"method": "GET",
"url": url,
"responseType": "json",
"headers": {
"Accept": "application/json",
},
onload: function(data) {
if(data && data.error)
console.error("Error in XHR request:", data.message)
if(callback)
callback(data);
},
onerror: function(e) {
console.error("Fetch failed");
if(callback)
callback({"error": true, "message": "XHR error"});
},
});
},
// Download all URLs in the list. Call callback with an array containing one ArrayData for each URL. If
// any URL fails to download, call callback with null.
//
// I'm not sure if it's due to a bug in the userscript extension or because we need to specify a
// header here, but this doesn't properly use cache and reloads the resources from scratch, which
// is really annoying. We can't read the images directly since they're on a different domain.
//
// We could start multiple requests to pipeline this better. However, the usual case where we'd download
// lots of images is downloading a group of images, and in that case we're already preloading them as
// images, so it's probably redundant to do it here.
download_urls: function(urls, callback)
{
// Make a copy.
urls = urls.slice(0);
var results = [];
var start_next = function()
{
var url = urls.shift();
if(url == null)
{
callback(results);
return;
}
// FIXME: This caches in GreaseMonkey, but not in TamperMonkey. Do we need to specify cache
// headers or is TamperMonkey just broken?
GM_xmlhttpRequest({
"method": "GET",
"url": url,
"responseType": "arraybuffer",
"headers": {
"Cache-Control": "max-age=360000",
},
onload: function(result) {
results.push(result.response);
start_next();
}.bind(this),
});
};
start_next();
},
// Load a page in an iframe, and call callback on the resulting document.
// Remove the iframe when the callback returns.
load_data_in_iframe: function(url, callback)
{
var iframe = document.createElement("iframe");
// Enable sandboxing, so scripts won't run in the iframe. Set allow-same-origin, or
// we won't be able to access it in contentDocument (which doesn't really make sense,
// sandbox is for sandboxing the iframe, not us).
iframe.sandbox = "allow-same-origin";
iframe.src = url;
iframe.hidden = true;
document.body.appendChild(iframe);
iframe.addEventListener("load", function(e) {
try {
callback(iframe.contentDocument);
} catch(e) {
// GM error logs don't make it to the console for some reason.
console.error(e);
} finally {
// Remove the iframe. For some reason, we have to do this after processing it.
document.body.removeChild(iframe);
}
});
},
set_recent_bookmark_tags(tags)
{
helpers.set_value("recent-bookmark-tags", JSON.stringify(tags));
},
get_recent_bookmark_tags()
{
var recent_bookmark_tags = helpers.get_value("recent-bookmark-tags");
if(recent_bookmark_tags == null)
return [];
return JSON.parse(recent_bookmark_tags);
},
// Move tag_list to the beginning of the recent tag list, and prune tags at the end.
update_recent_bookmark_tags: function(tag_list)
{
// Move the tags we're using to the top of the recent bookmark tag list.
var recent_bookmark_tags = helpers.get_recent_bookmark_tags();
for(var i = 0; i < tag_list.length; ++i)
{
var tag = tag_list[i];
var idx = recent_bookmark_tags.indexOf(tag_list[i]);
if(idx != -1)
recent_bookmark_tags.splice(idx, 1);
}
for(var i = 0; i < tag_list.length; ++i)
recent_bookmark_tags.unshift(tag_list[i]);
// Remove tags that haven't been used in a long time.
recent_bookmark_tags.splice(20);
helpers.set_recent_bookmark_tags(recent_bookmark_tags);
},
// Add tag to the recent search list, or move it to the front.
add_recent_search_tag(tag)
{
var recent_tags = helpers.get_value("recent-tag-searches") || [];
var idx = recent_tags.indexOf(tag);
if(idx != -1)
recent_tags.splice(idx, 1);
recent_tags.unshift(tag);
// Trim the list.
recent_tags.splice(50);
helpers.set_value("recent-tag-searches", recent_tags);
window.dispatchEvent(new Event("recent-tag-searches-changed"));
},
remove_recent_search_tag(tag)
{
// Remove tag from the list. There should normally only be one.
var recent_tags = helpers.get_value("recent-tag-searches") || [];
while(1)
{
var idx = recent_tags.indexOf(tag);
if(idx == -1)
break;
recent_tags.splice(idx, 1);
}
helpers.set_value("recent-tag-searches", recent_tags);
window.dispatchEvent(new Event("recent-tag-searches-changed"));
},
// Find globalInitData in a document, evaluate it and return it. If it can't be
// found, return null.
get_global_init_data(doc)
{
// Find a script element that sets globalInitData. This is the only thing in
// the page that we use.
var init_element;
for(var element of doc.querySelectorAll("script"))
{
if(element.innerText == null || element.innerText.indexOf("globalInitData") == -1)
continue;
init_element = element
break;
}
if(init_element == null)
return null;
// This script assigns globalInitData. Wrap it in a function to return it.
init_script = init_element.innerText;
init_script = "(function() { " + init_script + "; return globalInitData; })();";
var data = eval(init_script);
// globalInitData is frozen, which we don't want. Deep copy the object to undo this.
data = JSON.parse(JSON.stringify(data))
return data;
},
// If this is an older page (currently everything except illustrations), the CSRF token,
// etc. are stored on an object called "pixiv". We aren't actually executing scripts, so
// find the script block.
get_pixiv_data(doc)
{
// Find all script elements that set pixiv.xxx. There are two of these, and we need
// both of them.
var init_elements = [];
for(var element of doc.querySelectorAll("script"))
{
if(element.innerText == null)
continue;
if(!element.innerText.match(/pixiv.*(token|id) = /))
continue;
init_elements.push(element);
}
if(init_elements.length != 2)
return null;
// Create a stub around the scripts to let them execute as if they're initializing the
// original object.
var init_script = "";
init_script += "(function() {";
init_script += "var pixiv = { config: {}, context: {}, user: {} }; ";
init_script += init_elements[0].innerText;
init_script += init_elements[1].innerText;
init_script += "return pixiv;";
init_script += "})();";
return eval(init_script);
},
get_tags_from_illust_data(illust_data)
{
// illust_data might contain a list of dictionaries (data.tags.tags[].tag), or
// a simple list (data.tags[]), depending on the source.
if(illust_data.tags.tags == null)
return illust_data.tags;
var result = [];
for(var tag_data of illust_data.tags.tags)
result.push(tag_data.tag);
return result;
},
// Return true if the given illust_data.tags contains the pixel art (ドット絵) tag.
tags_contain_dot(illust_data)
{
var tags = helpers.get_tags_from_illust_data(illust_data);
for(var tag of tags)
if(tag.indexOf("ドット") != -1)
return true;
return false;
},
fix_pixiv_links: function(root)
{
for(var a of root.querySelectorAll("A[target='_blank']"))
a.target = "";
for(var a of root.querySelectorAll("A[href*='jump.php']"))
{
a.relList.add("noreferrer");
var url = new URL(a.href);
var target = url.search.substr(1); // remove ?
target = decodeURIComponent(target);
a.href = target;
}
},
set_page_title: function(title)
{
document.querySelector("title").textContent = title;
},
set_page_icon: function(url)
{
document.querySelector("link[rel='icon']").href = url;
},
// Watch for clicks on links inside node. If a search link is clicked, add it to the
// recent search list.
add_clicks_to_search_history: function(node)
{
node.addEventListener("click", function(e) {
if(e.defaultPrevented)
return;
if(e.target.tagName != "A")
return;
var url = new URL(e.target.href);
if(url.pathname != "/search.php")
return;
var tag = url.searchParams.get("word");
console.log("Adding to tag search history:", tag);
helpers.add_recent_search_tag(tag);
});
},
// Add a basic event handler for an input:
//
// - When enter is pressed, submit will be called.
// - Event propagation will be stopped, so global hotkeys don't trigger.
//
// Note that other event handlers on the input will still be called.
input_handler: function(input, submit)
{
input.addEventListener("keydown", function(e) {
// Always stopPropagation, so inputs aren't handled by main input handling.
e.stopPropagation();
if(e.keyCode == 13) // enter
submit(e);
});
},
};
// This installs some minor tweaks that aren't related to the main viewer functionality.
(function() {
// If this is an iframe, don't do anything. This may be a helper iframe loaded by
// load_data_in_iframe, in which case the main page will do the work.
if(window.top != window.self)
return;
window.addEventListener("DOMContentLoaded", function(e) {
try {
if(window.location.pathname.startsWith("/bookmark.php"))
{
// On the follow list, make the user links point at the works page instead
// of the useless profile page.
var links = document.documentElement.querySelectorAll('A');
for(var i = 0; i < links.length; ++i)
{
var a = links[i];
a.href = a.href.replace(/member\.php/, "member_illust.php");
}
};
} catch(e) {
// GM error logs don't make it to the console for some reason.
console.log(e);
}
});
})();
// Create an uncompressed ZIP from a list of files and filenames.
create_zip = function(filenames, files)
{
if(filenames.length != files.length)
throw "Mismatched array lengths";
// Encode the filenames.
var filename_blobs = [];
for(var i = 0; i < filenames.length; ++i)
{
var filename = new Blob([filenames[i]]);
filename_blobs.push(filename);
}
// Make CRC32s, and create blobs for each file.
var blobs = [];
var crc32s = [];
for(var i = 0; i < filenames.length; ++i)
{
var data = files[i];
var crc = crc32(new Int8Array(data));
crc32s.push(crc);
blobs.push(new Blob([data]));
}
var parts = [];
var file_pos = 0;
var file_offsets = [];
for(var i = 0; i < filenames.length; ++i)
{
var filename = filename_blobs[i];
var data = blobs[i];
var crc = crc32s[i];
// Remember the position of the local file header for this file.
file_offsets.push(file_pos);
var local_file_header = this.create_local_file_header(filename, data, crc);
parts.push(local_file_header);
file_pos += local_file_header.size;
// Add the data.
parts.push(data);
file_pos += data.size;
}
// Create the central directory.
var central_directory_pos = file_pos;
var central_directory_size = 0;
for(var i = 0; i < filenames.length; ++i)
{
var filename = filename_blobs[i];
var data = blobs[i];
var crc = crc32s[i];
var file_offset = file_offsets[i];
var central_record = this.create_central_directory_entry(filename, data, file_offset, crc);
central_directory_size += central_record.size;
parts.push(central_record);
}
var end_central_record = this.create_end_central(filenames.length, central_directory_pos, central_directory_size);
parts.push(end_central_record);
return new Blob(parts, {
"type": "application/zip",
});
};
create_zip.prototype.create_local_file_header = function(filename, file, crc)
{
var data = struct(" 0)
console.log("Removing duplicate illustration IDs:", ids_to_remove.join(", "));
illust_ids = illust_ids.slice();
for(var new_id of ids_to_remove)
{
var idx = illust_ids.indexOf(new_id);
illust_ids.splice(idx, 1);
}
}
// If there's nothing on this page, don't add it, so this doesn't increase
// get_highest_loaded_page().
// FIXME: If we removed everything, the data source will appear to have reached the last
// page and we won't load any more pages, since thumbnail_view assumes that a page not
// returning any data means we're at the end.
if(illust_ids.length == 0)
return true;
// See if we already have any IDs in illust_ids.
var duplicated_id = false;
for(var new_id of illust_ids)
{
if(all_illusts.indexOf(new_id) != -1)
{
duplicated_id = true;
break;
}
}
var result = true;
if(duplicated_id)
{
console.info("Page", page, "duplicates an illustration ID. Clearing page cache.");
this.illust_ids_by_page = {};
// Return false to let the caller know we've done this, and that it should clear
// any page caches.
result = false;
}
this.illust_ids_by_page[page] = illust_ids;
return result;
};
// Return the page number illust_id is on, or null if we don't know.
get_page_for_illust(illust_id)
{
for(var page of Object.keys(this.illust_ids_by_page))
{
var ids = this.illust_ids_by_page[page];
page = parseInt(page);
if(ids.indexOf(illust_id) != -1)
return page;
};
return null;
};
// Return the next or previous illustration. If we don't have that page, return null.
get_neighboring_illust_id(illust_id, next)
{
var page = this.get_page_for_illust(illust_id);
if(page == null)
return null;
var ids = this.illust_ids_by_page[page];
var idx = ids.indexOf(illust_id);
var new_idx = idx + (next? +1:-1);
if(new_idx < 0)
{
// Return the last illustration on the previous page, or null if that page isn't loaded.
var prev_page_no = page - 1;
var prev_page_illust_ids = this.illust_ids_by_page[prev_page_no];
if(prev_page_illust_ids == null)
return null;
return prev_page_illust_ids[prev_page_illust_ids.length-1];
}
else if(new_idx >= ids.length)
{
// Return the first illustration on the next page, or null if that page isn't loaded.
var next_page_no = page + 1;
var next_page_illust_ids = this.illust_ids_by_page[next_page_no];
if(next_page_illust_ids == null)
return null;
return next_page_illust_ids[0];
}
else
{
return ids[new_idx];
}
};
// Return the page we need to load to get the next or previous illustration. This only
// makes sense if get_neighboring_illust returns null.
get_page_for_neighboring_illust(illust_id, next)
{
var page = this.get_page_for_illust(illust_id);
if(page == null)
return null;
var ids = this.illust_ids_by_page[page];
var idx = ids.indexOf(illust_id);
var new_idx = idx + (next? +1:-1);
if(new_idx >= 0 && new_idx < ids.length)
return page;
page += next? +1:-1;
return page;
};
// Return the first ID, or null if we don't have any.
get_first_id()
{
var keys = Object.keys(this.illust_ids_by_page);
if(keys.length == 0)
return null;
var page = keys[0];
return this.illust_ids_by_page[page][0];
}
// Return true if the given page is loaded.
is_page_loaded(page)
{
return this.illust_ids_by_page[page] != null;
}
};
// A data source asynchronously loads illust_ids to show. The callback will be called
// with:
// {
// 'illust': {
// illust_id1: illust_data1,
// illust_id2: illust_data2,
// ...
// },
// illust_ids: [illust_id1, illust_id2, ...]
// next: function,
// }
//
// Some sources can retrieve user data, some can retrieve only illustration data, and
// some can't retrieve anything but IDs.
//
// The callback will always be called asynchronously, and data_source.callback can be set
// after creation.
//
// If "next" is included, it's a function that can be called to create a new data source
// to load the next page of data. If there are no more pages, next will be null.
// A data source handles a particular source of images, depending on what page we're
// on:
//
// - Retrieves batches of image IDs to display, eg. a single page of bookmark results
// - Load another page of results with load_more()
// - Updates the page URL to reflect the current image
//
// Not all data sources have multiple pages. For example, when we're viewing a regular
// illustration page, we get all of the author's other illust IDs at once, so we just
// load all of them as a single page.
class data_source
{
constructor()
{
this.id_list = new illust_id_list();
this.update_callbacks = [];
this.loading_page_callbacks = {};
this.first_empty_page = -1;
this.update_callbacks = [];
};
// If a data source returns a name, we'll display any .data-source-specific elements in
// the thumbnail view with that name.
get name() { return null; }
// Return the page that will be loaded by default, if load_page(null) is called.
//
// Most data sources store the page in the query.
get_default_page()
{
var query_args = page_manager.singleton().get_query_args();
return parseInt(query_args.get("p")) || 1;
}
// Load the given page, or the page of the current history state if page is null.
// Call callback when the load finishes.
//
// If we synchronously know that the page doesn't exist, return false and don't
// call callback. Otherwise, return true.
load_page(page, callback)
{
// If page is null, use the default page.
if(page == null)
page = this.get_default_page();
// Check if we're trying to load backwards too far.
if(page < 1)
{
console.info("No pages before page 1");
return false;
}
// If we know there's no data on this page (eg. we loaded an earlier page before and it
// was empty), don't try to load this one. This prevents us from spamming empty page
// requests.
if(this.first_empty_page != -1 && page >= this.first_empty_page)
{
console.info("No pages after", this.first_empty_page);
return false;
}
// If the page is already loaded, just call the callback.
if(this.id_list.is_page_loaded(page))
{
setTimeout(function() {
if(callback != null)
callback();
}.bind(this), 0);
return true;
}
// If a page is loading, loading_page_callbacks[page] is a list of callbacks waiting
// for that page.
if(this.loading_page_callbacks[page])
{
// This page is currently loading, so just add the callback to that page's list.
// This makes sure we don't spam the same request several times if different things
// request it at the same time.
if(callback != null)
this.loading_page_callbacks[page].push(callback);
return true;
}
// Create the callbacks list for this page if it doesn't exist. This also records that
// the request for this page is in progress.
if(this.loading_page_callbacks[page] == null)
this.loading_page_callbacks[page] = [];
// Add this callback to the list, if any.
if(callback != null)
this.loading_page_callbacks[page].push(callback);
var is_synchronous = true;
var completed = function()
{
// If there were no results, then we've loaded the last page. Don't try to load
// any pages beyond this.
if(this.id_list.illust_ids_by_page[page] == null)
{
console.log("No data on page", page);
if(this.first_empty_page == -1 || page < this.first_empty_page)
this.first_empty_page = page;
};
// Call all callbacks waiting for this page.
var callbacks = this.loading_page_callbacks[page].slice();
delete this.loading_page_callbacks[page];
for(var callback of callbacks)
{
try {
callback();
} catch(e) {
console.error(e);
}
}
}.bind(this);
// Start the actual load.
var result = this.load_page_internal(page, function() {
// If is_synchronous is true, the data source finished immediately before load_page_internal
// returned. This happens when the data is already available and didn't need to be loaded.
// Make sure we complete the load asynchronously even if it finished synchronously.
if(is_synchronous)
setTimeout(completed, 0);
else
completed();
}.bind(this));
is_synchronous = false;
if(!result)
{
// No request was actually started, so we're not calling the callback.
delete this.loading_page_callbacks[page];
}
return result;
}
// Return the illust_id to display by default.
//
// This should only be called after the initial data is loaded.
get_default_illust_id()
{
// If we have an explicit illust_id in the hash, use it. Note that some pages (in
// particular illustration pages) put this in the query, which is handled in the particular
// data source.
var hash_args = page_manager.singleton().get_hash_args();
if(hash_args.has("illust_id"))
return hash_args.get("illust_id");
return this.id_list.get_first_id();
};
// Return the page title to use.
get page_title()
{
return "Pixiv";
}
// This is implemented by the subclass.
load_page_internal(page, callback)
{
return false;
}
// This is called when the currently displayed illust_id changes. The illust_id should
// always have been loaded by this data source, so it should be in id_list. The data
// source should update the history state to reflect the current state.
//
// If add_to_history, use history.pushState, otherwise use history.replaceState. replace
// is true when we're just updating the current state (eg. after loading the first image)
// and false if we're actually navigating to a new image that should have a new history
// entry (eg. pressing page down).
set_current_illust_id(illust_id, add_to_history)
{
};
// Load from the current history state. Load the current page (if needed), then call
// callback().
//
// This is called when changing history states. The data source should load the new
// page if needed, then call this.callback.
load_from_current_state(callback)
{
this.load_page(null, callback);
};
// Return the estimated number of items per page. This is used to pad the thumbnail
// list to reduce items moving around when we load pages.
get estimated_items_per_page()
{
return 10;
};
// Return true if this data source wants to show thumbnails by default, or false if
// the default image should be shown.
get show_thumbs_by_default()
{
return true;
};
// If we're viewing a page specific to a user (an illustration or artist page), return
// the user ID we're viewing. This can change when refreshing the UI.
get viewing_user_id()
{
return null;
};
// If we're viewing a page specific to a user (an illustration or artist page), return
// the username we're viewing. This can change when refreshing the UI.
get viewing_username()
{
return null;
};
// Add or remove an update listener. These are called when the data source has new data,
// or wants a UI refresh to happen.
add_update_listener(callback)
{
this.update_callbacks.push(callback);
}
remove_update_listener(callback)
{
var idx = this.update_callbacks.indexOf(callback);
if(idx != -1)
this.update_callbacks.splice(idx);
}
// Register a page of data.
add_page(page, illust_ids)
{
var result = this.id_list.add_page(page, illust_ids);
// Call update listeners asynchronously to let them know we have more data.
setTimeout(function() {
this.call_update_listeners();
}.bind(this), 0);
return result;
}
call_update_listeners()
{
var callbacks = this.update_callbacks.slice();
for(var callback of callbacks)
{
try {
callback();
} catch(e) {
console.error(e);
}
}
}
// Each data source can have a different UI in the thumbnail view. container is
// the thumbnail-ui-box container to refresh.
refresh_thumbnail_ui(container) { }
// A helper for setting up UI links. Find the link with the given data-type,
// set all {key: value} entries as query parameters, and remove any query parameters
// where value is null. Set .selected if the resulting URL matches the current one.
//
// If default_values is present, it tells us the default key that will be used if
// a key isn't present. For example, search.php?s_mode=s_tag is the same as omitting
// s_mode. We prefer to omit it rather than clutter the URL with defaults, but we
// need to know this to figure out whether an item is selected or not.
set_item(container, type, fields, default_values)
{
var link = container.querySelector("[data-type='" + type + "']");
if(link == null)
{
console.warn("Couldn't find button with selector", type);
return;
}
// This button is selected if all of the keys it sets are present in the URL.
var button_is_selected = true;
// Adjust the URL for this button.
var url = new URL(document.location);
var new_url = new URL(document.location);
for(var key of Object.keys(fields))
{
var value = fields[key];
if(value != null)
new_url.searchParams.set(key, value);
else
new_url.searchParams.delete(key);
var this_value = value;
if(this_value == null && default_values != null)
this_value = default_values[key];
var selected_value = url.searchParams.get(key);
if(selected_value == null && default_values != null)
selected_value = default_values[key];
if(this_value != selected_value)
button_is_selected = false;
}
helpers.set_class(link, "selected", button_is_selected);
link.href = new_url.toString();
};
// Highlight search menu popups if any entry other than the default in them is
// selected.
//
// selector_list is a list of selectors for each menu item. If any of them are
// selected and don't have the data-default attribute, set .active on the popup.
// Search filters
// Set the active class on all top-level dropdowns which have something other than
// the default selected.
set_active_popup_highlight(container, selector_list)
{
for(var popup of selector_list)
{
var box = container.querySelector(popup);
var selected_item = box.querySelector(".selected");
if(selected_item == null)
{
// There's no selected item. If there's no default item then this is normal, but if
// there's a default item, it should have been selected by default, so this is probably
// a bug.
var default_entry_exists = box.querySelector("[data-default]") != null;
if(default_entry_exists)
console.warn("Popup", popup, "has no selection");
continue;
}
var selected_default = selected_item.dataset["default"];
helpers.set_class(box, "active", !selected_default);
}
}
};
// /discovery
//
// This is an actual API call for once, so we don't need to scrape HTML. We only show
// recommended works (we don't have a user view list).
//
// The API call returns 1000 entries. We don't do pagination, we just show the 1000 entries
// and then stop. I haven't checked to see if the API supports returning further pages.
class data_source_discovery extends data_source
{
get name() { return "discovery"; }
load_page_internal(page, callback)
{
if(page != 1)
return false;
// Get "mode" from the URL. If it's not present, use "all".
var query_args = page_manager.singleton().get_query_args();
var mode = query_args.get("mode") || "all";
var data = {
type: "illust",
sample_illusts: "auto",
num_recommendations: 1000,
page: "discovery",
mode: mode,
};
helpers.get_request("/rpc/recommender.php", data, function(result) {
// Unlike other APIs, this one returns IDs as ints rather than strings. Convert back
// to strings.
var illust_ids = [];
for(var illust_id of result.recommendations)
illust_ids.push(illust_id + "");
// Register the new page of data.
this.add_page(page, illust_ids);
if(callback)
callback();
}.bind(this))
return true;
};
// This doesn't matter for this data source, since we don't load any more pages after the first.
get estimated_items_per_page() { return 1; }
get page_title() { return "Discovery"; }
get_displaying_text() { return "Recommended Works"; }
// Update the address bar with the current illustration ID. If that illust ID is on a different
// page and we know the page number, update that as well.
set_current_illust_id(illust_id, add_to_history)
{
var query_args = page_manager.singleton().get_query_args();
var hash_args = page_manager.singleton().get_hash_args();
// Store the current illust ID in the hash, since the real bookmark page doesn't have
// an illust_id.
hash_args.set("illust_id", illust_id);
page_manager.singleton().set_args(query_args, hash_args, add_to_history);
};
refresh_thumbnail_ui(container)
{
// Set .selected on the current mode.
var current_mode = new URL(document.location).searchParams.get("mode") || "all";
helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all");
helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe");
helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18");
}
}
// bookmark_detail.php
//
// We use this as an anchor page for viewing recommended illusts for an image, since
// there's no dedicated page for this.
class data_source_related_illusts extends data_source
{
get name() { return "related-illusts"; }
load_page(page, callback)
{
// The first time we load a page, get info about the source illustration too, so
// we can show it in the UI.
if(!this.fetched_illust_info)
{
this.fetched_illust_info = true;
var query_args = page_manager.singleton().get_query_args();
var illust_id = query_args.get("illust_id");
image_data.singleton().get_image_info(illust_id, function(illust_info) {
this.illust_info = illust_info;
this.call_update_listeners();
}.bind(this));
}
return super.load_page(page, callback);
}
load_page_internal(page, callback)
{
if(page != 1)
return false;
var query_args = page_manager.singleton().get_query_args();
var illust_id = query_args.get("illust_id");
var data = {
type: "illust",
sample_illusts: illust_id,
num_recommendations: 1000,
};
helpers.get_request("/rpc/recommender.php", data, function(result) {
// Unlike other APIs, this one returns IDs as ints rather than strings. Convert back
// to strings.
var illust_ids = [];
for(var illust_id of result.recommendations)
illust_ids.push(illust_id + "");
// Register the new page of data.
this.add_page(page, illust_ids);
if(callback)
callback();
}.bind(this))
return true;
};
// This doesn't matter for this data source, since we don't load any more pages after the first.
get estimated_items_per_page() { return 1; }
get page_title() { return "Related Illusts"; }
get_displaying_text() { return "Related Illustrations"; }
// Update the address bar with the current illustration ID. If that illust ID is on a different
// page and we know the page number, update that as well.
set_current_illust_id(illust_id, add_to_history)
{
var query_args = page_manager.singleton().get_query_args();
var hash_args = page_manager.singleton().get_hash_args();
// Store the current illust ID in the hash. This is the image being viewed, not the source
// image for the suggestion list (which is in the query).
hash_args.set("illust_id", illust_id);
page_manager.singleton().set_args(query_args, hash_args, add_to_history);
};
refresh_thumbnail_ui(container)
{
// Set the source image.
var source_link = container.querySelector(".image-for-suggestions");
source_link.hidden = this.illust_info == null;
if(this.illust_info)
{
source_link.href = "/member_illust.php?illust_id=" + this.illust_info.illustId + "#ppixiv";
var img = source_link.querySelector(".image-for-suggestions > img");
img.src = this.illust_info.urls.thumb;
}
}
}
// /ranking.php
//
// This one has an API, and also formats the first page of results into the page.
// They have completely different formats, and the page is updated dynamically (unlike
// the pages we scrape), so we ignore the page for this one and just use the API.
//
// An exception is that we load the previous and next days from the page. This is better
// than using our current date, since it makes sure we have the same view of time as
// the search results.
class data_source_rankings extends data_source
{
constructor(doc)
{
super();
this.doc = doc;
this.max_page = 999999;
// This is the date that the page is showing us.
// We want to know the date the page is showing us, even if we requested the
// default. This is a little tricky since there's no unique class on that element,
// but it's always the element after "before" and the element before "after".
//
// We can also get this from the API response, but doing it here reduces UI
// pop by filling it in at the start.
var current = doc.querySelector(".ranking-menu .before + li > a");
this.today_text = current? current.innerText:"";
// Figure out today
var after = doc.querySelector(".ranking-menu .after > a");
if(after)
this.prev_date = new URL(after.href).searchParams.get("date");
var before = doc.querySelector(".ranking-menu .before > a");
if(before)
this.next_date = new URL(before.href).searchParams.get("date");
}
get name() { return "rankings"; }
load_page_internal(page, callback)
{
if(page > this.max_page)
return false;
// Get "mode" from the URL. If it's not present, use "all".
var query_args = page_manager.singleton().get_query_args();
var data = {
format: "json",
p: page,
};
var date = query_args.get("date");
if(date)
data.date = date;
var content = query_args.get("content");
if(content)
data.content = content;
var mode = query_args.get("mode");
if(mode)
data.mode = mode;
helpers.get_request("/ranking.php", data, function(result) {
// If "next" is false, this is the last page.
if(!result.next)
this.max_page = Math.min(page, this.max_page);
/* if(this.today_text == null)
this.today_text = result.date;
if(this.prev_date == null && result.prev_date)
this.prev_date = result.prev_date;
if(this.next_date == null && result.next_date)
this.next_date = result.next_date; */
// This returns a struct of data that's like the thumbnails data response,
// but it's not quite the same.
var illust_ids = [];
for(var item of result.contents)
{
// Most APIs return IDs as strings, but this one returns them as ints.
// Convert them to strings.
var illust_id = "" + item.illust_id;
var user_id = "" + item.user_id;
illust_ids.push(illust_id);
image_data.singleton().set_user_id_for_illust_id(illust_id, user_id)
}
// Register the new page of data.
this.add_page(page, illust_ids);
if(callback)
callback();
}.bind(this))
return true;
};
get estimated_items_per_page() { return 50; }
get page_title() { return "Rankings"; }
get_displaying_text() { return "Rankings"; }
// Update the address bar with the current illustration ID. If that illust ID is on a different
// page and we know the page number, update that as well.
set_current_illust_id(illust_id, add_to_history)
{
var query_args = page_manager.singleton().get_query_args();
var hash_args = page_manager.singleton().get_hash_args();
// Store the current illust ID in the hash, since the real bookmark page doesn't have
// an illust_id.
hash_args.set("illust_id", illust_id);
page_manager.singleton().set_args(query_args, hash_args, add_to_history);
};
refresh_thumbnail_ui(container)
{
var query_args = page_manager.singleton().get_query_args();
this.set_item(container, "content-all", {content: null});
this.set_item(container, "content-illust", {content: "illust"});
this.set_item(container, "content-ugoira", {content: "ugoira"});
this.set_item(container, "content-manga", {content: "manga"});
this.set_item(container, "mode-daily", {mode: null}, {mode: "daily"});
this.set_item(container, "mode-daily-r18", {mode: "daily_r18"});
this.set_item(container, "mode-weekly", {mode: "weekly"});
this.set_item(container, "mode-monthly", {mode: "monthly"});
this.set_item(container, "mode-rookie", {mode: "rookie"});
this.set_item(container, "mode-male", {mode: "male"});
this.set_item(container, "mode-female", {mode: "female"});
if(this.today_text)
container.querySelector(".nav-today").innerText = this.today_text;
var yesterday = container.querySelector(".nav-yesterday");
yesterday.hidden = this.prev_date == null;
if(this.prev_date)
{
var url = new URL(window.location);
url.searchParams.set("date", this.prev_date);
yesterday.querySelector("a").href = url;
}
var tomorrow = container.querySelector(".nav-tomorrow");
tomorrow.hidden = this.next_date == null;
if(this.next_date)
{
var url = new URL(window.location);
url.searchParams.set("date", this.next_date);
tomorrow.querySelector("a").href = url;
}
// Not all combinations of content and mode exist. For example, there's no ugoira
// monthly, and we'll get an error page if we load it. Hide navigations that aren't
// available. This isn't perfect: if you want to choose ugoira when you're on monthly
// you need to select a different time range first. We could have the content links
// switch to daily if not available...
var available_combinations = [
"all/daily",
"all/daily_r18",
"all/weekly",
"all/monthly",
"all/rookie",
"all/male",
"all/female",
"illust/daily",
"illust/daily_r18",
"illust/weekly",
"illust/monthly",
"illust/rookie",
"ugoira/daily",
"ugoira/weekly",
"ugoira/daily_r18",
"manga/daily",
"manga/daily_r18",
"manga/weekly",
"manga/monthly",
"manga/rookie",
];
// Check each link in both checked-links sections.
for(var a of container.querySelectorAll(".checked-links a"))
{
var url = new URL(a.href, document.location);
var link_content = url.searchParams.get("content") || "all";
var link_mode = url.searchParams.get("mode") || "daily";
var name = link_content + "/" + link_mode;
var available = available_combinations.indexOf(name) != -1;
var is_content_link = a.dataset.type.startsWith("content");
if(is_content_link)
{
// If this is a content link (eg. illustrations) and the combination of the
// current time range and this content type isn't available, make this link
// go to daily rather than hiding it, so all content types are always available
// and you don't have to switch time ranges just to select a different type.
if(!available)
{
url.searchParams.delete("mode");
a.href = url;
}
}
else
{
// If this is a mode link (eg. weekly) and it's not available, just hide
// the link.
a.hidden = !available;
}
}
}
}
// This is a base class for data sources that work by loading a regular Pixiv page
// and scraping it.
//
// This wouldn't be needed if we could access the mobile APIs, but for some reason those
// use different authentication tokens and can't be accessed from the website.
//
// All of these work the same way. We keep the current URL (ignoring the hash) synced up
// as a valid page URL that we can load. If we change pages or other search options, we
// modify the URL appropriately.
class data_source_from_page extends data_source
{
// The constructor receives the original HTMLDocument.
constructor(doc)
{
super();
this.original_doc = doc;
this.items_per_page = 1;
// Remember the URL that original_doc came from.
if(doc != null)
this.original_url = document.location.toString();
}
// Return true if the two URLs refer to the same data.
is_same_page(url1, url2)
{
var cleanup_url = function(url)
{
var url = new URL(url);
// p=1 and no page at all is the same. Remove p=1 so they compare the same.
if(url.searchParams.get("p") == "1")
url.searchParams.delete("p");
// Any "x" parameter is a dummy that we set to force the iframe to load, so ignore
// it here.
url.searchParams.delete("x");
// The hash doesn't affect the page that we load.
url.hash = "";
return url.toString();
};
var url1 = cleanup_url(url1);
var url2 = cleanup_url(url2);
return url1 == url2;
}
load_page_internal(page, callback)
{
// Our page URL looks like eg.
//
// https://www.pixiv.net/bookmark.php?p=2
//
// possibly with other search options. Request the current URL page data.
var url = new unsafeWindow.URL(document.location);
// Update the URL with the current page.
var params = url.searchParams;
params.set("p", page);
if(this.original_url && this.is_same_page(url, this.original_url))
{
this.finished_loading_illust(page, this.original_doc, callback);
return true;
}
// Work around a browser issue: loading an iframe with the same URL as the current page doesn't
// work. (This might have made sense once upon a time when it would always recurse, but today
// this doesn't make sense.) Just add a dummy query to the URL to make sure it's different.
//
// This usually doesn't happen, since we'll normally use this.original_doc if we're reading
// the same page. Skip it if it's not needed, so we don't throw weird URLs at the site if
// we don't have to.
if(this.is_same_page(url, document.location.toString()))
params.set("x", 1);
url.search = params.toString();
console.log("Loading:", url.toString());
helpers.load_data_in_iframe(url.toString(), function(document) {
this.finished_loading_illust(page, document, callback);
}.bind(this));
return true;
};
get estimated_items_per_page() { return this.items_per_page; }
// We finished loading a page. Parse it, register the results and call the completion callback.
finished_loading_illust(page, document, callback)
{
var illust_ids = this.parse_document(document);
// Assume that if the first request returns 10 items, all future pages will too. This
// is usually correct unless we happen to load the last page last. Allow this to increase
// in case that happens. (This is only used by the thumbnail view.)
if(this.items_per_page == 1)
this.items_per_page = Math.max(illust_ids.length, this.items_per_page);
// Register the new page of data.
if(!this.add_page(page, illust_ids))
{
// The page list was cleared because the underlying results have changed too much,
// which means we want to re-request pages when they're viewed next. Clear original_doc,
// or we won't actually do that for page 1.
this.original_doc = null;
this.original_url = null;
}
if(callback)
callback();
}
// Parse the loaded document and return the illust_ids.
parse_document(document)
{
throw "Not implemented";
}
// Update the address bar with the current illustration ID. If that illust ID is on a different
// page and we know the page number, update that as well.
set_current_illust_id(illust_id, add_to_history)
{
var query_args = page_manager.singleton().get_query_args();
var hash_args = page_manager.singleton().get_hash_args();
// Store the current illust ID in the hash, since the real bookmark page doesn't have
// an illust_id.
hash_args.set("illust_id", illust_id);
// Update the current page. (This can be undefined if we're on a page that isn't
// actually loaded for some reason.)
var original_page = this.id_list.get_page_for_illust(illust_id);
if(original_page != null)
query_args.set("p", original_page);
page_manager.singleton().set_args(query_args, hash_args, add_to_history);
};
};
// There are two ways we can show images for a user: from an illustration page
// (member_illust.php?mode=medium&illust_id=1234), or from the user's works page
// (member_illust.php?id=1234).
//
// The illustration page is better, since it gives us the ID of every post by the
// user, so we don't have to fetch them page by page, but we have to know the ID
// of a post to get to to that. It's also handy because we can tell where we are
// in the list from the illustration ID without having to know which page we're on,
// the page has the user info encoded (so we don't have to request it separately,
// making loads faster), and if we're going to display a specific illustration, we
// don't need to request it separately either.
//
// However, we can only do searching and filtering on the user page, and that's
// where we land when we load a link to the user.
class data_source_artist extends data_source_from_page
{
constructor(doc)
{
super(doc);
this.fetched_user_info = false;
}
get name() { return "artist"; }
get viewing_user_id()
{
var query_args = page_manager.singleton().get_query_args();
return query_args.get("id");
};
// If we're viewing a page specific to a user (an illustration or artist page), return
// the username we're viewing. This can change when refreshing the UI.
get viewing_username()
{
return this.username;
};
load_page(page, callback)
{
// The first time we load a page, start loading the user's info too.
if(!this.fetched_user_info)
{
this.fetched_user_info = true;
var url = new URL(document.location);
var user_id = url.searchParams.get("id");
if(user_id == null)
{
console.error("Don't know how to handle URL:", url);
return;
}
image_data.singleton().get_user_info(user_id, function(user_info) {
// Refresh our UI now that we have user info.
this.user_info = user_info;
this.call_update_listeners();
}.bind(this));
}
return super.load_page(page, callback);
}
parse_document(document)
{
// Find the user's name. We'll get this with the user data when it's fetched later, but
// we grab it now so we can return it from get_displaying_text.
var user_name_element = document.querySelector("a.user-name[title]");
this.username = user_name_element.title;
// Grab the user's post tags, if any.
this.post_tags = [];
for(var element of document.querySelectorAll(".user-tags a[href*='member_illust'][href*='tag=']"))
{
var tag = new URL(element.href).searchParams.get("tag");
if(tag != null)
this.post_tags.push(tag);
}
var items = document.querySelectorAll("A.work[href*='member_illust.php']");
var illust_ids = [];
for(var item of items)
{
var url = new URL(item.href);
illust_ids.push(url.searchParams.get("illust_id"));
}
return illust_ids;
}
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.user_info)
{
thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
helpers.set_page_icon(this.user_info.isFollowed? binary_data['favorited_icon.png']:binary_data['regular_pixiv_icon.png']);
}
this.set_item(container, "works", {type: null});
this.set_item(container, "manga", {type: "manga"});
this.set_item(container, "ugoira", {type: "ugoira"});
// Refresh the post tag list.
var current_query = new URL(document.location).searchParams.toString();
var tag_list = container.querySelector(".post-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = function(tag)
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(document.location);
if(tag != "All")
url.searchParams.set("tag", tag);
else
{
url.searchParams.delete("tag");
a.dataset["default"] = 1;
}
a.href = url.toString();
if(url.searchParams.toString() == current_query)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
for(var tag of this.post_tags || [])
add_tag_link(tag);
this.set_active_popup_highlight(container, [".member-tags-box"]);
}
get page_title() { return this.username; }
get_displaying_text()
{
if(this.username)
return this.username + "'s illustrations";
else
return "Illustrations";
};
}
class data_source_current_illust extends data_source_from_page
{
get name() { return "illust"; }
// Show the illustration by default.
get show_thumbs_by_default()
{
return false;
};
get_default_page() { return 1; }
// We only have one page and we already have it when we're constructed, but we wait to load
// it until load_page is called so this acts the same as the asynchronous data sources.
load_page(page, callback)
{
// This data source only ever loads a single page.
if(page != null && page != 1)
return false;
return super.load_page(page, callback);
}
parse_document(document)
{
var data = helpers.get_global_init_data(document);
if(data == null)
{
console.error("Couldn't find globalInitData");
return;
}
var illust_id = Object.keys(data.preload.illust)[0];
var user_id = Object.keys(data.preload.user)[0];
this.user_info = data.preload.user[user_id];
var this_illust_data = data.preload.illust[illust_id];
// Add the precache data for the image and user.
image_data.singleton().add_illust_data(this_illust_data);
image_data.singleton().add_user_data(data.preload.user[user_id]);
// Stash the user data so we can use it in get_displaying_text.
this.user_info = data.preload.user[user_id];
// Add the image list.
var illust_ids = [];
for(var related_illust_id in this_illust_data.userIllusts)
{
if(related_illust_id == illust_id)
continue;
illust_ids.push(related_illust_id);
}
// Make sure our illustration is in the list.
if(illust_ids.indexOf(illust_id) == -1)
illust_ids.push(illust_id);
// Sort newest first.
illust_ids.sort(function(a,b) { return b-a; });
return illust_ids;
};
// Unlike most data_source_from_page implementations, we only have a single page.
get_default_illust_id()
{
// ?illust_id should always be an illustration ID on illustration pages.
var query_args = page_manager.singleton().get_query_args();
return query_args.get("illust_id");
};
set_current_illust_id(illust_id, replace)
{
var query_args = page_manager.singleton().get_query_args();
var hash_args = page_manager.singleton().get_hash_args();
query_args.set("illust_id", illust_id);
page_manager.singleton().set_args(query_args, hash_args, replace);
};
get page_title()
{
if(this.user_info)
return this.user_info.name;
else
return "Illustrations";
}
get_displaying_text()
{
if(this.user_info)
return this.user_info.name + "'s illustrations";
else
return "Illustrations";
};
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.user_info)
{
thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
helpers.set_page_icon(this.user_info.isFollowed? binary_data['favorited_icon.png']:binary_data['regular_pixiv_icon.png']);
}
}
get page_title()
{
if(this.user_info)
return this.user_info.name;
else
return "Illustrations";
}
get viewing_user_id()
{
if(this.user_info == null)
return null;
return this.user_info.userId;
};
get viewing_username()
{
if(this.user_info == null)
return null;
return this.user_info.name;
}
};
// bookmark.php
//
// If id is in the query, we're viewing another user's bookmarks. Otherwise, we're
// viewing our own.
class data_source_bookmarks extends data_source_from_page
{
get name() { return "bookmarks"; }
constructor(doc)
{
super(doc);
this.bookmark_tags = [];
}
// Return true if we're viewing our own bookmarks.
viewing_own_bookmarks()
{
var query_args = page_manager.singleton().get_query_args();
return !query_args.has("id");
}
// Parse the loaded document and return the illust_ids.
parse_document(document)
{
var title = document.querySelector(".user-name[title]");
this.username = title.getAttribute("title");
// Grab the user's bookmark tags, if any.
this.bookmark_tags = [];
for(var element of document.querySelectorAll("#bookmark_list a[href*='bookmark.php']"))
{
var tag = new URL(element.href).searchParams.get("tag");
if(tag != null)
this.bookmark_tags.push(tag);
}
var items = document.querySelectorAll("._image-items .image-item");
var user_data = { };
var illust_ids = [];
for(var i = 0; i < items.length; ++i)
{
var item = items[i];
// Pull the illustration ID out of the link. For some reason, URLSearchParams
// is stupid and can't handle being given a .search that has ? on it.
var link = item.querySelector("a[href^='member_illust']");
var user_data_div = item.querySelector("[data-user_id]");
// If user_data_div doesn't exist, skip the entry even if we have a link. This happens
// for deleted entries.
if(user_data_div == null)
continue;
var query = new URL(link.href).search.substr(1);
var params = new URLSearchParams(query);
var illust_id = params.get("illust_id");
illust_ids.push(illust_id);
}
return illust_ids;
}
get page_title()
{
if(!this.viewing_own_bookmarks())
{
if(this.username)
return this.viewing_username + "'s Bookmarks";
return "User's Bookmarks";
}
return "Bookmarks";
}
get_displaying_text()
{
if(!this.viewing_own_bookmarks())
{
if(this.viewing_username)
return this.viewing_username + "'s Bookmarks";
return "User's Bookmarks";
}
var query_args = page_manager.singleton().get_query_args();
var private_bookmarks = query_args.get("rest") == "hide";
var displaying = private_bookmarks? "Private bookmarks":"Bookmarks";
var tag = query_args.get("tag");
if(tag)
displaying += " with tag \"" + tag + "\"";
return displaying;
};
refresh_thumbnail_ui(container)
{
// The public/private button only makes sense when viewing your own bookmarks.
container.querySelector(".bookmarks-public-private").hidden = !this.viewing_own_bookmarks();
// Set up the public and private buttons.
this.set_item(container, "public", {rest: null});
this.set_item(container, "private", {rest: "hide"});
// Refresh the bookmark tag list.
var current_query = new URL(document.location).searchParams.toString();
var tag_list = container.querySelector(".bookmark-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = function(tag)
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(document.location);
if(tag == "Uncategorized")
url.searchParams.set("untagged", 1);
else
url.searchParams.delete("untagged", 1);
if(tag != "All" && tag != "Uncategorized")
url.searchParams.set("tag", tag);
else
url.searchParams.delete("tag");
a.href = url.toString();
if(url.searchParams.toString() == current_query)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
add_tag_link("Uncategorized");
for(var tag of this.bookmark_tags || [])
add_tag_link(tag);
}
get viewing_user_id()
{
var query_args = page_manager.singleton().get_query_args();
return query_args.get("id");
};
get viewing_username()
{
return this.username;
}
};
// new_illust.php
class data_source_new_illust extends data_source_from_page
{
get name() { return "new_illust"; }
// Parse the loaded document and return the illust_ids.
parse_document(document)
{
var items = document.querySelectorAll("A.work[href*='member_illust.php']");
var illust_ids = [];
for(var item of items)
{
var url = new URL(item.href);
illust_ids.push(url.searchParams.get("illust_id"));
}
return illust_ids;
}
get page_title()
{
return "New Works";
}
get_displaying_text()
{
return "New Works";
};
refresh_thumbnail_ui(container)
{
this.set_item(container, "new-illust-type-all", {type: null});
this.set_item(container, "new-illust-type-illust", {type: "illust"});
this.set_item(container, "new-illust-type-manga", {type: "manga"});
this.set_item(container, "new-illust-type-ugoira", {type: "ugoira"});
// These links are different from anything else on the site: they switch between
// two top-level pages, even though they're just flags and everything else is the
// same.
var all_ages_link = container.querySelector("[data-type='new-illust-ages-all']");
var r18_link = container.querySelector("[data-type='new-illust-ages-r18']");
var button_is_selected = true;
var url = new URL(document.location);
url.pathname = "/new_illust.php";
all_ages_link.href = url;
var url = new URL(document.location);
url.pathname = "/new_illust_r18.php";
r18_link.href = url;
var url = new URL(document.location);
var currently_all_ages = url.pathname == "/new_illust.php";
helpers.set_class(currently_all_ages? all_ages_link:r18_link, "selected", button_is_selected);
}
}
// bookmark_new_illust.php
class data_source_bookmarks_new_illust extends data_source_from_page
{
get name() { return "bookmarks_new_illust"; }
constructor(doc)
{
super(doc);
this.bookmark_tags = [];
}
// Parse the loaded document and return the illust_ids.
parse_document(document)
{
this.bookmark_tags = [];
for(var element of document.querySelectorAll(".menu-items a[href*='bookmark_new_illust.php?tag'] span.icon-text"))
this.bookmark_tags.push(element.innerText);
var element = document.querySelector("#js-mount-point-latest-following");
var items = JSON.parse(element.dataset.items);
var illust_ids = [];
for(var illust of items)
illust_ids.push(illust.illustId);
return illust_ids;
}
get page_title()
{
return "Following";
}
get_displaying_text()
{
return "Following";
};
refresh_thumbnail_ui(container)
{
// Refresh the bookmark tag list.
var current_tag = new URL(document.location).searchParams.get("tag") || "All";
var tag_list = container.querySelector(".bookmark-tag-list");
helpers.remove_elements(tag_list);
var add_tag_link = function(tag)
{
var a = document.createElement("a");
a.classList.add("box-link");
a.classList.add("following-tag");
a.innerText = tag;
var url = new URL(document.location);
if(tag != "All")
url.searchParams.set("tag", tag);
else
url.searchParams.delete("tag");
a.href = url.toString();
if(tag == current_tag)
a.classList.add("selected");
tag_list.appendChild(a);
};
add_tag_link("All");
for(var tag of this.bookmark_tags)
add_tag_link(tag);
}
};
// search.php
class data_source_search extends data_source_from_page
{
get name() { return "search"; }
parse_document(document)
{
// The actual results are encoded in a string for some reason.
var result_list_json = document.querySelector("#js-mount-point-search-result-list").dataset.items;
var illusts = JSON.parse(result_list_json);
// Store related tags. Only do this the first time and don't change it when we read
// future pages, so the tags don't keep changing as you scroll around.
if(this.related_tags == null)
{
var related_tags_json = document.querySelector("#js-mount-point-search-result-list").dataset.relatedTags;
var related_tags = JSON.parse(related_tags_json);
this.related_tags = related_tags;
}
if(this.tag_translation == null)
{
var span = document.querySelector(".search-result-information .translation-column-title");
if(span != null)
{
this.tag_translation = span.innerText;
console.log(this.tag_translation);
}
}
var illust_ids = [];
for(var illust of illusts)
illust_ids.push(illust.illustId);
return illust_ids;
}
get page_title()
{
var query_args = page_manager.singleton().get_query_args();
var displaying = "Search: ";
var tag = query_args.get("word");
if(tag)
displaying += tag;
return displaying;
}
get_displaying_text()
{
var displaying = this.page_title;
// Add the tag translation if there is one. We only put this in the page and not
// the title to avoid cluttering the title.
if(this.tag_translation != null)
displaying += " (" + this.tag_translation + ")";
return displaying;
};
refresh_thumbnail_ui(container, thumbnail_view)
{
if(this.related_tags)
{
thumbnail_view.tag_widget.set({
tags: this.related_tags
});
}
this.set_item(container, "ages-all", {mode: null});
this.set_item(container, "ages-safe", {mode: "safe"});
this.set_item(container, "ages-r18", {mode: "r18"});
this.set_item(container, "order-newest", {order: null}, {order: "date_d"});
this.set_item(container, "order-oldest", {order: "date"});
this.set_item(container, "order-male", {order: "popular_male_d"});
this.set_item(container, "order-female", {order: "popular_female_d"});
this.set_item(container, "search-type-all", {type: null});
this.set_item(container, "search-type-illust", {type: "illust"});
this.set_item(container, "search-type-manga", {type: "manga"});
this.set_item(container, "search-type-ugoira", {type: "ugoira"});
this.set_item(container, "search-all", {s_mode: null}, {s_mode: "s_tag"});
this.set_item(container, "search-exact", {s_mode: "s_tag_full"});
this.set_item(container, "search-text", {s_mode: "s_tc"});
this.set_item(container, "res-all", {wlt: null, hlt: null, wgt: null, hgt: null});
this.set_item(container, "res-high", {wlt: 3000, hlt: 3000, wgt: null, hgt: null});
this.set_item(container, "res-medium", {wlt: 1000, hlt: 1000, wgt: 2999, hgt: 2999});
this.set_item(container, "res-low", {wlt: null, hlt: null, wgt: 999, hgt: 999});
this.set_item(container, "aspect-ratio-all", {ratio: null});
this.set_item(container, "aspect-ratio-landscape", {ratio: "0.5"});
this.set_item(container, "aspect-ratio-portrait", {ratio: "-0.5"});
this.set_item(container, "aspect-ratio-square", {ratio: "0"});
this.set_item(container, "bookmarks-all", {blt: null, bgt: null});
this.set_item(container, "bookmarks-5000", {blt: 5000, bgt: null});
this.set_item(container, "bookmarks-2500", {blt: 2500, bgt: null});
this.set_item(container, "bookmarks-1000", {blt: 1000, bgt: null});
this.set_item(container, "bookmarks-500", {blt: 500, bgt: null});
this.set_item(container, "bookmarks-250", {blt: 250, bgt: null});
this.set_item(container, "bookmarks-100", {blt: 100, bgt: null});
// The time filter is a range, but I'm not sure what time zone it filters in
// (presumably either JST or UTC). There's also only a date and not a time,
// which means you can't actually filter "today", since there's no way to specify
// which "today" you mean. So, we offer filtering starting at "this week",
// and you can just use the default date sort if you want to see new posts.
// For "this week", we set the end date a day in the future to make sure we
// don't filter out posts today.
this.set_item(container, "time-all", {scd: null, ecd: null});
var format_date = function(date)
{
var f = (date.getYear() + 1900).toFixed();
return (date.getYear() + 1900).toFixed().padStart(2, "0") + "-" +
(date.getMonth() + 1).toFixed().padStart(2, "0") + "-" +
date.getDate().toFixed().padStart(2, "0");
};
var set_date_filter = function(name, start, end)
{
var start_date = format_date(start);
var end_date = format_date(end);
this.set_item(container, name, {scd: start_date, ecd: end_date});
}.bind(this);
var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
var last_week = new Date(); last_week.setDate(last_week.getDate() - 7);
var last_month = new Date(); last_month.setMonth(last_month.getMonth() - 1);
var last_year = new Date(); last_year.setFullYear(last_year.getFullYear() - 1);
set_date_filter("time-week", last_week, tomorrow);
set_date_filter("time-month", last_month, tomorrow);
set_date_filter("time-year", last_year, tomorrow);
for(var years_ago = 1; years_ago <= 7; ++years_ago)
{
var start_year = new Date(); start_year.setFullYear(start_year.getFullYear() - years_ago - 1);
var end_year = new Date(); end_year.setFullYear(end_year.getFullYear() - years_ago);
set_date_filter("time-years-ago-" + years_ago, start_year, end_year);
}
this.set_active_popup_highlight(container, [".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box"]);
// The "reset search" button removes everything in the query except search terms.
var box = container.querySelector(".reset-search");
var url = new URL(document.location);
var tag = url.searchParams.get("word");
url.search = "";
if(tag != null)
url.searchParams.set("word", tag);
box.href = url;
}
};
// This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs.
var encode_mkv = (function() {
var encode_length = function(value)
{
// Encode a 40-bit EBML int. This lets us encode 32-bit ints with no extra logic.
return struct(">BI").pack(0x08, value);
};
var header_int = function(container, identifier, value)
{
container.push(new Uint8Array(identifier));
var data = struct(">II").pack(0, value);
var size = data.byteLength;
container.push(encode_length(size));
container.push(data);
};
var header_float = function(container, identifier, value)
{
container.push(new Uint8Array(identifier));
var data = struct(">f").pack(value);
var size = data.byteLength;
container.push(encode_length(size));
container.push(data);
};
var header_data = function(container, identifier, data)
{
container.push(new Uint8Array(identifier));
container.push(encode_length(data.byteLength));
container.push(data);
};
// Return the total size of an array of ArrayBuffers.
var total_size = function(array)
{
var size = 0;
for(var idx = 0; idx < array.length; ++idx)
{
var item = array[idx];
size += item.byteLength;
}
return size;
};
var append_array = function(a1, a2)
{
var result = new Uint8Array(a1.byteLength + a2.byteLength);
result.set(new Uint8Array(a1));
result.set(new Uint8Array(a2), a1.byteLength);
return result;
};
// Create an EBML block from an identifier and a list of Uint8Array parts. Return a
// single Uint8Array.
var create_data_block = function(identifier, parts)
{
var identifier = new Uint8Array(identifier);
var data_size = total_size(parts);
var encoded_data_size = encode_length(data_size);
var result = new Uint8Array(identifier.byteLength + encoded_data_size.byteLength + data_size);
var pos = 0;
result.set(new Uint8Array(identifier), pos);
pos += identifier.byteLength;
result.set(new Uint8Array(encoded_data_size), pos);
pos += encoded_data_size.byteLength;
for(var i = 0; i < parts.length; ++i)
{
var part = parts[i];
result.set(new Uint8Array(part), pos);
pos += part.byteLength;
}
return result;
};
// EBML data types
var ebml_header = function()
{
var parts = [];
header_int(parts, [0x42, 0x86], 1); // EBMLVersion
header_int(parts, [0x42, 0xF7], 1); // EBMLReadVersion
header_int(parts, [0x42, 0xF2], 4); // EBMLMaxIDLength
header_int(parts, [0x42, 0xF3], 8); // EBMLMaxSizeLength
header_data(parts, [0x42, 0x82], new Uint8Array([0x6D, 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61])); // DocType ("matroska")
header_int(parts, [0x42, 0x87], 4); // DocTypeVersion
header_int(parts, [0x42, 0x85], 2); // DocTypeReadVersion
return create_data_block([0x1A, 0x45, 0xDF, 0xA3], parts); // EBML
};
var ebml_info = function(duration)
{
var parts = [];
header_int(parts, [0x2A, 0xD7, 0xB1], 1000000); // TimecodeScale
header_data(parts, [0x4D, 0x80], new Uint8Array([120])); // MuxingApp ("x") (this shouldn't be mandatory)
header_data(parts, [0x57, 0x41], new Uint8Array([120])); // WritingApp ("x") (this shouldn't be mandatory)
header_float(parts, [0x44, 0x89], duration * 1000); // Duration (why is this a float?)
return create_data_block([0x15, 0x49, 0xA9, 0x66], parts); // Info
};
var ebml_track_entry_video = function(width, height)
{
var parts = [];
header_int(parts, [0xB0], width); // PixelWidth
header_int(parts, [0xBA], height); // PixelHeight
return create_data_block([0xE0], parts); // Video
};
var ebml_track_entry = function(width, height)
{
var parts = [];
header_int(parts, [0xD7], 1); // TrackNumber
header_int(parts, [0x73, 0xC5], 1); // TrackUID
header_int(parts, [0x83], 1); // TrackType (video)
header_int(parts, [0x9C], 0); // FlagLacing
header_int(parts, [0x23, 0xE3, 0x83], 33333333); // DefaultDuration (overridden per frame)
header_data(parts, [0x86], new Uint8Array([0x56, 0x5f, 0x4d, 0x4a, 0x50, 0x45, 0x47])); // CodecID ("V_MJPEG")
parts.push(ebml_track_entry_video(width, height));
return create_data_block([0xAE], parts); // TrackEntry
};
var ebml_tracks = function(width, height)
{
var parts = [];
parts.push(ebml_track_entry(width, height));
return create_data_block([0x16, 0x54, 0xAE, 0x6B], parts); // Tracks
};
var ebml_simpleblock = function(frame_data)
{
// We should be able to use encode_length(1), but for some reason, while everything else
// handles our non-optimal-length ints just fine, this field doesn't. Manually encode it
// instead.
var result = new Uint8Array([
0x81, // track number 1 (EBML encoded)
0, 0, // timecode relative to cluster
0x80, // flags (keyframe)
]);
result = append_array(result, frame_data);
return result;
};
var ebml_cluster = function(frame_data, frame_time)
{
var parts = [];
header_int(parts, [0xE7], Math.round(frame_time * 1000)); // Timecode
header_data(parts, [0xA3], ebml_simpleblock(frame_data)); // SimpleBlock
return create_data_block([0x1F, 0x43, 0xB6, 0x75], parts); // Cluster
};
var ebml_cue_track_positions = function(file_position)
{
var parts = [];
header_int(parts, [0xF7], 1); // CueTrack
header_int(parts, [0xF1], file_position); // CueClusterPosition
return create_data_block([0xB7], parts); // CueTrackPositions
};
var ebml_cue_point = function(frame_time, file_position)
{
var parts = [];
header_int(parts, [0xB3], Math.round(frame_time * 1000)); // CueTime
parts.push(ebml_cue_track_positions(file_position));
return create_data_block([0xBB], parts); // CuePoint
};
var ebml_cues = function(frame_times, frame_file_positions)
{
var parts = [];
for(var frame = 0; frame < frame_file_positions.length; ++frame)
{
var frame_time = frame_times[frame];
var file_position = frame_file_positions[frame];
parts.push(ebml_cue_point(frame_time, file_position));
}
return create_data_block([0x1C, 0x53, 0xBB, 0x6B], parts); // Cues
};
var ebml_segment = function(parts)
{
return create_data_block([0x18, 0x53, 0x80, 0x67], parts); // Segment
};
// API:
// We don't decode the JPEG frames while we do this, so the resolution is supplied here.
class encode_mkv
{
constructor(width, height)
{
this.width = width;
this.height = height;
this.frames = [];
}
add(jpeg_data, frame_duration_ms)
{
this.frames.push({
data: jpeg_data,
duration: frame_duration_ms,
});
};
build()
{
// Sum the duration of the video.
var duration = 0;
for(var frame = 0; frame < this.frames.length; ++frame)
{
var data = this.frames[frame].data;
var ms = this.frames[frame].duration;
duration += ms / 1000.0;
}
var header_parts = ebml_header();
var parts = [];
parts.push(ebml_info(duration));
parts.push(ebml_tracks(this.width, this.height));
// Figure out how many bytes there are from the start of the file to the first
// frame.
var current_pos = header_parts.byteLength;
// The size of the segment (which comes before all parts) doesn't change based
// on the number of entries, so create an empty one to quickly find out how much
// to advance.
current_pos += ebml_segment([]).byteLength;
for(var part of parts)
current_pos += part.byteLength;
// Create each frame as its own cluster, and keep track of the file position of each.
var frame_file_positions = [];
var frame_file_times = [];
var frame_time = 0;
for(var frame = 0; frame < this.frames.length; ++frame)
{
var data = this.frames[frame].data;
var ms = this.frames[frame].duration;
var cluster = ebml_cluster(data, frame_time);
parts.push(cluster);
frame_file_positions.push(current_pos);
frame_file_times.push(frame_time);
frame_time += ms / 1000.0;
current_pos += cluster.byteLength;
};
// Add the frame index.
parts.push(ebml_cues(frame_file_times, frame_file_positions));
// Create an EBMLSegment containing all of the parts (excluding the header).
var segment = ebml_segment(parts);
// Return a blob containing the final data.
var file = [];
file = file.concat(header_parts);
file = file.concat(segment);
return new Blob(file);
};
};
return encode_mkv;
})();
// Hide the mouse cursor when it hasn't moved briefly, to get it out of the way.
// This only hides the cursor over element.
//
// Chrome's cursor handling is buggy and doesn't update the cursor when it's not
// moving, so this only works in Firefox.
var hide_mouse_cursor_on_idle = function(element)
{
this.onmousemove = this.onmousemove.bind(this);
this.onblur = this.onblur.bind(this);
this.idle = this.idle.bind(this);
this.hide_immediately = this.hide_immediately.bind(this);
this.element = element;
this.force_hidden_until = null;
window.addEventListener("mousemove", this.onmousemove, true);
window.addEventListener("blur", this.blur, true);
window.addEventListener("hide-cursor-immediately", this.hide_immediately, true);
this.reset_timer();
};
hide_mouse_cursor_on_idle.prototype.remove_timer = function()
{
if(!this.timer)
return;
clearInterval(this.timer);
this.timer = null;
}
// Hide the cursor now, and keep it hidden very briefly even if it moves. This is done
// when releasing a zoom to prevent spuriously showing the mouse cursor.
hide_mouse_cursor_on_idle.prototype.hide_immediately = function(e)
{
this.force_hidden_until = Date.now() + 150;
this.idle();
}
hide_mouse_cursor_on_idle.prototype.reset_timer = function()
{
this.show_cursor();
this.remove_timer();
this.timer = setTimeout(this.idle, 500);
}
hide_mouse_cursor_on_idle.prototype.idle = function()
{
this.remove_timer();
this.hide_cursor();
}
hide_mouse_cursor_on_idle.prototype.onmousemove = function(e)
{
if(this.force_hidden_until && this.force_hidden_until > Date.now())
return;
this.reset_timer();
}
hide_mouse_cursor_on_idle.prototype.onblur = function(e)
{
this.remove_timer();
this.show_cursor();
}
hide_mouse_cursor_on_idle.prototype.show_cursor = function(e)
{
// this.element.style.cursor = "";
this.element.classList.remove("hide-cursor");
}
hide_mouse_cursor_on_idle.prototype.hide_cursor = function(e)
{
// Setting style.cursor to none doesn't work in Chrome. Doing it with a style works
// intermittently (seems to work better in fullscreen). Firefox doesn't have these
// problems.
// this.element.style.cursor = "none";
this.element.classList.add("hide-cursor");
}
// This handles fetching and caching image data and associated user data.
//
// We always load the user data for an illustration if it's not already loaded. We also
// load ugoira_metadata. This way, we can access all the info we need for an image in
// one place, without doing multi-phase loads elsewhere.
class image_data
{
constructor()
{
this.call_pending_callbacks = this.call_pending_callbacks.bind(this);
this.loaded_image_info = this.loaded_image_info.bind(this);
this.load_user_info = this.load_user_info.bind(this);
this.loaded_user_info = this.loaded_user_info.bind(this);
// Cached data:
this.image_data = { };
this.user_data = { };
this.illust_id_to_user_id = {};
this.loading_image_data_ids = {};
this.loading_user_data_ids = {};
this.pending_image_info_calls = [];
this.pending_user_info_calls = [];
};
// Return the singleton, creating it if needed.
static singleton()
{
if(image_data._singleton == null)
image_data._singleton = new image_data();
return image_data._singleton;
};
// Get image data. Call callback when it's available:
//
// callback(image_data, user_data);
//
// User data for the illustration will be fetched, and returned as image_data.userInfo.
// Note that user data can change (eg. when following a user), and all images for the
// same user will share the same userInfo object.
//
// If illust_id is a video, we'll also download the metadata before returning it, and store
// it as image_data.ugoiraMetadata.
get_image_info(illust_id, callback)
{
// If callback is null, just fetch the data.
if(callback != null)
this.pending_image_info_calls.push([illust_id, callback]);
this.load_image_info(illust_id);
}
// Just get user info.
get_user_info(user_id, callback)
{
// If callback is null, just fetch the data.
if(callback != null)
this.pending_user_info_calls.push([user_id, callback]);
this.load_user_info(user_id);
}
call_pending_callbacks()
{
// Copy the list, in case get_image_info is called from a callback.
var callbacks = this.pending_image_info_calls.slice();
for(var i = 0; i < this.pending_image_info_calls.length; ++i)
{
var pending = this.pending_image_info_calls[i];
var illust_id = pending[0];
var callback = pending[1];
// Wait until we have all the info for this image.
var illust_data = this.image_data[illust_id];
if(illust_data == null)
continue;
var user_data = this.user_data[illust_data.userId];
if(user_data == null)
continue;
// Make sure user_data is referenced from the image.
illust_data.userInfo = user_data;
// Remove the entry.
this.pending_image_info_calls.splice(i, 1);
--i;
// Run the callback.
try {
callback(illust_data);
} catch(e) {
console.error(e);
}
}
// Call user info callbacks. These are simpler.
var callbacks = this.pending_user_info_calls.slice();
for(var i = 0; i < this.pending_user_info_calls.length; ++i)
{
var pending = this.pending_user_info_calls[i];
var user_id = pending[0];
var callback = pending[1];
// Wait until we have all the info for this user.
var user_data = this.user_data[user_id];
if(user_data == null)
continue;
// Remove the entry.
this.pending_user_info_calls.splice(i, 1);
--i;
// Run the callback.
try {
callback(user_data);
} catch(e) {
console.error(e);
}
}
}
// Load illust_id and all data that it depends on. When it's available, call call_pending_callbacks.
load_image_info(illust_id)
{
// If we have the user ID cached, start loading it without waiting for the
// illustration data to load first.
var cached_user_id = this.illust_id_to_user_id[illust_id];
if(cached_user_id != null)
this.load_user_info(cached_user_id);
// If we're already loading this illustration, stop.
if(this.loading_image_data_ids[illust_id])
return;
// If we already have this illustration, just make sure we're fetching the user.
if(this.image_data[illust_id] != null)
{
this.load_user_info(this.image_data[illust_id].userId);
return;
}
// console.log("Fetch illust", illust_id);
this.loading_image_data_ids[illust_id] = true;
// This call returns only preview data, so we can't use it to batch load data, but we could
// use it to get thumbnails for a navigation pane:
// helpers.rpc_get_request("/rpc/illust_list.php?illust_ids=" + illust_id, function(result) { });
helpers.get_request("/ajax/illust/" + illust_id, {}, this.loaded_image_info);
}
loaded_image_info(illust_result)
{
if(illust_result == null || illust_result.error)
return;
var illust_data = illust_result.body;
var illust_id = illust_data.illustId;
// console.log("Got illust", illust_id);
// This is usually set by load_image_info, but we also need to set it if we're called by
// add_illust_data so it's true if we fetch metadata below.
this.loading_image_data_ids[illust_id] = true;
var finished_loading_image_data = function()
{
delete this.loading_image_data_ids[illust_id];
// Store the image data.
this.image_data[illust_id] = illust_data;
// Load user info for the illustration.
//
// Do this async rather than immediately, so if we're loading initial info with calls to
// add_illust_data and add_user_data, we'll give the caller a chance to finish and give us
// user info, rather than fetching it now when we won't need it.
setTimeout(function() {
this.load_user_info(illust_data.userId);
}.bind(this), 0);
}.bind(this);
if(illust_data.illustType == 2)
{
// If this is a video, load metadata and add it to the illust_data before we store it.
helpers.fetch_ugoira_metadata(illust_id, function(ugoira_result) {
illust_data.ugoiraMetadata = ugoira_result.body;
finished_loading_image_data();
}.bind(this));
}
else
{
// Otherwise, we're done loading the illustration.
finished_loading_image_data();
}
}
load_user_info(user_id)
{
// If we're already loading this user, stop.
if(this.loading_user_data_ids[user_id])
{
console.log("User " + user_id + " is already being fetched, waiting for it");
return;
}
// If we already have the user info for this illustration, we're done. Call call_pending_callbacks
// to fire any waiting callbacks.
if(this.user_data[user_id] != null)
{
setTimeout(function() {
this.call_pending_callbacks();
}.bind(this), 0);
return;
}
// console.log("Fetch user", user_id);
this.loading_user_data_ids[user_id] = true;
helpers.get_request("/ajax/user/" + user_id, {}, this.loaded_user_info);
}
loaded_user_info(user_result)
{
if(user_result.error)
return;
var user_data = user_result.body;
var user_id = user_data.userId;
// console.log("Got user", user_id);
delete this.loading_user_data_ids[user_id];
// Store the user data.
this.user_data[user_id] = user_data;
this.call_pending_callbacks();
}
// Add image and user data to the cache that we received from other sources. Note that if
// we have any fetches in the air already, we'll leave them running.
add_illust_data(illust_data)
{
// Call loaded_image_info directly, so we'll load video metadata, etc.
this.loaded_image_info({
error: false,
body: illust_data
});
}
add_user_data(user_data)
{
this.loaded_user_info({
body: user_data,
});
}
// When we load an image, we load the user with it, and we get the user ID from
// the illustration data. However, this is slow, since we have to wait for
// the illust request to finish before we know what user to load.
//
// In some cases we know from other sources what user we'll need (but where we
// don't want to load the user yet). This can be called to cache that, so if
// an illust is loaded, we can start the user fetch in parallel.
set_user_id_for_illust_id(illust_id, user_id)
{
this.illust_id_to_user_id[illust_id] = user_id;
}
}
// View img fullscreen. Clicking the image will zoom it to its original size and scroll
// it around.
//
// The image is always zoomed a fixed amount from its fullscreen size. This is generally
// more usable than doing things like zooming based on the native resolution.
var on_click_viewer = function(img)
{
this.onresize = this.onresize.bind(this);
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.block_event = this.block_event.bind(this);
this.window_blur = this.window_blur.bind(this);
// The caller can set this to a function to be called if the user clicks the image without
// dragging.
this.clicked_without_scrolling = null;
this.img = img;
this.img.style.width = "auto";
this.img.style.height = "100%";
this.enable();
};
on_click_viewer.prototype.image_changed = function()
{
if(this.watch_for_size_available)
{
clearInterval(this.watch_for_size_available);
this.watch_for_size_available = null;
}
// Hide the image until we have the size, so it doesn't flicker for one frame in the
// wrong place.
this.img.style.display = "none";
// We need to know the new natural size of the image, but in a huge web API oversight,
// there's no event for that. We don't want to wait for onload, since we want to know
// as soon as it's changed, so we'll set a timer and check periodically until we see
// a change.
//
// However, if we're changing from one image to another, there's no way to know when
// naturalWidth is updated. Work around this by loading the image in a second img and
// watching that instead. The browser will still only load the image once.
var dummy_img = document.createElement("img");
dummy_img.src = this.img.src;
var image_ready = function() {
if(dummy_img.naturalWidth == 0)
return;
// Store the size. We can't use the values on this.img, since Firefox sometimes updates
// them at different times. (That seems like a bug, since browsers are never supposed to
// expose internal race conditions to scripts.)
this.width = dummy_img.naturalWidth;
this.height = dummy_img.naturalHeight;
if(this.watch_for_size_available)
clearInterval(this.watch_for_size_available);
this.watch_for_size_available = null;
this.reposition();
this.img.style.display = "block";
}.bind(this);
// If the image is already loaded out of cache, it's ready now. Checking this now
// reduces flicker between images.
if(dummy_img.naturalWidth != 0)
image_ready();
else
this.watch_for_size_available = setInterval(image_ready, 10);
}
on_click_viewer.prototype.block_event = function(e)
{
e.preventDefault();
}
on_click_viewer.prototype.enable = function()
{
var target = this.img.parentNode;
this.event_target = target;
window.addEventListener("blur", this.window_blur);
window.addEventListener("resize", this.onresize, true);
target.addEventListener("mousedown", this.mousedown);
window.addEventListener("mouseup", this.mouseup);
target.addEventListener("dragstart", this.block_event);
target.addEventListener("selectstart", this.block_event);
// document.documentElement.style.overflow = "hidden";
target.style.MozUserSelect = "none";
}
on_click_viewer.prototype.disable = function()
{
if(this.img.parentNode == null)
{
console.log("Viewer already disabled");
return;
}
this.stop_dragging();
this.img.parentNode.removeChild(this.img);
if(this.watch_for_size_available)
{
clearInterval(this.watch_for_size_available);
this.watch_for_size_available = null;
}
if(this.event_target)
{
var target = this.event_target;
this.event_target = null;
target.removeEventListener("mousedown", this.mousedown);
target.removeEventListener("dragstart", this.block_event);
target.removeEventListener("selectstart", this.block_event);
target.style.MozUserSelect = "";
}
window.removeEventListener("blur", this.window_blur);
window.removeEventListener("resize", this.onresize, true);
window.removeEventListener("mouseup", this.mouseup);
window.removeEventListener("mousemove", this.mousemove);
}
on_click_viewer.prototype.onresize = function(e)
{
this.reposition();
}
on_click_viewer.prototype.window_blur = function(e)
{
this.stop_dragging();
}
on_click_viewer.prototype.mousedown = function(e)
{
if(e.button != 0)
return;
// We only want clicks on the image, or on the container backing the image, not other
// elements inside the container.
if(e.target != this.img && e.target != this.img.parentNode)
return;
this.disabled_cursor_on_element = e.target;
this.saved_cursor = e.target.style.cursor;
e.target.style.cursor = "none";
// Don't show the UI if the mouse hovers over it while dragging.
document.body.classList.add("hide-ui");
this.zoomed = true;
this.dragged_while_zoomed = false;
var img_rect = this.img.getBoundingClientRect();
// Set the zoom position to the top-left.
this.zoom_pos = [0,0]; //img_rect.left, img_rect.top];
// The size of the image being clicked:
var displayed_width = img_rect.right - img_rect.left;
var displayed_height = img_rect.bottom - img_rect.top;
// The offset of the click in pixels relative to the image:
var distance_from_img = [e.clientX - img_rect.left, e.clientY - img_rect.top];
// The normalized position clicked in the image (0-1).
// This adjusts the initial position, so the position clicked stays stationary.
this.zoom_center = [distance_from_img[0] / displayed_width, distance_from_img[1] / displayed_height];
this.reposition();
// Only listen to mousemove while we're dragging. Put this on window, so we get drags outside
// the window.
window.addEventListener("mousemove", this.mousemove);
}
on_click_viewer.prototype.mouseup = function(e)
{
if(e.button != 0)
return;
if(!this.zoomed)
return;
// Tell hide_mouse_cursor_on_idle that the mouse cursor should be hidden, even though the
// cursor may have just been moved. This prevents the cursor from appearing briefly and
// disappearing every time a zoom is released.
window.dispatchEvent(new Event("hide-cursor-immediately"));
this.stop_dragging();
}
on_click_viewer.prototype.stop_dragging = function()
{
window.removeEventListener("mousemove", this.mousemove);
if(this.disabled_cursor_on_element != null)
{
this.disabled_cursor_on_element.style.cursor = this.saved_cursor;
this.disabled_cursor_on_element = null;
this.saved_cursor = null;
}
document.body.classList.remove("hide-ui");
document.body.style.cursor = "";
this.zoomed = false;
this.reposition();
if(!this.dragged_while_zoomed && this.clicked_without_scrolling)
this.clicked_without_scrolling();
}
on_click_viewer.prototype.mousemove = function(e)
{
if(!this.zoomed)
return;
this.dragged_while_zoomed = true;
// Apply mouse dragging.
var x_offset = -e.movementX;
var y_offset = -e.movementY;
this.zoom_pos[0] += x_offset * 3;
this.zoom_pos[1] += y_offset * 3;
this.reposition();
}
on_click_viewer.prototype.reposition = function()
{
// Stop if we're being called after being disabled.
if(this.img.parentNode == null)
return;
var totalWidth = this.img.parentNode.offsetWidth;
var totalHeight = this.img.parentNode.offsetHeight;
var width = this.width;
var height = this.height;
// The ratio to scale the image to fit the screen:
var zoom_ratio = Math.min(totalWidth/width, totalHeight/height);
this.zoom_ratio = zoom_ratio;
height *= this.zoom_ratio;
width *= this.zoom_ratio;
// Normally (when unzoomed), the image is centered.
var left = Math.round((totalWidth - width) / 2);
var top = Math.round((totalHeight - height) / 2);
if(this.zoomed) {
var zoom_level = 2;
// left is the position of the left side of the image. We're going to scale around zoom_center,
// so shift by zoom_center in the unzoomed coordinate space. If zoom_center[0] is .5, shift
// the image left by half of its unzoomed width.
left += this.zoom_center[0] * width;
top += this.zoom_center[1] * height;
// Apply the zoom.
this.zoom_ratio *= zoom_level;
height *= zoom_level;
width *= zoom_level;
// Undo zoom centering in the new coordinate space.
left -= this.zoom_center[0] * width;
top -= this.zoom_center[1] * height;
// Apply the position.
left += this.zoom_pos[0];
top += this.zoom_pos[1];
}
left = Math.round(left);
top = Math.round(top);
this.img.style.width = width + "px";
this.img.style.height = height + "px";
this.img.style.position = "absolute";
this.img.style.left = left + "px";
this.img.style.top = top + "px";
this.img.style.right = "auto";
this.img.style.bottom = "auto";
};
var install_polyfills = function()
{
// Return true if name exists, eg. GM_xmlhttpRequest.
var script_global_exists = function(name)
{
// For some reason, the script globals like GM and GM_xmlhttpRequest aren't
// in window, so it's not clear how to check if they exist. Just try to
// access it and catch the ReferenceError exception if it doesn't exist.
try {
eval(name);
return true;
} catch(e) {
return false;
}
};
// If we have GM.xmlHttpRequest and not GM_xmlhttpRequest, set GM_xmlhttpRequest.
if(script_global_exists("GM") && GM.xmlHttpRequest && !script_global_exists("GM_xmlhttpRequest"))
window.GM_xmlhttpRequest = GM.xmlHttpRequest;
// padStart polyfill:
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
if(!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength,padString) {
targetLength = targetLength>>0; //truncate if number or convert non-number to 0;
padString = String((typeof padString !== 'undefined' ? padString : ' '));
if (this.length > targetLength) {
return String(this);
}
else {
targetLength = targetLength-this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0,targetLength) + String(this);
}
};
}
}
// A simple progress bar.
//
// Call bar.controller() to create a controller to update the progress bar.
class progress_bar
{
constructor(container)
{
this.container = container;
this.bar = this.container.appendChild(helpers.create_node('\
\
\
'));
this.bar.hidden = true;
};
// Create a progress_bar_controller for this progress bar.
//
// If there was a previous controller, it will be detached.
controller()
{
if(this.current_controller)
{
this.current_controller.detach();
this.current_controller = null;
}
this.current_controller = new progress_bar_controller(this);
return this.current_controller;
}
}
// This handles updating a progress_bar.
//
// This is separated from progress_bar, which allows us to transparently detach
// the controller from a progress_bar.
//
// For example, if we load a video file and show the loading in the progress bar, and
// the user then navigates to another video, we detach the first controller. This way,
// the new load will take over the progress bar (whether or not we actually cancel the
// earlier load) and progress bar users won't fight with each other.
class progress_bar_controller
{
constructor(bar)
{
this.progress_bar = bar;
}
set(value)
{
if(this.progress_bar == null)
return;
this.progress_bar.bar.hidden = (value == null);
this.progress_bar.bar.classList.remove("hide");
this.progress_bar.bar.getBoundingClientRect();
if(value != null)
this.progress_bar.bar.style.width = (value * 100) + "%";
}
// Flash the current progress value and fade out.
show_briefly()
{
this.progress_bar.bar.classList.add("hide");
}
detach()
{
this.progress_bar = null;
}
};
class seek_bar
{
constructor(container)
{
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mouseover = this.mouseover.bind(this);
this.mouseout = this.mouseout.bind(this);
this.container = container;
this.bar = this.container.appendChild(helpers.create_node('\