├── .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 | ![0](${{steps.image.outputs.url}}) 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 | [![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/ifefifghpkllfibejafbakmflidjcjfp?logo=google&logoColor=white&label=users)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 2 | [![Mozilla Add-on Users](https://img.shields.io/amo/users/link-extractor?logo=mozilla&label=users)](https://addons.mozilla.org/addon/link-extractor) 3 | [![Chrome Web Store Rating](https://img.shields.io/chrome-web-store/rating/ifefifghpkllfibejafbakmflidjcjfp?logo=google&logoColor=white)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 4 | [![Mozilla Add-on Rating](https://img.shields.io/amo/rating/link-extractor?logo=mozilla&logoColor=white)](https://addons.mozilla.org/addon/link-extractor) 5 | [![GitHub Repo Stars](https://img.shields.io/github/stars/cssnr/link-extractor?style=flat&logo=github&logoColor=white)](https://github.com/cssnr/link-extractor/stargazers) 6 | [![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/ifefifghpkllfibejafbakmflidjcjfp?label=chrome&logo=googlechrome)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 7 | [![Mozilla Add-on Version](https://img.shields.io/amo/v/link-extractor?label=firefox&logo=firefox)](https://addons.mozilla.org/addon/link-extractor) 8 | [![GitHub Release Version](https://img.shields.io/github/v/release/cssnr/link-extractor?logo=github)](https://github.com/cssnr/link-extractor/releases/latest) 9 | [![Build](https://img.shields.io/github/actions/workflow/status/cssnr/link-extractor/build.yaml?logo=github&logoColor=white&label=build)](https://github.com/cssnr/link-extractor/actions/workflows/build.yaml) 10 | [![Test](https://img.shields.io/github/actions/workflow/status/cssnr/link-extractor/test.yaml?logo=github&logoColor=white&label=test)](https://github.com/cssnr/link-extractor/actions/workflows/test.yaml) 11 | [![Lint](https://img.shields.io/github/actions/workflow/status/cssnr/link-extractor/lint.yaml?logo=github&logoColor=white&label=lint)](https://github.com/cssnr/link-extractor/actions/workflows/lint.yaml) 12 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=cssnr_link-extractor&metric=alert_status&label=quality)](https://sonarcloud.io/summary/overall?id=cssnr_link-extractor) 13 | [![GitHub Last Commit](https://img.shields.io/github/last-commit/cssnr/link-extractor?logo=github&logoColor=white&label=updated)](https://github.com/cssnr/link-extractor/graphs/commit-activity) 14 | [![GitHub Top Language](https://img.shields.io/github/languages/top/cssnr/link-extractor?logo=htmx&logoColor=white)](https://github.com/cssnr/link-extractor) 15 | [![GitHub Org Stars](https://img.shields.io/github/stars/cssnr?style=flat&logo=github&logoColor=white&label=org%20stars)](https://cssnr.github.io/) 16 | [![Discord](https://img.shields.io/discord/899171661457293343?logo=discord&logoColor=white&label=discord&color=7289da)](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 | [![Chrome](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/chrome_48.png)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 39 | [![Firefox](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/firefox_48.png)](https://addons.mozilla.org/addon/link-extractor) 40 | [![Edge](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/edge_48.png)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 41 | [![Opera](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/opera_48.png)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 42 | [![Brave](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/brave_48.png)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 43 | [![Chromium](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/chromium_48.png)](https://chromewebstore.google.com/detail/link-extractor/ifefifghpkllfibejafbakmflidjcjfp) 44 | [![Yandex](https://raw.githubusercontent.com/smashedr/logo-icons/master/browsers/yandex_48.png)](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 | [![Link Extractor Screenshots](/assets/banner.jpg)](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 | 143 | 144 |
145 |
146 |

Domains 0/

147 | 148 | 150 | 152 | 154 | 155 | 156 | D M to Copy Domains. 157 |
158 |
159 | 160 | 161 | 162 |
Domain
163 |
164 |
165 |
166 | 167 | 190 | 191 | 194 | 195 |
196 |
197 |
198 | 199 |
200 | 203 |
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 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
Keyboard Shortcuts
DescriptionShortcut
Unknown
46 |
47 | Manage Keyboard Shortcuts: 48 | 49 | https://mzl.la/3Qwp5QQ 50 | chrome://extensions/shortcuts 51 |
52 |
53 | 54 |
55 |
56 | General Options 57 |
58 |
59 | 60 |
61 |
62 |
63 | 64 | 65 | 66 | 67 |
68 | 69 | 72 | 73 | 74 | 75 | 76 | 77 |
78 |
79 | Regex Flags for Filtering. 80 | 82 | More Info 83 |
84 |
85 |
86 | 87 |
88 | 89 | 90 | 92 |
93 | 159 | 160 |
161 | 162 | 163 | 165 |
166 |
167 | 168 | 169 | 171 |
172 |
173 | 174 | 175 | 177 |
178 |
179 | 180 | 181 | 183 |
184 |
185 | 186 | 187 | 189 |
190 |
191 | 192 | 193 | 195 |
196 |
197 | 198 | 199 | 201 |
202 |
203 | 204 |

205 | More about Options 206 | 207 | on the Docs 208 |

209 | 210 |
211 | 215 | More about Permissions 216 |
217 |
218 | 222 |
223 | 224 |
225 |
226 | Saved Filters 227 |
228 |
229 | 230 |
231 | 232 |
233 | 234 | 237 |
238 |
239 | 240 |
241 | 242 | 243 | Import 244 | / 245 | 246 | Export 247 |
248 | 249 | 250 | 256 | 257 | 258 | 259 | 260 | 261 | 262 |
251 | Saved Filters 252 | 253 | 254 | 255 |
Filter
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 | 287 | 288 |
289 |
290 |
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 | Auto Auth 23 |

Link Extractor

24 |
25 |
26 | 27 |
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 | 43 |
44 | 47 | 48 | Open Options 49 |
50 |
51 | Documentation 53 | 54 | FAQ 56 | 57 | Get Support 59 |
60 |
61 |
62 |
63 |
64 | 65 | 68 | 69 |
70 |
71 |
72 | 73 |
74 | 77 |
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 |
40 | 41 | 42 |
43 |
44 |
45 | 46 |
47 | 48 |
49 |
50 | 51 |
52 | 54 |
55 | 58 | 61 |
62 |
63 | 64 |
65 | 66 | 67 |
68 | 69 | 71 | 72 | 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 | 97 | 99 | 101 |
102 | 103 |
104 |
105 | 106 | 107 | 109 |
110 |
111 | 112 | 113 | 115 |
116 |
117 | 118 | 119 | 121 |
122 |
123 | 124 | 125 | 127 |
128 |
129 | 130 | 131 | 133 |
134 |
135 | 136 | 137 | 139 |
140 |
141 | 142 |
143 | 147 |
148 | 149 | 150 | More Options 151 | 152 |
153 |
154 | 155 |
156 |
157 |
158 | 159 |
160 | 163 |
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 | --------------------------------------------------------------------------------