├── LICENSE ├── README.md └── Quicksave.plugin.js /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UNMAINTAINED 2 | 3 | # Quicksave-BD-plugin 4 | This is a plugin for BetterDiscord. 5 | 6 | ### What does it do? 7 | Adds saving buttons to images. These will download the images from their original urls and save them on disk. 8 | 9 | ![img](https://kosshi.net/images/quicksave0.png) 10 | ![img](https://kosshi.net/images/quicksave1.png) 11 | 12 | ### How to install? 13 | 1. Make sure you have native Discord application installed with [BetterDiscord](https://betterdiscord.net) 14 | 2. Locate the BetterDiscord plugin folder 15 | * %appdata%/BetterDiscord/plugins 16 | * or Settings>BetterDiscord>Plugins>Open Plugin Folder 17 | 3. Copy file 'Quicksave.plugin.js' to the plugin folder 18 | 4. Reload/restart Discord (CTRL+R) 19 | 5. Go to Settings>BetterDiscord>Plugins 20 | 6. Enable this plugin 21 | 7. Go to the plugin settings and set a download directory. 22 | 23 | 24 | ### Notes 25 | - The plugin will download images from their original URLs. This is done because Discord rehosted images are usually compressed. 26 | - This will reveal your IP address to the server when downloading. 27 | - The plugin wont check the files itself in case of viruses or stuff like that. 28 | - MIT license. Use at your own risk. 29 | -------------------------------------------------------------------------------- /Quicksave.plugin.js: -------------------------------------------------------------------------------- 1 | //META{"name":"Quicksave"}*// 2 | 3 | 'use strict'; 4 | class Quicksave { 5 | getAuthor() { return "kosshi";} 6 | getName() { return "Quicksave";} 7 | getVersion() { return "0.2.5";} 8 | getDescription(){ return "Lets you save images fast.";} 9 | load() {} // Called when the plugin is loaded in to memory 10 | start() { 11 | this.settingsVersion = 6; 12 | 13 | this.extensionWhitelist = 14 | `png, gif, jpg, jpeg, jpe, jif, jfif, jfi, bmp, apng, webp`; 15 | 16 | this.namingMethods = { 17 | original:"Keep Original", 18 | random: "Random" 19 | }; 20 | 21 | BdApi.injectCSS("quicksave-style", ` 22 | .thumbQuicksave { 23 | z-index: 9000!important; 24 | 25 | background-color: rgba(51, 51, 51, .8); 26 | 27 | position: absolute; 28 | display: block; 29 | 30 | padding: 3px 9px; 31 | margin: 5px; 32 | 33 | border-radius: 3px; 34 | 35 | font-family: inherit; 36 | color: #FFF; 37 | font-weight: 500; 38 | font-size: 14px; 39 | opacity: 0; 40 | } 41 | 42 | .imageWrapper-2p5ogY:hover .thumbQuicksave { 43 | opacity: 0.8; 44 | } 45 | 46 | .thumbQuicksave:hover { 47 | opacity: 1 !important; 48 | } 49 | 50 | #qs_button { 51 | padding-left: 10px; 52 | } 53 | `); 54 | 55 | this.injectThumbIcons(); 56 | } 57 | 58 | stop() { 59 | clearTimeout(this.injectionTimeout); 60 | BdApi.clearCSS("quicksave-style"); 61 | } 62 | 63 | accessSync(dir){ 64 | var fs = require('fs'); 65 | try { 66 | fs.accessSync(dir, fs.F_OK); 67 | return true; 68 | } catch (e) { 69 | return false; 70 | } 71 | } 72 | 73 | 74 | observer(e) { 75 | // MutationObserver, function is bound by BetterDiscord. 76 | // We use this to see if user opens an image, if so, add a button. 77 | 78 | var fs = require('fs'); 79 | 80 | // IMAGE OPEN BUTTON 81 | 82 | if( e.addedNodes.length > 0 83 | && e.addedNodes[0].className=='backdrop-1wrmKB da-backdrop' 84 | ){ 85 | 86 | let modal = document.querySelector('div.modal-1UGdnR'); 87 | 88 | // Check if the added element actually has image modal, loading or not 89 | if( 90 | // !modal.querySelector('.imagePlaceholder-jWw28v') && 91 | !modal.querySelector('.imageWrapper-2p5ogY') 92 | ) 93 | return; 94 | 95 | // Element that has the "Open Original" button as a child 96 | let buttonParent = document.querySelector( 97 | '.inner-1JeGVc > div' 98 | ); 99 | 100 | if(!buttonParent) return; 101 | console.log('buttonparent'); 102 | let settings = this.loadSettings(); 103 | 104 | let button = document.createElement('a'); 105 | 106 | // This class gives the styling of the Open Original buttom 107 | // These will break every other update now it seems 108 | button.className = 109 | "anchor-3Z-8Bb downloadLink-1ywL9o size14-3iUx6q weightMedium-2iZe9B da-anchor da-downloadLink da-weightMedium"; 110 | 111 | button.id = "qs_button"; 112 | button.href = "#"; 113 | 114 | // Should the access be checked all the time like this? 115 | fs.access(settings.direcotry, fs.W_OK, (err)=>{ 116 | if (err){ 117 | button.innerHTML = 118 | "Can't Quicksave: Go to plugin settings and "+ 119 | "set the download directory!"; 120 | }else{ 121 | button.onclick = this.saveCurrentImage.bind(this); 122 | button.innerHTML = "Quicksave"; 123 | } 124 | 125 | buttonParent.appendChild(button); 126 | }); 127 | } 128 | } 129 | 130 | injectThumbIcons() { 131 | var fs = require('fs'); 132 | let list = document.querySelectorAll("img"); 133 | for (let i = 0; i < list.length; i++) { 134 | let elem = list[i].parentElement; 135 | //console.log(elem); 136 | 137 | if( !elem.href 138 | || !elem.classList.contains('imageWrapper-2p5ogY') 139 | || elem.querySelector('.thumbQuicksave') 140 | ) continue; 141 | 142 | let div = document.createElement('div'); 143 | div.innerHTML = "Save"; 144 | div.className = "thumbQuicksave"; 145 | 146 | let settings = this.loadSettings(); 147 | fs.access(settings.direcotry, fs.W_OK, (err)=>{ 148 | if (err) 149 | div.innerHTML = "Dir Error"; 150 | else 151 | div.onclick = (e)=>{ 152 | // Prevent parent from opening the image 153 | e.stopPropagation(); 154 | e.preventDefault(); 155 | 156 | this.saveThumbImage(e); 157 | }; 158 | 159 | // appendChild but as the first child 160 | elem.insertAdjacentElement('afterbegin', div); 161 | }); 162 | } 163 | 164 | // Originally this code was in mutationobserver, but that wasn't reliable. 165 | // Now we use this timeout loop with global img search. Not optimal but 166 | // works very well (and maybe even better perfomance wise?) 167 | this.injectionTimeout = setTimeout(this.injectThumbIcons.bind(this), 2000); 168 | } 169 | 170 | saveSettings (button) { 171 | var settings = this.loadSettings(); 172 | var dir = document.getElementById('qs_directory').value; 173 | 174 | var plugin = BdApi.getPlugin('Quicksave'); 175 | var err = document.getElementById('qs_err'); 176 | 177 | if(dir.slice(-1)!='/') dir+='/'; 178 | 179 | if( plugin.accessSync(dir) ){ 180 | 181 | settings.direcotry = dir; 182 | settings.fnLength = document.getElementById('qs_fnLength') .value; 183 | settings.namingmethod = document.getElementById('qs_namingmethod').value; 184 | 185 | bdPluginStorage.set(this.getName(), 'config', JSON.stringify(settings)); 186 | 187 | plugin.stop(); 188 | plugin.start(); 189 | 190 | err.innerHTML = ""; 191 | button.innerHTML = "Saved and applied!"; 192 | } else { 193 | err.innerHTML = "Error: Invalid directory!"; 194 | return; 195 | } 196 | setTimeout(function(){button.innerHTML = "Save and apply";},1000); 197 | } 198 | 199 | defaultSettings() { 200 | return { 201 | version: this.settingsVersion, 202 | direcotry: "none", 203 | namingmethod: "original", 204 | fnLength: 4 205 | }; 206 | } 207 | 208 | resetSettings(button) { 209 | var settings = this.defaultSettings(); 210 | bdPluginStorage.set(this.getName(), 'config', JSON.stringify(settings)); 211 | this.stop(); 212 | this.start(); 213 | button.innerHTML = "Settings resetted!"; 214 | setTimeout(function(){button.innerHTML = "Reset settings";},1000); 215 | } 216 | 217 | loadSettings() { 218 | // Loads settings from localstorage 219 | var settings = (bdPluginStorage.get(this.getName(), 'config')) ? 220 | JSON.parse( bdPluginStorage.get(this.getName(), 'config')) : 221 | {version:"0"}; 222 | 223 | if(settings.version != this.settingsVersion){ 224 | console.log( 225 | `[${this.getName()}] Settings were outdated/invalid/nonexistent.`+ 226 | ` Using default settings.` 227 | ); 228 | settings = this.defaultSettings(); 229 | bdPluginStorage.set(this.getName(), 'config', JSON.stringify(settings)); 230 | } 231 | return settings; 232 | } 233 | 234 | getSettingsPanel () { 235 | var settings = this.loadSettings(); 236 | // rip column rules 237 | var html = 238 | ` 239 |

