Final updates for initial release
Debugged. Added setup function. Added Enrich features. Updated documentation.
This commit is contained in:
parent
a598ce1d16
commit
e3d0207787
165
README.md
165
README.md
|
@ -1,94 +1,119 @@
|
|||
# Obsidian Sample Plugin
|
||||
# Processor Processor for Obsidian
|
||||
|
||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
||||

|
||||

|
||||
|
||||
This project uses TypeScript to provide type checking and documentation.
|
||||
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
|
||||
For anyone involved in vendor risk management, mapping out subprocessor relationships can be a complex and time-consuming task. This plugin is a powerful, specialized tool designed to automate and streamline that process.
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open Sample Modal" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
- Registers a global click event and output 'click' to the console.
|
||||
- Registers a global interval which logs 'setInterval' to the console.
|
||||
Processor Processor acts as your AI-powered co-pilot to discover, map, enrich, and document the relationships between data processors and their subprocessors, all directly within your Obsidian vault. This is the first release, and your feedback is greatly appreciated!
|
||||
|
||||
## First time developing plugins?
|
||||
---
|
||||
|
||||
Quick starting guide for new plugin devs:
|
||||
### 🚀 Getting Started: One-Time Setup
|
||||
|
||||
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
|
||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
||||
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
|
||||
- Install NodeJS, then run `npm i` in the command line under your repo folder.
|
||||
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
|
||||
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
|
||||
- Reload Obsidian to load the new version of your plugin.
|
||||
- Enable plugin in settings window.
|
||||
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
|
||||
This plugin is deeply integrated with [RightBrain.ai](https://rightbrain.ai) to provide its intelligent features. You only need to perform this setup once.
|
||||
|
||||
## Releasing new releases
|
||||
**1. Create a RightBrain Account**
|
||||
|
||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
||||
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
|
||||
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
|
||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
|
||||
- Publish the release.
|
||||
* Go to [https://app.rightbrain.ai/](https://app.rightbrain.ai/) to register.
|
||||
* You can create an account using social sign-on with GitHub, GitLab, Google, or LinkedIn.
|
||||
* Follow the initial setup steps to create your account and first project.
|
||||
|
||||
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
|
||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
||||
**2. Create your RightBrain API Client**
|
||||
|
||||
## Adding your plugin to the community plugin list
|
||||
* Navigate to your RightBrain API Clients page: [https://stag.leftbrain.me/preferences?tab=api-clients](https://stag.leftbrain.me/preferences?tab=api-clients).
|
||||
* Click **Create OAuth Client**.
|
||||
* Give it a descriptive name (e.g., "Obsidian Plugin").
|
||||
* For "Token Endpoint Auth Method," select **Client Secret Basic (client_credentials)**.
|
||||
* Click **Create**.
|
||||
|
||||
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
|
||||
- Publish an initial version.
|
||||
- Make sure you have a `README.md` file in the root of your repo.
|
||||
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
|
||||
**3. Securely Store Your Client Secret**
|
||||
|
||||
## How to use
|
||||
* You will now be shown your `Client ID` and a `Client Secret`.
|
||||
* **IMPORTANT:** The `Client Secret` is like a password for your application. It will only be shown to you **once**.
|
||||
* Immediately copy the `Client Secret` and store it securely in a password manager.
|
||||
|
||||
- Clone this repo.
|
||||
- Make sure your NodeJS is at least v16 (`node --version`).
|
||||
- `npm i` or `yarn` to install dependencies.
|
||||
- `npm run dev` to start compilation in watch mode.
|
||||
**4. Copy Your Environment Variables**
|
||||
|
||||
## Manually installing the plugin
|
||||
* On the same page, you will see a block of text with your environment variables (`RB_ORG_ID`, etc.).
|
||||
* Click the **Copy ENV** button to copy this entire block to your clipboard.
|
||||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
**5. Run the Setup in Obsidian**
|
||||
|
||||
## Improve code quality with eslint (optional)
|
||||
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
|
||||
- To use eslint with this project, make sure to install eslint from terminal:
|
||||
- `npm install -g eslint`
|
||||
- To use eslint to analyze this project use this command:
|
||||
- `eslint main.ts`
|
||||
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
|
||||
- `eslint .\src\`
|
||||
* Make sure the Processor Processor plugin is installed and enabled in Obsidian.
|
||||
* Open the Obsidian Command Palette (`Cmd/Ctrl + P`).
|
||||
* Run the command: **`Complete First-Time Setup (Credentials & Tasks)`**.
|
||||
* A window will pop up. Paste the environment variables you just copied into the text area.
|
||||
* Click **Begin Setup**.
|
||||
|
||||
## Funding URL
|
||||
That's it! The plugin will automatically save your credentials and create all the necessary AI tasks in your RightBrain project.
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
---
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
### 🔒 A Note on Security
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
}
|
||||
```
|
||||
Your RightBrain credentials (Client ID, Client Secret) and other settings are stored in the `data.json` file located within this plugin's folder in your vault's system directory (`.obsidian/plugins/processor-processor/`).
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
Please be aware that your `Client Secret` is stored in plaintext in this file. This is standard practice for most Obsidian plugins that require API keys. We recommend you use a dedicated vault for this type of research and ensure your vault's location is secure.
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
### ✨ Core Features
|
||||
|
||||
See https://github.com/obsidianmd/obsidian-api
|
||||
* **One-Command Setup:** Get started in minutes. Paste your credentials from RightBrain and the plugin automatically configures itself and creates the required AI tasks in your project.
|
||||
* **Smart Recursive Mapping:** Start with a single company and automatically cascade searches through their subprocessors, building a deep dependency map. The search is "smart"—it uses a cache to avoid re-analyzing recent vendors and maps aliases to existing notes to prevent duplicates.
|
||||
* **Handle Difficult Sources:** Some subprocessor lists are buried in PDFs, hard-to-parse web pages, or not publicly available at all. The **`Manually Add Subprocessor List URL`** and **`Input Subprocessor List from Text`** features allow you to point the AI directly at a URL or simply paste the text to ensure nothing is missed.
|
||||
* **AI-Powered Verification & Extraction:** Uses RightBrain to verify if a URL is a genuine, current subprocessor list and then extracts the names of all third-party vendors and internal company affiliates.
|
||||
* **Automated Note Creation & Linking:** Creates a central, linked note for each processor and subprocessor. A processor's note lists its subprocessors; a subprocessor's note lists who it's "Used By."
|
||||
* **AI-Powered Deduplication:** Run a command on your processors folder to find and merge duplicate entities, combining their relationships automatically.
|
||||
* **Compliance Document Enrichment:** Right-click any processor file to automatically find and link to that company's public DPA, Terms of Service, and Security pages.
|
||||
|
||||
---
|
||||
|
||||
### How to Use: A Sample Workflow
|
||||
|
||||
1. **Start a Recursive Discovery:**
|
||||
* Open the command palette (`Cmd/Ctrl+P`) and run **`Search for Subprocessors (Recursive Discover)`**.
|
||||
* Enter a top-level vendor you use, like "Microsoft".
|
||||
* The plugin will begin discovering Microsoft's subprocessors, and then the subprocessors of those subprocessors, creating a network of notes in your `Processors` folder.
|
||||
|
||||
2. **Manually Add from a PDF:**
|
||||
* During your research, you find that one of Microsoft's subprocessors, "Contoso Ltd," only lists their own subprocessors in a PDF.
|
||||
* Open the PDF, copy the list of companies, and run the command **`Input Subprocessor List from Text`**.
|
||||
* Enter "Contoso Ltd" as the processor name, paste the text, and the plugin will extract the entities and link them correctly.
|
||||
|
||||
3. **Clean Up with Deduplication:**
|
||||
* After all the discovery, you might have notes for both "AWS" and "Amazon Web Services."
|
||||
* Right-click on your `Processors` folder and select **`Deduplicate Subprocessor Pages`** to automatically find and merge them.
|
||||
|
||||
4. **Enrich Key Vendor Files:**
|
||||
* Now that you have a clean list, right-click the `Microsoft.md` file in your vault.
|
||||
* Select **`Enrich Processor Documentation`**. The plugin will find and add direct links for Microsoft's DPA, ToS, and Security pages right into the note for easy access.
|
||||
|
||||
---
|
||||
|
||||
### 🌱 Future Development & Feedback
|
||||
|
||||
This is the first release of Processor Processor. The "Enrich" features are just the beginning of a larger plan to build a suite of automated due diligence tools.
|
||||
|
||||
Your feedback is invaluable for guiding what comes next! If you have ideas for new features or improvements, please share them by raising an issue on the plugin's GitHub repository.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Limitations & Caveats
|
||||
|
||||
* **Reliance on Public Data:** The plugin can only find and process subprocessor lists that are publicly accessible. If a company does not publish this information, the plugin cannot invent it.
|
||||
* **Scope of Subprocessor Lists:** Vendor subprocessor lists are typically comprehensive and cover all of their services. For example, Google's list includes subprocessors for all its products (Workspace, Cloud, etc.). If you only use Google Workspace, the plugin will still identify and map all subprocessors from the master list, many of which may not be relevant to your (or your processor's) specific use case. The plugin accurately reflects the source documentation and does not attempt to guess which subprocessors apply to you, as this would be unreliable.
|
||||
* **Quality of Source Data:** The accuracy of the extracted relationships depends on the clarity and format of the source documents. Ambiguous or poorly formatted lists may lead to less accurate results.
|
||||
* **This is not Legal Advice:** The plugin is a tool to accelerate research. It is not a substitute for professional legal or compliance advice. Always verify critical information.
|
||||
|
||||
---
|
||||
|
||||
### Author
|
||||
|
||||
Tisats
|
||||
[rightbrain.ai](https://rightbrain.ai)
|
||||
|
||||
### Funding
|
||||
|
||||
This plugin is provided to encourage the use and exploration of [RightBrain.ai](https://rightbrain.ai). If you find it useful, please consider exploring RightBrain for your other automation needs.
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "processor-processor",
|
||||
"name": "Procesor Processor",
|
||||
"name": "Processor Processor",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "Searches for subprocessor information for data processors.",
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
[
|
||||
{
|
||||
"name": "Verify Subprocessor List URL",
|
||||
"description": "Checks if a URL points to a current and valid subprocessor list for a specific company.",
|
||||
"system_prompt": "You are an expert in data privacy and compliance documentation analysis.",
|
||||
"user_prompt": "Goal: Your primary goal is to determine if the provided text from {url_content} is a subprocessor list that belongs to the {expected_processor_name} and is the current, active version.\n\nContext: Subprocessor lists disclose third-party data processors. Companies often leave historical/archived versions online. Your goal is to identify the currently effective list for the correct company. For date context: Today is approximately June 9, 2025.\n\nInput Parameters:\n\n{url_content}: The text content retrieved from a URL.\n\n{expected_processor_name}: The name of the company for whom you are trying to find the subprocessor list.\n\nProcessing Steps: Your decision process must follow these steps in order:\n\n1. Is it a Subprocessor List?\n- Scan for explicit titles (e.g., \"Subprocessor List,\" \"Our Sub-Processors\").\n- Look for a structured list/table of multiple distinct company names.\n- If it is not a subprocessor list, stop. The other checks are irrelevant.\n\n2. Is it for the Correct Company?\n- Only if it is a subprocessor list, analyze the page content (title, headings, legal text) to identify which company it belongs to.\n- Compare the identified company with the {expected_processor_name}. You must account for variations like abbreviations (e.g., \"AWS\" for \"Amazon Web Services\") and legal names (e.g., \"Google LLC\" for \"Google\").\n- If the list does not clearly belong to the {expected_processor_name}, stop. The currency check is irrelevant.\n\n3. Is the List Current?\n- Only if it is a subprocessor list for the correct company, determine if it's the current version using the following evidence, in order of priority:\n- A. Explicit Archival (Strongly indicates NOT CURRENT): Look for \"archived,\" \"historical,\" \"superseded by,\" or an effective end date clearly before June 2025.\n- B. Explicit Currency (Strongly indicates CURRENT): Look for \"current list,\" an effective date after June 2025, or a \"Last Updated\" date within the last year with no archival notices.\n- C. Old Dates (Suggests NOT CURRENT): An effective or updated date from 2-3+ years ago with no other confirmation.\n- D. No Negative Indicators (Weakly suggests CURRENT): If it's a list for the correct company with no date information and no archival notices, you may infer it's current.\n",
|
||||
"llm_model_id": "0195a35e-a71c-7c9d-f1fa-28d0b6667f2d",
|
||||
"output_format": {
|
||||
"isSubprocessorList": { "type": "boolean", "description": "True if the content appears to be a list of subprocessors." },
|
||||
"isCorrectProcessor": { "type": "boolean", "description": "True if the page content belongs to the 'expected_processor_name'." },
|
||||
"isCurrentVersion": { "type": "boolean", "description": "True if the list appears to be the current, active version." },
|
||||
"reasoning": { "type": "string", "description": "Briefly state the key evidence for your decision." },
|
||||
"page_content": { "type": "string", "description": "The full text content fetched from the input URL." }
|
||||
},
|
||||
"input_processors": [
|
||||
{ "param_name": "url_content", "input_processor": "url_fetcher", "config": { "extract_text": true } }
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Extract Entities From Page Content",
|
||||
"description": "Extracts and categorizes subprocessor and internal affiliate details from text content.",
|
||||
"system_prompt": "You are an expert data extraction specialist, skilled at identifying and categorizing entities from unstructured text. Focus on accurately discerning third-party subprocessors from internal entities, extracting key details and organizing the information into a structured JSON format.",
|
||||
"user_prompt": "Goal: Extract detailed information about third-party subprocessors and internal entities from a company's subprocessor information page.\n\nContext: Subprocessor information pages typically list external companies (third-party subprocessors) that process data on behalf of the main company, as well as internal entities or affiliates. This information is often structured in sections with headings and may be formatted as tables using Markdown or HTML elements.\n\nInput Parameters:\n{page_text} - The text content from a company's subprocessor information page.\n\nProcessing Steps:\n1. Analyze the provided text.\n2. Identify sections for third-party subprocessors (e.g., \"Third Party Sub-processors\") and internal entities (e.g., \"Our Group Companies\").\n3. For each third-party subprocessor, extract: name, processing function, and location.\n4. For each internal entity, extract: name, role/function, and location.\n5. Organize the extracted information into the required JSON structure.\n\nOutput Guidance:\nReturn a JSON object with two top-level keys: 'third_party_subprocessors' and 'own_entities', each a list of objects containing 'name', 'processing_function', and 'location'. If a category is empty, return an empty list. Use null for missing fields. If no distinction is made, classify all as 'third_party_subprocessors'.",
|
||||
"llm_model_id": "0195a35e-a71c-7c9d-f1fa-28d0b6667f2d",
|
||||
"output_format": {
|
||||
"third_party_subprocessors": {
|
||||
"type": "list", "item_type": "object",
|
||||
"nested_structure": { "name": { "type": "string" }, "processing_function": { "type": "string" }, "location": { "type": "string" } }
|
||||
},
|
||||
"own_entities": {
|
||||
"type": "list", "item_type": "object",
|
||||
"nested_structure": { "name": { "type": "string" }, "processing_function": { "type": "string" }, "location": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"input_processors": [],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Deduplicate Subprocessors",
|
||||
"description": "Identifies duplicate subprocessor pages in an Obsidian folder for merging.",
|
||||
"system_prompt": "You are an AI assistant specialized in data organization and deduplication for Obsidian notes. Your task is to analyze a list of 'subprocessor_pages' and identify duplicates based on their name and aliases.",
|
||||
"user_prompt": "Analyze the following list of subprocessor pages and identify any duplicates. For each set, determine a survivor and list the others to be merged.\n\nInput: {subprocessor_pages} (A list of objects, each with 'file_path', 'page_name', 'aliases').\n\nProcess:\n1. Normalize all names and aliases (lowercase, remove suffixes like 'inc', 'llc', 'corp', and generic terms like 'technologies', 'solutions').\n2. Group pages with identical or highly similar normalized identifiers.\n3. For each group, select one 'survivor' based on the most canonical name, highest alias count, or simplest file path.\n\nOutput: Return a JSON object with a 'deduplication_results' list. Each item should contain 'survivor_file_path', a 'duplicate_file_paths' list, and 'reasoning_for_survivor_choice'.",
|
||||
"llm_model_id": "0195a35e-a71c-7c9d-f1fa-28d0b6667f2d",
|
||||
"output_format": {
|
||||
"deduplication_results": {
|
||||
"type": "list", "item_type": "object",
|
||||
"nested_structure": {
|
||||
"survivor_file_path": { "type": "string" },
|
||||
"duplicate_file_paths": { "type": "list", "item_type": "string" },
|
||||
"reasoning_for_survivor_choice": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"input_processors": [],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "DDG SERP Parser",
|
||||
"description": "Parses a DuckDuckGo search results page and returns a filtered list of relevant URLs.",
|
||||
"system_prompt": "You are an AI assistant that functions as an expert web scraper and data extractor. Your primary goal is to analyze the provided HTML content of a search engine results page (SERP) from DuckDuckGo and extract individual organic search results.",
|
||||
"user_prompt": "The input parameter '{search_url_to_process}' contains the full HTML content of a DuckDuckGo search results page. Your task is to meticulously parse this HTML and extract each organic search result's 'title', 'url', and 'snippet'. Return your findings as a JSON object with a key 'search_results', which holds a list of objects.",
|
||||
"llm_model_id": "01965cb4-73f4-9ec3-6f21-bede0391e2b4",
|
||||
"output_format": {
|
||||
"search_results": {
|
||||
"type": "list", "item_type": "object",
|
||||
"nested_structure": { "title": { "type": "string" }, "url": { "type": "string" }, "snippet": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"input_processors": [
|
||||
{ "param_name": "search_url_to_process", "input_processor": "url_fetcher", "config": { "extract_text": true } }
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Find DPA URL",
|
||||
"description": "Finds the canonical URL for a company's Data Processing Agreement (DPA).",
|
||||
"system_prompt": "You are a specialized AI assistant proficient in legal document retrieval. Focus on quickly identifying and validating the official DPA URL using efficient search strategies.",
|
||||
"user_prompt": "Your sole purpose is to find the canonical URL for the Data Processing Agreement (DPA) of the given {company_name}. Formulate precise search queries (e.g., '\"{company_name}\" data processing agreement'), prioritize links from the company's official domains, and verify the page contains the actual DPA document. Your response MUST be a single, valid JSON object with one key: 'url'. If not found, the value must be null.",
|
||||
"llm_model_id": "01965cb4-73f4-9ec3-6f21-bede0391e2b4",
|
||||
"output_format": { "url": "string" },
|
||||
"input_processors": [],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Find ToS URL",
|
||||
"description": "Finds the canonical URL for a company's Terms of Service (ToS).",
|
||||
"system_prompt": "You are a highly skilled web researcher, adept at navigating complex websites and legal documents. Focus on identifying the most relevant URL for a company's official Terms of Service.",
|
||||
"user_prompt": "Your sole purpose is to find the canonical URL for the main customer Terms of Service (ToS) of the given {company_name}. Be aware of alternate names like 'Master Service Agreement' or 'General Terms.' Prioritize official domains and ensure the page contains the actual legal agreement, not a summary. Your response MUST be a single, valid JSON object with one key: 'url'. If not found, the value must be null.",
|
||||
"llm_model_id": "01965cb4-73f4-9ec3-6f21-bede0391e2b4",
|
||||
"output_format": { "url": "string" },
|
||||
"input_processors": [],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Find Security Page URL",
|
||||
"description": "Finds the canonical URL for a company's primary Security or Trust page.",
|
||||
"system_prompt": "You are a world-class cybersecurity researcher, skilled at finding key information. Locate the most authoritative security information source and return the URL.",
|
||||
"user_prompt": "Find the canonical URL for the primary Security or Trust page of the given {company_name}. These pages serve as central repositories for security practices and certifications. Prioritize official domains (e.g., company.com, trust.company.com) and pages that comprehensively address security. Your response MUST be a single, valid JSON object with one key: 'url'. If not found, the value must be null.",
|
||||
"llm_model_id": "01965cb4-73f4-9ec3-6f21-bede0391e2b4",
|
||||
"output_format": { "url": "string" },
|
||||
"input_processors": [],
|
||||
"enabled": true
|
||||
}
|
||||
]
|
|
@ -11,6 +11,7 @@
|
|||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
|
|
Loading…
Reference in New Issue