Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95b2ba217 | ||
|
|
20633972e9 | ||
|
|
3457fe18cf | ||
|
|
fe3de498c8 | ||
|
|
15134ea04b | ||
|
|
a9851272ae | ||
|
|
916c9d62d9 | ||
|
|
47b4823bc5 | ||
|
|
c72f9f5016 | ||
|
|
e747ee5cbc | ||
|
|
ba5b29a35d | ||
|
|
daa6230fc9 | ||
|
|
4765752ee3 | ||
|
|
25e23c89c3 | ||
|
|
08dbfead7c | ||
|
|
8f5d7ed3cf | ||
|
|
807ae6a8f8 | ||
|
|
5a7d7d88ff | ||
|
|
40ae8a8add | ||
|
|
6488e81901 | ||
|
|
d328f08952 | ||
|
|
bcad357017 | ||
|
|
9845a7cd68 | ||
|
|
7c632f7388 | ||
|
|
1442f62633 | ||
|
|
7f7a84e3ba | ||
|
|
dee3279f85 | ||
|
|
094dbb01b8 | ||
|
|
e299d40fa1 | ||
|
|
cb42f11b97 | ||
|
|
4531645b4c | ||
|
|
bf3426f91b | ||
|
|
3864f04218 | ||
|
|
e541a35e86 | ||
|
|
bd383ed273 | ||
|
|
949fac1a7c | ||
|
|
7ab16ecccb | ||
|
|
58cd551a30 | ||
|
|
7770a9b521 | ||
|
|
aab0f7ea46 | ||
|
|
ea41f4f503 |
@@ -10,7 +10,7 @@ module.exports = {
|
||||
},
|
||||
extends: [
|
||||
// Vue specific rules, eslint-plugin-vue
|
||||
'plugin:vue/essential',
|
||||
'plugin:vue/vue3-recommended',
|
||||
|
||||
// Extends eslint-config-airbnb
|
||||
'@vue/eslint-config-airbnb-with-typescript',
|
||||
|
||||
38
.github/workflows/tests.e2e.yaml
vendored
38
.github/workflows/tests.e2e.yaml
vendored
@@ -24,3 +24,41 @@ jobs:
|
||||
-
|
||||
name: Run e2e tests
|
||||
run: npm run test:cy:run
|
||||
-
|
||||
name: Output artifact directories
|
||||
id: artifacts
|
||||
if: always() # Run even if previous steps fail because test run video is always captured
|
||||
shell: bash
|
||||
run: |-
|
||||
declare -r dirs_json_file='cypress-dirs.json'
|
||||
if [ ! -f "${dirs_json_file}" ]; then
|
||||
echo "${dirs_json_file} does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCREENSHOTS_DIR=$(jq -r '.screenshots' "${dirs_json_file}")
|
||||
VIDEOS_DIR=$(jq -r '.videos' "${dirs_json_file}")
|
||||
|
||||
for dir in "${SCREENSHOTS_DIR}" "${VIDEOS_DIR}"; do
|
||||
if [ "${dir}" = 'null' ] || [ -z "${dir}" ]; then
|
||||
echo "One or more directories are null or not specified in cypress-dirs.json"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "SCREENSHOTS_DIR=${SCREENSHOTS_DIR}" >> "${GITHUB_OUTPUT}"
|
||||
echo "VIDEOS_DIR=${VIDEOS_DIR}" >> "${GITHUB_OUTPUT}"
|
||||
-
|
||||
name: Upload screenshots
|
||||
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: e2e-screenshots-${{ matrix.os }}
|
||||
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
|
||||
-
|
||||
name: Upload videos
|
||||
if: always() # Run even if previous steps fail because test run video is always captured
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: e2e-videos-${{ matrix.os }}
|
||||
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}
|
||||
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,5 +1,16 @@
|
||||
node_modules
|
||||
# Application build artifacts
|
||||
/dist-*/
|
||||
.vs
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/**/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# draw.io
|
||||
*.bkp
|
||||
*.dtmp
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.8 (2023-11-27)
|
||||
|
||||
* Remove duplicated `index.html` file | [aab0f7e](https://github.com/undergroundwires/privacy.sexy/commit/aab0f7ea4680f377c610066bd0e99011eed8b506)
|
||||
* Refactor DI for simplicity and type safety | [7770a9b](https://github.com/undergroundwires/privacy.sexy/commit/7770a9b5211d7208cfb2bfa5f737d46dc90b7946)
|
||||
* Refactor user selection state handling using hook | [58cd551](https://github.com/undergroundwires/privacy.sexy/commit/58cd551a304a03e42637e6858982f8c5dfd9f598)
|
||||
* Refactor watch sources for reliability | [7ab16ec](https://github.com/undergroundwires/privacy.sexy/commit/7ab16ecccb31b2d54e5b634520a8246fbbc248c1)
|
||||
* Refactor to enforce strictNullChecks | [949fac1](https://github.com/undergroundwires/privacy.sexy/commit/949fac1a7cbc962ed63058e6a896695cfb4d35c8)
|
||||
* Fix icon tooltip alignment on instructions modal | [bd383ed](https://github.com/undergroundwires/privacy.sexy/commit/bd383ed273ca95c10ea1cce765c0aa6836ec508c)
|
||||
* Fix mobile layout overflow caused by tooltips | [e541a35](https://github.com/undergroundwires/privacy.sexy/commit/e541a35e86c0eff83f84dd002b46de7c55ebbcac)
|
||||
* win: improve disabling of scheduled tasks | [3864f04](https://github.com/undergroundwires/privacy.sexy/commit/3864f042180f62afe469fdfe36010b018f84f4b3)
|
||||
* Fix card list UI layout shifts (jumps) on load | [bf3426f](https://github.com/undergroundwires/privacy.sexy/commit/bf3426f91b6b7dbcad58d58507222559a8d14242)
|
||||
* Refactor to Vue 3 recommended ESLint rules | [4531645](https://github.com/undergroundwires/privacy.sexy/commit/4531645b4c0c5143f15240652368bb9b9ddb48a4)
|
||||
* Fix code highlighting and optimize category select | [cb42f11](https://github.com/undergroundwires/privacy.sexy/commit/cb42f11b9785e74719338a0a80a50d81dfccb4b6)
|
||||
* Fix layout jumps/shifts and overflow on modals | [e299d40](https://github.com/undergroundwires/privacy.sexy/commit/e299d40fa1d71d921d4dac37e469fe299c9da3af)
|
||||
* win: fix and improve Store app categorization #190 | [094dbb0](https://github.com/undergroundwires/privacy.sexy/commit/094dbb01b83bce9925fafab778b922f64390c2be)
|
||||
* win: fix persistent update disabling /w tasks #272 | [dee3279](https://github.com/undergroundwires/privacy.sexy/commit/dee3279f85c99a9c62201a093b1afa41ec2412ec)
|
||||
* win: discourage IntelliCode disabling #267, #286 | [7f7a84e](https://github.com/undergroundwires/privacy.sexy/commit/7f7a84e3ba259fade22d4838563d16129a1585e6)
|
||||
* Fix spacing in documentation for readability | [1442f62](https://github.com/undergroundwires/privacy.sexy/commit/1442f626335e30e3a8d74e4e13e561c41f073ef8)
|
||||
* win: fix system app removal affecting updates #287 | [7c632f7](https://github.com/undergroundwires/privacy.sexy/commit/7c632f738853b32fd90952bb4ca1ac924f962eb0)
|
||||
* Fix rendering of inline code blocks for docs | [9845a7c](https://github.com/undergroundwires/privacy.sexy/commit/9845a7cd68a9920c96da739b58238bb1fdb1251d)
|
||||
* linux: fix Firefox settings not reverting #282 | [bcad357](https://github.com/undergroundwires/privacy.sexy/commit/bcad357017d9f29ce77e706ca943107dd9caefb6)
|
||||
* Fix incorrect URL rendering in documentation texts | [d328f08](https://github.com/undergroundwires/privacy.sexy/commit/d328f0895244d998e885ad8df335b6444b9ac66b)
|
||||
|
||||
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.7...0.12.8)
|
||||
|
||||
## 0.12.7 (2023-11-07)
|
||||
|
||||
* Add winget download instructions | [b2ffc90](https://github.com/undergroundwires/privacy.sexy/commit/b2ffc90da70367b9e65c82556e8f440f865ceb98)
|
||||
* Fix unresponsive copy button on instructions modal | [8ccaec7](https://github.com/undergroundwires/privacy.sexy/commit/8ccaec7af6ea3ecfd46bab5c13b90f71d55e32c1)
|
||||
* Fix tree node check states not being updated | [af7219f](https://github.com/undergroundwires/privacy.sexy/commit/af7219f6e12ab4a65ce07190f691cf3234e87e35)
|
||||
|
||||
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.6...0.12.7)
|
||||
|
||||
## 0.12.6 (2023-11-03)
|
||||
|
||||
* Bump dependencies to latest | [25d7f7b](https://github.com/undergroundwires/privacy.sexy/commit/25d7f7b2a479e51e092881cc2751e67a7d3f179f)
|
||||
|
||||
10
README.md
10
README.md
@@ -122,11 +122,11 @@
|
||||
## Get started
|
||||
|
||||
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
|
||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-Setup-0.12.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.AppImage). For more options, see [here](#additional-install-options).
|
||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-Setup-0.12.8.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-0.12.8.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-0.12.8.AppImage). For more options, see [here](#additional-install-options).
|
||||
|
||||
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
|
||||
For a detailed comparison of features between the desktop and web versions of privacy.sexy, see [Desktop vs. Web Features](./docs/desktop-vs-web-features.md).
|
||||
|
||||
💡 You should apply your configuration from time to time (more than once). It would strengthen your privacy and security control because privacy.sexy and its scripts get better and stronger in every new version.
|
||||
💡 Regularly applying your configuration with privacy.sexy is recommended, especially after each new release and major operating system updates. Each version updates scripts to enhance stability, privacy, and security.
|
||||
|
||||
[](https://privacy.sexy)
|
||||
|
||||
@@ -179,4 +179,6 @@ Check [architecture.md](./docs/architecture.md) for an overview of design and ho
|
||||
|
||||
## Security
|
||||
|
||||
Security is a top priority at privacy.sexy. An extensive commitment to security verification ensures this priority. For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md).
|
||||
Security is a top priority at privacy.sexy.
|
||||
An extensive commitment to security verification ensures this priority.
|
||||
For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md).
|
||||
|
||||
44
SECURITY.md
44
SECURITY.md
@@ -1,6 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
privacy.sexy takes security seriously. Commitment is made to address all security issues with urgency. Responsible reporting of any discovered vulnerabilities in the project is highly encouraged.
|
||||
Security is a top priority at privacy.sexy.
|
||||
Please report any discovered vulnerabilities responsibly.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -11,20 +12,45 @@ Efforts to responsibly disclose findings are greatly appreciated. To report a se
|
||||
|
||||
## Security Report Handling
|
||||
|
||||
Upon receipt of a security report, the following actions will be taken:
|
||||
Upon receiving a security report, the process involves:
|
||||
|
||||
- The report will be confirmed, identifying the affected components.
|
||||
- The impact and severity of the issue will be assessed.
|
||||
- Work on a fix and plan a release to address the vulnerability will be initiated.
|
||||
- The reporter will be kept updated about the progress.
|
||||
- Confirming the report and identifying affected components.
|
||||
- Assessing the impact and severity of the issue.
|
||||
- Fixing the vulnerability and planning a release to address it.
|
||||
- Keeping the reporter informed about progress.
|
||||
|
||||
## Testing
|
||||
## Security Practices
|
||||
|
||||
Regular and extensive testing is conducted to ensure robust security in the project. Information about testing practices can be found in the [Testing Documentation](./docs/tests.md).
|
||||
### Application Security
|
||||
|
||||
privacy.sexy adopts a defense in depth strategy to protect users on multiple layers:
|
||||
|
||||
- **Link Protection:**
|
||||
privacy.sexy ensures each external link has special attributes for your privacy and security.
|
||||
These attributes block the new site from accessing the privacy.sexy page, increasing your online safety and privacy.
|
||||
- **Content Security Policies (CSP):**
|
||||
privacy.sexy actively follows security guidelines from the Open Web Application Security Project (OWASP) at strictest level.
|
||||
This approach protects against attacks like Cross Site Scripting (XSS) and data injection.
|
||||
- **Context Isolation:**
|
||||
The desktop application isolates different code sections based on their access level.
|
||||
This separation prevents attackers from introducing harmful code into the app, known as injection attacks.
|
||||
|
||||
### Update Security and Integrity
|
||||
|
||||
privacy.sexy benefits from automated update processes including security tests. Automated deployments from source code ensure immediate and secure updates, mirroring the latest source code. This aligns the deployed application with the expected source code, enhancing transparency and trust. For more details, see [CI/CD Documentation](./docs/ci-cd.md).
|
||||
|
||||
Every desktop update undergoes a thorough verification process. Updates are cryptographically signed to ensure authenticity and integrity, preventing tampered versions from reaching your device. Version checks are conducted to prevent downgrade attacks.
|
||||
|
||||
### Testing
|
||||
|
||||
privacy.sexy's testing approach includes a mix of automated and community-driven tests.
|
||||
Details on testing practices are available in the [Testing Documentation](./docs/tests.md).
|
||||
|
||||
## Support
|
||||
|
||||
For additional assistance or any unanswered questions, [submit a GitHub issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose). Security concerns are a priority, and necessary support to address them is assured.
|
||||
For help or any questions, [submit a GitHub issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose). Addressing security concerns is a priority, and we ensure the necessary support.
|
||||
|
||||
Support privacy.sexy's commitment to security by [making a donation ❤️](https://github.com/sponsors/undergroundwires). Your contributions aid in maintaining and enhancing the project's security features.
|
||||
|
||||
---
|
||||
|
||||
|
||||
5
cypress-dirs.json
Normal file
5
cypress-dirs.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"base": "tests/e2e",
|
||||
"videos": "tests/e2e/videos",
|
||||
"screenshots": "tests/e2e/videos"
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
import ViteConfig from './vite.config';
|
||||
|
||||
const CYPRESS_BASE_DIR = 'tests/e2e/';
|
||||
import cypressDirs from './cypress-dirs.json' assert { type: 'json' };
|
||||
|
||||
export default defineConfig({
|
||||
fixturesFolder: `${CYPRESS_BASE_DIR}/fixtures`,
|
||||
screenshotsFolder: `${CYPRESS_BASE_DIR}/screenshots`,
|
||||
fixturesFolder: `${cypressDirs.base}/fixtures`,
|
||||
screenshotsFolder: cypressDirs.screenshots,
|
||||
|
||||
video: true,
|
||||
videosFolder: `${CYPRESS_BASE_DIR}/videos`,
|
||||
videosFolder: cypressDirs.videos,
|
||||
|
||||
e2e: {
|
||||
baseUrl: `http://localhost:${ViteConfig.server.port}/`,
|
||||
specPattern: `${CYPRESS_BASE_DIR}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
|
||||
supportFile: `${CYPRESS_BASE_DIR}/support/e2e.ts`,
|
||||
baseUrl: `http://localhost:${getApplicationPort()}/`,
|
||||
specPattern: `${cypressDirs.base}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
|
||||
supportFile: `${cypressDirs.base}/support/e2e.ts`,
|
||||
},
|
||||
|
||||
/*
|
||||
Disabling Chrome's web security to allow for faster DOM queries to access DOM earlier than
|
||||
`cy.get()`. It bypasses the usual same-origin policy constraints
|
||||
*/
|
||||
chromeWebSecurity: false,
|
||||
});
|
||||
|
||||
function getApplicationPort(): number {
|
||||
const port = ViteConfig.server?.port;
|
||||
if (port === undefined) {
|
||||
throw new Error('Unknown application port');
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
54
docs/desktop-vs-web-features.md
Normal file
54
docs/desktop-vs-web-features.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Desktop vs. Web Features
|
||||
|
||||
This table highlights differences between the desktop and web versions of `privacy.sexy`.
|
||||
|
||||
| Feature | Desktop | Web |
|
||||
| ------- | ------- | --- |
|
||||
| [Usage without installation](#usage-without-installation) | 🔴 Not available | 🟢 Available |
|
||||
| [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available |
|
||||
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
|
||||
| [Logging](#logging) | 🟢 Available | 🔴 Not available |
|
||||
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
|
||||
|
||||
## Feature descriptions
|
||||
|
||||
### Usage without installation
|
||||
|
||||
You can use the web version directly in a browser without installation.
|
||||
The desktop version requires download and installation.
|
||||
|
||||
> **Note for Linux users:** On Linux, privacy.sexy is available as an `AppImage`, a portable format that doesn't need traditional installation.
|
||||
> This allows Linux users to use the desktop version without full installation, akin to the web version.
|
||||
|
||||
### Offline usage
|
||||
|
||||
The web version, once loaded, supports offline use.
|
||||
Desktop version inherently allows offline usage.
|
||||
|
||||
### Auto-updates
|
||||
|
||||
Both the desktop and web versions of privacy.sexy provide timely access to the latest features and security improvements. The updates are automatically deployed from source code, reflecting the latest changes for enhanced security and reliability. For more details, see [CI/CD documentation](./ci-cd.md).
|
||||
|
||||
The desktop version ensures secure delivery through cryptographic signatures and version checks.
|
||||
|
||||
[Security is a top priority](./../SECURITY.md#update-security-and-integrity) at privacy.sexy.
|
||||
|
||||
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
|
||||
> Users get notified about updates but might need to complete the installation manually.
|
||||
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
|
||||
|
||||
### Logging
|
||||
|
||||
The desktop version supports logging of activities to aid in troubleshooting.
|
||||
This feature is not available in the web version.
|
||||
|
||||
Log file locations vary by operating system:
|
||||
|
||||
- macOS: `$HOME/Library/Logs/privacy.sexy`
|
||||
- Linux: `$HOME/.config/privacy.sexy/logs`
|
||||
- Windows: `%APPDATA%\privacy.sexy\logs`
|
||||
|
||||
### Script execution
|
||||
|
||||
Direct execution of scripts is possible in the desktop version, offering a more integrated experience.
|
||||
This functionality is not present in the web version due to browser limitations.
|
||||
@@ -11,6 +11,8 @@ The presentation layer uses an event-driven architecture for bidirectional react
|
||||
## Structure
|
||||
|
||||
- [`/src/` **`presentation/`**](./../src/presentation/): Contains Vue and Electron code.
|
||||
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
|
||||
- [**`index.html`**](./../src/presentation/index.html): The `index.html` entry file, located at the root of the project as required by Vite
|
||||
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
|
||||
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
|
||||
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
|
||||
@@ -20,8 +22,7 @@ The presentation layer uses an event-driven architecture for bidirectional react
|
||||
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
|
||||
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
|
||||
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles coupled to Vue components.
|
||||
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
|
||||
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
|
||||
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint..
|
||||
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
|
||||
- [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events.
|
||||
- [`/preload/` **`index.ts`**](./../src/presentation/main.ts): Script executed before the renderer, securing Node.js features for renderer use.
|
||||
@@ -70,10 +71,11 @@ To add a new dependency:
|
||||
1. **Define its symbol**: Define an associated symbol for every dependency in [`injectionSymbols.ts`](./../src/presentation/injectionSymbols.ts). Symbols are grouped into:
|
||||
- **Singletons**: Shared across components, instantiated once.
|
||||
- **Transients**: Factories yielding a new instance on every access.
|
||||
2. **Provide the dependency**: Modify the [`provideDependencies`](./../src/presentation/bootstrapping/DependencyProvider.ts) function to include the new dependency. [`App.vue`](./../src/presentation/components/App.vue) calls this function within its `setup()` hook to register the dependencies.
|
||||
3. **Inject the dependency**: Use Vue's `inject` method alongside the defined symbol to incorporate the dependency into components.
|
||||
- For singletons, invoke the factory method: `inject(symbolKey)()`.
|
||||
- For transients, directly inject: `inject(symbolKey)`.
|
||||
2. **Provide the dependency**:
|
||||
Modify the [`provideDependencies`](./../src/presentation/bootstrapping/DependencyProvider.ts) function to include the new dependency.
|
||||
[`App.vue`](./../src/presentation/components/App.vue) calls this function within its `setup()` hook to register the dependencies.
|
||||
3. **Inject the dependency**: Use `injectKey` to inject a dependency. Pass a selector function to `injectKey` that retrieves the appropriate symbol from the provided dependencies.
|
||||
- Example usage: `injectKey((keys) => keys.useCollectionState)`;
|
||||
|
||||
## Shared UI components
|
||||
|
||||
|
||||
@@ -68,21 +68,23 @@ These checks validate various qualities like runtime execution, building process
|
||||
- [`./src/`](./../src/): Contains the code subject to testing.
|
||||
- [`./tests/shared/`](./../tests/shared/): Contains code shared by different test categories.
|
||||
- [`bootstrap/setup.ts`](./../tests/shared/bootstrap/setup.ts): Initializes unit and integration tests.
|
||||
- [`Assertions/`](./../tests/shared/Assertions/): Contains common assertion functions, prefixed with `expect`.
|
||||
- [`./tests/unit/`](./../tests/unit/)
|
||||
- Stores unit test code.
|
||||
- The directory structure mirrors [`./src/`](./../src).
|
||||
- E.g., tests for [`./src/application/ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts) reside in [`./tests/unit/application/ApplicationFactory.spec.ts`](./../tests/unit/application/ApplicationFactory.spec.ts).
|
||||
- [`shared/`](./../tests/unit/shared/)
|
||||
- Contains shared unit test functionalities.
|
||||
- [`Assertions/`](./../tests/unit/shared/Assertions): Contains common assertion functions, prefixed with `expect`.
|
||||
- [`TestCases/`](./../tests/unit/shared/TestCases/)
|
||||
- Shared test cases.
|
||||
- Functions that calls `it()` from [Vitest](https://vitest.dev/) should have `it` prefix.
|
||||
- [`Stubs/`](./../tests/unit/shared/Stubs): Maintains stubs for component isolation, equipped with basic functionalities and, when necessary, spying or mocking capabilities.
|
||||
- [`./tests/integration/`](./../tests/integration/): Contains integration test files.
|
||||
- [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file.
|
||||
- [`cypress-dirs.json`](./../cypress-dirs.json): A central definition of directories used by Cypress, designed for reuse across different configurations.
|
||||
- [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder, includes tests with `.cy.ts` extension.
|
||||
- [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single spec file.
|
||||
- [`/tsconfig.json`]: TypeScript configuration for file Cypress code, improves IDE support, recommended to have by official documentation.
|
||||
- *(git ignored)* `/videos`: Asset folder for videos taken during tests.
|
||||
- *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.
|
||||
- [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single test file.
|
||||
- [`/support/interactions/`](./../tests/e2e/support/interactions/): Contains reusable functions for simulating user interactions, enhancing test readability and maintainability.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.12.6",
|
||||
"version": "0.12.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.8",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/vue": "^1.0.2",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"ace-builds": "^1.30.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-log": "^5.0.1",
|
||||
"electron-progressbar": "^2.1.0",
|
||||
"electron-updater": "^6.1.4",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -6699,9 +6699,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-log": {
|
||||
"version": "4.4.8",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
|
||||
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz",
|
||||
"integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-progressbar": {
|
||||
"version": "2.1.0",
|
||||
@@ -24483,9 +24486,9 @@
|
||||
}
|
||||
},
|
||||
"electron-log": {
|
||||
"version": "4.4.8",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
|
||||
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz",
|
||||
"integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA=="
|
||||
},
|
||||
"electron-progressbar": {
|
||||
"version": "2.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.12.6",
|
||||
"version": "0.12.8",
|
||||
"private": true,
|
||||
"slogan": "Now you have the choice",
|
||||
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
|
||||
@@ -24,7 +24,7 @@
|
||||
"electron:preview": "electron-vite preview",
|
||||
"electron:prebuild": "electron-vite build",
|
||||
"electron:build": "electron-builder",
|
||||
"lint:eslint": "eslint . --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --max-warnings=0 --ignore-path .gitignore",
|
||||
"lint:md": "markdownlint **/*.md --ignore node_modules",
|
||||
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
|
||||
"lint:md:relative-urls": "remark . --frail --use remark-validate-links",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"ace-builds": "^1.30.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-log": "^5.0.1",
|
||||
"electron-progressbar": "^2.1.0",
|
||||
"electron-updater": "^6.1.4",
|
||||
"file-saver": "^2.0.5",
|
||||
|
||||
@@ -12,9 +12,6 @@ export class ApplicationFactory implements IApplicationFactory {
|
||||
private readonly getter: AsyncLazy<IApplication>;
|
||||
|
||||
protected constructor(costlyGetter: ApplicationGetterType) {
|
||||
if (!costlyGetter) {
|
||||
throw new Error('missing getter');
|
||||
}
|
||||
this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter()));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Compares to Array<T> objects for equality, ignoring order
|
||||
export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) {
|
||||
if (!array1) { throw new Error('missing first array'); }
|
||||
if (!array2) { throw new Error('missing second array'); }
|
||||
const sortedArray1 = sort(array1);
|
||||
const sortedArray2 = sort(array2);
|
||||
return sequenceEqual(sortedArray1, sortedArray2);
|
||||
@@ -12,8 +10,6 @@ export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) {
|
||||
|
||||
// Compares to Array<T> objects for equality in same order
|
||||
export function sequenceEqual<T>(array1: readonly T[], array2: readonly T[]) {
|
||||
if (!array1) { throw new Error('missing first array'); }
|
||||
if (!array2) { throw new Error('missing second array'); }
|
||||
if (array1.length !== array2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -20,23 +20,30 @@ export abstract class CustomError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const Environment = {
|
||||
interface ErrorPrototypeManipulation {
|
||||
getSetPrototypeOf: () => (typeof Object.setPrototypeOf | undefined);
|
||||
getCaptureStackTrace: () => (typeof Error.captureStackTrace | undefined);
|
||||
}
|
||||
|
||||
export const PlatformErrorPrototypeManipulation: ErrorPrototypeManipulation = {
|
||||
getSetPrototypeOf: () => Object.setPrototypeOf,
|
||||
getCaptureStackTrace: () => Error.captureStackTrace,
|
||||
};
|
||||
|
||||
function fixPrototype(target: Error, prototype: CustomError) {
|
||||
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
|
||||
const setPrototypeOf = Environment.getSetPrototypeOf();
|
||||
if (!functionExists(setPrototypeOf)) {
|
||||
// This is recommended by TypeScript guidelines.
|
||||
// Source: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
|
||||
// Snapshots: https://web.archive.org/web/20231111234849/https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget, https://archive.ph/tr7cX#support-for-newtarget
|
||||
const setPrototypeOf = PlatformErrorPrototypeManipulation.getSetPrototypeOf();
|
||||
if (!isFunction(setPrototypeOf)) {
|
||||
return;
|
||||
}
|
||||
setPrototypeOf(target, prototype);
|
||||
}
|
||||
|
||||
function ensureStackTrace(target: Error) {
|
||||
const captureStackTrace = Environment.getCaptureStackTrace();
|
||||
if (!functionExists(captureStackTrace)) {
|
||||
const captureStackTrace = PlatformErrorPrototypeManipulation.getCaptureStackTrace();
|
||||
if (!isFunction(captureStackTrace)) {
|
||||
// captureStackTrace is only available on V8, if it's not available
|
||||
// modern JS engines will usually generate a stack trace on error objects when they're thrown.
|
||||
return;
|
||||
@@ -44,7 +51,7 @@ function ensureStackTrace(target: Error) {
|
||||
captureStackTrace(target, target.constructor);
|
||||
}
|
||||
|
||||
function functionExists(func: unknown): boolean {
|
||||
// Not doing truthy/falsy check i.e. if(func) as most values are truthy in JS for robustness
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
function isFunction(func: unknown): func is Function {
|
||||
return typeof func === 'function';
|
||||
}
|
||||
|
||||
@@ -54,9 +54,6 @@ export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(
|
||||
value: TEnumValue,
|
||||
enumVariable: EnumVariable<T, TEnumValue>,
|
||||
) {
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error('absent enum value');
|
||||
}
|
||||
if (!(value in enumVariable)) {
|
||||
throw new RangeError(`enum value "${value}" is out of range`);
|
||||
}
|
||||
|
||||
6
src/application/Common/Log/Logger.ts
Normal file
6
src/application/Common/Log/Logger.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Logger {
|
||||
info(...params: unknown[]): void;
|
||||
warn(...params: unknown[]): void;
|
||||
error(...params: unknown[]): void;
|
||||
debug(...params: unknown[]): void;
|
||||
}
|
||||
5
src/application/Common/Log/LoggerFactory.ts
Normal file
5
src/application/Common/Log/LoggerFactory.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
|
||||
export interface LoggerFactory {
|
||||
readonly logger: Logger;
|
||||
}
|
||||
@@ -9,19 +9,16 @@ export abstract class ScriptingLanguageFactory<T> implements IScriptingLanguageF
|
||||
|
||||
public create(language: ScriptingLanguage): T {
|
||||
assertInRange(language, ScriptingLanguage);
|
||||
if (!this.getters.has(language)) {
|
||||
const getter = this.getters.get(language);
|
||||
if (!getter) {
|
||||
throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`);
|
||||
}
|
||||
const getter = this.getters.get(language);
|
||||
const instance = getter();
|
||||
return instance;
|
||||
}
|
||||
|
||||
protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) {
|
||||
assertInRange(language, ScriptingLanguage);
|
||||
if (!getter) {
|
||||
throw new Error('missing getter');
|
||||
}
|
||||
if (this.getters.has(language)) {
|
||||
throw new Error(`${ScriptingLanguage[language]} is already registered`);
|
||||
}
|
||||
|
||||
27
src/application/Common/Timing/BatchedDebounce.ts
Normal file
27
src/application/Common/Timing/BatchedDebounce.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { PlatformTimer } from './PlatformTimer';
|
||||
import { TimeoutType, Timer } from './Timer';
|
||||
|
||||
export function batchedDebounce<T>(
|
||||
callback: (batches: readonly T[]) => void,
|
||||
waitInMs: number,
|
||||
timer: Timer = PlatformTimer,
|
||||
): (arg: T) => void {
|
||||
let lastTimeoutId: TimeoutType | undefined;
|
||||
let batches: Array<T> = [];
|
||||
|
||||
return (arg: T) => {
|
||||
batches.push(arg);
|
||||
|
||||
const later = () => {
|
||||
callback(batches);
|
||||
batches = [];
|
||||
lastTimeoutId = undefined;
|
||||
};
|
||||
|
||||
if (lastTimeoutId !== undefined) {
|
||||
timer.clearTimeout(lastTimeoutId);
|
||||
}
|
||||
|
||||
lastTimeoutId = timer.setTimeout(later, waitInMs);
|
||||
};
|
||||
}
|
||||
7
src/application/Common/Timing/PlatformTimer.ts
Normal file
7
src/application/Common/Timing/PlatformTimer.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Timer } from './Timer';
|
||||
|
||||
export const PlatformTimer: Timer = {
|
||||
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
||||
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
|
||||
dateNow: () => Date.now(),
|
||||
};
|
||||
@@ -1,47 +1,29 @@
|
||||
import { Timer, TimeoutType } from './Timer';
|
||||
import { PlatformTimer } from './PlatformTimer';
|
||||
|
||||
export type CallbackType = (..._: unknown[]) => void;
|
||||
|
||||
export function throttle(
|
||||
callback: CallbackType,
|
||||
waitInMs: number,
|
||||
timer: ITimer = NodeTimer,
|
||||
timer: Timer = PlatformTimer,
|
||||
): CallbackType {
|
||||
const throttler = new Throttler(timer, waitInMs, callback);
|
||||
return (...args: unknown[]) => throttler.invoke(...args);
|
||||
}
|
||||
|
||||
// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number)
|
||||
export type TimeoutType = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface ITimer {
|
||||
setTimeout: (callback: () => void, ms: number) => TimeoutType;
|
||||
clearTimeout: (timeoutId: TimeoutType) => void;
|
||||
dateNow(): number;
|
||||
}
|
||||
|
||||
const NodeTimer: ITimer = {
|
||||
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
||||
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
|
||||
dateNow: () => Date.now(),
|
||||
};
|
||||
|
||||
interface IThrottler {
|
||||
invoke: CallbackType;
|
||||
}
|
||||
|
||||
class Throttler implements IThrottler {
|
||||
private queuedExecutionId: TimeoutType;
|
||||
class Throttler {
|
||||
private queuedExecutionId: TimeoutType | undefined;
|
||||
|
||||
private previouslyRun: number;
|
||||
|
||||
constructor(
|
||||
private readonly timer: ITimer,
|
||||
private readonly timer: Timer,
|
||||
private readonly waitInMs: number,
|
||||
private readonly callback: CallbackType,
|
||||
) {
|
||||
if (!timer) { throw new Error('missing timer'); }
|
||||
if (!waitInMs) { throw new Error('missing delay'); }
|
||||
if (waitInMs < 0) { throw new Error('negative delay'); }
|
||||
if (!callback) { throw new Error('missing callback'); }
|
||||
}
|
||||
|
||||
public invoke(...args: unknown[]): void {
|
||||
8
src/application/Common/Timing/Timer.ts
Normal file
8
src/application/Common/Timing/Timer.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number)
|
||||
export type TimeoutType = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface Timer {
|
||||
setTimeout: (callback: () => void, ms: number) => TimeoutType;
|
||||
clearTimeout: (timeoutId: TimeoutType) => void;
|
||||
dateNow(): number;
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export class ApplicationContext implements IApplicationContext {
|
||||
public readonly app: IApplication,
|
||||
initialContext: OperatingSystem,
|
||||
) {
|
||||
validateApp(app);
|
||||
this.states = initializeStates(app);
|
||||
this.changeContext(initialContext);
|
||||
}
|
||||
@@ -36,10 +35,8 @@ export class ApplicationContext implements IApplicationContext {
|
||||
if (this.currentOs === os) {
|
||||
return;
|
||||
}
|
||||
this.collection = this.app.getCollection(os);
|
||||
if (!this.collection) {
|
||||
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`);
|
||||
}
|
||||
const collection = this.app.getCollection(os);
|
||||
this.collection = collection;
|
||||
const event: IApplicationContextChangedEvent = {
|
||||
newState: this.states[os],
|
||||
oldState: this.states[this.currentOs],
|
||||
@@ -49,12 +46,6 @@ export class ApplicationContext implements IApplicationContext {
|
||||
}
|
||||
}
|
||||
|
||||
function validateApp(app: IApplication) {
|
||||
if (!app) {
|
||||
throw new Error('missing app');
|
||||
}
|
||||
}
|
||||
|
||||
function initializeStates(app: IApplication): StateMachine {
|
||||
const machine = new Map<OperatingSystem, ICategoryCollectionState>();
|
||||
for (const collection of app.collections) {
|
||||
|
||||
@@ -10,18 +10,23 @@ export async function buildContext(
|
||||
factory: IApplicationFactory = ApplicationFactory.Current,
|
||||
environment = RuntimeEnvironment.CurrentEnvironment,
|
||||
): Promise<IApplicationContext> {
|
||||
if (!factory) { throw new Error('missing factory'); }
|
||||
if (!environment) { throw new Error('missing environment'); }
|
||||
const app = await factory.getApp();
|
||||
const os = getInitialOs(app, environment.os);
|
||||
return new ApplicationContext(app, os);
|
||||
}
|
||||
|
||||
function getInitialOs(app: IApplication, currentOs: OperatingSystem): OperatingSystem {
|
||||
function getInitialOs(
|
||||
app: IApplication,
|
||||
currentOs: OperatingSystem | undefined,
|
||||
): OperatingSystem {
|
||||
const supportedOsList = app.getSupportedOsList();
|
||||
if (supportedOsList.includes(currentOs)) {
|
||||
if (currentOs !== undefined && supportedOsList.includes(currentOs)) {
|
||||
return currentOs;
|
||||
}
|
||||
return getMostSupportedOs(supportedOsList, app);
|
||||
}
|
||||
|
||||
function getMostSupportedOs(supportedOsList: OperatingSystem[], app: IApplication) {
|
||||
supportedOsList.sort((os1, os2) => {
|
||||
const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts;
|
||||
return getPriority(os2) - getPriority(os1);
|
||||
|
||||
@@ -4,23 +4,48 @@ import { UserFilter } from './Filter/UserFilter';
|
||||
import { IUserFilter } from './Filter/IUserFilter';
|
||||
import { ApplicationCode } from './Code/ApplicationCode';
|
||||
import { UserSelection } from './Selection/UserSelection';
|
||||
import { IUserSelection } from './Selection/IUserSelection';
|
||||
import { ICategoryCollectionState } from './ICategoryCollectionState';
|
||||
import { IApplicationCode } from './Code/IApplicationCode';
|
||||
import { UserSelectionFacade } from './Selection/UserSelectionFacade';
|
||||
|
||||
export class CategoryCollectionState implements ICategoryCollectionState {
|
||||
public readonly os: OperatingSystem;
|
||||
|
||||
public readonly code: IApplicationCode;
|
||||
|
||||
public readonly selection: IUserSelection;
|
||||
public readonly selection: UserSelection;
|
||||
|
||||
public readonly filter: IUserFilter;
|
||||
|
||||
public constructor(readonly collection: ICategoryCollection) {
|
||||
this.selection = new UserSelection(collection, []);
|
||||
this.code = new ApplicationCode(this.selection, collection.scripting);
|
||||
this.filter = new UserFilter(collection);
|
||||
public constructor(
|
||||
public readonly collection: ICategoryCollection,
|
||||
selectionFactory = DefaultSelectionFactory,
|
||||
codeFactory = DefaultCodeFactory,
|
||||
filterFactory = DefaultFilterFactory,
|
||||
) {
|
||||
this.selection = selectionFactory(collection, []);
|
||||
this.code = codeFactory(this.selection.scripts, collection.scripting);
|
||||
this.filter = filterFactory(collection);
|
||||
this.os = collection.os;
|
||||
}
|
||||
}
|
||||
|
||||
export type CodeFactory = (
|
||||
...params: ConstructorParameters<typeof ApplicationCode>
|
||||
) => IApplicationCode;
|
||||
|
||||
const DefaultCodeFactory: CodeFactory = (...params) => new ApplicationCode(...params);
|
||||
|
||||
export type SelectionFactory = (
|
||||
...params: ConstructorParameters<typeof UserSelectionFacade>
|
||||
) => UserSelection;
|
||||
|
||||
const DefaultSelectionFactory: SelectionFactory = (
|
||||
...params
|
||||
) => new UserSelectionFacade(...params);
|
||||
|
||||
export type FilterFactory = (
|
||||
...params: ConstructorParameters<typeof UserFilter>
|
||||
) => IUserFilter;
|
||||
|
||||
const DefaultFilterFactory: FilterFactory = (...params) => new UserFilter(...params);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IReadOnlyUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { ReadonlyScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
|
||||
import { CodeChangedEvent } from './Event/CodeChangedEvent';
|
||||
import { CodePosition } from './Position/CodePosition';
|
||||
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
|
||||
@@ -17,15 +17,12 @@ export class ApplicationCode implements IApplicationCode {
|
||||
private scriptPositions = new Map<SelectedScript, CodePosition>();
|
||||
|
||||
constructor(
|
||||
userSelection: IReadOnlyUserSelection,
|
||||
selection: ReadonlyScriptSelection,
|
||||
private readonly scriptingDefinition: IScriptingDefinition,
|
||||
private readonly generator: IUserScriptGenerator = new UserScriptGenerator(),
|
||||
) {
|
||||
if (!userSelection) { throw new Error('missing userSelection'); }
|
||||
if (!scriptingDefinition) { throw new Error('missing scriptingDefinition'); }
|
||||
if (!generator) { throw new Error('missing generator'); }
|
||||
this.setCode(userSelection.selectedScripts);
|
||||
userSelection.changed.on((scripts) => {
|
||||
this.setCode(selection.selectedScripts);
|
||||
selection.changed.on((scripts) => {
|
||||
this.setCode(scripts);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { SelectedScript } from '../../Selection/SelectedScript';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { ICodeChangedEvent } from './ICodeChangedEvent';
|
||||
|
||||
export class CodeChangedEvent implements ICodeChangedEvent {
|
||||
@@ -36,7 +36,18 @@ export class CodeChangedEvent implements ICodeChangedEvent {
|
||||
}
|
||||
|
||||
public getScriptPositionInCode(script: IScript): ICodePosition {
|
||||
return this.scripts.get(script);
|
||||
return this.getPositionById(script.id);
|
||||
}
|
||||
|
||||
private getPositionById(scriptId: string): ICodePosition {
|
||||
const position = [...this.scripts.entries()]
|
||||
.filter(([s]) => s.id === scriptId)
|
||||
.map(([, pos]) => pos)
|
||||
.at(0);
|
||||
if (!position) {
|
||||
throw new Error('Unknown script: Position could not be found for the script');
|
||||
}
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo
|
||||
|
||||
export interface ICodeChangedEvent {
|
||||
readonly code: string;
|
||||
addedScripts: ReadonlyArray<IScript>;
|
||||
removedScripts: ReadonlyArray<IScript>;
|
||||
changedScripts: ReadonlyArray<IScript>;
|
||||
readonly addedScripts: ReadonlyArray<IScript>;
|
||||
readonly removedScripts: ReadonlyArray<IScript>;
|
||||
readonly changedScripts: ReadonlyArray<IScript>;
|
||||
isEmpty(): boolean;
|
||||
getScriptPositionInCode(script: IScript): ICodePosition;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export abstract class CodeBuilder implements ICodeBuilder {
|
||||
return this;
|
||||
}
|
||||
const lines = code.match(/[^\r\n]+/g);
|
||||
this.lines.push(...lines);
|
||||
if (lines) {
|
||||
this.lines.push(...lines);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
|
||||
export interface IUserScript {
|
||||
code: string;
|
||||
scriptPositions: Map<SelectedScript, ICodePosition>;
|
||||
readonly code: string;
|
||||
readonly scriptPositions: Map<SelectedScript, ICodePosition>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { IUserScript } from './IUserScript';
|
||||
|
||||
export interface IUserScriptGenerator {
|
||||
buildCode(
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
scriptingDefinition: IScriptingDefinition): IUserScript;
|
||||
scriptingDefinition: IScriptingDefinition,
|
||||
): IUserScript;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { CodePosition } from '../Position/CodePosition';
|
||||
import { IUserScriptGenerator } from './IUserScriptGenerator';
|
||||
import { IUserScript } from './IUserScript';
|
||||
@@ -17,8 +17,6 @@ export class UserScriptGenerator implements IUserScriptGenerator {
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
scriptingDefinition: IScriptingDefinition,
|
||||
): IUserScript {
|
||||
if (!selectedScripts) { throw new Error('missing scripts'); }
|
||||
if (!scriptingDefinition) { throw new Error('missing definition'); }
|
||||
if (!selectedScripts.length) {
|
||||
return { code: '', scriptPositions: new Map<SelectedScript, ICodePosition>() };
|
||||
}
|
||||
@@ -68,8 +66,19 @@ function appendSelection(
|
||||
function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder {
|
||||
const { script } = selection;
|
||||
const name = selection.revert ? `${script.name} (revert)` : script.name;
|
||||
const scriptCode = selection.revert ? script.code.revert : script.code.execute;
|
||||
const scriptCode = getSelectedCode(selection);
|
||||
return builder
|
||||
.appendLine()
|
||||
.appendFunction(name, scriptCode);
|
||||
}
|
||||
|
||||
function getSelectedCode(selection: SelectedScript): string {
|
||||
const { code } = selection.script;
|
||||
if (!selection.revert) {
|
||||
return code.execute;
|
||||
}
|
||||
if (!code.revert) {
|
||||
throw new Error('Reverted script lacks revert code.');
|
||||
}
|
||||
return code.revert;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import { FilterActionType } from './FilterActionType';
|
||||
import { IFilterChangeDetails, IFilterChangeDetailsVisitor } from './IFilterChangeDetails';
|
||||
import {
|
||||
IFilterChangeDetails, IFilterChangeDetailsVisitor,
|
||||
ApplyFilterAction, ClearFilterAction,
|
||||
} from './IFilterChangeDetails';
|
||||
|
||||
export class FilterChange implements IFilterChangeDetails {
|
||||
public static forApply(filter: IFilterResult) {
|
||||
if (!filter) {
|
||||
throw new Error('missing filter');
|
||||
}
|
||||
return new FilterChange(FilterActionType.Apply, filter);
|
||||
public static forApply(
|
||||
filter: IFilterResult,
|
||||
): IFilterChangeDetails {
|
||||
return new FilterChange({ type: FilterActionType.Apply, filter });
|
||||
}
|
||||
|
||||
public static forClear() {
|
||||
return new FilterChange(FilterActionType.Clear);
|
||||
public static forClear(): IFilterChangeDetails {
|
||||
return new FilterChange({ type: FilterActionType.Clear });
|
||||
}
|
||||
|
||||
private constructor(
|
||||
public readonly actionType: FilterActionType,
|
||||
public readonly filter?: IFilterResult,
|
||||
) { }
|
||||
private constructor(public readonly action: ApplyFilterAction | ClearFilterAction) { }
|
||||
|
||||
public visit(visitor: IFilterChangeDetailsVisitor): void {
|
||||
if (!visitor) {
|
||||
throw new Error('missing visitor');
|
||||
}
|
||||
switch (this.actionType) {
|
||||
switch (this.action.type) {
|
||||
case FilterActionType.Apply:
|
||||
visitor.onApply(this.filter);
|
||||
if (visitor.onApply) {
|
||||
visitor.onApply(this.action.filter);
|
||||
}
|
||||
break;
|
||||
case FilterActionType.Clear:
|
||||
visitor.onClear();
|
||||
if (visitor.onClear) {
|
||||
visitor.onClear();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${this.actionType}`);
|
||||
throw new Error(`Unknown action: ${this.action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,22 @@ import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'
|
||||
import { FilterActionType } from './FilterActionType';
|
||||
|
||||
export interface IFilterChangeDetails {
|
||||
readonly actionType: FilterActionType;
|
||||
readonly filter?: IFilterResult;
|
||||
|
||||
readonly action: FilterAction;
|
||||
visit(visitor: IFilterChangeDetailsVisitor): void;
|
||||
}
|
||||
|
||||
export interface IFilterChangeDetailsVisitor {
|
||||
onClear(): void;
|
||||
onApply(filter: IFilterResult): void;
|
||||
readonly onClear?: () => void;
|
||||
readonly onApply?: (filter: IFilterResult) => void;
|
||||
}
|
||||
|
||||
export type ApplyFilterAction = {
|
||||
readonly type: FilterActionType.Apply,
|
||||
readonly filter: IFilterResult;
|
||||
};
|
||||
|
||||
export type ClearFilterAction = {
|
||||
readonly type: FilterActionType.Clear,
|
||||
};
|
||||
|
||||
export type FilterAction = ApplyFilterAction | ClearFilterAction;
|
||||
|
||||
@@ -9,8 +9,6 @@ export class FilterResult implements IFilterResult {
|
||||
public readonly query: string,
|
||||
) {
|
||||
if (!query) { throw new Error('Query is empty or undefined'); }
|
||||
if (!scriptMatches) { throw new Error('Script matches is undefined'); }
|
||||
if (!categoryMatches) { throw new Error('Category matches is undefined'); }
|
||||
}
|
||||
|
||||
public hasAnyMatches(): boolean {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter';
|
||||
import { IReadOnlyUserSelection, IUserSelection } from './Selection/IUserSelection';
|
||||
import { ReadonlyUserSelection, UserSelection } from './Selection/UserSelection';
|
||||
import { IApplicationCode } from './Code/IApplicationCode';
|
||||
|
||||
export interface IReadOnlyCategoryCollectionState {
|
||||
readonly code: IApplicationCode;
|
||||
readonly os: OperatingSystem;
|
||||
readonly filter: IReadOnlyUserFilter;
|
||||
readonly selection: IReadOnlyUserSelection;
|
||||
readonly selection: ReadonlyUserSelection;
|
||||
readonly collection: ICategoryCollection;
|
||||
}
|
||||
|
||||
export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState {
|
||||
readonly filter: IUserFilter;
|
||||
readonly selection: IUserSelection;
|
||||
readonly selection: UserSelection;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { CategorySelectionChangeCommand } from './CategorySelectionChange';
|
||||
|
||||
export interface ReadonlyCategorySelection {
|
||||
areAllScriptsSelected(category: ICategory): boolean;
|
||||
isAnyScriptSelected(category: ICategory): boolean;
|
||||
}
|
||||
|
||||
export interface CategorySelection extends ReadonlyCategorySelection {
|
||||
processChanges(action: CategorySelectionChangeCommand): void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type CategorySelectionStatus = {
|
||||
readonly isSelected: true;
|
||||
readonly isReverted: boolean;
|
||||
} | {
|
||||
readonly isSelected: false;
|
||||
};
|
||||
|
||||
export interface CategorySelectionChange {
|
||||
readonly categoryId: number;
|
||||
readonly newStatus: CategorySelectionStatus;
|
||||
}
|
||||
|
||||
export interface CategorySelectionChangeCommand {
|
||||
readonly changes: readonly CategorySelectionChange[];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { ScriptSelection } from '../Script/ScriptSelection';
|
||||
import { ScriptSelectionChange } from '../Script/ScriptSelectionChange';
|
||||
import { CategorySelection } from './CategorySelection';
|
||||
import { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange';
|
||||
|
||||
export class ScriptToCategorySelectionMapper implements CategorySelection {
|
||||
constructor(
|
||||
private readonly scriptSelection: ScriptSelection,
|
||||
private readonly collection: ICategoryCollection,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public areAllScriptsSelected(category: ICategory): boolean {
|
||||
const { selectedScripts } = this.scriptSelection;
|
||||
if (selectedScripts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const scripts = category.getAllScriptsRecursively();
|
||||
if (selectedScripts.length < scripts.length) {
|
||||
return false;
|
||||
}
|
||||
return scripts.every(
|
||||
(script) => selectedScripts.some((selected) => selected.id === script.id),
|
||||
);
|
||||
}
|
||||
|
||||
public isAnyScriptSelected(category: ICategory): boolean {
|
||||
const { selectedScripts } = this.scriptSelection;
|
||||
if (selectedScripts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return selectedScripts.some((s) => category.includes(s.script));
|
||||
}
|
||||
|
||||
public processChanges(action: CategorySelectionChangeCommand): void {
|
||||
const scriptChanges = action.changes.reduce((changes, change) => {
|
||||
changes.push(...this.collectScriptChanges(change));
|
||||
return changes;
|
||||
}, new Array<ScriptSelectionChange>());
|
||||
this.scriptSelection.processChanges({
|
||||
changes: scriptChanges,
|
||||
});
|
||||
}
|
||||
|
||||
private collectScriptChanges(change: CategorySelectionChange): ScriptSelectionChange[] {
|
||||
const category = this.collection.getCategory(change.categoryId);
|
||||
const scripts = category.getAllScriptsRecursively();
|
||||
const scriptsChangesInCategory = scripts
|
||||
.map((script): ScriptSelectionChange => ({
|
||||
scriptId: script.id,
|
||||
newStatus: {
|
||||
...change.newStatus,
|
||||
},
|
||||
}));
|
||||
return scriptsChangesInCategory;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||
import { SelectedScript } from './SelectedScript';
|
||||
|
||||
export interface IReadOnlyUserSelection {
|
||||
readonly changed: IEventSource<ReadonlyArray<SelectedScript>>;
|
||||
readonly selectedScripts: ReadonlyArray<SelectedScript>;
|
||||
isSelected(scriptId: string): boolean;
|
||||
areAllSelected(category: ICategory): boolean;
|
||||
isAnySelected(category: ICategory): boolean;
|
||||
}
|
||||
|
||||
export interface IUserSelection extends IReadOnlyUserSelection {
|
||||
removeAllInCategory(categoryId: number): void;
|
||||
addOrUpdateAllInCategory(categoryId: number, revert: boolean): void;
|
||||
addSelectedScript(scriptId: string, revert: boolean): void;
|
||||
addOrUpdateSelectedScript(scriptId: string, revert: boolean): void;
|
||||
removeSelectedScript(scriptId: string): void;
|
||||
selectOnly(scripts: ReadonlyArray<IScript>): void;
|
||||
selectAll(): void;
|
||||
deselectAll(): void;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||
import { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
|
||||
import { ScriptSelection } from './ScriptSelection';
|
||||
import { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
|
||||
import { SelectedScript } from './SelectedScript';
|
||||
import { UserSelectedScript } from './UserSelectedScript';
|
||||
|
||||
const DEBOUNCE_DELAY_IN_MS = 100;
|
||||
|
||||
export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeCommand>;
|
||||
|
||||
export class DebouncedScriptSelection implements ScriptSelection {
|
||||
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
|
||||
|
||||
private readonly scripts: Repository<string, SelectedScript>;
|
||||
|
||||
public readonly processChanges: ScriptSelection['processChanges'];
|
||||
|
||||
constructor(
|
||||
private readonly collection: ICategoryCollection,
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
debounce: DebounceFunction = batchedDebounce,
|
||||
) {
|
||||
this.scripts = new InMemoryRepository<string, SelectedScript>();
|
||||
for (const script of selectedScripts) {
|
||||
this.scripts.addItem(script);
|
||||
}
|
||||
this.processChanges = debounce(
|
||||
(batchedRequests: readonly ScriptSelectionChangeCommand[]) => {
|
||||
const consolidatedChanges = batchedRequests.flatMap((request) => request.changes);
|
||||
this.processScriptChanges(consolidatedChanges);
|
||||
},
|
||||
DEBOUNCE_DELAY_IN_MS,
|
||||
);
|
||||
}
|
||||
|
||||
public isSelected(scriptId: string): boolean {
|
||||
return this.scripts.exists(scriptId);
|
||||
}
|
||||
|
||||
public get selectedScripts(): readonly SelectedScript[] {
|
||||
return this.scripts.getItems();
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
const scriptsToSelect = this.collection
|
||||
.getAllScripts()
|
||||
.filter((script) => !this.scripts.exists(script.id))
|
||||
.map((script) => new UserSelectedScript(script, false));
|
||||
if (scriptsToSelect.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.processChanges({
|
||||
changes: scriptsToSelect.map((script): ScriptSelectionChange => ({
|
||||
scriptId: script.id,
|
||||
newStatus: {
|
||||
isSelected: true,
|
||||
isReverted: false,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
public deselectAll(): void {
|
||||
if (this.scripts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
|
||||
this.processChanges({
|
||||
changes: selectedScriptIds.map((scriptId): ScriptSelectionChange => ({
|
||||
scriptId,
|
||||
newStatus: {
|
||||
isSelected: false,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
public selectOnly(scripts: readonly IScript[]): void {
|
||||
if (scripts.length === 0) {
|
||||
throw new Error('Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.');
|
||||
}
|
||||
this.processChanges({
|
||||
changes: [
|
||||
...getScriptIdsToBeDeselected(this.scripts, scripts)
|
||||
.map((scriptId): ScriptSelectionChange => ({
|
||||
scriptId,
|
||||
newStatus: {
|
||||
isSelected: false,
|
||||
},
|
||||
})),
|
||||
...getScriptIdsToBeSelected(this.scripts, scripts)
|
||||
.map((scriptId): ScriptSelectionChange => ({
|
||||
scriptId,
|
||||
newStatus: {
|
||||
isSelected: true,
|
||||
isReverted: false,
|
||||
},
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private processScriptChanges(changes: readonly ScriptSelectionChange[]): void {
|
||||
let totalChanged = 0;
|
||||
for (const change of changes) {
|
||||
totalChanged += this.applyChange(change);
|
||||
}
|
||||
if (totalChanged > 0) {
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
}
|
||||
|
||||
private applyChange(change: ScriptSelectionChange): number {
|
||||
const script = this.collection.getScript(change.scriptId);
|
||||
if (change.newStatus.isSelected) {
|
||||
return this.addOrUpdateScript(script.id, change.newStatus.isReverted);
|
||||
}
|
||||
return this.removeScript(script.id);
|
||||
}
|
||||
|
||||
private addOrUpdateScript(scriptId: string, revert: boolean): number {
|
||||
const script = this.collection.getScript(scriptId);
|
||||
const selectedScript = new UserSelectedScript(script, revert);
|
||||
if (!this.scripts.exists(selectedScript.id)) {
|
||||
this.scripts.addItem(selectedScript);
|
||||
return 1;
|
||||
}
|
||||
const existingSelectedScript = this.scripts.getById(selectedScript.id);
|
||||
if (equals(selectedScript, existingSelectedScript)) {
|
||||
return 0;
|
||||
}
|
||||
this.scripts.addOrUpdateItem(selectedScript);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private removeScript(scriptId: string): number {
|
||||
if (!this.scripts.exists(scriptId)) {
|
||||
return 0;
|
||||
}
|
||||
this.scripts.removeItem(scriptId);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getScriptIdsToBeSelected(
|
||||
existingItems: ReadonlyRepository<string, SelectedScript>,
|
||||
desiredScripts: readonly IScript[],
|
||||
): string[] {
|
||||
return desiredScripts
|
||||
.filter((script) => !existingItems.exists(script.id))
|
||||
.map((script) => script.id);
|
||||
}
|
||||
|
||||
function getScriptIdsToBeDeselected(
|
||||
existingItems: ReadonlyRepository<string, SelectedScript>,
|
||||
desiredScripts: readonly IScript[],
|
||||
): string[] {
|
||||
return existingItems
|
||||
.getItems()
|
||||
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id))
|
||||
.map((script) => script.id);
|
||||
}
|
||||
|
||||
function equals(a: SelectedScript, b: SelectedScript): boolean {
|
||||
return a.script.equals(b.script.id) && a.revert === b.revert;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { SelectedScript } from './SelectedScript';
|
||||
import { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
|
||||
|
||||
export interface ReadonlyScriptSelection {
|
||||
readonly changed: IEventSource<readonly SelectedScript[]>;
|
||||
readonly selectedScripts: readonly SelectedScript[];
|
||||
isSelected(scriptId: string): boolean;
|
||||
}
|
||||
|
||||
export interface ScriptSelection extends ReadonlyScriptSelection {
|
||||
selectOnly(scripts: readonly IScript[]): void;
|
||||
selectAll(): void;
|
||||
deselectAll(): void;
|
||||
processChanges(action: ScriptSelectionChangeCommand): void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type ScriptSelectionStatus = {
|
||||
readonly isSelected: true;
|
||||
readonly isReverted: boolean;
|
||||
} | {
|
||||
readonly isSelected: false;
|
||||
};
|
||||
|
||||
export interface ScriptSelectionChange {
|
||||
readonly scriptId: string;
|
||||
readonly newStatus: ScriptSelectionStatus;
|
||||
}
|
||||
|
||||
export interface ScriptSelectionChangeCommand {
|
||||
readonly changes: ReadonlyArray<ScriptSelectionChange>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IEntity } from '@/infrastructure/Entity/IEntity';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
|
||||
type ScriptId = IScript['id'];
|
||||
|
||||
export interface SelectedScript extends IEntity<ScriptId> {
|
||||
readonly script: IScript;
|
||||
readonly revert: boolean;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { SelectedScript } from './SelectedScript';
|
||||
|
||||
export class SelectedScript extends BaseEntity<string> {
|
||||
type SelectedScriptId = SelectedScript['id'];
|
||||
|
||||
export class UserSelectedScript extends BaseEntity<SelectedScriptId> {
|
||||
constructor(
|
||||
public readonly script: IScript,
|
||||
public readonly revert: boolean,
|
||||
) {
|
||||
super(script.id);
|
||||
if (revert && !script.canRevert()) {
|
||||
throw new Error('cannot revert an irreversible script');
|
||||
throw new Error(`The script with ID '${script.id}' is not reversible and cannot be reverted.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,12 @@
|
||||
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||
import { IRepository } from '@/infrastructure/Repository/IRepository';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { IUserSelection } from './IUserSelection';
|
||||
import { SelectedScript } from './SelectedScript';
|
||||
import { CategorySelection, ReadonlyCategorySelection } from './Category/CategorySelection';
|
||||
import { ReadonlyScriptSelection, ScriptSelection } from './Script/ScriptSelection';
|
||||
|
||||
export class UserSelection implements IUserSelection {
|
||||
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
|
||||
|
||||
private readonly scripts: IRepository<string, SelectedScript>;
|
||||
|
||||
constructor(
|
||||
private readonly collection: ICategoryCollection,
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
) {
|
||||
this.scripts = new InMemoryRepository<string, SelectedScript>();
|
||||
for (const script of selectedScripts) {
|
||||
this.scripts.addItem(script);
|
||||
}
|
||||
}
|
||||
|
||||
public areAllSelected(category: ICategory): boolean {
|
||||
if (this.selectedScripts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const scripts = category.getAllScriptsRecursively();
|
||||
if (this.selectedScripts.length < scripts.length) {
|
||||
return false;
|
||||
}
|
||||
return scripts.every(
|
||||
(script) => this.selectedScripts.some((selected) => selected.id === script.id),
|
||||
);
|
||||
}
|
||||
|
||||
public isAnySelected(category: ICategory): boolean {
|
||||
if (this.selectedScripts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return this.selectedScripts.some((s) => category.includes(s.script));
|
||||
}
|
||||
|
||||
public removeAllInCategory(categoryId: number): void {
|
||||
const category = this.collection.findCategory(categoryId);
|
||||
const scriptsToRemove = category.getAllScriptsRecursively()
|
||||
.filter((script) => this.scripts.exists(script.id));
|
||||
if (!scriptsToRemove.length) {
|
||||
return;
|
||||
}
|
||||
for (const script of scriptsToRemove) {
|
||||
this.scripts.removeItem(script.id);
|
||||
}
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
|
||||
public addOrUpdateAllInCategory(categoryId: number, revert = false): void {
|
||||
const scriptsToAddOrUpdate = this.collection
|
||||
.findCategory(categoryId)
|
||||
.getAllScriptsRecursively()
|
||||
.filter(
|
||||
(script) => !this.scripts.exists(script.id)
|
||||
|| this.scripts.getById(script.id).revert !== revert,
|
||||
)
|
||||
.map((script) => new SelectedScript(script, revert));
|
||||
if (!scriptsToAddOrUpdate.length) {
|
||||
return;
|
||||
}
|
||||
for (const script of scriptsToAddOrUpdate) {
|
||||
this.scripts.addOrUpdateItem(script);
|
||||
}
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
|
||||
public addSelectedScript(scriptId: string, revert: boolean): void {
|
||||
const script = this.collection.findScript(scriptId);
|
||||
if (!script) {
|
||||
throw new Error(`Cannot add (id: ${scriptId}) as it is unknown`);
|
||||
}
|
||||
const selectedScript = new SelectedScript(script, revert);
|
||||
this.scripts.addItem(selectedScript);
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
|
||||
public addOrUpdateSelectedScript(scriptId: string, revert: boolean): void {
|
||||
const script = this.collection.findScript(scriptId);
|
||||
const selectedScript = new SelectedScript(script, revert);
|
||||
this.scripts.addOrUpdateItem(selectedScript);
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
|
||||
public removeSelectedScript(scriptId: string): void {
|
||||
this.scripts.removeItem(scriptId);
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
|
||||
public isSelected(scriptId: string): boolean {
|
||||
return this.scripts.exists(scriptId);
|
||||
}
|
||||
|
||||
/** Get users scripts based on his/her selections */
|
||||
public get selectedScripts(): ReadonlyArray<SelectedScript> {
|
||||
return this.scripts.getItems();
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
const scriptsToSelect = this.collection
|
||||
.getAllScripts()
|
||||
.filter((script) => !this.scripts.exists(script.id))
|
||||
.map((script) => new SelectedScript(script, false));
|
||||
if (scriptsToSelect.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const script of scriptsToSelect) {
|
||||
this.scripts.addItem(script);
|
||||
}
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
|
||||
public deselectAll(): void {
|
||||
if (this.scripts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
|
||||
for (const scriptId of selectedScriptIds) {
|
||||
this.scripts.removeItem(scriptId);
|
||||
}
|
||||
this.changed.notify([]);
|
||||
}
|
||||
|
||||
public selectOnly(scripts: readonly IScript[]): void {
|
||||
if (!scripts || scripts.length === 0) {
|
||||
throw new Error('Scripts are empty. Use deselectAll() if you want to deselect everything');
|
||||
}
|
||||
let totalChanged = 0;
|
||||
totalChanged += this.unselectMissingWithoutNotifying(scripts);
|
||||
totalChanged += this.selectNewWithoutNotifying(scripts);
|
||||
if (totalChanged > 0) {
|
||||
this.changed.notify(this.scripts.getItems());
|
||||
}
|
||||
}
|
||||
|
||||
private unselectMissingWithoutNotifying(scripts: readonly IScript[]): number {
|
||||
if (this.scripts.length === 0 || scripts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const existingItems = this.scripts.getItems();
|
||||
const missingIds = existingItems
|
||||
.filter((existing) => !scripts.some((script) => existing.id === script.id))
|
||||
.map((script) => script.id);
|
||||
for (const id of missingIds) {
|
||||
this.scripts.removeItem(id);
|
||||
}
|
||||
return missingIds.length;
|
||||
}
|
||||
|
||||
private selectNewWithoutNotifying(scripts: readonly IScript[]): number {
|
||||
const unselectedScripts = scripts
|
||||
.filter((script) => !this.scripts.exists(script.id))
|
||||
.map((script) => new SelectedScript(script, false));
|
||||
for (const newScript of unselectedScripts) {
|
||||
this.scripts.addItem(newScript);
|
||||
}
|
||||
return unselectedScripts.length;
|
||||
}
|
||||
export interface ReadonlyUserSelection {
|
||||
readonly categories: ReadonlyCategorySelection;
|
||||
readonly scripts: ReadonlyScriptSelection;
|
||||
}
|
||||
|
||||
export interface UserSelection extends ReadonlyUserSelection {
|
||||
readonly categories: CategorySelection;
|
||||
readonly scripts: ScriptSelection;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { CategorySelection } from './Category/CategorySelection';
|
||||
import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper';
|
||||
import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection';
|
||||
import { ScriptSelection } from './Script/ScriptSelection';
|
||||
import { UserSelection } from './UserSelection';
|
||||
import { SelectedScript } from './Script/SelectedScript';
|
||||
|
||||
export class UserSelectionFacade implements UserSelection {
|
||||
public readonly categories: CategorySelection;
|
||||
|
||||
public readonly scripts: ScriptSelection;
|
||||
|
||||
constructor(
|
||||
collection: ICategoryCollection,
|
||||
selectedScripts: readonly SelectedScript[],
|
||||
scriptsFactory = DefaultScriptsFactory,
|
||||
categoriesFactory = DefaultCategoriesFactory,
|
||||
) {
|
||||
this.scripts = scriptsFactory(collection, selectedScripts);
|
||||
this.categories = categoriesFactory(this.scripts, collection);
|
||||
}
|
||||
}
|
||||
|
||||
export type ScriptsFactory = (
|
||||
...params: ConstructorParameters<typeof DebouncedScriptSelection>
|
||||
) => ScriptSelection;
|
||||
|
||||
const DefaultScriptsFactory: ScriptsFactory = (
|
||||
...params
|
||||
) => new DebouncedScriptSelection(...params);
|
||||
|
||||
export type CategoriesFactory = (
|
||||
...params: ConstructorParameters<typeof ScriptToCategorySelectionMapper>
|
||||
) => CategorySelection;
|
||||
|
||||
const DefaultCategoriesFactory: CategoriesFactory = (
|
||||
...params
|
||||
) => new ScriptToCategorySelectionMapper(...params);
|
||||
@@ -32,10 +32,7 @@ const PreParsedCollections: readonly CollectionData [] = [
|
||||
];
|
||||
|
||||
function validateCollectionsData(collections: readonly CollectionData[]) {
|
||||
if (!collections?.length) {
|
||||
if (!collections.length) {
|
||||
throw new Error('missing collections');
|
||||
}
|
||||
if (collections.some((collection) => !collection)) {
|
||||
throw new Error('missing collection provided');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,7 @@ export function parseCategoryCollection(
|
||||
}
|
||||
|
||||
function validate(content: CollectionData): void {
|
||||
if (!content) {
|
||||
throw new Error('missing content');
|
||||
}
|
||||
if (!content.actions || content.actions.length <= 0) {
|
||||
if (!content.actions.length) {
|
||||
throw new Error('content does not define any action');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
CategoryData, ScriptData, CategoryOrScriptData, InstructionHolder,
|
||||
CategoryData, ScriptData, CategoryOrScriptData,
|
||||
} from '@/application/collections/';
|
||||
import { Script } from '@/domain/Script';
|
||||
import { Category } from '@/domain/Category';
|
||||
@@ -16,7 +16,6 @@ export function parseCategory(
|
||||
context: ICategoryCollectionParseContext,
|
||||
factory: CategoryFactoryType = CategoryFactory,
|
||||
): Category {
|
||||
if (!context) { throw new Error('missing context'); }
|
||||
return parseCategoryRecursively({
|
||||
categoryData: category,
|
||||
context,
|
||||
@@ -30,8 +29,8 @@ interface ICategoryParseContext {
|
||||
readonly factory: CategoryFactoryType,
|
||||
readonly parentCategory?: CategoryData,
|
||||
}
|
||||
// eslint-disable-next-line consistent-return
|
||||
function parseCategoryRecursively(context: ICategoryParseContext): Category {
|
||||
|
||||
function parseCategoryRecursively(context: ICategoryParseContext): Category | never {
|
||||
ensureValidCategory(context.categoryData, context.parentCategory);
|
||||
const children: ICategoryChildren = {
|
||||
subCategories: new Array<Category>(),
|
||||
@@ -55,7 +54,7 @@ function parseCategoryRecursively(context: ICategoryParseContext): Category {
|
||||
/* scripts: */ children.subScripts,
|
||||
);
|
||||
} catch (err) {
|
||||
new NodeValidator({
|
||||
return new NodeValidator({
|
||||
type: NodeType.Category,
|
||||
selfNode: context.categoryData,
|
||||
parentNode: context.parentCategory,
|
||||
@@ -72,7 +71,7 @@ function ensureValidCategory(category: CategoryData, parentCategory?: CategoryDa
|
||||
.assertDefined(category)
|
||||
.assertValidName(category.category)
|
||||
.assert(
|
||||
() => category.children && category.children.length > 0,
|
||||
() => category.children.length > 0,
|
||||
`"${category.category}" has no children.`,
|
||||
);
|
||||
}
|
||||
@@ -94,14 +93,14 @@ function parseNode(context: INodeParseContext) {
|
||||
validator.assertDefined(context.nodeData);
|
||||
if (isCategory(context.nodeData)) {
|
||||
const subCategory = parseCategoryRecursively({
|
||||
categoryData: context.nodeData as CategoryData,
|
||||
categoryData: context.nodeData,
|
||||
context: context.context,
|
||||
factory: context.factory,
|
||||
parentCategory: context.parent,
|
||||
});
|
||||
context.children.subCategories.push(subCategory);
|
||||
} else if (isScript(context.nodeData)) {
|
||||
const script = parseScript(context.nodeData as ScriptData, context.context);
|
||||
const script = parseScript(context.nodeData, context.context);
|
||||
context.children.subScripts.push(script);
|
||||
} else {
|
||||
validator.throw('Node is neither a category or a script.');
|
||||
@@ -109,19 +108,18 @@ function parseNode(context: INodeParseContext) {
|
||||
}
|
||||
|
||||
function isScript(data: CategoryOrScriptData): data is ScriptData {
|
||||
const holder = (data as InstructionHolder);
|
||||
return hasCode(holder) || hasCall(holder);
|
||||
return hasCode(data) || hasCall(data);
|
||||
}
|
||||
|
||||
function isCategory(data: CategoryOrScriptData): data is CategoryData {
|
||||
return hasProperty(data, 'category');
|
||||
}
|
||||
|
||||
function hasCode(data: InstructionHolder): boolean {
|
||||
function hasCode(data: unknown): boolean {
|
||||
return hasProperty(data, 'code');
|
||||
}
|
||||
|
||||
function hasCall(data: InstructionHolder) {
|
||||
function hasCall(data: unknown) {
|
||||
return hasProperty(data, 'call');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { DocumentableData, DocumentationData } from '@/application/collections/';
|
||||
|
||||
export function parseDocs(documentable: DocumentableData): readonly string[] {
|
||||
if (!documentable) {
|
||||
throw new Error('missing documentable');
|
||||
}
|
||||
const { docs } = documentable;
|
||||
if (!docs) {
|
||||
return [];
|
||||
|
||||
@@ -32,7 +32,7 @@ export class NodeValidator {
|
||||
return this;
|
||||
}
|
||||
|
||||
public throw(errorMessage: string) {
|
||||
public throw(errorMessage: string): never {
|
||||
throw new NodeDataError(errorMessage, this.context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ export class CategoryCollectionParseContext implements ICategoryCollectionParseC
|
||||
scripting: IScriptingDefinition,
|
||||
syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
|
||||
) {
|
||||
if (!scripting) { throw new Error('missing scripting'); }
|
||||
this.syntax = syntaxFactory.create(scripting.language);
|
||||
this.compiler = new ScriptCompiler(functionsData, this.syntax);
|
||||
this.compiler = new ScriptCompiler(functionsData ?? [], this.syntax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,10 @@ export class Expression implements IExpression {
|
||||
public readonly evaluator: ExpressionEvaluator,
|
||||
parameters?: IReadOnlyFunctionParameterCollection,
|
||||
) {
|
||||
if (!position) {
|
||||
throw new Error('missing position');
|
||||
}
|
||||
if (!evaluator) {
|
||||
throw new Error('missing evaluator');
|
||||
}
|
||||
this.parameters = parameters ?? new FunctionParameterCollection();
|
||||
}
|
||||
|
||||
public evaluate(context: IExpressionEvaluationContext): string {
|
||||
if (!context) {
|
||||
throw new Error('missing context');
|
||||
}
|
||||
validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args);
|
||||
const args = filterUnusedArguments(this.parameters, context.args);
|
||||
const filteredContext = new ExpressionEvaluationContext(args, context.pipelineCompiler);
|
||||
|
||||
@@ -12,8 +12,5 @@ export class ExpressionEvaluationContext implements IExpressionEvaluationContext
|
||||
public readonly args: IReadOnlyFunctionCallArgumentCollection,
|
||||
public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler(),
|
||||
) {
|
||||
if (!args) {
|
||||
throw new Error('missing args, send empty collection instead.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ExpressionPosition } from './ExpressionPosition';
|
||||
|
||||
export function createPositionFromRegexFullMatch(
|
||||
match: RegExpMatchArray,
|
||||
): ExpressionPosition {
|
||||
const startPos = match.index;
|
||||
if (startPos === undefined) {
|
||||
throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`);
|
||||
}
|
||||
const fullMatch = match[0];
|
||||
if (!fullMatch.length) {
|
||||
throw new Error(`Regex match is empty: ${JSON.stringify(match)}`);
|
||||
}
|
||||
const endPos = startPos + fullMatch.length;
|
||||
return new ExpressionPosition(startPos, endPos);
|
||||
}
|
||||
@@ -11,14 +11,11 @@ export class ExpressionsCompiler implements IExpressionsCompiler {
|
||||
) { }
|
||||
|
||||
public compileExpressions(
|
||||
code: string | undefined,
|
||||
code: string,
|
||||
args: IReadOnlyFunctionCallArgumentCollection,
|
||||
): string {
|
||||
if (!args) {
|
||||
throw new Error('missing args, send empty collection instead.');
|
||||
}
|
||||
if (!code) {
|
||||
return code;
|
||||
return '';
|
||||
}
|
||||
const context = new ExpressionEvaluationContext(args);
|
||||
const compiledCode = compileRecursively(code, context, this.extractor);
|
||||
@@ -145,7 +142,7 @@ function ensureParamsUsedInCodeHasArgsProvided(
|
||||
providedArgs: IReadOnlyFunctionCallArgumentCollection,
|
||||
): void {
|
||||
const usedParameterNames = extractRequiredParameterNames(expressions);
|
||||
if (!usedParameterNames?.length) {
|
||||
if (!usedParameterNames.length) {
|
||||
return;
|
||||
}
|
||||
const notProvidedParameters = usedParameterNames
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argume
|
||||
|
||||
export interface IExpressionsCompiler {
|
||||
compileExpressions(
|
||||
code: string | undefined,
|
||||
args: IReadOnlyFunctionCallArgumentCollection): string;
|
||||
code: string,
|
||||
args: IReadOnlyFunctionCallArgumentCollection,
|
||||
): string;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ const Parsers = [
|
||||
|
||||
export class CompositeExpressionParser implements IExpressionParser {
|
||||
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {
|
||||
if (!leafs) {
|
||||
if (!leafs.length) {
|
||||
throw new Error('missing leafs');
|
||||
}
|
||||
if (leafs.some((leaf) => !leaf)) {
|
||||
throw new Error('missing leaf');
|
||||
}
|
||||
}
|
||||
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IExpressionParser } from '../IExpressionParser';
|
||||
import { ExpressionPosition } from '../../Expression/ExpressionPosition';
|
||||
import { IExpression } from '../../Expression/IExpression';
|
||||
import { Expression, ExpressionEvaluator } from '../../Expression/Expression';
|
||||
import { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
|
||||
import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
|
||||
import { createPositionFromRegexFullMatch } from '../../Expression/ExpressionPositionFactory';
|
||||
|
||||
export abstract class RegexParser implements IExpressionParser {
|
||||
protected abstract readonly regex: RegExp;
|
||||
@@ -21,7 +21,7 @@ export abstract class RegexParser implements IExpressionParser {
|
||||
const matches = code.matchAll(this.regex);
|
||||
for (const match of matches) {
|
||||
const primitiveExpression = this.buildExpression(match);
|
||||
const position = this.doOrRethrow(() => createPosition(match), 'invalid script position', code);
|
||||
const position = this.doOrRethrow(() => createPositionFromRegexFullMatch(match), 'invalid script position', code);
|
||||
const parameters = createParameters(primitiveExpression);
|
||||
const expression = new Expression(position, primitiveExpression.evaluator, parameters);
|
||||
yield expression;
|
||||
@@ -37,12 +37,6 @@ export abstract class RegexParser implements IExpressionParser {
|
||||
}
|
||||
}
|
||||
|
||||
function createPosition(match: RegExpMatchArray): ExpressionPosition {
|
||||
const startPos = match.index;
|
||||
const endPos = startPos + match[0].length;
|
||||
return new ExpressionPosition(startPos, endPos);
|
||||
}
|
||||
|
||||
function createParameters(
|
||||
expression: IPrimitiveExpression,
|
||||
): FunctionParameterCollection {
|
||||
|
||||
@@ -28,7 +28,7 @@ function hasLines(text: string) {
|
||||
*/
|
||||
function inlineComments(code: string): string {
|
||||
const makeInlineComment = (comment: string) => {
|
||||
const value = comment?.trim();
|
||||
const value = comment.trim();
|
||||
if (!value) {
|
||||
return '<##>';
|
||||
}
|
||||
|
||||
@@ -15,12 +15,6 @@ export class PipeFactory implements IPipeFactory {
|
||||
private readonly pipes = new Map<string, IPipe>();
|
||||
|
||||
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
|
||||
if (!pipes) {
|
||||
throw new Error('missing pipes');
|
||||
}
|
||||
if (pipes.some((pipe) => !pipe)) {
|
||||
throw new Error('missing pipe in list');
|
||||
}
|
||||
for (const pipe of pipes) {
|
||||
this.registerPipe(pipe);
|
||||
}
|
||||
@@ -28,10 +22,11 @@ export class PipeFactory implements IPipeFactory {
|
||||
|
||||
public get(pipeName: string): IPipe {
|
||||
validatePipeName(pipeName);
|
||||
if (!this.pipes.has(pipeName)) {
|
||||
const pipe = this.pipes.get(pipeName);
|
||||
if (!pipe) {
|
||||
throw new Error(`Unknown pipe: "${pipeName}"`);
|
||||
}
|
||||
return this.pipes.get(pipeName);
|
||||
return pipe;
|
||||
}
|
||||
|
||||
private registerPipe(pipe: IPipe): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function
|
||||
import { IExpression } from '../Expression/IExpression';
|
||||
import { ExpressionPosition } from '../Expression/ExpressionPosition';
|
||||
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
|
||||
import { createPositionFromRegexFullMatch } from '../Expression/ExpressionPositionFactory';
|
||||
|
||||
export class WithParser implements IExpressionParser {
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
@@ -42,31 +43,25 @@ function parseAllWithExpressions(
|
||||
expressions.push({
|
||||
type: WithStatementType.Start,
|
||||
parameterName: match[1],
|
||||
position: createPosition(match),
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(WithStatementEndRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.End,
|
||||
position: createPosition(match),
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.ContextVariable,
|
||||
position: createPosition(match),
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
pipeline: match[1],
|
||||
});
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
function createPosition(match: RegExpMatchArray): ExpressionPosition {
|
||||
const startPos = match.index;
|
||||
const endPos = startPos + match[0].length;
|
||||
return new ExpressionPosition(startPos, endPos);
|
||||
}
|
||||
|
||||
class WithStatementBuilder {
|
||||
private readonly contextVariables = new Array<{
|
||||
readonly positionInScope: ExpressionPosition;
|
||||
@@ -125,7 +120,7 @@ class WithStatementBuilder {
|
||||
|
||||
private substituteContextVariables(
|
||||
scope: string,
|
||||
substituter: (pipeline: string) => string,
|
||||
substituter: (pipeline?: string) => string,
|
||||
): string {
|
||||
if (!this.contextVariables.length) {
|
||||
return scope;
|
||||
@@ -157,7 +152,7 @@ function parseWithExpressions(input: string): IExpression[] {
|
||||
.sort((a, b) => b.position.start - a.position.start);
|
||||
const expressions = new Array<IExpression>();
|
||||
const builders = new Array<WithStatementBuilder>();
|
||||
const throwWithContext = (message: string) => {
|
||||
const throwWithContext = (message: string): never => {
|
||||
throw new Error(`${message}\n${buildErrorContext(input, allStatements)}}`);
|
||||
};
|
||||
while (sortedStatements.length > 0) {
|
||||
@@ -178,12 +173,15 @@ function parseWithExpressions(input: string): IExpression[] {
|
||||
}
|
||||
builders[builders.length - 1].addContextVariable(statement.position, statement.pipeline);
|
||||
break;
|
||||
case WithStatementType.End:
|
||||
if (builders.length === 0) {
|
||||
case WithStatementType.End: {
|
||||
const builder = builders.pop();
|
||||
if (!builder) {
|
||||
throwWithContext('Redundant `end` statement, missing `with`?');
|
||||
break;
|
||||
}
|
||||
expressions.push(builders.pop().buildExpression(statement.position, input));
|
||||
expressions.push(builder.buildExpression(statement.position, input));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (builders.length > 0) {
|
||||
|
||||
@@ -5,9 +5,6 @@ export class FunctionCallArgumentCollection implements IFunctionCallArgumentColl
|
||||
private readonly arguments = new Map<string, IFunctionCallArgument>();
|
||||
|
||||
public addArgument(argument: IFunctionCallArgument): void {
|
||||
if (!argument) {
|
||||
throw new Error('missing argument');
|
||||
}
|
||||
if (this.hasArgument(argument.parameterName)) {
|
||||
throw new Error(`argument value for parameter ${argument.parameterName} is already provided`);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,22 @@ import { CodeSegmentMerger } from './CodeSegmentMerger';
|
||||
|
||||
export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
|
||||
public mergeCodeParts(codeSegments: readonly CompiledCode[]): CompiledCode {
|
||||
if (!codeSegments?.length) {
|
||||
if (!codeSegments.length) {
|
||||
throw new Error('missing segments');
|
||||
}
|
||||
return {
|
||||
code: joinCodeParts(codeSegments.map((f) => f.code)),
|
||||
revertCode: joinCodeParts(codeSegments.map((f) => f.revertCode)),
|
||||
revertCode: joinCodeParts(
|
||||
codeSegments
|
||||
.map((f) => f.revertCode)
|
||||
.filter((code): code is string => Boolean(code)),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function joinCodeParts(codeSegments: readonly string[]): string {
|
||||
return codeSegments
|
||||
.filter((segment) => segment?.length > 0)
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ export class FunctionCallSequenceCompiler implements FunctionCallCompiler {
|
||||
calls: readonly FunctionCall[],
|
||||
functions: ISharedFunctionCollection,
|
||||
): CompiledCode {
|
||||
if (!functions) { throw new Error('missing functions'); }
|
||||
if (!calls?.length) { throw new Error('missing calls'); }
|
||||
if (calls.some((f) => !f)) { throw new Error('missing function call'); }
|
||||
if (!calls.length) { throw new Error('missing calls'); }
|
||||
const context: FunctionCallCompilationContext = {
|
||||
allFunctions: functions,
|
||||
rootCallSequence: calls,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler';
|
||||
import { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler';
|
||||
import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
|
||||
import { FunctionBodyType, ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
|
||||
import { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
import { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
|
||||
@@ -12,19 +12,33 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
|
||||
}
|
||||
|
||||
public canCompile(func: ISharedFunction): boolean {
|
||||
return func.body.code !== undefined;
|
||||
return func.body.type === FunctionBodyType.Code;
|
||||
}
|
||||
|
||||
public compileFunction(
|
||||
calledFunction: ISharedFunction,
|
||||
callToFunction: FunctionCall,
|
||||
): CompiledCode[] {
|
||||
if (calledFunction.body.type !== FunctionBodyType.Code) {
|
||||
throw new Error([
|
||||
'Unexpected function body type.',
|
||||
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
|
||||
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
|
||||
'Function:',
|
||||
`\t${JSON.stringify(callToFunction)}`,
|
||||
].join('\n'));
|
||||
}
|
||||
const { code } = calledFunction.body;
|
||||
const { args } = callToFunction;
|
||||
return [
|
||||
{
|
||||
code: this.expressionsCompiler.compileExpressions(code.execute, args),
|
||||
revertCode: this.expressionsCompiler.compileExpressions(code.revert, args),
|
||||
revertCode: (() => {
|
||||
if (!code.revert) {
|
||||
return undefined;
|
||||
}
|
||||
return this.expressionsCompiler.compileExpressions(code.revert, args);
|
||||
})(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
|
||||
import { CallFunctionBody, FunctionBodyType, ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
|
||||
import { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
|
||||
import { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
@@ -13,7 +13,7 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
|
||||
}
|
||||
|
||||
public canCompile(func: ISharedFunction): boolean {
|
||||
return func.body.calls !== undefined;
|
||||
return func.body.type === FunctionBodyType.Calls;
|
||||
}
|
||||
|
||||
public compileFunction(
|
||||
@@ -21,7 +21,7 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
|
||||
callToFunction: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): CompiledCode[] {
|
||||
const nestedCalls = calledFunction.body.calls;
|
||||
const nestedCalls = (calledFunction.body as CallFunctionBody).calls;
|
||||
return nestedCalls.map((nestedCall) => {
|
||||
try {
|
||||
const compiledParentCall = this.argumentCompiler
|
||||
|
||||
@@ -5,9 +5,6 @@ import { FunctionCallArgument } from './Argument/FunctionCallArgument';
|
||||
import { ParsedFunctionCall } from './ParsedFunctionCall';
|
||||
|
||||
export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] {
|
||||
if (calls === undefined) {
|
||||
throw new Error('missing call data');
|
||||
}
|
||||
const sequence = getCallSequence(calls);
|
||||
return sequence.map((call) => parseFunctionCall(call));
|
||||
}
|
||||
@@ -19,22 +16,21 @@ function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
|
||||
if (calls instanceof Array) {
|
||||
return calls as FunctionCallData[];
|
||||
}
|
||||
return [calls as FunctionCallData];
|
||||
const singleCall = calls;
|
||||
return [singleCall];
|
||||
}
|
||||
|
||||
function parseFunctionCall(call: FunctionCallData): FunctionCall {
|
||||
if (!call) {
|
||||
throw new Error('missing call data');
|
||||
}
|
||||
const callArgs = parseArgs(call.parameters);
|
||||
return new ParsedFunctionCall(call.function, callArgs);
|
||||
}
|
||||
|
||||
function parseArgs(
|
||||
parameters: FunctionCallParametersData,
|
||||
parameters: FunctionCallParametersData | undefined,
|
||||
): FunctionCallArgumentCollection {
|
||||
return Object.keys(parameters || {})
|
||||
.map((parameterName) => new FunctionCallArgument(parameterName, parameters[parameterName]))
|
||||
const parametersMap = parameters ?? {};
|
||||
return Object.keys(parametersMap)
|
||||
.map((parameterName) => new FunctionCallArgument(parameterName, parametersMap[parameterName]))
|
||||
.reduce((args, arg) => {
|
||||
args.addArgument(arg);
|
||||
return args;
|
||||
|
||||
@@ -9,8 +9,5 @@ export class ParsedFunctionCall implements FunctionCall {
|
||||
if (!functionName) {
|
||||
throw new Error('missing function name in function call');
|
||||
}
|
||||
if (!args) {
|
||||
throw new Error('missing args');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@ import { FunctionCall } from './Call/FunctionCall';
|
||||
export interface ISharedFunction {
|
||||
readonly name: string;
|
||||
readonly parameters: IReadOnlyFunctionParameterCollection;
|
||||
readonly body: ISharedFunctionBody;
|
||||
readonly body: SharedFunctionBody;
|
||||
}
|
||||
|
||||
export interface ISharedFunctionBody {
|
||||
readonly type: FunctionBodyType;
|
||||
readonly code: IFunctionCode | undefined;
|
||||
readonly calls: readonly FunctionCall[] | undefined;
|
||||
export interface CallFunctionBody {
|
||||
readonly type: FunctionBodyType.Calls,
|
||||
readonly calls: readonly FunctionCall[],
|
||||
}
|
||||
|
||||
export interface CodeFunctionBody {
|
||||
readonly type: FunctionBodyType.Code;
|
||||
readonly code: IFunctionCode,
|
||||
}
|
||||
|
||||
export type SharedFunctionBody = CallFunctionBody | CodeFunctionBody;
|
||||
|
||||
export enum FunctionBodyType {
|
||||
Code,
|
||||
Calls,
|
||||
|
||||
@@ -18,9 +18,6 @@ export class FunctionParameterCollection implements IFunctionParameterCollection
|
||||
}
|
||||
|
||||
private ensureValidParameter(parameter: IFunctionParameter) {
|
||||
if (!parameter) {
|
||||
throw new Error('missing parameter');
|
||||
}
|
||||
if (this.includesName(parameter.name)) {
|
||||
throw new Error(`duplicate parameter name: "${parameter.name}"`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FunctionCall } from './Call/FunctionCall';
|
||||
|
||||
import {
|
||||
FunctionBodyType, IFunctionCode, ISharedFunction, ISharedFunctionBody,
|
||||
FunctionBodyType, IFunctionCode, ISharedFunction, SharedFunctionBody,
|
||||
} from './ISharedFunction';
|
||||
import { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
|
||||
|
||||
@@ -10,7 +10,7 @@ export function createCallerFunction(
|
||||
parameters: IReadOnlyFunctionParameterCollection,
|
||||
callSequence: readonly FunctionCall[],
|
||||
): ISharedFunction {
|
||||
if (!callSequence || !callSequence.length) {
|
||||
if (!callSequence.length) {
|
||||
throw new Error(`missing call sequence in function "${name}"`);
|
||||
}
|
||||
return new SharedFunction(name, parameters, callSequence, FunctionBodyType.Calls);
|
||||
@@ -33,7 +33,7 @@ export function createFunctionWithInlineCode(
|
||||
}
|
||||
|
||||
class SharedFunction implements ISharedFunction {
|
||||
public readonly body: ISharedFunctionBody;
|
||||
public readonly body: SharedFunctionBody;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -42,11 +42,22 @@ class SharedFunction implements ISharedFunction {
|
||||
bodyType: FunctionBodyType,
|
||||
) {
|
||||
if (!name) { throw new Error('missing function name'); }
|
||||
if (!parameters) { throw new Error('missing parameters'); }
|
||||
this.body = {
|
||||
type: bodyType,
|
||||
code: bodyType === FunctionBodyType.Code ? content as IFunctionCode : undefined,
|
||||
calls: bodyType === FunctionBodyType.Calls ? content as readonly FunctionCall[] : undefined,
|
||||
};
|
||||
|
||||
switch (bodyType) {
|
||||
case FunctionBodyType.Code:
|
||||
this.body = {
|
||||
type: FunctionBodyType.Code,
|
||||
code: content as IFunctionCode,
|
||||
};
|
||||
break;
|
||||
case FunctionBodyType.Calls:
|
||||
this.body = {
|
||||
type: FunctionBodyType.Calls,
|
||||
calls: content as readonly FunctionCall[],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown body type: ${FunctionBodyType[bodyType]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export class SharedFunctionCollection implements ISharedFunctionCollection {
|
||||
private readonly functionsByName = new Map<string, ISharedFunction>();
|
||||
|
||||
public addFunction(func: ISharedFunction): void {
|
||||
if (!func) { throw new Error('missing function'); }
|
||||
if (this.has(func.name)) {
|
||||
throw new Error(`function with name ${func.name} already exists`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FunctionData, InstructionHolder } from '@/application/collections/';
|
||||
import type {
|
||||
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction,
|
||||
} from '@/application/collections/';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
|
||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||
@@ -23,9 +25,8 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
): ISharedFunctionCollection {
|
||||
if (!syntax) { throw new Error('missing syntax'); }
|
||||
const collection = new SharedFunctionCollection();
|
||||
if (!functions || !functions.length) {
|
||||
if (!functions.length) {
|
||||
return collection;
|
||||
}
|
||||
ensureValidFunctions(functions);
|
||||
@@ -55,16 +56,18 @@ function parseFunction(
|
||||
}
|
||||
|
||||
function validateCode(
|
||||
data: FunctionData,
|
||||
data: CodeFunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
validator: ICodeValidator,
|
||||
): void {
|
||||
[data.code, data.revertCode].forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
[data.code, data.revertCode]
|
||||
.filter((code): code is string => Boolean(code))
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection {
|
||||
@@ -85,19 +88,18 @@ function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollecti
|
||||
}, new FunctionParameterCollection());
|
||||
}
|
||||
|
||||
function hasCode(data: FunctionData): boolean {
|
||||
return Boolean(data.code);
|
||||
function hasCode(data: FunctionData): data is CodeFunctionData {
|
||||
return (data as CodeInstruction).code !== undefined;
|
||||
}
|
||||
|
||||
function hasCall(data: FunctionData): boolean {
|
||||
return Boolean(data.call);
|
||||
function hasCall(data: FunctionData): data is CallFunctionData {
|
||||
return (data as CallInstruction).call !== undefined;
|
||||
}
|
||||
|
||||
function ensureValidFunctions(functions: readonly FunctionData[]) {
|
||||
ensureNoUndefinedItem(functions);
|
||||
ensureNoDuplicatesInFunctionNames(functions);
|
||||
ensureNoDuplicateCode(functions);
|
||||
ensureEitherCallOrCodeIsDefined(functions);
|
||||
ensureNoDuplicateCode(functions);
|
||||
ensureExpectedParametersType(functions);
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ function printList(list: readonly string[]): string {
|
||||
return `"${list.join('","')}"`;
|
||||
}
|
||||
|
||||
function ensureEitherCallOrCodeIsDefined(holders: readonly InstructionHolder[]) {
|
||||
function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) {
|
||||
// Ensure functions do not define both call and code
|
||||
const withBothCallAndCode = holders.filter((holder) => hasCode(holder) && hasCall(holder));
|
||||
if (withBothCallAndCode.length) {
|
||||
@@ -132,7 +134,7 @@ function isArrayOfObjects(value: unknown): boolean {
|
||||
&& value.every((item) => typeof item === 'object');
|
||||
}
|
||||
|
||||
function printNames(holders: readonly InstructionHolder[]) {
|
||||
function printNames(holders: readonly FunctionData[]) {
|
||||
return printList(holders.map((holder) => holder.name));
|
||||
}
|
||||
|
||||
@@ -144,22 +146,19 @@ function ensureNoDuplicatesInFunctionNames(functions: readonly FunctionData[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNoUndefinedItem(functions: readonly FunctionData[]) {
|
||||
if (functions.some((func) => !func)) {
|
||||
throw new Error('some functions are undefined');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
|
||||
const duplicateCodes = getDuplicates(functions
|
||||
const callFunctions = functions
|
||||
.filter((func) => hasCode(func))
|
||||
.map((func) => func as CodeFunctionData);
|
||||
const duplicateCodes = getDuplicates(callFunctions
|
||||
.map((func) => func.code)
|
||||
.filter((code) => code));
|
||||
if (duplicateCodes.length > 0) {
|
||||
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
|
||||
}
|
||||
const duplicateRevertCodes = getDuplicates(functions
|
||||
.filter((func) => func.revertCode)
|
||||
.map((func) => func.revertCode));
|
||||
const duplicateRevertCodes = getDuplicates(callFunctions
|
||||
.map((func) => func.revertCode)
|
||||
.filter((code): code is string => Boolean(code)));
|
||||
if (duplicateRevertCodes.length > 0) {
|
||||
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FunctionData, ScriptData } from '@/application/collections/';
|
||||
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
|
||||
import { IScriptCode } from '@/domain/IScriptCode';
|
||||
import { ScriptCode } from '@/domain/ScriptCode';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
@@ -18,27 +18,24 @@ export class ScriptCompiler implements IScriptCompiler {
|
||||
private readonly functions: ISharedFunctionCollection;
|
||||
|
||||
constructor(
|
||||
functions: readonly FunctionData[] | undefined,
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance,
|
||||
private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance,
|
||||
private readonly codeValidator: ICodeValidator = CodeValidator.instance,
|
||||
) {
|
||||
if (!syntax) { throw new Error('missing syntax'); }
|
||||
this.functions = sharedFunctionsParser.parseFunctions(functions, syntax);
|
||||
}
|
||||
|
||||
public canCompile(script: ScriptData): boolean {
|
||||
if (!script) { throw new Error('missing script'); }
|
||||
if (!script.call) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return hasCall(script);
|
||||
}
|
||||
|
||||
public compile(script: ScriptData): IScriptCode {
|
||||
if (!script) { throw new Error('missing script'); }
|
||||
try {
|
||||
if (!hasCall(script)) {
|
||||
throw new Error('Script does include any calls.');
|
||||
}
|
||||
const calls = parseFunctionCalls(script.call);
|
||||
const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions);
|
||||
validateCompiledCode(compiledCode, this.codeValidator);
|
||||
@@ -53,7 +50,17 @@ export class ScriptCompiler implements IScriptCompiler {
|
||||
}
|
||||
|
||||
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
|
||||
[compiledCode.code, compiledCode.revertCode].forEach(
|
||||
(code) => validator.throwIfInvalid(code, [new NoEmptyLines()]),
|
||||
);
|
||||
[compiledCode.code, compiledCode.revertCode]
|
||||
.filter((code): code is string => Boolean(code))
|
||||
.map((code) => code as string)
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
|
||||
return (data as CallInstruction).call !== undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ScriptData } from '@/application/collections/';
|
||||
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
|
||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { Script } from '@/domain/Script';
|
||||
@@ -14,7 +14,6 @@ import { ICategoryCollectionParseContext } from './ICategoryCollectionParseConte
|
||||
import { CodeValidator } from './Validation/CodeValidator';
|
||||
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
export function parseScript(
|
||||
data: ScriptData,
|
||||
context: ICategoryCollectionParseContext,
|
||||
@@ -24,7 +23,6 @@ export function parseScript(
|
||||
): Script {
|
||||
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
|
||||
validateScript(data, validator);
|
||||
if (!context) { throw new Error('missing context'); }
|
||||
try {
|
||||
const script = scriptFactory(
|
||||
/* name: */ data.name,
|
||||
@@ -34,12 +32,12 @@ export function parseScript(
|
||||
);
|
||||
return script;
|
||||
} catch (err) {
|
||||
validator.throw(err.message);
|
||||
return validator.throw(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLevel(
|
||||
level: string,
|
||||
level: string | undefined,
|
||||
parser: IEnumParser<RecommendationLevel>,
|
||||
): RecommendationLevel | undefined {
|
||||
if (!level) {
|
||||
@@ -56,39 +54,45 @@ function parseCode(
|
||||
if (context.compiler.canCompile(script)) {
|
||||
return context.compiler.compile(script);
|
||||
}
|
||||
const code = new ScriptCode(script.code, script.revertCode);
|
||||
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
|
||||
const code = new ScriptCode(codeScript.code, codeScript.revertCode);
|
||||
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
|
||||
return code;
|
||||
}
|
||||
|
||||
function validateHardcodedCodeWithoutCalls(
|
||||
scriptCode: ScriptCode,
|
||||
codeValidator: ICodeValidator,
|
||||
validator: ICodeValidator,
|
||||
syntax: ILanguageSyntax,
|
||||
) {
|
||||
[scriptCode.execute, scriptCode.revert].forEach(
|
||||
(code) => codeValidator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
[scriptCode.execute, scriptCode.revert]
|
||||
.filter((code): code is string => Boolean(code))
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function validateScript(script: ScriptData, validator: NodeValidator) {
|
||||
function validateScript(
|
||||
script: ScriptData,
|
||||
validator: NodeValidator,
|
||||
): asserts script is NonNullable<ScriptData> {
|
||||
validator
|
||||
.assertDefined(script)
|
||||
.assertValidName(script.name)
|
||||
.assert(
|
||||
() => Boolean(script.code || script.call),
|
||||
'Must define either "call" or "code".',
|
||||
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
|
||||
'Neither "call" or "code" is defined.',
|
||||
)
|
||||
.assert(
|
||||
() => !(script.code && script.call),
|
||||
'Cannot define both "call" and "code".',
|
||||
() => !((script as CodeScriptData).code && (script as CallScriptData).call),
|
||||
'Both "call" and "code" are defined.',
|
||||
)
|
||||
.assert(
|
||||
() => !(script.revertCode && script.call),
|
||||
'Cannot define "revertCode" if "call" is defined.',
|
||||
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
|
||||
'Both "call" and "revertCode" are defined.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export class CodeValidator implements ICodeValidator {
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void {
|
||||
if (!rules || rules.length === 0) { throw new Error('missing rules'); }
|
||||
if (rules.length === 0) { throw new Error('missing rules'); }
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ import { ICodeLine } from '../ICodeLine';
|
||||
import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
|
||||
|
||||
export class NoDuplicatedLines implements ICodeValidationRule {
|
||||
constructor(private readonly syntax: ILanguageSyntax) {
|
||||
if (!syntax) { throw new Error('missing syntax'); }
|
||||
}
|
||||
constructor(private readonly syntax: ILanguageSyntax) { }
|
||||
|
||||
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
|
||||
return lines
|
||||
|
||||
@@ -17,7 +17,6 @@ export class CodeSubstituter implements ICodeSubstituter {
|
||||
|
||||
public substitute(code: string, info: IProjectInformation): string {
|
||||
if (!code) { throw new Error('missing code'); }
|
||||
if (!info) { throw new Error('missing info'); }
|
||||
const args = new FunctionCallArgumentCollection();
|
||||
const substitute = (name: string, value: string) => args
|
||||
.addArgument(new FunctionCallArgument(name, value));
|
||||
|
||||
@@ -18,8 +18,6 @@ export class ScriptingDefinitionParser {
|
||||
definition: ScriptingDefinitionData,
|
||||
info: IProjectInformation,
|
||||
): IScriptingDefinition {
|
||||
if (!info) { throw new Error('missing info'); }
|
||||
if (!definition) { throw new Error('missing definition'); }
|
||||
const language = this.languageParser.parseEnum(definition.language, 'language');
|
||||
const startCode = this.codeSubstituter.substitute(definition.startCode, info);
|
||||
const endCode = this.codeSubstituter.substitute(definition.endCode, info);
|
||||
|
||||
17
src/application/Repository/Repository.ts
Normal file
17
src/application/Repository/Repository.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IEntity } from '@/infrastructure/Entity/IEntity';
|
||||
|
||||
export interface ReadonlyRepository<TKey, TEntity extends IEntity<TKey>> {
|
||||
readonly length: number;
|
||||
getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[];
|
||||
getById(id: TKey): TEntity;
|
||||
exists(id: TKey): boolean;
|
||||
}
|
||||
|
||||
export interface MutableRepository<TKey, TEntity extends IEntity<TKey>> {
|
||||
addItem(item: TEntity): void;
|
||||
addOrUpdateItem(item: TEntity): void;
|
||||
removeItem(id: TKey): void;
|
||||
}
|
||||
|
||||
export interface Repository<TKey, TEntity extends IEntity<TKey>>
|
||||
extends ReadonlyRepository<TKey, TEntity>, MutableRepository<TKey, TEntity> { }
|
||||
37
src/application/collections/collection.yaml.d.ts
vendored
37
src/application/collections/collection.yaml.d.ts
vendored
@@ -12,29 +12,38 @@ declare module '@/application/collections/*' {
|
||||
}
|
||||
|
||||
export type CategoryOrScriptData = CategoryData | ScriptData;
|
||||
export type DocumentationData = ReadonlyArray<string> | string;
|
||||
export type DocumentationData = ReadonlyArray<string> | string | undefined;
|
||||
|
||||
export interface DocumentableData {
|
||||
readonly docs?: DocumentationData;
|
||||
}
|
||||
|
||||
export interface InstructionHolder {
|
||||
readonly name: string;
|
||||
|
||||
readonly code?: string;
|
||||
export interface CodeInstruction {
|
||||
readonly code: string;
|
||||
readonly revertCode?: string;
|
||||
|
||||
readonly call?: FunctionCallsData;
|
||||
}
|
||||
|
||||
export interface CallInstruction {
|
||||
readonly call: FunctionCallsData;
|
||||
}
|
||||
|
||||
export type InstructionHolder = CodeInstruction | CallInstruction;
|
||||
|
||||
export interface ParameterDefinitionData {
|
||||
readonly name: string;
|
||||
readonly optional?: boolean;
|
||||
}
|
||||
|
||||
export interface FunctionData extends InstructionHolder {
|
||||
export type FunctionDefinition = {
|
||||
readonly name: string;
|
||||
readonly parameters?: readonly ParameterDefinitionData[];
|
||||
}
|
||||
};
|
||||
|
||||
export type CodeFunctionData = FunctionDefinition & CodeInstruction;
|
||||
|
||||
export type CallFunctionData = FunctionDefinition & CallInstruction;
|
||||
|
||||
export type FunctionData = CodeFunctionData | CallFunctionData;
|
||||
|
||||
export interface FunctionCallParametersData {
|
||||
readonly [index: string]: string;
|
||||
@@ -47,10 +56,16 @@ declare module '@/application/collections/*' {
|
||||
|
||||
export type FunctionCallsData = readonly FunctionCallData[] | FunctionCallData | undefined;
|
||||
|
||||
export interface ScriptData extends InstructionHolder, DocumentableData {
|
||||
export type ScriptDefinition = DocumentableData & {
|
||||
readonly name: string;
|
||||
readonly recommend?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type CodeScriptData = ScriptDefinition & CodeInstruction;
|
||||
|
||||
export type CallScriptData = ScriptDefinition & CallInstruction;
|
||||
|
||||
export type ScriptData = CodeScriptData | CallScriptData;
|
||||
|
||||
export interface ScriptingDefinitionData {
|
||||
readonly language: string;
|
||||
|
||||
@@ -3241,6 +3241,8 @@ functions:
|
||||
revertCode: '{{ with $revertCode }}{{ . }}{{ end }}'
|
||||
-
|
||||
name: RunIfCommandExists # Skips if command does not exist
|
||||
# Marked: refactor-with-partials
|
||||
# Same function as macOS
|
||||
parameters:
|
||||
- name: command
|
||||
- name: code
|
||||
@@ -3739,7 +3741,7 @@ functions:
|
||||
- name: prefName
|
||||
- name: jsonValue
|
||||
docs: |-
|
||||
This script either creates or updates the `user.js` file to set specific Mozilla Firefox preferences.
|
||||
This script modifies the `user.js` file in Firefox profiles to set specific preferences.
|
||||
|
||||
The `user.js` file can be found in a Firefox profile folder [1] and its location depends on the type of installation:
|
||||
|
||||
@@ -3747,12 +3749,18 @@ functions:
|
||||
- Flatpak: `~/.var/app/org.mozilla.firefox/.mozilla/firefox/<profile-name>/user.js`
|
||||
- Snap: `~/snap/firefox/common/.mozilla/firefox/<profile-name>/user.js`
|
||||
|
||||
While the `user.js` file is optional [2], if it's present, the Firefox application will prioritize its settings over
|
||||
those in `prefs.js` upon startup [1][2]. To prevent potential profile corruption, Mozilla advises against editing
|
||||
`prefs.js` directly [2].
|
||||
While the `user.js` file is optional [2], if it's present, the Firefox will prioritize its settings over
|
||||
those in `prefs.js` upon startup [1] [2]. It's recommended not to directly edit `prefs.js` to avoid profile corruption [2].
|
||||
|
||||
When `user.js` is modified or deleted, corresponding changes in `prefs.js` are necessary for reversion, as Firefox
|
||||
doesn't automatically revert these changes [3].
|
||||
|
||||
This script safely modifies `user.js` and ensures changes are reflected in `prefs.js` during reversion, addressing
|
||||
issues with preference persistence [3].
|
||||
|
||||
[1]: https://web.archive.org/web/20230811005205/https://kb.mozillazine.org/User.js_file "User.js file - MozillaZine Knowledge Base"
|
||||
[2]: https://web.archive.org/web/20221029211757/https://kb.mozillazine.org/Prefs.js_file "Prefs.js file - MozillaZine Knowledge Base"
|
||||
[3]: https://github.com/undergroundwires/privacy.sexy/issues/282 "[BUG]: Reverting Firefox settings do not work on Linux · Issue #282 · undergroundwires/privacy.sexy | github.com"
|
||||
code: |-
|
||||
pref_name='{{ $prefName }}'
|
||||
pref_value='{{ $jsonValue }}'
|
||||
@@ -3792,12 +3800,16 @@ functions:
|
||||
if [ "$total_profiles_found" -eq 0 ]; then
|
||||
echo 'No profile folders are found, no changes are made.'
|
||||
else
|
||||
echo "Preferences verified in $total_profiles_found profiles."
|
||||
echo "Successfully verified preferences in $total_profiles_found profiles."
|
||||
fi
|
||||
revertCode: |-
|
||||
pref_name='{{ $prefName }}'
|
||||
pref_value='{{ $jsonValue }}'
|
||||
echo "Reverting preference: \"$pref_name\" to its default."
|
||||
if command -v 'ps' &> /dev/null && ps aux | grep -i "[f]irefox" > /dev/null; then
|
||||
>&2 echo -e "\e[33mWarning: Firefox is currently running. Please close Firefox before executing the revert script to ensure changes are applied effectively.\e[0m"
|
||||
fi
|
||||
declare -a files_to_modify=('prefs.js' 'user.js')
|
||||
declare -a profile_paths=(
|
||||
~/.mozilla/firefox/*/
|
||||
~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/
|
||||
@@ -3805,31 +3817,39 @@ functions:
|
||||
)
|
||||
declare -i total_profiles_found=0
|
||||
for profile_dir in "${profile_paths[@]}"; do
|
||||
user_js_file="${profile_dir}user.js"
|
||||
if [ ! -f "$user_js_file" ]; then
|
||||
if [ ! -d "$profile_dir" ]; then
|
||||
continue
|
||||
fi
|
||||
((total_profiles_found++))
|
||||
echo "$user_js_file:"
|
||||
pref_start="user_pref(\"$pref_name\","
|
||||
pref_line="user_pref(\"$pref_name\", $pref_value);"
|
||||
if ! grep --quiet "^$pref_start" "${user_js_file}"; then
|
||||
echo $'\t''Skipping, preference was not configured before.'
|
||||
elif grep --quiet "^$pref_line$" "${user_js_file}"; then
|
||||
sed --in-place "/^$pref_line/d" "$user_js_file"
|
||||
echo $'\t''Successfully reverted preference to default.'
|
||||
if ! grep --quiet '[^[:space:]]' "$user_js_file"; then
|
||||
rm "$user_js_file"
|
||||
echo $'\t''Removed user.js file as it became empty.'
|
||||
fi
|
||||
else
|
||||
echo $'\t''Skipping, the preference has value that is not configured by privacy.sexy.'
|
||||
if [[ ! "$(basename "$profile_dir")" =~ ^[a-z0-9]{8}\..+ ]]; then
|
||||
continue # Not a profile folder
|
||||
fi
|
||||
((total_profiles_found++))
|
||||
for file_to_modify in "${files_to_modify[@]}"; do
|
||||
config_file_path="${profile_dir}${file_to_modify}"
|
||||
if [ ! -f "$config_file_path" ]; then
|
||||
continue
|
||||
fi
|
||||
echo "$config_file_path:"
|
||||
pref_start="user_pref(\"$pref_name\","
|
||||
pref_line="user_pref(\"$pref_name\", $pref_value);"
|
||||
if ! grep --quiet "^$pref_start" "${config_file_path}"; then
|
||||
echo $'\t''Skipping, preference was not configured before.'
|
||||
elif grep --quiet "^$pref_line$" "${config_file_path}"; then
|
||||
sed --in-place "/^$pref_line/d" "$config_file_path"
|
||||
echo $'\t''Successfully reverted preference to default.'
|
||||
if ! grep --quiet '[^[:space:]]' "$config_file_path"; then
|
||||
rm "$config_file_path"
|
||||
echo $'\t'"Removed the file as it became empty."
|
||||
fi
|
||||
else
|
||||
echo $'\t''Skipping, the preference has value that is not configured by privacy.sexy.'
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ "$total_profiles_found" -eq 0 ]; then
|
||||
echo 'No reversion was necessary.'
|
||||
else
|
||||
echo "Preferences verified in $total_profiles_found profiles."
|
||||
echo "Successfully verified preferences in $total_profiles_found profiles."
|
||||
fi
|
||||
-
|
||||
name: RenameFile
|
||||
|
||||
@@ -444,47 +444,285 @@ actions:
|
||||
recommend: standard
|
||||
code: sudo purge
|
||||
-
|
||||
category: Clear all privacy permissions for applications
|
||||
category: Clear application privacy permissions
|
||||
docs: |-
|
||||
This category provides scripts to reset privacy permissions for a variety of applications on your device,
|
||||
helping you to re-establish control over your personal data. Each script targets a specific permission type – such
|
||||
as camera, microphone, contacts, or accessibility services – enabling you to revoke permissions that have previously
|
||||
been granted to applications.
|
||||
|
||||
By resetting these permissions, you not only enhance your privacy but also improve your device's security. After
|
||||
running these scripts, applications will require your explicit permission again to access these services or
|
||||
information. This means the next time an app attempts to use a service like your camera or access your contacts,
|
||||
you'll be prompted to grant or deny permission. It's a proactive step to ensure that your sensitive information
|
||||
or system services are accessed only with your current and informed consent.
|
||||
children:
|
||||
# Main documentation: https://archive.ph/26Hlq (https://developer.apple.com/documentation/devicemanagement/privacypreferencespolicycontrol/services)
|
||||
-
|
||||
name: Clear "camera" permissions
|
||||
code: tccutil reset Camera
|
||||
name: Clear **"All"** permissions
|
||||
docs: |-
|
||||
This script resets all permissions for applications.
|
||||
It revokes all previously granted permissions, enhancing privacy and security by ensuring no application has unauthorized access to system services or user data.
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: All
|
||||
-
|
||||
name: Clear "microphone" permissions
|
||||
code: tccutil reset Microphone
|
||||
name: Clear "Camera" permissions
|
||||
docs: |-
|
||||
This script resets permissions for camera access [1].
|
||||
It ensures no application can access the system camera without explicit user permission, protecting against unauthorized surveillance and data breaches.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: Camera
|
||||
-
|
||||
name: Clear "accessibility" permissions
|
||||
code: tccutil reset Accessibility
|
||||
name: Clear "Microphone" permissions
|
||||
docs: |-
|
||||
This script resets permissions for microphone access [1].
|
||||
It revokes all granted access to the microphone, protecting against eavesdropping and unauthorized audio recording by applications.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: Microphone
|
||||
-
|
||||
name: Clear "screen capture" permissions
|
||||
code: tccutil reset ScreenCapture
|
||||
name: Clear "Accessibility" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessibility features [1].
|
||||
It revokes application access to accessibility services, preventing misuse and ensuring these features are used only with user consent.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: Accessibility
|
||||
-
|
||||
name: Clear "reminders" permissions
|
||||
code: tccutil reset Reminders
|
||||
name: Clear "Screen Capture" permissions
|
||||
docs: |-
|
||||
This script resets permissions for screen capture [1].
|
||||
It ensures applications cannot capture screen content without user authorization, protecting sensitive information displayed on the screen.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: ScreenCapture
|
||||
-
|
||||
name: Clear "photos" permissions
|
||||
code: tccutil reset Photos
|
||||
name: Clear "Reminders" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing reminders information managed by the Reminders app [1].
|
||||
It ensures applications cannot access or modify reminders data without explicit user permission, maintaining the privacy of personal reminders.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: Reminders
|
||||
-
|
||||
name: Clear "calendar" permissions
|
||||
code: tccutil reset Calendar
|
||||
name: Clear "Photos" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing the pictures managed by the Photos app [1].
|
||||
It revokes all permissions granted to applications, safeguarding personal photos and media from unauthorized access.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: Photos
|
||||
-
|
||||
name: Clear "full disk access" permissions
|
||||
code: tccutil reset SystemPolicyAllFiles
|
||||
name: Clear "Calendar" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing the calendar information managed by the Calendar app [1].
|
||||
It ensures that applications cannot access calendar data without user consent, protecting personal and sensitive calendar information.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: Calendar
|
||||
-
|
||||
name: Clear "contacts" permissions
|
||||
code: tccutil reset SystemPolicyAllFiles
|
||||
name: Clear "Full Disk Access" permissions
|
||||
docs: |-
|
||||
This script resets permissions for full disk access.
|
||||
Full disk access allows the application access to all protected files, including system administration files [1].
|
||||
It revokes broad file access from applications, significantly reducing the risk of data exposure and enhancing overall system security.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyAllFiles
|
||||
-
|
||||
name: Clear "desktop folder" permissions
|
||||
code: tccutil reset SystemPolicyDesktopFolder
|
||||
name: Clear "Contacts" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing contacts.
|
||||
The contact information managed by the Contacts app [1].
|
||||
It ensures that applications cannot access the user's contact list without explicit permission, maintaining the confidentiality of personal contacts.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: AddressBook
|
||||
-
|
||||
name: Clear "documents folder" permissions
|
||||
code: tccutil reset SystemPolicyDocumentsFolder
|
||||
name: Clear "Desktop Folder" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing the Desktop folder [1].
|
||||
It revokes application access to files on the desktop, protecting personal and work-related documents from unauthorized access.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyDesktopFolder
|
||||
-
|
||||
name: Clear "downloads" permissions
|
||||
code: tccutil reset SystemPolicyDownloadsFolder
|
||||
name: Clear "Documents Folder" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing the Documents folder [1].
|
||||
It prevents applications from accessing files in this folder without user consent, safeguarding important and private documents.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyDocumentsFolder
|
||||
-
|
||||
name: Clear all app permissions
|
||||
code: tccutil reset All
|
||||
name: Clear "Downloads Folder" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing the Downloads folder [1].
|
||||
It ensures that applications cannot access downloaded files without user authorization, protecting downloaded content from misuse.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyDownloadsFolder
|
||||
-
|
||||
name: Clear "Apple Events" permissions
|
||||
docs: |-
|
||||
This script resets permissions for Apple Events [1].
|
||||
It revokes permissions for applications to send restricted Apple Events to other processes [1], enhancing privacy and security.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: AppleEvents
|
||||
-
|
||||
name: Clear "File Provider Presence" permissions
|
||||
docs: |-
|
||||
This script resets permissions for File Provider Presence [1].
|
||||
It revokes the ability of File Provider applications to know when the user is accessing their managed files [1], enhancing user privacy.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: FileProviderPresence
|
||||
-
|
||||
name: Clear "Listen Events" permissions
|
||||
docs: |-
|
||||
This script resets "ListenEvent" permissions [1].
|
||||
It revokes application access to listen to system events [1], preventing unauthorized monitoring of user interactions with the system.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: ListenEvent
|
||||
-
|
||||
name: Clear "Media Library" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing the Media Library [1].
|
||||
It ensures that applications cannot access Apple Music, music and video activity, and the media library [1] without user consent.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: MediaLibrary
|
||||
-
|
||||
name: Clear "Post Event" permissions
|
||||
docs: |-
|
||||
This script resets permissions for sending "PostEvent" [1].
|
||||
It prevents applications from using CoreGraphics APIs to send system events [1], safeguarding against potential misuse.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: PostEvent
|
||||
-
|
||||
name: Clear "Speech Recognition" permissions
|
||||
recommend: strict
|
||||
docs: |-
|
||||
This script resets permissions for using Speech Recognition [1].
|
||||
It revokes application access to the speech recognition facility and sending speech data to Apple [1], protecting user privacy.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SpeechRecognition
|
||||
-
|
||||
name: Clear "App Modification" permissions
|
||||
docs: |-
|
||||
This script resets permissions for modifying other apps [1].
|
||||
It prevents applications from updating or deleting other apps [1], maintaining system integrity and user control.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyAppBundles
|
||||
-
|
||||
name: Clear "Application Data" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing application data [1].
|
||||
It revokes application access to specific application data, enhancing privacy and data security.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyAppData
|
||||
-
|
||||
name: Clear "Network Volumes" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing files on network volumes [1].
|
||||
It ensures applications cannot access network files without user authorization.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyNetworkVolumes
|
||||
-
|
||||
name: Clear "Removable Volumes" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing files on removable volumes [1].
|
||||
It protects data on external drives from unauthorized application access.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicyRemovableVolumes
|
||||
-
|
||||
name: Clear "System Administration Files" permissions
|
||||
docs: |-
|
||||
This script resets permissions for accessing system administration files [1].
|
||||
It enhances system security by restricting application access to critical system files.
|
||||
|
||||
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
|
||||
call:
|
||||
function: ResetServicePermissions
|
||||
parameters:
|
||||
serviceId: SystemPolicySysAdminFiles
|
||||
-
|
||||
category: Configure programs
|
||||
children:
|
||||
@@ -1268,3 +1506,55 @@ functions:
|
||||
echo "[$profile_file] No need for any action, configuration does not exist"
|
||||
fi
|
||||
done
|
||||
-
|
||||
name: RunIfCommandExists # Skips if command does not exist
|
||||
# Marked: refactor-with-partials
|
||||
# Same function as Linux
|
||||
parameters:
|
||||
- name: command
|
||||
- name: code
|
||||
- name: revertCode
|
||||
optional: true
|
||||
code: |-
|
||||
if ! command -v '{{ $command }}' &> /dev/null; then
|
||||
echo 'Skipping because "{{ $command }}" is not found.'
|
||||
else
|
||||
{{ $code }}
|
||||
fi
|
||||
revertCode: |-
|
||||
{{ with $revertCode }}
|
||||
if ! command -v '{{ $command }}' &> /dev/null; then
|
||||
>&2 echo 'Cannot revert because "{{ $command }}" is not found.'
|
||||
else
|
||||
{{ . }}
|
||||
fi
|
||||
{{ end }}
|
||||
-
|
||||
name: ResetServicePermissions
|
||||
parameters:
|
||||
- name: serviceId # Specifies the service ID for which to reset permissions
|
||||
docs: |-
|
||||
This function resets the specified service ID permissions.
|
||||
The `serviceId` parameter allows you to define the specific service ID (e.g., Camera, Microphone,
|
||||
Accessibility) for which you want to reset all user-granted permissions.
|
||||
call:
|
||||
function: RunIfCommandExists
|
||||
parameters:
|
||||
command: tccutil
|
||||
code: |-
|
||||
declare serviceId='{{ $serviceId }}'
|
||||
declare reset_output reset_exit_code
|
||||
{
|
||||
reset_output=$(tccutil reset "$serviceId" 2>&1)
|
||||
reset_exit_code=$?
|
||||
}
|
||||
if [ $reset_exit_code -eq 0 ]; then
|
||||
echo "Successfully reset permissions for \"${serviceId}\"."
|
||||
elif [ $reset_exit_code -eq 70 ]; then
|
||||
echo "Skipping, service ID \"${serviceId}\" is not supported on your operating system version."
|
||||
elif [ $reset_exit_code -ne 0 ]; then
|
||||
>&2 echo "Failed to reset permissions for \"${serviceId}\". Exit code: $reset_exit_code."
|
||||
if [ -n "$reset_output" ]; then
|
||||
echo "Output from \`tccutil\`: $reset_output."
|
||||
fi
|
||||
fi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ export class Application implements IApplication {
|
||||
public info: IProjectInformation,
|
||||
public collections: readonly ICategoryCollection[],
|
||||
) {
|
||||
validateInformation(info);
|
||||
validateCollections(collections);
|
||||
}
|
||||
|
||||
@@ -16,19 +15,17 @@ export class Application implements IApplication {
|
||||
return this.collections.map((collection) => collection.os);
|
||||
}
|
||||
|
||||
public getCollection(operatingSystem: OperatingSystem): ICategoryCollection | undefined {
|
||||
return this.collections.find((collection) => collection.os === operatingSystem);
|
||||
}
|
||||
}
|
||||
|
||||
function validateInformation(info: IProjectInformation) {
|
||||
if (!info) {
|
||||
throw new Error('missing project information');
|
||||
public getCollection(operatingSystem: OperatingSystem): ICategoryCollection {
|
||||
const collection = this.collections.find((c) => c.os === operatingSystem);
|
||||
if (!collection) {
|
||||
throw new Error(`Operating system "${OperatingSystem[operatingSystem]}" is not defined in application`);
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCollections(collections: readonly ICategoryCollection[]) {
|
||||
if (!collections || !collections.length) {
|
||||
if (!collections.length) {
|
||||
throw new Error('missing collections');
|
||||
}
|
||||
if (collections.filter((c) => !c).length > 0) {
|
||||
|
||||
@@ -3,14 +3,14 @@ import { IScript } from './IScript';
|
||||
import { ICategory } from './ICategory';
|
||||
|
||||
export class Category extends BaseEntity<number> implements ICategory {
|
||||
private allSubScripts: ReadonlyArray<IScript> = undefined;
|
||||
private allSubScripts?: ReadonlyArray<IScript> = undefined;
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
public readonly name: string,
|
||||
public readonly docs: ReadonlyArray<string>,
|
||||
public readonly subCategories?: ReadonlyArray<ICategory>,
|
||||
public readonly scripts?: ReadonlyArray<IScript>,
|
||||
public readonly subCategories: ReadonlyArray<ICategory>,
|
||||
public readonly scripts: ReadonlyArray<IScript>,
|
||||
) {
|
||||
super(id);
|
||||
validateCategory(this);
|
||||
@@ -39,10 +39,7 @@ function validateCategory(category: ICategory) {
|
||||
if (!category.name) {
|
||||
throw new Error('missing name');
|
||||
}
|
||||
if (
|
||||
(!category.subCategories || category.subCategories.length === 0)
|
||||
&& (!category.scripts || category.scripts.length === 0)
|
||||
) {
|
||||
if (category.subCategories.length === 0 && category.scripts.length === 0) {
|
||||
throw new Error('A category must have at least one sub-category or script');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ export class CategoryCollection implements ICategoryCollection {
|
||||
public readonly actions: ReadonlyArray<ICategory>,
|
||||
public readonly scripting: IScriptingDefinition,
|
||||
) {
|
||||
if (!scripting) {
|
||||
throw new Error('missing scripting definition');
|
||||
}
|
||||
this.queryable = makeQueryable(actions);
|
||||
assertInRange(os, OperatingSystem);
|
||||
ensureValid(this.queryable);
|
||||
@@ -29,17 +26,26 @@ export class CategoryCollection implements ICategoryCollection {
|
||||
ensureNoDuplicates(this.queryable.allScripts);
|
||||
}
|
||||
|
||||
public findCategory(categoryId: number): ICategory | undefined {
|
||||
return this.queryable.allCategories.find((category) => category.id === categoryId);
|
||||
public getCategory(categoryId: number): ICategory {
|
||||
const category = this.queryable.allCategories.find((c) => c.id === categoryId);
|
||||
if (!category) {
|
||||
throw new Error(`Missing category with ID: "${categoryId}"`);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
public getScriptsByLevel(level: RecommendationLevel): readonly IScript[] {
|
||||
assertInRange(level, RecommendationLevel);
|
||||
return this.queryable.scriptsByLevel.get(level);
|
||||
const scripts = this.queryable.scriptsByLevel.get(level);
|
||||
return scripts ?? [];
|
||||
}
|
||||
|
||||
public findScript(scriptId: string): IScript | undefined {
|
||||
return this.queryable.allScripts.find((script) => script.id === scriptId);
|
||||
public getScript(scriptId: string): IScript {
|
||||
const script = this.queryable.allScripts.find((s) => s.id === scriptId);
|
||||
if (!script) {
|
||||
throw new Error(`missing script: ${scriptId}`);
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
public getAllScripts(): IScript[] {
|
||||
@@ -78,13 +84,13 @@ function ensureValid(application: IQueryableCollection) {
|
||||
}
|
||||
|
||||
function ensureValidCategories(allCategories: readonly ICategory[]) {
|
||||
if (!allCategories || allCategories.length === 0) {
|
||||
if (!allCategories.length) {
|
||||
throw new Error('must consist of at least one category');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureValidScripts(allScripts: readonly IScript[]) {
|
||||
if (!allScripts || allScripts.length === 0) {
|
||||
if (!allScripts.length) {
|
||||
throw new Error('must consist of at least one script');
|
||||
}
|
||||
const missingRecommendationLevels = getEnumValues(RecommendationLevel)
|
||||
|
||||
@@ -7,5 +7,5 @@ export interface IApplication {
|
||||
readonly collections: readonly ICategoryCollection[];
|
||||
|
||||
getSupportedOsList(): OperatingSystem[];
|
||||
getCollection(operatingSystem: OperatingSystem): ICategoryCollection | undefined;
|
||||
getCollection(operatingSystem: OperatingSystem): ICategoryCollection;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { IDocumentable } from './IDocumentable';
|
||||
export interface ICategory extends IEntity<number>, IDocumentable {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly subCategories?: ReadonlyArray<ICategory>;
|
||||
readonly scripts?: ReadonlyArray<IScript>;
|
||||
readonly subCategories: ReadonlyArray<ICategory>;
|
||||
readonly scripts: ReadonlyArray<IScript>;
|
||||
includes(script: IScript): boolean;
|
||||
getAllScriptsRecursively(): ReadonlyArray<IScript>;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface ICategoryCollection {
|
||||
readonly actions: ReadonlyArray<ICategory>;
|
||||
|
||||
getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<IScript>;
|
||||
findCategory(categoryId: number): ICategory | undefined;
|
||||
findScript(scriptId: string): IScript | undefined;
|
||||
getCategory(categoryId: number): ICategory;
|
||||
getScript(scriptId: string): IScript;
|
||||
getAllScripts(): ReadonlyArray<IScript>;
|
||||
getAllCategories(): ReadonlyArray<ICategory>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface IScriptCode {
|
||||
readonly execute: string;
|
||||
readonly revert: string;
|
||||
readonly revert?: string;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,34 @@ export enum OperatingSystem {
|
||||
Linux,
|
||||
KaiOS,
|
||||
ChromeOS,
|
||||
BlackBerryOS,
|
||||
BlackBerry,
|
||||
BlackBerryTabletOS,
|
||||
Android,
|
||||
iOS,
|
||||
iPadOS,
|
||||
|
||||
/**
|
||||
* Legacy: Released in 1999, discontinued in 2013, succeeded by BlackBerry10.
|
||||
*/
|
||||
BlackBerryOS,
|
||||
|
||||
/**
|
||||
* Legacy: Released in 2013, discontinued in 2015, succeeded by {@link OperatingSystem.Android}.
|
||||
*/
|
||||
BlackBerry10,
|
||||
|
||||
/**
|
||||
* Legacy: Released in 2010, discontinued in 2017,
|
||||
* succeeded by {@link OperatingSystem.Windows10Mobile}.
|
||||
*/
|
||||
WindowsPhone,
|
||||
|
||||
/**
|
||||
* Legacy: Released in 2015, discontinued in 2017, succeeded by {@link OperatingSystem.Android}.
|
||||
*/
|
||||
Windows10Mobile,
|
||||
|
||||
/**
|
||||
* Also known as "BlackBerry PlayBook OS"
|
||||
* Legacy: Released in 2011, discontinued in 2014, succeeded by {@link OperatingSystem.Android}.
|
||||
*/
|
||||
BlackBerryTabletOS,
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ export class ProjectInformation implements IProjectInformation {
|
||||
if (!name) {
|
||||
throw new Error('name is undefined');
|
||||
}
|
||||
if (!version) {
|
||||
throw new Error('undefined version');
|
||||
}
|
||||
if (!slogan) {
|
||||
throw new Error('undefined slogan');
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ export class Script extends BaseEntity<string> implements IScript {
|
||||
public readonly level?: RecommendationLevel,
|
||||
) {
|
||||
super(name);
|
||||
if (!code) {
|
||||
throw new Error('missing code');
|
||||
}
|
||||
validateLevel(level);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import { IScriptCode } from './IScriptCode';
|
||||
export class ScriptCode implements IScriptCode {
|
||||
constructor(
|
||||
public readonly execute: string,
|
||||
public readonly revert: string,
|
||||
public readonly revert: string | undefined,
|
||||
) {
|
||||
validateCode(execute);
|
||||
validateRevertCode(revert, execute);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRevertCode(revertCode: string, execute: string) {
|
||||
function validateRevertCode(revertCode: string | undefined, execute: string) {
|
||||
if (!revertCode) {
|
||||
return;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ function validateRevertCode(revertCode: string, execute: string) {
|
||||
}
|
||||
|
||||
function validateCode(code: string): void {
|
||||
if (!code || code.length === 0) {
|
||||
if (code.length === 0) {
|
||||
throw new Error('missing code');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user