├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug-report.yml └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── AI_PROMPTS.md ├── LICENSE ├── README.md ├── SECURITY.md ├── esbuild.config.mjs ├── images ├── Screenshot_1.png ├── Screenshot_2.png ├── Screenshot_3.png └── demo_gif.gif ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── Extractors │ ├── constants.ts │ ├── extractor.ts │ ├── helper.ts │ ├── types │ │ └── index.ts │ └── utils.ts ├── adBlock.ts ├── assets │ └── image.ts ├── contextMenu.ts ├── main.ts ├── mediaUtils.ts ├── modal │ ├── ShortcutModal.ts │ ├── aiProcessingModal.ts │ ├── clipModal.ts │ ├── deleteCategory.ts │ ├── deleteFiles.ts │ ├── folderSelection.ts │ ├── promptModal.ts │ └── warningModal.ts ├── search │ ├── fetchSuggestions.ts │ ├── search.ts │ └── searchUrls.ts ├── services │ └── gemini.ts ├── settingTabs.ts ├── settings.ts ├── translations.ts ├── utils.ts ├── view │ ├── ClipperView.ts │ ├── EditorWebView.ts │ ├── HomeTab.ts │ └── ModalWebView.ts └── webViewComponent.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: elharis 2 | 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or request a feature for the Obsidian Plugin 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for your contribution! 8 | 9 | 10 | - type: textarea 11 | id: issue-description 12 | attributes: 13 | label: What happened? 14 | description: Describe what happened and what you expected to happen. 15 | placeholder: Provide a detailed explanation of the issue. 16 | validations: 17 | required: true 18 | 19 | - type: checkboxes 20 | id: reproducible 21 | attributes: 22 | label: Is this issue reproducible? 23 | description: Can the issue be consistently reproduced? 24 | options: 25 | - label: I have checked if the issue is reproducible and can replicate it. 26 | 27 | - type: input 28 | id: plugin-version 29 | attributes: 30 | label: Plugin Version 31 | description: What version of the plugin are you using? 32 | placeholder: e.g., 1.0.0 33 | validations: 34 | required: true 35 | 36 | 37 | - type: input 38 | id: platform 39 | attributes: 40 | label: What platform are you using? 41 | placeholder: "e.g., Windows, macOS, Linux" 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: logs 47 | attributes: 48 | label: Relevant Log Output 49 | description: Please provide any relevant logs or errors. These will help in diagnosing the issue. 50 | placeholder: Paste log output here if applicable. 51 | render: shell 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | 25 | requset.md -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /AI_PROMPTS.md: -------------------------------------------------------------------------------- 1 | 2 | # Using AI Prompts in NetClip 3 | 4 | NetClip's AI Prompt feature enhances your web clipping experience by allowing you to process and transform clipped content using customizable AI-driven instructions. This section guides you through setting up and using AI prompts, leveraging built-in variables, creating custom prompts, and troubleshooting common issues. 5 | 6 | ## Table of Contents 7 | - [Getting Started](#getting-started) 8 | - [Built-in Variable](#built-in-variable) 9 | - [Creating Custom Prompts](#creating-custom-prompts) 10 | - [Example Prompts](#example-prompts) 11 | - [Troubleshooting](#troubleshooting) 12 | 13 | --- 14 | 15 | ## Getting Started 16 | 17 | To begin using AI prompts in NetClip, you’ll need to enable the feature and configure it with an [API key](https://aistudio.google.com/apikey). Follow these steps: 18 | 19 | 1. **Enable AI Prompt:** 20 | - Open Obsidian and go to **Settings > NetClip**. 21 | - Navigate to the **"AI Prompt"** tab. 22 | - Toggle **"Enable AI"** to **ON**. 23 | - Enter your [Get API key | Google AI Studio](https://aistudio.google.com/apikey) 24 | 25 | 2. **Access Prompts:** 26 | - Once AI is enabled, the **"AI Prompts"** section will appear in the NetClip settings. 27 | - Default example prompts are provided for immediate use. 28 | - You can use these as-is, modify them, or delete them to suit your needs. 29 | 30 | --- 31 | 32 | ## Built-in Variable 33 | 34 | NetClip includes a special built-in variable, `${article}`, which automatically captures the extracted content (e.g., title, body text, metadata) from your web clips. This variable is pre-populated when you clip a webpage, making it easy to integrate into your prompts. 35 | 36 | ### Example Usage: 37 | ``` 38 | Translate the following ${article} to ${target_lang} 39 | ``` 40 | 41 | In this prompt, `${article}` represents the clipped webpage content, and `${target_lang}` is a custom variable you define (e.g., "Japanese"). 42 | 43 | --- 44 | 45 | ## Creating Custom Prompts 46 | 47 | You can create your own prompts to tailor the AI’s behavior to your specific needs. Here’s how: 48 | 49 | 1. **Add a New Prompt:** 50 | - In the **AI prompt** tab of NetClip settings, click **"Add New Prompt"**. 51 | - Fill in the following fields: 52 | - **Name**: A short, descriptive name (e.g., "Summarize Article"). 53 | - **Prompt**: The instruction for the AI, including variables (e.g., "Summarize ${article} in ${style} style"). 54 | - **Variables**: Custom variables to make your prompt dynamic. 55 | 56 | 1. **Adding Variables(optional):** 57 | - Click **"Add Variable"**. 58 | - Enter a variable name (e.g., `style`) without the `${}` syntax you can add as many as you want. 59 | - Add possible values for the variable, one per line (e.g., `concise`, `detailed`). 60 | - Use the variable in your prompt as `${variableName}` (e.g., `${style}`). 61 | 62 | 3. **Saving and Testing:** 63 | - Save the prompt and test it by clipping a webpage and applying the prompt via the NetClip interface. 64 | 65 | 66 | --- 67 | 68 | ## Example Prompts 69 | 70 | Below are three practical examples of custom prompts you can create in NetClip, formatted for easy copying with separate sections for each component, along with their use cases. 71 | 72 | ## 1. Translation Prompt 73 | 74 | --- 75 | #### Prompt Name: 76 | ``` 77 | Translate Content 78 | ``` 79 | #### Prompt: 80 | ``` 81 | Translate the following ${article} to ${target_lang} 82 | ``` 83 | #### Variable Name: 84 | ``` 85 | target_lang 86 | ``` 87 | #### Variables: 88 | ``` 89 | Japanese 90 | English 91 | Spanish 92 | French 93 | German 94 | Chinese 95 | ``` 96 | 97 | **Use Case**: Convert a clipped English article into Japanese for language practice. 98 | 99 | 100 | ## 2. Summarization Prompt 101 | 102 | --- 103 | #### Prompt Name: 104 | ``` 105 | Summarize Content 106 | ``` 107 | #### Prompt: 108 | ``` 109 | Summarize ${article} in ${style} style. Keep the summary ${length}. 110 | ``` 111 | #### Variable Name: 112 | ``` 113 | style 114 | ``` 115 | #### Variables: 116 | ``` 117 | concise 118 | detailed 119 | bullet points 120 | academic 121 | ``` 122 | #### Variable Name: 123 | ``` 124 | length 125 | ``` 126 | #### Variables: 127 | ``` 128 | short (2-3 sentences) 129 | medium (1 paragraph) 130 | long (2-3 paragraphs) 131 | ``` 132 | **Use Case**: Summarize a long research article into a concise bullet-point list for quick reference. 133 | 134 | 135 | 136 | ### 3. Note Formatting Prompt 137 | --- 138 | 139 | #### Prompt Name: 140 | ``` 141 | Format as Note 142 | ``` 143 | #### Prompt: 144 | ``` 145 | Convert ${article} into a structured note with headings, bullet points, and key takeaways. Use ${format} formatting style. 146 | ``` 147 | #### Variable Name: 148 | ``` 149 | format 150 | ``` 151 | #### Variables: 152 | ``` 153 | Academic 154 | Meeting Notes 155 | Study Notes 156 | ``` 157 | 158 | **Use Case**: Turn a clipped blog post into a well-organized Markdown study note with key points highlighted. 159 | 160 | --- 161 | 162 | ## Troubleshooting 163 | 164 | If you encounter issues with AI prompts, here are common problems and solutions: 165 | 166 | 1. **Prompt Not Working:** 167 | - **Check AI Status**: Ensure "Enable AI" is toggled on in settings. 168 | - **Verify API Key**: Confirm your Gemini API key is valid and correctly entered. 169 | - **Inspect Console**: Open the developer console (ctrl+shift+i)mac(cmd) to check for error messages. 170 | - **Variable Check**: Ensure all variables in the prompt are defined. 171 | 172 | 2. **Variable Issues:** 173 | - **Naming Consistency**: Variable names in the prompt (e.g., `${style}`) must match those in the variables section (e.g., `style`). 174 | - **Syntax**: Always wrap variables in `${}` within the prompt text. 175 | - **Value Selection**: Ensure all required variables have a selected value when running the prompt. 176 | 177 | 1. **Article Issues:** 178 | - **Long Content**: Very large articles may exceed API limits; try clipping smaller sections. 179 | - **Formatting Needs**: Add specific instructions (e.g., "convert to Markdown") if the output isn’t as expected. 180 | 181 | 4. **Common Errors:** 182 | - **"Variable not found"**: Double-check variable names and definitions. 183 | - **"AI Processing failed"**: Verify your API key and internet connection. 184 | - **"No response"**: Simplify the prompt or reduce content length if the article is too complex. 185 | 186 | For further assistance or to report bugs, visit the [GitHub repository](https://github.com/Elhary/Obsidian-NetClip/issues) and open an issue. 187 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 haris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## NetClip 2 | 3 | [![GitHub](https://img.shields.io/badge/GitHub-View_Repository-738adb?style=for-the-badge&logo=github&logoColor=white&labelColor=1a1b27)](https://github.com/Elhary/Obsidian-NetClip) 4 | 5 | [![Version](https://img.shields.io/github/manifest-json/v/Elhary/Obsidian-NetClip?style=for-the-badge&color=6A80B9&labelColor=1a1b27&label=Version)](https://github.com/Elhary/Obsidian-NetClip/releases/latest) 6 | 7 | [![Last Commit](https://img.shields.io/github/last-commit/Elhary/Obsidian-NetClip?style=for-the-badge&color=C890A7&labelColor=1a1b27&label=Last%20Update)](https://github.com/Elhary/Obsidian-NetClip/commits/main) 8 | 9 | [![Stars](https://img.shields.io/github/stars/Elhary/Obsidian-NetClip?style=for-the-badge&color=DDA853&labelColor=1a1b27&label=Stars)](https://github.com/Elhary/Obsidian-NetClip/stargazers) 10 | 11 | [![Issues](https://img.shields.io/github/issues/Elhary/Obsidian-NetClip/help%20wanted?style=for-the-badge&color=A6CDC6&labelColor=1a1b27&label=Help%20Wanted)](https://github.com/Elhary/Obsidian-NetClip/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) 12 | 13 | 14 | NetClip is an Obsidian plugin that lets you browse the web and clip webpages directly into your vault. It also offers organized category management for better content organization. 15 | 16 | 17 | ### Demo 18 | 19 | ![demo_video](./images/demo_gif.gif) 20 | 21 | 22 | 23 | --- 24 | 25 | ## Clipper View 26 | ![preview_img_2](./images/Screenshot_2.png) 27 | 28 | ## Web view 29 | 30 | ![preview_img_2](./images/Screenshot_3.png) 31 | 32 | 33 | 34 | ## Home Tab 35 | 36 | ![preview_img_1](./images/Screenshot_1.png) 37 | 38 | 39 | 40 | --- 41 | 42 | 43 | 44 | 45 | ### Features 46 | 47 | - **Web Clipping**: Save web articles (title, content, metadata) as Markdown files. 48 | - **Content Organization**: Organize clipped content into folders/categories. 49 | - **Metadata Extraction**: Includes author, thumbnail, publication date, reading time 50 | - **Webview**: browse pages within Obsidian. 51 | - **Search & Filter**: Search clipped content and filter by categories. 52 | - **Search Suggestions**: Auto-complete for search queries 53 | - **AI Processing**: Transform and analyze content using Google's Gemini AI 54 | 55 | ### AI Processing Features 56 | 57 | - **Custom AI Prompts**: Create and customize prompts to process your clipped content 58 | - **Variable Support**: Define variables in your prompts for flexible content processing 59 | - **Real-time Progress**: Watch as your content is processed with a sleek progress UI 60 | 61 | To use AI features: 62 | 63 | 1. Get a Gemini API key from [Get API key | Google AI Studio](https://aistudio.google.com/apikey) 64 | 2. Enable AI processing in plugin settings 65 | 3. Add your [API key](https://aistudio.google.com/apikey) 66 | 4. Create custom prompts or use the default ones 67 | 5. Select prompts when clipping content 68 | 69 | ### How to Clip a Webpage 70 | 71 | 1. Open the **Clipper View** by clicking the plugin's icon in your sidebar. 72 | 2. Click the **plus (+) button** at the top right. 73 | 3. The URL will automatically be pasted into the input field. 74 | 4. select a **category** for the clip (Optional). 75 | 5. Click **Clip** to save the content into your vault. 76 | 6. 77 | ### Webview Clipping 78 | 79 | In Webview mode, there are two buttons available: 80 | 81 | - **Quick Save**: Save the webpage instantly to your vault. 82 | - **Open Modal**: Opens a modal for selecting a category before saving. 83 | 84 | ### Available Commands 85 | 86 | - **Open NetClip View**: Launches the main NetClip interface to view all saved pages. 87 | - **Open Web in Editor**: Opens a webview in the workspace with the default URL. 88 | - **Open Web Modal**: Opens a modal webview with the default URL. 89 | - **Open Modal Clipper**: Opens a modal for clipping a webpage. 90 | 91 | ### Managing Categories 92 | 93 | 1. Create new categories via the plugin settings. 94 | 2. Modify or delete existing categories as needed. 95 | 3. Filter saved content by selecting the appropriate category tab. 96 | 97 | ### Installation 98 | 99 | #### Community Plugins 100 | 101 | 1. Open Obsidian and navigate to **Settings** > **Community Plugins**. 102 | 2. Search for **NetClip** in the community plugins directory. 103 | 3. Click **Install** and enable the plugin. 104 | 4. Customize your preferences under the **NetClip** settings tab. 105 | 106 | 107 | 108 | #### Install via BRAT 109 | 110 | 1. Install [BRAT](https://tfthacker.com/brat-quick-guide#Adding+a+beta+plugin) from Obsidian's Community Plugins. 111 | 2. Open the **BRAT** command form and type:   112 |    ``BRAT: Add a beta plugin for testing`` 113 | 3. Enter the link:   114 |    `https://github.com/Elhary/Obsidian-NetClip` 115 | 4. After adding the plugin, you will see it in your ribbon or plugin list. 116 | 117 | ### Support 118 | 119 | If you'd like to support the plugin's development, feel free to donate!  Every little bit helps. 120 | 121 | 122 | - [Ko-fi](https://ko-fi.com/elharis) 123 | 124 | ### License 125 | This plugin is licensed under the [MIT](https://mit-license.org) License. 126 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /images/Screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elhary/Obsidian-NetClip/16b0235176b4c191afd2a72b2247d675d4c96b92/images/Screenshot_1.png -------------------------------------------------------------------------------- /images/Screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elhary/Obsidian-NetClip/16b0235176b4c191afd2a72b2247d675d4c96b92/images/Screenshot_2.png -------------------------------------------------------------------------------- /images/Screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elhary/Obsidian-NetClip/16b0235176b4c191afd2a72b2247d675d4c96b92/images/Screenshot_3.png -------------------------------------------------------------------------------- /images/demo_gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elhary/Obsidian-NetClip/16b0235176b4c191afd2a72b2247d675d4c96b92/images/demo_gif.gif -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "net-clip", 3 | "name": "NetClip", 4 | "version": "1.3.8", 5 | "minAppVersion": "1.4.0", 6 | "description": "Clip, save, search, and browse web pages within your vault", 7 | "author": "Elhary", 8 | "authorUrl": "https://github.com/Elhary", 9 | "fundingUrl": { 10 | "Ko-fi": "https://ko-fi.com/elharis" 11 | }, 12 | "isDesktopOnly": false 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "net-clip", 3 | "version": "1.3.8", 4 | "description": "Clip, save, search, and browse web pages within Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@types/uuid": "^10.0.0", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.25.0", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4", 24 | "uuid": "^11.1.0" 25 | }, 26 | "dependencies": { 27 | "@electron/remote": "^2.1.2", 28 | "@google/generative-ai": "^0.24.0", 29 | "@mozilla/readability": "^0.6.0", 30 | "@types/turndown": "^5.0.5", 31 | "turndown": "^7.2.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Extractors/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONSTANTS = { 2 | MAX_DESCRIPTION_LENGTH: 200, 3 | CURRENCY_MAP: { 4 | '$': 'USD', 5 | '£': 'GBP', 6 | '€': 'EUR', 7 | '¥': 'JPY', 8 | USD: 'USD', 9 | EUR: 'EUR', 10 | GBP: 'GBP', 11 | JPY: 'JPY' 12 | }, 13 | 14 | SELECTORS: { 15 | MAIN_CONTENT: [ 16 | 'main', 'article', '.main-content', '#main-content', '.entry-content', 17 | '#productDescription', '#feature-bullets', '.markdown', '[class*="blog"]', 18 | '#centerCol', '.a-section.a-spacing-none', '.Blog' 19 | ], 20 | 21 | CLEANUP: [ 22 | 'script', 'style', 'svg', 'nav', 'footer', 'header', 'aside', 23 | '[class*="footer"]', '[class*="nav"]', '[class*="sidebar"]', 24 | '.ad-container', '.advertisement', '.cookie-consent', '.menu', 25 | '.tags', '[class*="popup"]', '[class*="related"]', '.related' 26 | ], 27 | 28 | PRICE: ['.price', '.current-price', '.priceToPay', '.a-price', '#priceblock_ourprice'] 29 | } 30 | }; -------------------------------------------------------------------------------- /src/Extractors/extractor.ts: -------------------------------------------------------------------------------- 1 | import WebClipperPlugin from '../main'; 2 | import { ProcessNodeHelper } from './helper'; 3 | import { Readability } from '@mozilla/readability'; 4 | import { ReadabilityArticle, MediaContent, PriceInfo } from './types/index'; 5 | import { CONSTANTS } from './constants'; 6 | import { DOMHelper, TextHelper } from './utils'; 7 | 8 | 9 | export class ContentExtractors { 10 | private plugin: WebClipperPlugin; 11 | private processNodeHelper: ProcessNodeHelper; 12 | private mediaContents = new Set(); 13 | 14 | constructor(plugin: WebClipperPlugin) { 15 | this.plugin = plugin; 16 | this.processNodeHelper = new ProcessNodeHelper(plugin); 17 | } 18 | 19 | extractMainContent(doc: Document, baseUrl: string): string { 20 | this.processNodeHelper.resetProcessingFlags(); 21 | this.mediaContents.clear(); 22 | 23 | const docClone = doc.cloneNode(true) as Document; 24 | docClone.querySelectorAll('img[src*=".gif"], img[data-src*=".gif"]').forEach(gif => gif.classList.add('gif')); 25 | 26 | const article = new Readability(docClone, { 27 | charThreshold: 20, 28 | classesToPreserve: ['markdown', 'highlight', 'code', 'gif'], 29 | nbTopCandidates: 5, 30 | maxElemsToParse: 0, 31 | keepClasses: true 32 | }).parse() as ReadabilityArticle | null; 33 | 34 | if (!article) return this.fallbackExtraction(doc, baseUrl); 35 | 36 | const container = document.createElement('div'); 37 | DOMHelper.setContentSafely(container, article.content || ''); 38 | const mediaElements = this.processMediaElements(container, baseUrl); 39 | 40 | return this.buildMetadata(article) + 41 | this.processNodeHelper.processNode(container, baseUrl).replace(/\n{3,}/g, '\n\n').trim() + 42 | mediaElements; 43 | } 44 | 45 | private processMediaElements(container: Element, baseUrl: string): string { 46 | const mediaElements: MediaContent[] = []; 47 | container.querySelectorAll('img[src*=".gif"], img[data-src*=".gif"], .gif').forEach(gif => { 48 | const url = DOMHelper.resolveUrl(gif.getAttribute('src') || gif.getAttribute('data-src') || '', baseUrl); 49 | const alt = gif.getAttribute('alt') || 'GIF'; 50 | 51 | if (url && !this.mediaContents.has(url)) { 52 | this.mediaContents.add(url); 53 | mediaElements.push({ type: 'gif', url, alt }); 54 | } 55 | }); 56 | 57 | return mediaElements.map(media => `\n![${media.alt}](${media.url})\n`).join(''); 58 | } 59 | 60 | 61 | 62 | extractThumbnail(doc: Document): string { 63 | const ogImage = doc.querySelector('meta[property="og:image"]'); 64 | if (ogImage) { 65 | const content = ogImage.getAttribute('content'); 66 | return content ? `${content}?crossorigin=anonymous` : ''; 67 | } 68 | 69 | const imgElements = doc.querySelectorAll('img[data-lazy-srcset], img[data-srcset], img[data-src]'); 70 | for (const img of Array.from(imgElements)) { 71 | const srcset = img.getAttribute('data-lazy-srcset') || img.getAttribute('data-srcset'); 72 | if (srcset) { 73 | const urls = srcset.split(',').map(entry => entry.trim().split(' ')[0]); 74 | const httpsUrl = urls.find(url => url.startsWith('https://')); 75 | if (httpsUrl) return httpsUrl; 76 | } 77 | const dataSrc = img.getAttribute('data-src'); 78 | if (dataSrc?.startsWith('https://')) return dataSrc; 79 | } 80 | 81 | const imgSrc = doc.querySelector('img[src^="https://"]')?.getAttribute('src'); 82 | if (imgSrc) return imgSrc; 83 | 84 | return doc.querySelector('.a-dynamic-image')?.getAttribute('src') || ''; 85 | } 86 | 87 | extractDescription(doc: Document): string | null { 88 | const jsonLd = this.parseJsonLd(doc); 89 | const sources = [ 90 | () => jsonLd.description || jsonLd.articleBody?.substring(0, CONSTANTS.MAX_DESCRIPTION_LENGTH), 91 | () => doc.querySelector('meta[name="description"]')?.getAttribute('content'), 92 | () => doc.querySelector('meta[property="og:description"]')?.getAttribute('content'), 93 | () => doc.querySelector('meta[name="twitter:description"]')?.getAttribute('content'), 94 | () => DOMHelper.extractFromSelectors(doc, [ 95 | '[itemprop="description"]', 96 | '.product-description', 97 | '#productDescription', 98 | '.description', 99 | '#description' 100 | ]) 101 | ]; 102 | 103 | const description = sources.reduce((acc, source) => acc || source(), null); 104 | return description ? TextHelper.cleanText(description, CONSTANTS.MAX_DESCRIPTION_LENGTH) : null; 105 | } 106 | 107 | extractPublishTime(doc: Document): string | null { 108 | const jsonLd = this.parseJsonLd(doc); 109 | const sources = [ 110 | () => jsonLd.datePublished || jsonLd.dateCreated || jsonLd.dateModified, 111 | () => doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content'), 112 | () => doc.querySelector('meta[property="og:published_time"]')?.getAttribute('content'), 113 | () => doc.querySelector('time[datetime]')?.getAttribute('datetime'), 114 | () => DOMHelper.extractFromSelectors(doc, [ 115 | '[itemprop="datePublished"]', 116 | '.published-date', 117 | '.post-date', 118 | '.article-date' 119 | ]) 120 | ]; 121 | 122 | const date = sources.reduce((acc, source) => acc || source(), null); 123 | return date ? new Date(date).toISOString() : null; 124 | } 125 | 126 | extractAuthor(doc: Document): string | null { 127 | const jsonLd = this.parseJsonLd(doc); 128 | const sources = [ 129 | () => { 130 | const article = new Readability(doc.cloneNode(true) as Document).parse() as ReadabilityArticle; 131 | return article?.byline; 132 | }, 133 | () => jsonLd.author?.name || (typeof jsonLd.author === 'string' ? jsonLd.author : null), 134 | () => doc.querySelector('meta[name="author"]')?.getAttribute('content'), 135 | () => DOMHelper.extractFromSelectors(doc, [ 136 | '[itemProp="author"] [itemProp="name"]', 137 | '[itemProp="author"]', 138 | '.author-name', 139 | '.author', 140 | '[class*="author"]' 141 | ]) 142 | ]; 143 | 144 | const author = sources.reduce((acc, source) => acc || source(), null); 145 | return author ? TextHelper.cleanAuthor(author) : null; 146 | } 147 | 148 | extractPrice(doc: Document): string | null { 149 | const prices = this.extractPriceInfo(doc); 150 | return prices.length ? prices.map(price => 151 | price.currency ? 152 | `${price.currency === 'USD' ? '$' : price.currency === 'GBP' ? '£' : '€'}${price.amount}` : 153 | price.amount 154 | ).join(', ') : null; 155 | } 156 | 157 | private extractPriceInfo(doc: Document): PriceInfo[] { 158 | const jsonLd = this.parseJsonLd(doc); 159 | const prices: PriceInfo[] = []; 160 | 161 | if (jsonLd.offers?.price) { 162 | prices.push({ 163 | amount: jsonLd.offers.price.toString(), 164 | currency: jsonLd.offers.priceCurrency 165 | }); 166 | } 167 | 168 | CONSTANTS.SELECTORS.PRICE.forEach(selector => { 169 | const priceText = doc.querySelector(selector)?.textContent?.trim(); 170 | if (priceText?.match(/(?:\$|€|£|USD|EUR|GBP)?\s*\d+([.,]\d{2})?/)) { 171 | prices.push({ 172 | amount: TextHelper.normalizePrice(priceText), 173 | currency: TextHelper.extractCurrency(priceText) 174 | }); 175 | } 176 | }); 177 | 178 | return Array.from(new Map( 179 | prices.map(price => [`${price.currency || ''}${price.amount}`, price]) 180 | ).values()); 181 | } 182 | 183 | extractBrand(doc: Document): string | null { 184 | const jsonLd = this.parseJsonLd(doc); 185 | const sources = [ 186 | () => jsonLd.brand?.name || jsonLd.manufacturer?.name, 187 | () => doc.querySelector('meta[property="og:brand"]')?.getAttribute('content'), 188 | () => doc.querySelector('meta[property="product:brand"]')?.getAttribute('content'), 189 | () => DOMHelper.extractFromSelectors(doc, [ 190 | '#brand', 191 | '.brand', 192 | '[itemprop="brand"]', 193 | '.product-brand', 194 | '#bylineInfo' 195 | ]) 196 | ]; 197 | 198 | const brand = sources.reduce((acc, source) => acc || source(), null); 199 | return brand ? TextHelper.cleanBrand(brand) : null; 200 | } 201 | 202 | extractRating(doc: Document): string | null { 203 | const ratingElement = doc.querySelector([ 204 | '#acrPopover', 205 | 'meta[itemprop="rating"]', 206 | '.average-rating', 207 | '.star-rating', 208 | '[class*="rating"]' 209 | ].join(',')); 210 | 211 | let rating = ratingElement?.textContent?.trim(); 212 | if (!rating) { 213 | const stars = Array.from(doc.querySelectorAll('span, div')) 214 | .find(el => el.textContent?.trim().match(/^★+$/))?.textContent; 215 | if (stars) { 216 | rating = `${stars.length} out of 5 stars`; 217 | } 218 | } 219 | 220 | if (rating) { 221 | const numericMatch = rating.match(/(\d+(\.\d+)?)\s*out\s*of\s*5/i); 222 | if (numericMatch) { 223 | const value = parseFloat(numericMatch[1]); 224 | const stars = '★'.repeat(Math.round(value)) + '☆'.repeat(5 - Math.round(value)); 225 | return `${value} out of 5 stars (${stars})`; 226 | } 227 | 228 | const starMatch = rating.match(/★+/); 229 | if (starMatch) { 230 | const value = starMatch[0].length; 231 | const stars = '★'.repeat(value) + '☆'.repeat(5 - value); 232 | return `${value} out of 5 stars (${stars})`; 233 | } 234 | } 235 | 236 | return null; 237 | } 238 | 239 | private buildMetadata(article: ReadabilityArticle): string { 240 | return [ 241 | article.title ? `# ${article.title}\n\n` : '', 242 | article.byline ? `*By ${article.byline}*\n\n` : '', 243 | article.excerpt ? `> ${article.excerpt}\n\n` : '' 244 | ].join(''); 245 | } 246 | 247 | private fallbackExtraction(doc: Document, baseUrl: string): string { 248 | const mainContent = CONSTANTS.SELECTORS.MAIN_CONTENT.reduce((acc, selector) => 249 | acc || doc.querySelector(selector), doc.body as Element); 250 | 251 | this.cleanupElements(mainContent); 252 | const mediaElements = this.processMediaElements(mainContent, baseUrl); 253 | 254 | return this.processNodeHelper.processNode(mainContent, baseUrl) 255 | .replace(/\n{3,}/g, '\n\n').trim() + mediaElements; 256 | } 257 | 258 | private cleanupElements(element: Element): void { 259 | const removeSelectors = CONSTANTS.SELECTORS.CLEANUP.join(','); 260 | 261 | element.querySelectorAll(removeSelectors).forEach(el => { 262 | if (!el.matches('img[src*=".gif"], .gif') && !el.querySelector('img[src*=".gif"], .gif')) { 263 | el.remove(); 264 | } 265 | }); 266 | } 267 | 268 | private parseJsonLd(doc: Document): any { 269 | try { 270 | return JSON.parse(doc.querySelector('script[type="application/ld+json"]')?.textContent || '{}'); 271 | } catch { 272 | return {}; 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/Extractors/helper.ts: -------------------------------------------------------------------------------- 1 | import WebClipperPlugin from '../main'; 2 | 3 | export class ProcessNodeHelper { 4 | private plugin: WebClipperPlugin; 5 | private recentLinks: Set = new Set(); 6 | private seenImages: Set = new Set(); 7 | 8 | constructor(plugin: WebClipperPlugin) { 9 | this.plugin = plugin; 10 | } 11 | 12 | resetProcessingFlags(): void { 13 | this.seenImages.clear(); 14 | this.recentLinks.clear(); 15 | } 16 | 17 | processNode(node: Node, baseUrl: string): string { 18 | if (node.nodeType === Node.TEXT_NODE) { 19 | return (node.textContent || '').trim(); 20 | } 21 | 22 | if (!(node instanceof HTMLElement)) { 23 | return ''; 24 | } 25 | 26 | const element = node as HTMLElement; 27 | return this.processElement(element, baseUrl); 28 | } 29 | 30 | private processElement(element: HTMLElement, baseUrl: string): string { 31 | const tagProcessors: Record string> = { 32 | BLOCKQUOTE: (el) => this.processBlockquote(el, baseUrl), 33 | A: (el) => this.processAnchor(el, baseUrl), 34 | STRONG: (el) => this.processWrappedContent(el, baseUrl, '**'), 35 | EM: (el) => this.processWrappedContent(el, baseUrl, '*'), 36 | H1: (el) => this.processHeading(el, baseUrl, 1), 37 | H2: (el) => this.processHeading(el, baseUrl, 2), 38 | H3: (el) => this.processHeading(el, baseUrl, 3), 39 | H4: (el) => this.processHeading(el, baseUrl, 4), 40 | H5: (el) => this.processHeading(el, baseUrl, 5), 41 | H6: (el) => this.processHeading(el, baseUrl, 6), 42 | TABLE: (el) => this.processTable(el as HTMLTableElement, baseUrl), 43 | UL: (el) => this.processList(el, baseUrl, 'ul'), 44 | OL: (el) => this.processList(el, baseUrl, 'ol'), 45 | P: (el) => this.processParagraph(el, baseUrl), 46 | BR: () => '\n', 47 | HR: () => '---\n\n', 48 | IMG: (el) => this.processImage(el as HTMLImageElement, baseUrl), 49 | IFRAME: (el) => this.processIframe(el), 50 | VIDEO: (el) => this.processVideo(el), 51 | CODE: (el) => this.processCode(el), 52 | PRE: (el) => this.processPre(el, baseUrl), 53 | }; 54 | 55 | const processor = tagProcessors[element.tagName]; 56 | if (processor) { 57 | return processor(element); 58 | } 59 | 60 | return Array.from(element.childNodes) 61 | .map((child) => this.processNode(child, baseUrl)) 62 | .join(''); 63 | } 64 | 65 | private processBlockquote(element: HTMLElement, baseUrl: string): string { 66 | const content = Array.from(element.childNodes) 67 | .map((child) => this.processNode(child, baseUrl)) 68 | .join('') 69 | .trim(); 70 | return content ? `> ${content.replace(/\n/g, '\n> ')}\n\n` : ''; 71 | } 72 | 73 | private processAnchor(element: HTMLElement, baseUrl: string): string { 74 | const href = element.getAttribute('href'); 75 | const text = element.textContent?.trim() || ''; 76 | 77 | if (!href || !text) return text; 78 | 79 | const absoluteLink = this.resolveUrl(baseUrl, href); 80 | const linkKey = `${text}:${absoluteLink}`; 81 | 82 | if (this.recentLinks.has(linkKey)) return text; 83 | 84 | this.recentLinks.add(linkKey); 85 | if (this.recentLinks.size > 10) { 86 | const oldestLink = this.recentLinks.values().next().value; 87 | this.recentLinks.delete(oldestLink); 88 | } 89 | 90 | const prefixSpace = this.shouldAddSpace(element.previousSibling) || ''; 91 | const suffixSpace = this.shouldAddSpace(element.nextSibling) || ''; 92 | 93 | const needsPrefixSpace = prefixSpace === '' && 94 | (element.previousSibling?.nodeType === Node.TEXT_NODE || 95 | element.previousSibling instanceof HTMLElement); 96 | 97 | const needsSuffixSpace = suffixSpace === '' && 98 | (element.nextSibling?.nodeType === Node.TEXT_NODE || 99 | element.nextSibling instanceof HTMLElement); 100 | 101 | return `${needsPrefixSpace ? ' ' : ''}[${text}](${absoluteLink})${needsSuffixSpace ? ' ' : ''}`; 102 | } 103 | 104 | private processWrappedContent(element: HTMLElement, baseUrl: string, wrapper: string): string { 105 | const content = Array.from(element.childNodes) 106 | .map((child) => this.processNode(child, baseUrl)) 107 | .join(''); 108 | return `${wrapper}${content}${wrapper}`; 109 | } 110 | 111 | private processHeading(element: HTMLElement, baseUrl: string, level: number): string { 112 | const content = Array.from(element.childNodes) 113 | .map((child) => this.processNode(child, baseUrl)) 114 | .join(''); 115 | return `${'#'.repeat(level)} ${content}\n\n`; 116 | } 117 | 118 | private processTable(table: HTMLTableElement, baseUrl: string): string { 119 | let content = '\n'; 120 | const rows = table.rows; 121 | const columnCount = Math.max(...Array.from(rows).map((row) => row.cells.length)); 122 | 123 | 124 | if (rows.length > 0) { 125 | const headerRow = rows[0]; 126 | content += '|' + Array.from(headerRow.cells) 127 | .map((cell) => this.processNode(cell, baseUrl).replace(/\|/g, '\\|').trim()) 128 | .join('|') + '|\n'; 129 | content += '|' + Array(columnCount).fill('---').join('|') + '|\n'; 130 | } 131 | 132 | for (let i = 1; i < rows.length; i++) { 133 | content += '|' + Array.from(rows[i].cells) 134 | .map((cell) => this.processNode(cell, baseUrl).replace(/\|/g, '\\|').trim()) 135 | .join('|') + '|\n'; 136 | } 137 | 138 | return content + '\n'; 139 | } 140 | 141 | private processList(element: HTMLElement, baseUrl: string, type: 'ul' | 'ol'): string { 142 | const listItems = Array.from(element.childNodes) 143 | .filter((child) => child.nodeName === 'LI') 144 | .map((child, index) => { 145 | const content = this.processNode(child, baseUrl).trim(); 146 | return type === 'ul' ? `- ${content}` : `${index + 1}. ${content}`; 147 | }); 148 | return listItems.join('\n') + '\n\n'; 149 | } 150 | 151 | private processParagraph(element: HTMLElement, baseUrl: string): string { 152 | const content = Array.from(element.childNodes) 153 | .map((child) => this.processNode(child, baseUrl)) 154 | .join(''); 155 | return `${content}\n\n`; 156 | } 157 | 158 | private resolveUrl(baseUrl: string, href: string): string { 159 | try { 160 | return new URL(href, baseUrl).toString(); 161 | } catch { 162 | return href; 163 | } 164 | } 165 | 166 | private shouldAddSpace(sibling: Node | null): string { 167 | if (!sibling) return ''; 168 | 169 | if (sibling.nodeType === Node.TEXT_NODE) { 170 | const text = sibling.textContent || ''; 171 | return /\S$/.test(text) ? ' ' : ''; 172 | } 173 | 174 | if (sibling instanceof HTMLElement) { 175 | const blockElements = ['P', 'DIV', 'BR', 'HR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']; 176 | return blockElements.includes(sibling.tagName) ? ' ' : ''; 177 | } 178 | 179 | return ''; 180 | } 181 | 182 | private processImage(element: HTMLImageElement, baseUrl: string): string { 183 | const crossOrigin = element.hasAttribute('crossorigin') 184 | ? element.getAttribute('crossorigin') 185 | : 'anonymous'; 186 | 187 | 188 | const dataSrcSet = element.getAttribute('data-lazy-srcset') || element.getAttribute('data-srcset'); 189 | let src = ''; 190 | 191 | if (dataSrcSet) { 192 | const urls = dataSrcSet.split(',').map(entry => { 193 | const [url, size] = entry.trim().split(' '); 194 | return { url: this.resolveUrl(baseUrl, url), size }; 195 | }); 196 | const httpsUrl = urls.find(u => u.url.startsWith('https://')); 197 | src = httpsUrl?.url || ''; 198 | } 199 | 200 | if (!src) { 201 | src = this.resolveUrl(baseUrl, 202 | element.getAttribute('data-src') || 203 | element.getAttribute('src') || 204 | '' 205 | ); 206 | } 207 | 208 | const alt = element.getAttribute('alt') || ''; 209 | if (!src || this.seenImages.has(src)) return ''; 210 | 211 | this.seenImages.add(src); 212 | return `![${alt}](${src}?crossorigin=${encodeURIComponent(crossOrigin!)})`; 213 | } 214 | 215 | private processIframe(element: HTMLElement): string { 216 | const src = element.getAttribute('src'); 217 | return src ? `[Embedded content](${src})\n\n` : ''; 218 | } 219 | 220 | private processVideo(element: HTMLElement): string { 221 | const src = element.getAttribute('src'); 222 | return src ? `[Video content](${src})\n\n` : ''; 223 | } 224 | 225 | private processCode(element: HTMLElement): string { 226 | const content = element.textContent || ''; 227 | return `\`${content.trim()}\``; 228 | } 229 | 230 | private processPre(element: HTMLElement, baseUrl: string): string { 231 | const content = Array.from(element.childNodes) 232 | .map((child) => this.processNode(child, baseUrl)) 233 | .join(''); 234 | return `\`\`\`\n${content.trim()}\n\`\`\`\n\n`; 235 | } 236 | } -------------------------------------------------------------------------------- /src/Extractors/types/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface ReadabilityArticle { 3 | title: string | null; 4 | byline: string | null; 5 | dir: string | null; 6 | content: string | null; 7 | textContent: string | null; 8 | length: number; 9 | excerpt: string | null; 10 | siteName: string | null; 11 | } 12 | 13 | export type MediaType = 'image' | 'gif'; 14 | 15 | export interface MediaContent { 16 | type: MediaType; 17 | url: string; 18 | alt?: string; 19 | poster?: string; 20 | title?: string; 21 | } 22 | 23 | export interface PriceInfo { 24 | amount: string; 25 | currency?: string; 26 | type?: 'sale' | 'regular' | 'list'; 27 | } -------------------------------------------------------------------------------- /src/Extractors/utils.ts: -------------------------------------------------------------------------------- 1 | import { CONSTANTS} from './constants' 2 | 3 | export class DOMHelper { 4 | static setContentSafely(element: Element, content: string): void { 5 | const parser = new DOMParser(); 6 | const doc = parser.parseFromString(content, 'text/html'); 7 | 8 | while (element.firstChild) { 9 | element.removeChild(element.firstChild); 10 | } 11 | 12 | Array.from(doc.body.childNodes).forEach(node => { 13 | element.appendChild(document.importNode(node, true)); 14 | }); 15 | } 16 | 17 | 18 | static resolveUrl(url: string, base: string): string { 19 | if (!url || url.startsWith('data:') || url.startsWith('blob:')) return url; 20 | try { 21 | return new URL(url, base).toString(); 22 | } catch { 23 | return url; 24 | } 25 | } 26 | 27 | static extractFromSelectors(doc: Document, selectors: string[]): string | null { 28 | return selectors.reduce((acc, selector) => 29 | acc || doc.querySelector(selector)?.textContent?.trim() || null, null); 30 | } 31 | } 32 | 33 | export class TextHelper { 34 | static cleanText(text: string, maxLength: number): string { 35 | return text 36 | .replace(/[\r\n\t]+/g, ' ') 37 | .replace(/\s+/g, ' ') 38 | .replace(/[<>]/g, '') 39 | .trim() 40 | .substring(0, maxLength) 41 | .replace(/\w+$/, '') 42 | .trim() + (text.length > maxLength ? '...' : ''); 43 | } 44 | 45 | static cleanAuthor(author: string): string { 46 | return author 47 | .replace(/^(by|written by|posted by|authored by)\s+/i, '') 48 | .replace(/\s*\|\s*\d+.*$/, '') 49 | .replace(/\s*\(\d+.*\)/, '') 50 | .replace(/\s*[,\|]\s*(staff\s+writer|contributor|guest\s+author|editor).*/i, '') 51 | .trim(); 52 | } 53 | 54 | static cleanBrand(brand: string): string { 55 | return brand 56 | .replace(/^(brand|by|visit|store|shop)[:|\s]+/i, '') 57 | .replace(/\s*(›|»|»|·|\||\-|—)\s*.*/i, '') 58 | .trim(); 59 | } 60 | 61 | static normalizePrice(price: string): string { 62 | const match = price.match(/\d+([.,]\d{2})?/); 63 | return match ? match[0].replace(/,(\d{2})$/, '.$1').replace(/^(\d+),(\d{2,})$/, '$1.$2') : '0.00'; 64 | } 65 | 66 | static extractCurrency(text: string): string | undefined { 67 | const symbol = text.match(/[\$€£]|USD|EUR|GBP/)?.[0]; 68 | return symbol ? CONSTANTS.CURRENCY_MAP[symbol as keyof typeof CONSTANTS.CURRENCY_MAP] : undefined; 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /src/adBlock.ts: -------------------------------------------------------------------------------- 1 | import { WebviewTag } from "./webViewComponent"; 2 | 3 | export class AdBlocker { 4 | private filters: { 5 | domainBlocks: Set; 6 | patternBlocks: RegExp[]; 7 | elementBlocks: string[]; 8 | } = { 9 | domainBlocks: new Set(), 10 | patternBlocks: [], 11 | elementBlocks: [] 12 | }; 13 | 14 | private static instance: AdBlocker | null = null; 15 | private domainPatterns: string[] = []; 16 | private combinedPattern: RegExp | null = null 17 | private initialized = false; 18 | private blockedDomains: Set = new Set(); 19 | private blockedPatterns: RegExp[] = []; 20 | private blockedRequest: Set = new Set(); 21 | 22 | constructor(private settings: any) { 23 | if (!AdBlocker.instance) { 24 | AdBlocker.instance = this; 25 | this.initializeFilters(); 26 | } 27 | return AdBlocker.instance; 28 | } 29 | 30 | public static async perload(): Promise{ 31 | if(!this.instance){ 32 | this.instance = new AdBlocker({}); 33 | await this.instance.initializeFilters(); 34 | } 35 | } 36 | 37 | public async initializeFilters(): Promise { 38 | if (this.initialized) return; 39 | 40 | const responses = await Promise.all( 41 | this.filterUrls.map(async (url) => { 42 | try { 43 | const response = await fetch(url); 44 | return response.ok ? response.text() : ''; 45 | } catch { 46 | return ''; 47 | } 48 | }) 49 | ); 50 | 51 | const rules = responses 52 | .join('\n') 53 | .split('\n') 54 | .filter(line => line && !line.startsWith('!') && !line.startsWith('[')); 55 | 56 | await Promise.all([ 57 | this.processDomainRules(rules), 58 | this.processPatternRules(rules), 59 | this.processElementRules(rules) 60 | ]) 61 | 62 | this.domainPatterns = Array.from(this.filters.domainBlocks); 63 | this.combinedPattern = new RegExp(this.filters.patternBlocks.map(p => p.source).join('|'), 'i'); 64 | this.initialized = true; 65 | } 66 | 67 | private async processDomainRules(rules: string[]): Promise{ 68 | for (const rule of rules){ 69 | if(rule.startsWith('||')){ 70 | const domain = rule.substring(2).split('^')[0].split('*')[0]; 71 | if(domain) this.filters.domainBlocks.add(domain); 72 | } 73 | } 74 | } 75 | 76 | private async processPatternRules(rules: string[]): Promise { 77 | for (const rule of rules){ 78 | if (rule.startsWith('/') && rule.endsWith('/')) { 79 | const pattern = rule.slice(1, -1); 80 | this.filters.patternBlocks.push(new RegExp(pattern, 'i')); 81 | } 82 | } 83 | } 84 | 85 | private async processElementRules(rules: string[]): Promise { 86 | for (const rule of rules){ 87 | if (rule.startsWith('##')) { 88 | const selector = rule.substring(2); 89 | this.filters.elementBlocks.push(selector); 90 | } 91 | } 92 | } 93 | 94 | private filterUrls = [ 95 | 'https://easylist.to/easylist/easylist.txt', 96 | 'https://easylist.to/easylist/easyprivacy.txt', 97 | 'https://easylist.to/easylist/fanboy-annoyance.txt', 98 | 'https://easylist.to/easylist/fanboy-social.txt', 99 | 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt', 100 | 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/badware.txt', 101 | 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/privacy.txt', 102 | 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/refs/heads/master/filters/privacy.txt' 103 | ]; 104 | 105 | public isAdRequest(url: string): boolean { 106 | const urlObj = new URL(url); 107 | if (this.domainPatterns.some(pattern => urlObj.hostname.includes(pattern))) { 108 | return true; 109 | } 110 | 111 | if (this.combinedPattern && this.combinedPattern.test(url)) { 112 | return true; 113 | } 114 | return false; 115 | } 116 | 117 | public getDomainPatterns(): string[] { 118 | return this.domainPatterns; 119 | } 120 | 121 | public getDOMFilterScript(): string { 122 | return ` 123 | (function() { 124 | const isYouTube = window.location.hostname.includes('youtube.com'); 125 | 126 | if (isYouTube) { 127 | const handleVideoAds = () => { 128 | const video = document.querySelector('video'); 129 | if (video) { 130 | const isAd = document.querySelector('.ytp-ad-module, .ad-showing, .ad-interrupting') !== null || 131 | (video.currentSrc && (video.currentSrc.includes('/ad/') || video.currentSrc.includes('/adlog/'))) || 132 | (video.duration > 0 && video.duration <= 30 && video.currentTime === 0) || 133 | document.querySelector('.ytp-ad-player-overlay, .ytp-ad-message-container') !== null || 134 | document.querySelector('.video-ads') !== null; 135 | 136 | if (isAd) { 137 | const skipButton = document.querySelector('.ytp-ad-skip-button, .ytp-ad-overlay-close-button'); 138 | if (skipButton) { 139 | skipButton.click(); 140 | return; 141 | } 142 | 143 | if (video.duration <= 30) { 144 | video.currentTime = video.duration; 145 | video.play(); 146 | } 147 | 148 | if (video.paused) { 149 | video.play(); 150 | } 151 | 152 | const adElements = document.querySelectorAll('.ytp-ad-player-overlay, .ytp-ad-message-container, .video-ads'); 153 | adElements.forEach(element => element.remove()); 154 | } 155 | } 156 | }; 157 | 158 | const videoObserver = new MutationObserver((mutations) => { 159 | for (const mutation of mutations) { 160 | if (mutation.type === 'childList' && 161 | (mutation.target.nodeName === 'VIDEO' || 162 | mutation.target.classList.contains('ytp-ad-module') || 163 | mutation.target.classList.contains('ytp-ad-player-overlay') || 164 | mutation.target.classList.contains('video-ads'))) { 165 | handleVideoAds(); 166 | break; 167 | } 168 | } 169 | }); 170 | 171 | const containers = document.querySelectorAll('#movie_player, .html5-video-container, .video-ads'); 172 | containers.forEach(container => { 173 | videoObserver.observe(container, { 174 | childList: true, 175 | subtree: true, 176 | attributes: false 177 | }); 178 | }); 179 | 180 | handleVideoAds(); 181 | 182 | const video = document.querySelector('video'); 183 | if (video) { 184 | video.addEventListener('timeupdate', handleVideoAds); 185 | video.addEventListener('play', handleVideoAds); 186 | video.addEventListener('loadedmetadata', handleVideoAds); 187 | video.addEventListener('progress', handleVideoAds); 188 | } 189 | 190 | if (video) { 191 | video.addEventListener('play', () => { 192 | if (video.paused) { 193 | video.play(); 194 | } 195 | }); 196 | } 197 | } 198 | })(); 199 | `; 200 | } 201 | 202 | public setupRequestInterception(webview: WebviewTag): void{ 203 | const blockedRequests = new Set(); 204 | 205 | webview.addEventListener('will-request', (event: any) => { 206 | const url = event.url; 207 | const requestId = event.id; 208 | 209 | if (blockedRequests.has(requestId)) { 210 | event.preventDefault(); 211 | return; 212 | } 213 | 214 | let hostname: string; 215 | try { 216 | hostname = new URL(url).hostname; 217 | } catch { 218 | return; 219 | } 220 | 221 | if (this.blockedDomains.has(hostname)) { 222 | blockedRequests.add(requestId); 223 | event.preventDefault(); 224 | return; 225 | } 226 | 227 | if (this.blockedPatterns.length > 0) { 228 | for (const pattern of this.blockedPatterns) { 229 | if (pattern.test(url)) { 230 | blockedRequests.add(requestId); 231 | event.preventDefault(); 232 | return; 233 | } 234 | } 235 | } 236 | }); 237 | 238 | webview.addEventListener('will-receive-headers', (event: any) => { 239 | const headers = event.headers; 240 | if (headers) { 241 | const contentType = headers['content-type']?.join(' '); 242 | if (contentType && /(ad|track|analytics)/i.test(contentType)) { 243 | blockedRequests.add(event.id); 244 | event.preventDefault(); 245 | } 246 | } 247 | }); 248 | } 249 | 250 | public applyFilters(webview: WebviewTag): void { 251 | webview.executeJavaScript(this.getDOMFilterScript()) 252 | .catch(error => { 253 | console.error('Adblock script error:', error); 254 | }); 255 | (webview as unknown as Electron.WebviewTag).insertCSS(this.getCSSBlockingRules()); 256 | } 257 | 258 | private getCSSBlockingRules(): string { 259 | return this.filters.elementBlocks 260 | .map(selector => `${selector} { display: none !important; }`) 261 | .join('\n'); 262 | } 263 | } -------------------------------------------------------------------------------- /src/assets/image.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_IMAGE = `data:image/svg+xml;base64,${btoa(` 2 | 3 | 4 | `)}`; -------------------------------------------------------------------------------- /src/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Notice, TFile } from 'obsidian'; 2 | import { WebViewModal } from './view/ModalWebView'; 3 | import NetClipPlugin from './main'; 4 | import { WorkspaceLeafWebView, VIEW_TYPE_WORKSPACE_WEBVIEW } from './view/EditorWebView'; 5 | 6 | export class ClipperContextMenu { 7 | private app: any; 8 | private file: TFile; 9 | private url?: string; 10 | private onDelete: (file: TFile) => void; 11 | private onOpenArticle: (file: TFile) => void; 12 | plugin: NetClipPlugin; 13 | 14 | constructor( 15 | app: any, 16 | file: TFile, 17 | onDelete: (file: TFile) => void, 18 | onOpenArticle: (file: TFile) => void, 19 | url?: string, 20 | ) { 21 | this.app = app; 22 | this.file = file; 23 | this.url = url; 24 | this.onDelete = onDelete; 25 | this.onOpenArticle = onOpenArticle; 26 | this.plugin = this.app.plugins.getPlugin('net-clip'); 27 | } 28 | 29 | show(anchorElement: HTMLElement) { 30 | const menu = new Menu(); 31 | 32 | menu.addItem((item) => { 33 | item.setTitle("Open page in editor") 34 | .setIcon("globe") 35 | .onClick(async () => { 36 | if (this.url) { 37 | const existingLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_WORKSPACE_WEBVIEW); 38 | 39 | for (const leaf of existingLeaves) { 40 | await leaf.view.onLoadEvent; 41 | if (leaf.view instanceof WorkspaceLeafWebView && leaf.view.url === this.url) { 42 | this.app.workspace.setActiveLeaf(leaf, { focus: true }); 43 | return; 44 | } 45 | } 46 | 47 | const leaf = this.app.workspace.getLeaf('tab'); 48 | await leaf.setViewState({ 49 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 50 | state: { url: this.url } 51 | }); 52 | 53 | await leaf.view.onLoadEvent; 54 | if (leaf.view instanceof WorkspaceLeafWebView) { 55 | leaf.view.setUrl(this.url); 56 | this.app.workspace.setActiveLeaf(leaf, { focus: true }); 57 | } 58 | } else { 59 | new Notice('No URL found for this clipping'); 60 | } 61 | }); 62 | }); 63 | 64 | 65 | menu.addItem((item) => { 66 | item.setTitle("Open page in modal") 67 | .setIcon("maximize") 68 | .onClick(() => { 69 | if (this.url) { 70 | const modal = new WebViewModal( 71 | this.app, 72 | this.url, 73 | this.plugin 74 | ); 75 | modal.open(); 76 | } else { 77 | new Notice('No URL found for this clipping'); 78 | } 79 | }); 80 | }); 81 | 82 | menu.addItem((item) => { 83 | item 84 | .setTitle("Open in editor") 85 | .setIcon("file-text") 86 | .onClick(() => { 87 | this.onOpenArticle(this.file); 88 | }); 89 | }); 90 | 91 | menu.addItem((item) => { 92 | item 93 | .setTitle("Delete") 94 | .setIcon("trash") 95 | .onClick(() => { 96 | this.onDelete(this.file); 97 | }); 98 | }); 99 | 100 | const rect = anchorElement.getBoundingClientRect(); 101 | menu.showAtPosition({ x: rect.left, y: rect.bottom }); 102 | } 103 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, requestUrl, WorkspaceLeaf, TFile, TAbstractFile } from 'obsidian'; 2 | import { CLIPPER_VIEW, ClipperHomeView } from './view/ClipperView'; 3 | import { sanitizePath, getDomain, normalizeUrl } from './utils'; 4 | import { ContentExtractors } from './Extractors/extractor'; 5 | import { VIEW_TYPE_WORKSPACE_WEBVIEW, WorkspaceLeafWebView } from './view/EditorWebView'; 6 | import { WebViewModal } from './view/ModalWebView'; 7 | import { ClipModal } from './modal/clipModal'; 8 | import { DEFAULT_SETTINGS, NetClipSettings, AIPrompt } from './settings'; 9 | import { GeminiService } from './services/gemini'; 10 | import NetClipSettingTab from './settingTabs'; 11 | import { Menu, Editor, MarkdownView } from 'obsidian'; 12 | import { HOME_TAB_VIEW, HomeTabView } from './view/HomeTab'; 13 | 14 | export default class NetClipPlugin extends Plugin { 15 | private readonly DEMO_CATEGORIES = ['Articles', 'Research', 'Tech']; 16 | contentExtractors: ContentExtractors; 17 | seenItems: Set = new Set(); 18 | settings: NetClipSettings; 19 | public geminiService: GeminiService | null = null; 20 | 21 | isNewContent(content: string): boolean { 22 | if (this.seenItems.has(content)) { 23 | return false; 24 | } 25 | this.seenItems.add(content); 26 | return true; 27 | } 28 | 29 | processContent(content: string): string { 30 | const lines = content.split('\n'); 31 | const uniqueLines = lines.filter(line => this.isNewContent(line.trim())); 32 | return uniqueLines.join('\n'); 33 | } 34 | 35 | private initWebViewLeaf(): void { 36 | const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_WORKSPACE_WEBVIEW); 37 | if (existingLeaf.length > 0) return; 38 | 39 | const leaf = this.app.workspace.getRightLeaf(false); 40 | if (leaf) { 41 | leaf.setViewState({ type: VIEW_TYPE_WORKSPACE_WEBVIEW }); 42 | } 43 | } 44 | 45 | public async FoldersExist() { 46 | const mainFolderPath = this.settings.parentFolderPath 47 | ? `${this.settings.parentFolderPath}/${this.settings.defaultFolderName}` 48 | : this.settings.defaultFolderName; 49 | 50 | try { 51 | if (this.settings.parentFolderPath && !this.app.vault.getFolderByPath(this.settings.parentFolderPath)) { 52 | try { 53 | await this.app.vault.createFolder(this.settings.parentFolderPath); 54 | } catch (error) { 55 | if (!error.message.includes('already exists')) { 56 | throw error; 57 | } 58 | } 59 | } 60 | 61 | if (!this.app.vault.getFolderByPath(mainFolderPath)) { 62 | try { 63 | await this.app.vault.createFolder(mainFolderPath); 64 | 65 | for (const category of this.DEMO_CATEGORIES) { 66 | const categoryPath = `${mainFolderPath}/${category}`; 67 | if (!this.app.vault.getFolderByPath(categoryPath)) { 68 | try { 69 | await this.app.vault.createFolder(categoryPath); 70 | if (!this.settings.categories.includes(category)) { 71 | this.settings.categories.push(category); 72 | } 73 | } catch (error) { 74 | if (!error.message.includes('already exists')) { 75 | throw error; 76 | } 77 | } 78 | } 79 | } 80 | await this.saveSettings(); 81 | new Notice(`Created folders in ${mainFolderPath}`); 82 | } catch (error) { 83 | if (!error.message.includes('already exists')) { 84 | throw error; 85 | } 86 | } 87 | } 88 | 89 | for (const category of this.settings.categories) { 90 | const categoryPath = `${mainFolderPath}/${category}`; 91 | if (!this.app.vault.getFolderByPath(categoryPath)) { 92 | try { 93 | await this.app.vault.createFolder(categoryPath); 94 | } catch (error) { 95 | if (!error.message.includes('already exists')) { 96 | throw error; 97 | } 98 | } 99 | } 100 | } 101 | } catch (error) { 102 | console.error('Error creating folders:', error); 103 | throw error; 104 | } 105 | } 106 | 107 | async onload() { 108 | await this.loadSettings(); 109 | await this.FoldersExist(); 110 | 111 | if (this.settings.geminiApiKey) { 112 | this.geminiService = new GeminiService(this.settings.geminiApiKey, this.settings); 113 | } 114 | 115 | this.registerEvent( 116 | this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor, view: MarkdownView) => { 117 | const cursor = editor.getCursor(); 118 | const line = editor.getLine(cursor.line); 119 | 120 | 121 | const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; 122 | const urlRegex = /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g; 123 | 124 | let match; 125 | let url: string | null = null; 126 | 127 | 128 | while ((match = mdLinkRegex.exec(line)) !== null) { 129 | const linkStart = match.index; 130 | const linkEnd = linkStart + match[0].length; 131 | if (cursor.ch >= linkStart && cursor.ch <= linkEnd) { 132 | url = match[2]; 133 | break; 134 | } 135 | } 136 | 137 | if (!url) { 138 | while ((match = urlRegex.exec(line)) !== null) { 139 | const linkStart = match.index; 140 | const linkEnd = linkStart + match[0].length; 141 | if (cursor.ch >= linkStart && cursor.ch <= linkEnd) { 142 | url = match[0]; 143 | break; 144 | } 145 | } 146 | } 147 | 148 | if (url) { 149 | menu.addItem((item) => { 150 | item 151 | .setTitle("Open in WebView") 152 | .setIcon("globe") 153 | .onClick(async () => { 154 | const leaf = this.app.workspace.getLeaf(true); 155 | await leaf.setViewState({ 156 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 157 | state: { url: url } 158 | }); 159 | this.app.workspace.revealLeaf(leaf); 160 | }); 161 | }); 162 | 163 | menu.addItem((item) => { 164 | item 165 | .setTitle("Open in Modal WebView") 166 | .setIcon("picture-in-picture-2") 167 | .onClick(() => { 168 | new WebViewModal(this.app, url, this).open(); 169 | }); 170 | }); 171 | } 172 | }) 173 | ); 174 | 175 | this.app.workspace.onLayoutReady(() => this.initWebViewLeaf()); 176 | this.contentExtractors = new ContentExtractors(this); 177 | 178 | this.addRibbonIcon('newspaper', 'NetClip', async () => this.activateView()); 179 | 180 | this.addCommand({ 181 | id: 'open-clipper', 182 | name: 'Open clipper', 183 | callback: () => this.activateView() 184 | }); 185 | 186 | this.addCommand({ 187 | id: 'open-modal-clipper', 188 | name: 'Open modal clipper', 189 | callback: () => new ClipModal(this.app, this).open() 190 | }); 191 | 192 | this.addCommand({ 193 | id: 'open-web-editor', 194 | name: 'Open page on editor', 195 | callback: async () => { 196 | const leaf = this.app.workspace.getLeaf(true); 197 | await leaf.setViewState({ type: VIEW_TYPE_WORKSPACE_WEBVIEW, active: true }); 198 | this.app.workspace.revealLeaf(leaf); 199 | } 200 | }); 201 | 202 | this.addCommand({ 203 | id: 'open-web-modal', 204 | name: 'Open page in modal', 205 | callback: () => { 206 | const defaultUrl = this.settings.defaultWebUrl || 'https://google.com'; 207 | new WebViewModal(this.app, defaultUrl, this).open(); 208 | } 209 | }); 210 | 211 | this.addCommand({ 212 | id: 'open-link-in-webview', 213 | name: 'Open link under cursor in WebView', 214 | editorCallback: (editor: Editor) => { 215 | const cursor = editor.getCursor(); 216 | const line = editor.getLine(cursor.line); 217 | const url = this.getLinkUnderCursor(line, cursor.ch); 218 | 219 | if (url) { 220 | const leaf = this.app.workspace.getLeaf(true); 221 | leaf.setViewState({ 222 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 223 | state: { url: url } 224 | }); 225 | this.app.workspace.revealLeaf(leaf); 226 | } else { 227 | new Notice('No link found under cursor'); 228 | } 229 | }, 230 | hotkeys: [{ modifiers: ['Ctrl', 'Shift'], key: 'z' }] 231 | }); 232 | 233 | this.addCommand({ 234 | id: 'open-link-in-modal-webview', 235 | name: 'Open link under cursor in Modal WebView', 236 | editorCallback: (editor: Editor) => { 237 | const cursor = editor.getCursor(); 238 | const line = editor.getLine(cursor.line); 239 | const url = this.getLinkUnderCursor(line, cursor.ch); 240 | 241 | if (url) { 242 | new WebViewModal(this.app, url, this).open(); 243 | } else { 244 | new Notice('No link found under cursor'); 245 | } 246 | }, 247 | hotkeys: [{ modifiers: ['Ctrl', 'Alt'], key: 'z' }] 248 | }); 249 | 250 | this.registerView(CLIPPER_VIEW, (leaf) => 251 | new ClipperHomeView(leaf, this) 252 | ); 253 | 254 | this.registerView(VIEW_TYPE_WORKSPACE_WEBVIEW, (leaf) => 255 | new WorkspaceLeafWebView(leaf, this) 256 | ); 257 | 258 | this.addSettingTab(new NetClipSettingTab(this.app, this)); 259 | 260 | this.registerView(HOME_TAB_VIEW, (leaf) => new HomeTabView(leaf, this)) 261 | 262 | this.registerEvent(this.app.workspace.on('layout-change', () => { 263 | if (this.settings.replaceTabHome) { 264 | this.replaceTabHome(); 265 | } 266 | })); 267 | 268 | this.addCommand({ 269 | id: 'open-home-tab', 270 | name: 'Open Home Tab', 271 | callback: () => this.activateHomeTab() 272 | }); 273 | } 274 | 275 | 276 | 277 | async loadSettings() { 278 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 279 | 280 | if (this.settings.shortcuts && this.settings.shortcuts.length === 0) { 281 | const githubShortcut = { 282 | id: Date.now().toString(), 283 | name: '', 284 | url: 'https://github.com/Elhary/Obsidian-NetClip', 285 | favicon: `https://www.google.com/s2/favicons?domain=github.com&sz=128` 286 | }; 287 | const youtubeShortcut = { 288 | id: Date.now().toString(), 289 | name: '', 290 | url: 'https://youtube.com', 291 | favicon: `https://www.google.com/s2/favicons?domain=youtube.com&sz=128` 292 | }; 293 | const redditShortcut = { 294 | id: Date.now().toString(), 295 | name: '', 296 | url: 'https://www.reddit.com/r/ObsidianMD/', 297 | favicon: `https://www.google.com/s2/favicons?domain=reddit.com&sz=128` 298 | }; 299 | const obsidianShortcut = { 300 | id: Date.now().toString(), 301 | name: '', 302 | url: 'https://forum.obsidian.md/', 303 | favicon: `https://www.google.com/s2/favicons?domain=obsidian.md&sz=128` 304 | }; 305 | this.settings.shortcuts.push(githubShortcut, youtubeShortcut, redditShortcut, obsidianShortcut ); 306 | this.saveSettings(); 307 | } 308 | } 309 | 310 | async saveSettings() { 311 | await this.saveData(this.settings); 312 | await this.updateHomeView(); 313 | } 314 | 315 | async activateView() { 316 | const { workspace } = this.app; 317 | let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(CLIPPER_VIEW)[0] || null; 318 | 319 | if (!leaf) { 320 | switch (this.settings.viewPosition) { 321 | case 'left': leaf = workspace.getLeftLeaf(false); break; 322 | case 'right': leaf = workspace.getRightLeaf(false); break; 323 | default: leaf = workspace.getLeaf(false); 324 | } 325 | } 326 | 327 | if (leaf) { 328 | await leaf.setViewState({ type: CLIPPER_VIEW, active: true }); 329 | workspace.revealLeaf(leaf); 330 | } 331 | } 332 | 333 | async clipWebpage( 334 | url: string, 335 | category: string = '', 336 | selectedPrompt: AIPrompt | AIPrompt[] | null = null, 337 | selectedVariables: Record> | Record = {}, 338 | keepOriginalContent: boolean = true 339 | ): Promise { 340 | if (!this.contentExtractors) { 341 | throw new Error("Content extractors not initialized"); 342 | } 343 | 344 | await this.FoldersExist(); 345 | new Notice("Clipping..."); 346 | 347 | const normalizedUrl = normalizeUrl(url); 348 | if (!normalizedUrl || !normalizedUrl.startsWith('http')) { 349 | throw new Error("Invalid URL provided"); 350 | } 351 | 352 | const urlDomain = getDomain(normalizedUrl).toLowerCase(); 353 | let effectiveCategory = category; 354 | let customSaveLocation = ''; 355 | 356 | if (!category) { 357 | const domainMapping = this.settings.defaultSaveLocations.domainMappings[urlDomain]; 358 | if (domainMapping) { 359 | customSaveLocation = domainMapping; 360 | } else if (this.settings.defaultSaveLocations.defaultLocation) { 361 | customSaveLocation = this.settings.defaultSaveLocations.defaultLocation; 362 | } 363 | } 364 | 365 | const response = await requestUrl({ 366 | url: normalizedUrl 367 | }); 368 | 369 | const html = response.text; 370 | const parser = new DOMParser(); 371 | const doc = parser.parseFromString(html, "text/html"); 372 | let title = doc.querySelector('title')?.textContent || ''; 373 | if (!title) { 374 | const headingElement = doc.querySelector('h1, .title'); 375 | title = headingElement?.textContent?.trim() || `Article from ${getDomain(url)}`; 376 | } 377 | title = title.replace(/[#"]/g, '').trim(); 378 | 379 | let content = this.contentExtractors.extractMainContent(doc, normalizedUrl); 380 | const thumbnailUrl = this.contentExtractors.extractThumbnail(doc); 381 | const author = this.contentExtractors.extractAuthor(doc); 382 | const desc = this.contentExtractors.extractDescription(doc); 383 | const publishTime = this.contentExtractors.extractPublishTime(doc); 384 | const price = this.contentExtractors.extractPrice(doc); 385 | const brand = this.contentExtractors.extractBrand(doc); 386 | const rating = this.contentExtractors.extractRating(doc); 387 | 388 | let folderPath; 389 | if (customSaveLocation) { 390 | folderPath = customSaveLocation; 391 | } else if (category) { 392 | const baseFolderPath = this.settings.parentFolderPath 393 | ? `${this.settings.parentFolderPath}/${this.settings.defaultFolderName}` 394 | : this.settings.defaultFolderName; 395 | folderPath = `${baseFolderPath}/${effectiveCategory}`; 396 | } else { 397 | folderPath = this.settings.parentFolderPath 398 | ? `${this.settings.parentFolderPath}/${this.settings.defaultFolderName}` 399 | : this.settings.defaultFolderName; 400 | } 401 | 402 | if (folderPath && !this.app.vault.getFolderByPath(folderPath)) { 403 | await this.app.vault.createFolder(folderPath); 404 | } 405 | 406 | const wordCount = content.split(/\s+/).length; 407 | const readingTime = this.calculateReadingTime(wordCount); 408 | 409 | const frontmatter = this.generateFrontmatter( 410 | title, url, publishTime, author, desc, readingTime, price, brand, rating, thumbnailUrl 411 | ); 412 | 413 | const formattedContent = content.trim() 414 | .split('\n') 415 | .map(line => line.trim()) 416 | .join('\n'); 417 | 418 | const completeNote = `${frontmatter}\n${formattedContent}\n`; 419 | 420 | let processedContent = completeNote; 421 | if (this.geminiService && selectedPrompt) { 422 | processedContent = await this.geminiService.processContent( 423 | completeNote, 424 | selectedPrompt, 425 | selectedVariables, 426 | keepOriginalContent 427 | ); 428 | 429 | const titleMatch = processedContent.match(/^---[\s\S]*?\ntitle: "([^"]+)"[\s\S]*?\n---/); 430 | if (titleMatch && titleMatch[1]) { 431 | title = titleMatch[1]; 432 | } 433 | } 434 | 435 | const fileName = sanitizePath(`${title}.md`); 436 | const filePath = folderPath ? `${folderPath}/${fileName}` : fileName; 437 | 438 | await this.app.vault.create(filePath, processedContent); 439 | await this.updateHomeView(); 440 | new Notice(`Successfully clipped ${title}`); 441 | 442 | return filePath; 443 | } 444 | 445 | private calculateReadingTime(wordCount: number): string { 446 | const min = Math.floor(wordCount / 250); 447 | const max = Math.ceil(wordCount / 200); 448 | return min === max ? `${min}` : `${min}~${max}`; 449 | } 450 | 451 | private generateFrontmatter( 452 | title: string, url: string, publishTime: string | null, 453 | author: string | null, desc: string | null, readingTime: string, 454 | price: string | null, brand: string | null, rating: string | null, 455 | thumbnailUrl: string | null 456 | ): string { 457 | return `---\n` + 458 | `title: "${title}"\n` + 459 | `source: "${url}"\n` + 460 | (publishTime ? `published: ${new Date(publishTime).toISOString().split('T')[0]}\n` : '') + 461 | (author ? `author: "${author}"\n` : '') + 462 | (desc ? `desc: "${desc}"\n` : '') + 463 | `readingTime: "${readingTime}min"\n` + 464 | (price ? `price: "${price}"\n` : '') + 465 | (brand ? `brand: "${brand}"\n` : '') + 466 | (rating ? `rating: "${rating}"\n` : '') + 467 | `---\n\n` + 468 | (thumbnailUrl ? `![Thumbnail](${thumbnailUrl})\n\n` : ''); 469 | } 470 | 471 | public async updateHomeView() { 472 | const leaves = this.app.workspace.getLeavesOfType(CLIPPER_VIEW); 473 | for (const leaf of leaves) { 474 | const view = leaf.view; 475 | if (view instanceof ClipperHomeView) { 476 | const tabsContainer = view.containerEl.querySelector(".netclip_category_tabs"); 477 | if (tabsContainer instanceof HTMLElement) { 478 | view.renderCategoryTabs(tabsContainer); 479 | } 480 | const container = view.containerEl.querySelector(".netclip_saved_container"); 481 | if (container instanceof HTMLDivElement) { 482 | await view.renderSavedContent(container); 483 | } 484 | } 485 | } 486 | } 487 | 488 | async processExistingNote(filePath: string, prompt: AIPrompt, variables: Record): Promise { 489 | if (!this.geminiService) { 490 | throw new Error("AI service not initialized"); 491 | } 492 | 493 | const file = this.app.vault.getAbstractFileByPath(filePath); 494 | if (!file || !(file instanceof TFile)) { 495 | throw new Error("File not found"); 496 | } 497 | 498 | const currentContent = await this.app.vault.read(file); 499 | const processedContent = await this.geminiService.processContent( 500 | currentContent, 501 | prompt, 502 | variables 503 | ); 504 | 505 | await this.app.vault.modify(file, processedContent); 506 | new Notice(`Successfully processed with ${prompt.name}`); 507 | } 508 | 509 | 510 | private getLinkUnderCursor(line: string, cursorPos: number): string | null { 511 | const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; 512 | const urlRegex = /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g; 513 | 514 | let match; 515 | 516 | while ((match = mdLinkRegex.exec(line)) !== null) { 517 | const linkStart = match.index; 518 | const linkEnd = linkStart + match[0].length; 519 | if (cursorPos >= linkStart && cursorPos <= linkEnd) { 520 | return match[2]; 521 | } 522 | } 523 | 524 | while ((match = urlRegex.exec(line)) !== null) { 525 | const linkStart = match.index; 526 | const linkEnd = linkStart + match[0].length; 527 | if (cursorPos >= linkStart && cursorPos <= linkEnd) { 528 | return match[0]; 529 | } 530 | } 531 | 532 | return null; 533 | } 534 | 535 | 536 | private replaceTabHome() { 537 | const emptyLeaves = this.app.workspace.getLeavesOfType('empty'); 538 | if (emptyLeaves.length > 0) { 539 | emptyLeaves.forEach(leaf => { 540 | leaf.setViewState({ 541 | type: HOME_TAB_VIEW 542 | }); 543 | }); 544 | } 545 | } 546 | 547 | 548 | public activateHomeTab() { 549 | const leaf = this.app.workspace.getLeaf('tab'); 550 | if (leaf) { 551 | leaf.setViewState({ 552 | type: HOME_TAB_VIEW 553 | }); 554 | this.app.workspace.revealLeaf(leaf); 555 | } 556 | } 557 | 558 | public refreshHomeViews(): void { 559 | this.app.workspace.getLeavesOfType(HOME_TAB_VIEW).forEach((leaf) => { 560 | if (leaf.view instanceof HomeTabView) { 561 | leaf.detach(); 562 | const newLeaf = this.app.workspace.getLeaf(); 563 | newLeaf.setViewState({ 564 | type: HOME_TAB_VIEW, 565 | active: true 566 | }); 567 | } 568 | }); 569 | } 570 | } 571 | -------------------------------------------------------------------------------- /src/mediaUtils.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | 3 | export async function findFirstImageInNote(app: App, content: string) { 4 | try { 5 | const markdownMatch = content.match(/!\[(.*?)\]\((\S+?(?:\.(?:jpg|jpeg|png|gif|webp)|format=(?:jpg|jpeg|png|gif|webp))[^\s)]*)\s*(?:\s+["'][^"']*["'])?\s*\)/i); 6 | if (markdownMatch && markdownMatch[2]) { 7 | return markdownMatch[2]; 8 | } 9 | 10 | const internalMatch = content.match(/!?\[\[(.*?\.(?:jpg|jpeg|png|gif|webp))(?:\|.*?)?\]\]/i); 11 | if (internalMatch && internalMatch[1]) { 12 | const file = app.metadataCache.getFirstLinkpathDest(internalMatch[1], ''); 13 | if (file) { 14 | return app.vault.getResourcePath(file); 15 | } 16 | } 17 | 18 | return null; 19 | } catch (error) { 20 | console.error('Error finding image in note:', error); 21 | return null; 22 | } 23 | } -------------------------------------------------------------------------------- /src/modal/ShortcutModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian'; 2 | import { getDomain } from '../utils'; 3 | import { t } from '../translations'; 4 | 5 | export interface Shortcut { 6 | id: string; 7 | name: string; 8 | url: string; 9 | } 10 | 11 | export class ShortcutModal extends Modal { 12 | private shortcut: Shortcut | null; 13 | private nameInput: HTMLInputElement; 14 | private urlInput: HTMLInputElement; 15 | private onSubmit: (shortcut: Shortcut | null) => void; 16 | 17 | constructor(app: App, shortcut: Shortcut | null, onSubmit: (shortcut: Shortcut | null) => void) { 18 | super(app); 19 | this.shortcut = shortcut; 20 | this.onSubmit = onSubmit; 21 | } 22 | 23 | onOpen() { 24 | const { contentEl } = this; 25 | 26 | contentEl.createEl('h2', { text: this.shortcut ? 'Edit shortcut' : 'Add shortcut' }); 27 | 28 | new Setting(contentEl) 29 | .setName('URL') 30 | .setDesc('Enter the website URL') 31 | .addText(text => { 32 | this.urlInput = text.inputEl; 33 | text.setValue(this.shortcut?.url || ''); 34 | text.setPlaceholder('https://example.com'); 35 | }); 36 | 37 | new Setting(contentEl) 38 | .setName('Title (optional)') 39 | .setDesc('Enter shorcut title') 40 | .addText(text => { 41 | this.nameInput = text.inputEl; 42 | text.setValue(this.shortcut?.name || ''); 43 | text.setPlaceholder('My shortcut'); 44 | }); 45 | 46 | const buttonContainer = contentEl.createDiv({ cls: 'netclip-modal-buttons' }); 47 | 48 | buttonContainer.createEl('button', { 49 | text: t('cancel') || 'Cancel', 50 | cls: 'netclip-modal-button-cancel' 51 | }).addEventListener('click', () => { 52 | this.close(); 53 | }); 54 | 55 | buttonContainer.createEl('button', { 56 | text: this.shortcut ? t('update') || 'Update' : t('add') || 'Add', 57 | cls: 'netclip-modal-button-submit' 58 | }).addEventListener('click', () => { 59 | this.handleSubmit(); 60 | }); 61 | } 62 | 63 | private handleSubmit() { 64 | const url = this.urlInput.value.trim(); 65 | 66 | if (!url) { 67 | this.urlInput.classList.add('is-invalid'); 68 | return; 69 | } 70 | 71 | // Add https:// if protocol is missing 72 | let finalUrl = url; 73 | if (!finalUrl.startsWith('http://') && !finalUrl.startsWith('https://')) { 74 | finalUrl = 'https://' + finalUrl; 75 | } 76 | 77 | const name = this.nameInput.value.trim(); 78 | 79 | const shortcut: Shortcut = { 80 | id: this.shortcut?.id || '', 81 | name: name, 82 | url: finalUrl, 83 | }; 84 | 85 | this.onSubmit(shortcut); 86 | this.close(); 87 | } 88 | 89 | onClose() { 90 | const { contentEl } = this; 91 | contentEl.empty(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/modal/aiProcessingModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice, setIcon } from 'obsidian'; 2 | import { AIPrompt } from '../settings'; 3 | import NetClipPlugin from '../main'; 4 | 5 | export class AIProcessingModal extends Modal { 6 | private aiStepElements: HTMLElement[] = []; 7 | private aiStatusText: HTMLElement; 8 | private currentStep = 0; 9 | private aiSteps = [ 10 | 'Extracting content from webpage...', 11 | 'Analyzing content structure...', 12 | 'Processing with AI model...' 13 | ]; 14 | private promptProgressContainer: HTMLElement; 15 | private promptProgressElements: Map = new Map(); 16 | 17 | constructor( 18 | app: App, 19 | private plugin: NetClipPlugin, 20 | private url: string, 21 | private category: string, 22 | private selectedPrompts: AIPrompt[], 23 | private selectedVariables: Record>, 24 | private keepOriginalContent: boolean = true 25 | ) { 26 | super(app); 27 | this.selectedPrompts.forEach(prompt => { 28 | this.aiSteps.push(`Applying prompt: ${prompt.name}...`); 29 | }); 30 | this.aiSteps.push('Formatting final document...'); 31 | } 32 | 33 | async onOpen() { 34 | this.modalEl.addClass('netclip_ai_processing_modal'); 35 | const { contentEl } = this; 36 | contentEl.empty(); 37 | contentEl.addClass('netclip_ai_processing_content'); 38 | 39 | contentEl.createEl('h2', {text: 'AI Processing'}); 40 | 41 | const infoMessage = contentEl.createDiv({cls: 'netclip_info_message'}); 42 | const infoIcon = infoMessage.createDiv({cls: 'netclip_info_icon'}); 43 | setIcon(infoIcon, 'info'); 44 | const infoText = infoMessage.createDiv({cls: 'netclip_info_text'}); 45 | infoText.setText('You can close this modal. You\'ll be notified when it\'s done.'); 46 | 47 | const aiProcessingContainer = contentEl.createDiv({cls: 'netclip_ai_processing_container'}); 48 | const aiAnimationContainer = aiProcessingContainer.createDiv({cls: 'netclip_ai_animation'}); 49 | this.aiStatusText = aiProcessingContainer.createDiv({cls: 'netclip_ai_status'}); 50 | this.aiStatusText.setText('Initializing AI processing...'); 51 | 52 | const typingContainer = aiAnimationContainer.createDiv({cls: 'netclip_typing_animation'}); 53 | for (let i = 0; i < 3; i++) { 54 | typingContainer.createDiv({cls: 'netclip_typing_dot'}); 55 | } 56 | 57 | const aiStepsContainer = aiProcessingContainer.createDiv({cls: 'netclip_ai_steps'}); 58 | 59 | this.aiStepElements = []; 60 | this.aiSteps.forEach(step => { 61 | const stepEl = aiStepsContainer.createDiv({cls: 'netclip_ai_step'}); 62 | stepEl.setText(step); 63 | this.aiStepElements.push(stepEl); 64 | }); 65 | 66 | this.promptProgressContainer = aiProcessingContainer.createDiv({cls: 'netclip_prompt_progress'}); 67 | this.promptProgressContainer.style.display = 'none'; 68 | 69 | document.addEventListener('netclip-ai-progress', this.handleAIProgress.bind(this)); 70 | 71 | this.processWithAnimation(); 72 | } 73 | 74 | private handleAIProgress(event: CustomEvent) { 75 | const { total, current, promptName } = event.detail; 76 | this.aiStatusText.setText(`Processing prompt ${current}/${total}: ${promptName}`); 77 | this.promptProgressContainer.style.display = 'block'; 78 | 79 | if (!this.promptProgressElements.has(promptName)) { 80 | const promptEl = this.promptProgressContainer.createDiv({cls: 'netclip_prompt_item'}); 81 | const promptName_el = promptEl.createDiv({cls: 'netclip_prompt_name'}); 82 | promptName_el.setText(promptName); 83 | const promptStatus = promptEl.createDiv({cls: 'netclip_prompt_status'}); 84 | promptStatus.setText('Processing...'); 85 | promptStatus.addClass('processing'); 86 | this.promptProgressElements.set(promptName, promptStatus); 87 | } 88 | 89 | this.selectedPrompts.forEach((prompt, index) => { 90 | if (index < current - 1) { 91 | const status = this.promptProgressElements.get(prompt.name); 92 | if (status) { 93 | status.setText('Completed'); 94 | status.removeClass('processing'); 95 | status.addClass('completed'); 96 | } 97 | } 98 | }); 99 | } 100 | 101 | private updateStep() { 102 | this.aiStepElements.forEach((el, index) => { 103 | if (index < this.currentStep) { 104 | el.addClass('completed'); 105 | } else if (index === this.currentStep) { 106 | el.addClass('active'); 107 | } else { 108 | el.removeClass('active', 'completed'); 109 | } 110 | }); 111 | } 112 | 113 | async processWithAnimation() { 114 | try { 115 | this.updateStep(); 116 | const stepDelay = 1000; 117 | 118 | await new Promise(resolve => setTimeout(resolve, stepDelay)); 119 | this.currentStep++; 120 | this.updateStep(); 121 | 122 | await new Promise(resolve => setTimeout(resolve, stepDelay)); 123 | this.currentStep++; 124 | this.updateStep(); 125 | 126 | await new Promise(resolve => setTimeout(resolve, stepDelay)); 127 | this.currentStep++; 128 | this.updateStep(); 129 | 130 | await this.plugin.clipWebpage( 131 | this.url, 132 | this.category, 133 | this.selectedPrompts, 134 | this.selectedVariables, 135 | this.keepOriginalContent 136 | ); 137 | 138 | this.currentStep = this.aiSteps.length - 1; 139 | this.updateStep(); 140 | 141 | await new Promise(resolve => setTimeout(resolve, stepDelay)); 142 | 143 | document.removeEventListener('netclip-ai-progress', this.handleAIProgress.bind(this)); 144 | new Notice('AI processing completed successfully!', 5000); 145 | this.close(); 146 | } catch (error) { 147 | this.aiStatusText.setText(`Error: ${error.message}`); 148 | this.aiStatusText.addClass('error'); 149 | 150 | const errorCloseBtn = this.contentEl.createEl('button', { 151 | text: 'Close', 152 | cls: 'netclip_error_close' 153 | }); 154 | errorCloseBtn.addEventListener('click', () => { 155 | document.removeEventListener('netclip-ai-progress', this.handleAIProgress.bind(this)); 156 | this.close(); 157 | }); 158 | 159 | new Notice(`AI processing failed: ${error.message}`, 5000); 160 | } 161 | } 162 | 163 | onClose() { 164 | document.removeEventListener('netclip-ai-progress', this.handleAIProgress.bind(this)); 165 | this.contentEl.empty(); 166 | } 167 | } -------------------------------------------------------------------------------- /src/modal/clipModal.ts: -------------------------------------------------------------------------------- 1 | import {App, Modal, Setting} from 'obsidian'; 2 | import NetClipPlugin from "../main"; 3 | import { normalizeUrl } from "../utils"; 4 | import { AIPrompt } from '../settings'; 5 | import { AIProcessingModal } from './aiProcessingModal'; 6 | 7 | export class ClipModal extends Modal { 8 | modalEl: HTMLElement; 9 | private selectedPrompts: AIPrompt[] = []; 10 | private selectedVariables: Record> = {}; 11 | private keepOriginalContent: boolean; 12 | 13 | constructor(app: App, private plugin: NetClipPlugin){ 14 | super(app); 15 | this.keepOriginalContent = this.plugin.settings.keepOriginalContent; 16 | } 17 | 18 | async tryGetClipboardUrl(): Promise { 19 | try { 20 | const text = await navigator.clipboard.readText(); 21 | if (text.startsWith('http')) { 22 | return text; 23 | } 24 | } catch (error) { 25 | console.error('Failed to read clipboard:', error); 26 | } 27 | return null; 28 | } 29 | 30 | private renderVariableSelectors(promptDiv: HTMLElement, prompt: AIPrompt) { 31 | if (!prompt.variables || Object.keys(prompt.variables).length === 0) return; 32 | 33 | const varContainer = promptDiv.createDiv({ 34 | cls: `prompt-variables-${prompt.name.replace(/\s+/g, '-')}` 35 | }); 36 | 37 | Object.entries(prompt.variables).forEach(([key, options]) => { 38 | new Setting(varContainer) 39 | .setName(key) 40 | .addDropdown(dropdown => { 41 | options.forEach(option => dropdown.addOption(option, option)); 42 | dropdown.onChange(value => { 43 | if (!this.selectedVariables[prompt.name]) { 44 | this.selectedVariables[prompt.name] = {}; 45 | } 46 | this.selectedVariables[prompt.name][key] = value; 47 | }); 48 | }); 49 | }); 50 | } 51 | 52 | async onOpen(){ 53 | this.modalEl.addClass('netclip_clip_modal'); 54 | 55 | const {contentEl} = this; 56 | contentEl.addClass('netclip_clip_modal_content'); 57 | contentEl.createEl('h2', {text: 'Clip webpage'}); 58 | 59 | const clipContainer = contentEl.createDiv({cls: 'netclip_clip_container'}); 60 | const urlContainer = clipContainer.createDiv({cls: 'netclip_clip_url_container'}); 61 | 62 | urlContainer.createEl('label', {text: 'Url:'}); 63 | const urlInput = urlContainer.createEl('input', { 64 | type: 'text', 65 | cls: 'netclip_clip_input', 66 | placeholder: 'Enter URL to clip...' 67 | }); 68 | 69 | const clipboardUrl = await this.tryGetClipboardUrl(); 70 | if (clipboardUrl){ 71 | urlInput.value = clipboardUrl; 72 | } 73 | 74 | const categoryContainer = clipContainer.createDiv({cls: 'netclip_clip_category_container'}); 75 | categoryContainer.createEl('label', {text: 'Save to:'}); 76 | const categorySelect = categoryContainer.createEl('select'); 77 | 78 | categorySelect.createEl('option', { 79 | value: '', 80 | text: 'All' 81 | }); 82 | 83 | this.plugin.settings.categories.forEach(category => { 84 | categorySelect.createEl('option', { 85 | value: category, 86 | text: category 87 | }); 88 | }); 89 | 90 | if (this.plugin.settings.enableAI && this.plugin.settings.geminiApiKey) { 91 | const aipromptContainer = contentEl.createDiv({cls: 'ai_prompt_container'}); 92 | aipromptContainer.createEl('h3', {text: 'AI Processing'}); 93 | const promptContainer = aipromptContainer.createEl('div', {cls: 'netclip_prompt_container'}) 94 | 95 | new Setting(aipromptContainer) 96 | .setName('Keep Original Content') 97 | .setDesc('Keep the original content when applying AI prompts') 98 | .addToggle(toggle => toggle 99 | .setValue(this.keepOriginalContent) 100 | .onChange(value => { 101 | this.keepOriginalContent = value; 102 | this.plugin.settings.keepOriginalContent = value; 103 | this.plugin.saveSettings(); 104 | })); 105 | 106 | const enabledPrompts = this.plugin.settings.prompts.filter(prompt => prompt.enabled); 107 | 108 | enabledPrompts.forEach(prompt => { 109 | const promptDiv = promptContainer.createDiv({cls: 'prompt-section'}); 110 | new Setting(promptDiv) 111 | .setName(prompt.name) 112 | .addToggle(toggle => toggle 113 | .setValue(false) 114 | .onChange(value => { 115 | if (value) { 116 | if (!this.selectedPrompts.includes(prompt)) { 117 | this.selectedPrompts.push(prompt); 118 | this.selectedVariables[prompt.name] = {}; 119 | this.renderVariableSelectors(promptDiv, prompt); 120 | } 121 | } else { 122 | const index = this.selectedPrompts.indexOf(prompt); 123 | if (index > -1) { 124 | this.selectedPrompts.splice(index, 1); 125 | delete this.selectedVariables[prompt.name]; 126 | const varContainer = promptDiv.querySelector(`.prompt-variables-${prompt.name.replace(/\s+/g, '-')}`); 127 | if (varContainer) varContainer.remove(); 128 | } 129 | } 130 | })); 131 | }); 132 | } 133 | 134 | const clipButton = contentEl.createEl('button', {text: 'Clip'}); 135 | 136 | clipButton.addEventListener('click', async () => { 137 | if(urlInput.value){ 138 | const normalizedUrl = normalizeUrl(urlInput.value); 139 | if (normalizedUrl){ 140 | if (this.selectedPrompts.length > 0) { 141 | this.close(); 142 | new AIProcessingModal( 143 | this.app, 144 | this.plugin, 145 | normalizedUrl, 146 | categorySelect.value, 147 | this.selectedPrompts, 148 | this.selectedVariables, 149 | this.keepOriginalContent 150 | ).open(); 151 | } else { 152 | await this.plugin.clipWebpage( 153 | normalizedUrl, 154 | categorySelect.value, 155 | null, 156 | {} 157 | ); 158 | this.close(); 159 | } 160 | } 161 | } 162 | }); 163 | } 164 | 165 | onClose() { 166 | const {contentEl} = this; 167 | contentEl.empty(); 168 | } 169 | } -------------------------------------------------------------------------------- /src/modal/deleteCategory.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal} from 'obsidian'; 2 | 3 | export class DeleteCategoryModal extends Modal{ 4 | private result: boolean = false; 5 | 6 | constructor( 7 | app: App, 8 | private categoryName: string, 9 | private onSubmit: (result: boolean) => void 10 | ){ 11 | super(app); 12 | } 13 | 14 | 15 | onOpen() { 16 | 17 | const {contentEl} = this; 18 | 19 | contentEl.createEl('h2', {text: 'Delete category'}); 20 | contentEl.createEl('p', { 21 | text: `The folder "${this.categoryName}" is not empty. Are you sure you want to delete it and all its contents?` 22 | }); 23 | 24 | const buttonContainer = contentEl.createEl('div', { 25 | cls: 'netclip_button-container' 26 | }); 27 | 28 | 29 | 30 | const confirmButton = buttonContainer.createEl('button', { 31 | text: 'Delete', 32 | cls: 'netclip_warning' 33 | }); 34 | 35 | const cancelButton = buttonContainer.createEl('button', { 36 | text: 'Cancel', 37 | cls: 'netclip_cancel' 38 | }); 39 | 40 | cancelButton.onclick = () => { 41 | this.result = false; 42 | this.close(); 43 | }; 44 | 45 | confirmButton.onclick = () => { 46 | this.result = true; 47 | this.close(); 48 | } 49 | 50 | } 51 | 52 | onClose() { 53 | const { contentEl } = this; 54 | contentEl.empty(); 55 | this.onSubmit(this.result); 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/modal/deleteFiles.ts: -------------------------------------------------------------------------------- 1 | import { Modal, TFile } from 'obsidian'; 2 | 3 | export class DeleteConfirmationModal extends Modal { 4 | private file: TFile; 5 | private onConfirmDelete: () => Promise; 6 | 7 | constructor(app: any, file: TFile, onConfirmDelete: () => Promise) { 8 | super(app); 9 | this.file = file; 10 | this.onConfirmDelete = onConfirmDelete; 11 | } 12 | 13 | onOpen() { 14 | this.titleEl.setText('Confirm delete'); 15 | this.contentEl.createEl('p', { text: `Are you sure you want to delete the article "${this.file.basename}"?` }); 16 | 17 | const buttonContainer = this.contentEl.createEl('div', { cls: 'netclip_button-container' }); 18 | 19 | const confirmButton = buttonContainer.createEl('button', { 20 | cls: 'netclip_warning', 21 | text: 'Delete' 22 | }); 23 | 24 | const cancelButton = buttonContainer.createEl('button', { text: 'Cancel' }); 25 | 26 | confirmButton.addEventListener('click', async () => { 27 | await this.onConfirmDelete(); 28 | this.close(); 29 | }); 30 | 31 | cancelButton.addEventListener('click', () => { 32 | this.close(); 33 | }); 34 | } 35 | 36 | onClose() { 37 | this.contentEl.empty(); 38 | } 39 | } -------------------------------------------------------------------------------- /src/modal/folderSelection.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting, TFolder, setIcon } from 'obsidian'; 2 | import NetClipPlugin from '../main'; 3 | import { t } from '../translations'; 4 | 5 | export class FolderSelectionModal extends Modal { 6 | private plugin: NetClipPlugin; 7 | private folderCallback: (folderPath: string) => void; 8 | private selectedFolderPath: string = ''; 9 | private searchInput: HTMLInputElement; 10 | private folderList: HTMLElement; 11 | private allFolders: TFolder[] = []; 12 | 13 | constructor(app: App, plugin: NetClipPlugin) { 14 | super(app); 15 | this.plugin = plugin; 16 | this.selectedFolderPath = plugin.settings.parentFolderPath; 17 | } 18 | 19 | onChooseFolder(callback: (folderPath: string) => void) { 20 | this.folderCallback = callback; 21 | } 22 | 23 | onOpen() { 24 | const { contentEl } = this; 25 | contentEl.empty(); 26 | contentEl.addClass('netclip-folder-selection-modal'); 27 | 28 | contentEl.createEl('h2', { text: t('select_parent_folder') }); 29 | contentEl.createEl('p', { text: t('select_parent_folder_desc') }); 30 | 31 | 32 | const searchContainer = contentEl.createDiv('netclip-folder-search'); 33 | this.searchInput = searchContainer.createEl('input', { 34 | type: 'text', 35 | placeholder: 'Search folders...', 36 | cls: 'netclip-folder-search-input' 37 | }); 38 | 39 | this.searchInput.addEventListener('input', () => { 40 | this.filterFolders(this.searchInput.value); 41 | }); 42 | 43 | const rootSetting = new Setting(contentEl); 44 | 45 | const rootNameContainer = document.createElement('span'); 46 | rootNameContainer.className = 'netclip-folder-name-container'; 47 | const rootIconContainer = document.createElement('span'); 48 | rootIconContainer.className = 'netclip-folder-icon'; 49 | setIcon(rootIconContainer, 'vault'); 50 | rootNameContainer.appendChild(rootIconContainer); 51 | const rootTextSpan = document.createElement('span'); 52 | rootTextSpan.textContent = t('vault_root'); 53 | rootNameContainer.appendChild(rootTextSpan); 54 | 55 | rootSetting.nameEl.empty(); 56 | rootSetting.nameEl.appendChild(rootNameContainer); 57 | rootSetting.setDesc(t('store_in_root_desc')); 58 | 59 | rootSetting.addButton(button => button 60 | .setButtonText(t('select')) 61 | .onClick(() => { 62 | this.selectedFolderPath = ''; 63 | this.close(); 64 | this.folderCallback(''); 65 | })); 66 | 67 | this.allFolders = this.getAllFolders(); 68 | 69 | contentEl.createEl('h3', { text: t('available_folders') }); 70 | this.folderList = contentEl.createEl('div', { cls: 'netclip-folder-list' }); 71 | 72 | this.displayFolders(this.allFolders); 73 | 74 | new Setting(contentEl) 75 | .addButton(button => button 76 | .setButtonText(t('cancel')) 77 | .onClick(() => { 78 | this.close(); 79 | })); 80 | } 81 | 82 | onClose() { 83 | const { contentEl } = this; 84 | contentEl.empty(); 85 | } 86 | 87 | private getAllFolders(): TFolder[] { 88 | const folders: TFolder[] = []; 89 | 90 | const getFolders = (folder: TFolder) => { 91 | folders.push(folder); 92 | 93 | for (const child of folder.children) { 94 | if (child instanceof TFolder) { 95 | getFolders(child); 96 | } 97 | } 98 | }; 99 | 100 | for (const child of this.app.vault.getRoot().children) { 101 | if (child instanceof TFolder) { 102 | getFolders(child); 103 | } 104 | } 105 | 106 | return folders.sort((a, b) => a.path.localeCompare(b.path)); 107 | } 108 | 109 | private displayFolders(folders: TFolder[]) { 110 | this.folderList.empty(); 111 | 112 | folders.forEach(folder => { 113 | const folderPath = folder.path; 114 | const folderSetting = new Setting(this.folderList); 115 | 116 | const nameContainer = document.createElement('span'); 117 | nameContainer.className = 'netclip-folder-name-container'; 118 | const iconContainer = document.createElement('span'); 119 | iconContainer.className = 'netclip-folder-icon'; 120 | setIcon(iconContainer, 'folder'); 121 | 122 | nameContainer.appendChild(iconContainer); 123 | const textSpan = document.createElement('span'); 124 | textSpan.textContent = folderPath; 125 | nameContainer.appendChild(textSpan); 126 | 127 | folderSetting.nameEl.empty(); 128 | folderSetting.nameEl.appendChild(nameContainer); 129 | 130 | folderSetting.addButton(button => button 131 | .setButtonText(t('select')) 132 | .onClick(() => { 133 | this.selectedFolderPath = folderPath; 134 | this.close(); 135 | this.folderCallback(folderPath); 136 | })); 137 | }); 138 | } 139 | 140 | private filterFolders(searchTerm: string) { 141 | const normalizedSearch = searchTerm.toLowerCase(); 142 | const filteredFolders = this.allFolders.filter(folder => 143 | folder.path.toLowerCase().includes(normalizedSearch) 144 | ); 145 | this.displayFolders(filteredFolders); 146 | } 147 | } -------------------------------------------------------------------------------- /src/modal/promptModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian'; 2 | import { AIPrompt } from '../settings'; 3 | 4 | export class PromptModal extends Modal { 5 | private prompt: AIPrompt; 6 | private tempPrompt: AIPrompt; 7 | private onSave: (prompt: AIPrompt) => void; 8 | private pendingVariableChanges: Map = new Map(); 9 | 10 | constructor(app: App, prompt: AIPrompt, onSave: (prompt: AIPrompt) => void) { 11 | super(app); 12 | this.prompt = prompt; 13 | this.tempPrompt = JSON.parse(JSON.stringify(prompt)); 14 | this.onSave = onSave; 15 | } 16 | 17 | onOpen() { 18 | const { contentEl } = this; 19 | contentEl.empty(); 20 | this.modalEl.addClass('netclip_prompt_modal'); 21 | 22 | 23 | contentEl.createEl('h2', { text: 'Edit Prompt' }); 24 | 25 | new Setting(contentEl) 26 | .setName('Name') 27 | .addText(text => text 28 | .setValue(this.tempPrompt.name) 29 | .onChange(value => this.tempPrompt.name = value)); 30 | 31 | new Setting(contentEl) 32 | .setName('Prompt') 33 | .setDesc('Use ${variableName} for variables. ${content} is a special built-in variable that contains the extracted content.') 34 | .addTextArea(text => text 35 | .setValue(this.tempPrompt.prompt) 36 | .onChange(value => this.tempPrompt.prompt = value)); 37 | 38 | const variablesContainer = contentEl.createDiv(); 39 | variablesContainer.createEl('h3', { text: 'Variables' }); 40 | 41 | Object.entries(this.tempPrompt.variables || {}).forEach(([key, values]) => { 42 | this.createVariableSection(variablesContainer, key, values); 43 | }); 44 | 45 | new Setting(contentEl) 46 | .addButton(btn => btn 47 | .setButtonText('Add Variable') 48 | .onClick(() => { 49 | const newKey = 'newVariable' + Object.keys(this.tempPrompt.variables || {}).length; 50 | this.tempPrompt.variables = { 51 | ...this.tempPrompt.variables, 52 | [newKey]: [] 53 | }; 54 | this.createVariableSection(variablesContainer, newKey, []); 55 | })); 56 | 57 | new Setting(contentEl) 58 | .addButton(btn => btn 59 | .setButtonText('Save') 60 | .setCta() 61 | .onClick(() => { 62 | for (const [oldKey, change] of this.pendingVariableChanges) { 63 | const newVars = { ...this.tempPrompt.variables }; 64 | if (oldKey !== change.newName) { 65 | delete newVars[oldKey]; 66 | } 67 | newVars[change.newName] = change.values; 68 | this.tempPrompt.variables = newVars; 69 | } 70 | this.onSave(this.tempPrompt); 71 | this.close(); 72 | })) 73 | .addButton(btn => btn 74 | .setButtonText('Cancel') 75 | .onClick(() => { 76 | this.tempPrompt = JSON.parse(JSON.stringify(this.prompt)); 77 | this.close(); 78 | })); 79 | } 80 | 81 | private createVariableSection(container: HTMLElement, key: string, values: string[]) { 82 | const varContainer = container.createDiv({ cls: 'prompt-variable-container' }); 83 | 84 | new Setting(varContainer) 85 | .setName('Variable Name') 86 | .setDesc('Enter a name for your variable. Use ${variableName} in your prompt to reference it.') 87 | .addText(text => { 88 | text.setValue(key) 89 | .setPlaceholder("Enter variable name"); 90 | 91 | text.inputEl.addEventListener('input', (e) => { 92 | const newName = (e.target as HTMLInputElement).value; 93 | if (newName !== key) { 94 | this.pendingVariableChanges.set(key, { 95 | newName, 96 | values: this.tempPrompt.variables[key] 97 | }); 98 | } else { 99 | this.pendingVariableChanges.delete(key); 100 | } 101 | }); 102 | return text; 103 | }); 104 | 105 | new Setting(varContainer) 106 | .setName('Variable Values') 107 | .setDesc('Add possible values for this variable, one per line') 108 | .addTextArea(text => { 109 | text.setPlaceholder("Enter possible values, one per line") 110 | .setValue(values.join('\n')) 111 | .onChange(value => { 112 | const newValues = value.split('\n').filter(v => v.trim()); 113 | const existingChange = this.pendingVariableChanges.get(key); 114 | if (existingChange) { 115 | existingChange.values = newValues; 116 | } else { 117 | this.pendingVariableChanges.set(key, { 118 | newName: key, 119 | values: newValues 120 | }); 121 | } 122 | }); 123 | return text; 124 | }); 125 | 126 | new Setting(varContainer) 127 | .addButton(btn => btn 128 | .setIcon('trash') 129 | .setClass('netclip_trash') 130 | .setTooltip('Delete variable') 131 | .onClick(() => { 132 | const modal = new Modal(this.app); 133 | modal.titleEl.setText('Delete Variable'); 134 | modal.contentEl.createDiv().setText(`Are you sure you want to delete the variable "${key}"?`); 135 | 136 | new Setting(modal.contentEl) 137 | .addButton(btn => btn 138 | .setButtonText('Cancel') 139 | .onClick(() => { 140 | modal.close(); 141 | })) 142 | .addButton(btn => btn 143 | .setButtonText('Delete') 144 | .setWarning() 145 | .onClick(() => { 146 | const newVars = { ...this.tempPrompt.variables }; 147 | delete newVars[key]; 148 | this.tempPrompt.variables = newVars; 149 | varContainer.remove(); 150 | modal.close(); 151 | })); 152 | 153 | modal.open(); 154 | })); 155 | } 156 | 157 | onClose() { 158 | const { contentEl } = this; 159 | contentEl.empty(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/modal/warningModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian'; 2 | 3 | export class WarningModal extends Modal { 4 | private onConfirm: (result: boolean) => void; 5 | private message: string; 6 | private title: string; 7 | 8 | constructor(app: App, title: string, message: string, onConfirm: (result: boolean) => void) { 9 | super(app); 10 | this.title = title; 11 | this.message = message; 12 | this.onConfirm = onConfirm; 13 | } 14 | 15 | onOpen() { 16 | this.titleEl.setText(this.title); 17 | this.contentEl.createDiv().setText(this.message); 18 | 19 | new Setting(this.contentEl) 20 | .addButton(btn => btn 21 | .setButtonText('Cancel') 22 | .onClick(() => { 23 | this.onConfirm(false); 24 | this.close(); 25 | })) 26 | .addButton(btn => btn 27 | .setButtonText('Delete') 28 | .setWarning() 29 | .onClick(() => { 30 | this.onConfirm(true); 31 | this.close(); 32 | })); 33 | } 34 | 35 | onClose() { 36 | this.contentEl.empty(); 37 | } 38 | } -------------------------------------------------------------------------------- /src/search/fetchSuggestions.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | 3 | export const fetchSuggestions = ( 4 | query: string, 5 | suggestionContainer: HTMLElement, 6 | suggestionsBox: HTMLElement, 7 | selectSuggestion: (suggestion: string) => void 8 | ): void => { 9 | 10 | while (suggestionsBox.firstChild) { 11 | suggestionsBox.removeChild(suggestionsBox.firstChild); 12 | } 13 | 14 | if (!query || query.trim() === '') { 15 | suggestionContainer.classList.add('netclip_search_hidden'); 16 | return; 17 | } 18 | 19 | requestUrl({ 20 | url: `https://suggestqueries.google.com/complete/search?client=chrome&q=${encodeURIComponent(query)}`, 21 | method: 'GET', 22 | headers: { 23 | 'User-Agent': 'Mozilla/5.0', 24 | 'Accept': 'application/json' 25 | }, 26 | }).then(response => { 27 | if (response.status !== 200) { 28 | throw new Error(`HTTP error! status: ${response.status}`); 29 | } 30 | 31 | try { 32 | const data = JSON.parse(response.text); 33 | const suggestions = data[1] || []; 34 | 35 | suggestions.forEach((suggestion: string) => { 36 | const suggestionDiv = document.createElement('div'); 37 | suggestionDiv.classList.add('netClip-suggestion-item'); 38 | 39 | 40 | const textSpan = document.createElement('span'); 41 | textSpan.textContent = suggestion; 42 | 43 | 44 | suggestionDiv.appendChild(textSpan); 45 | suggestionDiv.addEventListener('click', () => selectSuggestion(suggestion)); 46 | suggestionsBox.appendChild(suggestionDiv); 47 | }); 48 | 49 | 50 | if(suggestions.length > 0){ 51 | suggestionContainer.classList.remove('netclip_search_hidden'); 52 | }else{ 53 | suggestionContainer.classList.add('netclip_search_hidden'); 54 | } 55 | 56 | } catch (parseError) { 57 | console.error('Error parsing suggestions:', parseError); 58 | suggestionContainer.classList.add('netclip_search_hidden'); 59 | } 60 | }).catch(error => { 61 | console.error('Error fetching suggestions:', error); 62 | suggestionContainer.classList.add('netclip_search_hidden'); 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /src/search/search.ts: -------------------------------------------------------------------------------- 1 | import { baseSearchUrls } from './searchUrls'; 2 | import { fetchSuggestions } from './fetchSuggestions'; 3 | 4 | export interface WebSearchSettings { 5 | searchEngine?: 'google' | 'youtube' | 'bing' | 'perplexity' | 'duckduckgo' | 'genspark' | 'kagi'; 6 | } 7 | 8 | export class WebSearch { 9 | 10 | private searchInput: HTMLInputElement; 11 | private suggestionsBox: HTMLElement; 12 | private suggestionContainer: HTMLElement; 13 | private currentSuggestionIndex: number = -1; 14 | private settings: WebSearchSettings; 15 | private isVisible: boolean = false; 16 | 17 | 18 | private baseSearchUrls: Record = baseSearchUrls; 19 | 20 | // Store bound event listeners 21 | private handleInput: (event: Event) => void; 22 | private handleKeydown: (event: KeyboardEvent) => void; 23 | private handleBlur: (event: FocusEvent) => void; 24 | private handleWindowClick: (event: MouseEvent) => void; 25 | 26 | constructor( 27 | searchInput: HTMLInputElement, 28 | suggestionContainer: HTMLElement, 29 | suggestionsBox: HTMLElement, 30 | settings: WebSearchSettings = {} 31 | ) { 32 | this.searchInput = searchInput; 33 | this.suggestionContainer = suggestionContainer; 34 | this.suggestionsBox = suggestionsBox; 35 | this.settings = { 36 | searchEngine: 'google', 37 | ...settings 38 | }; 39 | 40 | 41 | this.suggestionContainer.classList.add('netclip_search_hidden'); 42 | 43 | // Bind event handlers 44 | this.handleInput = this.onInput.bind(this); 45 | this.handleKeydown = this.onKeydown.bind(this); 46 | this.handleBlur = this.onBlur.bind(this); 47 | this.handleWindowClick = this.onWindowClick.bind(this); 48 | 49 | this.setupEventListeners(); 50 | } 51 | 52 | private setupEventListeners(): void { 53 | this.searchInput.addEventListener('input', this.handleInput); 54 | this.searchInput.addEventListener('keydown', this.handleKeydown); 55 | this.searchInput.addEventListener('blur', this.handleBlur); 56 | window.addEventListener('click', this.handleWindowClick, true); 57 | 58 | const frameContainer = document.querySelector('.netClip_frame-container'); 59 | if (frameContainer) { 60 | frameContainer.addEventListener('click', () => { 61 | this.hideSuggestions(); 62 | }, true); 63 | } 64 | } 65 | 66 | private onInput(): void { 67 | const query = this.searchInput.value.trim(); 68 | if (query === '') { 69 | this.hideSuggestions(); 70 | } else { 71 | this.showSuggestions(); 72 | fetchSuggestions( 73 | query, 74 | this.suggestionContainer, 75 | this.suggestionsBox, 76 | this.selectSuggestion.bind(this) 77 | ); 78 | } 79 | } 80 | 81 | private onKeydown(event: KeyboardEvent): void { 82 | const suggestions = this.suggestionsBox.children; 83 | switch (event.key) { 84 | case 'ArrowDown': 85 | event.preventDefault(); 86 | this.navigateSuggestions('down', suggestions); 87 | break; 88 | case 'ArrowUp': 89 | event.preventDefault(); 90 | this.navigateSuggestions('up', suggestions); 91 | break; 92 | case 'Enter': 93 | event.preventDefault(); 94 | this.handleEnterKey(suggestions); 95 | break; 96 | case 'Escape': 97 | this.hideSuggestions(); 98 | break; 99 | } 100 | } 101 | 102 | private onBlur(event: FocusEvent): void { 103 | setTimeout(() => { 104 | if (!this.suggestionContainer.contains(document.activeElement)) { 105 | this.hideSuggestions(); 106 | } 107 | }, 200); 108 | } 109 | 110 | private onWindowClick(event: MouseEvent): void { 111 | const target = event.target as HTMLElement; 112 | if (!this.searchInput.contains(target) && 113 | !this.suggestionContainer.contains(target)) { 114 | this.hideSuggestions(); 115 | } 116 | } 117 | 118 | private isValidUrl(str: string): boolean { 119 | try { 120 | new URL(str); 121 | return true; 122 | } catch { 123 | return false; 124 | } 125 | } 126 | 127 | private constructSearchUrl(query: string): string { 128 | const selectedEngine = this.settings.searchEngine || 'google'; 129 | const baseSearchUrl = this.baseSearchUrls[selectedEngine]; 130 | const encodedQuery = encodeURIComponent(query.trim()); 131 | return `${baseSearchUrl}${encodedQuery}`; 132 | } 133 | 134 | private navigateToQuery(query: string): string { 135 | const searchUrl = this.isValidUrl(query) 136 | ? query 137 | : this.constructSearchUrl(query); 138 | 139 | const event = new CustomEvent('search-query', { 140 | detail: { url: searchUrl, query: query } 141 | }); 142 | this.searchInput.dispatchEvent(event); 143 | 144 | return searchUrl; 145 | } 146 | 147 | private selectSuggestion(suggestion: string): void { 148 | this.searchInput.value = suggestion; 149 | this.navigateToQuery(suggestion); 150 | this.hideSuggestions(); 151 | } 152 | 153 | private navigateSuggestions(direction: 'up' | 'down', suggestions: HTMLCollection): void { 154 | if (suggestions.length === 0) return; 155 | 156 | if (this.currentSuggestionIndex !== -1) { 157 | (suggestions[this.currentSuggestionIndex] as HTMLElement).classList.remove('selected'); 158 | } 159 | 160 | if (direction === 'down') { 161 | this.currentSuggestionIndex = 162 | this.currentSuggestionIndex < suggestions.length - 1 163 | ? this.currentSuggestionIndex + 1 164 | : -1; 165 | } else { 166 | this.currentSuggestionIndex = 167 | this.currentSuggestionIndex > -1 168 | ? this.currentSuggestionIndex - 1 169 | : suggestions.length - 1; 170 | } 171 | if (this.currentSuggestionIndex === -1) { 172 | this.searchInput.value = this.searchInput.getAttribute('data-original-value') || ''; 173 | } else { 174 | const selectedSuggestion = suggestions[this.currentSuggestionIndex] as HTMLElement; 175 | selectedSuggestion.classList.add('selected'); 176 | this.searchInput.value = selectedSuggestion.textContent || ''; 177 | } 178 | } 179 | 180 | 181 | private handleEnterKey(suggestions: HTMLCollection): void { 182 | if (this.currentSuggestionIndex !== -1 && suggestions[this.currentSuggestionIndex]) { 183 | (suggestions[this.currentSuggestionIndex] as HTMLElement).click(); 184 | } else { 185 | const query = this.searchInput.value; 186 | if (query) { 187 | this.navigateToQuery(query); 188 | } 189 | } 190 | this.hideSuggestions(); 191 | } 192 | 193 | 194 | private showSuggestions(): void { 195 | this.isVisible = true; 196 | this.suggestionContainer.classList.remove('netclip_search_hidden'); 197 | } 198 | 199 | private hideSuggestions(): void { 200 | this.suggestionContainer.classList.add('netclip_search_hidden'); 201 | while (this.suggestionsBox.firstChild) { 202 | this.suggestionsBox.removeChild(this.suggestionsBox.firstChild); 203 | } 204 | this.currentSuggestionIndex = -1; 205 | } 206 | 207 | public unload(): void { 208 | 209 | this.searchInput.removeEventListener('input', this.handleInput); 210 | this.searchInput.removeEventListener('keydown', this.handleKeydown); 211 | this.searchInput.removeEventListener('blur', this.handleBlur); 212 | window.removeEventListener('click', this.handleWindowClick); 213 | 214 | this.hideSuggestions(); 215 | 216 | 217 | if (this.suggestionContainer.parentNode) { 218 | this.suggestionContainer.remove(); 219 | } 220 | if (this.suggestionsBox.parentNode) { 221 | this.suggestionsBox.remove(); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/search/searchUrls.ts: -------------------------------------------------------------------------------- 1 | export const baseSearchUrls: Record = { 2 | google: 'https://www.google.com/search?q=', 3 | youtube: 'https://www.youtube.com/results?search_query=', 4 | bing: 'https://www.bing.com/search?q=', 5 | perplexity: 'https://www.perplexity.ai/search/new?q=', 6 | duckduckgo: 'https://duckduckgo.com/?q=', 7 | genspark: 'https://www.genspark.ai/search?query=', 8 | kagi: 'https://kagi.com/search?q=', 9 | yahoo: 'http://search.yahoo.com/search?p=' 10 | }; 11 | -------------------------------------------------------------------------------- /src/services/gemini.ts: -------------------------------------------------------------------------------- 1 | import { GoogleGenerativeAI } from "@google/generative-ai"; 2 | import { AIPrompt } from "../settings"; 3 | import { NetClipSettings } from "../settings"; 4 | 5 | const SYSTEM_INSTRUCTION = `YAML Property Rules: 6 | 1. Properties must be at the top of the note 7 | 2. Each property value should be concise (max 150 characters) 8 | 3. Title should be clear and descriptive (max 100 characters) 9 | 4. Description should be a brief summary (max 150 characters) 10 | 5. Keep all property values in a single line 11 | 6. Use quotes for values containing special characters 12 | 7. IMPORTANT: Never remove or modify the ![Thumbnail]() image tag if it exists`; 13 | 14 | export class GeminiService { 15 | private genAI: GoogleGenerativeAI; 16 | private model: any; 17 | private settings: NetClipSettings; 18 | 19 | constructor(apiKey: string, settings: NetClipSettings) { 20 | this.genAI = new GoogleGenerativeAI(apiKey); 21 | this.model = this.genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); 22 | this.settings = settings; 23 | } 24 | 25 | private replaceVariables(prompt: string, variables: Record): string { 26 | return prompt.replace(/\${(\w+)}/g, (match, variable) => { 27 | return variables[variable] || match; 28 | }); 29 | } 30 | 31 | private extractFrontmatterAndContent(markdown: string): { frontmatter: string | null; frontmatterObj: Record; content: string } { 32 | const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; 33 | const match = markdown.match(frontmatterRegex); 34 | 35 | if (!match) { 36 | return { 37 | frontmatter: null, 38 | frontmatterObj: {}, 39 | content: markdown 40 | }; 41 | } 42 | 43 | const frontmatter = match[0]; 44 | const content = markdown.slice(frontmatter.length); 45 | const frontmatterContent = match[1]; 46 | 47 | const frontmatterObj: Record = {}; 48 | const lines = frontmatterContent.split('\n'); 49 | 50 | for (const line of lines) { 51 | const colonIndex = line.indexOf(':'); 52 | if (colonIndex !== -1) { 53 | const key = line.slice(0, colonIndex).trim(); 54 | let value = line.slice(colonIndex + 1).trim(); 55 | 56 | if (value.startsWith('"') && value.endsWith('"')) { 57 | value = value.slice(1, -1); 58 | } 59 | 60 | frontmatterObj[key] = value; 61 | } 62 | } 63 | 64 | return { 65 | frontmatter, 66 | frontmatterObj, 67 | content 68 | }; 69 | } 70 | 71 | private generateFrontmatter(frontmatterObj: Record): string { 72 | const lines = ['---']; 73 | 74 | for (const [key, value] of Object.entries(frontmatterObj)) { 75 | const needsQuotes = typeof value === 'string' && ( 76 | value.includes(':') || 77 | value.includes('"') || 78 | value.includes("'") || 79 | value.includes('\n') || 80 | value.includes('#') || 81 | /^[0-9]/.test(value) 82 | ); 83 | 84 | const formattedValue = needsQuotes ? `"${value.replace(/"/g, '\\"')}"` : value; 85 | lines.push(`${key}: ${formattedValue}`); 86 | } 87 | 88 | lines.push('---\n'); 89 | return lines.join('\n'); 90 | } 91 | 92 | async processContent( 93 | markdown: string, 94 | prompts: AIPrompt | AIPrompt[] | null, 95 | variables: Record> | Record, 96 | keepOriginalContent: boolean = true 97 | ): Promise { 98 | if (!this.model) { 99 | throw new Error("Gemini model not initialized"); 100 | } 101 | 102 | if (!prompts) { 103 | return markdown; 104 | } 105 | 106 | const promptArray = Array.isArray(prompts) ? prompts : [prompts]; 107 | const variablesMap = Array.isArray(prompts) ? variables as Record> : { single: variables as Record }; 108 | 109 | let { frontmatterObj, content } = this.extractFrontmatterAndContent(markdown); 110 | 111 | const thumbnailMatch = content.match(/^!\[Thumbnail\]\([^)]*\)\n*/); 112 | const thumbnailPart = thumbnailMatch ? thumbnailMatch[0] : ''; 113 | 114 | let currentContent = content.replace(/^!\[Thumbnail\]\([^)]*\)\n*/, ''); 115 | let originalContent = currentContent; 116 | let currentFrontmatter = frontmatterObj; 117 | 118 | const progressEvent = new CustomEvent('netclip-ai-progress', { 119 | detail: { total: promptArray.length, current: 0, promptName: '' } 120 | }); 121 | 122 | for (let i = 0; i < promptArray.length; i++) { 123 | const prompt = promptArray[i]; 124 | if (!prompt) continue; 125 | 126 | progressEvent.detail.current = i + 1; 127 | progressEvent.detail.promptName = prompt.name; 128 | document.dispatchEvent(progressEvent); 129 | 130 | const promptVars = Array.isArray(prompts) ? variablesMap[prompt.name] || {} : variablesMap.single; 131 | 132 | const processedPrompt = this.replaceVariables(prompt.prompt, { 133 | ...promptVars, 134 | article: currentContent 135 | }); 136 | 137 | const singlePrompt = `System Instruction for YAML Properties: 138 | ${SYSTEM_INSTRUCTION} 139 | 140 | Current Frontmatter: 141 | ${JSON.stringify(currentFrontmatter, null, 2)} 142 | 143 | Content to Process: 144 | ${currentContent} 145 | 146 | Your task: 147 | ${processedPrompt} 148 | 149 | Return the result in this exact format: 150 | --- 151 | [Your modified frontmatter here, following the system instructions] 152 | --- 153 | 154 | [Your processed content here] 155 | 156 | IMPORTANT: 157 | - Keep all required frontmatter properties 158 | - Follow the system instructions for property formatting 159 | - Include your processed content after the frontmatter 160 | - DO NOT remove or modify the ![Thumbnail]() image tag if it exists 161 | - Keep the thumbnail image tag exactly as is, at its original position`; 162 | 163 | const result = await this.model.generateContent(singlePrompt); 164 | const stepProcessedContent = result.response.text(); 165 | 166 | const processedParts = this.extractFrontmatterAndContent(stepProcessedContent); 167 | 168 | if (processedParts.frontmatter) { 169 | currentFrontmatter = processedParts.frontmatterObj; 170 | currentContent = processedParts.content.trim(); 171 | } else { 172 | currentContent = stepProcessedContent.trim(); 173 | } 174 | } 175 | 176 | const newFrontmatter = this.generateFrontmatter(currentFrontmatter); 177 | let finalContent = ''; 178 | 179 | if (keepOriginalContent) { 180 | finalContent = newFrontmatter + thumbnailPart + originalContent + '\n\n## AI Generated Content\n\n' + currentContent + '\n'; 181 | } else { 182 | finalContent = newFrontmatter + thumbnailPart + currentContent + '\n'; 183 | } 184 | 185 | return finalContent; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { Shortcut } from "./modal/ShortcutModal"; 2 | 3 | export interface NetClipSettings { 4 | viewPosition: 'left' | 'right' | 'default'; 5 | defaultFolderName: string; 6 | parentFolderPath: string; 7 | defaultWebUrl: string; 8 | searchEngine: 'google' | 'youtube' | 'bing' | 'perplexity' | 'duckduckgo' | 'genspark' | 'kagi'; 9 | categories: string[]; 10 | categoryIcons: Record; 11 | enableCorsProxy: boolean; 12 | adBlock: { 13 | enabled: boolean; 14 | } 15 | privateMode: boolean; 16 | geminiApiKey: string; 17 | enableAI: boolean; 18 | prompts: AIPrompt[]; 19 | defaultSaveLocations: { 20 | defaultLocation: string; 21 | domainMappings: Record; 22 | }; 23 | cardDisplay: { 24 | showDescription: boolean; 25 | showAuthor: boolean; 26 | showDate: boolean; 27 | showDomain: boolean; 28 | showThumbnail: boolean; 29 | }; 30 | replaceTabHome: boolean; 31 | shortcuts: Shortcut[]; 32 | showClock: boolean; 33 | homeTab: { 34 | showRecentFiles: boolean; 35 | showSavedArticles: boolean; 36 | backgroundImage: string; 37 | backgroundBlur: number; 38 | textColor: string; 39 | textBrightness: number; 40 | }; 41 | keepOriginalContent: boolean; 42 | } 43 | 44 | export interface AIPrompt { 45 | name: string; 46 | prompt: string; 47 | enabled: boolean; 48 | variables: Record; 49 | } 50 | 51 | export const DEFAULT_SETTINGS: NetClipSettings = { 52 | viewPosition: 'right', 53 | defaultFolderName: 'NetClip', 54 | parentFolderPath: '', 55 | defaultWebUrl: 'https://google.com', 56 | searchEngine: 'google', 57 | categories: [], 58 | categoryIcons: {}, 59 | enableCorsProxy: false, 60 | adBlock: { 61 | enabled: true 62 | }, 63 | privateMode: false, 64 | geminiApiKey: '', 65 | enableAI: false, 66 | prompts: [ 67 | { 68 | name: "Translate Content", 69 | prompt: "Translate the following ${article} to ${target_lang}", 70 | enabled: false, 71 | variables: { 72 | "target_lang": ["Japanese", "English", "Spanish", "French", "German", "Chinese"] 73 | } 74 | }, 75 | { 76 | name: "Summarize Content", 77 | prompt: "Summarize ${article} in ${style} style. Keep the summary ${length}.", 78 | enabled: false, 79 | variables: { 80 | "style": ["concise", "detailed", "bullet points", "academic"], 81 | "length": ["short (2-3 sentences)", "medium (1 paragraph)", "long (2-3 paragraphs)"] 82 | } 83 | }, 84 | { 85 | name: "Format as Note", 86 | prompt: "Convert ${article} into a structured note with headings, bullet points, and key takeaways. Use ${format} formatting style.", 87 | enabled: false, 88 | variables: { 89 | "format": ["Academic", "Meeting Notes", "Study Notes"] 90 | } 91 | } 92 | ], 93 | defaultSaveLocations: { 94 | defaultLocation: '', 95 | domainMappings: {} 96 | }, 97 | cardDisplay: { 98 | showDescription: true, 99 | showAuthor: true, 100 | showDate: true, 101 | showDomain: true, 102 | showThumbnail: true 103 | }, 104 | replaceTabHome: false, 105 | shortcuts: [], 106 | showClock: true, 107 | homeTab: { 108 | showRecentFiles: true, 109 | showSavedArticles: true, 110 | backgroundImage: '', 111 | backgroundBlur: 0, 112 | textColor: '#ffffff', 113 | textBrightness: 100 114 | }, 115 | keepOriginalContent: false 116 | }; -------------------------------------------------------------------------------- /src/translations.ts: -------------------------------------------------------------------------------- 1 | import * as exp from "constants"; 2 | 3 | interface Translations { 4 | "en": { [key: string]: string }; 5 | "ja": { [key: string]: string }; 6 | } 7 | 8 | type LanguageKey = keyof Translations; 9 | 10 | 11 | export function t(key: string): string { 12 | 13 | const storeLang = window.localStorage.getItem('language'); 14 | const lang = (storeLang || 'en') as LanguageKey; 15 | const translation = TRANSLATIONS[lang] || TRANSLATIONS['en']; 16 | return translation[key] 17 | } 18 | 19 | export const TRANSLATIONS: Translations = { 20 | en: { 21 | 'clipper_view': 'Clipper View', 22 | 23 | 'search_saved_articles': 'Search saved articles...', 24 | 25 | 'sort_a_z': 'A-Z', 26 | 'sort_z_a': 'Z-A', 27 | 'sort_newest_first': 'Newest First', 28 | 'sort_oldest_first': 'Oldest First', 29 | 30 | 'all': 'All', 31 | 'unknown_source': 'Unknown source', 32 | 33 | 'no_matching_articles': 'No matching articles found.', 34 | 35 | 'clip_webpage': 'Clip webpage', 36 | 'url': 'Url:', 37 | 'enter_url': 'Enter URL to clip...', 38 | 'save_to': 'Save to:', 39 | 'clip': 'Clip', 40 | 41 | 'confirm_delete': 'Confirm delete', 42 | 'confirm_delete_message': 'Are you sure you want to delete the article "{0}"?', 43 | 'delete': 'Delete', 44 | 'cancel': 'Cancel', 45 | 46 | 'select_parent_folder': 'Select Parent Folder', 47 | 'select_parent_folder_desc': 'Choose a parent folder for NetClip content. Leave empty to use vault root.', 48 | 'vault_root': 'Vault Root', 49 | 'store_in_root_desc': 'Store NetClip content directly in the vault root', 50 | 'available_folders': 'Available Folders', 51 | 'select': 'Select', 52 | 53 | 'web_view': 'Web view', 54 | 'search_engine': 'Search engine', 55 | 'search_engine_desc': 'Choose the default search engine for search queries', 56 | 'default_url': 'Default url', 57 | 'default_url_desc': 'Set the default URL opened when using the web modal/editor', 58 | 'enter_default_url': 'Enter default URL', 59 | 'invalid_url': 'Invalid URL. Please enter a valid URL.', 60 | 'enable_ad_blocking': 'Enable ad blocking (experimental)', 61 | 'enable_ad_blocking_desc': 'Block ads in web view', 62 | 'private_mode': 'Private mode', 63 | 'private_mode_desc': 'Block cookies, localStorage, and other tracking mechanisms (prevents saving browsing data)', 64 | 'clipper': 'Clipper', 65 | 'ai_prompts_tab': 'AI prompts', 66 | 'view_position': 'View position', 67 | 'view_position_desc': 'Choose where the Web Clipper view should appear', 68 | 'left_sidebar': 'Left sidebar', 69 | 'right_sidebar': 'Right sidebar', 70 | 'default_position': 'Default position', 71 | 'change_folder_name': 'Change folder name', 72 | 'change_folder_name_desc': 'Change the folder for saving clipped articles', 73 | 'enter_folder_name': 'Enter folder name', 74 | 'confirm': 'Confirm', 75 | 'folder_renamed': 'Folder renamed to "{0}"', 76 | 'categories': 'Categories', 77 | 'categories_desc': 'Create new category folder', 78 | 'new_category_name': 'New category name', 79 | 'create': 'Create', 80 | 'please_enter_category_name': 'Please enter a category name', 81 | 'category_exists': 'Category "{0}" already exists', 82 | 'category_created': 'Category "{0}" created successfully', 83 | 'category_deleted': 'Category "{0}" deleted successfully', 84 | 'enter_icon_name': 'Enter icon name', 85 | 'folder_not_found': 'Folder "{0}" not found.', 86 | 'folder_exists': 'Folder "{0}" already exists.', 87 | 'web_view_tab': 'Web view', 88 | 'clipper_tab': 'Clipper', 89 | 'parent_folder': 'Parent folder', 90 | 'parent_folder_desc': 'Choose a parent folder for NetClip content (leave empty to use vault root)', 91 | 'parent_folder_path': 'Parent folder path', 92 | 'browse': 'Browse', 93 | 94 | 'sort_by': 'Sort by', 95 | 'domain_filter': 'Filter by domain', 96 | 'all_domains': 'All domains', 97 | 98 | 'open_web': 'Open web view', 99 | 'open_settings': 'Open settings', 100 | 'add_clip': 'Add new clip', 101 | 102 | 'current_icon': 'Current icon: {0}', 103 | 104 | 'enable_ai': 'Enable AI Processing', 105 | 'enable_ai_desc': 'Enable AI-powered content processing using Gemini API', 106 | 'gemini_api_key': 'Gemini API Key', 107 | 'gemini_api_key_desc': 'Enter your Gemini API key', 108 | 'enter_api_key': 'Enter API key', 109 | 'ai_prompts': 'AI Prompts', 110 | 'ai_prompts_desc': 'Create and manage AI processing prompts', 111 | 'add_new_prompt': 'Add New Prompt', 112 | 'edit_prompt': 'Edit', 113 | 'delete_prompt': 'Delete', 114 | 'export_prompts': 'Export All Prompts', 115 | 'import_prompts': 'Import Prompts', 116 | 'export_prompts_desc': 'Export all AI prompts as a JSON file', 117 | 'import_prompts_desc': 'Import AI prompts from a JSON file', 118 | 'import_success': 'Successfully imported prompts', 119 | 'import_error': 'Error importing prompts: Invalid file format', 120 | 'export_prompt': 'Export', 121 | 'export_single_prompt_desc': 'Export this prompt as a JSON file', 122 | 'show_in_clipper': 'Show in clipper', 123 | 'show_in_clipper_desc': 'Show this prompt in the clip modal when clipping content', 124 | 'hide_in_clipper': 'Hide in clipper', 125 | 'hide_in_clipper_desc': 'Hide this prompt from the clip modal', 126 | 127 | 'support_tab': 'Support', 128 | 'github_repo': 'GitHub Repository', 129 | 'github_repo_desc': 'Visit the GitHub repository for documentation, issues, and updates', 130 | 'open_github': 'Open GitHub', 131 | 'support_development': 'Support Development', 132 | 'support_development_desc': 'If this plugin is useful to you, consider supporting its development or giving it a star on GitHub!', 133 | 'buy_coffee': 'Buy Me a Coffee', 134 | 'buy_coffee_desc': 'Support me on Buy Me a Coffee', 135 | 'support_kofi': 'Support on Ko-fi', 136 | 'support_kofi_desc': 'Support me on Ko-fi', 137 | 138 | 'home_tab': 'Home tab', 139 | 'show_clock': 'Show clock', 140 | 'show_recent_files': 'Show recent files', 141 | 'show_saved_articles': 'Show saved articles', 142 | 'replace_new_tabs': 'Replace new tabs', 143 | 'replace_new_tabs_desc': 'When enabled, new empty tabs will be replaced with the NetClip home tab', 144 | 'show_clock_desc': 'Show a clock on the home tab', 145 | 'show_recent_files_desc': 'Display the recent files section on the home tab', 146 | 'show_saved_articles_desc': 'Display the saved articles section on the home tab', 147 | }, 148 | 'ja': { 149 | 'clipper_view': 'クリッパービュー', 150 | 151 | 'search_saved_articles': '保存された記事を検索...', 152 | 153 | 'sort_a_z': 'A-Z', 154 | 'sort_z_a': 'Z-A', 155 | 'sort_newest_first': '新しい順', 156 | 'sort_oldest_first': '古い順', 157 | 158 | 'all': 'すべて', 159 | 'unknown_source': '不明なソース', 160 | 161 | 'no_matching_articles': '一致する記事が見つかりません。', 162 | 163 | 'clip_webpage': 'ウェブページをクリップ', 164 | 'url': 'URL:', 165 | 'enter_url': 'クリップするURLを入力...', 166 | 'save_to': '保存先:', 167 | 'clip': 'クリップ', 168 | 169 | 'confirm_delete': '削除の確認', 170 | 'confirm_delete_message': '記事「{0}」を削除してもよろしいですか?', 171 | 'delete': '削除', 172 | 'cancel': 'キャンセル', 173 | 174 | 'clipping': 'クリップ中...', 175 | 'clipping_success': '{0} のクリップに成功しました', 176 | 'clipping_failed': 'クリップに失敗しました: {0}', 177 | 'created_folders': '{0} にフォルダを作成しました', 178 | 'no_url_found': 'このクリップにURLが見つかりません', 179 | 'clip_webpage_function_not_available': 'クリップ機能が利用できません', 180 | 181 | 'select_parent_folder': '親フォルダを選択', 182 | 'select_parent_folder_desc': 'NetClipコンテンツの親フォルダを選択してください。空白の場合はvaultルートを使用します。', 183 | 'vault_root': 'Vaultルート', 184 | 'store_in_root_desc': 'NetClipコンテンツをVaultルートに直接保存', 185 | 'available_folders': '利用可能なフォルダ', 186 | 'select': '選択', 187 | 188 | 'web_view': 'ウェブビュー', 189 | 'search_engine': '検索エンジン', 190 | 'search_engine_desc': '検索クエリのデフォルト検索エンジンを選択', 191 | 'default_url': 'デフォルトURL', 192 | 'default_url_desc': 'ウェブモーダル/エディタで開くデフォルトURLを設定', 193 | 'enter_default_url': 'デフォルトURLを入力', 194 | 'invalid_url': '無効なURLです。有効なURLを入力してください。', 195 | 'enable_ad_blocking': '広告ブロック機能を有効にする(実験的)', 196 | 'enable_ad_blocking_desc': 'ウェブビューで広告をブロック', 197 | 'private_mode': 'プライベートモード', 198 | 'private_mode_desc': 'Cookie、localStorage、その他の追跡メカニズムをブロック(閲覧データの保存を防止)', 199 | 'clipper': 'クリッパー', 200 | 'ai_prompts_tab': 'AI プロンプト', 201 | 'view_position': 'ビュー位置', 202 | 'view_position_desc': 'Webクリッパービューを表示する場所を選択', 203 | 'left_sidebar': '左サイドバー', 204 | 'right_sidebar': '右サイドバー', 205 | 'default_position': 'デフォルト位置', 206 | 'change_folder_name': 'フォルダ名を変更', 207 | 'change_folder_name_desc': 'クリップした記事を保存するフォルダを変更', 208 | 'enter_folder_name': 'フォルダ名を入力', 209 | 'confirm': '確認', 210 | 'folder_renamed': 'フォルダ名を「{0}」に変更しました', 211 | 'categories': 'カテゴリ', 212 | 'categories_desc': '新しいカテゴリフォルダを作成', 213 | 'new_category_name': '新しいカテゴリ名', 214 | 'create': '作成', 215 | 'please_enter_category_name': 'カテゴリ名を入力してください', 216 | 'category_exists': 'カテゴリ「{0}」はすでに存在します', 217 | 'category_created': 'カテゴリ「{0}」が正常に作成されました', 218 | 'category_deleted': 'カテゴリ「{0}」が正常に削除されました', 219 | 'enter_icon_name': 'アイコン名を入力', 220 | 'folder_not_found': 'フォルダ「{0}」が見つかりません。', 221 | 'folder_exists': 'フォルダ「{0}」はすでに存在します', 222 | 'web_view_tab': 'ウェブビュー', 223 | 'clipper_tab': 'クリッパー', 224 | 'parent_folder': '親フォルダ', 225 | 'parent_folder_desc': 'NetClipコンテンツの親フォルダを選択(空白の場合はvaultルートを使用)', 226 | 'parent_folder_path': '親フォルダのパス', 227 | 'browse': '参照', 228 | 229 | 'sort_by': '並び替え', 230 | 'domain_filter': 'ドメインでフィルター', 231 | 'all_domains': 'すべてのドメイン', 232 | 233 | 'open_web': 'ウェブビューを開く', 234 | 'open_settings': '設定を開く', 235 | 'add_clip': '新規クリップを追加', 236 | 237 | 'current_icon': '現在のアイコン: {0}', 238 | 239 | 'enable_ai': 'AI処理を有効にする', 240 | 'enable_ai_desc': 'Gemini APIを使用したAI処理を有効にする', 241 | 'gemini_api_key': 'Gemini APIキー', 242 | 'gemini_api_key_desc': 'Gemini APIキーを入力してください', 243 | 'enter_api_key': 'APIキーを入力', 244 | 'ai_prompts': 'AIプロンプト', 245 | 'ai_prompts_desc': 'AI処理プロンプトの作成と管理', 246 | 'add_new_prompt': '新規プロンプトを追加', 247 | 'edit_prompt': '編集', 248 | 'delete_prompt': '削除', 249 | 'export_prompts': 'すべてのプロンプトをエクスポート', 250 | 'import_prompts': 'プロンプトをインポート', 251 | 'export_prompts_desc': 'すべてのAIプロンプトをJSONファイルとしてエクスポート', 252 | 'import_prompts_desc': 'JSONファイルからAIプロンプトをインポート', 253 | 'import_success': 'プロンプトのインポートに成功しました', 254 | 'import_error': 'プロンプトのインポートエラー:無効なファイル形式です', 255 | 'export_prompt': 'エクスポート', 256 | 'export_single_prompt_desc': 'このプロンプトをJSONファイルとしてエクスポート', 257 | 'show_in_clipper': 'クリッパーに表示', 258 | 'show_in_clipper_desc': 'コンテンツをクリップする際にこのプロンプトをクリップモーダルに表示', 259 | 'hide_in_clipper': 'クリッパーに非表示', 260 | 'hide_in_clipper_desc': 'このプロンプトをクリップモーダルから非表示にする', 261 | 262 | 'support_tab': 'サポート', 263 | 'github_repo': 'GitHubリポジトリ', 264 | 'github_repo_desc': 'ドキュメント、問題報告、アップデートについてはGitHubリポジトリをご覧ください', 265 | 'open_github': 'GitHubを開く', 266 | 'support_development': '開発をサポート', 267 | 'support_development_desc': 'このプラグインが便利だと感じたら、開発をサポートしていただくか、GitHubでスターを付けていただけると嬉しいです!', 268 | 'buy_coffee': 'コーヒーを買う', 269 | 'buy_coffee_desc': 'Buy Me a Coffeeでサポート', 270 | 'support_kofi': 'Ko-fiでサポート', 271 | 'support_kofi_desc': 'Ko-fiでサポート', 272 | 273 | 'home_tab': 'ホームタブ', 274 | 'show_clock': '時計を表示', 275 | 'show_recent_files': '最近のファイルを表示', 276 | 'show_saved_articles': '保存された記事を表示', 277 | 'replace_new_tabs': '新規タブを置き換える', 278 | 'replace_new_tabs_desc': '有効にすると、新しい空のタブがNetClipホームタブに置き換えられます', 279 | 'show_clock_desc': 'ホームタブに時計を表示します', 280 | 'show_recent_files_desc': 'ホームタブに最近のファイルセクションを表示します', 281 | 'show_saved_articles_desc': 'ホームタブに保存された記事セクションを表示します', 282 | } 283 | 284 | } 285 | 286 | export function formatString(str: string, ...args: any[]): string { 287 | return str.replace(/{(\d+)}/g, (match, index) => { 288 | return typeof args[index] !== 'undefined' ? args[index] : match; 289 | }); 290 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | export function normalizeUrl(url: string): string | null { 3 | try { 4 | const parsedUrl = new URL(url); 5 | 6 | // Remove common tracking parameters 7 | const trackingParams = [ 8 | 'utm_source', 'utm_medium', 'utm_campaign', 9 | 'utm_term', 'utm_content', 'fbclid', 'gclid' 10 | ]; 11 | 12 | trackingParams.forEach(param => { 13 | parsedUrl.searchParams.delete(param); 14 | }); 15 | 16 | return parsedUrl.toString(); 17 | } catch { 18 | return null; 19 | } 20 | } 21 | 22 | 23 | export function sanitizePath(path: string): string { 24 | return path 25 | .replace(/[\/\\:*?"<>|]/g, '_') // Remove invalid filename characters 26 | .replace(/\s+/g, ' ') // Normalize whitespace 27 | .trim(); // Trim leading/trailing whitespace 28 | } 29 | 30 | 31 | export function getDomain(url: string): string { 32 | try { 33 | const parsedUrl = new URL(url); 34 | return parsedUrl.hostname.replace(/^www\./, ''); 35 | } catch { 36 | return 'Unknown Domain'; 37 | } 38 | } 39 | 40 | 41 | export function resolveUrl(base: string, relative: string): string { 42 | try { 43 | return new URL(relative, base).toString(); 44 | } catch { 45 | return relative; 46 | } 47 | } 48 | 49 | 50 | export function extractTitleFromHtml(html: string): string { 51 | const titleMatch = html.match(/(.*?)<\/title>/); 52 | if (titleMatch && titleMatch[1]) { 53 | return titleMatch[1].trim(); 54 | } 55 | return 'Untitled'; 56 | } 57 | 58 | 59 | export function isValidUrl(url: string): boolean { 60 | try { 61 | new URL(url); 62 | return true; 63 | } catch { 64 | return false; 65 | } 66 | } 67 | 68 | 69 | export function getFileExtension(url: string): string { 70 | const parsedUrl = new URL(url); 71 | const path = parsedUrl.pathname; 72 | const extension = path.split('.').pop(); 73 | return extension ? extension.toLowerCase() : ''; 74 | } 75 | 76 | 77 | export function addQueryParam(url: string, paramName: string, paramValue: string): string { 78 | const parsedUrl = new URL(url); 79 | parsedUrl.searchParams.append(paramName, paramValue); 80 | return parsedUrl.toString(); 81 | } -------------------------------------------------------------------------------- /src/view/ClipperView.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf, TFile, setIcon } from 'obsidian' 2 | import NetClipPlugin from '../main' 3 | import { getDomain } from '../utils' 4 | import { DeleteConfirmationModal } from '../modal/deleteFiles' 5 | import { ClipperContextMenu } from '../contextMenu' 6 | import { VIEW_TYPE_WORKSPACE_WEBVIEW, WorkspaceLeafWebView } from './EditorWebView' 7 | import { ClipModal } from 'src/modal/clipModal' 8 | import { DEFAULT_IMAGE } from '../assets/image' 9 | import { Menu } from 'obsidian' 10 | import { t } from '../translations' 11 | import { findFirstImageInNote } from '../mediaUtils' 12 | 13 | export const CLIPPER_VIEW = 'clipper-view'; 14 | 15 | export class ClipperHomeView extends ItemView { 16 | private plugin: NetClipPlugin; 17 | settings: any; 18 | private currentCategory: string = ''; 19 | icon = 'newspaper'; 20 | 21 | constructor(leaf: WorkspaceLeaf, plugin: NetClipPlugin) { 22 | super(leaf); 23 | this.plugin = plugin; 24 | this.settings = plugin.settings; 25 | } 26 | 27 | getViewType(): string { 28 | return CLIPPER_VIEW; 29 | } 30 | 31 | getDisplayText(): string { 32 | return t('clipper_view') 33 | } 34 | 35 | public async reloadView() { 36 | this.containerEl.empty(); 37 | await this.onOpen(); 38 | } 39 | 40 | async onOpen() { 41 | this.containerEl = this.containerEl.children[1] as HTMLElement; 42 | this.containerEl.empty(); 43 | 44 | const clipperContainer = this.containerEl.createEl('div', { cls: 'net_clipper_container' }); 45 | 46 | const clipperHeader = clipperContainer.createEl('div', { cls: 'net_clipper_header' }); 47 | const rightContainer = clipperHeader.createEl('div', { cls: 'net_clipper_header_right' }); 48 | 49 | const openWeb = rightContainer.createEl('span', { cls: 'netopen_Web', attr: { 'aria-label': t('open_web') } }); 50 | const openSettings = rightContainer.createEl('span', { cls: 'netopen_settings', attr: { 'aria-label': t('open_settings') } }); 51 | setIcon(openWeb, 'globe') 52 | setIcon(openSettings, 'lucide-bolt'); 53 | 54 | const searchBoxContainer = clipperContainer.createEl('div', { cls: 'netclip_search_box' }); 55 | const searchIcon = searchBoxContainer.createEl('span', { cls: 'netclip_search_icon' }); 56 | setIcon(searchIcon, 'search'); 57 | const searchInput = searchBoxContainer.createEl('input', { 58 | type: 'text', 59 | cls: 'netclip_search_input', 60 | placeholder: t('search_saved_articles') 61 | }); 62 | 63 | const bottomContainer = clipperContainer.createEl('div', {cls: 'netclip_bottom_container'}) 64 | const categoryTabsContainer = bottomContainer.createEl('div', { cls: 'netclip_category_tabs'}); 65 | this.renderCategoryTabs(categoryTabsContainer); 66 | 67 | const sortContainer = bottomContainer.createEl('div', { cls: 'netclip_sort_container' }); 68 | const domainSortButton = sortContainer.createEl('button', { 69 | cls: 'netclip_sort_button', 70 | attr: { 'aria-label': t('domain_filter') } 71 | }); 72 | const sortButton = sortContainer.createEl('button', { 73 | cls: 'netclip_sort_button', 74 | attr: { 'aria-label': t('sort_by') } 75 | }); 76 | setIcon(sortButton, 'arrow-up-down'); 77 | setIcon(domainSortButton, 'earth'); 78 | 79 | searchInput.addEventListener('input', () => { 80 | const searchTerm = searchInput.value.toLowerCase(); 81 | const savedContainer = this.containerEl.querySelector('.netclip_saved_container') as HTMLElement; 82 | this.renderSavedContent(savedContainer, searchTerm); 83 | }) 84 | 85 | const clipButtonContainer = clipperHeader.createEl('div', { cls: 'netclip_button_container' }); 86 | const clipButton = clipButtonContainer.createEl('button', { 87 | cls: 'netclip_btn', 88 | attr: { 'aria-label': t('add_clip') } 89 | }); 90 | setIcon(clipButton, 'plus'); 91 | 92 | sortButton.addEventListener('click', (event) => { 93 | const menu = new Menu(); 94 | 95 | menu.addItem((item) => 96 | item 97 | .setTitle(t('sort_a_z')) 98 | .setIcon('arrow-up') 99 | .onClick(() => this.applySort('a-z')) 100 | ); 101 | 102 | menu.addItem((item) => 103 | item 104 | .setTitle(t('sort_z_a')) 105 | .setIcon('arrow-down') 106 | .onClick(() => this.applySort('z-a')) 107 | ); 108 | 109 | menu.addItem((item) => 110 | item 111 | .setTitle(t('sort_newest_first')) 112 | .setIcon('arrow-down') 113 | .onClick(() => this.applySort('new-old')) 114 | ); 115 | 116 | menu.addItem((item) => 117 | item 118 | .setTitle(t('sort_oldest_first')) 119 | .setIcon('arrow-up') 120 | .onClick(() => this.applySort('old-new')) 121 | ); 122 | 123 | menu.showAtMouseEvent(event); 124 | }); 125 | 126 | domainSortButton.addEventListener('click', async (event) => { 127 | const menu = new Menu(); 128 | const files = this.app.vault.getMarkdownFiles(); 129 | const domains = new Set<string>(); 130 | 131 | const baseFolderPath = this.settings.parentFolderPath 132 | ? `${this.settings.parentFolderPath}/${this.settings.defaultFolderName}` 133 | : this.settings.defaultFolderName; 134 | 135 | await Promise.all(files.map(async file => { 136 | if (file.path.startsWith(baseFolderPath)) { 137 | const content = await this.app.vault.cachedRead(file); 138 | const urlMatch = content.match(/source: "([^"]+)"/); 139 | if (urlMatch) { 140 | const domain = getDomain(urlMatch[1]); 141 | domains.add(domain); 142 | } 143 | } 144 | })); 145 | 146 | menu.addItem((item) => 147 | item 148 | .setTitle(t('all_domains')) 149 | .setIcon('dot') 150 | .onClick(() => this.applyDomainFilter('')) 151 | ); 152 | 153 | domains.forEach(domain => { 154 | const displayName = domain.replace('.com', ''); 155 | menu.addItem((item) => 156 | item 157 | .setTitle(displayName) 158 | .setIcon('dot') 159 | .onClick(() => this.applyDomainFilter(domain)) 160 | ); 161 | }); 162 | 163 | menu.showAtMouseEvent(event); 164 | }); 165 | 166 | const SavedContentBox = clipperContainer.createEl("div", { cls: "netclip_saved_container" }); 167 | 168 | openWeb.addEventListener('click', async () => { 169 | const defaultUrl = this.settings.defaultWebUrl || 'https://google.com'; 170 | const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_WORKSPACE_WEBVIEW) 171 | .find((leaf: any) => { 172 | const view = leaf.view as WorkspaceLeafWebView; 173 | return view.url === defaultUrl; 174 | }); 175 | 176 | if (existingLeaf) { 177 | this.app.workspace.setActiveLeaf(existingLeaf, { focus: true }); 178 | } else { 179 | const leaf = this.app.workspace.getLeaf(true); 180 | await leaf.setViewState({ 181 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 182 | state: { url: defaultUrl } 183 | }); 184 | 185 | this.app.workspace.setActiveLeaf(leaf, { focus: true }); 186 | } 187 | }); 188 | 189 | openSettings.addEventListener('click', () => { 190 | (this.app as any).setting.open(); 191 | (this.app as any).setting.openTabById(this.plugin.manifest.id); 192 | }); 193 | 194 | clipButton.addEventListener("click", () => { 195 | new ClipModal(this.app, this.plugin).open(); 196 | }); 197 | 198 | await this.renderSavedContent(SavedContentBox) 199 | } 200 | 201 | private async applySort(sortOrder: string) { 202 | const savedContainer = this.containerEl.querySelector('.netclip_saved_container') as HTMLElement; 203 | await this.renderSavedContent(savedContainer, '', sortOrder); 204 | } 205 | 206 | private async applyDomainFilter(domain: string) { 207 | const savedContainer = this.containerEl.querySelector('.netclip_saved_container') as HTMLElement; 208 | await this.renderSavedContent(savedContainer, '', 'a-z', domain); 209 | } 210 | 211 | public renderCategoryTabs(tabsContainer: HTMLElement){ 212 | tabsContainer.empty(); 213 | 214 | const allTab = tabsContainer.createEl('div', { 215 | cls: `netclip_category_tab ${this.currentCategory === '' ? 'active' : ''}`, 216 | }); 217 | 218 | const allTabContent = allTab.createEl('div', { cls: 'netclip-category-content' }); 219 | allTabContent.createEl('span', { text: t('all') }); 220 | 221 | allTab.addEventListener('click', () => this.switchCategory('', tabsContainer)); 222 | 223 | this.plugin.settings.categories.forEach(category => { 224 | const tab = tabsContainer.createEl('div', { 225 | cls: `netclip_category_tab ${this.currentCategory === category ? 'active' : ''}`, 226 | }); 227 | 228 | const tabContent = tab.createEl('div', { cls: 'netclip-category-content' }); 229 | 230 | if (this.plugin.settings.categoryIcons[category]) { 231 | const iconSpan = tabContent.createEl('span', { cls: 'category-icon' }); 232 | setIcon(iconSpan, this.plugin.settings.categoryIcons[category]); 233 | } 234 | 235 | tabContent.createEl('span', { text: category }); 236 | 237 | tab.addEventListener('click', () => this.switchCategory(category, tabsContainer)); 238 | }) 239 | } 240 | 241 | private async switchCategory(category: string, tabsContainer: HTMLElement) { 242 | this.currentCategory = category; 243 | 244 | const tabs = tabsContainer.querySelectorAll('.netclip_category_tab'); 245 | tabs.forEach(tab => { 246 | tab.classList.remove('active'); 247 | if ((category === '' && tab.textContent === t('all')) || 248 | tab.textContent === category) { 249 | tab.classList.add('active'); 250 | } 251 | }); 252 | 253 | const savedContainer = this.containerEl.querySelector(".netclip_saved_container") as HTMLElement; 254 | await this.renderSavedContent(savedContainer); 255 | } 256 | 257 | public async renderSavedContent(container: HTMLElement, filter: string = '', sortOrder: string = 'new-old', domainFilter: string = '') { 258 | container.empty(); 259 | 260 | const files = this.app.vault.getMarkdownFiles(); 261 | const baseFolderPath = this.settings.parentFolderPath 262 | ? `${this.settings.parentFolderPath}/${this.settings.defaultFolderName}` 263 | : this.settings.defaultFolderName; 264 | 265 | const clippedFiles = files.filter(file => { 266 | const isInMainFolder = file.path.startsWith(baseFolderPath); 267 | if (!this.currentCategory) { 268 | return isInMainFolder; 269 | } 270 | return file.path.startsWith(`${baseFolderPath}/${this.currentCategory}`); 271 | }); 272 | 273 | let filteredFiles = filter 274 | ? clippedFiles.filter(file => file.basename.toLowerCase().includes(filter)) 275 | : clippedFiles; 276 | 277 | if (domainFilter) { 278 | filteredFiles = (await Promise.all(filteredFiles.map(async file => { 279 | const content = await this.app.vault.cachedRead(file); 280 | const urlMatch = content.match(/source: "([^"]+)"/); 281 | if (urlMatch) { 282 | const domain = getDomain(urlMatch[1]); 283 | return domain === domainFilter ? file : null; 284 | } 285 | return null; 286 | }))).filter(Boolean) as TFile[]; 287 | } 288 | 289 | const sortedFiles = filteredFiles.sort((a, b) => { 290 | switch (sortOrder) { 291 | case 'a-z': 292 | return a.basename.localeCompare(b.basename); 293 | case 'z-a': 294 | return b.basename.localeCompare(a.basename); 295 | case 'new-old': 296 | return b.stat.mtime - a.stat.mtime; 297 | case 'old-new': 298 | return a.stat.mtime - b.stat.mtime; 299 | default: 300 | return 0; 301 | } 302 | }); 303 | 304 | if (sortedFiles.length === 0) { 305 | const emptyContainer = container.createEl('div', { cls: 'empty_box' }); 306 | const emptyIcon = emptyContainer.createEl("span", { cls: 'empty_icon' }); 307 | setIcon(emptyIcon, 'lucide-book-open'); 308 | emptyContainer.createEl("p", { text: t('no_matching_articles') }); 309 | return; 310 | } 311 | 312 | for (const file of sortedFiles) { 313 | const content = await this.app.vault.cachedRead(file); 314 | const clippedEl = container.createEl('div', { cls: 'netClip_card' }); 315 | 316 | if (this.settings.cardDisplay.showThumbnail) { 317 | const frontmatterMatch = content.match(/^---[\s\S]*?thumbnail: "([^"]+)"[\s\S]*?---/); 318 | let thumbnailUrl = frontmatterMatch ? frontmatterMatch[1] : null; 319 | 320 | if (!thumbnailUrl) { 321 | const thumbnailMatch = content.match(/!\[Thumbnail\]\((.+)\)/); 322 | thumbnailUrl = thumbnailMatch ? thumbnailMatch[1] : null; 323 | } 324 | 325 | if (!thumbnailUrl) { 326 | thumbnailUrl = await findFirstImageInNote(this.app, content); 327 | } 328 | 329 | clippedEl.createEl("img", { 330 | attr: { 331 | src: thumbnailUrl || DEFAULT_IMAGE, 332 | loading: "lazy" 333 | } 334 | }); 335 | } 336 | 337 | const contentContainer = clippedEl.createEl('div', { cls: 'netclip_card_content' }); 338 | 339 | const topContainer = contentContainer.createEl('div', { cls: 'netclip_card_top' }); 340 | const clippedTitle = topContainer.createEl("h6", { text: file.basename }); 341 | clippedTitle.addEventListener('click', () => { 342 | this.openArticle(file); 343 | }); 344 | 345 | if (this.settings.cardDisplay.showDescription) { 346 | const descriptionMatch = content.match(/desc:\s*(?:"([^"]+)"|([^\n]+))/); 347 | if (descriptionMatch) { 348 | topContainer.createEl("p", { 349 | cls: "netclip_card_description", 350 | text: descriptionMatch[1] || descriptionMatch[2] 351 | }); 352 | } 353 | } 354 | 355 | const metaContainer = topContainer.createEl("div", { cls: "netclip_card_meta" }); 356 | 357 | if (this.settings.cardDisplay.showAuthor) { 358 | const authorMatch = content.match(/author:\s*(?:"([^"]+)"|([^\n]+))/); 359 | if (authorMatch) { 360 | metaContainer.createEl("span", { 361 | cls: "netclip_card_author", 362 | text: authorMatch[1] || authorMatch[2] 363 | }); 364 | } 365 | } 366 | 367 | if (this.settings.cardDisplay.showDate) { 368 | const creationDate = new Date(file.stat.ctime); 369 | const formattedDate = creationDate.toLocaleDateString(undefined, { 370 | year: 'numeric', 371 | month: 'short', 372 | day: 'numeric' 373 | }); 374 | metaContainer.createEl("span", { 375 | cls: "netclip_card_date", 376 | text: formattedDate 377 | }); 378 | } 379 | 380 | const bottomContent = contentContainer.createEl("div", { cls: "netclip_card_bottom" }); 381 | const urlMatch = content.match(/source: "([^"]+)"/); 382 | 383 | if (this.settings.cardDisplay.showDomain && urlMatch) { 384 | const articleUrl = urlMatch[1]; 385 | const domainName = getDomain(articleUrl); 386 | bottomContent.createEl("a", { 387 | cls: "domain", 388 | href: articleUrl, 389 | text: domainName 390 | }); 391 | } 392 | 393 | this.createMenuButton(bottomContent, file, urlMatch?.[1]); 394 | container.appendChild(clippedEl); 395 | } 396 | } 397 | 398 | private createMenuButton(bottomContent: HTMLElement, file: TFile, url?: string) { 399 | const menuButton = bottomContent.createEl("span", { cls: "menu-button" }); 400 | setIcon(menuButton, 'more-vertical'); 401 | 402 | menuButton.addEventListener("click", (event) => { 403 | event.preventDefault(); 404 | event.stopPropagation(); 405 | 406 | const contextMenu = new ClipperContextMenu( 407 | this.app, 408 | file, 409 | this.showDeleteConfirmation.bind(this), 410 | this.openArticle.bind(this), 411 | url 412 | ); 413 | contextMenu.show(menuButton); 414 | }); 415 | } 416 | 417 | private showDeleteConfirmation(file: TFile) { 418 | const modal = new DeleteConfirmationModal( 419 | this.app, 420 | file, 421 | async () => { 422 | await this.app.fileManager.trashFile(file); 423 | const savedContainer = this.containerEl.querySelector(".netclip_saved_container") as HTMLElement; 424 | await this.renderSavedContent(savedContainer); 425 | } 426 | ); 427 | modal.open(); 428 | } 429 | 430 | private openArticle(file: TFile) { 431 | const openLeaves = this.app.workspace.getLeavesOfType("markdown"); 432 | const targetLeaf = openLeaves.find((leaf) => { 433 | const viewState = leaf.getViewState(); 434 | return viewState.type === "markdown" && viewState.state?.file === file.path; 435 | }); 436 | 437 | if (targetLeaf) { 438 | this.app.workspace.revealLeaf(targetLeaf); 439 | } else { 440 | this.app.workspace.openLinkText(file.path, '', true); 441 | } 442 | } 443 | } -------------------------------------------------------------------------------- /src/view/EditorWebView.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf, Notice, ViewStateResult } from 'obsidian'; 2 | import WebClipperPlugin from '../main'; 3 | import { WebViewComponent } from '../webViewComponent'; 4 | 5 | export const VIEW_TYPE_WORKSPACE_WEBVIEW = 'netClip_workspace_webview'; 6 | 7 | export class WorkspaceLeafWebView extends ItemView { 8 | private webViewComponent: WebViewComponent; 9 | private plugin: WebClipperPlugin; 10 | private initialUrl = ''; 11 | icon = 'globe'; 12 | url: string | undefined; 13 | currentTitle = 'Web View' 14 | public onLoadEvent: Promise<void>; 15 | private resolveLoadEvent: () => void; 16 | 17 | constructor(leaf: WorkspaceLeaf, plugin: WebClipperPlugin) { 18 | super(leaf); 19 | this.plugin = plugin; 20 | this.onLoadEvent = new Promise((resolve) => { 21 | this.resolveLoadEvent = resolve; 22 | }); 23 | } 24 | 25 | setUrl(url: string) { 26 | this.initialUrl = url; 27 | this.reloadWebView(); 28 | } 29 | 30 | getViewType(): string { 31 | return VIEW_TYPE_WORKSPACE_WEBVIEW; 32 | } 33 | 34 | getDisplayText(): string { 35 | return this.currentTitle; 36 | } 37 | 38 | private reloadWebView() { 39 | this.containerEl.empty(); 40 | this.createWebViewComponent(); 41 | } 42 | 43 | async setState(state: any, result: ViewStateResult): Promise<void> { 44 | if (state?.url) { 45 | this.initialUrl = state.url; 46 | this.reloadWebView(); 47 | } 48 | super.setState(state, result); 49 | } 50 | 51 | private createWebViewComponent() { 52 | this.webViewComponent = new WebViewComponent( 53 | this.app, 54 | this.initialUrl, 55 | { 56 | searchEngine: this.plugin.settings.searchEngine 57 | }, 58 | async (clipUrl) => { 59 | if (this.plugin && typeof this.plugin.clipWebpage === 'function') { 60 | await this.plugin.clipWebpage(clipUrl); 61 | } else { 62 | new Notice('Clip webpage function not available'); 63 | } 64 | }, 65 | this.plugin 66 | ); 67 | 68 | this.webViewComponent.onWindowOpen((url: string) => { 69 | const leaf = this.app.workspace.getLeaf(true); 70 | leaf.setViewState({ 71 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 72 | state: {url: url} 73 | }) 74 | this.app.workspace.revealLeaf(leaf); 75 | }) 76 | 77 | const containerEl = this.webViewComponent.createContainer(); 78 | 79 | this.webViewComponent.onTitleChange((title: string) => { 80 | this.currentTitle = title; 81 | this.leaf.setViewState({ 82 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 83 | state: { title: title } 84 | }); 85 | }); 86 | 87 | this.containerEl.appendChild(containerEl); 88 | } 89 | 90 | async onOpen(): Promise<void> { 91 | this.containerEl = this.containerEl.children[1] as HTMLElement; 92 | this.containerEl.empty(); 93 | 94 | const state = this.leaf.getViewState(); 95 | if(state.state?.url && typeof state.state.url === 'string'){ 96 | this.initialUrl = state.state.url; 97 | }else{ 98 | this.initialUrl = this.plugin.settings.defaultWebUrl || 'https://google.com'; 99 | } 100 | 101 | this.createWebViewComponent(); 102 | this.resolveLoadEvent(); 103 | } 104 | 105 | 106 | async onClose(): Promise<void> { 107 | this.containerEl.empty(); 108 | } 109 | } -------------------------------------------------------------------------------- /src/view/HomeTab.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf, setIcon, TFile, Menu, Notice, MarkdownView } from 'obsidian'; 2 | import NetClipPlugin from '../main'; 3 | import { t } from '../translations'; 4 | import { VIEW_TYPE_WORKSPACE_WEBVIEW } from './EditorWebView'; 5 | import { DEFAULT_IMAGE } from '../assets/image'; 6 | import { getDomain } from '../utils'; 7 | import { ShortcutModal, Shortcut } from '../modal/ShortcutModal'; 8 | import { CLIPPER_VIEW } from './ClipperView'; 9 | import { ClipModal } from 'src/modal/clipModal'; 10 | import { findFirstImageInNote } from '../mediaUtils'; 11 | 12 | export const HOME_TAB_VIEW = 'netclip-home-tab-view'; 13 | 14 | export class HomeTabView extends ItemView { 15 | private plugin: NetClipPlugin; 16 | private searchInput: HTMLInputElement; 17 | private shortcuts: Shortcut[] = []; 18 | private shortcutsContainer: HTMLElement; 19 | navigation = false 20 | allowNoFile = true 21 | icon = 'home' 22 | 23 | constructor(leaf: WorkspaceLeaf, plugin: NetClipPlugin){ 24 | super(leaf); 25 | this.plugin = plugin; 26 | this.shortcuts = this.plugin.settings.shortcuts || []; 27 | 28 | 29 | this.addAction('globe', 'Open web view', (evt: MouseEvent) => { 30 | this.openWebView(); 31 | }) 32 | 33 | this.addAction('newspaper', 'newspaper', (evt: MouseEvent) => { 34 | this.openCliperView(); 35 | }) 36 | 37 | this.addAction("refresh-cw", 'reload', (evt: MouseEvent) => { 38 | this.refreshContent(); 39 | }) 40 | 41 | this.addAction('scissors', 'Open Clip', (evt: MouseEvent) => { 42 | this.showClipModal(); 43 | }); 44 | 45 | this.addAction('file-plus', 'create new file', (evt: MouseEvent)=> { 46 | this.createNewFile(); 47 | }) 48 | } 49 | 50 | getViewType(): string { 51 | return HOME_TAB_VIEW 52 | } 53 | 54 | getDisplayText(): string { 55 | return 'Home' 56 | } 57 | 58 | getState(): any { 59 | return {} 60 | } 61 | 62 | setState(state: any, result: any): any { 63 | return; 64 | } 65 | 66 | private updateBackgroundImage() { 67 | const { backgroundImage, backgroundBlur, textColor, textBrightness } = this.plugin.settings.homeTab; 68 | const leafContent = this.containerEl.closest('.workspace-leaf-content[data-type="netclip-home-tab-view"]'); 69 | if (leafContent) { 70 | if (backgroundImage) { 71 | leafContent.setAttribute('style', 72 | `--background-image: url('${backgroundImage}'); 73 | --background-blur: ${backgroundBlur}px; 74 | --custom-text-color: ${textColor}; 75 | --text-brightness: ${textBrightness}%;` 76 | ); 77 | } else { 78 | leafContent.removeAttribute('style'); 79 | } 80 | } 81 | } 82 | 83 | async onOpen(){ 84 | const container = this.containerEl.children[1]; 85 | container.empty(); 86 | 87 | // Apply background image if set 88 | this.updateBackgroundImage(); 89 | 90 | if(this.plugin.settings.showClock){ 91 | const clockSection = container.createEl('div', { cls: 'netclip-clock-section' }); 92 | const timeEl = clockSection.createEl('div', { cls: 'netclip-time' }); 93 | const dateEl = clockSection.createEl('div', { cls: 'netclip-date' }); 94 | 95 | this.updateClock(timeEl, dateEl); 96 | 97 | const clockInterval = window.setInterval(() => { 98 | this.updateClock(timeEl, dateEl) 99 | },1000) 100 | 101 | this.registerInterval(clockInterval) 102 | 103 | } 104 | 105 | 106 | const searchContainer = container.createEl('div', { cls: 'netclip-home-tab-search' }); 107 | const searchIcon = searchContainer.createEl('span', { cls: 'netclip-search-icon' }); 108 | setIcon(searchIcon, 'search'); 109 | 110 | this.searchInput = searchContainer.createEl('input', { 111 | type: 'text', 112 | cls: 'netclip-search-input', 113 | placeholder: t('search_web') || 'Search the web...' 114 | }); 115 | 116 | this.searchInput.addEventListener('keydown', (e) => { 117 | if (e.key === 'Enter' && this.searchInput.value.trim()){ 118 | this.openWebSearch(this.searchInput.value.trim()); 119 | } 120 | }); 121 | 122 | const shortcutsSection = container.createEl('div', { cls: 'netclip-shortcuts-section' }); 123 | this.shortcutsContainer = shortcutsSection.createEl('div', { cls: 'netclip-shortcuts-container' }); 124 | this.renderShortcuts() 125 | const sectionsContainer = container.createEl('div', { cls: 'netclip-home-tab-sections' }); 126 | 127 | if(this.plugin.settings.homeTab.showRecentFiles){ 128 | const recentSection = sectionsContainer.createEl('div', { cls: 'netclip-home-tab-section' }); 129 | const titleContainer = recentSection.createEl('div', { cls: 'netclip-section-title' }); 130 | const titleIcon = titleContainer.createEl('span', { cls: 'netclip-section-icon' }); 131 | setIcon(titleIcon, 'clock'); 132 | const titleText = titleContainer.createEl('h3', { text: 'Recent Files' }); 133 | 134 | 135 | const recentFilesContainer = recentSection.createEl('div', { cls: 'netclip-recent-files' }); 136 | await this.renderRecentFiles(recentFilesContainer); 137 | } 138 | 139 | if (this.plugin.settings.homeTab.showSavedArticles) { 140 | const savedSection = sectionsContainer.createEl('div', { cls: 'netclip-home-tab-section' }); 141 | const savedTitleContainer = savedSection.createEl('div', { cls: 'netclip-section-title' }); 142 | const savedTitleIcon = savedTitleContainer.createEl('span', { cls: 'netclip-section-icon' }); 143 | setIcon(savedTitleIcon, 'bookmark') 144 | const savedTitleText = savedTitleContainer.createEl('h3', { text: t('saved_articles') || 'Saved Articles' }); 145 | const savedArticlesContainer = savedSection.createEl('div', { cls: 'netclip-saved-articles' }); 146 | 147 | await this.renderSavedArticles(savedArticlesContainer); 148 | } 149 | 150 | } 151 | 152 | 153 | onPaneMenu(menu: Menu, source: string): void { 154 | super.onPaneMenu(menu, source); 155 | 156 | menu.addItem((item) => { 157 | item.setTitle( 'Add shortcut') 158 | .setIcon('plus') 159 | .onClick(() => this.addShortcutModal()); 160 | }) 161 | 162 | menu.addItem((item) => { 163 | item.setTitle('open clipper') 164 | .setIcon('scissors') 165 | .onClick(() => this.showClipModal()); 166 | }) 167 | 168 | 169 | menu.addItem((item) => { 170 | item.setTitle('Refresh') 171 | .setIcon('refresh-cw') 172 | .onClick(() => this.refreshContent()); 173 | }) 174 | 175 | 176 | menu.addItem((item) => { 177 | item.setTitle('Create new file') 178 | .setIcon('file') 179 | .onClick(() => this.createNewFile()); 180 | }) 181 | } 182 | 183 | onResize(): void { 184 | 185 | } 186 | 187 | async onClose(){ 188 | this.containerEl.empty(); 189 | } 190 | 191 | private renderShortcuts(){ 192 | this.shortcutsContainer.empty(); 193 | const shortcutsGrid = this.shortcutsContainer.createEl('div', { cls: 'netclip-shortcuts-grid' }); 194 | 195 | this.shortcuts.forEach(shortcut => { 196 | this.shortcutEl(shortcutsGrid, shortcut); 197 | }); 198 | 199 | const addShortcutButton = shortcutsGrid.createEl('div', { cls: 'netclip-shortcut-add' }); 200 | const addIcon = addShortcutButton.createEl('div', { cls: 'netclip-shortcut-add-icon' }); 201 | setIcon(addIcon, 'plus'); 202 | addShortcutButton.createEl('div', { cls: 'netclip-shortcut-add-text'}); 203 | 204 | addShortcutButton.addEventListener('click', () => { 205 | this.addShortcutModal(); 206 | }); 207 | } 208 | 209 | private shortcutEl(container: HTMLElement, shortcut: Shortcut) { 210 | const shortcutEl = container.createEl('div', { cls: 'netclip-shortcut' }); 211 | 212 | const iconContainer = shortcutEl.createEl('div', { cls: 'netclip-shortcut-icon' }); 213 | const domain = getDomain(shortcut.url); 214 | const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; 215 | iconContainer.createEl('img', { 216 | cls: 'netclip-shortcut-favicon', 217 | attr: { src: faviconUrl } 218 | }); 219 | 220 | shortcutEl.createEl('div', { 221 | cls: 'netclip-shortcut-name', 222 | text: shortcut.name || '' 223 | }); 224 | 225 | shortcutEl.addEventListener('click', (e) => { 226 | e.preventDefault(); 227 | e.stopPropagation(); 228 | this.openShortcut(shortcut); 229 | }); 230 | 231 | shortcutEl.addEventListener('contextmenu', (e) => { 232 | e.preventDefault(); 233 | this.shortcutContextMenu(shortcut, e); 234 | }); 235 | } 236 | 237 | private openShortcut(shortcut: Shortcut) { 238 | const leaf = this.app.workspace.getLeaf(true); 239 | leaf.setViewState({ 240 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 241 | state: { url: shortcut.url } 242 | }); 243 | this.app.workspace.revealLeaf(leaf); 244 | } 245 | 246 | 247 | 248 | private shortcutContextMenu(shortcut: Shortcut, event: MouseEvent) { 249 | const menu = new Menu(); 250 | 251 | menu.addItem(item => { 252 | item.setTitle('Edit shortcut') 253 | .setIcon('pencil') 254 | .onClick(() => this.editShortcutModal(shortcut)); 255 | }); 256 | 257 | menu.addItem(item => { 258 | item.setTitle('Remove shortcut') 259 | .setIcon('trash') 260 | .onClick(() => this.removeShortcut(shortcut)); 261 | }); 262 | 263 | menu.addItem(item => { 264 | item.setTitle('Add shortcut') 265 | .setIcon('plus') 266 | .onClick(() => this.addShortcutModal()); 267 | }); 268 | 269 | menu.showAtMouseEvent(event); 270 | } 271 | 272 | private async addShortcutModal() { 273 | const modal = new ShortcutModal(this.app, null, (shortcut) => { 274 | if (shortcut) { 275 | this.addShortcut(shortcut); 276 | } 277 | }); 278 | modal.open(); 279 | } 280 | 281 | private async editShortcutModal(shortcut: Shortcut) { 282 | const modal = new ShortcutModal(this.app, shortcut, (updatedShortcut) => { 283 | if (updatedShortcut) { 284 | this.updateShortcut(shortcut.id, updatedShortcut); 285 | } 286 | }); 287 | modal.open(); 288 | } 289 | 290 | private addShortcut(shortcut: Shortcut) { 291 | shortcut.id = Date.now().toString(); 292 | this.shortcuts.push(shortcut); 293 | this.saveShortcuts(); 294 | this.renderShortcuts(); 295 | } 296 | 297 | private updateShortcut(id: string, updatedShortcut: Shortcut) { 298 | const index = this.shortcuts.findIndex(s => s.id === id); 299 | if (index !== -1) { 300 | updatedShortcut.id = id; 301 | this.shortcuts[index] = updatedShortcut; 302 | this.saveShortcuts(); 303 | this.renderShortcuts(); 304 | } 305 | } 306 | 307 | 308 | private removeShortcut(shortcut: Shortcut) { 309 | this.shortcuts = this.shortcuts.filter(s => s.id !== shortcut.id); 310 | this.saveShortcuts(); 311 | this.renderShortcuts(); 312 | } 313 | 314 | private async saveShortcuts() { 315 | this.plugin.settings.shortcuts = this.shortcuts; 316 | await this.plugin.saveSettings(); 317 | } 318 | 319 | 320 | async renderRecentFiles(container: HTMLElement, filter: string = '') { 321 | container.empty(); 322 | 323 | let files = this.app.vault.getMarkdownFiles() 324 | .sort((a, b) => b.stat.mtime - a.stat.mtime) 325 | .slice(0, 10); 326 | 327 | if (filter) { 328 | const lowerFilter = filter.toLowerCase(); 329 | files = files.filter(file => file.basename.toLowerCase().includes(lowerFilter)); 330 | } 331 | 332 | if (files.length === 0) { 333 | container.createEl('p', { text: t('no_recent_files') || 'No recent files found' }); 334 | return; 335 | } 336 | 337 | for (const file of files) { 338 | const fileItem = container.createEl('div', { cls: 'netclip-file-item' }); 339 | const fileIcon = fileItem.createEl('span', { cls: 'netclip-file-icon' }); 340 | setIcon(fileIcon, 'file-text'); 341 | 342 | fileItem.createEl('span', { text: file.basename, cls: 'netclip-file-name' }); 343 | 344 | const date = new Date(file.stat.mtime); 345 | const dateStr = `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; 346 | fileItem.createEl('span', { text: dateStr, cls: 'netclip-file-date' }); 347 | 348 | fileItem.addEventListener('click', () => this.openFile(file)); 349 | } 350 | } 351 | 352 | 353 | 354 | 355 | 356 | 357 | async renderSavedArticles(container: HTMLElement) { 358 | container.empty(); 359 | 360 | const baseFolderPath = this.plugin.settings.parentFolderPath 361 | ? `${this.plugin.settings.parentFolderPath}/${this.plugin.settings.defaultFolderName}` 362 | : this.plugin.settings.defaultFolderName; 363 | 364 | let files = this.app.vault.getMarkdownFiles() 365 | .filter(file => file.path.startsWith(baseFolderPath)); 366 | 367 | if (files.length === 0) { 368 | container.createEl('p', { text: t('no_saved_articles') || 'No saved articles found' }); 369 | return; 370 | } 371 | 372 | files = this.shuffleArray(files).slice(0, 9); 373 | 374 | const articlesGrid = container.createEl('div', { cls: 'netclip-articles-grid' }); 375 | 376 | for (const file of files) { 377 | const content = await this.app.vault.cachedRead(file); 378 | const articleCard = articlesGrid.createEl('div', { cls: 'netclip-article-card' }); 379 | 380 | const imageContainer = articleCard.createEl('div', { cls: 'netclip-article-image-container' }); 381 | 382 | const frontmatterMatch = content.match(/^---[\s\S]*?thumbnail: "([^"]+)"[\s\S]*?---/); 383 | let thumbnailUrl = frontmatterMatch ? frontmatterMatch[1] : null; 384 | 385 | if (!thumbnailUrl) { 386 | const thumbnailMatch = content.match(/!\[Thumbnail\]\((.+)\)/); 387 | thumbnailUrl = thumbnailMatch ? thumbnailMatch[1] : null; 388 | } 389 | 390 | if (!thumbnailUrl) { 391 | thumbnailUrl = await findFirstImageInNote(this.app, content); 392 | } 393 | 394 | imageContainer.createEl("img", { 395 | cls: 'netclip-article-thumbnail', 396 | attr: { 397 | src: thumbnailUrl || DEFAULT_IMAGE, 398 | loading: "lazy" 399 | } 400 | }); 401 | 402 | const contentContainer = articleCard.createEl('div', { cls: 'netclip-article-content' }); 403 | 404 | let displayTitle = file.basename; 405 | displayTitle = displayTitle.replace(/_/g, ' '); 406 | if (displayTitle.includes(' - ')) { 407 | displayTitle = displayTitle.substring(0, displayTitle.indexOf(' - ')); 408 | } 409 | 410 | 411 | contentContainer.createEl("div", { 412 | cls: 'netclip-article-title', 413 | text: displayTitle 414 | }); 415 | 416 | 417 | const metaContainer = contentContainer.createEl('div', { cls: 'netclip-article-meta' }); 418 | 419 | 420 | const urlMatch = content.match(/source: "([^"]+)"/); 421 | if (urlMatch) { 422 | const sourceContainer = metaContainer.createEl('div', { cls: 'netclip-article-source' }); 423 | const domainName = getDomain(urlMatch[1]); 424 | sourceContainer.setText(domainName); 425 | 426 | 427 | const dateContainer = metaContainer.createEl('div', { cls: 'netclip-article-date' }); 428 | const creationDate = new Date(file.stat.ctime); 429 | const formattedDate = creationDate.toLocaleDateString(undefined, { 430 | month: 'short', 431 | day: 'numeric' 432 | }); 433 | dateContainer.setText(formattedDate); 434 | } 435 | 436 | articleCard.addEventListener('click', () => this.openFile(file)); 437 | } 438 | 439 | const viewAllBtn = container.createEl('button', { 440 | cls: 'netclip-view-all-btn', 441 | text: t('view_all') || 'View All Articles' 442 | }); 443 | 444 | viewAllBtn.addEventListener('click', () => this.plugin.activateView()); 445 | } 446 | 447 | 448 | 449 | private shuffleArray<T>(array: T[]): T[] { 450 | const newArray = [...array]; 451 | for (let i = newArray.length - 1; i > 0; i--) { 452 | const j = Math.floor(Math.random() * (i + 1)); 453 | [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; // Swap elements 454 | } 455 | return newArray; 456 | } 457 | 458 | private openFile(file: TFile) { 459 | this.app.workspace.getLeaf(false).openFile(file); 460 | } 461 | 462 | 463 | 464 | private openWebSearch(query: string) { 465 | const searchEngine = this.plugin.settings.searchEngine || 'google'; 466 | let searchUrl = ''; 467 | 468 | switch (searchEngine) { 469 | case 'google': 470 | searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; 471 | break; 472 | case 'bing': 473 | searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}`; 474 | break; 475 | case 'duckduckgo': 476 | searchUrl = `https://duckduckgo.com/?q=${encodeURIComponent(query)}`; 477 | break; 478 | case 'youtube': 479 | searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; 480 | break; 481 | case 'perplexity': 482 | searchUrl = `https://www.perplexity.ai/search?q=${encodeURIComponent(query)}`; 483 | break; 484 | } 485 | 486 | const leaf = this.app.workspace.getLeaf(true); 487 | leaf.setViewState({ 488 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 489 | state: { url: searchUrl } 490 | }); 491 | this.app.workspace.revealLeaf(leaf); 492 | } 493 | 494 | 495 | 496 | private updateClock(timeEl: HTMLElement, dateEl: HTMLElement) { 497 | const now = new Date(); 498 | 499 | const hours = now.getHours().toString().padStart(2, '0'); 500 | const minutes = now.getMinutes().toString().padStart(2, '0'); 501 | timeEl.textContent = `${hours}:${minutes}`; 502 | 503 | const options: Intl.DateTimeFormatOptions = { 504 | weekday: 'long', 505 | month: 'long', 506 | day: 'numeric' 507 | }; 508 | dateEl.textContent = now.toLocaleDateString(undefined, options); 509 | } 510 | 511 | private openWebView() { 512 | const defaultUrl = this.plugin.settings.defaultWebUrl || 'https://google.com'; 513 | const leaf = this.app.workspace.getLeaf(true); 514 | leaf.setViewState({ 515 | type: VIEW_TYPE_WORKSPACE_WEBVIEW, 516 | state: { url: defaultUrl } 517 | }); 518 | this.app.workspace.revealLeaf(leaf); 519 | } 520 | 521 | 522 | private openCliperView(){ 523 | const leaf = this.app.workspace.getLeaf(true) 524 | leaf.setViewState({ 525 | type: CLIPPER_VIEW 526 | }); 527 | this.app.workspace.revealLeaf(leaf); 528 | } 529 | 530 | 531 | private showClipModal(){ 532 | new ClipModal(this.app, this.plugin).open() 533 | } 534 | 535 | 536 | private async createNewFile() { 537 | try { 538 | 539 | const timestamp = new Date().getTime(); 540 | const fileName = `New_Note_${timestamp}.md`; 541 | const filePath = `${fileName}` 542 | const file = await this.app.vault.create(filePath, ''); 543 | const leaf = this.app.workspace.getLeaf(false); 544 | await leaf.openFile(file); 545 | 546 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 547 | if (activeView && activeView.editor) { 548 | activeView.editor.focus(); 549 | } 550 | } catch (error) { 551 | console.error('Error creating new file:', error); 552 | new Notice(`Failed to create new file: ${error.message}`); 553 | } 554 | } 555 | 556 | private async refreshContent() { 557 | this.contentEl.empty(); 558 | await this.onOpen(); 559 | this.updateBackgroundImage(); 560 | } 561 | 562 | async onunload() { 563 | const leafContent = this.containerEl.closest('.workspace-leaf-content[data-type="netclip-home-tab-view"]'); 564 | if (leafContent) { 565 | leafContent.removeAttribute('style'); 566 | } 567 | } 568 | } -------------------------------------------------------------------------------- /src/view/ModalWebView.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice } from 'obsidian'; 2 | import { WebViewComponent } from '../webViewComponent'; 3 | import WebClipperPlugin from '../main'; 4 | 5 | 6 | export class WebViewModal extends Modal { 7 | private webViewComponent: WebViewComponent; 8 | private plugin: WebClipperPlugin; 9 | 10 | 11 | constructor( 12 | app: App, 13 | url: string, 14 | plugin: WebClipperPlugin 15 | ) { 16 | super(app); 17 | this.plugin = plugin; 18 | 19 | this.modalEl.addClass('netclip_modal'); 20 | 21 | 22 | this.webViewComponent = new WebViewComponent( 23 | this.app, 24 | url, 25 | { 26 | searchEngine: plugin?.settings?.searchEngine || 'default' 27 | }, 28 | 29 | async (clipUrl) => { 30 | if (this.plugin && typeof this.plugin.clipWebpage === 'function') { 31 | await this.plugin.clipWebpage(clipUrl); 32 | } else { 33 | new Notice('Clip webpage function not available'); 34 | } 35 | }, 36 | this.plugin 37 | ); 38 | } 39 | 40 | 41 | onOpen() { 42 | const { contentEl } = this; 43 | const webViewContainer = this.webViewComponent.createContainer(); 44 | contentEl.appendChild(webViewContainer); 45 | } 46 | 47 | onClose() { 48 | const { contentEl } = this; 49 | contentEl.empty(); 50 | } 51 | } -------------------------------------------------------------------------------- /src/webViewComponent.ts: -------------------------------------------------------------------------------- 1 | import { App, Platform, setIcon, setTooltip } from 'obsidian'; 2 | import { WebSearch, WebSearchSettings } from './search/search'; 3 | import { ClipModal } from './modal/clipModal'; 4 | import { AdBlocker } from './adBlock'; 5 | import type * as Electron from 'electron'; 6 | 7 | let remote: any; 8 | 9 | if (!Platform.isMobileApp) { 10 | remote = require('@electron/remote'); 11 | } 12 | 13 | export interface WebviewTag extends HTMLElement { 14 | src: string; 15 | allowpopups?: boolean; 16 | partition?: string 17 | reload: () => void; 18 | getURL?: () => string; 19 | goBack?: () => void; 20 | goForward?: () => void; 21 | canGoBack?: () => boolean; 22 | canGoForward?: () => boolean; 23 | executeJavaScript: (code: string) => Promise<void>; 24 | remove: () => void; 25 | detach: () => void; 26 | } 27 | 28 | interface WebViewComponentSettings extends WebSearchSettings { 29 | defaultWidth?: string; 30 | defaultHeight?: string; 31 | fitToContainer?: boolean; 32 | } 33 | 34 | export class WebViewComponent { 35 | private static globalAdBlocker: AdBlocker | null = null; 36 | private frame: HTMLIFrameElement | WebviewTag; 37 | private url: string; 38 | private isFrameReady: boolean = false; 39 | private settings: WebViewComponentSettings; 40 | private backBtn: HTMLButtonElement; 41 | private forwardBtn: HTMLButtonElement; 42 | private refreshBtn: HTMLButtonElement; 43 | private clipBtn?: HTMLButtonElement; 44 | private modalClipBtn?: HTMLButtonElement; 45 | private navigationHistory: string[] = []; 46 | private currentHistoryIndex: number = -1; 47 | private loadingSpinner: HTMLElement; 48 | private searchInput: HTMLInputElement; 49 | private search: WebSearch; 50 | private onClipCallback?: (url: string) => Promise<void>; 51 | private titleChangeCallback?: (title: string) => void; 52 | private windowOpenCallback?: (url: string) => void; 53 | private adBlocker: AdBlocker; 54 | private eventListeners: { element: HTMLElement; type: string; listener: EventListener }[] = []; 55 | private frameDoc: Document | null = null; 56 | 57 | constructor( 58 | private app: App, 59 | url: string, 60 | settings: WebViewComponentSettings = {}, 61 | onClipCallback?: (url: string) => Promise<void>, 62 | private plugin?: any 63 | ) { 64 | this.url = url; 65 | this.settings = { 66 | defaultWidth: '100%', 67 | defaultHeight: '100%', 68 | searchEngine: 'google', 69 | fitToContainer: true, 70 | ...settings 71 | }; 72 | this.onClipCallback = onClipCallback; 73 | 74 | if(!WebViewComponent.globalAdBlocker){ 75 | WebViewComponent.globalAdBlocker = new AdBlocker(plugin?.settings?.adBlock); 76 | } 77 | 78 | this.adBlocker = WebViewComponent.globalAdBlocker; 79 | } 80 | 81 | createContainer(): HTMLElement { 82 | const containerEl = document.createElement('div'); 83 | containerEl.classList.add('netClip_webview_container'); 84 | const controlsEl = containerEl.createDiv('netClip_web_controls'); 85 | this.setupNavigationBtns(controlsEl); 86 | this.setupSearchInput(controlsEl); 87 | this.setupClipBtn(controlsEl); 88 | this.setupFrameContainer(containerEl); 89 | this.navigationHistory.push(this.url); 90 | this.currentHistoryIndex = 0; 91 | return containerEl; 92 | } 93 | 94 | private setupFrameContainer(containerEl: HTMLElement): HTMLElement { 95 | const frameContainer = containerEl.createDiv('netClip_frame-container'); 96 | this.loadingSpinner = frameContainer.createDiv('loading-spinner'); 97 | this.frameDoc = frameContainer.ownerDocument; 98 | this.frame = this.createFrame(); 99 | frameContainer.appendChild(this.frame); 100 | return frameContainer; 101 | } 102 | 103 | private createFrame(): HTMLIFrameElement | WebviewTag { 104 | return Platform.isMobileApp ? this.createIframe() : this.createWebview(); 105 | } 106 | 107 | private createWebview(): WebviewTag { 108 | const webview = this.frameDoc?.createElement('webview') as WebviewTag || document.createElement('webview') as WebviewTag; 109 | webview.classList.add('webview'); 110 | 111 | webview.setAttribute('allowpopups', ''); 112 | webview.setAttribute('webpreferences', 'contextIsolation=yes, nodeIntegration=no'); 113 | webview.setAttribute('plugins', 'true'); 114 | 115 | 116 | if (!this.plugin?.settings?.privateMode) { 117 | webview.partition = 'persist:netclip-shared'; 118 | } else { 119 | const sessionId = `private-${Date.now()}`; 120 | webview.partition = `persist:${sessionId}`; 121 | } 122 | 123 | if (this.plugin?.settings?.privateMode) { 124 | webview.setAttribute('disablewebsecurity', 'true'); 125 | webview.setAttribute('disableblinkfeatures', 'AutomationControlled'); 126 | webview.setAttribute('disablefeatures', 'Translate,NetworkService'); 127 | webview.setAttribute('cookiestore', 'private'); 128 | webview.setAttribute('disablethirdpartycookies', 'true'); 129 | } 130 | 131 | webview.src = this.url; 132 | 133 | // Handle window destruction and recreation 134 | webview.addEventListener('destroyed', () => { 135 | const parent = webview.parentElement; 136 | if (parent && parent.ownerDocument !== this.frameDoc) { 137 | this.cleanupWebview(webview); 138 | 139 | 140 | this.frameDoc = parent.ownerDocument; 141 | 142 | const newWebview = this.createWebview(); 143 | parent.appendChild(newWebview); 144 | this.frame = newWebview; 145 | this.setupWebviewEvents(newWebview); 146 | } 147 | }); 148 | 149 | this.setupWebviewEvents(webview); 150 | return webview; 151 | } 152 | 153 | private cleanupWebview(webview: WebviewTag): void { 154 | if (webview) { 155 | try { 156 | webview.remove(); 157 | } catch (e) { 158 | // console.warn('Error cleaning up webview:', e); 159 | } 160 | } 161 | } 162 | 163 | private setupSearchInput(container: HTMLElement): void { 164 | const searchContainer = container.createDiv('netClip_search_container'); 165 | this.searchInput = searchContainer.createEl('input', { type: 'text', placeholder: 'Search...', value: this.url }); 166 | const suggestionContainer = searchContainer.createDiv('netClip_query_box'); 167 | const suggestionsBox = suggestionContainer.createDiv('netClip_suggestions'); 168 | this.search = new WebSearch( 169 | this.searchInput, 170 | suggestionContainer, 171 | suggestionsBox, 172 | { searchEngine: this.settings.searchEngine } 173 | ); 174 | this.searchInput.addEventListener('search-query', (event: CustomEvent) => { 175 | const { url } = event.detail; 176 | this.navigateTo(url); 177 | }); 178 | } 179 | 180 | private setupClipBtn(container: HTMLElement): void { 181 | const clipContianer = container.createDiv('netClip_clip_btn_container'); 182 | 183 | if (this.onClipCallback) { 184 | this.clipBtn = clipContianer.createEl('button', { 185 | text: 'Quick clip', 186 | cls: 'netClip_quick_clip_btn netClip_btn' 187 | }); 188 | 189 | setIcon(this.clipBtn, 'folder-down') 190 | setTooltip(this.clipBtn, 'Quick save'); 191 | 192 | this.clipBtn.onclick = () => { 193 | this.onClipCallback?.(this.getCurrentUrl()); 194 | }; 195 | } 196 | 197 | if (this.plugin) { 198 | this.modalClipBtn = clipContianer.createEl('button', { 199 | cls: 'netClip_modal_clip_btn netClip_btn' 200 | }); 201 | 202 | setIcon(this.modalClipBtn, 'folder-symlink'); 203 | setTooltip(this.modalClipBtn, 'Save to...'); 204 | 205 | this.modalClipBtn.onclick = () => { 206 | if (this.plugin) { 207 | const modal = new ClipModal(this.app, this.plugin); 208 | modal.tryGetClipboardUrl = async () => this.getCurrentUrl(); 209 | modal.open(); 210 | } 211 | }; 212 | } 213 | } 214 | 215 | private setupNavigationBtns(container: HTMLElement): void { 216 | const leftContainer = container.createDiv('netClip_nav_left'); 217 | this.backBtn = leftContainer.createEl('button', { cls: 'netClip_back_btn netClip_btn' }); 218 | setIcon(this.backBtn, 'chevron-left'); 219 | this.backBtn.onclick = () => this.goBack(); 220 | this.backBtn.disabled = true; 221 | 222 | this.forwardBtn = leftContainer.createEl('button', { cls: 'netClip_forward_btn netClip_btn' }); 223 | setIcon(this.forwardBtn, 'chevron-right'); 224 | this.forwardBtn.onclick = () => this.goForward(); 225 | this.forwardBtn.disabled = true; 226 | 227 | this.refreshBtn = leftContainer.createEl('button', { cls: 'netClip_refresh_btn netClip_btn' }); 228 | setIcon(this.refreshBtn, 'rotate-ccw'); 229 | this.refreshBtn.onclick = () => this.refresh(); 230 | } 231 | 232 | private createIframe(): HTMLIFrameElement { 233 | const iframe = document.createElement('iframe'); 234 | iframe.setAttribute('allowpopups', ''); 235 | iframe.setAttribute('credentialless', 'true'); 236 | iframe.setAttribute('crossorigin', 'anonymous'); 237 | iframe.setAttribute('src', this.url); 238 | 239 | let sandbox = 'allow-forms allow-modals allow-popups allow-presentation allow-scripts allow-top-navigation-by-user-activation'; 240 | if(!this.plugin?.settings?.privateMode){ 241 | sandbox += ' allow-same-origin'; 242 | } 243 | iframe.setAttribute('sandbox', sandbox); 244 | 245 | iframe.setAttribute('allow', 'encrypted-media;fullscreen;oversized-images;picture-in-picture;sync-xhr;geolocation'); 246 | iframe.addEventListener('load', () => { 247 | this.onFrameLoad(); 248 | if (this.plugin?.settings?.adBlock?.enabled) { 249 | setTimeout(() => { 250 | if (iframe.contentDocument) { 251 | this.adBlocker.applyFilters(iframe as unknown as Electron.WebviewTag); 252 | } 253 | }, 500); 254 | } 255 | const title = iframe.contentDocument?.title; 256 | if(title && this.titleChangeCallback){ 257 | this.titleChangeCallback(title); 258 | } 259 | }); 260 | return iframe; 261 | } 262 | 263 | private setupWebviewEvents(webview: WebviewTag): void { 264 | webview.addEventListener('dom-ready', () => { 265 | this.isFrameReady = true; 266 | this.updateUrlDisplay(); 267 | this.loadingSpinner.classList.remove('loading-spinner-visible'); 268 | 269 | const webContents = remote.webContents.fromId((webview as any).getWebContentsId()); 270 | 271 | 272 | webContents.setWindowOpenHandler(({url}: {url: string}) => { 273 | if (this.windowOpenCallback) { 274 | this.windowOpenCallback(url); 275 | return {action: 'deny'}; 276 | } 277 | 278 | if (this.plugin?.settings?.privateMode) { 279 | this.createNewPrivateWindow(url); 280 | return {action: 'deny'}; 281 | } 282 | 283 | 284 | return { 285 | action: 'allow', 286 | overrideBrowserWindowOptions: { 287 | width: 800, 288 | height: 600, 289 | webPreferences: { 290 | nodeIntegration: false, 291 | contextIsolation: true, 292 | webSecurity: true, 293 | partition: webview.partition, 294 | javascript: true, 295 | plugins: true, 296 | webgl: true 297 | } 298 | } 299 | }; 300 | }); 301 | 302 | if (this.plugin?.settings?.adBlock?.enabled) { 303 | this.setupAdBlocking(webview); 304 | } 305 | 306 | if (this.plugin?.settings?.privateMode) { 307 | this.setupPrivateMode(webview); 308 | } 309 | }); 310 | 311 | 312 | webview.addEventListener('did-start-loading', () => { 313 | this.loadingSpinner.classList.add('loading-spinner-visible'); 314 | }); 315 | 316 | webview.addEventListener('did-stop-loading', () => { 317 | this.loadingSpinner.classList.remove('loading-spinner-visible'); 318 | }); 319 | 320 | webview.addEventListener('did-navigate', () => { 321 | this.updateUrlDisplay(); 322 | this.updateNavigationButtons(); 323 | }); 324 | 325 | webview.addEventListener('did-navigate-in-page', () => { 326 | this.updateUrlDisplay(); 327 | this.updateNavigationButtons(); 328 | }); 329 | 330 | webview.addEventListener('page-title-updated', (event: any) => { 331 | if(this.titleChangeCallback){ 332 | this.titleChangeCallback(event.title); 333 | } 334 | }); 335 | 336 | webview.addEventListener('did-fail-load', (event: any) => { 337 | this.loadingSpinner.classList.remove('loading-spinner-visible'); 338 | }); 339 | } 340 | 341 | private createNewPrivateWindow(url: string): void { 342 | const container = document.createElement('div'); 343 | container.classList.add('netClip_webview_container'); 344 | 345 | const controlsEl = container.createDiv('netClip_web_controls'); 346 | this.setupNavigationBtns(controlsEl); 347 | this.setupSearchInput(controlsEl); 348 | this.setupClipBtn(controlsEl); 349 | 350 | const frameContainer = container.createDiv('netClip_frame-container'); 351 | const newWebview = this.createWebview(); 352 | newWebview.src = url; 353 | frameContainer.appendChild(newWebview); 354 | 355 | document.body.appendChild(container); 356 | container.style.position = 'fixed'; 357 | container.style.top = '50%'; 358 | container.style.left = '50%'; 359 | container.style.transform = 'translate(-50%, -50%)'; 360 | container.style.width = '80%'; 361 | container.style.height = '80%'; 362 | container.style.zIndex = '1000'; 363 | } 364 | 365 | private onFrameLoad(): void { 366 | this.isFrameReady = true; 367 | this.updateUrlDisplay(); 368 | const currentUrl = this.getCurrentUrl(); 369 | if (currentUrl !== this.navigationHistory[this.currentHistoryIndex]) { 370 | this.navigationHistory = this.navigationHistory.slice(0, this.currentHistoryIndex + 1); 371 | this.navigationHistory.push(currentUrl); 372 | this.currentHistoryIndex++; 373 | this.updateNavigationButtons(); 374 | } 375 | } 376 | 377 | private navigateTo(url: string): void { 378 | this.url = url; 379 | this.navigationHistory.push(this.url); 380 | this.currentHistoryIndex = this.navigationHistory.length - 1; 381 | this.frame.src = this.url; 382 | this.updateUrlDisplay(); 383 | } 384 | 385 | private getCurrentUrl(): string { 386 | if (this.frame instanceof HTMLIFrameElement) { 387 | return this.frame.contentWindow?.location.href || this.url; 388 | } else if (this.frame && typeof this.frame.getURL === 'function') { 389 | return this.frame.getURL() || this.url; 390 | } 391 | return this.url; 392 | } 393 | 394 | private updateUrlDisplay(): void { 395 | const currentUrl = this.getCurrentUrl(); 396 | this.url = currentUrl; 397 | if (this.searchInput) { 398 | this.searchInput.value = currentUrl; 399 | } 400 | } 401 | 402 | private updateNavigationButtons(): void { 403 | if (this.frame instanceof HTMLIFrameElement) { 404 | this.backBtn.disabled = this.currentHistoryIndex <= 0; 405 | this.forwardBtn.disabled = this.currentHistoryIndex >= this.navigationHistory.length - 1; 406 | } else { 407 | const webview = this.frame as WebviewTag; 408 | this.backBtn.disabled = !(webview.canGoBack?.()); 409 | this.forwardBtn.disabled = !(webview.canGoForward?.()); 410 | } 411 | } 412 | 413 | private goBack(): void { 414 | if (this.isFrameReady) { 415 | this.loadingSpinner.classList.add('loading-spinner-visible'); 416 | if (this.frame instanceof HTMLIFrameElement) { 417 | if (this.currentHistoryIndex > 0) { 418 | this.currentHistoryIndex--; 419 | this.frame.src = this.navigationHistory[this.currentHistoryIndex]; 420 | } 421 | } else { 422 | const webview = this.frame as WebviewTag; 423 | if (webview.canGoBack?.()) { 424 | webview.goBack?.(); 425 | } 426 | } 427 | } 428 | } 429 | 430 | private goForward(): void { 431 | if (this.isFrameReady) { 432 | this.loadingSpinner.classList.add('loading-spinner-visible'); 433 | if (this.frame instanceof HTMLIFrameElement) { 434 | if (this.currentHistoryIndex < this.navigationHistory.length - 1) { 435 | this.currentHistoryIndex++; 436 | this.frame.src = this.navigationHistory[this.currentHistoryIndex]; 437 | } 438 | } else { 439 | const webview = this.frame as WebviewTag; 440 | if (webview.canGoForward?.()) { 441 | webview.goForward?.(); 442 | } 443 | } 444 | } 445 | } 446 | 447 | private refresh(): void { 448 | if (this.isFrameReady) { 449 | this.loadingSpinner.classList.add('loading-spinner-visible'); 450 | if (this.frame instanceof HTMLIFrameElement) { 451 | this.frame.contentWindow?.location.reload(); 452 | } else { 453 | this.frame.reload(); 454 | } 455 | } 456 | } 457 | 458 | onTitleChange(callback: (title: string) => void){ 459 | this.titleChangeCallback = callback 460 | } 461 | 462 | onWindowOpen(callback: (url: string) => void){ 463 | this.windowOpenCallback = callback; 464 | } 465 | 466 | private setupAdBlocking(webview: WebviewTag): void { 467 | this.adBlocker.initializeFilters().then(() => { 468 | this.adBlocker.applyFilters(webview as unknown as Electron.WebviewTag); 469 | 470 | webview.addEventListener('dom-ready', () => { 471 | webview.executeJavaScript(this.adBlocker.getDOMFilterScript()) 472 | .catch(error => { 473 | // console.error('Adblock script error:', error); 474 | }); 475 | }); 476 | }); 477 | } 478 | 479 | private setupPrivateMode(webview: WebviewTag): void { 480 | webview.executeJavaScript(` 481 | window.sessionStorage.clear(); 482 | window.localStorage.clear(); 483 | 484 | // Override storage APIs 485 | Object.defineProperties(window, { 486 | 'localStorage': { 487 | value: undefined 488 | }, 489 | 'sessionStorage': { 490 | value: undefined 491 | } 492 | }); 493 | 494 | // Clear cookies 495 | document.cookie.split(';').forEach(cookie => { 496 | const eqPos = cookie.indexOf('='); 497 | const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; 498 | document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; 499 | }); 500 | `).catch(console.error); 501 | 502 | const webContents = remote.webContents.fromId((webview as any).getWebContentsId()); 503 | webContents.session.webRequest.onBeforeSendHeaders((details: any, callback: any) => { 504 | const requestHeaders = { 505 | ...details.requestHeaders, 506 | 'DNT': '1', 507 | 'Sec-GPC': '1' 508 | }; 509 | callback({ requestHeaders }); 510 | }); 511 | } 512 | 513 | private addEventListenerWithCleanup(element: HTMLElement, type: string, listener: EventListener) { 514 | element.addEventListener(type, listener); 515 | this.eventListeners.push({ element, type, listener }); 516 | } 517 | 518 | unload() { 519 | this.eventListeners.forEach(({ element, type, listener }) => { 520 | element.removeEventListener(type, listener); 521 | }); 522 | this.eventListeners = []; 523 | 524 | if (this.frame) { 525 | this.frame.remove(); 526 | } 527 | 528 | if (this.search) { 529 | this.search.unload(); 530 | } 531 | } 532 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.4.0", 3 | "1.0.1": "1.4.0", 4 | "1.0.2": "1.4.0", 5 | "1.1.2": "1.4.0", 6 | "1.1.3": "1.4.0", 7 | "1.1.4": "1.4.0", 8 | "1.1.5": "1.4.0", 9 | "1.1.6": "1.4.0", 10 | "1.2.6": "1.4.0", 11 | "1.2.7": "1.4.0", 12 | "1.2.8": "1.4.0", 13 | "1.2.9": "1.4.0", 14 | "1.3.0": "1.4.0", 15 | "1.3.1": "1.4.0", 16 | "1.3.2": "1.4.0", 17 | "1.3.3": "1.4.0", 18 | "1.3.4": "1.4.0", 19 | "1.3.5": "1.4.0", 20 | "1.3.6": "1.4.0", 21 | "1.3.7": "1.4.0" 22 | } 23 | --------------------------------------------------------------------------------