Settings Panel


240 |

Quicksave directory

241 |

242 | 243 |

File naming method

244 | 251 |

252 | 253 | Random filename length
254 | 255 | 256 |


257 | 260 | 261 |

264 | 265 |

"; 266 | 267 | Help!
268 | "What to put in the directory thing?"
269 | C:/Users/youruser/Desktop/ for example.

270 | `; 271 | return html; 272 | } 273 | 274 | randomFilename64(length){ 275 | var name = ''; 276 | while(length--) 277 | name += 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'[ (Math.random()*64|0) ]; 278 | return name; 279 | } 280 | 281 | 282 | 283 | // Only purpoise of the next two functions is to interact with the DOM! 284 | // These will be called when user clicks on save image buttons. 285 | // Extract the url, run download() and display info to user here. 286 | saveCurrentImage(){ 287 | // Called from the modal 288 | var button = document.getElementById('qs_button'); 289 | var plugin = BdApi.getPlugin('Quicksave'); 290 | 291 | var url = document.querySelector( // Open Original - button 292 | 'div.modal-1UGdnR > div > div > a:nth-child(2)' 293 | ); 294 | 295 | if(!url || !url.href || url.href.search('http') == -1) { 296 | button.innerHTML = "Error: Couldn't extract url!"; 297 | console.log(url); 298 | return; 299 | } 300 | 301 | url = url.href; 302 | 303 | button.innerHTML = "Downloading..."; 304 | 305 | plugin.download(url, (err)=>{ 306 | if(err) 307 | button.innerHTML = "Error! " + err; 308 | else 309 | button.innerHTML = "Download finished!"; 310 | }, 311 | 312 | (bytes, total) => { 313 | // TODO: In case of undefined total, just tell bytes downloaded 314 | const f = bytes/total; 315 | const totalBars = 10; 316 | 317 | let filledBars = Math.round(f*totalBars); 318 | let bar = ""; 319 | 320 | for (var i = 0; i < totalBars; i++) 321 | bar+= i{ 346 | if(err) { 347 | button.innerHTML = "Error"; 348 | console.error("Quicksave error: "+err); 349 | } 350 | else 351 | button.innerHTML = "Done"; 352 | }, 353 | 354 | (bytes, total) => { 355 | // TODO: In case of undefined total, just tell bytes downloaded 356 | button.innerHTML = `${(bytes/total*100)|0}%`; 357 | }); 358 | } 359 | 360 | download(url, callback, progressCallback){ 361 | // Decides the filename, downloads and writes the file 362 | // 363 | // callback: 364 | // In case of errors, first argument will be a string describing the error. 365 | // If everything's fine, its gonna be null 366 | // 367 | // progressCallback 368 | // First argument will be downloaded bytes, second is total bytes (may 369 | // be undefined!) 370 | 371 | var plugin = BdApi.getPlugin('Quicksave'); 372 | var settings = plugin.loadSettings(); 373 | var fs = require('fs'); 374 | var dir = settings.direcotry; 375 | 376 | const qs = "[QUICKSAVE]"; // Use as the prefix when logging 377 | 378 | if(!callback) callback = console.error; 379 | 380 | // Removes ":large" from twitter's image urls. Pulled feature. 381 | var twitterFix = new RegExp(":large$"); 382 | if (twitterFix.test(url)) url = url.replace(twitterFix, ''); 383 | 384 | // Using node apis lets us skip all security built to chrome. This is useful 385 | // if we want to download from anywhere we want, and we do: Discords rehosts 386 | // are usually compressed. 387 | // Can https use http? is this seperation necessary? 388 | var net = (url.split('//')[0]=='https:') ? require('https') : require('http'); 389 | 390 | 391 | // TODO: 392 | // Make sure we dont download gigabytes 393 | console.info(`${qs} GET ${url}`); 394 | 395 | const req = net.request(url, (res) => { 396 | console.info(`${qs} Server responded`, res); 397 | // REDIRECTIONS 398 | if( res.statusCode == 301 // Moved permanently 399 | || res.statusCode == 308 // Permanent redirect 400 | || res.statusCode == 302 // Found 401 | || res.statusCode == 307 // See other 402 | ) { 403 | 404 | let newURL = res.headers.location; 405 | console.info(`${qs} Redirected to ${newURL}`); 406 | if(!newURL) { 407 | callback( 408 | `Redirected with ${res.statusCode}`+ 409 | `but no location header present!` 410 | ); 411 | console.error(`${qs} No location header in redirection!`); 412 | return; 413 | } 414 | plugin.download(newURL, callback, progressCallback); 415 | return; 416 | } 417 | 418 | // Error if not 200 419 | if(res.statusCode != 200) { 420 | callback("Server responded with "+ res.statusCode); 421 | console.error("Response code "+res.statusCode+"", res); 422 | return; 423 | } 424 | 425 | // Check content type 426 | let contentType = res.headers["content-type"]; 427 | if(!contentType){ 428 | callback(`No content-type header present!`); 429 | console.error(`${qs} No content-type header`); 430 | return; 431 | } 432 | 433 | const dropboxSpecialCase = ( url.search('dropbox') > 0 && 434 | contentType.search('binary') > 0 ); 435 | 436 | if( contentType.search('image') < 0 && !dropboxSpecialCase) { 437 | callback(`Content-type '${contentType}' is not an image.`); 438 | console.error(`${qs} Non-image content-type header!`); 439 | return; 440 | } 441 | 442 | 443 | var filename = plugin.getFilename( 444 | { 445 | plugin:plugin, 446 | settings:settings, 447 | url: url, 448 | dir: dir, 449 | res: res 450 | } 451 | ); 452 | 453 | if(!filename) { 454 | callback("Problems with the filename! Check console."); 455 | return; 456 | } 457 | 458 | 459 | console.info(`${qs} SOURCE ${url}`); 460 | console.info(`${qs} DESTINATION ${dir+filename}`); 461 | console.info(`${qs} Now downloading...`); 462 | 463 | let total = res.headers["content-length"]; // may be undefined!! 464 | let bytes = 0; 465 | 466 | // Keep the file fragmented until all chunks are downloaded, then 467 | // concat. Moslty because its easy to implement, but also useful if we 468 | // don't know the file size in advacne. 469 | 470 | let chunks = []; 471 | 472 | res.on('data', (d) => { 473 | chunks.push(d); 474 | 475 | bytes+=d.length; 476 | if(progressCallback) progressCallback(bytes, total); 477 | }); 478 | 479 | res.on('end', ()=> { 480 | fs.writeFile(dir+filename, Buffer.concat(chunks), (err)=>{ 481 | if(err){ 482 | console.error(err); 483 | callback(err.message); 484 | } else { 485 | console.info(`${qs} File has been successfully written!`); 486 | if(BdApi.showToast) 487 | BdApi.showToast(`Saved as ${dir+filename}`); 488 | callback(null); 489 | } 490 | }); 491 | }); 492 | }); 493 | 494 | req.on('error', (err) => { 495 | console.error(err); 496 | callback(err.message); 497 | }); 498 | req.end(); 499 | } 500 | 501 | getFilename(context){ 502 | const plugin = context.plugin; 503 | const settings = context.settings; 504 | const url = context.url; 505 | const res = context.res; // Node http module response 506 | const dir = context.dir; 507 | 508 | let fullname = null; // Everything 509 | let filename = null; // No extension 510 | let filetype = null; // Only extension 511 | 512 | // Try to find out the real filename. Can be done like so: 513 | // - From filename[*] field of content-disposition header 514 | // - From URL 515 | 516 | // ## URL 517 | // Get the chars after the last / and remove ? and anything after it 518 | let filename_url = url.split('/').slice(-1)[0].split('?')[0]; 519 | fullname = filename_url.trim(); 520 | 521 | // ## HEADER 522 | // Don't bother with filename*, just keep it simple 523 | let cd_header = res.headers["content-disposition"]; 524 | if(cd_header){ 525 | let result = cd_header.match(/filename="(.+?)"/); 526 | if(result[1]) fullname = result[1].trim(); 527 | } 528 | 529 | // Don't write the file without a valid file extension! We need to find it 530 | // out as well: 531 | // - From the already extracted filename 532 | // - (not implemented) From content-type header 533 | // - (not implemented) From the magic bytes of the file 534 | // Extracting from the filename is easy and works 99% of the time 535 | 536 | filetype = fullname.split('.').slice(-1)[0].trim(); 537 | 538 | // Validate file extension 539 | if(plugin.extensionWhitelist.search(filetype.toLowerCase()) < 0) { 540 | console.error("Can't find a valid file extension!", context); 541 | return null; 542 | } 543 | 544 | filename = fullname.slice(0, -(filetype.length+1)); 545 | 546 | // Do user configured renaming here! 547 | if( settings.namingmethod == "random" ) { 548 | filename = plugin.randomFilename64(settings.fnLength); 549 | } 550 | 551 | // SANITIZE THE FILENAME 552 | // Strip path info, restrict the charset, remove dots or spaces from lead 553 | 554 | filename.split(/[/\\]/).slice(-1)[0].replace(/([^a-z0-9_\-.() ]+)/, '_'); 555 | 556 | while( filename.length > 0 && (filename[0] == '.' || filename[0] == ' ') ) 557 | filename = filename.slice(1); 558 | 559 | filename = filename.trim(); 560 | if(filename.length === 0) filename = "unknown"; 561 | 562 | fullname = filename+'.'+filetype; 563 | 564 | // Rename to "filename (number).jpg" if file occupied 565 | let num = 2; 566 | let maxloops = 99; 567 | // Using accessSync like this will lock up discord! 568 | // Possible race condition with other applications between this and actual 569 | // write in the caller! Use fs.exists! 570 | while( plugin.accessSync(dir+fullname) && maxloops-- ){ 571 | fullname = filename + ` (${num}).` + filetype; 572 | num++; 573 | } 574 | 575 | if(maxloops == -1){ 576 | console.error( 577 | 'No free filename found', context 578 | ); 579 | return null; 580 | } 581 | return fullname; 582 | } 583 | } 584 | --------------------------------------------------------------------------------