7 | Browse and download images on the web
8 |
9 |
10 |
11 |
12 | Welcome! If you're here to learn more about how to use this extension check out the [User Guide](USERGUIDE)
13 |
14 | If you're a developer interested in running the extension locally instead of installing it from the Chrome Web Store - keep reading!
15 |
16 | ## Local development
17 | 1. First, install the dependencies:
18 | ```bash
19 | bun install
20 | ```
21 | 2. Then you can start the development server which watches for file changes automatically:
22 | ```bash
23 | bun start
24 | ```
25 | Or alternatively - only run the build once:
26 | ```bash
27 | bun run build
28 | ```
29 | 3. Open the extension list in your browser settings: [chrome://extensions](chrome://extensions)
30 | 4. Enable **Developer mode**
31 | 5. Click the **Load unpacked** button, navigate to the extension root folder and pick the `build` folder
32 | 6. Enjoy!
33 |
34 | ## Test
35 | Run and watch tests related to locally changed files - useful during development:
36 | ```bash
37 | bun test
38 | ```
39 |
40 | Or run all the tests without watching and generate a coverage report:
41 | ```bash
42 | bun run test.all
43 | ```
44 |
45 | ## License
46 | See [LICENSE.md](LICENSE.md)
47 |
--------------------------------------------------------------------------------
/USERGUIDE:
--------------------------------------------------------------------------------
1 | 🟦 Image Downloader
2 | ━━━━━━━━━━
3 | To save multiple photos at once, this extension enables you to:
4 | - Browse pictures on the active webpage
5 | - Filter by size, dimensions, or URL
6 | - Download or view any single photo with one click
7 | - Save to a subfolder
8 | - Rename downloaded files
9 | - Download in the background!
10 |
11 | Note: If no default download folder is set, you’ll need to choose a save location for each photo, which may trigger multiple popups. Configure a download directory in your browser settings for a smoother experience.
12 |
13 | ❓ FAQs
14 | ━━━━
15 | 💭 Why does this extension need access to all site data?
16 | To extract photos from a webpage, Image Downloader must access all content when the popup is activated. No data is sent off your device. See our Privacy Policy for details: https://pactinteractive.github.io/image-downloader
17 |
18 | 💭 Why were some downloaded photos smaller or missing?
19 | Image Downloader only retrieves photos currently displayed on the page, which may be thumbnails rather than full-resolution versions (e.g., in Facebook albums). Some sites, like Instagram, load only a few pictures at a time in carousels to save bandwidth.
20 |
21 | We’re exploring ways to improve Image Downloader’s capabilities. For now, consider using extensions tailored to specific sites (e.g., Facebook) for advanced functionality.
22 |
--------------------------------------------------------------------------------
/images/btc-qr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/btc-qr.png
--------------------------------------------------------------------------------
/images/btc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/btc.png
--------------------------------------------------------------------------------
/images/chevron.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/images/cog.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/images/download.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/images/gumroad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/gumroad.png
--------------------------------------------------------------------------------
/images/gumroad.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/images/logo.svg:
--------------------------------------------------------------------------------
1 |
24 |
--------------------------------------------------------------------------------
/images/me.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/images/me.jpg
--------------------------------------------------------------------------------
/images/open.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/images/times.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // For a detailed explanation regarding each configuration property, visit:
2 | // https://jestjs.io/docs/en/configuration.html
3 |
4 | module.exports = {
5 | // All imported modules in your tests should be mocked automatically
6 | // automock: false,
7 |
8 | // Stop running tests after `n` failures
9 | // bail: 0,
10 |
11 | // The directory where Jest should store its cached dependency information
12 | // cacheDirectory: "C:\\Users\\vdsab\\AppData\\Local\\Temp\\jest",
13 |
14 | // Automatically clear mock calls and instances between every test
15 | clearMocks: true,
16 |
17 | // Indicates whether the coverage information should be collected while executing the test
18 | // collectCoverage: false,
19 |
20 | // An array of glob patterns indicating a set of files for which coverage information should be collected
21 | // collectCoverageFrom: undefined,
22 |
23 | // The directory where Jest should output its coverage files
24 | coverageDirectory: 'coverage',
25 |
26 | // An array of regexp pattern strings used to skip coverage collection
27 | coveragePathIgnorePatterns: [
28 | 'build/',
29 | 'lib/',
30 | 'node_modules/',
31 | ],
32 |
33 | // A list of reporter names that Jest uses when writing coverage reports
34 | // coverageReporters: [
35 | // "json",
36 | // "text",
37 | // "lcov",
38 | // "clover"
39 | // ],
40 |
41 | // An object that configures minimum threshold enforcement for coverage results
42 | // coverageThreshold: undefined,
43 |
44 | // A path to a custom dependency extractor
45 | // dependencyExtractor: undefined,
46 |
47 | // Make calling deprecated APIs throw helpful error messages
48 | // errorOnDeprecated: false,
49 |
50 | // Force coverage collection from ignored files using an array of glob patterns
51 | // forceCoverageMatch: [],
52 |
53 | // A path to a module which exports an async function that is triggered once before all test suites
54 | // globalSetup: undefined,
55 |
56 | // A path to a module which exports an async function that is triggered once after all test suites
57 | // globalTeardown: undefined,
58 |
59 | // A set of global variables that need to be available in all test environments
60 | // globals: {},
61 |
62 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
63 | // maxWorkers: "50%",
64 |
65 | // An array of directory names to be searched recursively up from the requiring module's location
66 | // moduleDirectories: [
67 | // "node_modules"
68 | // ],
69 |
70 | // An array of file extensions your modules use
71 | // moduleFileExtensions: [
72 | // "js",
73 | // "json",
74 | // "jsx",
75 | // "ts",
76 | // "tsx",
77 | // "node"
78 | // ],
79 |
80 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
81 | // moduleNameMapper: {},
82 |
83 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
84 | // modulePathIgnorePatterns: [],
85 |
86 | // Activates notifications for test results
87 | // notify: false,
88 |
89 | // An enum that specifies notification mode. Requires { notify: true }
90 | // notifyMode: "failure-change",
91 |
92 | // A preset that is used as a base for Jest's configuration
93 | preset: 'ts-jest',
94 |
95 | // Run tests from one or more projects
96 | // projects: undefined,
97 |
98 | // Use this configuration option to add custom reporters to Jest
99 | // reporters: undefined,
100 |
101 | // Automatically reset mock state between every test
102 | // resetMocks: false,
103 |
104 | // Reset the module registry before running each individual test
105 | resetModules: true,
106 |
107 | // A path to a custom resolver
108 | // resolver: undefined,
109 |
110 | // Automatically restore mock state between every test
111 | // restoreMocks: false,
112 |
113 | // The root directory that Jest should scan for tests and modules within
114 | // rootDir: undefined,
115 |
116 | // A list of paths to directories that Jest should use to search for files in
117 | // roots: [
118 | // ""
119 | // ],
120 |
121 | // Allows you to use a custom runner instead of Jest's default test runner
122 | // runner: "jest-runner",
123 |
124 | // The paths to modules that run some code to configure or set up the testing environment before each test
125 | // setupFiles: [],
126 |
127 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
128 | // setupFilesAfterEnv: [],
129 |
130 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
131 | // snapshotSerializers: [],
132 |
133 | // The test environment that will be used for testing
134 | testEnvironment: 'jsdom',
135 |
136 | // Options that will be passed to the testEnvironment
137 | // testEnvironmentOptions: {},
138 |
139 | // Adds a location field to test results
140 | // testLocationInResults: false,
141 |
142 | // The glob patterns Jest uses to detect test files
143 | testMatch: [
144 | '/src/**/__tests__/**/*.[jt]s?(x)',
145 | '/src/**/?(*.)+(spec|test).[tj]s?(x)',
146 | ],
147 |
148 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
149 | // testPathIgnorePatterns: [
150 | // "\\\\node_modules\\\\"
151 | // ],
152 |
153 | // The regexp pattern or array of patterns that Jest uses to detect test files
154 | // testRegex: [],
155 |
156 | // This option allows the use of a custom results processor
157 | // testResultsProcessor: undefined,
158 |
159 | // This option allows use of a custom test runner
160 | // testRunner: "jasmine2",
161 |
162 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
163 | // testURL: "http://localhost",
164 |
165 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
166 | // timers: "real",
167 |
168 | // A map from regular expressions to paths to transformers
169 | transform: {
170 | '^.+\\.js$': '/node_modules/babel-jest',
171 | },
172 |
173 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
174 | // transformIgnorePatterns: [
175 | // "\\\\node_modules\\\\"
176 | // ],
177 |
178 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
179 | // unmockedModulePathPatterns: undefined,
180 |
181 | // Indicates whether each individual test should be reported during the run
182 | // verbose: undefined,
183 |
184 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
185 | // watchPathIgnorePatterns: [],
186 |
187 | // Whether to use watchman for file crawling
188 | // watchman: true,
189 | };
190 |
--------------------------------------------------------------------------------
/lib/htm.js:
--------------------------------------------------------------------------------
1 | var n = function (t, s, r, e) {
2 | var u;
3 | s[0] = 0;
4 | for (var h = 1; h < s.length; h++) {
5 | var p = s[h++],
6 | a = s[h] ? ((s[0] |= p ? 1 : 2), r[s[h++]]) : s[++h];
7 | 3 === p
8 | ? (e[0] = a)
9 | : 4 === p
10 | ? (e[1] = Object.assign(e[1] || {}, a))
11 | : 5 === p
12 | ? ((e[1] = e[1] || {})[s[++h]] = a)
13 | : 6 === p
14 | ? (e[1][s[++h]] += a + '')
15 | : p
16 | ? ((u = t.apply(a, n(t, a, r, ['', null]))),
17 | e.push(u),
18 | a[0] ? (s[0] |= 2) : ((s[h - 2] = 0), (s[h] = u)))
19 | : e.push(a);
20 | }
21 | return e;
22 | },
23 | t = new Map();
24 | export default function (s) {
25 | var r = t.get(this);
26 | return (
27 | r || ((r = new Map()), t.set(this, r)),
28 | (r = n(
29 | this,
30 | r.get(s) ||
31 | (r.set(
32 | s,
33 | (r = (function (n) {
34 | for (
35 | var t,
36 | s,
37 | r = 1,
38 | e = '',
39 | u = '',
40 | h = [0],
41 | p = function (n) {
42 | 1 === r && (n || (e = e.replace(/^\s*\n\s*|\s*\n\s*$/g, '')))
43 | ? h.push(0, n, e)
44 | : 3 === r && (n || e)
45 | ? (h.push(3, n, e), (r = 2))
46 | : 2 === r && '...' === e && n
47 | ? h.push(4, n, 0)
48 | : 2 === r && e && !n
49 | ? h.push(5, 0, !0, e)
50 | : r >= 5 &&
51 | ((e || (!n && 5 === r)) &&
52 | (h.push(r, 0, e, s), (r = 6)),
53 | n && (h.push(r, n, 0, s), (r = 6))),
54 | (e = '');
55 | },
56 | a = 0;
57 | a < n.length;
58 | a++
59 | ) {
60 | a && (1 === r && p(), p(a));
61 | for (var l = 0; l < n[a].length; l++)
62 | (t = n[a][l]),
63 | 1 === r
64 | ? '<' === t
65 | ? (p(), (h = [h]), (r = 3))
66 | : (e += t)
67 | : 4 === r
68 | ? '--' === e && '>' === t
69 | ? ((r = 1), (e = ''))
70 | : (e = t + e[0])
71 | : u
72 | ? t === u
73 | ? (u = '')
74 | : (e += t)
75 | : '"' === t || "'" === t
76 | ? (u = t)
77 | : '>' === t
78 | ? (p(), (r = 1))
79 | : r &&
80 | ('=' === t
81 | ? ((r = 5), (s = e), (e = ''))
82 | : '/' === t && (r < 5 || '>' === n[a][l + 1])
83 | ? (p(),
84 | 3 === r && (h = h[0]),
85 | (r = h),
86 | (h = h[0]).push(2, 0, r),
87 | (r = 0))
88 | : ' ' === t ||
89 | '\t' === t ||
90 | '\n' === t ||
91 | '\r' === t
92 | ? (p(), (r = 2))
93 | : (e += t)),
94 | 3 === r && '!--' === e && ((r = 4), (h = h[0]));
95 | }
96 | return p(), h;
97 | })(s)),
98 | ),
99 | r),
100 | arguments,
101 | [],
102 | )).length > 1
103 | ? r
104 | : r[0]
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/lib/nouislider.min.css:
--------------------------------------------------------------------------------
1 | /*! nouislider - 14.7.0 - 4/6/2021 */
2 | .noUi-target,.noUi-target *{-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-ms-touch-action:none;touch-action:none;-ms-user-select:none;-moz-user-select:none;user-select:none;-moz-box-sizing:border-box;box-sizing:border-box}.noUi-target{position:relative}.noUi-base,.noUi-connects{width:100%;height:100%;position:relative;z-index:1}.noUi-connects{overflow:hidden;z-index:0}.noUi-connect,.noUi-origin{will-change:transform;position:absolute;z-index:1;top:0;right:0;-ms-transform-origin:0 0;-webkit-transform-origin:0 0;-webkit-transform-style:preserve-3d;transform-origin:0 0;transform-style:flat}.noUi-connect{height:100%;width:100%}.noUi-origin{height:10%;width:10%}.noUi-txt-dir-rtl.noUi-horizontal .noUi-origin{left:0;right:auto}.noUi-vertical .noUi-origin{width:0}.noUi-horizontal .noUi-origin{height:0}.noUi-handle{-webkit-backface-visibility:hidden;backface-visibility:hidden;position:absolute}.noUi-touch-area{height:100%;width:100%}.noUi-state-tap .noUi-connect,.noUi-state-tap .noUi-origin{-webkit-transition:transform .3s;transition:transform .3s}.noUi-state-drag *{cursor:inherit!important}.noUi-horizontal{height:18px}.noUi-horizontal .noUi-handle{width:34px;height:28px;right:-17px;top:-6px}.noUi-vertical{width:18px}.noUi-vertical .noUi-handle{width:28px;height:34px;right:-6px;top:-17px}.noUi-txt-dir-rtl.noUi-horizontal .noUi-handle{left:-17px;right:auto}.noUi-target{background:#FAFAFA;border-radius:4px;border:1px solid #D3D3D3;box-shadow:inset 0 1px 1px #F0F0F0,0 3px 6px -5px #BBB}.noUi-connects{border-radius:3px}.noUi-connect{background:#3FB8AF}.noUi-draggable{cursor:ew-resize}.noUi-vertical .noUi-draggable{cursor:ns-resize}.noUi-handle{border:1px solid #D9D9D9;border-radius:3px;background:#FFF;cursor:default;box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #EBEBEB,0 3px 6px -3px #BBB}.noUi-active{box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #DDD,0 3px 6px -3px #BBB}.noUi-handle:after,.noUi-handle:before{content:"";display:block;position:absolute;height:14px;width:1px;background:#E8E7E6;left:14px;top:6px}.noUi-handle:after{left:17px}.noUi-vertical .noUi-handle:after,.noUi-vertical .noUi-handle:before{width:14px;height:1px;left:6px;top:14px}.noUi-vertical .noUi-handle:after{top:17px}[disabled] .noUi-connect{background:#B8B8B8}[disabled] .noUi-handle,[disabled].noUi-handle,[disabled].noUi-target{cursor:not-allowed}.noUi-pips,.noUi-pips *{-moz-box-sizing:border-box;box-sizing:border-box}.noUi-pips{position:absolute;color:#999}.noUi-value{position:absolute;white-space:nowrap;text-align:center}.noUi-value-sub{color:#ccc;font-size:10px}.noUi-marker{position:absolute;background:#CCC}.noUi-marker-sub{background:#AAA}.noUi-marker-large{background:#AAA}.noUi-pips-horizontal{padding:10px 0;height:80px;top:100%;left:0;width:100%}.noUi-value-horizontal{-webkit-transform:translate(-50%,50%);transform:translate(-50%,50%)}.noUi-rtl .noUi-value-horizontal{-webkit-transform:translate(50%,50%);transform:translate(50%,50%)}.noUi-marker-horizontal.noUi-marker{margin-left:-1px;width:2px;height:5px}.noUi-marker-horizontal.noUi-marker-sub{height:10px}.noUi-marker-horizontal.noUi-marker-large{height:15px}.noUi-pips-vertical{padding:0 10px;height:100%;top:0;left:100%}.noUi-value-vertical{-webkit-transform:translate(0,-50%);transform:translate(0,-50%);padding-left:25px}.noUi-rtl .noUi-value-vertical{-webkit-transform:translate(0,50%);transform:translate(0,50%)}.noUi-marker-vertical.noUi-marker{width:5px;height:2px;margin-top:-1px}.noUi-marker-vertical.noUi-marker-sub{width:10px}.noUi-marker-vertical.noUi-marker-large{width:15px}.noUi-tooltip{display:block;position:absolute;border:1px solid #D9D9D9;border-radius:3px;background:#fff;color:#000;padding:5px;text-align:center;white-space:nowrap}.noUi-horizontal .noUi-tooltip{-webkit-transform:translate(-50%,0);transform:translate(-50%,0);left:50%;bottom:120%}.noUi-vertical .noUi-tooltip{-webkit-transform:translate(0,-50%);transform:translate(0,-50%);top:50%;right:120%}.noUi-horizontal .noUi-origin>.noUi-tooltip{-webkit-transform:translate(50%,0);transform:translate(50%,0);left:auto;bottom:10px}.noUi-vertical .noUi-origin>.noUi-tooltip{-webkit-transform:translate(0,-18px);transform:translate(0,-18px);top:auto;right:28px}
--------------------------------------------------------------------------------
/lib/nouislider.min.js:
--------------------------------------------------------------------------------
1 | /*! nouislider - 14.7.0 - 4/6/2021 */
2 | !function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?module.exports=t():window.noUiSlider=t()}(function(){"use strict";var lt="14.7.0";function ut(t){t.parentElement.removeChild(t)}function ct(t){return null!=t}function pt(t){t.preventDefault()}function o(t){return"number"==typeof t&&!isNaN(t)&&isFinite(t)}function ft(t,e,r){0=e[r];)r+=1;return r}function r(t,e,r){if(r>=t.slice(-1)[0])return 100;var n,i,o=f(r,t),s=t[o-1],a=t[o],l=e[o-1],u=e[o];return l+(i=r,p(n=[s,a],n[0]<0?i+Math.abs(n[0]):i-n[0],0)/c(l,u))}function n(t,e,r,n){if(100===n)return n;var i,o,s=f(n,t),a=t[s-1],l=t[s];return r?(l-a)/2this.xPct[i+1];)i++;else t===this.xPct[this.xPct.length-1]&&(i=this.xPct.length-2);r||t!==this.xPct[i+1]||i++;var o=1,s=e[i],a=0,l=0,u=0,c=0;for(n=r?(t-this.xPct[i])/(this.xPct[i+1]-this.xPct[i]):(this.xPct[i+1]-t)/(this.xPct[i+1]-this.xPct[i]);0= 2) required for mode 'count'.");var n=e-1,i=100/n;for(e=[];n--;)e[n]=n*i;e.push(100),t="positions"}return"positions"===t?e.map(function(t){return y.fromStepping(r?y.getStep(t):t)}):"values"===t?r?e.map(function(t){return y.fromStepping(y.getStep(y.toStepping(t)))}):e:void 0}(n,t.values||!1,t.stepped||!1),a=(m=i,g=n,v=s,b={},e=y.xVal[0],r=y.xVal[y.xVal.length-1],S=x=!1,w=0,(v=v.slice().sort(function(t,e){return t-e}).filter(function(t){return!this[t]&&(this[t]=!0)},{}))[0]!==e&&(v.unshift(e),x=!0),v[v.length-1]!==r&&(v.push(r),S=!0),v.forEach(function(t,e){var r,n,i,o,s,a,l,u,c,p,f=t,d=v[e+1],h="steps"===g;if(h&&(r=y.xNumSteps[e]),r||(r=d-f),!1!==f)for(void 0===d&&(d=f),r=Math.max(r,1e-7),n=f;n<=d;n=(n+r).toFixed(7)/1){for(u=(s=(o=y.toStepping(n))-w)/m,p=s/(c=Math.round(u)),i=1;i<=c;i+=1)b[(a=w+i*p).toFixed(5)]=[y.fromStepping(a),0];l=-1r.stepAfter.startValue&&(i=r.stepAfter.startValue-n),o=n>r.thisStep.startValue?r.thisStep.step:!1!==r.stepBefore.step&&n-r.stepBefore.highestStep,100===e?i=null:0===e&&(o=null);var s=y.countStepDecimals();return null!==i&&!1!==i&&(i=Number(i.toFixed(s))),null!==o&&!1!==o&&(o=Number(o.toFixed(s))),[o,i]}return mt(e=h,b.cssClasses.target),0===b.dir?mt(e,b.cssClasses.ltr):mt(e,b.cssClasses.rtl),0===b.ort?mt(e,b.cssClasses.horizontal):mt(e,b.cssClasses.vertical),mt(e,"rtl"===getComputedStyle(e).direction?b.cssClasses.textDirectionRtl:b.cssClasses.textDirectionLtr),l=V(e,b.cssClasses.base),function(t,e){var r=V(e,b.cssClasses.connects);u=[],(s=[]).push(M(r,t[0]));for(var n=0;n>>1,d=a[c];if(0>>1;cD(l,e))fD(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(fD(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
16 | function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
17 | k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-hae?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
24 | k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125"
28 | ]
29 | }
30 | ],
31 | "permissions": [
32 | "activeTab",
33 | "scripting",
34 | "downloads"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PactInteractive/image-downloader/ff83e1bc219a7b098a80087ebd8c8ce4d929c1a1/options.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "image-downloader",
4 | "version": "4.0.2",
5 | "license": "GPLv3",
6 | "scripts": {
7 | "dev": "bun start",
8 | "start": "bun run build && bun scripts/watch.js",
9 | "build": "bun scripts/build.js && bun run build.zip",
10 | "build.zip": "bun -e \"await Bun.file('../image-downloader.zip').delete().catch(() => {}); import { zip } from 'cross-zip'; await zip('build', '../image-downloader.zip');\"",
11 | "task": "bun scripts/task.js",
12 | "test": "jest --watch",
13 | "test.all": "jest --coverage"
14 | },
15 | "devDependencies": {
16 | "@types/chrome": "0.0.315",
17 | "@types/jest": "26.0.20",
18 | "@types/jquery": "3.5.1",
19 | "cross-zip": "4.0.1",
20 | "fs-extra": "9.0.1",
21 | "glob": "7.1.6",
22 | "glob-watcher": "5.0.5",
23 | "jest": "26.6.3",
24 | "prettier": "3.5.3",
25 | "react": "18.3.1",
26 | "react-dom": "18.3.1",
27 | "sharp": "0.34.1",
28 | "sneer": "1.0.1",
29 | "ts-jest": "26.1.4",
30 | "typescript": "5.8.3"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | import { sync } from 'glob';
2 |
3 | import { filesToCopy } from './config';
4 | import { clean, copyFile, updateManifest } from './tasks';
5 |
6 | async function build() {
7 | await clean();
8 | await updateManifest();
9 | await Promise.all(
10 | filesToCopy
11 | .map((filePattern) => sync(filePattern))
12 | .reduce((parent, child) => [...parent, ...child], [])
13 | .map(copyFile)
14 | .map((promise) =>
15 | promise.catch((error) => {
16 | if (error.code === 'EEXIST') {
17 | // Ignore already existing file error
18 | } else {
19 | throw error;
20 | }
21 | }),
22 | ),
23 | );
24 | }
25 |
26 | build();
27 |
--------------------------------------------------------------------------------
/scripts/config.js:
--------------------------------------------------------------------------------
1 | const packagePath = './package.json';
2 | const manifestPath = './manifest.json';
3 |
4 | module.exports = {
5 | outputDirectory: 'build',
6 | icons: {
7 | inputSvg: './images/logo.svg',
8 | outputDirectory: 'images',
9 | prefix: 'icon_',
10 | sizes: [16, 32, 48, 128],
11 | },
12 | filesToCopy: [
13 | manifestPath,
14 | './images/**/*',
15 | './lib/**/*',
16 | './src/**/!(test.ts|*.test.ts)',
17 | './stylesheets/**/*',
18 | ],
19 | paths: {
20 | package: packagePath,
21 | manifest: manifestPath,
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/scripts/task.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Usage:
3 | * ```sh
4 | * node scripts/task.js clean
5 | * ```
6 | */
7 |
8 | import tasks from './tasks';
9 |
10 | const [taskName, ...args] = process.argv.slice(2);
11 | if (!tasks[taskName]) {
12 | const taskNameList = Object.keys(tasks)
13 | .map((task) => `- ${task}`)
14 | .join('\n');
15 | console.error(
16 | `Unknown task ${taskName} - did you mean to use one of the following:\n${taskNameList}`
17 | );
18 | } else {
19 | tasks[taskName](...args);
20 | }
21 |
--------------------------------------------------------------------------------
/scripts/tasks.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import { join, normalize } from 'path';
3 | import sharp from 'sharp';
4 |
5 | import * as config from './config';
6 |
7 | export async function clean() {
8 | await fs.emptyDir(config.outputDirectory);
9 | }
10 |
11 | export async function updateManifest() {
12 | const [packageJson, manifestJson] = await Promise.all([
13 | fs.readJson(config.paths.package),
14 | fs.readJson(config.paths.manifest),
15 | ]);
16 |
17 | const icons = await generateIcons(config.icons);
18 |
19 | await fs.writeJson(
20 | config.paths.manifest,
21 | { ...manifestJson, version: packageJson.version, icons },
22 | { spaces: 2 },
23 | );
24 |
25 | return normalize(config.paths.manifest);
26 | }
27 |
28 | export async function copyFile(path) {
29 | await fs.copy(path, join(config.outputDirectory, path), { recursive: true });
30 | }
31 |
32 | export async function removeFile(path) {
33 | await fs.remove(join(config.outputDirectory, path));
34 | }
35 |
36 | // See https://wxt.dev/api/config.html
37 | async function generateIcons({ inputSvg, outputDirectory, prefix, sizes }) {
38 | try {
39 | const icons = {};
40 |
41 | // Ensure output directory exists
42 | await fs.mkdir(join(config.outputDirectory, outputDirectory), {
43 | recursive: true,
44 | });
45 |
46 | // Check if input SVG exists
47 | try {
48 | await fs.access(inputSvg);
49 | } catch {
50 | throw new Error(`Input SVG file (${inputSvg}) not found.`);
51 | }
52 |
53 | // Generate PNGs for each size
54 | for (const size of sizes) {
55 | const outputPath = join(
56 | config.outputDirectory,
57 | outputDirectory,
58 | `${prefix}${size}.png`,
59 | );
60 | await sharp(inputSvg)
61 | .resize(size, size, {
62 | fit: 'contain',
63 | background: { r: 0, g: 0, b: 0, alpha: 0 },
64 | })
65 | .png()
66 | .toFile(outputPath);
67 | console.log(`Generated ${outputPath}`);
68 |
69 | icons[`${size}`] = `/${outputDirectory}/${prefix}${size}.png`;
70 | }
71 |
72 | return icons;
73 | } catch (error) {
74 | console.error('Error generating icons:', error);
75 | process.exit(1);
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/scripts/watch.js:
--------------------------------------------------------------------------------
1 | import watch from 'glob-watcher';
2 |
3 | import { filesToCopy, paths } from './config';
4 | import { updateManifest, copyFile, removeFile } from './tasks';
5 |
6 | const logAndExecute =
7 | (message, fn) =>
8 | async (path, ...args) => {
9 | const result = await fn(path, ...args);
10 | console.log(
11 | `[${new Date().toLocaleTimeString()}]`,
12 | message,
13 | result || path,
14 | );
15 | };
16 |
17 | watch(paths.package).on('change', updateManifest);
18 |
19 | watch(filesToCopy)
20 | .on('add', logAndExecute('Add', copyFile))
21 | .on('change', logAndExecute('Update', copyFile))
22 | .on('unlink', logAndExecute('Remove', removeFile));
23 |
--------------------------------------------------------------------------------
/src/Options/About.js:
--------------------------------------------------------------------------------
1 | import html from '../html.js';
2 | import { ExternalLink } from '../components/ExternalLink.js';
3 |
4 | const numberOfActiveUsers = '1,500,000+';
5 | const years = new Date().getFullYear() - 2012;
6 |
7 | export const About = () => html`
8 |
9 |
19 | Made by @vdsabev
20 |
21 | (🐕 with Ruby's help)
22 |
23 |
24 |
25 | If you're one of the ${numberOfActiveUsers} people using this extension,
26 | over the past ${years} years marketing companies have approached me with
27 | offers to pay in exchange for your private data like:
28 |
29 |
30 |
31 |
what websites you visit
32 |
when you visit them
33 |
where you visit them from
34 |
35 |
36 |
37 | My response to such offers will always be a resounding NO!
38 |
39 |
40 |
41 | The extension will remain free, open-source, and
42 | without targeted ads or tracking algorithms.
43 | The source code can be found on GitHub:${' '}
44 | <${ExternalLink}
45 | href="https://github.com/PactInteractive/image-downloader"
46 | />
47 |
132 |
133 | <${Checkbox}
134 | title="Only show images from direct links on the page; useful on sites like Reddit"
135 | checked=${options.only_images_from_links === 'true'}
136 | onChange=${setCheckboxOption('only_images_from_links')}
137 | >
138 | Only images from links
139 | />
140 |
17 | If the Ask where to save each file before downloading option is
18 | checked, proceeding might open a lot of popup windows. Continue with
19 | the download?
20 |
21 |
22 |
23 |
24 |
25 | <${Checkbox} onChange=${onCheckboxChange}>
26 | Got it, don't show again
27 | />
28 |