├── .github
├── ISSUE_TEMPLATE
│ ├── 0-bug.yaml
│ └── config.yml
└── workflows
│ ├── build.yaml
│ ├── issue.yaml
│ ├── lint.yaml
│ └── test.yaml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── CONTRIBUTING.md
├── LICENSE
├── PRIVACY.md
├── README.md
├── assets
├── banner.jpg
├── lazy-icon.png
├── logo.pdn
└── logo.svg
├── eslint.config.mjs
├── gulpfile.js
├── manifest-chrome.json
├── manifest-firefox.json
├── manifest.json
├── package-lock.json
├── package.json
├── src
├── css
│ ├── links.css
│ ├── main.css
│ ├── options.css
│ ├── permissions.css
│ └── popup.css
├── html
│ ├── lazy.html
│ ├── links.html
│ ├── options.html
│ ├── permissions.html
│ └── popup.html
├── images
│ ├── lazy
│ │ ├── icon-black.png
│ │ ├── icon-red.png
│ │ ├── icon-white.png
│ │ └── icon-yellow.png
│ ├── logo128.png
│ ├── logo16.png
│ ├── logo32.png
│ ├── logo48.png
│ └── logo96.png
└── js
│ ├── exports.js
│ ├── extract.js
│ ├── lazy.js
│ ├── links.js
│ ├── main.js
│ ├── options.js
│ ├── pdf.js
│ ├── permissions.js
│ ├── popup.js
│ ├── service-worker.js
│ └── theme.js
└── tests
├── common.js
├── issue.js
├── manifest-test.json
├── patterns.txt
└── test.js
/.github/ISSUE_TEMPLATE/0-bug.yaml:
--------------------------------------------------------------------------------
1 | name: "⚠️ Report an Issue"
2 | description: "Something Not Working Right? Please let us know..."
3 | labels: ["bug"]
4 | assignees:
5 | - smashedr
6 |
7 | body:
8 | - type: input
9 | id: website
10 | validations:
11 | required: false
12 | attributes:
13 | label: Site Link
14 | description: Please provide a link to the site you are having issues on if possible.
15 | placeholder: https://example.com/
16 |
17 | - type: textarea
18 | id: description
19 | validations:
20 | required: true
21 | attributes:
22 | label: Details
23 | description: Please describe the issue you are experiencing and how to reproduce.
24 | placeholder: Provide as many details as you can...
25 |
26 | - type: textarea
27 | id: logs
28 | validations:
29 | required: true
30 | attributes:
31 | label: Support Information
32 | description: Open the extension options, scroll to the bottom, click Copy Support Information and paste below.
33 | render: shell
34 |
35 | - type: markdown
36 | attributes:
37 | value: |
38 | All issues/bugs that we can verify will be fixed. Thank you for taking the time to make this report!
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links:
3 | - name: "💡 Request a Feature"
4 | about: Request a New Feature or Enhancement in the Discussions.
5 | url: https://github.com/cssnr/link-extractor/discussions/new?category=feature-requests
6 |
7 | - name: "❔ Ask a Question"
8 | about: Ask a General Question or start a Discussions.
9 | url: https://github.com/cssnr/link-extractor/discussions/new?category=q-a
10 |
11 | - name: "💬 Join Discord"
12 | about: Chat with us about Issues, Features, Questions and More.
13 | url: https://discord.gg/wXy6m2X8wY
14 |
15 | - name: "📝 Submit Feedback"
16 | about: Send General Feedback.
17 | url: https://cssnr.github.io/feedback/?app=Link%20Extractor
18 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: "Build"
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | build:
10 | name: "Build"
11 | runs-on: ubuntu-latest
12 | timeout-minutes: 5
13 |
14 | steps:
15 | - name: "Checkout"
16 | uses: actions/checkout@v4
17 |
18 | - name: "Setup Node 22"
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: 22
22 | cache: npm
23 |
24 | - name: "Install"
25 | run: |
26 | npm install
27 |
28 | - name: "Update Manifest Version"
29 | if: ${{ github.event_name == 'release' }}
30 | uses: cssnr/update-json-value-action@v1
31 |
32 | - name: "Build"
33 | run: |
34 | npm install
35 | npm run build
36 |
37 | #- name: "Upload to Actions"
38 | # uses: actions/upload-artifact@v4
39 | # with:
40 | # name: artifacts
41 | # path: web-ext-artifacts/
42 |
43 | - name: "Upload to Release"
44 | if: ${{ github.event_name == 'release' }}
45 | uses: svenstaro/upload-release-action@v2
46 | with:
47 | file: web-ext-artifacts/*
48 | tag: ${{ github.ref }}
49 | overwrite: true
50 | file_glob: true
51 |
--------------------------------------------------------------------------------
/.github/workflows/issue.yaml:
--------------------------------------------------------------------------------
1 | name: "Issue"
2 |
3 | on:
4 | issues:
5 | types: [opened]
6 |
7 | jobs:
8 | issue:
9 | name: "Issue"
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 5
12 |
13 | steps:
14 | - name: "Checkout"
15 | uses: actions/checkout@v4
16 |
17 | - name: "Debug Issue"
18 | run: |
19 | echo Issue number: '${{ github.event.issue.number }}'
20 | echo Issue title: '${{ github.event.issue.title }}'
21 | echo Issue body: '${{ github.event.issue.body }}'
22 |
23 | - name: "Setup Node 22"
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: 22
27 |
28 | - name: "Parse Issue"
29 | id: issue
30 | uses: cssnr/parse-issue-form-action@master
31 | with:
32 | body: ${{ github.event.issue.body }}
33 |
34 | - name: "Debug Parsed Issue"
35 | run: |
36 | echo Site Link: '${{ steps.issue.outputs.site_link }}'
37 | echo Details: '${{ steps.issue.outputs.details }}'
38 | echo Support Information: '${{ steps.issue.outputs.support_information }}'
39 |
40 | - name: "Install"
41 | run: |
42 | npm install
43 |
44 | - name: "Process Issue"
45 | env:
46 | URL: ${{ steps.issue.outputs.site_link }}
47 | run: |
48 | npm run issue
49 |
50 | - name: "Debug Files"
51 | run: |
52 | ls -lah tests/screenshots
53 |
54 | - name: "Upload Image"
55 | id: image
56 | uses: McCzarny/upload-image@v1.0.0
57 | with:
58 | path: tests/screenshots/links.png
59 | uploadMethod: imgbb
60 | apiKey: ${{ secrets.IMGBB_API_KEY }}
61 |
62 | - name: "Read Logs"
63 | id: logs
64 | uses: juliangruber/read-file-action@v1
65 | with:
66 | path: tests/screenshots/logs.txt
67 |
68 | - name: "Debug Logs"
69 | run: echo "${{ steps.logs.outputs.content }}"
70 |
71 | - name: "Add Comment"
72 | run: gh issue comment "$NUMBER" --body "$BODY"
73 | env:
74 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75 | GH_REPO: ${{ github.repository }}
76 | NUMBER: ${{ github.event.issue.number }}
77 | BODY: |
78 | Link Extractor Results for: [${{ steps.issue.outputs.site_link }}](${{ steps.issue.outputs.site_link }})
79 |
80 | 
81 |
82 | Logs:
83 | ```json
84 | ${{ steps.logs.outputs.content }}
85 | ```
86 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yaml:
--------------------------------------------------------------------------------
1 | name: "Lint"
2 |
3 | on:
4 | workflow_dispatch:
5 | pull_request:
6 | branches: [master]
7 | push:
8 | branches: [master]
9 |
10 | jobs:
11 | lint:
12 | name: "Lint"
13 | runs-on: ubuntu-latest
14 | timeout-minutes: 5
15 | if: ${{ !contains(github.event.head_commit.message, '#nolint') }}
16 |
17 | steps:
18 | - name: "Checkout"
19 | uses: actions/checkout@v4
20 |
21 | - name: "Setup Node 22"
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: 22
25 | #cache: npm
26 |
27 | - name: "Install"
28 | id: install
29 | run: |
30 | npm install
31 |
32 | - name: "ESLint"
33 | if: ${{ steps.install.outcome == 'success' }}
34 | run: |
35 | npm run lint
36 |
37 | - name: "Prettier"
38 | if: ${{ steps.install.outcome == 'success' }}
39 | run: |
40 | npm run prettier
41 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: "Test"
2 |
3 | on:
4 | workflow_dispatch:
5 | schedule:
6 | - cron: "18 18 * * 1,3,5"
7 | pull_request:
8 | branches: [master]
9 | push:
10 | branches: [master]
11 | #paths:
12 | # - ".github/workflows/test.yaml"
13 | # - "src/**"
14 | # - "tests/**"
15 | # - "gulpfile.js"
16 | # - "manifest.json"
17 | # - "package.json"
18 | # - "package-lock.json"
19 |
20 | jobs:
21 | test:
22 | name: "Test"
23 | runs-on: ubuntu-latest
24 | timeout-minutes: 5
25 | if: ${{ !contains(github.event.head_commit.message, '#notest') }}
26 |
27 | steps:
28 | - name: "Checkout"
29 | uses: actions/checkout@v4
30 |
31 | - name: "Setup Node 22"
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version: 22
35 | #cache: npm
36 |
37 | - name: "Install"
38 | run: |
39 | npm install
40 |
41 | - name: "Test"
42 | id: test
43 | run: |
44 | npm run test
45 |
46 | - name: "Push Artifacts"
47 | uses: cssnr/push-artifacts-action@master
48 | if: ${{ github.event_name == 'pull_request' }}
49 | continue-on-error: true
50 | with:
51 | source: "tests/screenshots/"
52 | dest: "/static"
53 | host: ${{ secrets.RSYNC_HOST }}
54 | user: ${{ secrets.RSYNC_USER }}
55 | pass: ${{ secrets.RSYNC_PASS }}
56 | port: ${{ secrets.RSYNC_PORT }}
57 | webhost: "https://artifacts.hosted-domains.com"
58 | webhook: ${{ secrets.DISCORD_WEBHOOK }}
59 | token: ${{ secrets.GITHUB_TOKEN }}
60 |
61 | - name: "Schedule Failure Notification"
62 | uses: sarisia/actions-status-discord@v1
63 | if: ${{ always() && github.event_name == 'schedule' && steps.test.outcome == 'failure' }}
64 | with:
65 | webhook: ${{ secrets.DISCORD_WEBHOOK }}
66 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | *.iml
3 | .vscode/
4 | **/dist/
5 | build/
6 | node_modules/
7 | web-ext-artifacts/
8 | tests/screenshots/
9 | src/manifest.json
10 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .vscode/
3 | dist/
4 | node_modules/
5 | package-lock.json
6 | *.html
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "semi": false,
4 | "singleQuote": true,
5 | "overrides": [
6 | {
7 | "files": ["**/*.html", "**/*.yaml", "**/*.yml"],
8 | "options": {
9 | "singleQuote": false
10 | }
11 | },
12 | {
13 | "files": ["**/*.js", "**/*.css", "**/*.scss"],
14 | "options": {
15 | "tabWidth": 4
16 | }
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | - [Workflow](#Workflow)
4 | - [Quick Start](#Quick-Start)
5 | - [Building](#Building)
6 | - [Chrome Setup](#Chrome-Setup)
7 | - [Firefox Setup](#Firefox-Setup)
8 |
9 | > [!WARNING]
10 | > This guide is a work in progress and may not be complete.
11 |
12 | This is a basic contributing guide and is a work in progress.
13 |
14 | ## Workflow
15 |
16 | 1. Fork the repository.
17 | 2. Create a branch in your fork!
18 | 3. Install and test (see [Quick Start](#Quick-Start)).
19 | 4. Commit and push your changes.
20 | 5. Create a PR to this repository.
21 | 6. Verify the tests pass, otherwise resolve.
22 | 7. Make sure to keep your branch up-to-date.
23 |
24 | ## Quick Start
25 |
26 | First, clone (or download) this repository and change into the directory.
27 |
28 | Second, install the dependencies:
29 |
30 | ```shell
31 | npm install
32 | ```
33 |
34 | Finally, to run Chrome or Firefox with web-ext, run one of the following:
35 |
36 | ```shell
37 | npm run chrome
38 | npm run firefox
39 | ```
40 |
41 | Additionally, to Load Unpacked/Temporary Add-on make a `manifest.json` and run from the [src](src) folder, run one of
42 | the following:
43 |
44 | ```shell
45 | npm run manifest:chrome
46 | npm run manifest:firefox
47 | ```
48 |
49 | Chrome: [https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world#load-unpacked](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world#load-unpacked)
50 | Firefox: [https://extensionworkshop.com/documentation/develop/temporary-installation-in-firefox/](https://extensionworkshop.com/documentation/develop/temporary-installation-in-firefox/)
51 |
52 | For more information on web-ext, [read this documentation](https://extensionworkshop.com/documentation/develop/web-ext-command-reference/).
53 | To pass additional arguments to an `npm run` command, use `--`.
54 | Example: `npm run chrome -- --chromium-binary=...`
55 |
56 | ## Building
57 |
58 | Install the requirements and copy libraries into the `src/dist` directory by running `npm install`.
59 | See [gulpfile.js](gulpfile.js) for more information on `postinstall`.
60 |
61 | ```shell
62 | npm install
63 | ```
64 |
65 | To create a `.zip` archive of the [src](src) directory for the desired browser run one of the following:
66 |
67 | ```shell
68 | npm run build
69 | npm run build:chrome
70 | npm run build:firefox
71 | ```
72 |
73 | For more information on building, see the scripts section in the [package.json](package.json) file.
74 |
75 | ### Chrome Setup
76 |
77 | 1. Build or Download a [Release](https://github.com/cssnr/link-extractor/releases).
78 | 2. Unzip the archive, place the folder where it must remain and note its location for later.
79 | 3. Open Chrome, click the `3 dots` in the top right, click `Extensions`, click `Manage Extensions`.
80 | 4. In the top right, click `Developer Mode` then on the top left click `Load unpacked`.
81 | 5. Navigate to the folder you extracted in step #3 then click `Select Folder`.
82 |
83 | ### Firefox Setup
84 |
85 | 1. Build or Download a [Release](https://github.com/cssnr/link-extractor/releases).
86 | 2. Unzip the archive, place the folder where it must remain and note its location for later.
87 | 3. Go to `about:debugging#/runtime/this-firefox` and click `Load Temporary Add-on...`
88 | 4. Navigate to the folder you extracted earlier, select `manifest.json` then click `Select File`.
89 | 5. Optional: open `about:config` search for `extensions.webextensions.keepStorageOnUninstall` and set to `true`.
90 |
91 | If you need to test a restart, you must pack the addon. This only works in ESR, Development, or Nightly.
92 | You may also use an Unbranded Build: [https://wiki.mozilla.org/Add-ons/Extension_Signing#Unbranded_Builds](https://wiki.mozilla.org/Add-ons/Extension_Signing#Unbranded_Builds)
93 |
94 | 1. Run `npm run build:firefox` then use `web-ext-artifacts/{name}-firefox-{version}.zip`.
95 | 2. Open `about:config` search for `xpinstall.signatures.required` and set to `false`.
96 | 3. Open `about:addons` and drag the zip file to the page or choose Install from File from the Settings wheel.
97 |
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Data Collection
2 |
3 | Your data is not being collected, stored or used for any purpose.
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
2 | [](https://addons.mozilla.org/addon/link-extractor)
3 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
4 | [](https://addons.mozilla.org/addon/link-extractor)
5 | [](https://github.com/cssnr/link-extractor/stargazers)
6 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
7 | [](https://addons.mozilla.org/addon/link-extractor)
8 | [](https://github.com/cssnr/link-extractor/releases/latest)
9 | [](https://github.com/cssnr/link-extractor/actions/workflows/build.yaml)
10 | [](https://github.com/cssnr/link-extractor/actions/workflows/test.yaml)
11 | [](https://github.com/cssnr/link-extractor/actions/workflows/lint.yaml)
12 | [](https://sonarcloud.io/summary/overall?id=cssnr_link-extractor)
13 | [](https://github.com/cssnr/link-extractor/graphs/commit-activity)
14 | [](https://github.com/cssnr/link-extractor)
15 | [](https://cssnr.github.io/)
16 | [](https://discord.gg/wXy6m2X8wY)
17 |
18 | # Link Extractor
19 |
20 | Modern Chrome Web Extension and Firefox Browser Addon to easily extract, parse,or open all links/domains from a site or text with optional filters.
21 | Feature packed with automatic dark/light mode, copy to clipboard, keyboard shortcuts, custom options, and much more...
22 |
23 | Website: https://link-extractor.cssnr.com/
24 |
25 | - [Install](#Install)
26 | - [Features](#Features)
27 | - [Upcoming Features](#Upcoming-Features)
28 | - [Known Issues](#Known-Issues)
29 | - [Configuration](#Configuration)
30 | - [Support](#Support)
31 | - [Contributing](#Contributing)
32 |
33 | ## Install
34 |
35 | - [Google Chrome Web Store](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
36 | - [Mozilla Firefox Add-ons](https://addons.mozilla.org/addon/link-extractor)
37 |
38 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
39 | [](https://addons.mozilla.org/addon/link-extractor)
40 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
41 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
42 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
43 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
44 | [](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp)
45 |
46 | All **Chromium** Based Browsers can install the extension from the
47 | [Chrome Web Store](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp).
48 |
49 | Mobile browser support available for
50 | [Firefox](https://addons.mozilla.org/addon/link-extractor) and
51 | [Yandex](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp).
52 |
53 | ## Features
54 |
55 | Please submit a [Feature Request](https://github.com/cssnr/link-extractor/discussions/new?category=feature-requests)
56 | for new features. For any issues, bugs or concerns; please [Open an Issue](https://github.com/cssnr/link-extractor/issues/new).
57 |
58 | - Extract All Links and Domains from Any Site
59 | - Extract Links from Selected Text on any Site
60 | - Extract Links from Clipboard or Any Text
61 | - Extract Links from All Selected Tabs
62 | - Extract Links from PDF Documents
63 | - Copy Selected Links with right-click Menu
64 | - Display Additional Link Details and Text
65 | - Open Multiple Links in Tabs from Text
66 | - Download Links and Domains as a Text File
67 | - Copy the Text from a Link via Context Menu
68 | - Quick Filter Links with a Regular Expression
69 | - Store Regular Expressions for Quick Filtering
70 | - Import and Export Saved Regular Expressions
71 | - Automatic Dark/Light Mode based on Browser Setting
72 | - Activate from Popup, Context Menu, Keyboard Shortcuts or Omnibox
73 |
74 | [](https://link-extractor.cssnr.com/screenshots/)
75 |
76 | ### Upcoming Features
77 |
78 | - Option to Extract Links from All Text Files (PDF Extraction currently in Beta)
79 | - Option to Set Names/Titles for Saved Filters
80 | - Option to Extract Using Multiple Filters with AND/OR
81 |
82 | > [!TIP]
83 | > Don't see your feature here?
84 | > Request one on the [Feature Request Discussion](https://github.com/cssnr/link-extractor/discussions/categories/feature-requests).
85 |
86 | ### Known Issues
87 |
88 | See the [Support](#Support) to let us know about issues...
89 |
90 | For more information see the [FAQ](https://link-extractor.cssnr.com/faq/).
91 |
92 | ## Configuration
93 |
94 | - [View Configuration Documentation on Website](https://link-extractor.cssnr.com/docs/#configure)
95 |
96 | You can pin the Addon by clicking the `Puzzle Piece`, find the Link Extractor icon, then;
97 | **Chrome,** click the `Pin` icon.
98 | **Firefox,** click the `Settings Wheel` and `Pin to Toolbar`.
99 |
100 | To open the options, click on the icon (from above) then click `Open Options`.
101 | You can also access `Options` through the right-click context menu (enabled by default).
102 | Here you can set flags and add as many saved regular expressions as you would like for easy use later.
103 | Make sure to click`Save Options` when finished.
104 |
105 | For more information on regex, see: https://regex101.com/
106 |
107 | ## Support
108 |
109 | For help using the web extension or to request features, see:
110 |
111 | - Documentation: https://link-extractor.cssnr.com/docs/
112 | - Q&A Discussion: https://github.com/cssnr/link-extractor/discussions/categories/q-a
113 | - Request a Feature: https://github.com/cssnr/link-extractor/discussions/categories/feature-requests
114 |
115 | If you are experiencing an issue/bug or getting unexpected results, you can:
116 |
117 | - Report an Issue: https://github.com/cssnr/link-extractor/issues
118 | - Chat with us on Discord: https://discord.gg/wXy6m2X8wY
119 | - Provide General Feedback: [https://cssnr.github.io/feedback/](https://cssnr.github.io/feedback/?app=Link%20Extractor)
120 |
121 | Logs can be found inspecting the page (Ctrl+Shift+I), clicking on the Console, and;
122 | Firefox: toggling Debug logs, Chrome: toggling Verbose from levels dropdown.
123 |
124 | # Contributing
125 |
126 | Currently, the best way to contribute to this project is to give a 5-star rating on
127 | [Google](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) or
128 | [Mozilla](https://addons.mozilla.org/addon/link-extractor) and to star this project on GitHub.
129 |
130 | For instructions on building, testing and submitting a PR, see [CONTRIBUTING.md](CONTRIBUTING.md).
131 |
132 | Other Web Extensions I have created and published:
133 |
134 | - [Link Extractor](https://github.com/cssnr/link-extractor?tab=readme-ov-file#readme)
135 | - [Open Links in New Tab](https://github.com/cssnr/open-links-in-new-tab?tab=readme-ov-file#readme)
136 | - [Auto Auth](https://github.com/cssnr/auto-auth?tab=readme-ov-file#readme)
137 | - [Cache Cleaner](https://github.com/cssnr/cache-cleaner?tab=readme-ov-file#readme)
138 | - [HLS Video Downloader](https://github.com/cssnr/hls-video-downloader?tab=readme-ov-file#readme)
139 | - [SMWC Web Extension](https://github.com/cssnr/smwc-web-extension?tab=readme-ov-file#readme)
140 | - [PlayDrift Extension](https://github.com/cssnr/playdrift-extension?tab=readme-ov-file#readme)
141 | - [ASN Plus](https://github.com/cssnr/asn-plus?tab=readme-ov-file#readme)
142 | - [Aviation Tools](https://github.com/cssnr/aviation-tools?tab=readme-ov-file#readme)
143 | - [Text Formatter](https://github.com/cssnr/text-formatter?tab=readme-ov-file#readme)
144 |
145 | For a full list of current projects visit: [https://cssnr.github.io/](https://cssnr.github.io/)
146 |
--------------------------------------------------------------------------------
/assets/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/assets/banner.jpg
--------------------------------------------------------------------------------
/assets/lazy-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/assets/lazy-icon.png
--------------------------------------------------------------------------------
/assets/logo.pdn:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/assets/logo.pdn
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 |
3 | export default [
4 | js.configs.recommended,
5 | {
6 | languageOptions: {
7 | ecmaVersion: 'latest',
8 | sourceType: 'module',
9 | },
10 | settings: {
11 | env: {
12 | browser: true,
13 | es2021: true,
14 | jquery: true,
15 | webextensions: true,
16 | },
17 | },
18 | rules: {
19 | 'no-undef': 'off',
20 | 'no-extra-semi': 'off',
21 | },
22 | },
23 | ]
24 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const gulp = require('gulp')
2 |
3 | gulp.task('bootstrap', () => {
4 | return gulp
5 | .src([
6 | 'node_modules/bootstrap/dist/css/bootstrap.min.css',
7 | 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js',
8 | ])
9 | .pipe(gulp.dest('src/dist/bootstrap'))
10 | })
11 |
12 | gulp.task('clipboard', () => {
13 | return gulp
14 | .src('node_modules/clipboard/dist/clipboard.min.js')
15 | .pipe(gulp.dest('src/dist/clipboard'))
16 | })
17 |
18 | gulp.task('datatables', () => {
19 | return gulp
20 | .src([
21 | 'node_modules/datatables.net/js/dataTables.min.js',
22 | 'node_modules/datatables.net-bs5/js/dataTables.bootstrap5.min.js',
23 | 'node_modules/datatables.net-bs5/css/dataTables.bootstrap5.min.css',
24 | 'node_modules/datatables.net-buttons/js/dataTables.buttons.min.js',
25 | 'node_modules/datatables.net-buttons/js/buttons.colVis.min.js',
26 | 'node_modules/datatables.net-buttons/js/buttons.html5.min.js',
27 | 'node_modules/datatables.net-buttons-bs5/js/buttons.bootstrap5.min.js',
28 | 'node_modules/datatables.net-buttons-bs5/css/buttons.bootstrap5.min.css',
29 | ])
30 | .pipe(gulp.dest('src/dist/datatables'))
31 | })
32 |
33 | gulp.task('fontawesome', () => {
34 | return gulp
35 | .src(
36 | [
37 | 'node_modules/@fortawesome/fontawesome-free/css/all.min.css',
38 | 'node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-*',
39 | 'node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-*',
40 | ],
41 | {
42 | base: 'node_modules/@fortawesome/fontawesome-free',
43 | encoding: false,
44 | }
45 | )
46 | .pipe(gulp.dest('src/dist/fontawesome'))
47 | })
48 |
49 | gulp.task('jquery', () => {
50 | return gulp
51 | .src('node_modules/jquery/dist/jquery.min.js')
52 | .pipe(gulp.dest('src/dist/jquery'))
53 | })
54 |
55 | gulp.task('pdfjs', () => {
56 | return gulp
57 | .src([
58 | 'node_modules/pdfjs-dist/build/pdf.min.mjs',
59 | 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs',
60 | ])
61 | .pipe(gulp.dest('src/dist/pdfjs'))
62 | })
63 |
64 | gulp.task(
65 | 'default',
66 | gulp.parallel(
67 | 'bootstrap',
68 | 'clipboard',
69 | 'datatables',
70 | 'fontawesome',
71 | 'jquery',
72 | 'pdfjs'
73 | )
74 | )
75 |
--------------------------------------------------------------------------------
/manifest-chrome.json:
--------------------------------------------------------------------------------
1 | {
2 | "optional_host_permissions": ["*://*/*"],
3 | "background": {
4 | "service_worker": "js/service-worker.js"
5 | },
6 | "minimum_chrome_version": "88"
7 | }
8 |
--------------------------------------------------------------------------------
/manifest-firefox.json:
--------------------------------------------------------------------------------
1 | {
2 | "optional_permissions": ["*://*/*"],
3 | "background": {
4 | "scripts": ["js/service-worker.js"]
5 | },
6 | "browser_specific_settings": {
7 | "gecko": {
8 | "id": "link-extractor@cssnr.com",
9 | "strict_min_version": "112.0"
10 | },
11 | "gecko_android ": {
12 | "strict_min_version": "120.0"
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Link Extractor",
3 | "description": "Easily extract all links/domains from tabs/text with optional filters. Allows opening, copying, sorting, downloading, and more...",
4 | "homepage_url": "https://link-extractor.cssnr.com/",
5 | "author": "Shane",
6 | "version": "0.0.1",
7 | "manifest_version": 3,
8 | "commands": {
9 | "_execute_action": {
10 | "suggested_key": {
11 | "default": "Alt+Shift+A"
12 | },
13 | "description": "Show Main Popup Action"
14 | },
15 | "extractAll": {
16 | "suggested_key": {
17 | "default": "Alt+Shift+X"
18 | },
19 | "description": "Extract Links from Tab(s)"
20 | },
21 | "extractSelection": {
22 | "description": "Extract Links from Selected Text"
23 | },
24 | "copyAll": {
25 | "suggested_key": {
26 | "default": "Alt+Shift+C"
27 | },
28 | "description": "Copy Links from Tab(s)"
29 | },
30 | "copySelection": {
31 | "description": "Copy Links from Selected Text"
32 | }
33 | },
34 | "omnibox": {
35 | "keyword": "link"
36 | },
37 | "permissions": ["activeTab", "contextMenus", "scripting", "storage"],
38 | "background": {
39 | "type": "module"
40 | },
41 | "options_ui": {
42 | "page": "html/options.html",
43 | "open_in_tab": true
44 | },
45 | "action": {
46 | "default_popup": "html/popup.html",
47 | "default_title": "Link Extractor",
48 | "default_icon": {
49 | "16": "images/logo16.png",
50 | "32": "images/logo32.png",
51 | "48": "images/logo48.png",
52 | "96": "images/logo96.png",
53 | "128": "images/logo128.png"
54 | }
55 | },
56 | "icons": {
57 | "16": "images/logo16.png",
58 | "32": "images/logo32.png",
59 | "48": "images/logo48.png",
60 | "96": "images/logo96.png",
61 | "128": "images/logo128.png"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "link-extractor",
3 | "scripts": {
4 | "postinstall": "npx gulp",
5 | "lint:eslint": "npx eslint src/js tests",
6 | "lint:web-ext": "npm run manifest:firefox && npx web-ext lint --source-dir ./src/ --ignore-files dist/**",
7 | "lint": "npm run lint:eslint && npm run lint:web-ext",
8 | "test": "npm run manifest:test && node tests/test.js",
9 | "issue": "npm run manifest:test && node tests/issue.js",
10 | "chrome": "npm run manifest:chrome && web-ext run --source-dir ./src/ --target=chromium",
11 | "firefox": "npm run manifest:firefox && web-ext run --source-dir ./src/",
12 | "manifest:chrome": "npx json-merger -p -am concat -o src/manifest.json manifest.json manifest-chrome.json",
13 | "manifest:firefox": "npx json-merger -p -am concat -o src/manifest.json manifest.json manifest-firefox.json",
14 | "manifest:test": "npx json-merger -p -am concat -o src/manifest.json manifest.json tests/manifest-test.json",
15 | "build:chrome": "npm run manifest:chrome && npx web-ext build -n {name}-chrome-{version}.zip -o -s src",
16 | "build:firefox": "npm run manifest:firefox && npx web-ext build -n {name}-firefox-{version}.zip -o -s src",
17 | "build": "npm run build:chrome && npm run build:firefox",
18 | "prettier": "npx prettier --check ."
19 | },
20 | "dependencies": {
21 | "@fortawesome/fontawesome-free": "^6.7.2",
22 | "bootstrap": "^5.3.3",
23 | "clipboard": "^2.0.11",
24 | "datatables.net": "^2.1.5",
25 | "datatables.net-bs5": "^2.2.2",
26 | "datatables.net-buttons": "^3.1.2",
27 | "datatables.net-buttons-bs5": "^3.2.2",
28 | "jquery": "^3.7.1",
29 | "pdfjs-dist": "^4.10.38"
30 | },
31 | "devDependencies": {
32 | "@eslint/js": "^9.21.0",
33 | "@types/chrome": "^0.0.299",
34 | "eslint": "^9.21.0",
35 | "gulp": "^5.0.0",
36 | "json-merger": "^2.1.0",
37 | "prettier": "^3.5.2",
38 | "puppeteer": "^22.15.0",
39 | "web-ext": "^8.4.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/css/links.css:
--------------------------------------------------------------------------------
1 | /* CSS for links.html */
2 |
3 | a:visited {
4 | color: #bf40bf;
5 | }
6 |
7 | #floating-links {
8 | margin-top: 10px;
9 | margin-right: 10px;
10 | width: 160px;
11 | z-index: 10;
12 | }
13 |
14 | #toast-container {
15 | max-width: 260px;
16 | width: 100%;
17 | }
18 |
19 | td.truncate {
20 | overflow: hidden;
21 | text-overflow: ellipsis;
22 | }
23 |
--------------------------------------------------------------------------------
/src/css/main.css:
--------------------------------------------------------------------------------
1 | /* CSS for global */
2 |
3 | :root,
4 | [data-bs-theme='light'] {
5 | --bs-emphasis-bg: var(--bs-white);
6 | --bs-emphasis-bg-rgb: var(--bs-white-rgb);
7 | }
8 |
9 | [data-bs-theme='dark'] {
10 | --bs-emphasis-bg: var(--bs-black);
11 | --bs-emphasis-bg-rgb: var(--bs-black-rgb);
12 | }
13 |
14 | svg {
15 | height: 1em;
16 | width: 1em;
17 | margin-bottom: 0.15em;
18 | }
19 |
20 | #toast-container {
21 | z-index: 3;
22 | }
23 |
24 | #back-to-top {
25 | position: fixed;
26 | bottom: 64px;
27 | right: 20px;
28 | display: none;
29 | z-index: 3;
30 | }
31 |
32 | .text-ellipsis {
33 | max-width: 100%;
34 | overflow: hidden;
35 | white-space: nowrap;
36 | text-overflow: ellipsis;
37 | }
38 |
--------------------------------------------------------------------------------
/src/css/options.css:
--------------------------------------------------------------------------------
1 | /* CSS for options.html */
2 |
3 | body {
4 | min-width: 340px;
5 | }
6 |
7 | #toast-container {
8 | max-width: 320px;
9 | width: 100%;
10 | }
11 |
--------------------------------------------------------------------------------
/src/css/permissions.css:
--------------------------------------------------------------------------------
1 | /* CSS for permissions.html */
2 |
3 | body {
4 | min-width: 340px;
5 | }
6 |
--------------------------------------------------------------------------------
/src/css/popup.css:
--------------------------------------------------------------------------------
1 | /* CSS for popup.html */
2 |
3 | body {
4 | min-width: 340px;
5 | }
6 |
7 | input::placeholder,
8 | textarea::placeholder {
9 | text-align: center;
10 | }
11 |
12 | .dropdown-menu {
13 | max-width: 100vw;
14 | white-space: normal;
15 | overflow: hidden;
16 | }
17 |
--------------------------------------------------------------------------------
/src/html/lazy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/html/links.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Link Extractor Results
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
38 |
39 |
40 |
Loading...
41 |
42 |
43 |
Links 0 /
44 |
45 |
46 | Copy Links
47 |
48 | Download
49 |
50 | Open
51 |
52 |
53 |
54 | C L to Copy Links.
55 |
56 |
57 | K Z Keyboard Shortcuts.
58 |
59 |
60 |
61 |
62 |
63 |
64 | Find and Replace
65 |
66 |
67 |
68 |
69 |
120 |
121 | Note: Updates links do not yet work with Datatables (Copy Table, CSV Export, Filter);
122 | however, do work with Copy Links, Download and Open buttons at the top.
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | Link
132 | Text
133 | Title
134 | Label
135 | Rel
136 | Target
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
Domains 0 /
147 |
148 |
149 | Copy Domains
150 |
151 | Download
152 |
153 | Open
154 |
155 |
156 | D M to Copy Domains.
157 |
158 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
174 |
175 |
176 |
177 |
C or L Copy All Links
178 |
D or M Copy All Domains
179 |
F or J Focus Links Filter
180 |
G or H Focus Domains Filter
181 |
T or O Open Options
182 |
Z or K Keyboard Shortcuts
183 |
Escape Unfocus Filter Input
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
198 |
199 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
--------------------------------------------------------------------------------
/src/html/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Link Extractor Options
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
28 |
29 |
30 |
31 |
32 | Keyboard Shortcuts
33 |
34 |
35 |
36 | Keyboard Shortcuts
37 | Description Shortcut
38 |
39 |
40 |
41 |
42 | Unknown
43 |
44 |
45 |
46 |
52 |
53 |
54 |
55 |
56 | General Options
57 |
58 |
59 |
60 |
203 |
204 |
205 | More about Options
206 |
207 | on the Docs
208 |
209 |
210 |
217 |
218 |
221 | Remove Host Permissions
222 |
223 |
224 |
225 |
226 | Saved Filters
227 |
228 |
229 |
230 |
239 |
240 |
248 |
249 |
250 |
251 | Saved Filters
252 |
253 |
254 |
255 |
256 |
257 |
258 | Filter
259 |
260 |
261 |
262 |
263 |
264 |
265 | Copy Support Information for issue reporting.
266 |
267 |
268 |
269 |
270 |
271 |
Documentation
273 |
•
274 |
FAQ
276 |
•
277 |
Report Issue
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
--------------------------------------------------------------------------------
/src/html/permissions.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Link Extractor Permissions
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
23 |
Link Extractor
24 |
25 |
28 |
Host Permissions enables the following features:
29 |
All Browsers
30 |
31 | Extracting links from multiple selected tabs.
32 |
33 |
Firefox
34 |
35 | Using Omnibox to extract links.
36 | Extracting links from PDFs.
37 |
38 |
39 |
42 | Grant Host Permissions
43 |
44 |
45 | Permissions Granted.
46 |
47 |
48 | Open Options
49 |
50 |
51 |
Documentation
53 |
•
54 |
FAQ
56 |
•
57 |
Get Support
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
72 |
73 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/src/html/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Link Extractor
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
30 |
31 |
38 |
39 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | All Links
54 |
55 |
56 | Filters
57 |
58 |
61 |
62 |
63 |
64 |
68 |
69 |
70 | Only Domains
71 |
72 |
74 | Extract from PDF
75 |
76 |
77 |
78 | Browser does not allow access to files.
79 |
80 |
81 |
82 | PDF files require
file access .
83 |
84 |
85 |
86 | PDF extraction needs
host permissions .
87 |
88 |
89 |
93 |
94 |
95 | Parse
97 | Open
99 | Open
101 |
102 |
103 |
104 |
105 |
106 | Lazy Load Opened Tabs
107 |
109 |
110 |
111 |
112 | Remove Duplicate Links
113 |
115 |
116 |
117 |
118 | Use Default Link Filtering
119 |
121 |
122 |
123 |
124 | Save Links Page Options
125 |
127 |
128 |
129 |
130 | Truncate Long Links
131 |
133 |
134 |
135 |
136 | Don't Wrap Long Links
137 |
139 |
140 |
141 |
142 |
143 |
146 | Grant Host Permissions
147 |
148 |
149 |
150 | More Options
151 |
152 |
153 |
154 |
155 |
158 |
159 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
--------------------------------------------------------------------------------
/src/images/lazy/icon-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/lazy/icon-black.png
--------------------------------------------------------------------------------
/src/images/lazy/icon-red.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/lazy/icon-red.png
--------------------------------------------------------------------------------
/src/images/lazy/icon-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/lazy/icon-white.png
--------------------------------------------------------------------------------
/src/images/lazy/icon-yellow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/lazy/icon-yellow.png
--------------------------------------------------------------------------------
/src/images/logo128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/logo128.png
--------------------------------------------------------------------------------
/src/images/logo16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/logo16.png
--------------------------------------------------------------------------------
/src/images/logo32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/logo32.png
--------------------------------------------------------------------------------
/src/images/logo48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/logo48.png
--------------------------------------------------------------------------------
/src/images/logo96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cssnr/link-extractor/0c2bd653c87a0abeaa877984c929bcd8fe698613/src/images/logo96.png
--------------------------------------------------------------------------------
/src/js/exports.js:
--------------------------------------------------------------------------------
1 | // JS Exports
2 |
3 | export const githubURL = 'https://github.com/cssnr/link-extractor'
4 |
5 | /**
6 | * Inject extract.js to Tab and Open links.html with params
7 | * @function injectTab
8 | * @param {Object} injectOptions Inject Tab Options
9 | * @param {String} [injectOptions.filter] Regex Filter
10 | * @param {Boolean} [injectOptions.domains] Only Domains
11 | * @param {Boolean} [injectOptions.selection] Only Selection
12 | * @param {Boolean} [injectOptions.open] Open Links Page
13 | * @param {chrome.tabs.Tab} [injectOptions.tab] Open Links Page
14 | * @return {Promise}
15 | */
16 | export async function injectTab({
17 | filter = null,
18 | domains = false,
19 | selection = false,
20 | open = true,
21 | tab = null,
22 | } = {}) {
23 | console.log('injectTab:', filter, domains, selection)
24 |
25 | // Extract tabIds from all highlighted tabs
26 | const tabIds = []
27 | if (tab) {
28 | tabIds.push(tab.id)
29 | } else {
30 | const tabs = await chrome.tabs.query({
31 | currentWindow: true,
32 | highlighted: true,
33 | })
34 | console.debug('tabs:', tabs)
35 | for (const tab of tabs) {
36 | console.debug(`tab: ${tab.id}`, tab)
37 | if (!tab.url) {
38 | const url = new URL(
39 | chrome.runtime.getURL('/html/permissions.html')
40 | )
41 | const message = `Missing permissions for one or more selected tabs: ${tab.id}`
42 | url.searchParams.append('message', message)
43 | await chrome.tabs.create({ active: true, url: url.href })
44 | console.log('%cHost/Tab Permissions Error', 'color: OrangeRed')
45 | return
46 | }
47 | tabIds.push(tab.id)
48 | }
49 | }
50 | console.log('tabIds:', tabIds)
51 | if (!tabIds.length) {
52 | // TODO: Display Error to User
53 | console.error('No Tab IDs to Inject')
54 | return
55 | }
56 |
57 | // Inject extract.js which listens for messages
58 | for (const tab of tabIds) {
59 | console.debug(`injecting tab.id: ${tab}`)
60 | await chrome.scripting.executeScript({
61 | target: { tabId: tab },
62 | files: ['/js/extract.js'],
63 | })
64 | }
65 |
66 | // Create URL to links.html if open
67 | if (!open) {
68 | console.debug('Skipping opening links.html on !open:', open)
69 | return
70 | }
71 | const url = new URL(chrome.runtime.getURL('/html/links.html'))
72 | // Set URL searchParams
73 | url.searchParams.set('tabs', tabIds.join(','))
74 | if (filter) {
75 | url.searchParams.set('filter', filter)
76 | }
77 | if (domains) {
78 | url.searchParams.set('domains', domains.toString())
79 | }
80 | if (selection) {
81 | url.searchParams.set('selection', selection.toString())
82 | }
83 | // Open Tab to links.html with desired params
84 | console.debug(`url: ${url.href}`)
85 | await chrome.tabs.create({ active: true, url: url.href })
86 | }
87 |
88 | /**
89 | * Update Options
90 | * @function initOptions
91 | * @param {Object} options
92 | */
93 | export function updateOptions(options) {
94 | console.debug('updateOptions:', options)
95 | for (let [key, value] of Object.entries(options)) {
96 | // console.debug(`%c${key}: %c${value}`)
97 | if (typeof value === 'undefined') {
98 | console.warn('Value undefined for key:', key)
99 | continue
100 | }
101 | // Option Key should be `radioXXX` and values should be the option IDs
102 | if (key.startsWith('radio')) {
103 | key = value //NOSONAR
104 | value = true //NOSONAR
105 | }
106 | const el = document.getElementById(key)
107 | if (!el) {
108 | continue
109 | }
110 | if (el.tagName !== 'INPUT') {
111 | el.textContent = value.toString()
112 | } else if (typeof value === 'boolean') {
113 | el.checked = value
114 | } else {
115 | el.value = value
116 | }
117 | if (el.dataset.related) {
118 | hideShowElement(`#${el.dataset.related}`, value)
119 | }
120 | }
121 | }
122 |
123 | /**
124 | * Hide or Show Element with JQuery
125 | * @function hideShowElement
126 | * @param {String} selector
127 | * @param {Boolean} [show]
128 | * @param {String} [speed]
129 | */
130 | function hideShowElement(selector, show, speed = 'fast') {
131 | const element = $(`${selector}`)
132 | // console.debug('hideShowElement:', show, element)
133 | if (show) {
134 | element.show(speed)
135 | } else {
136 | element.hide(speed)
137 | }
138 | }
139 |
140 | /**
141 | * Save Options Callback
142 | * NOTE: Look into simplifying this function
143 | * @function saveOptions
144 | * @param {InputEvent} event
145 | */
146 | export async function saveOptions(event) /* NOSONAR */ {
147 | console.debug('saveOptions:', event)
148 | // console.debug('%c ----- targets -----', 'color: Yellow')
149 | // console.debug('event.currentTarget:', event.currentTarget)
150 | // // console.debug('target:', target)
151 | // console.debug('event.target:', event.target)
152 | // console.debug('%c ----- targets -----', 'color: Yellow')
153 | // target = event.currentTarget || target || event.target
154 | const target = event.currentTarget || event.target
155 | console.debug('target:', target)
156 | let key = target.id
157 | // console.debug('key:', key)
158 | let value
159 | const { options } = await chrome.storage.sync.get(['options'])
160 | if (key === 'flags') {
161 | // key = 'flags'
162 | /** @type {HTMLInputElement} */
163 | const element = document.getElementById(key)
164 | let flags = element.value.toLowerCase().replace(/\s+/gm, '').split('')
165 | flags = new Set(flags)
166 | flags = [...flags].join('')
167 | console.debug(`flags: ${flags}`)
168 | for (const flag of flags) {
169 | if (!'dgimsuvy'.includes(flag)) {
170 | element.classList.add('is-invalid')
171 | showToast(`Invalid Regex Flag: ${flag}`, 'danger')
172 | return
173 | }
174 | }
175 | element.value = flags
176 | value = flags
177 | // } else if (key.startsWith('reset-')) {
178 | // key = target.dataset.target
179 | // console.debug('key reset-:', key)
180 | // /** @type {HTMLInputElement} */
181 | // const element = document.getElementById(key)
182 | // console.debug('element:', element)
183 | // element.value = target.dataset.value
184 | // value = target.dataset.value
185 | } else if (target.dataset.target) {
186 | key = target.dataset.target
187 | console.debug('key dataset.target:', key)
188 | const element = document.getElementById(key)
189 | value = element.value
190 | } else if (target.type === 'radio') {
191 | key = target.name
192 | console.debug('key radio:', key)
193 | const radios = document.getElementsByName(key)
194 | for (const input of radios) {
195 | if (input.checked) {
196 | value = input.id
197 | break
198 | }
199 | }
200 | } else if (target.type === 'checkbox') {
201 | value = target.checked
202 | } else {
203 | value = target.value
204 | }
205 | if (value !== undefined) {
206 | options[key] = value
207 | console.log(`Set %c${key}:`, 'color: Khaki', value)
208 | await chrome.storage.sync.set({ options })
209 | } else {
210 | console.warn(`No Value for key: ${key}`)
211 | }
212 | }
213 |
214 | /**
215 | * Open URL
216 | * @function openURL
217 | * @param {String} url
218 | * @param {Boolean} [lazy]
219 | */
220 | export function openURL(url, lazy = false) {
221 | // console.debug('openLink:', url, lazy)
222 | if (!url.includes('://')) {
223 | url = `http://${url}`
224 | }
225 | // console.debug('url:', url)
226 | if (lazy) {
227 | const lazyURL = new URL(chrome.runtime.getURL('/html/lazy.html'))
228 | lazyURL.searchParams.append('url', url)
229 | // noinspection JSIgnoredPromiseFromCall
230 | chrome.tabs.create({ active: false, url: lazyURL.href })
231 | } else {
232 | // noinspection JSIgnoredPromiseFromCall
233 | chrome.tabs.create({ active: false, url })
234 | }
235 | }
236 |
237 | /**
238 | * Update DOM with Manifest Details
239 | * @function updateManifest
240 | */
241 | export async function updateManifest() {
242 | const manifest = chrome.runtime.getManifest()
243 | document.querySelectorAll('.version').forEach((el) => {
244 | el.textContent = manifest.version
245 | })
246 | document.querySelectorAll('[href="homepage_url"]').forEach((el) => {
247 | el.href = manifest.homepage_url
248 | })
249 | document.querySelectorAll('[href="version_url"]').forEach((el) => {
250 | el.href = `${githubURL}/releases/tag/${manifest.version}`
251 | })
252 | }
253 |
254 | /**
255 | * Export Data Click Callback
256 | * @function exportClick
257 | * @param {MouseEvent} event
258 | */
259 | export async function exportClick(event) {
260 | console.debug('exportClick:', event)
261 | event.preventDefault()
262 | const name = event.target.dataset.importName
263 | console.debug('name:', name)
264 | const display = event.target.dataset.importDisplay || name
265 | const data = await chrome.storage.sync.get()
266 | // console.debug('data:', data[name])
267 | if (!data[name].length) {
268 | return showToast(`No ${display} Found!`, 'warning')
269 | }
270 | const json = JSON.stringify(data[name], null, 2)
271 | textFileDownload(`${name}.txt`, json)
272 | }
273 |
274 | /**
275 | * Import Data Click Callback
276 | * @function importClick
277 | * @param {MouseEvent} event
278 | */
279 | export async function importClick(event) {
280 | console.debug('importClick:', event)
281 | event.preventDefault()
282 | document.getElementById('import-input').click()
283 | }
284 |
285 | /**
286 | * Input Data Change Callback
287 | * @function importChange
288 | * @param {InputEvent} event
289 | */
290 | export async function importChange(event) {
291 | console.debug('importChange:', event)
292 | event.preventDefault()
293 | const name = event.target.dataset.importName
294 | console.debug('name:', name)
295 | const display = event.target.dataset.importDisplay || name
296 | console.debug('display:', display)
297 | try {
298 | const file = event.target.files.item(0)
299 | const text = await file.text()
300 | const data = JSON.parse(text)
301 | console.debug('data:', data)
302 | const storage = await chrome.storage.sync.get()
303 | console.debug(`storage[${name}]:`, storage[name])
304 | let count = 0
305 | for (const item of data) {
306 | console.debug('item:', item)
307 | if (!storage[name].includes(item)) {
308 | storage[name].push(item)
309 | count += 1
310 | }
311 | }
312 | await chrome.storage.sync.set(storage)
313 | showToast(`Imported ${count}/${data.length} ${display}.`, 'success')
314 | } catch (e) {
315 | console.log(e)
316 | showToast(`Import Error: ${e.message}.`, 'danger')
317 | }
318 | }
319 |
320 | /**
321 | * Text File Download
322 | * @function textFileDownload
323 | * @param {String} filename
324 | * @param {String} text
325 | */
326 | export function textFileDownload(filename, text) {
327 | console.debug(`textFileDownload: ${filename}`)
328 | const element = document.createElement('a')
329 | element.setAttribute(
330 | 'href',
331 | 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)
332 | )
333 | element.setAttribute('download', filename)
334 | element.classList.add('d-none')
335 | document.body.appendChild(element)
336 | element.click()
337 | document.body.removeChild(element)
338 | }
339 |
340 | /**
341 | * Grant Permissions Click Callback
342 | * @function grantPerms
343 | * @param {MouseEvent} event
344 | * @param {Boolean} [close]
345 | */
346 | export async function grantPerms(event, close = false) {
347 | console.debug('grantPerms:', event)
348 | // noinspection ES6MissingAwait
349 | requestPerms()
350 | if (close) {
351 | window.close()
352 | }
353 | }
354 |
355 | /**
356 | * Request Host Permissions
357 | * @function requestPerms
358 | * @return {Promise}
359 | */
360 | export async function requestPerms() {
361 | return await chrome.permissions.request({
362 | origins: ['*://*/*'],
363 | })
364 | }
365 |
366 | /**
367 | * Check Host Permissions
368 | * @function checkPerms
369 | * @return {Promise}
370 | */
371 | export async function checkPerms() {
372 | const hasPerms = await chrome.permissions.contains({
373 | origins: ['*://*/*'],
374 | })
375 | console.debug('checkPerms:', hasPerms)
376 | // Firefox still uses DOM Based Background Scripts
377 | if (typeof document === 'undefined') {
378 | return hasPerms
379 | }
380 | const hasPermsEl = document.querySelectorAll('.has-perms')
381 | const grantPermsEl = document.querySelectorAll('.grant-perms')
382 | if (hasPerms) {
383 | hasPermsEl.forEach((el) => el.classList.remove('d-none'))
384 | grantPermsEl.forEach((el) => el.classList.add('d-none'))
385 | } else {
386 | grantPermsEl.forEach((el) => el.classList.remove('d-none'))
387 | hasPermsEl.forEach((el) => el.classList.add('d-none'))
388 | }
389 | return hasPerms
390 | }
391 |
392 | /**
393 | * Revoke Permissions Click Callback
394 | * NOTE: Chrome does not allow revoking required permissions with this method
395 | * @function revokePerms
396 | * @param {Event} event
397 | */
398 | export async function revokePerms(event) {
399 | console.debug('revokePerms:', event)
400 | const permissions = await chrome.permissions.getAll()
401 | console.debug('permissions:', permissions)
402 | try {
403 | await chrome.permissions.remove({
404 | origins: permissions.origins,
405 | })
406 | await checkPerms()
407 | } catch (e) {
408 | console.log(e)
409 | showToast(e.message, 'danger')
410 | }
411 | }
412 |
413 | /**
414 | * Permissions On Added Callback
415 | * @param permissions
416 | */
417 | export async function onAdded(permissions) {
418 | console.debug('onAdded', permissions)
419 | await checkPerms()
420 | }
421 |
422 | /**
423 | * Permissions On Added Callback
424 | * @param permissions
425 | */
426 | export async function onRemoved(permissions) {
427 | console.debug('onRemoved', permissions)
428 | await checkPerms()
429 | }
430 |
431 | /**
432 | * @function detectBrowser
433 | * @typedef {Object} Browser
434 | * @property {String} Browser.name
435 | * @property {String} Browser.id
436 | * @property {String} Browser.class
437 | * @return {Browser}
438 | */
439 | export function detectBrowser() {
440 | const browser = {}
441 | if (!navigator?.userAgent) {
442 | return browser
443 | }
444 | if (navigator.userAgent.includes('Firefox/')) {
445 | // console.debug('Detected Browser: Firefox')
446 | browser.name = 'Firefox'
447 | browser.id = 'firefox'
448 | } else if (navigator.userAgent.includes('Edg/')) {
449 | // console.debug('Detected Browser: Edge')
450 | browser.name = 'Edge'
451 | browser.id = 'edge'
452 | } else if (navigator.userAgent.includes('OPR/')) {
453 | // console.debug('Detected Browser: Opera')
454 | browser.name = 'Opera'
455 | browser.id = 'chrome'
456 | } else {
457 | // console.debug('Detected Browser: Chrome')
458 | browser.name = 'Chrome'
459 | browser.id = 'chrome'
460 | }
461 | return browser
462 | }
463 |
464 | /**
465 | * @function updateBrowser
466 | * @return {Promise}
467 | */
468 | export function updateBrowser() {
469 | let selector = '.chrome'
470 | // noinspection JSUnresolvedReference
471 | if (typeof browser !== 'undefined') {
472 | selector = '.firefox'
473 | }
474 | document
475 | .querySelectorAll(selector)
476 | .forEach((el) => el.classList.remove('d-none'))
477 | }
478 |
--------------------------------------------------------------------------------
/src/js/extract.js:
--------------------------------------------------------------------------------
1 | // JS Injected to Extract Links
2 |
3 | if (!window.injected) {
4 | console.log('Injected: extract.js')
5 | chrome.runtime.onMessage.addListener(onMessage)
6 | window.injected = true
7 | }
8 |
9 | /**
10 | * Handle Messages
11 | * @function onMessage
12 | * @param {String} message
13 | * @param {MessageSender} sender
14 | * @param {Function} sendResponse
15 | */
16 | function onMessage(message, sender, sendResponse) {
17 | console.debug(`onMessage: message: ${message}`)
18 | if (message === 'all') {
19 | sendResponse(extractAllLinks())
20 | } else if (message === 'selection') {
21 | sendResponse(extractSelection())
22 | } else {
23 | console.warn('Unknown message:', message)
24 | }
25 | }
26 |
27 | // /**
28 | // * Extract links
29 | // * @function extractAllLinks
30 | // * @return {Array}
31 | // */
32 | // function extractAllLinks() {
33 | // console.debug('extractAllLinks')
34 | // const links = []
35 | // for (const element of document.links) {
36 | // if (element.href) {
37 | // pushElement(links, element)
38 | // }
39 | // }
40 | // console.debug('links:', links)
41 | // return links
42 | // }
43 |
44 | /**
45 | * Extract links
46 | * @function extractAllLinks
47 | * @return {Object[]}
48 | */
49 | function extractAllLinks() {
50 | console.debug('extractAllLinks')
51 | const links = findLinks(document)
52 | console.debug('links:', links)
53 | return links
54 | }
55 |
56 | /**
57 | * Recursively Find Links from shadowRoot
58 | * @function findLinks
59 | * @param {Document|ShadowRoot} root
60 | * @return {Object[]}
61 | */
62 | function findLinks(root) {
63 | // console.debug('findLinks:', root)
64 | const links = []
65 | if (root.querySelectorAll) {
66 | root.querySelectorAll('a, area').forEach((el) => {
67 | pushElement(links, el)
68 | })
69 | }
70 | const roots = Array.from(root.querySelectorAll('*')).filter(
71 | (el) => el.shadowRoot
72 | )
73 | roots.forEach((el) => {
74 | links.push(...findLinks(el.shadowRoot))
75 | })
76 | return links
77 | }
78 |
79 | /**
80 | * A Function
81 | * @function extractSelection
82 | * @return {Object[]}
83 | */
84 | function extractSelection() {
85 | console.debug('extractSelection')
86 | const links = []
87 | const selection = window.getSelection()
88 | console.debug('selection:', selection)
89 | if (selection?.type !== 'Range') {
90 | console.log('No selection or wrong selection.type')
91 | return links
92 | }
93 | for (let i = 0; i < selection.rangeCount; i++) {
94 | const ancestor = selection.getRangeAt(i).commonAncestorContainer
95 | if (ancestor.nodeName === '#text') {
96 | continue
97 | }
98 | // console.debug('ancestor:', ancestor)
99 | ancestor?.querySelectorAll('a, area')?.forEach((el) => {
100 | if (selection.containsNode(el, true)) {
101 | // console.debug('el:', el)
102 | pushElement(links, el)
103 | }
104 | })
105 | }
106 | console.debug('links:', links)
107 | return links
108 | }
109 |
110 | /**
111 | * Add Element to Array
112 | * @function pushElement
113 | * @param {Object[]} array
114 | * @param {HTMLAnchorElement} element
115 | */
116 | function pushElement(array, element) {
117 | // console.debug('element:', element)
118 | try {
119 | if (element.href) {
120 | const data = {
121 | href: decodeURI(element.href),
122 | text: element.textContent?.trim(),
123 | title: element.title,
124 | label: element.ariaLabel || '',
125 | rel: element.rel,
126 | target: element.target,
127 | origin: element.origin,
128 | }
129 | array.push(data)
130 | }
131 | } catch (e) {
132 | console.log(e)
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/js/lazy.js:
--------------------------------------------------------------------------------
1 | // JS for lazy.html
2 |
3 | const searchParams = new URLSearchParams(window.location.search)
4 | const url = new URL(searchParams.get('url'))
5 |
6 | // document.title = `${url.host}${url.pathname}`
7 | // const link = document.createElement('link')
8 | // link.rel = 'icon'
9 | // link.href = `${url.origin}/favicon.ico`
10 | // document.head.appendChild(link)
11 |
12 | chrome.storage.sync.get(['options']).then((items) => {
13 | console.debug('options:', items.options)
14 | // if (items.options.lazyFavicon) {
15 | // const urlPath = `${url.host}${url.pathname}`
16 | let title = items.options.lazyTitle
17 | title = title.replaceAll('{host}', url.host)
18 | title = title.replaceAll('{pathname}', url.pathname)
19 | console.debug('title:', title)
20 | document.title = title
21 | // }
22 | if (items.options.lazyFavicon) {
23 | // const link = document.createElement('link')
24 | const link = document.querySelector('link[rel="icon"]')
25 | console.debug('link:', link)
26 | // link.rel = 'icon'
27 | if (items.options.radioFavicon === 'default') {
28 | link.href = `${url.origin}/favicon.ico`
29 | } else {
30 | const path = `/images/lazy/${items.options.radioFavicon}.png`
31 | link.href = chrome.runtime.getURL(path)
32 | }
33 | console.debug('link.href:', link.href)
34 | // document.head.appendChild(link)
35 | }
36 | })
37 |
38 | let theme = localStorage.getItem('theme')
39 | if (!theme || theme === 'auto') {
40 | theme = window.matchMedia('(prefers-color-scheme: dark)').matches
41 | ? 'dark'
42 | : 'light'
43 | }
44 | if (theme === 'dark') {
45 | document.body.style.backgroundColor = '#1c1b21'
46 | } else {
47 | document.body.style.backgroundColor = '#fff'
48 | }
49 |
50 | window.addEventListener('focus', () => {
51 | console.log('url:', url)
52 | window.location = url.href
53 | })
54 |
--------------------------------------------------------------------------------
/src/js/links.js:
--------------------------------------------------------------------------------
1 | // JS for links.html
2 |
3 | import { openURL, textFileDownload } from './exports.js'
4 |
5 | window.addEventListener('keydown', handleKeyboard)
6 | document.addEventListener('DOMContentLoaded', initLinks)
7 | document.getElementById('findReplace').addEventListener('submit', findReplace)
8 | document.getElementById('reReset').addEventListener('click', reResetClick)
9 | document
10 | .getElementsByName('reType')
11 | .forEach((el) => el.addEventListener('change', reTypeChange))
12 | document
13 | .querySelectorAll('.copy-links')
14 | .forEach((el) => el.addEventListener('click', copyLinksClick))
15 | document
16 | .querySelectorAll('.download-file')
17 | .forEach((el) => el.addEventListener('click', downloadFileClick))
18 | document
19 | .querySelectorAll('.open-in-tabs')
20 | .forEach((el) => el.addEventListener('click', openLinksClick))
21 | document
22 | .querySelectorAll('[data-bs-toggle="tooltip"]')
23 | .forEach((el) => new bootstrap.Tooltip(el))
24 |
25 | const findCollapse = document.getElementById('findCollapse')
26 | findCollapse.addEventListener('show.bs.collapse', () => {
27 | console.debug('Show Collapse')
28 | localStorage.setItem('findCollapse', 'shown')
29 | })
30 | findCollapse.addEventListener('hide.bs.collapse', () => {
31 | console.debug('Hide Collapse')
32 | localStorage.setItem('findCollapse', 'hidden')
33 | })
34 |
35 | const urlParams = new URLSearchParams(window.location.search)
36 |
37 | // noinspection JSUnusedGlobalSymbols
38 | const dtOptions = {
39 | info: false,
40 | processing: true,
41 | responsive: true,
42 | pageLength: -1,
43 | lengthMenu: [
44 | [-1, 10, 25, 50, 100, 250, 500, 1000],
45 | ['All', 10, 25, 50, 100, 250, 500, 1000],
46 | ],
47 | language: {
48 | emptyTable: '',
49 | lengthMenu: '_MENU_ Links',
50 | search: 'Filter:',
51 | searchPlaceholder: 'Type to Filter...',
52 | zeroRecords: '',
53 | },
54 | columnDefs: [
55 | { targets: 0, render: genUrl, visible: true, className: '' },
56 | { targets: '_all', visible: false },
57 | ],
58 | search: {
59 | regex: true,
60 | },
61 | stateSave: false,
62 | stateSaveParams: function (settings, data) {
63 | // noinspection JSValidateTypes
64 | data.search.search = ''
65 | },
66 | }
67 |
68 | const linksOptions = {
69 | columns: [
70 | { data: 'href' },
71 | { data: 'text' },
72 | { data: 'title' },
73 | { data: 'label' },
74 | { data: 'rel' },
75 | { data: 'target' },
76 | ],
77 | layout: {
78 | top2Start: {
79 | buttons: {
80 | dom: {
81 | button: {
82 | className: 'btn btn-sm',
83 | },
84 | },
85 | buttons: [
86 | {
87 | extend: 'colvis',
88 | text: 'Show Additional Data',
89 | className: 'btn-primary',
90 | columns: [1, 2, 3, 4, 5],
91 | postfixButtons: ['colvisRestore'],
92 | },
93 | {
94 | extend: 'copy',
95 | text: 'Copy Table',
96 | className: 'btn-outline-primary',
97 | title: null,
98 | exportOptions: {
99 | orthogonal: 'export',
100 | columns: ':visible',
101 | },
102 | },
103 | {
104 | extend: 'csv',
105 | text: 'CSV Export',
106 | className: 'btn-outline-primary',
107 | title: 'links',
108 | exportOptions: {
109 | orthogonal: 'export',
110 | columns: ':visible',
111 | },
112 | },
113 | ],
114 | },
115 | },
116 | topStart: 'pageLength',
117 | topEnd: 'search',
118 | },
119 | }
120 |
121 | let linksTable
122 | let domainsTable
123 |
124 | function genUrl(url) {
125 | const link = document.createElement('a')
126 | link.text = url
127 | link.href = url
128 | link.title = url
129 | link.dataset.original = url
130 | link.target = '_blank'
131 | link.rel = 'noopener'
132 | return link
133 | }
134 |
135 | // Manually Set Theme for DataTables
136 | let prefers = window.matchMedia('(prefers-color-scheme: dark)').matches
137 | ? 'dark'
138 | : 'light'
139 | let html = document.querySelector('html')
140 | html.classList.add(prefers)
141 | html.setAttribute('data-bs-theme', prefers)
142 |
143 | /**
144 | * DOMContentLoaded - Initialize Links
145 | * @function initLinks
146 | */
147 | async function initLinks() {
148 | console.debug('initLinks:', urlParams)
149 | try {
150 | const tabIds = urlParams.get('tabs')
151 | const tabs = tabIds?.split(',')
152 | const selection = urlParams.has('selection')
153 |
154 | const allLinks = []
155 | if (tabs?.length) {
156 | console.debug('tabs:', tabs)
157 | for (const tabId of tabs) {
158 | const action = selection ? 'selection' : 'all'
159 | const links = await chrome.tabs.sendMessage(
160 | parseInt(tabId),
161 | action
162 | )
163 | allLinks.push(...links)
164 | }
165 | } else {
166 | const { links } = await chrome.storage.local.get(['links'])
167 | allLinks.push(...links)
168 | }
169 | await processLinks(allLinks)
170 | } catch (e) {
171 | console.warn('error:', e)
172 | alert('Error Processing Results. See Console for More Details...')
173 | window.close()
174 | }
175 |
176 | const collapse = localStorage.getItem('findCollapse')
177 | console.debug('collapse:', collapse)
178 | if (collapse === 'shown') {
179 | // const bsCollapse = new bootstrap.Collapse(findCollapse, {
180 | // toggle: false,
181 | // })
182 | // bsCollapse.show()
183 | findCollapse.classList.add('show')
184 | }
185 | const type = localStorage.getItem('reType')
186 | console.debug('type:', type)
187 | if (type) {
188 | document.getElementById(type).checked = true
189 | }
190 |
191 | const { patterns } = await chrome.storage.sync.get(['patterns'])
192 | if (patterns.length) {
193 | const datalist = document.createElement('datalist')
194 | datalist.id = 'filters-list'
195 | for (const filter of patterns) {
196 | // console.debug('filter:', filter)
197 | const option = document.createElement('option')
198 | option.value = filter
199 | datalist.appendChild(option)
200 | }
201 | document.body.appendChild(datalist)
202 | const inputs = document.querySelectorAll('.dt-search > input')
203 | for (const input of inputs) {
204 | // console.debug('input:', input)
205 | input.setAttribute('list', 'filters-list')
206 | }
207 | }
208 | window.dispatchEvent(new Event('resize'))
209 | }
210 |
211 | /**
212 | * Process Links
213 | * NOTE: Look into simplifying this function
214 | * @function processLinks
215 | * @param {Array} links
216 | */
217 | async function processLinks(links) {
218 | console.debug('processLinks:', links)
219 | const urlFilter = urlParams.get('filter')
220 | const onlyDomains = urlParams.has('domains')
221 | const { options } = await chrome.storage.sync.get(['options'])
222 | // console.debug('options:', options)
223 |
224 | // Set Table Options
225 | if (options.linksTruncate) {
226 | // console.debug('linksTruncate')
227 | dtOptions.columnDefs[0].className += ' truncate'
228 | window.addEventListener('resize', windowResize)
229 | document.querySelectorAll('table').forEach((table) => {
230 | table.style.tableLayout = 'fixed'
231 | })
232 | }
233 | if (options.linksNoWrap) {
234 | // console.debug('linksNoWrap')
235 | dtOptions.columnDefs[0].className += ' text-nowrap'
236 | }
237 | // console.debug('table-responsive')
238 | // document.querySelectorAll('.table-wrapper').forEach((el) => {
239 | // el.classList.add('table-responsive')
240 | // })
241 |
242 | // Filter links by ://
243 | if (options.defaultFilter) {
244 | links = links.filter((link) => link.href.lastIndexOf('://', 10) > 0)
245 | }
246 |
247 | // Remove duplicate and sort links
248 | if (options.removeDuplicates) {
249 | const hrefs = []
250 | links = links.filter((value) => {
251 | if (hrefs.includes(value.href)) {
252 | return false
253 | } else {
254 | hrefs.push(value.href)
255 | return true
256 | }
257 | })
258 | }
259 |
260 | // Enable stateSave in datatables
261 | if (options.saveState) {
262 | dtOptions.stateSave = true
263 | }
264 |
265 | // Filter links based on pattern
266 | if (urlFilter) {
267 | const re = new RegExp(urlFilter, options.flags)
268 | console.debug(`Filtering with regex: ${re} / ${options.flags}`)
269 | links = links.filter((item) => item.href.match(re))
270 | }
271 |
272 | // If no items, alert and return
273 | if (!links.length) {
274 | alert('No Results')
275 | return window.close()
276 | }
277 |
278 | // Update links if onlyDomains is not set
279 | if (!onlyDomains) {
280 | document.getElementById('links-total').textContent =
281 | links.length.toString()
282 | const linksElements = document.querySelectorAll('.links')
283 | linksElements.forEach((el) => el.classList.remove('d-none'))
284 |
285 | let opts = { ...dtOptions, ...linksOptions }
286 | linksTable = new DataTable('#links-table', opts)
287 | console.debug('links:', links)
288 | linksTable.on('draw.dt', debounce(dtDraw, 150))
289 | linksTable.on('column-visibility.dt', dtVisibility)
290 | linksTable.rows.add(links).draw()
291 | }
292 |
293 | // Extract domains from items, sort, and remove null
294 | let domains = [...new Set(links.map((link) => link.origin))]
295 | domains = domains.filter(function (el) {
296 | return el != null
297 | })
298 | domains = domains.map((domain) => [domain])
299 | document.getElementById('domains-total').textContent =
300 | domains.length.toString()
301 | if (domains.length) {
302 | const domainsElements = document.querySelectorAll('.domains')
303 | domainsElements.forEach((el) => el.classList.remove('d-none'))
304 | domainsTable = new DataTable('#domains-table', dtOptions)
305 | console.debug('domains:', domains)
306 | domainsTable.on('draw.dt', debounce(dtDraw, 150))
307 | domainsTable.rows.add(domains).draw()
308 | }
309 |
310 | // Hide Loading message
311 | document.getElementById('loading-message').classList.add('d-none')
312 |
313 | // Modifications for Android
314 | const platform = await chrome.runtime.getPlatformInfo()
315 | if (platform.os === 'android') {
316 | // Consider always applying table-responsive to table-wrapper
317 | document.querySelectorAll('.table-wrapper').forEach((el) => {
318 | el.classList.add('table-responsive')
319 | })
320 | document.querySelectorAll('.keyboard').forEach((el) => {
321 | el.classList.add('d-none')
322 | })
323 | }
324 | }
325 |
326 | function windowResize() {
327 | // console.debug('windowResize')
328 | linksTable?.columns.adjust().draw()
329 | domainsTable?.columns.adjust().draw()
330 | }
331 |
332 | function dtDraw(event) {
333 | document.getElementById(event.target.dataset.counter).textContent = event.dt
334 | .rows(':visible')
335 | .count()
336 | }
337 |
338 | function dtVisibility(e, settings, column, state) {
339 | settings.aoColumns[column].bSearchable = state
340 | linksTable.rows().invalidate().draw()
341 | }
342 |
343 | /**
344 | * Find and Replace Submit Callback
345 | * @function findReplace
346 | * @param {SubmitEvent} event
347 | */
348 | async function findReplace(event) {
349 | console.debug('findReplace:', event)
350 | event.preventDefault()
351 | const find = event.target.elements.reFind.value
352 | const replace = event.target.elements.reReplace.value
353 | console.debug('find:', find)
354 | console.debug('replace:', replace)
355 | if (!find) {
356 | showToast('You must enter a find value.', 'danger')
357 | return
358 | }
359 | const re = new RegExp(find, 'gm')
360 | console.debug('re:', re)
361 | // const type = document.querySelector('input[name="reType"]:checked').value
362 | const type = event.target.elements.reType.value
363 | console.debug('type:', type)
364 | const links = document.getElementById('links-body').querySelectorAll('a')
365 | let count = 0
366 | for (const link of links) {
367 | const before = link.href
368 | console.debug('before:', before)
369 | if (type === 'normal') {
370 | const result = link.href.replace(find, replace)
371 | console.debug('result:', result)
372 | link.href = result
373 | link.textContent = result
374 | } else if (type === 'regex') {
375 | const result = link.href.replace(re, replace)
376 | console.debug('result:', result)
377 | link.href = result
378 | link.textContent = result
379 | } else if (type === 'groups') {
380 | const matches = link.href.match(re)
381 | console.debug('matches:', matches)
382 | if (matches) {
383 | matches.forEach((match, i) => {
384 | console.debug(`match ${i}:`, match)
385 | const result = replace.replace(`$${i + 1}`, match)
386 | console.debug('result:', result)
387 | link.href = result
388 | link.textContent = result
389 | })
390 | }
391 | }
392 | const after = link.getAttribute('href')
393 | console.debug('after:', after)
394 | if (after !== before) {
395 | count++
396 | }
397 | }
398 | const status = count ? 'success' : 'warning'
399 | showToast(`Updated ${count} Links.`, status)
400 | if (count) {
401 | document.getElementById('reReset').classList.remove('disabled')
402 | }
403 | }
404 |
405 | /**
406 | * Reset Regex Click Callback
407 | * @function reResetClick
408 | * @param {MouseEvent} event
409 | */
410 | async function reResetClick(event) {
411 | console.debug('reResetClick:', event)
412 | event.currentTarget.classList.add('disabled')
413 | document
414 | .getElementById('links-body')
415 | .querySelectorAll('a')
416 | .forEach((el) => {
417 | console.debug('el.dataset.original:', el.dataset.original)
418 | el.href = el.dataset.original
419 | el.textContent = el.dataset.original
420 | })
421 | showToast('Links reset to original values.')
422 | }
423 |
424 | /**
425 | * Regex Type Change Callback
426 | * @function reTypeChange
427 | * @param {InputEvent} event
428 | */
429 | async function reTypeChange(event) {
430 | // console.debug('reTypeChange:', event)
431 | console.debug('reTypeChange id:', event.target.id)
432 | localStorage.setItem('reType', event.target.id)
433 | }
434 |
435 | /**
436 | * Copy links Button Click Callback
437 | * @function copyLinksClick
438 | * @param {MouseEvent} event
439 | */
440 | function copyLinksClick(event) {
441 | console.debug('copyLinksClick:', event)
442 | event.preventDefault()
443 | const links = getTableLinks('#links-body')
444 | // console.debug('links:', links)
445 | if (links) {
446 | // noinspection JSIgnoredPromiseFromCall
447 | navigator.clipboard.writeText(links)
448 | showToast('Links Copied', 'success')
449 | } else {
450 | showToast('No Links to Copy', 'warning')
451 | }
452 | }
453 |
454 | /**
455 | * Download Links Button Click Callback
456 | * @function downloadFileClick
457 | * @param {MouseEvent} event
458 | */
459 | function downloadFileClick(event) {
460 | console.debug('downloadFileClick:', event)
461 | const closest = event.target?.closest('button')
462 | const links = getTableLinks(closest?.dataset?.target)
463 | // console.debug('links:', links)
464 | const name = closest.dataset.filename || 'links.txt'
465 | // console.debug('name:', name)
466 | if (links) {
467 | textFileDownload(name, links)
468 | showToast('Download Started.', 'success')
469 | } else {
470 | showToast('Nothing to Download.', 'warning')
471 | }
472 | }
473 |
474 | /**
475 | * Open links Button Click Callback
476 | * @function openLinksClick
477 | * @param {MouseEvent} event
478 | */
479 | async function openLinksClick(event) {
480 | console.debug('openLinksClick:', event)
481 | const closest = event.target?.closest('button')
482 | const links = getTableLinks(closest?.dataset?.target)
483 | // console.debug('links:', links)
484 | const { options } = await chrome.storage.sync.get(['options'])
485 | if (links) {
486 | links.split('\n').forEach(function (url) {
487 | openURL(url, options.lazyLoad)
488 | })
489 | } else {
490 | showToast('No Links to Open.', 'warning')
491 | }
492 | }
493 |
494 | /**
495 | * Open links Button Click Callback
496 | * @function getTableLinks
497 | * @param {String} selector
498 | * @return {String}
499 | */
500 | function getTableLinks(selector) {
501 | console.debug('getTableLinks:', selector)
502 | const table = document.querySelector(selector)
503 | const urls = []
504 | for (const row of table.rows) {
505 | // noinspection JSUnresolvedReference
506 | urls.push(row.cells[0].textContent.trim())
507 | }
508 | return urls.join('\n').trim()
509 | }
510 |
511 | /**
512 | * Handle Keyboard Shortcuts Callback
513 | * @function handleKeyboard
514 | * @param {KeyboardEvent} e
515 | */
516 | function handleKeyboard(e) {
517 | // console.debug('handleKeyboard:', e)
518 | if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.repeat) {
519 | return
520 | }
521 | if (e.code === 'Escape') {
522 | e.preventDefault()
523 | e.target?.blur()
524 | }
525 | if (['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'].includes(e.target.tagName)) {
526 | return
527 | }
528 | if (['KeyZ', 'KeyK'].includes(e.code)) {
529 | bootstrap.Modal.getOrCreateInstance('#keybinds-modal').toggle()
530 | } else if (['KeyC', 'KeyL'].includes(e.code)) {
531 | document.getElementById('copy-links').click()
532 | } else if (['KeyD', 'KeyM'].includes(e.code)) {
533 | document.getElementById('copy-domains').click()
534 | } else if (['KeyF', 'KeyJ'].includes(e.code)) {
535 | e.preventDefault()
536 | const input = document.getElementById('dt-search-0')
537 | input?.scrollIntoView()
538 | input?.focus()
539 | input?.select()
540 | } else if (['KeyG', 'KeyH'].includes(e.code)) {
541 | e.preventDefault()
542 | const input = document.getElementById('dt-search-1')
543 | input?.scrollIntoView()
544 | input?.focus()
545 | input?.select()
546 | } else if (['KeyT', 'KeyO'].includes(e.code)) {
547 | // noinspection JSIgnoredPromiseFromCall
548 | chrome.runtime.openOptionsPage()
549 | }
550 | }
551 |
--------------------------------------------------------------------------------
/src/js/main.js:
--------------------------------------------------------------------------------
1 | // JS for links.html and options.html
2 |
3 | const backToTop = document.getElementById('back-to-top')
4 | if (backToTop) {
5 | window.addEventListener('scroll', debounce(onScroll))
6 | backToTop.addEventListener('click', () => {
7 | document.body.scrollTop = 0
8 | document.documentElement.scrollTop = 0
9 | })
10 | }
11 |
12 | // noinspection TypeScriptUMDGlobal
13 | if (typeof ClipboardJS !== 'undefined') {
14 | document
15 | .querySelectorAll('.clip')
16 | .forEach((el) =>
17 | el.addEventListener('click', (e) => e.preventDefault())
18 | )
19 | // noinspection TypeScriptUMDGlobal
20 | const clipboard = new ClipboardJS('.clip')
21 | clipboard.on('success', function (event) {
22 | // console.debug('clipboard.success:', event)
23 | // const text = event.text
24 | // console.debug(`text: "${text}"`)
25 | // noinspection JSUnresolvedReference
26 | if (event.trigger.dataset.toast) {
27 | // noinspection JSUnresolvedReference
28 | showToast(event.trigger.dataset.toast, 'success')
29 | } else {
30 | showToast('Copied to Clipboard', 'success')
31 | }
32 | })
33 | clipboard.on('error', function (event) {
34 | console.debug('clipboard.error:', event)
35 | showToast('Clipboard Copy Failed', 'warning')
36 | })
37 | }
38 |
39 | $('.form-control').on('change input', function () {
40 | $(this).removeClass('is-invalid')
41 | })
42 |
43 | /**
44 | * On Scroll Callback
45 | * @function onScroll
46 | */
47 | function onScroll() {
48 | if (
49 | document.body.scrollTop > 20 ||
50 | document.documentElement.scrollTop > 20
51 | ) {
52 | backToTop.style.display = 'block'
53 | } else {
54 | backToTop.style.display = 'none'
55 | }
56 | }
57 |
58 | /**
59 | * Show Bootstrap Toast
60 | * @function showToast
61 | * @param {String} message
62 | * @param {String} type
63 | */
64 | function showToast(message, type = 'primary') {
65 | console.debug(`showToast: ${type}: ${message}`)
66 | const clone = document.querySelector('#clones .toast')
67 | const container = document.getElementById('toast-container')
68 | if (!clone || !container) {
69 | return console.warn('Missing clone or container:', clone, container)
70 | }
71 | const element = clone.cloneNode(true)
72 | element.querySelector('.toast-body').textContent = message
73 | element.classList.add(`text-bg-${type}`)
74 | container.appendChild(element)
75 | const toast = new bootstrap.Toast(element)
76 | element.addEventListener('mousemove', () => toast.hide())
77 | toast.show()
78 | }
79 |
80 | /**
81 | * DeBounce Function
82 | * @function debounce
83 | * @param {Function} fn
84 | * @param {Number} timeout
85 | */
86 | function debounce(fn, timeout = 250) {
87 | let timeoutID
88 | return (...args) => {
89 | clearTimeout(timeoutID)
90 | timeoutID = setTimeout(() => fn(...args), timeout)
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/js/options.js:
--------------------------------------------------------------------------------
1 | // JS for options.html
2 |
3 | import {
4 | checkPerms,
5 | exportClick,
6 | grantPerms,
7 | importChange,
8 | importClick,
9 | onAdded,
10 | onRemoved,
11 | revokePerms,
12 | saveOptions,
13 | updateBrowser,
14 | updateManifest,
15 | updateOptions,
16 | } from './exports.js'
17 |
18 | chrome.storage.onChanged.addListener(onChanged)
19 | chrome.permissions.onAdded.addListener(onAdded)
20 | chrome.permissions.onRemoved.addListener(onRemoved)
21 |
22 | document.addEventListener('DOMContentLoaded', initOptions)
23 | // document.addEventListener('drop', drop)
24 | // document.addEventListener('dragover', dragOver)
25 | document.addEventListener('blur', filterClick)
26 | document.addEventListener('click', filterClick)
27 | document.getElementById('update-filter').addEventListener('submit', filterClick)
28 | document.getElementById('filters-form').addEventListener('submit', addFilter)
29 | document.getElementById('copy-support').addEventListener('click', copySupport)
30 | document
31 | .querySelectorAll('.revoke-permissions')
32 | .forEach((el) => el.addEventListener('click', revokePerms))
33 | document
34 | .querySelectorAll('.grant-permissions')
35 | .forEach((el) => el.addEventListener('click', grantPerms))
36 | document
37 | .querySelectorAll('[data-reset-input]')
38 | .forEach((el) => el.addEventListener('click', resetInput))
39 | document
40 | .querySelectorAll('[data-insert-input]')
41 | .forEach((el) => el.addEventListener('click', insertInput))
42 | document
43 | .querySelectorAll('#options-form input, select')
44 | .forEach((el) => el.addEventListener('change', saveOptions))
45 | document
46 | .getElementById('options-form')
47 | .addEventListener('submit', (e) => e.preventDefault())
48 | document
49 | .querySelectorAll('[data-bs-toggle="tooltip"]')
50 | .forEach((el) => new bootstrap.Tooltip(el))
51 | document
52 | .getElementById('chrome-shortcuts')
53 | ?.addEventListener('click', () =>
54 | chrome.tabs.update({ url: 'chrome://extensions/shortcuts' })
55 | )
56 |
57 | document.getElementById('export-data').addEventListener('click', exportClick)
58 | document.getElementById('import-data').addEventListener('click', importClick)
59 | document.getElementById('import-input').addEventListener('change', importChange)
60 |
61 | const filtersTbody = document.querySelector('#filters-table tbody')
62 |
63 | /**
64 | * DOMContentLoaded - Initialize Options
65 | * @function initOptions
66 | */
67 | async function initOptions() {
68 | console.debug('initOptions')
69 | // noinspection ES6MissingAwait
70 | updateManifest()
71 | // noinspection ES6MissingAwait
72 | updateBrowser()
73 | // noinspection ES6MissingAwait
74 | setShortcuts([
75 | '_execute_action',
76 | 'extractAll',
77 | 'extractSelection',
78 | 'copyAll',
79 | 'copySelection',
80 | ])
81 | // noinspection ES6MissingAwait
82 | checkPerms()
83 | chrome.storage.sync.get(['options', 'patterns']).then((items) => {
84 | // console.debug('options:', items.options)
85 | updateOptions(items.options)
86 | updateTable(items.patterns)
87 | })
88 | }
89 |
90 | /**
91 | * Update Filters Table with data
92 | * @function updateTable
93 | * @param {Object} data
94 | */
95 | function updateTable(data) {
96 | const faTrash = document.querySelector('#clones > .fa-trash-can')
97 | const faGrip = document.querySelector('#clones > .fa-grip')
98 | filtersTbody.innerHTML = ''
99 | data.forEach((value, i) => {
100 | const row = filtersTbody.insertRow()
101 | // TODO: Use Better ID or Dataset
102 | row.id = i
103 |
104 | // TRASH
105 | const button = document.createElement('a')
106 | const svg = faTrash.cloneNode(true)
107 | button.appendChild(svg)
108 | button.title = 'Delete'
109 | button.dataset.value = value
110 | button.classList.add('link-danger')
111 | button.setAttribute('role', 'button')
112 | button.addEventListener('click', deleteFilter)
113 | const cell1 = row.insertCell()
114 | cell1.classList.add('text-center', 'align-middle')
115 | // cell1.dataset.idx = i.toString()
116 | cell1.appendChild(button)
117 |
118 | // FILTER
119 | const link = genFilterLink(i.toString(), value)
120 | const cell2 = row.insertCell()
121 | cell2.id = `td-filter-${i}`
122 | cell2.dataset.idx = i.toString()
123 | cell2.classList.add('text-break')
124 | cell2.title = 'Edit'
125 | cell2.setAttribute('role', 'button')
126 | cell2.appendChild(link)
127 |
128 | // GRIP
129 | const cell3 = row.insertCell()
130 | cell3.classList.add('text-center', 'align-middle', 'link-body-emphasis')
131 | cell3.setAttribute('role', 'button')
132 | const grip = faGrip.cloneNode(true)
133 | grip.title = 'Drag'
134 | cell3.appendChild(grip)
135 | cell3.setAttribute('draggable', 'true')
136 | cell3.addEventListener('dragstart', dragStart)
137 | })
138 | filtersTbody.addEventListener('dragover', dragOver)
139 | filtersTbody.addEventListener('dragleave', dragEnd)
140 | filtersTbody.addEventListener('dragend', dragEnd)
141 | filtersTbody.addEventListener('drop', drop)
142 | }
143 |
144 | /**
145 | * Add Filter Callback
146 | * @function addFilter
147 | * @param {SubmitEvent} event
148 | */
149 | async function addFilter(event) {
150 | console.debug('addFilter:', event)
151 | event.preventDefault()
152 | const input = event.target.elements['add-filter']
153 | const filter = input.value
154 | if (filter) {
155 | console.debug('%cfilter:', 'color: Lime', filter)
156 | const { patterns } = await chrome.storage.sync.get(['patterns'])
157 | if (!patterns.includes(filter)) {
158 | patterns.push(filter)
159 | // console.debug('patterns:', patterns)
160 | await chrome.storage.sync.set({ patterns })
161 | updateTable(patterns)
162 | input.value = ''
163 | showToast(`Added Filter: ${filter}`, 'success')
164 | } else {
165 | showToast(`Filter Exists: ${filter}`, 'warning')
166 | }
167 | }
168 | input.focus()
169 | }
170 |
171 | /**
172 | * Delete Filter
173 | * @function deleteFilter
174 | * @param {MouseEvent} event
175 | * @param {String} [index]
176 | */
177 | async function deleteFilter(event, index = undefined) {
178 | console.debug('deleteFilter:', index, event)
179 | event.preventDefault()
180 | const filter = event.currentTarget?.dataset?.value
181 | console.debug('%cfilter:', 'color: Yellow', filter)
182 | const { patterns } = await chrome.storage.sync.get(['patterns'])
183 | // console.debug('patterns:', patterns)
184 | if (!index) {
185 | // const anchor = event.target.closest('a')
186 | if (filter && patterns.includes(filter)) {
187 | index = patterns.indexOf(filter)
188 | }
189 | }
190 | console.debug('index:', index)
191 | if (index !== undefined) {
192 | const name = patterns[index]
193 | patterns.splice(index, 1)
194 | await chrome.storage.sync.set({ patterns })
195 | // console.debug('patterns:', patterns)
196 | updateTable(patterns)
197 | // document.getElementById('add-filter').focus()
198 | showToast(`Removed Filter: ${name}`, 'info')
199 | }
200 | }
201 |
202 | /**
203 | * Reset Title Input Callback
204 | * @function resetInput
205 | * @param {InputEvent} event
206 | */
207 | async function resetInput(event) {
208 | console.debug('resetInput:', event)
209 | const target = event.currentTarget
210 | console.debug('target:', target)
211 | event.preventDefault()
212 | const input = document.getElementById(target.dataset.resetInput)
213 | console.debug('input:', input)
214 | input.value = target.dataset.value
215 | input.classList.remove('is-invalid')
216 | input.focus()
217 | const changeEvent = new Event('change')
218 | input.dispatchEvent(changeEvent)
219 | }
220 |
221 | /**
222 | * Insert Value into Input Callback
223 | * @function insertInput
224 | * @param {InputEvent} event
225 | */
226 | async function insertInput(event) {
227 | console.debug('insertInput:', event)
228 | const target = event.currentTarget
229 | event.preventDefault()
230 | console.debug('target:', target)
231 | const id = target.dataset.target
232 | console.debug('id:', id)
233 | const value = target.dataset.value
234 | console.debug('value:', value)
235 | const input = document.getElementById(id)
236 | console.debug('input:', input)
237 | const pos = input.selectionStart
238 | console.debug('pos:', pos)
239 | const cur = input.value
240 | console.debug('cur:', cur)
241 | input.value = [cur.slice(0, pos), value, cur.slice(pos)].join('')
242 | const newPos = pos + value.length
243 | input.focus()
244 | input.setSelectionRange(newPos, newPos)
245 | await saveOptions(event)
246 | }
247 |
248 | let row
249 | let last = -1
250 |
251 | /**
252 | * Drag Start Event Callback
253 | * Trigger filterClick to prevent dragging while editing
254 | * @function dragStart
255 | * @param {MouseEvent} event
256 | */
257 | async function dragStart(event) {
258 | console.debug('%cdragStart:', 'color: Aqua', event)
259 | // editing = false
260 | await filterClick(event)
261 | row = event.target.closest('tr')
262 | }
263 |
264 | /**
265 | * Drag Over Event Callback
266 | * @function dragOver
267 | * @param {MouseEvent} event
268 | */
269 | function dragOver(event) {
270 | // console.debug('dragOver:', event)
271 | // if (event.target.tagName === 'INPUT') {
272 | // return
273 | // }
274 | event.preventDefault()
275 | if (!row) {
276 | return // row not set on dragStart, so not a row being dragged
277 | }
278 | const tr = event.target.closest('tr')
279 | // console.debug('tr:', tr)
280 | if (tr?.id && tr.id !== last) {
281 | const el = document.getElementById(last)
282 | el?.classList.remove('table-group-divider')
283 | tr.classList.add('table-group-divider')
284 | last = tr.id
285 | }
286 | }
287 |
288 | function dragEnd() {
289 | // console.debug('dragEnd:', event)
290 | const el = document.getElementById(last)
291 | el?.classList.remove('table-group-divider')
292 | last = -1
293 | }
294 |
295 | async function drop(event) {
296 | console.debug('%cdrop:', 'color: Lime', event)
297 | // if (event.target.tagName === 'INPUT') {
298 | // return
299 | // }
300 | event.preventDefault()
301 | const tr = event.target.closest('tr')
302 | if (!row || !tr) {
303 | row = null
304 | return console.debug('%crow or tr undefined', 'color: Yellow')
305 | }
306 | tr.classList?.remove('table-group-divider')
307 | last = -1
308 | // console.debug(`row.id: ${row.id} - tr.id: ${tr.id}`)
309 | if (row.id === tr.id) {
310 | row = null
311 | return console.debug('%creturn on same row drop', 'color: Yellow')
312 | }
313 | filtersTbody.removeChild(row)
314 | filtersTbody.insertBefore(row, tr)
315 | const { patterns } = await chrome.storage.sync.get(['patterns'])
316 | // console.debug('patterns:', patterns)
317 | let source = parseInt(row.id)
318 | let target = parseInt(tr.id)
319 | if (source < target) {
320 | target -= 1
321 | }
322 | // console.debug(`Source: ${source} - Target: ${target}`)
323 | array_move(patterns, source, target)
324 | // console.debug('patterns:', patterns)
325 | await chrome.storage.sync.set({ patterns })
326 | row = null
327 | }
328 |
329 | /**
330 | * Note: Copied from Stack Overflow
331 | * @param {Array} arr
332 | * @param {Number} old_index
333 | * @param {Number} new_index
334 | */
335 | function array_move(arr, old_index, new_index) {
336 | if (new_index >= arr.length) {
337 | let k = new_index - arr.length + 1
338 | while (k--) {
339 | arr.push(undefined)
340 | }
341 | }
342 | arr.splice(new_index, 0, arr.splice(old_index, 1)[0])
343 | }
344 |
345 | /**
346 | * @param {String} idx
347 | * @param {String} value
348 | * @return {HTMLAnchorElement}
349 | */
350 | function genFilterLink(idx, value) {
351 | const link = document.createElement('a')
352 | // link.dataset.idx = idx
353 | link.text = value
354 | link.title = value
355 | link.classList.add(
356 | 'link-body-emphasis',
357 | 'link-underline',
358 | 'link-underline-opacity-0'
359 | )
360 | link.setAttribute('role', 'button')
361 | return link
362 | }
363 |
364 | let editing = false
365 |
366 | async function filterClick(event) {
367 | // console.debug('filterClick:', event)
368 | if (event.type === 'submit') {
369 | // NOTE: The submit event is also triggering a click event
370 | return event.preventDefault()
371 | }
372 | if (event.target?.classList?.contains('filter-edit')) {
373 | return console.debug('return on click in input')
374 | }
375 | let deleted
376 | let previous = editing
377 | if (editing !== false) {
378 | console.log(`%c-- saving: ${editing}`, 'color: DeepPink')
379 | deleted = await saveEditing(event, editing)
380 | editing = false
381 | }
382 | if (event.target?.closest) {
383 | const td = event.target?.closest('td')
384 | if (td?.dataset?.idx !== undefined) {
385 | let idx = td.dataset.idx
386 | if (deleted && parseInt(td.dataset.idx) > parseInt(previous)) {
387 | idx -= 1
388 | }
389 | console.log(`%c-- editing: ${idx}`, 'color: DeepPink')
390 | editing = idx
391 | beginEditing(event, editing)
392 | }
393 | }
394 | }
395 |
396 | /**
397 | * @function saveEditing
398 | * @param {MouseEvent} event
399 | * @param {String} idx
400 | * @return {Promise}
401 | */
402 | async function saveEditing(event, idx) {
403 | event.preventDefault() // block dragStart if editing
404 | const td = document.getElementById(`td-filter-${idx}`)
405 | console.debug(`%csaveEditInput: ${idx}`, 'color: SpringGreen', event, td)
406 | if (!td) {
407 | console.log(`%cTD Not Found: #td-filter-${idx}`, 'color: OrangeRed')
408 | return false
409 | }
410 |
411 | const input = td.querySelector('input')
412 | let value = input?.value
413 | console.log('value:', value)
414 | if (!value) {
415 | await deleteFilter(event, idx)
416 | return true
417 | }
418 |
419 | const { patterns } = await chrome.storage.sync.get(['patterns'])
420 | // console.debug('patterns:', patterns)
421 | if (value === patterns[idx]) {
422 | console.log(`%c-- unchanged: ${idx}`, 'color: DeepPink')
423 | } else if (patterns.includes(value)) {
424 | showToast('Filter Already Exists!', 'warning')
425 | console.debug('Value Already Exists!')
426 | value = patterns[idx]
427 | } else {
428 | console.log(
429 | `Updated idx "${idx}" from "${patterns[idx]}" to "${value}"`
430 | )
431 | patterns[idx] = value
432 | await chrome.storage.sync.set({ patterns })
433 | }
434 |
435 | const link = genFilterLink(idx, value)
436 | td.removeChild(input)
437 | td.appendChild(link)
438 | return false
439 | }
440 |
441 | /**
442 | * @function beginEditing
443 | * @param {MouseEvent} event
444 | * @param {String} idx
445 | */
446 | function beginEditing(event, idx) {
447 | const td = document.getElementById(`td-filter-${idx}`)
448 | console.debug(`addEditInput: ${idx}`, event, td)
449 | if (!td) {
450 | return console.log(`%cNot Found: #td-filter-${idx}`, 'color: Yellow')
451 | }
452 |
453 | const link = td.querySelector('a')
454 | const value = link.textContent
455 | console.log('value:', value)
456 |
457 | const input = document.querySelector('#clones > input').cloneNode()
458 | input.value = value
459 | input.dataset.idx = idx
460 |
461 | td.removeChild(link)
462 | td.appendChild(input)
463 |
464 | input.focus()
465 | input.select()
466 | }
467 |
468 | /**
469 | * Set Keyboard Shortcuts
470 | * @function setShortcuts
471 | * @param {Array} names
472 | * @param {String} [selector]
473 | * @return {Promise}
474 | */
475 | async function setShortcuts(names, selector = '#keyboard-shortcuts') {
476 | if (!chrome.commands) {
477 | return console.debug('Skipping: chrome.commands')
478 | }
479 | const parent = document.querySelector(selector)
480 | parent.classList.remove('d-none')
481 | const table = parent.querySelector('table')
482 | console.log('table:', table)
483 | const tbody = table.querySelector('tbody')
484 | const commands = await chrome.commands.getAll()
485 | // console.log('commands:', commands)
486 | for (const name of names) {
487 | const command = commands.find((x) => x.name === name)
488 | // console.debug('command:', command)
489 | if (!command) {
490 | console.warn('Command Not Found:', command)
491 | }
492 | const row = table.querySelector('tfoot > tr').cloneNode(true)
493 | let description = command.description
494 | // Note: Chrome does not parse the description for _execute_action in manifest.json
495 | if (!description && command.name === '_execute_action') {
496 | description = 'Show Popup Action'
497 | }
498 | row.querySelector('.description').textContent = description
499 | row.querySelector('kbd').textContent = command.shortcut || 'Not Set'
500 | tbody.appendChild(row)
501 | }
502 | }
503 |
504 | /**
505 | * On Changed Callback
506 | * @function onChanged
507 | * @param {Object} changes
508 | * @param {String} namespace
509 | */
510 | function onChanged(changes, namespace) {
511 | // console.debug('onChanged:', changes, namespace)
512 | for (let [key, { newValue }] of Object.entries(changes)) {
513 | if (namespace === 'sync') {
514 | if (key === 'options') {
515 | updateOptions(newValue)
516 | } else if (key === 'patterns') {
517 | updateTable(newValue)
518 | }
519 | }
520 | }
521 | }
522 |
523 | /**
524 | * Copy Support/Debugging Information
525 | * @function copySupport
526 | * @param {MouseEvent} event
527 | */
528 | async function copySupport(event) {
529 | console.debug('copySupport:', event)
530 | event.preventDefault()
531 | const manifest = chrome.runtime.getManifest()
532 | const { options } = await chrome.storage.sync.get(['options'])
533 | const permissions = await chrome.permissions.getAll()
534 | const local = window.localStorage
535 | const result = [
536 | `${manifest.name} - ${manifest.version}`,
537 | navigator.userAgent,
538 | `permissions.origins: ${JSON.stringify(permissions.origins)}`,
539 | `options: ${JSON.stringify(options)}`,
540 | `links-table: ${local['DataTables_links-table_/html/links.html']}`,
541 | `domains-table: ${local['DataTables_domains-table_/html/links.html']}`,
542 | ]
543 | await navigator.clipboard.writeText(result.join('\n'))
544 | showToast('Support Information Copied.', 'success')
545 | }
546 |
--------------------------------------------------------------------------------
/src/js/pdf.js:
--------------------------------------------------------------------------------
1 | import * as pdfjsLib from '../dist/pdfjs/pdf.min.mjs'
2 |
3 | // noinspection JSUnresolvedReference
4 | pdfjsLib.GlobalWorkerOptions.workerSrc = '../dist/pdfjs/pdf.worker.min.mjs'
5 |
6 | /**
7 | * @function getPDF
8 | * @param {String} url
9 | * @return {Promise}
10 | */
11 | export async function getPDF(url) {
12 | // const response = await fetchPDF(url)
13 | const response = await fetch(url)
14 | const arrayBuffer = await response.arrayBuffer()
15 | const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
16 | const lines = []
17 |
18 | for (let i = 1; i <= pdf.numPages; i++) {
19 | const page = await pdf.getPage(i)
20 | const textContent = await page.getTextContent()
21 |
22 | // Extracting text
23 | textContent.items.forEach((item) => {
24 | if (item.str) {
25 | lines.push(item.str)
26 | }
27 | })
28 |
29 | // Extracting annotation URLs
30 | const annotations = await page.getAnnotations()
31 | annotations.forEach((annotation) => {
32 | // console.log('annotation:', annotation)
33 | if (annotation.url) {
34 | lines.push(annotation.url)
35 | }
36 | })
37 | }
38 |
39 | return lines
40 | }
41 |
42 | // /**
43 | // * @function fetchPDF
44 | // * @param {String} pdfUrl
45 | // * @return {Promise}
46 | // */
47 | // async function fetchPDF(pdfUrl) {
48 | // console.debug(`fetchPDF: ${pdfUrl}`)
49 | // try {
50 | // return await fetch(pdfUrl)
51 | // } catch (e) {
52 | // if (pdfUrl.startsWith('file://')) {
53 | // throw e
54 | // }
55 | // console.log(`%cPDF Fetch Error: ${e.message}`, 'color: OrangeRed')
56 | // const { options } = await chrome.storage.sync.get(['options'])
57 | // if (!options.proxyUrl) {
58 | // throw e
59 | // }
60 | // showToast('Fetch Failed, Trying Proxy...')
61 | // const url = new URL(options.proxyUrl)
62 | // url.searchParams.append('url', pdfUrl)
63 | // console.log(`%cTrying Proxy URL: ${url.href}`, 'color: LimeGreen')
64 | // return await fetch(url.href)
65 | // }
66 | // }
67 |
--------------------------------------------------------------------------------
/src/js/permissions.js:
--------------------------------------------------------------------------------
1 | // JS for permissions.html
2 |
3 | import { checkPerms, grantPerms, onRemoved, updateManifest } from './exports.js'
4 |
5 | chrome.permissions.onAdded.addListener(onAdded)
6 | chrome.permissions.onRemoved.addListener(onRemoved)
7 |
8 | document.addEventListener('DOMContentLoaded', initPermissions)
9 | document
10 | .querySelectorAll('.grant-permissions')
11 | .forEach((el) => el.addEventListener('click', grantPerms))
12 |
13 | /**
14 | * DOMContentLoaded - Initialize Permissions
15 | * @function initPermissions
16 | */
17 | async function initPermissions() {
18 | console.debug('initPermissions')
19 | // noinspection ES6MissingAwait
20 | updateManifest()
21 | // noinspection ES6MissingAwait
22 | checkPerms()
23 | const url = new URL(window.location)
24 | const message = url.searchParams.get('message')
25 | if (message) {
26 | const alert = document.querySelector('.alert-danger')
27 | alert.classList.remove('d-none')
28 | alert.textContent = message
29 | }
30 | }
31 |
32 | /**
33 | * Permissions On Added Callback
34 | * @param permissions
35 | */
36 | async function onAdded(permissions) {
37 | console.debug('onAdded', permissions)
38 | const hasPerms = await checkPerms()
39 | if (hasPerms && window.opener) {
40 | await chrome.runtime.openOptionsPage()
41 | window.close()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/js/popup.js:
--------------------------------------------------------------------------------
1 | // JS for popup.html
2 |
3 | import {
4 | checkPerms,
5 | detectBrowser,
6 | grantPerms,
7 | injectTab,
8 | openURL,
9 | saveOptions,
10 | updateManifest,
11 | updateOptions,
12 | } from './exports.js'
13 |
14 | import { getPDF } from './pdf.js'
15 |
16 | document.addEventListener('DOMContentLoaded', initPopup)
17 | document.getElementById('filter-form').addEventListener('submit', filterForm)
18 | document.getElementById('links-form').addEventListener('submit', linksForm)
19 | document.getElementById('links-text').addEventListener('input', updateLinks)
20 | // noinspection JSCheckFunctionSignatures
21 | document
22 | .querySelectorAll('.grant-permissions')
23 | .forEach((el) => el.addEventListener('click', (e) => grantPerms(e, true)))
24 |
25 | document
26 | .querySelectorAll('a[href]')
27 | .forEach((el) => el.addEventListener('click', popupLinks))
28 | document
29 | .querySelectorAll('[data-filter]')
30 | .forEach((el) => el.addEventListener('click', filterForm))
31 | document
32 | .querySelectorAll('#options-form input')
33 | .forEach((el) => el.addEventListener('change', saveOptions))
34 | document
35 | .querySelectorAll('[data-bs-toggle="tooltip"]')
36 | .forEach((el) => new bootstrap.Tooltip(el))
37 |
38 | const filterInput = document.getElementById('filter-input')
39 | const pdfBtn = document.getElementById('pdf-btn')
40 | const pdfIcon = document.getElementById('pdf-icon')
41 |
42 | /**
43 | * DOMContentLoaded - Initialize Popup
44 | * @function initOptions
45 | */
46 | async function initPopup() {
47 | console.debug('initPopup')
48 | filterInput.focus()
49 | // noinspection ES6MissingAwait
50 | updateManifest()
51 | chrome.storage.sync.get(['options', 'patterns']).then((items) => {
52 | console.debug('options:', items.options)
53 | updateOptions(items.options)
54 | if (items.patterns?.length) {
55 | document.getElementById('no-filters').remove()
56 | items.patterns.forEach(function (value, i) {
57 | createFilterLink(i.toString(), value)
58 | })
59 | }
60 | })
61 | checkPerms().then((hasPerms) => {
62 | processFileTypes(hasPerms).catch((e) => console.debug(e))
63 | })
64 |
65 | // const tabs = await chrome.tabs.query({ highlighted: true })
66 | // console.debug('tabs:', tabs)
67 | // if (tabs.length > 1) {
68 | // console.info('Multiple Tabs Selected')
69 | // }
70 | }
71 |
72 | async function processFileTypes(hasPerms) {
73 | const [tab] = await chrome.tabs.query({ currentWindow: true, active: true })
74 | console.debug('tab:', tab)
75 | const url = new URL(tab.url)
76 | // console.debug('url:', url)
77 | const browser = detectBrowser()
78 | // console.debug('browser:', browser)
79 | if (url.pathname.toLowerCase().endsWith('.pdf')) {
80 | console.debug(`Detected PDF: ${url.href}`)
81 | if (url.protocol === 'file:') {
82 | if (browser.id === 'firefox') {
83 | const el = document.getElementById('no-file-access')
84 | el.querySelector('span').textContent = browser.name
85 | el.classList.remove('d-none')
86 | return
87 | }
88 | const fileAccess =
89 | await chrome.extension.isAllowedFileSchemeAccess()
90 | console.debug('fileAccess:', fileAccess)
91 | if (!fileAccess) {
92 | document
93 | .getElementById('file-access')
94 | .classList.remove('d-none')
95 | return
96 | }
97 | }
98 | if (!hasPerms) {
99 | if (browser.id === 'firefox') {
100 | document.getElementById('pdf-perms').classList.remove('d-none')
101 | return
102 | }
103 | }
104 | pdfBtn.dataset.pdfUrl = url.href
105 | pdfBtn.classList.remove('d-none')
106 | pdfBtn.addEventListener('click', extractPDF)
107 | }
108 | }
109 |
110 | async function extractPDF(event) {
111 | try {
112 | pdfBtn.classList.add('disabled')
113 | pdfIcon.classList.remove('fa-flask')
114 | pdfIcon.classList.add('fa-sync', 'fa-spin')
115 | const pdfUrl = event.currentTarget.dataset.pdfUrl
116 | console.debug('pdfUrl:', pdfUrl)
117 | const data = await getPDF(pdfUrl)
118 | console.debug('data:', data)
119 | const urls = extractURLs(data.join('\n'))
120 | console.debug('urls:', urls)
121 | await chrome.storage.local.set({ links: urls })
122 | const url = chrome.runtime.getURL('/html/links.html')
123 | await chrome.tabs.create({ active: true, url })
124 | window.close()
125 | } catch (e) {
126 | console.log('e:', e)
127 | if (e.message === 'Promise.withResolvers is not a function') {
128 | showToast('This browser does not support pdf.js', 'danger')
129 | } else {
130 | showToast(e.message, 'danger')
131 | }
132 | } finally {
133 | pdfIcon.classList.remove('fa-sync', 'fa-spin')
134 | pdfIcon.classList.add('fa-flask')
135 | pdfBtn.classList.remove('disabled')
136 | }
137 | }
138 |
139 | /**
140 | * Add Form Input for a Filter
141 | * @function createFilterLink
142 | * @param {String} number
143 | * @param {String} value
144 | */
145 | function createFilterLink(number, value = '') {
146 | const ul = document.getElementById('filters-ul')
147 | const li = document.createElement('li')
148 | const a = document.createElement('a')
149 | a.textContent = value
150 | a.dataset.pattern = value
151 | a.classList.add('dropdown-item', 'small', 'text-ellipsis')
152 | a.setAttribute('role', 'button')
153 | a.addEventListener('click', filterForm)
154 | li.appendChild(a)
155 | ul.appendChild(li)
156 | }
157 |
158 | /**
159 | * Popup Links Click Callback
160 | * Firefox requires a call to window.close()
161 | * @function popupLinks
162 | * @param {MouseEvent} event
163 | */
164 | async function popupLinks(event) {
165 | console.debug('popupLinks:', event)
166 | event.preventDefault()
167 | // const anchor = event.target.closest('a')
168 | const href = event.currentTarget.getAttribute('href').replace(/^\.+/g, '')
169 | console.debug('href:', href)
170 | let url
171 | if (href.endsWith('html/options.html')) {
172 | await chrome.runtime.openOptionsPage()
173 | window.close()
174 | return
175 | } else if (href === '#') {
176 | return
177 | } else if (href.startsWith('http')) {
178 | url = href
179 | } else {
180 | url = chrome.runtime.getURL(href)
181 | }
182 | console.log('url:', url)
183 | await chrome.tabs.create({ active: true, url })
184 | window.close()
185 | }
186 |
187 | /**
188 | * Filter Form Submit Callback
189 | * @function formSubmit
190 | * @param {SubmitEvent} event
191 | */
192 | async function filterForm(event) {
193 | console.debug('filterForm:', event)
194 | const target = event.currentTarget
195 | console.debug('target:', target)
196 | event.preventDefault()
197 | let filter
198 | if (target.classList.contains('dropdown-item')) {
199 | filter = target.dataset.pattern
200 | } else if (filterInput?.value) {
201 | filter = filterInput.value
202 | }
203 | const domains = target.dataset.filter === 'domains'
204 | try {
205 | await injectTab({ filter, domains })
206 | window.close()
207 | } catch (e) {
208 | console.log('e:', e)
209 | showToast(e.message, 'danger')
210 | }
211 | }
212 |
213 | /**
214 | * Links Form Submit Callback
215 | * @function linksForm
216 | * @param {SubmitEvent} event
217 | */
218 | async function linksForm(event) {
219 | console.debug('linksForm:', event)
220 | event.preventDefault()
221 | const value = event.target.elements['links-text'].value
222 | // console.debug('value:', value)
223 | const { options } = await chrome.storage.sync.get(['options'])
224 | if (event.submitter.id === 'parse-links') {
225 | const urls = extractURLs(value)
226 | // console.debug('urls:', urls)
227 | await chrome.storage.local.set({ links: urls })
228 | const url = chrome.runtime.getURL('/html/links.html')
229 | await chrome.tabs.create({ active: true, url })
230 | } else if (event.submitter.id === 'open-parsed') {
231 | const urls = extractURLs(value)
232 | // console.debug('urls:', urls)
233 | urls.forEach(function (url) {
234 | openURL(url.href, options.lazyLoad)
235 | })
236 | } else if (event.submitter.id === 'open-text') {
237 | let text = value.split(/\s+/).filter((s) => s !== '')
238 | // console.debug('text:', text)
239 | text.forEach(function (url) {
240 | // links without a : get prepended the web extension url by default
241 | openURL(url, options.lazyLoad)
242 | })
243 | } else {
244 | console.error('Unknown event.submitter:', event.submitter)
245 | }
246 | window.close()
247 | }
248 |
249 | /**
250 | * Update Links Input Callback
251 | * @function updateLinks
252 | * @param {InputEvent} event
253 | */
254 | function updateLinks(event) {
255 | // console.debug('updateLinks:', event)
256 | const urls = extractURLs(event.target.value)
257 | // console.debug('urls:', urls)
258 | const text = event.target.value.split(/\s+/).filter((s) => s !== '')
259 | // console.debug('text:', text)
260 | document
261 | .querySelectorAll('.parse-links')
262 | .forEach((el) => updateElements(el, urls.length))
263 | document
264 | .querySelectorAll('.parse-lines')
265 | .forEach((el) => updateElements(el, text.length))
266 | }
267 |
268 | /**
269 | * Update Elements based on Array lines
270 | * @function updateElements
271 | * @param {HTMLElement} el
272 | * @param {Number} length
273 | */
274 | function updateElements(el, length) {
275 | // console.debug('el, lines:', el, lines)
276 | if (length) {
277 | el.classList.remove('disabled')
278 | el.textContent = `${el.dataset.text} (${length})`
279 | } else {
280 | el.classList.add('disabled')
281 | el.textContent = `${el.dataset.text}`
282 | }
283 | }
284 |
285 | /**
286 | * Extract URLs from text
287 | * TODO: Improve Function and Simplify Regular Expression
288 | * @function extractURLs
289 | * @param {String} text
290 | * @return {Array}
291 | */
292 | function extractURLs(text) {
293 | // console.debug('extractURLs:', text)
294 | const urls = []
295 | let urlmatch
296 | const regex =
297 | /\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()[\]{};:'".,<>?«»“”‘’]))/gi // NOSONAR
298 | while ((urlmatch = regex.exec(text)) !== null) {
299 | try {
300 | let match = urlmatch[0]
301 | match = match.includes('://') ? match : `http://${match}`
302 | // console.debug('match:', match)
303 | const url = new URL(match)
304 | const data = {
305 | text: '',
306 | title: '',
307 | label: '',
308 | target: '',
309 | rel: '',
310 | href: url.href,
311 | origin: url.origin,
312 | }
313 | urls.push(data)
314 | // eslint-disable-next-line no-unused-vars
315 | } catch (e) {
316 | console.debug('Error Processing match:', urlmatch)
317 | }
318 | }
319 | // return [...new Set(urls)]
320 | return urls
321 | }
322 |
--------------------------------------------------------------------------------
/src/js/service-worker.js:
--------------------------------------------------------------------------------
1 | // JS Background Service Worker
2 |
3 | import { checkPerms, injectTab, githubURL } from './exports.js'
4 |
5 | chrome.runtime.onInstalled.addListener(onInstalled)
6 | chrome.runtime.onStartup.addListener(onStartup)
7 | chrome.contextMenus?.onClicked.addListener(onClicked)
8 | chrome.commands?.onCommand.addListener(onCommand)
9 | chrome.storage.onChanged.addListener(onChanged)
10 | chrome.omnibox?.onInputChanged.addListener(onInputChanged)
11 | chrome.omnibox?.onInputEntered.addListener(onInputEntered)
12 | chrome.permissions.onAdded.addListener(onAdded)
13 | chrome.permissions.onRemoved.addListener(onRemoved)
14 |
15 | /**
16 | * On Installed Callback
17 | * @function onInstalled
18 | * @param {chrome.runtime.InstalledDetails} details
19 | */
20 | async function onInstalled(details) {
21 | console.log('onInstalled:', details)
22 | const installURL = 'https://link-extractor.cssnr.com/docs/?install=true'
23 | const { options, patterns } = await setDefaultOptions({
24 | linksDisplay: -1,
25 | flags: 'ig',
26 | lazyLoad: true,
27 | lazyFavicon: true,
28 | lazyTitle: '[{host}{pathname}]',
29 | radioFavicon: 'default',
30 | removeDuplicates: true,
31 | defaultFilter: true,
32 | saveState: true,
33 | linksTruncate: true,
34 | linksNoWrap: false,
35 | contextMenu: true,
36 | showUpdate: false,
37 | })
38 | console.log('options, patterns:', options, patterns)
39 | if (options.contextMenu) {
40 | createContextMenus(patterns)
41 | }
42 | if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
43 | // noinspection ES6MissingAwait
44 | chrome.runtime.openOptionsPage()
45 | await chrome.tabs.create({ active: false, url: installURL })
46 | } else if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) {
47 | if (options.showUpdate) {
48 | const manifest = chrome.runtime.getManifest()
49 | if (manifest.version !== details.previousVersion) {
50 | const url = `${githubURL}/releases/tag/${manifest.version}`
51 | console.log(`url: ${url}`)
52 | await chrome.tabs.create({ active: false, url })
53 | }
54 | }
55 | }
56 | checkPerms().then((hasPerms) => {
57 | if (hasPerms /* NOSONAR */) {
58 | onAdded()
59 | } else {
60 | onRemoved()
61 | }
62 | })
63 | setUninstallURL()
64 | }
65 |
66 | /**
67 | * On Startup Callback
68 | * @function onStartup
69 | */
70 | async function onStartup() {
71 | console.log('onStartup')
72 | // noinspection JSUnresolvedReference
73 | if (typeof browser !== 'undefined') {
74 | console.log('Firefox Startup Workarounds')
75 | const { options, patterns } = await chrome.storage.sync.get([
76 | 'options',
77 | 'patterns',
78 | ])
79 | console.debug('options:', options)
80 | if (options.contextMenu) {
81 | createContextMenus(patterns)
82 | }
83 | setUninstallURL()
84 | }
85 | }
86 |
87 | function setUninstallURL() {
88 | const manifest = chrome.runtime.getManifest()
89 | const url = new URL('https://link-extractor.cssnr.com/uninstall/')
90 | url.searchParams.append('version', manifest.version)
91 | chrome.runtime.setUninstallURL(url.href)
92 | console.debug(`setUninstallURL: ${url.href}`)
93 | }
94 |
95 | /**
96 | * Context Menus On Clicked Callback
97 | * @function onClicked
98 | * @param {chrome.contextMenus.OnClickData} ctx
99 | * @param {chrome.tabs.Tab} tab
100 | */
101 | async function onClicked(ctx, tab) {
102 | console.log('onClicked:', ctx, tab)
103 | if (['options', 'filters'].includes(ctx.menuItemId)) {
104 | await chrome.runtime.openOptionsPage()
105 | } else if (ctx.menuItemId === 'links') {
106 | console.debug('injectTab: links')
107 | await injectTab()
108 | } else if (ctx.menuItemId === 'domains') {
109 | console.debug('injectTab: domains')
110 | await injectTab({ domains: true })
111 | } else if (ctx.menuItemId === 'selection') {
112 | console.debug('injectTab: selection')
113 | await injectTab({ tab, selection: true })
114 | } else if (ctx.menuItemId.startsWith('filter-')) {
115 | const i = ctx.menuItemId.split('-')[1]
116 | console.debug(`injectTab: filter-${i}`)
117 | const { patterns } = await chrome.storage.sync.get(['patterns'])
118 | console.debug(`filter: ${patterns[i]}`)
119 | await injectTab({ filter: patterns[i] })
120 | } else if (ctx.menuItemId === 'copy') {
121 | console.debug('injectFunction: copyActiveElementText: copy', ctx)
122 | await injectFunction(copyActiveElementText, [ctx])
123 | } else if (ctx.menuItemId === 'copyAllLinks') {
124 | console.debug('injectFunction: copyLinks: copyAllLinks', tab)
125 | // await injectCopyLinks(tab)
126 | await injectTab({ tab, open: false })
127 | const { options } = await chrome.storage.sync.get(['options'])
128 | await injectFunction(copyLinks, [options.removeDuplicates])
129 | } else if (ctx.menuItemId === 'copySelLinks') {
130 | console.debug('injectFunction: copyLinks: copySelLinks', tab)
131 | // await injectCopyLinks(tab, true)
132 | await injectTab({ tab, open: false })
133 | const { options } = await chrome.storage.sync.get(['options'])
134 | await injectFunction(copyLinks, [options.removeDuplicates, true])
135 | } else {
136 | console.error(`Unknown ctx.menuItemId: ${ctx.menuItemId}`)
137 | }
138 | }
139 |
140 | /**
141 | * On Command Callback
142 | * @function onCommand
143 | * @param {String} command
144 | * @param {chrome.tabs.Tab} tab
145 | */
146 | async function onCommand(command, tab) {
147 | console.log(`onCommand: ${command}:`, tab)
148 | if (command === 'extractAll') {
149 | console.debug('extractAll')
150 | await injectTab()
151 | } else if (command === 'extractSelection') {
152 | console.debug('extractSelection')
153 | await injectTab({ selection: true })
154 | } else if (command === 'copyAll') {
155 | console.debug('copyAll')
156 | // await injectCopyLinks(tab)
157 | await injectTab({ open: false })
158 | const { options } = await chrome.storage.sync.get(['options'])
159 | await injectFunction(copyLinks, [options.removeDuplicates])
160 | } else if (command === 'copySelection') {
161 | console.debug('copySelection')
162 | // await injectCopyLinks(tab, true)
163 | await injectTab({ open: false })
164 | const { options } = await chrome.storage.sync.get(['options'])
165 | await injectFunction(copyLinks, [options.removeDuplicates, true])
166 | } else {
167 | console.error(`Unknown command: ${command}`)
168 | }
169 | }
170 |
171 | /**
172 | * On Changed Callback
173 | * TODO: Cleanup this function
174 | * @function onChanged
175 | * @param {Object} changes
176 | * @param {String} namespace
177 | */
178 | async function onChanged(changes, namespace) /* NOSONAR */ {
179 | // console.debug('onChanged:', changes, namespace)
180 | for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
181 | if (namespace === 'sync' && key === 'options') {
182 | if (oldValue?.contextMenu !== newValue?.contextMenu) {
183 | if (newValue?.contextMenu) {
184 | console.log('contextMenu: %cEnabling.', 'color: Lime')
185 | // chrome.storage.sync
186 | // .get(['patterns'])
187 | // .then((items) => createContextMenus(items.patterns))
188 | const { patterns } = await chrome.storage.sync.get([
189 | 'patterns',
190 | ])
191 | createContextMenus(patterns)
192 | } else {
193 | console.log('contextMenu: %cDisabling.', 'color: OrangeRed')
194 | chrome.contextMenus.removeAll()
195 | }
196 | }
197 | } else if (namespace === 'sync' && key === 'patterns') {
198 | const { options } = await chrome.storage.sync.get(['options'])
199 | if (options?.contextMenu) {
200 | console.log('contextMenu: %cUpdating Patterns.', 'color: Aqua')
201 | createContextMenus(newValue)
202 | }
203 | }
204 | }
205 | }
206 |
207 | /**
208 | * Omnibox Input Changed Callback
209 | * @function onInputChanged
210 | * @param {String} text
211 | * @param {Function} suggest
212 | */
213 | async function onInputChanged(text, suggest) {
214 | // console.debug('onInputChanged:', text, suggest)
215 | const { patterns } = await chrome.storage.sync.get(['patterns'])
216 | const results = []
217 | patterns.forEach((filter) => {
218 | if (filter.toLowerCase().includes(text.toLowerCase())) {
219 | const suggestResult = {
220 | description: filter,
221 | content: filter,
222 | }
223 | results.push(suggestResult)
224 | }
225 | })
226 | suggest(results)
227 | }
228 |
229 | /**
230 | * Omnibox Input Entered Callback
231 | * @function onInputEntered
232 | * @param {String} text
233 | */
234 | async function onInputEntered(text) {
235 | console.debug('onInputEntered:', text)
236 | const opts = {}
237 | text = text.trim()
238 | if (text) {
239 | opts.filter = text
240 | }
241 | await injectTab(opts)
242 | }
243 |
244 | /**
245 | * Permissions On Added Callback
246 | * @param permissions
247 | */
248 | export async function onAdded(permissions) {
249 | console.debug('onAdded:', permissions)
250 | chrome.omnibox?.setDefaultSuggestion({
251 | description: 'Link Extractor - Extract All Links or Type in a Filter',
252 | })
253 | }
254 |
255 | /**
256 | * Permissions On Added Callback
257 | * @param permissions
258 | */
259 | export async function onRemoved(permissions) {
260 | console.debug('onRemoved:', permissions)
261 | // noinspection JSUnresolvedReference
262 | if (typeof browser !== 'undefined') {
263 | chrome.omnibox?.setDefaultSuggestion({
264 | description:
265 | 'Link Extractor - Omnibox Requires Host Permissions. See Popup/Options.',
266 | })
267 | } else {
268 | await onAdded()
269 | }
270 | }
271 |
272 | /**
273 | * Create Context Menus
274 | * @function createContextMenus
275 | * @param {String[]} [patterns]
276 | */
277 | function createContextMenus(patterns) {
278 | if (!chrome.contextMenus) {
279 | return console.debug('Skipping: chrome.contextMenus')
280 | }
281 | console.debug('createContextMenus:', patterns)
282 | chrome.contextMenus.removeAll()
283 | const contexts = [
284 | [['link'], 'copy', 'Copy Link Text to Clipboard'],
285 | [['all'], 'copyAllLinks', 'Copy All Links to Clipboard'],
286 | [['selection'], 'copySelLinks', 'Copy Selected Links to Clipboard'],
287 | [['selection'], 'selection', 'Extract Links from Selection'],
288 | [['all'], 'separator'],
289 | [['all'], 'links', 'Extract All Links'],
290 | [['all'], 'filters', 'Extract with Filter'],
291 | [['all'], 'domains', 'Extract Domains Only'],
292 | [['all'], 'separator'],
293 | [['all'], 'options', 'Open Options'],
294 | ]
295 | contexts.forEach(addContext)
296 | if (patterns) {
297 | patterns.forEach((pattern, i) => {
298 | console.debug(`pattern: ${i}: ${pattern}`)
299 | chrome.contextMenus.create({
300 | contexts: ['all'],
301 | id: `filter-${i}`,
302 | title: pattern,
303 | parentId: 'filters',
304 | })
305 | })
306 | }
307 | }
308 |
309 | /**
310 | * Add Context from Array
311 | * @function addContext
312 | * @param {[chrome.contextMenus.ContextType[],String,String,chrome.contextMenus.ContextItemType?]} context
313 | */
314 | function addContext(context) {
315 | // console.debug('addContext:', context)
316 | try {
317 | if (context[1] === 'separator') {
318 | const id = Math.random().toString().substring(2, 7)
319 | context[1] = `${id}`
320 | context.push('separator', 'separator')
321 | }
322 | // console.debug('menus.create:', context)
323 | chrome.contextMenus.create({
324 | contexts: context[0],
325 | id: context[1],
326 | title: context[2],
327 | type: context[3] || 'normal',
328 | })
329 | } catch (e) {
330 | console.log('%cError Adding Context:', 'color: Yellow', e)
331 | }
332 | }
333 |
334 | /**
335 | * Copy Text of ctx.linkText or from Active Element
336 | * TODO: Update this once
337 | * Mozilla adds support for document.activeElement
338 | * Chromium adds supports ctx.linkText
339 | * @function copyActiveElementText
340 | * @param {chrome.contextMenus.OnClickData} ctx
341 | */
342 | function copyActiveElementText(ctx) {
343 | // console.log('document.activeElement:', document.activeElement)
344 | // noinspection JSUnresolvedReference
345 | let text =
346 | ctx.linkText?.trim() ||
347 | document.activeElement.innerText?.trim() ||
348 | document.activeElement.title?.trim() ||
349 | document.activeElement.firstElementChild?.alt?.trim() ||
350 | document.activeElement.ariaLabel?.trim()
351 | console.log('text:', text)
352 | if (text?.length) {
353 | // noinspection JSIgnoredPromiseFromCall
354 | navigator.clipboard.writeText(text)
355 | } else {
356 | console.log('%cNo Text to Copy.', 'color: Yellow')
357 | }
358 | }
359 |
360 | /**
361 | * Copy All Links
362 | * @function copySelectionLinks
363 | * @param {Boolean} removeDuplicates
364 | * @param {Boolean} selection
365 | */
366 | function copyLinks(removeDuplicates, selection = false) {
367 | console.debug('copyLinks:', removeDuplicates, selection)
368 | let links
369 | if (selection) {
370 | links = extractSelection()
371 | } else {
372 | links = extractAllLinks()
373 | }
374 | console.debug('links:', links)
375 | let results = []
376 | for (const link of links) {
377 | results.push(link.href)
378 | }
379 | if (removeDuplicates) {
380 | results = [...new Set(results)]
381 | }
382 | // console.debug('results:', results)
383 | const text = results.join('\n')
384 | console.debug('text:', text)
385 | if (text?.length) {
386 | // noinspection JSIgnoredPromiseFromCall
387 | navigator.clipboard.writeText(text)
388 | } else {
389 | console.log('%cNo Links to Copy.', 'color: Yellow')
390 | }
391 | }
392 |
393 | // async function injectCopyLinks(tab, selection = false) {
394 | // console.debug('copySelection')
395 | // await chrome.scripting.executeScript({
396 | // target: { tabId: tab.id },
397 | // files: ['/js/extract.js'],
398 | // })
399 | // const { options } = await chrome.storage.sync.get(['options'])
400 | // await injectFunction(copyLinks, [options.removeDuplicates, selection])
401 | // }
402 |
403 | /**
404 | * Inject Function into Current Tab with args
405 | * @function injectFunction
406 | * @param {Function} func
407 | * @param {Array} args
408 | * @return {Promise<*>}
409 | */
410 | async function injectFunction(func, args) {
411 | const [tab] = await chrome.tabs.query({ currentWindow: true, active: true })
412 | const results = await chrome.scripting.executeScript({
413 | target: { tabId: tab.id },
414 | func: func,
415 | args: args,
416 | })
417 | console.log('results:', results)
418 | return results[0]?.result
419 | }
420 |
421 | /**
422 | * Set Default Options
423 | * @function setDefaultOptions
424 | * @param {Object} defaultOptions
425 | * @return {Promise}
426 | */
427 | async function setDefaultOptions(defaultOptions) {
428 | console.log('setDefaultOptions:', defaultOptions)
429 | let { options, patterns } = await chrome.storage.sync.get([
430 | 'options',
431 | 'patterns',
432 | ])
433 | console.debug('options, patterns:', options, patterns)
434 |
435 | // patterns
436 | if (!patterns) {
437 | console.log('Init patterns to empty array.')
438 | patterns = []
439 | await chrome.storage.sync.set({ patterns })
440 | }
441 |
442 | // options
443 | options = options || {}
444 | let changed = false
445 | for (const [key, value] of Object.entries(defaultOptions)) {
446 | // console.debug(`${key}: default: ${value} current: ${options[key]}`)
447 | if (options[key] === undefined) {
448 | changed = true
449 | options[key] = value
450 | console.log(`Set %c${key}:`, 'color: Khaki', value)
451 | }
452 | }
453 | if (changed) {
454 | await chrome.storage.sync.set({ options })
455 | console.debug('changed options:', options)
456 | }
457 |
458 | return { options, patterns }
459 | }
460 |
--------------------------------------------------------------------------------
/src/js/theme.js:
--------------------------------------------------------------------------------
1 | // JS Bootstrap Theme Switcher
2 |
3 | ;(() => {
4 | const getStoredTheme = () => localStorage.getItem('theme')
5 | const setStoredTheme = (theme) => localStorage.setItem('theme', theme)
6 | const getMediaMatch = () =>
7 | window.matchMedia('(prefers-color-scheme: dark)').matches
8 | ? 'dark'
9 | : 'light'
10 |
11 | const getPreferredTheme = () => {
12 | const storedTheme = getStoredTheme()
13 | if (storedTheme) {
14 | return storedTheme
15 | } else {
16 | return getMediaMatch()
17 | }
18 | }
19 |
20 | const setTheme = (theme) => {
21 | // console.debug(`setTheme: ${theme}`)
22 | if (theme === 'auto') {
23 | document.documentElement.setAttribute(
24 | 'data-bs-theme',
25 | getMediaMatch()
26 | )
27 | } else {
28 | document.documentElement.setAttribute('data-bs-theme', theme)
29 | }
30 | }
31 |
32 | const stored = getStoredTheme()
33 | if (!stored) {
34 | setStoredTheme('auto')
35 | }
36 | setTheme(getPreferredTheme())
37 |
38 | const showActiveTheme = (theme) => {
39 | // console.debug(`showActiveTheme: ${theme}`)
40 | const themeIcon = document.querySelector('#theme-icon')
41 | if (!themeIcon) {
42 | // console.debug('No Theme Icon to Set.')
43 | return
44 | }
45 | document.querySelectorAll('[data-bs-theme-value]').forEach((el) => {
46 | if (el.dataset.bsThemeValue === theme) {
47 | const i = el.querySelector('i')
48 | themeIcon.className = i.className + ' fa-lg'
49 | el.classList.add('active')
50 | el.setAttribute('aria-pressed', 'true')
51 | } else {
52 | el.classList.remove('active')
53 | el.setAttribute('aria-pressed', 'false')
54 | }
55 | })
56 | }
57 |
58 | window.addEventListener('storage', (event) => {
59 | // console.log('storage:', event)
60 | if (event.key === 'theme') {
61 | setTheme(event.newValue)
62 | showActiveTheme(event.newValue)
63 | }
64 | })
65 |
66 | window
67 | .matchMedia('(prefers-color-scheme: dark)')
68 | .addEventListener('change', () => {
69 | const storedTheme = getStoredTheme()
70 | console.debug('prefers-color-scheme: change:', storedTheme)
71 | if (storedTheme === 'auto') {
72 | const preferred = getPreferredTheme()
73 | setTheme(preferred)
74 | }
75 | })
76 |
77 | window.addEventListener('DOMContentLoaded', () => {
78 | const preferred = getPreferredTheme()
79 | // console.debug('DOMContentLoaded: preferred:', preferred)
80 | showActiveTheme(preferred)
81 |
82 | document.querySelectorAll('[data-bs-theme-value]').forEach((el) => {
83 | el.addEventListener('click', () => {
84 | const value = el.getAttribute('data-bs-theme-value')
85 | setStoredTheme(value)
86 | setTheme(value)
87 | showActiveTheme(value)
88 | })
89 | })
90 | })
91 | })()
92 |
--------------------------------------------------------------------------------
/tests/common.js:
--------------------------------------------------------------------------------
1 | const puppeteer = require('puppeteer')
2 | const path = require('path')
3 |
4 | const sourceDir = 'src'
5 | const timeout = 10000
6 |
7 | /**
8 | * @function getBrowser
9 | * @return {puppeteer.Browser}
10 | */
11 | async function getBrowser() {
12 | const pathToExtension = path.join(process.cwd(), sourceDir)
13 | console.log('pathToExtension:', pathToExtension)
14 | return await puppeteer.launch({
15 | args: [
16 | `--disable-extensions-except=${pathToExtension}`,
17 | `--load-extension=${pathToExtension}`,
18 | '--no-sandbox',
19 | // '--disable-blink-features=AutomationControlled',
20 | // '--disable-features=ChromeUserPermPrompt',
21 | ],
22 | dumpio: true,
23 | // headless: false,
24 | // slowMo: 50,
25 | })
26 | }
27 |
28 | /**
29 | * @function getWorker
30 | * @global browser
31 | * @global timeout
32 | * @return {Promise}
33 | */
34 | async function getWorker(browser) {
35 | const workerTarget = await browser.waitForTarget(
36 | (target) =>
37 | target.type() === 'service_worker' &&
38 | target.url().endsWith('service-worker.js'),
39 | { timeout }
40 | )
41 | return await workerTarget.worker()
42 | }
43 |
44 | /**
45 | * @function getPage
46 | * @global browser
47 | * @global timeout
48 | * @param {puppeteer.Browser} browser
49 | * @param {String} name
50 | * @param {Boolean=} log
51 | * @param {String=} size
52 | * @return {Promise}
53 | */
54 | async function getPage(browser, name, log, size) {
55 | console.debug(`getPage: ${name}`, log, size)
56 | const target = await browser.waitForTarget(
57 | (target) => target.type() === 'page' && target.url().includes(name),
58 | { timeout }
59 | )
60 | const newPage = await target.asPage()
61 | await newPage.emulateMediaFeatures([
62 | { name: 'prefers-color-scheme', value: 'dark' },
63 | ])
64 | newPage.setDefaultTimeout(timeout)
65 | if (size) {
66 | const [width, height] = size.split('x').map((x) => parseInt(x))
67 | await newPage.setViewport({ width, height })
68 | }
69 | if (log) {
70 | console.debug(`Adding Logger: ${name}`)
71 | newPage.on('console', (msg) =>
72 | console.log(`console: ${name}:`, msg.text())
73 | )
74 | }
75 | return newPage
76 | }
77 |
78 | async function scrollPage(page) {
79 | await page.evaluate(() => {
80 | window.scrollBy({
81 | top: window.innerHeight,
82 | left: 0,
83 | behavior: 'instant',
84 | })
85 | })
86 | await new Promise((resolve) => setTimeout(resolve, 500))
87 | }
88 |
89 | module.exports = { getBrowser, getWorker, getPage, scrollPage }
90 |
--------------------------------------------------------------------------------
/tests/issue.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const { getBrowser, getPage, getWorker } = require('./common')
3 |
4 | const screenshotsDir = 'tests/screenshots'
5 | let page
6 |
7 | /**
8 | * @function addLogger
9 | * @param {Page} page
10 | * @param {String[]} results
11 | * @param {String} name
12 | * @return {Promise}
13 | */
14 | async function addLogger(page, results, name) {
15 | page.on('console', async (msg) => {
16 | const text = msg.text()
17 | if (text.startsWith('links:')) {
18 | const value = await msg.args()[1].jsonValue()
19 | results.unshift(`links count: ${value.length}`)
20 | } else if (text.startsWith('domains:')) {
21 | const value = await msg.args()[1].jsonValue()
22 | results.unshift(`domains count: ${value.length}`)
23 | } else {
24 | results.push(`${name}: ${text}`)
25 | }
26 | })
27 | }
28 |
29 | ;(async () => {
30 | fs.rmSync(screenshotsDir, { recursive: true, force: true })
31 | fs.mkdirSync(screenshotsDir)
32 |
33 | console.log('process.env.URL:', process.env.URL)
34 | const url = new URL(process.env.URL)
35 | console.log('url.href:', url.href)
36 |
37 | // Get Browser
38 | const browser = await getBrowser()
39 | console.log('browser:', browser)
40 |
41 | // Get Service Worker
42 | const worker = await getWorker(browser)
43 | console.log('worker:', worker)
44 |
45 | const logs = []
46 |
47 | page = await browser.newPage()
48 | // page.on('console', (msg) => logs.push(msg.text()))
49 | await addLogger(page, logs, 'site')
50 | await page.goto(url.href)
51 | await page.bringToFront()
52 | await page.waitForNetworkIdle()
53 | await new Promise((resolve) => setTimeout(resolve, 1000))
54 |
55 | await worker.evaluate('chrome.action.openPopup();')
56 | page = await getPage(browser, 'popup.html')
57 | // page.on('console', (msg) => logs.push(msg.text()))
58 | await addLogger(page, logs, 'popup')
59 | await page.locator('[data-filter=""]').click()
60 |
61 | page = await getPage(browser, 'links.html', false, '768x1024')
62 | // page.on('console', (msg) => logs.push(msg.text()))
63 | await addLogger(page, logs, 'links')
64 | await page.waitForNetworkIdle()
65 | await page.screenshot({ path: `${screenshotsDir}/links.png` })
66 |
67 | await browser.close()
68 |
69 | console.log('logs:', logs)
70 | const content = JSON.stringify(logs)
71 | fs.writeFileSync(`${screenshotsDir}/logs.txt`, content)
72 | })()
73 |
--------------------------------------------------------------------------------
/tests/manifest-test.json:
--------------------------------------------------------------------------------
1 | {
2 | "host_permissions": ["*://*/*"],
3 | "background": {
4 | "service_worker": "js/service-worker.js"
5 | },
6 | "minimum_chrome_version": "88"
7 | }
8 |
--------------------------------------------------------------------------------
/tests/patterns.txt:
--------------------------------------------------------------------------------
1 | ["docs","\\.[a-z0-9]+$","\\.(jpg|jpeg|png|gif)$","\\.(jpg|jpeg|png|gif|bmp|webp)$"]
2 |
--------------------------------------------------------------------------------
/tests/test.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const { getBrowser, getPage, getWorker, scrollPage } = require('./common')
3 |
4 | const screenshotsDir = 'tests/screenshots'
5 | let count = 1
6 | let page
7 |
8 | /**
9 | * @function screenshot
10 | * @param {String} name
11 | * @return {Promise}
12 | */
13 | async function screenshot(name) {
14 | const num = count.toString().padStart(2, '0')
15 | await page.screenshot({ path: `${screenshotsDir}/${num}_${name}.png` })
16 | count++
17 | }
18 |
19 | ;(async () => {
20 | fs.rmSync(screenshotsDir, { recursive: true, force: true })
21 | fs.mkdirSync(screenshotsDir)
22 |
23 | // Get Browser
24 | const browser = await getBrowser()
25 | console.log('browser:', browser)
26 |
27 | // Get Service Worker
28 | const worker = await getWorker(browser)
29 | console.log('worker:', worker)
30 |
31 | // Popup
32 | console.log('Activate Popup')
33 | await worker.evaluate('chrome.action.openPopup();')
34 | page = await getPage(browser, 'popup.html', true)
35 | console.log('page:', page)
36 | await page.waitForNetworkIdle()
37 | await screenshot('popup')
38 |
39 | await page.locator('#linksNoWrap').click()
40 | await new Promise((resolve) => setTimeout(resolve, 500))
41 | await screenshot('popup')
42 |
43 | // await page.locator('[href="../html/options.html"]').click()
44 | await page.evaluate((selector) => {
45 | document.querySelector(selector).click()
46 | }, 'a[href="../html/options.html"]')
47 | // await page
48 | // .locator('a')
49 | // .filter((el) => el.href.endsWith('html/options.html'))
50 | // .click()
51 |
52 | // Options
53 | page = await getPage(browser, 'options.html', true, '768x920')
54 | console.log('page:', page)
55 | await page.waitForNetworkIdle()
56 | await screenshot('options')
57 |
58 | const [fileChooser] = await Promise.all([
59 | page.waitForFileChooser(),
60 | page.click('#import-data'), // some button that triggers file selection
61 | ])
62 | await fileChooser.accept(['./tests/patterns.txt'])
63 | await scrollPage(page)
64 | await screenshot('options')
65 |
66 | // Page
67 | console.log('Testing: https://link-extractor.cssnr.com/')
68 | await page.goto('https://link-extractor.cssnr.com/')
69 | page.on('console', (msg) => console.log(`console: page:`, msg.text()))
70 | await page.bringToFront()
71 | await page.waitForNetworkIdle()
72 |
73 | // Links
74 | console.log('Activate Popup')
75 | await worker.evaluate('chrome.action.openPopup();')
76 | let popup1 = await getPage(browser, 'popup.html', true)
77 | console.log('popup1:', popup1)
78 | await popup1.locator('[data-filter=""]').click()
79 |
80 | page = await getPage(browser, 'links.html', true, '768x920')
81 | console.log('page:', page)
82 | await page.waitForNetworkIdle()
83 | await screenshot('link-extractor')
84 |
85 | // Page
86 | console.log('Testing: https://archive.org/')
87 | await page.goto('https://archive.org/')
88 | page.on('console', (msg) => console.log(`console: page:`, msg.text()))
89 | await page.bringToFront()
90 | // await page.waitForNetworkIdle()
91 | await new Promise((resolve) => setTimeout(resolve, 1000))
92 |
93 | // Links
94 | console.log('Activate Popup')
95 | await worker.evaluate('chrome.action.openPopup();')
96 | let popup2 = await getPage(browser, 'popup.html', true)
97 | console.log('popup2:', popup2)
98 | await popup2.locator('[data-filter=""]').click()
99 |
100 | page = await getPage(browser, 'links.html', true, '768x920')
101 | console.log('page:', page)
102 | await page.waitForNetworkIdle()
103 | await screenshot('archive.org')
104 |
105 | // Page
106 | console.log('Testing: https://link-extractor.cssnr.com/media/test/test.pdf')
107 | await page.goto('https://df.cssnr.com/raw/test.pdf')
108 | page.on('console', (msg) => console.log(`console: page:`, msg.text()))
109 | await page.bringToFront()
110 | // await page.waitForNetworkIdle()
111 | await new Promise((resolve) => setTimeout(resolve, 1000))
112 |
113 | // Links
114 | console.log('Activate Popup')
115 | await worker.evaluate('chrome.action.openPopup();')
116 | let popup3 = await getPage(browser, 'popup.html', true)
117 | console.log('popup3:', popup3)
118 | page = await getPage(browser, 'popup.html', true)
119 | await screenshot('pdf-popup')
120 | await popup3.locator('#pdf-btn').click()
121 |
122 | page = await getPage(browser, 'links.html', true, '768x920')
123 | console.log('page:', page)
124 | await page.waitForNetworkIdle()
125 | await screenshot('pdf-links')
126 |
127 | await browser.close()
128 | })()
129 |
--------------------------------------------------------------------------------