Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d328f08952 | ||
|
|
bcad357017 | ||
|
|
9845a7cd68 | ||
|
|
7c632f7388 | ||
|
|
1442f62633 | ||
|
|
7f7a84e3ba | ||
|
|
dee3279f85 | ||
|
|
094dbb01b8 | ||
|
|
e299d40fa1 | ||
|
|
cb42f11b97 | ||
|
|
4531645b4c | ||
|
|
bf3426f91b | ||
|
|
3864f04218 | ||
|
|
e541a35e86 | ||
|
|
bd383ed273 | ||
|
|
949fac1a7c | ||
|
|
7ab16ecccb | ||
|
|
58cd551a30 | ||
|
|
7770a9b521 | ||
|
|
aab0f7ea46 | ||
|
|
ea41f4f503 | ||
|
|
af7219f6e1 | ||
|
|
8ccaec7af6 | ||
|
|
b2ffc90da7 | ||
|
|
72e4d0b896 | ||
|
|
5bb13e34f8 | ||
|
|
0466b86f10 | ||
|
|
ca81f68ff1 | ||
|
|
4995e49c46 | ||
|
|
77123d8c92 | ||
|
|
e72c1c13ea | ||
|
|
e775d68a9b | ||
|
|
f8e5f1a5a2 | ||
|
|
f4a74f058d | ||
|
|
80821fca07 | ||
|
|
dfd4451561 | ||
|
|
8570b02dde | ||
|
|
d6da406c61 | ||
|
|
060e789662 | ||
|
|
e40b9a3cf5 | ||
|
|
237d9944f9 |
@@ -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 }}
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,5 +1,13 @@
|
||||
node_modules
|
||||
# Application build artifacts
|
||||
/dist-*/
|
||||
.vs
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/**/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# draw.io
|
||||
*.bkp
|
||||
*.dtmp
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
* win: improve system app uninstall cleanup #73 | [dbe3c5c](https://github.com/undergroundwires/privacy.sexy/commit/dbe3c5cfb91ba8a1657838b69117858843c8fbc8)
|
||||
* win: improve system app uninstall /w fallback #260 | [98a26f9](https://github.com/undergroundwires/privacy.sexy/commit/98a26f9ae47af2668aa53f39d1768983036048ce)
|
||||
* Improve performance of rendering during search | [79b46bf](https://github.com/undergroundwires/privacy.sexy/commit/79b46bf21004d96d31551439e5db5d698a3f71f3)
|
||||
* Fix YAML error for site release in CI/CD | [237d994](https://github.com/undergroundwires/privacy.sexy/commit/237d9944f900f5172366868d75219224ff0542b0)
|
||||
* win: fix Microsoft Advertising app removal #200 | [e40b9a3](https://github.com/undergroundwires/privacy.sexy/commit/e40b9a3cf53c341f2e84023a9f0e9680ac08f3fa)
|
||||
* win: improve directory cleanup security | [060e789](https://github.com/undergroundwires/privacy.sexy/commit/060e7896624309aebd25e8b190c127282de177e8)
|
||||
* Centralize Electron entry file path configuration | [d6da406](https://github.com/undergroundwires/privacy.sexy/commit/d6da406c61e5b9f5408851d1302d6d7398157a2e)
|
||||
* win: prevent updates from reinstalling apps #260 | [8570b02](https://github.com/undergroundwires/privacy.sexy/commit/8570b02dde14ffad64863f614682c3fc1f87b6c2)
|
||||
* win: improve script environment robustness #221 | [dfd4451](https://github.com/undergroundwires/privacy.sexy/commit/dfd44515613f38abe5a806bda36f44e7b715b50b)
|
||||
* Fix compiler failing with nested `with` expression | [80821fc](https://github.com/undergroundwires/privacy.sexy/commit/80821fca0769e5fd2c6338918fbdcea12fbe83d2)
|
||||
* win: improve soft file/app delete security #260 | [f4a74f0](https://github.com/undergroundwires/privacy.sexy/commit/f4a74f058db9b5bcbcbe438785db5ec88ecc1657)
|
||||
* Fix incorrect tooltip position after window resize | [f8e5f1a](https://github.com/undergroundwires/privacy.sexy/commit/f8e5f1a5a2afa1f18567e6d965359b6a1f082367)
|
||||
* linux: fix string formatting of Firefox configs | [e775d68](https://github.com/undergroundwires/privacy.sexy/commit/e775d68a9b4a5f9e893ff0e3500dade036185193)
|
||||
* win: improve file delete | [e72c1c1](https://github.com/undergroundwires/privacy.sexy/commit/e72c1c13ea2d73ebfc7a8da5a21254fdfc0e5b59)
|
||||
* win: change system app removal to hard delete #260 | [77123d8](https://github.com/undergroundwires/privacy.sexy/commit/77123d8c929d23676a9cb21d7b697703fd1b6e82)
|
||||
* Improve UI performance by optimizing reactivity | [4995e49](https://github.com/undergroundwires/privacy.sexy/commit/4995e49c469211404dac9fcb79b75eb121f80bce)
|
||||
* Migrate to Vue 3.0 #230 | [ca81f68](https://github.com/undergroundwires/privacy.sexy/commit/ca81f68ff1c3bbe5b22981096ae9220b0b5851c7)
|
||||
* win, linux: unify & improve Firefox clean-up #273 | [0466b86](https://github.com/undergroundwires/privacy.sexy/commit/0466b86f1013341c966a9bbf6513990337b16598)
|
||||
* win: fix store revert for multiple installs #260 | [5bb13e3](https://github.com/undergroundwires/privacy.sexy/commit/5bb13e34f8de2e2a7ba943ff72b12c0569435e62)
|
||||
|
||||
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.5...0.12.6)
|
||||
|
||||
## 0.12.5 (2023-10-13)
|
||||
|
||||
* Fix Docker build and improve checks #220 | [7669985](https://github.com/undergroundwires/privacy.sexy/commit/7669985f8e1446e726a95626ecf35b3ce6b60a16)
|
||||
|
||||
@@ -43,6 +43,7 @@ You have two alternatives:
|
||||
|
||||
1. [Create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) and ask for someone else to add the script for you.
|
||||
2. Or send a PR yourself. This would make it faster to get your code into the project. You need to add scripts to related OS in [collections](src/application/collections/) folder. Then you'd sent a pull request, see [pull request process](#pull-request-process).
|
||||
- 💡 You should use existing shared functions for most of the operations, like `DisableService` for disabling services, to maintain code consistency and efficiency.
|
||||
- 📖 If you're unsure about the syntax, check [collection-files.md](docs/collection-files.md).
|
||||
- 📖 If you wish to use templates, use [templating.md](./docs/templating.md).
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -122,7 +122,7 @@
|
||||
## Get started
|
||||
|
||||
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
|
||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-Setup-0.12.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.AppImage). For more options, see [here](#additional-install-options).
|
||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-Setup-0.12.7.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-0.12.7.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-0.12.7.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.
|
||||
|
||||
@@ -153,12 +153,21 @@ Online version does not require to run any software on your computer. Offline ve
|
||||
## Additional Install Options
|
||||
|
||||
- Check the [releases page](https://github.com/undergroundwires/privacy.sexy/releases) for all available versions.
|
||||
- Using [Scoop](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) package manager on Windows:
|
||||
- Other unofficial channels (not maintained by privacy.sexy) for Windows include:
|
||||
- [Scoop 🥄](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) (latest version):
|
||||
|
||||
```powershell
|
||||
scoop bucket add extras
|
||||
scoop install privacy.sexy
|
||||
```
|
||||
```powershell
|
||||
scoop bucket add extras
|
||||
scoop install privacy.sexy
|
||||
```
|
||||
|
||||
- [winget 🪟](https://winget.run/pkg/undergroundwires/privacy.sexy) (may be outdated):
|
||||
|
||||
```powershell
|
||||
winget install -e --id undergroundwires.privacy.sexy
|
||||
```
|
||||
|
||||
With winget, updates require manual submission; the auto-update feature within privacy.sexy will notify you of new releases post-installation.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,79 +2,142 @@
|
||||
|
||||
## Benefits of templating
|
||||
|
||||
- Generating scripts by sharing code to increase best-practice usage and maintainability.
|
||||
- Creating self-contained scripts without cross-dependencies.
|
||||
- Use of pipes for writing cleaner code and letting pipes do dirty work.
|
||||
- **Code sharing:** Share code across scripts for consistent practices and easier maintenance.
|
||||
- **Script independence:** Generate self-contained scripts, eliminating the need for external code.
|
||||
- **Cleaner code:** Use pipes for complex operations, resulting in more readable and streamlined code.
|
||||
|
||||
## Expressions
|
||||
|
||||
- Expressions start and end with mustaches (double brackets, `{{` and `}}`).
|
||||
- E.g. `Hello {{ $name }} !`
|
||||
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same.
|
||||
- Functions enables usage of expressions.
|
||||
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
|
||||
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
|
||||
- Expressions inside expressions (nested templates) are supported.
|
||||
- An expression can output another expression that will also be compiled.
|
||||
- E.g. following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output.
|
||||
**Syntax:**
|
||||
|
||||
```go
|
||||
{{ with $condition }}
|
||||
echo {{ $text }}
|
||||
{{ end }}
|
||||
```
|
||||
Expressions are enclosed within `{{` and `}}`.
|
||||
Example: `Hello {{ $name }}!`.
|
||||
They are a core component of templating, enhancing scripts with dynamic capabilities and functionality.
|
||||
|
||||
**Syntax similarity:**
|
||||
|
||||
The syntax shares similarities with [Go Templates ❤️](https://pkg.go.dev/text/template), but with some differences:
|
||||
|
||||
**Function definitions:**
|
||||
|
||||
You can use expressions in function definition.
|
||||
Refer to [Function](./collection-files.md#function) for more details.
|
||||
|
||||
Example usage:
|
||||
|
||||
```yaml
|
||||
name: GreetFunction
|
||||
parameters:
|
||||
- name: name
|
||||
code: Hello {{ $name }}!
|
||||
```
|
||||
|
||||
If you assign `name` the value `world`, invoking `GreetFunction` would result in `Hello world!`.
|
||||
|
||||
**Function arguments:**
|
||||
|
||||
You can also use expressions in arguments in nested function calls.
|
||||
Refer to [`Function | collection-files.md`](./collection-files.md#functioncall) for more details.
|
||||
|
||||
Example with nested function calls:
|
||||
|
||||
```yaml
|
||||
-
|
||||
name: PrintMessageFunction
|
||||
parameters:
|
||||
- name: message
|
||||
code: echo "{{ $message }}"
|
||||
-
|
||||
name: GreetUserFunction
|
||||
parameters:
|
||||
- name: userName
|
||||
call:
|
||||
name: PrintMessageFunction
|
||||
parameters:
|
||||
argument: 'Hello, {{ $userName }}!'
|
||||
```
|
||||
|
||||
Here, if `userName` is `Alice`, invoking `GreetUserFunction` would execute `echo "Hello, Alice!"`.
|
||||
|
||||
**Nested templates:**
|
||||
|
||||
You can nest expressions inside expressions (also called "nested templates").
|
||||
This means that an expression can output another expression where compiler will compile both.
|
||||
|
||||
For example, following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output:
|
||||
|
||||
```go
|
||||
{{ with $condition }}
|
||||
echo {{ $text }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Parameter substitution
|
||||
|
||||
A simple function example:
|
||||
Parameter substitution dynamically replaces variable references with their corresponding values in the script.
|
||||
|
||||
**Example function:**
|
||||
|
||||
```yaml
|
||||
function: EchoArgument
|
||||
name: DisplayTextFunction
|
||||
parameters:
|
||||
- name: 'argument'
|
||||
code: Hello {{ $argument }} !
|
||||
- name: 'text'
|
||||
code: echo {{ $text }}
|
||||
```
|
||||
|
||||
It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following:
|
||||
|
||||
```yaml
|
||||
script: Echo script
|
||||
call:
|
||||
function: EchoArgument
|
||||
parameters:
|
||||
argument: World
|
||||
```
|
||||
|
||||
A function can call other functions such as:
|
||||
|
||||
```yaml
|
||||
-
|
||||
function: CallerFunction
|
||||
parameters:
|
||||
- name: 'value'
|
||||
call:
|
||||
function: EchoArgument
|
||||
parameters:
|
||||
argument: {{ $value }}
|
||||
-
|
||||
function: EchoArgument
|
||||
parameters:
|
||||
- name: 'argument'
|
||||
code: Hello {{ $argument }} !
|
||||
```
|
||||
Invoking `DisplayTextFunction` with `text` set to `"Hello, world!"` would result in `echo "Hello, World!"`.
|
||||
|
||||
### with
|
||||
|
||||
Skips its "block" if the variable is absent or empty. Its "block" is between `with` start (`{{ with .. }}`) and end (`{{ end }`}) expressions.
|
||||
E.g. `{{ with $parameterName }} Hi, I'm a block! {{ end }}` would only output `Hi, I'm a block!` if `parameterName` has any value..
|
||||
The `with` expression enables conditional rendering and provides a context variable for simpler code.
|
||||
|
||||
It binds its context (value of the provided parameter value) as arbitrary `.` value. It allows you to use the argument value of the given parameter when it is provided and not empty such as:
|
||||
**Optional block rendering:**
|
||||
|
||||
If the provided variable is falsy (`false`, `null`, or empty), the compiler skips the enclosed block of code.
|
||||
A "block" lies between the with start (`{{ with .. }}`) and end (`{{ end }}`) expressions, defining its boundaries.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
{{ with $optionalVariable }}
|
||||
Hello
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
This would display `Hello` if `$optionalVariable` is truthy.
|
||||
|
||||
**Parameter declaration:**
|
||||
|
||||
You should set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
|
||||
|
||||
Declare parameters used for `with` condition as optional such as:
|
||||
|
||||
```yaml
|
||||
name: ConditionalOutputFunction
|
||||
parameters:
|
||||
- name: 'data'
|
||||
optional: true
|
||||
code: |-
|
||||
{{ with $data }}
|
||||
Data is: {{ . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
**Context variable:**
|
||||
|
||||
`with` statement binds its context (value of the provided parameter value) as arbitrary `.` value.
|
||||
`{{ . }}` syntax gives you access to the context variable.
|
||||
This is optional to use, and not required to use `with` expressions.
|
||||
|
||||
For example:
|
||||
|
||||
```go
|
||||
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }}
|
||||
```
|
||||
|
||||
It supports multiline text inside the block. You can have something like:
|
||||
**Multiline text:**
|
||||
|
||||
It supports multiline text inside the block. You can write something like:
|
||||
|
||||
```go
|
||||
{{ with $argument }}
|
||||
@@ -83,7 +146,9 @@ It supports multiline text inside the block. You can have something like:
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
|
||||
**Inner expressions:**
|
||||
|
||||
You can also embed other expressions inside its block, such as [parameter substitution](#parameter-substitution):
|
||||
|
||||
```go
|
||||
{{ with $condition }}
|
||||
@@ -91,32 +156,44 @@ You can also use other expressions inside its block, such as [parameter substitu
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
|
||||
This also includes nesting `with` statements:
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
function: FunctionThatOutputsConditionally
|
||||
parameters:
|
||||
- name: 'argument'
|
||||
optional: true
|
||||
code: |-
|
||||
{{ with $argument }}
|
||||
Value is: {{ . }}
|
||||
```go
|
||||
{{ with $condition1 }}
|
||||
Value of $condition1: {{ . }}
|
||||
{{ with $condition2 }}
|
||||
Value of $condition2: {{ . }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Pipes
|
||||
|
||||
- Pipes are functions available for handling text.
|
||||
- Allows stacking actions one after another also known as "chaining".
|
||||
- Like [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), the concept is simple: each pipeline's output becomes the input of the following pipe.
|
||||
- You cannot create pipes. [A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
|
||||
- You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
|
||||
- ❗ Pipe names must be camelCase without any space or special characters.
|
||||
- **Existing pipes**
|
||||
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
|
||||
- `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`).
|
||||
- **Example usages**
|
||||
- `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
|
||||
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`
|
||||
Pipes are functions designed for text manipulation.
|
||||
They allow for a sequential application of operations resembling [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), also known as "chaining".
|
||||
Each pipeline's output becomes the input of the following pipe.
|
||||
|
||||
**Pre-defined**:
|
||||
|
||||
Pipes are pre-defined by the system.
|
||||
You cannot create pipes in [collection files](./collection-files.md).
|
||||
[A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
|
||||
|
||||
**Compatibility:**
|
||||
|
||||
You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
|
||||
|
||||
For example:
|
||||
|
||||
```go
|
||||
{{ with $script }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}
|
||||
```
|
||||
|
||||
**Naming:**
|
||||
|
||||
❗ Pipe names must be camelCase without any space or special characters.
|
||||
|
||||
**Available pipes:**
|
||||
|
||||
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
|
||||
- `escapeDoubleQuotes`: Escapes `"` characters for batch command execution, allows you to use them inside double quotes (`"`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,15 +8,21 @@ import distDirs from './dist-dirs.json' assert { type: 'json' };
|
||||
const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
|
||||
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
|
||||
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html');
|
||||
const DIST_DIR = resolvePathFromProjectRoot(distDirs.electronUnbundled);
|
||||
const ELECTRON_DIST_SUBDIRECTORIES = {
|
||||
main: resolveElectronDistSubdirectory('main'),
|
||||
preload: resolveElectronDistSubdirectory('preload'),
|
||||
renderer: resolveElectronDistSubdirectory('renderer'),
|
||||
};
|
||||
|
||||
process.env.ELECTRON_ENTRY = resolve(ELECTRON_DIST_SUBDIRECTORIES.main, 'index.cjs');
|
||||
|
||||
export default defineConfig({
|
||||
main: getSharedElectronConfig({
|
||||
distDirSubfolder: 'main',
|
||||
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.main,
|
||||
entryFilePath: MAIN_ENTRY_FILE,
|
||||
}),
|
||||
preload: getSharedElectronConfig({
|
||||
distDirSubfolder: 'preload',
|
||||
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.preload,
|
||||
entryFilePath: PRELOAD_ENTRY_FILE,
|
||||
}),
|
||||
renderer: mergeConfig(
|
||||
@@ -25,7 +31,7 @@ export default defineConfig({
|
||||
}),
|
||||
{
|
||||
build: {
|
||||
outDir: resolve(DIST_DIR, 'renderer'),
|
||||
outDir: ELECTRON_DIST_SUBDIRECTORIES.renderer,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: WEB_INDEX_HTML_PATH,
|
||||
@@ -42,7 +48,7 @@ function getSharedElectronConfig(options: {
|
||||
}): UserConfig {
|
||||
return {
|
||||
build: {
|
||||
outDir: resolve(DIST_DIR, options.distDirSubfolder),
|
||||
outDir: options.distDirSubfolder,
|
||||
lib: {
|
||||
entry: options.entryFilePath,
|
||||
},
|
||||
@@ -64,6 +70,11 @@ function getSharedElectronConfig(options: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePathFromProjectRoot(pathSegment: string) {
|
||||
function resolvePathFromProjectRoot(pathSegment: string): string {
|
||||
return resolve(__dirname, pathSegment);
|
||||
}
|
||||
|
||||
function resolveElectronDistSubdirectory(subDirectory: string): string {
|
||||
const electronDistDir = resolvePathFromProjectRoot(distDirs.electronUnbundled);
|
||||
return resolve(electronDistDir, subDirectory);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
1185
package-lock.json
generated
1185
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.7",
|
||||
"private": true,
|
||||
"slogan": "Now you have the choice",
|
||||
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
|
||||
"author": "undergroundwires",
|
||||
"type": "module",
|
||||
"main": "./dist-electron-unbundled/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
@@ -25,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",
|
||||
@@ -43,7 +42,7 @@
|
||||
"electron-updater": "^6.1.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"markdown-it": "^13.0.2",
|
||||
"vue": "^2.7.14"
|
||||
"vue": "^3.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||
@@ -53,17 +52,17 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitejs/plugin-legacy": "^4.1.1",
|
||||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cypress": "^13.3.1",
|
||||
"electron": "^27.0.0",
|
||||
"electron-builder": "^24.6.4",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^1.0.27",
|
||||
"electron-vite": "^1.0.28",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
@@ -98,5 +97,11 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/undergroundwires/privacy.sexy.git"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"dmg-license": "^1.0.11"
|
||||
},
|
||||
"//optionalDependencies": {
|
||||
"dmg-license": "Required by `electron-builder` for DMG builds on macOS, https://github.com/electron-userland/electron-builder/issues/6489, https://github.com/electron-userland/electron-builder/issues/6520"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -14,45 +14,44 @@ export class ExpressionRegexBuilder {
|
||||
.addRawRegex('\\s+');
|
||||
}
|
||||
|
||||
public matchPipeline() {
|
||||
public captureOptionalPipeline() {
|
||||
return this
|
||||
.expectZeroOrMoreWhitespaces()
|
||||
.addRawRegex('(\\|\\s*.+?)?');
|
||||
.addRawRegex('((?:\\|\\s*\\b[a-zA-Z]+\\b\\s*)*)');
|
||||
}
|
||||
|
||||
public matchUntilFirstWhitespace() {
|
||||
public captureUntilWhitespaceOrPipe() {
|
||||
return this
|
||||
.addRawRegex('([^|\\s]+)');
|
||||
}
|
||||
|
||||
public matchMultilineAnythingExceptSurroundingWhitespaces() {
|
||||
public captureMultilineAnythingExceptSurroundingWhitespaces() {
|
||||
return this
|
||||
.expectZeroOrMoreWhitespaces()
|
||||
.addRawRegex('([\\S\\s]+?)')
|
||||
.expectZeroOrMoreWhitespaces();
|
||||
.expectOptionalWhitespaces()
|
||||
.addRawRegex('([\\s\\S]*\\S)')
|
||||
.expectOptionalWhitespaces();
|
||||
}
|
||||
|
||||
public expectExpressionStart() {
|
||||
return this
|
||||
.expectCharacters('{{')
|
||||
.expectZeroOrMoreWhitespaces();
|
||||
.expectOptionalWhitespaces();
|
||||
}
|
||||
|
||||
public expectExpressionEnd() {
|
||||
return this
|
||||
.expectZeroOrMoreWhitespaces()
|
||||
.expectOptionalWhitespaces()
|
||||
.expectCharacters('}}');
|
||||
}
|
||||
|
||||
public expectOptionalWhitespaces() {
|
||||
return this
|
||||
.addRawRegex('\\s*');
|
||||
}
|
||||
|
||||
public buildRegExp(): RegExp {
|
||||
return new RegExp(this.parts.join(''), 'g');
|
||||
}
|
||||
|
||||
private expectZeroOrMoreWhitespaces() {
|
||||
return this
|
||||
.addRawRegex('\\s*');
|
||||
}
|
||||
|
||||
private addRawRegex(regex: string) {
|
||||
this.parts.push(regex);
|
||||
return this;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,8 +6,9 @@ export class ParameterSubstitutionParser extends RegexParser {
|
||||
protected readonly regex = new ExpressionRegexBuilder()
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('$')
|
||||
.matchUntilFirstWhitespace() // First match: Parameter name
|
||||
.matchPipeline() // Second match: Pipeline
|
||||
.captureUntilWhitespaceOrPipe() // First capture: Parameter name
|
||||
.expectOptionalWhitespaces()
|
||||
.captureOptionalPipeline() // Second capture: Pipeline
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
|
||||
@@ -1,59 +1,220 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
|
||||
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
|
||||
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
|
||||
import { IExpression } from '../Expression/IExpression';
|
||||
import { ExpressionPosition } from '../Expression/ExpressionPosition';
|
||||
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
|
||||
import { createPositionFromRegexFullMatch } from '../Expression/ExpressionPositionFactory';
|
||||
|
||||
export class WithParser extends RegexParser {
|
||||
protected readonly regex = new ExpressionRegexBuilder()
|
||||
// {{ with $parameterName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('with')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('$')
|
||||
.matchUntilFirstWhitespace() // First match: parameter name
|
||||
.expectExpressionEnd()
|
||||
// ...
|
||||
.matchMultilineAnythingExceptSurroundingWhitespaces() // Second match: Scope text
|
||||
// {{ end }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('end')
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
export class WithParser implements IExpressionParser {
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
if (!code) {
|
||||
throw new Error('missing code');
|
||||
}
|
||||
return parseWithExpressions(code);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
|
||||
const parameterName = match[1];
|
||||
const scopeText = match[2];
|
||||
enum WithStatementType {
|
||||
Start,
|
||||
End,
|
||||
ContextVariable,
|
||||
}
|
||||
|
||||
type WithStatement = {
|
||||
readonly type: WithStatementType.Start;
|
||||
readonly parameterName: string;
|
||||
readonly position: ExpressionPosition;
|
||||
} | {
|
||||
readonly type: WithStatementType.End;
|
||||
readonly position: ExpressionPosition;
|
||||
} | {
|
||||
readonly type: WithStatementType.ContextVariable;
|
||||
readonly position: ExpressionPosition;
|
||||
readonly pipeline: string | undefined;
|
||||
};
|
||||
|
||||
function parseAllWithExpressions(
|
||||
input: string,
|
||||
): WithStatement[] {
|
||||
const expressions = new Array<WithStatement>();
|
||||
for (const match of input.matchAll(WithStatementStartRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.Start,
|
||||
parameterName: match[1],
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(WithStatementEndRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.End,
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.ContextVariable,
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
pipeline: match[1],
|
||||
});
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
class WithStatementBuilder {
|
||||
private readonly contextVariables = new Array<{
|
||||
readonly positionInScope: ExpressionPosition;
|
||||
readonly pipeline: string | undefined;
|
||||
}>();
|
||||
|
||||
public addContextVariable(
|
||||
absolutePosition: ExpressionPosition,
|
||||
pipeline: string | undefined,
|
||||
): void {
|
||||
const positionInScope = new ExpressionPosition(
|
||||
absolutePosition.start - this.startExpressionPosition.end,
|
||||
absolutePosition.end - this.startExpressionPosition.end,
|
||||
);
|
||||
this.contextVariables.push({
|
||||
positionInScope,
|
||||
pipeline,
|
||||
});
|
||||
}
|
||||
|
||||
public buildExpression(endExpressionPosition: ExpressionPosition, input: string): IExpression {
|
||||
const parameters = new FunctionParameterCollection();
|
||||
parameters.addParameter(new FunctionParameter(this.parameterName, true));
|
||||
const position = new ExpressionPosition(
|
||||
this.startExpressionPosition.start,
|
||||
endExpressionPosition.end,
|
||||
);
|
||||
const scope = input.substring(this.startExpressionPosition.end, endExpressionPosition.start);
|
||||
return {
|
||||
parameters: [new FunctionParameter(parameterName, true)],
|
||||
evaluator: (context) => {
|
||||
const argumentValue = context.args.hasArgument(parameterName)
|
||||
? context.args.getArgument(parameterName).argumentValue
|
||||
parameters,
|
||||
position,
|
||||
evaluate: (context) => {
|
||||
const argumentValue = context.args.hasArgument(this.parameterName)
|
||||
? context.args.getArgument(this.parameterName).argumentValue
|
||||
: undefined;
|
||||
if (!argumentValue) {
|
||||
return '';
|
||||
}
|
||||
return replaceEachScopeSubstitution(scopeText, (pipeline) => {
|
||||
const substitutedScope = this.substituteContextVariables(scope, (pipeline) => {
|
||||
if (!pipeline) {
|
||||
return argumentValue;
|
||||
}
|
||||
return context.pipelineCompiler.compile(argumentValue, pipeline);
|
||||
});
|
||||
return substitutedScope;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly startExpressionPosition: ExpressionPosition,
|
||||
private readonly parameterName: string,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
private substituteContextVariables(
|
||||
scope: string,
|
||||
substituter: (pipeline?: string) => string,
|
||||
): string {
|
||||
if (!this.contextVariables.length) {
|
||||
return scope;
|
||||
}
|
||||
let substitutedScope = '';
|
||||
let scopeSubstrIndex = 0;
|
||||
for (const contextVariable of this.contextVariables) {
|
||||
substitutedScope += scope.substring(scopeSubstrIndex, contextVariable.positionInScope.start);
|
||||
substitutedScope += substituter(contextVariable.pipeline);
|
||||
scopeSubstrIndex = contextVariable.positionInScope.end;
|
||||
}
|
||||
substitutedScope += scope.substring(scopeSubstrIndex, scope.length);
|
||||
return substitutedScope;
|
||||
}
|
||||
}
|
||||
|
||||
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
|
||||
function buildErrorContext(code: string, statements: readonly WithStatement[]): string {
|
||||
const formattedStatements = statements.map((s) => `- [${s.position.start}, ${s.position.end}] ${WithStatementType[s.type]}`).join('\n');
|
||||
return [
|
||||
'Code:', '---', code, '---',
|
||||
'nStatements:', '---', formattedStatements, '---',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseWithExpressions(input: string): IExpression[] {
|
||||
const allStatements = parseAllWithExpressions(input);
|
||||
const sortedStatements = allStatements
|
||||
.slice()
|
||||
.sort((a, b) => b.position.start - a.position.start);
|
||||
const expressions = new Array<IExpression>();
|
||||
const builders = new Array<WithStatementBuilder>();
|
||||
const throwWithContext = (message: string): never => {
|
||||
throw new Error(`${message}\n${buildErrorContext(input, allStatements)}}`);
|
||||
};
|
||||
while (sortedStatements.length > 0) {
|
||||
const statement = sortedStatements.pop();
|
||||
if (!statement) {
|
||||
break;
|
||||
}
|
||||
switch (statement.type) { // eslint-disable-line default-case
|
||||
case WithStatementType.Start:
|
||||
builders.push(new WithStatementBuilder(
|
||||
statement.position,
|
||||
statement.parameterName,
|
||||
));
|
||||
break;
|
||||
case WithStatementType.ContextVariable:
|
||||
if (builders.length === 0) {
|
||||
throwWithContext('Context variable before `with` statement.');
|
||||
}
|
||||
builders[builders.length - 1].addContextVariable(statement.position, statement.pipeline);
|
||||
break;
|
||||
case WithStatementType.End: {
|
||||
const builder = builders.pop();
|
||||
if (!builder) {
|
||||
throwWithContext('Redundant `end` statement, missing `with`?');
|
||||
break;
|
||||
}
|
||||
expressions.push(builder.buildExpression(statement.position, input));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (builders.length > 0) {
|
||||
throwWithContext('Missing `end` statement, forgot `{{ end }}?');
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
const ContextVariableWithPipelineRegEx = new ExpressionRegexBuilder()
|
||||
// {{ . | pipeName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('.')
|
||||
.matchPipeline() // First match: pipeline
|
||||
.expectOptionalWhitespaces()
|
||||
.captureOptionalPipeline() // First capture: pipeline
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
|
||||
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets,
|
||||
// but instead letting the pipeline compiler to fail on those.
|
||||
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => {
|
||||
return replacer(match1);
|
||||
});
|
||||
}
|
||||
const WithStatementStartRegEx = new ExpressionRegexBuilder()
|
||||
// {{ with $parameterName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('with')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('$')
|
||||
.captureUntilWhitespaceOrPipe() // First capture: parameter name
|
||||
.expectExpressionEnd()
|
||||
.expectOptionalWhitespaces()
|
||||
.buildRegExp();
|
||||
|
||||
const WithStatementEndRegEx = new ExpressionRegexBuilder()
|
||||
// {{ end }}
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('end')
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -707,15 +707,9 @@ actions:
|
||||
-
|
||||
category: Clear Firefox history
|
||||
docs: |-
|
||||
Mozilla Firefox, or simply Firefox, is a free and open-source web browser developed by the Mozilla Foundation and
|
||||
its subsidiary the Mozilla Corporation [1].
|
||||
This category encompasses a series of scripts aimed at helping users manage and delete their browsing history and related data in Mozilla Firefox.
|
||||
|
||||
Firefox stores user-related data in user profiles [2].
|
||||
|
||||
See also [the Firefox homepage](https://web.archive.org/web/20221029214632/https://www.mozilla.org/en-US/firefox/).
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145113/https://en.wikipedia.org/wiki/Firefox "Firefox | Wikipedia | en.wikipedia.org"
|
||||
[2]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
The scripts are designed to target different aspects of user data stored by Firefox, providing users options for maintaining privacy and freeing up disk space.
|
||||
children:
|
||||
-
|
||||
name: Clear Firefox cache
|
||||
@@ -755,9 +749,13 @@ actions:
|
||||
# Snap installation
|
||||
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: crashes/
|
||||
pathGlob: crashes/*
|
||||
-
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
pathGlob: crashes/events/*
|
||||
-
|
||||
name: Clear Firefox cookies
|
||||
docs: |-
|
||||
@@ -765,41 +763,37 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: cookies.sqlite
|
||||
pathGlob: cookies.sqlite
|
||||
-
|
||||
name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.)
|
||||
# This script (name, documentation and code) is same in Linux and Windows collections.
|
||||
# Changes should be done at both places.
|
||||
# Marked: refactor-with-partials
|
||||
docs: |-
|
||||
The file "places.sqlite" stores the annotations, bookmarks, favorite icons, input history, keywords, and browsing history (a record of visited pages) [1].
|
||||
The tables include [1]:
|
||||
- `moz_anno_attributes`: Annotation Attributes
|
||||
- `moz_annos`: Annotations
|
||||
- `moz_bookmarks`: Bookmarks
|
||||
- `moz_bookmarks_roots`: Bookmark roots i.e. places, menu, toolbar, tags, unfiled
|
||||
- `moz_favicons`: Favorite icons - including URL of icon
|
||||
- `moz_historyvisits`: A history of the number of times a site has been visited
|
||||
- `moz_inputhistory`: A history of URLs typed by the user
|
||||
- `moz_items_annos`: Item annotations
|
||||
- `moz_keywords`: Keywords
|
||||
- `moz_places`: Places/Sites visited - referenced by `moz_historyvisits`
|
||||
URL data is stored in the `moz_places` table. However, this table is connected to `moz_annos`, `moz_bookmarks`, and `moz_inputhistory` and `moz_historyvisits`.
|
||||
As these entries are connected to each other, we'll delete all of them at the same time [2].
|
||||
This script targets the Firefox browsing history, including URLs, downloads, bookmarks, and site visits, by deleting specific database entries.
|
||||
|
||||
**Bookmarks**:
|
||||
Firefox bookmarks are stored in tables such as `moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots` [3].
|
||||
There are also not very well documented tables, such as `moz_bookmarks_deleted` [4].
|
||||
Firefox stores various user data in a file named `places.sqlite`. This file includes:
|
||||
|
||||
**Downloads:**
|
||||
Firefox downloads are stored in the 'places.sqlite' database, within the 'moz_annos' table [5].
|
||||
The entries in `moz_annos` are linked to `moz_places` that store the actual history entry (`moz_places.id = moz_annos.place_id`) [6].
|
||||
Associated URL information is stored within the 'moz_places' table [5].
|
||||
Downloads have been historically stored in `downloads.rdf` for Firefox 2.x and below [7].
|
||||
Starting with Firefox 3.x they're stored in `downloads.sqlite` [7].
|
||||
- Annotations, bookmarks, and favorite icons (`moz_anno_attributes`, `moz_annos`, `moz_favicons`) [1]
|
||||
- Browsing history, a record of pages visited (`moz_places`, `moz_historyvisits`) [1]
|
||||
- Keywords and typed URLs (`moz_keywords`, `moz_inputhistory`) [1]
|
||||
- Item annotations (`moz_items_annos`) [1]
|
||||
- Bookmark roots such as places, menu, toolbar, tags, unfiled (`moz_bookmarks_roots`) [1]
|
||||
|
||||
**Favicons:**
|
||||
Firefox favicons are stored in the `favicons.sqlite` database, within the `moz_icons` table [5].
|
||||
Older versions of Firefox stored Favicons in the 'places.sqlite' database, within the `moz_favicons` table [5].
|
||||
The `moz_places` table holds URL data, connecting to various other tables like `moz_annos`, `moz_bookmarks`, `moz_inputhistory`, and `moz_historyvisits` [2].
|
||||
Due to these connections, the script removes entries from all relevant tables simultaneously to maintain database integrity.
|
||||
|
||||
**Bookmarks**: Stored across several tables (`moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots`) [3], with additional undocumented tables like `moz_bookmarks_deleted` [4].
|
||||
|
||||
**Downloads**: Stored in the 'places.sqlite' database, within the 'moz_annos' table [5]. The entries in `moz_annos` are linked to `moz_places` that store the actual history entry
|
||||
(`moz_places.id = moz_annos.place_id`) [6]. Associated URL information is stored within the 'moz_places' table [5]. Downloads have been historically stored in `downloads.rdf` for Firefox 2.x
|
||||
and below [7], and `downloads.sqlite` later on [7].
|
||||
|
||||
**Favicons**: Older Firefox versions stored favicons in `places.sqlite` within the `moz_favicons` table [5], while newer versions use `favicons.sqlite` and the `moz_icons` table [5].
|
||||
|
||||
By executing this script, users can ensure their Firefox browsing history, bookmarks, and downloads are thoroughly removed, contributing to a cleaner and more private browsing experience.
|
||||
|
||||
[1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
|
||||
[2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki"
|
||||
@@ -810,21 +804,21 @@ actions:
|
||||
[7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org"
|
||||
call:
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: downloads.rdf
|
||||
pathGlob: downloads.rdf
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: downloads.sqlite
|
||||
pathGlob: downloads.sqlite
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: places.sqlite
|
||||
pathGlob: places.sqlite
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: favicons.sqlite
|
||||
pathGlob: favicons.sqlite
|
||||
-
|
||||
name: Clear Firefox logins
|
||||
docs: |-
|
||||
@@ -837,17 +831,17 @@ actions:
|
||||
[2]: https://web.archive.org/web/20221029145757/https://bugzilla.mozilla.org/show_bug.cgi?id=1593467 "1593467 - Automatically restore from logins-backup.json when logins.json is missing or corrupt | Bugzilla | mozilla.org | bugzilla.mozilla.org"
|
||||
call:
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: logins.json
|
||||
pathGlob: logins.json
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: logins-backup.json
|
||||
pathGlob: logins-backup.json
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: signons.sqlite
|
||||
pathGlob: signons.sqlite
|
||||
-
|
||||
name: Clear Firefox autocomplete history
|
||||
docs: |-
|
||||
@@ -856,9 +850,9 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: formhistory.sqlite
|
||||
pathGlob: formhistory.sqlite
|
||||
-
|
||||
name: Clear Firefox "Multi-Account Containers" data
|
||||
docs: |-
|
||||
@@ -866,9 +860,9 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: containers.json
|
||||
pathGlob: containers.json
|
||||
-
|
||||
name: Clear Firefox open tabs and windows data
|
||||
docs: |-
|
||||
@@ -878,9 +872,9 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: sessionstore.jsonlz4
|
||||
pathGlob: sessionstore.jsonlz4
|
||||
-
|
||||
category: Clear system and kernel usage data
|
||||
docs: |-
|
||||
@@ -2911,7 +2905,8 @@ actions:
|
||||
function: AddFirefoxPrefs
|
||||
parameters:
|
||||
prefName: toolkit.telemetry.log.level
|
||||
jsonValue: 'Fatal'
|
||||
jsonValue: >-
|
||||
"Fatal"
|
||||
-
|
||||
name: Disable Firefox telemetry log output
|
||||
recommend: standard
|
||||
@@ -2924,7 +2919,8 @@ actions:
|
||||
function: AddFirefoxPrefs
|
||||
parameters:
|
||||
prefName: toolkit.telemetry.log.dump
|
||||
jsonValue: 'Fatal'
|
||||
jsonValue: >-
|
||||
"Fatal"
|
||||
-
|
||||
name: Clear Firefox telemetry user ID
|
||||
recommend: standard
|
||||
@@ -3491,16 +3487,66 @@ functions:
|
||||
>&2 echo "Failed, $service does not exist."
|
||||
fi
|
||||
-
|
||||
name: DeleteFromFirefoxProfiles
|
||||
name: Comment
|
||||
# 💡 Purpose:
|
||||
# Adds a comment in the executed code for better readability and debugging.
|
||||
# This function does not affect the execution flow but helps in understanding the purpose of subsequent code.
|
||||
parameters:
|
||||
- name: path # file or folder in profile file
|
||||
code: |-
|
||||
# {{ $path }}: Global installation
|
||||
rm -rfv ~/.mozilla/firefox/*/{{ $path }}
|
||||
# {{ $path }}: Flatpak installation
|
||||
rm -rfv ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $path }}
|
||||
# {{ $path }}: Snap installation
|
||||
rm -rfv ~/snap/firefox/common/.mozilla/firefox/*/{{ $path }}
|
||||
- name: codeComment
|
||||
optional: true
|
||||
- name: revertCodeComment
|
||||
optional: true
|
||||
call:
|
||||
function: RunInlineCode
|
||||
parameters:
|
||||
code: '{{ with $codeComment }}# {{ . }}{{ end }}'
|
||||
revertCode: '{{ with $revertCodeComment }}# {{ . }}{{ end }}'
|
||||
-
|
||||
name: DeleteFiles
|
||||
parameters:
|
||||
- name: fileGlob
|
||||
call:
|
||||
-
|
||||
function: Comment
|
||||
parameters:
|
||||
codeComment: >-
|
||||
Delete files matching pattern: "{{ $fileGlob }}"
|
||||
-
|
||||
function: RunPython3Code
|
||||
parameters:
|
||||
code: |-
|
||||
import glob
|
||||
import os
|
||||
path = '{{ $fileGlob }}'
|
||||
expanded_path = os.path.expandvars(os.path.expanduser(path))
|
||||
print(f'Deleting files matching pattern: {expanded_path}')
|
||||
paths = glob.glob(expanded_path)
|
||||
if not paths:
|
||||
print('Skipping, no paths found.')
|
||||
for path in paths:
|
||||
if not os.path.isfile(path):
|
||||
print(f'Skipping folder: "{path}".')
|
||||
continue
|
||||
os.remove(path)
|
||||
print(f'Successfully delete file: "{path}".')
|
||||
print(f'Successfully deleted {len(paths)} file(s).')
|
||||
-
|
||||
name: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
- name: pathGlob # file or folder in profile file
|
||||
call:
|
||||
- # Global installation
|
||||
function: DeleteFiles
|
||||
parameters:
|
||||
fileGlob: ~/.mozilla/firefox/*/{{ $pathGlob }}
|
||||
- # Flatpak installation
|
||||
function: DeleteFiles
|
||||
parameters:
|
||||
fileGlob: ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $pathGlob }}
|
||||
- # Snap installation
|
||||
function: DeleteFiles
|
||||
parameters:
|
||||
fileGlob: ~/snap/firefox/common/.mozilla/firefox/*/{{ $pathGlob }}
|
||||
-
|
||||
name: CleanTableFromFirefoxProfileDatabase
|
||||
parameters:
|
||||
@@ -3693,7 +3739,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:
|
||||
|
||||
@@ -3701,12 +3747,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 }}'
|
||||
@@ -3746,12 +3798,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/*/
|
||||
@@ -3759,31 +3815,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
|
||||
|
||||
@@ -1238,376 +1238,6 @@ actions:
|
||||
sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate 'CriticalUpdateInstall' -bool true
|
||||
# Trigger background check with normal scan (critical updates only)
|
||||
sudo softwareupdate --background-critical
|
||||
-
|
||||
category: Disable OS services
|
||||
children:
|
||||
# Get active services : launchctl list | grep -v "\-\t0"
|
||||
# Find a service : sudo grep -lR [service] /System/Library/Launch* /Library/Launch* ~/Library/LaunchAgents
|
||||
# Locate a service : pgrep -fl [service]
|
||||
# TODO: https://gist.github.com/ecompayment/b1054421eb90f296bbca226683c7ff7e
|
||||
-
|
||||
category: Disable continuously data-collecting services by default
|
||||
children:
|
||||
-
|
||||
name: Disable diagnostics and usage data sender
|
||||
recommend: standard
|
||||
docs: https://apple.stackexchange.com/questions/66119/disable-submitdiaginfo
|
||||
call:
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.SubmitDiagInfo
|
||||
type: LaunchDaemons
|
||||
-
|
||||
name: Disable diagnostics and usage data sender
|
||||
recommend: standard
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.rtcreportingd.plist
|
||||
type: LaunchDaemons
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /usr/libexec/rtcreportingd
|
||||
-
|
||||
name: Disable Family Circle Daemon for Family Sharing
|
||||
docs: https://support.apple.com/en-us/HT201060
|
||||
recommend: standard
|
||||
# Connects to setup.icloud.com HTTPS (TCP 443 )
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.familycircled
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/FamilyCircle.framework/Versions/A/Resources/familycircled
|
||||
-
|
||||
name: Disable home sharing
|
||||
docs: https://discussions.apple.com/thread/7434075?answerId=29677460022#29677460022
|
||||
# Connects to apps.mzstatic.com and init.itunes.apple.com HTTPS (TCP 443 )
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.itunescloudd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /usr/libexec/rtcreportingd # TODO: SIP required?
|
||||
-
|
||||
name: Disable CommerceKit handling purchases for Apple products
|
||||
# the Mac App Store, iTunes store, and Book Store
|
||||
# Connects to init.itunes.apple.com and xp.apple.com HTTPS (TCP 443 )
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.commerce.plist
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/commerce
|
||||
-
|
||||
category: Disable Siri services # TODO: merge with other assistantd script
|
||||
children:
|
||||
-
|
||||
name: Disable Siri dictation service sending voice data
|
||||
recommend: strict
|
||||
docs: https://apple.stackexchange.com/questions/57514/what-is-assistantd
|
||||
# Connects to guzzoni.apple.com HTTPS (TCP 443 )
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.assistantd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Support/assistantd
|
||||
-
|
||||
name: Disable Siri assistant service
|
||||
recommend: strict
|
||||
docs: https://www.howtogeek.com/354897/what-are-assistant_service-and-assistantd-and-why-are-they-running-on-my-mac/
|
||||
# Connects to radio.itunes.apple.com HTTPS (TCP 443 )
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.assistant_service.plist
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Support/assistant_service
|
||||
-
|
||||
category: Disable Messages services
|
||||
docs: https://blog.quarkslab.com/imessage-privacy.html
|
||||
children:
|
||||
-
|
||||
name: Disable Apple Push Service Daemon used for Notification Center and Messages
|
||||
# Connects to *-courier.push.apple.com (where * is a number) using HTTPS (TCP 443) and apple-push (TCP 5223)
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.apsd
|
||||
type: LaunchDaemons
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/ApplePushService.framework/apsd
|
||||
-
|
||||
name: Disable iMessage Agent in Messages app
|
||||
# Used for e.g. FaceTime invitations
|
||||
docs:
|
||||
- https://apple.stackexchange.com/questions/86814/firewall-settings-with-imagent
|
||||
- https://blog.quarkslab.com/imessage-privacy.html
|
||||
# Connects to using HTTPS (TCP 443) and apple-push (TCP 5223)
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.imagent
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/IMCore.framework/imagent.app/Contents/MacOS/imagent
|
||||
-
|
||||
name: Disable Address Book Source Sync (breaks Contacts data sync)
|
||||
# Synchronizes data data for the “Contacts” app with iCloud, CardDAV, and Exchange servers
|
||||
docs: https://apple.stackexchange.com/questions/219774/how-to-disable-addressbooksourcesync-in-el-capitan
|
||||
# Connects to p25-contacts.icloud.com using HTTPS (TCP 443) and apple-push (TCP 5223)
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.AddressBook.SourceSync
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/Frameworks/AddressBook.framework/Versions/A/Helpers/AddressBookSourceSync.app/Contents/MacOS/AddressBookSourceSync
|
||||
-
|
||||
name: Disable usage tracking agent
|
||||
recommend: strict
|
||||
docs: https://www.unix.com/man-page/mojave/8/USAGETRACKINGAGENT/
|
||||
# Connects to itunes.apple.com using HTTPS 443 (TCP)
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.UsageTrackingAgent
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/UsageTracking.framework/Versions/A/UsageTrackingAgent
|
||||
-
|
||||
name: Disable AMPLibraryAgent for Apple Music
|
||||
# Connects to buy.itunes.apple.com, init.itunes.apple.com, play.itunes.apple.com, xp.apple.com using HTTPS 443 (TCP)
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.AMPLibraryAgent
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: System/Library/PrivateFrameworks/AMPLibrary.framework/Versions/A/Support/AMPLibraryAgent
|
||||
-
|
||||
category: Disable location services
|
||||
children:
|
||||
-
|
||||
name: Disable Maps push daemon
|
||||
docs:
|
||||
- https://www.unix.com/man-page/mojave/8/MAPSPUSHD/
|
||||
- https://discussions.apple.com/thread/7025815
|
||||
call:
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.Maps.pushdaemon
|
||||
type: LaunchAgents
|
||||
-
|
||||
name: Disable Geo Daemon / geolocation daemon used to show maps by apps e.g. Maps
|
||||
# Connects to Apple servers for loading map data on behalf of other apps and for resolving geographical coordinates to readable addresses.
|
||||
# Connects to gspe*-ssl.ls.apple.com (where * is a number from 1 to 100 ), sp-ssl.ls.apple.com, configuration.ls.apple.com using HTTPS 443 (TCP)
|
||||
call:
|
||||
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/GeoServices.framework/Versions/A/XPCServices/com.apple.geod.xpc/Contents/MacOS/com.apple.geod
|
||||
-
|
||||
name: Disable Location-Based Suggestions for Siri, Spotlight and other places
|
||||
# Used for suggestions in Spotlight, Messages, Lookup, Safari, Siri, and other place
|
||||
# Connects to api-glb-euc1b.smoot.apple.com, api.smoot.apple.com using HTTPS 443 (TCP)
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.parsecd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/CoreParsec.framework/parsecd
|
||||
-
|
||||
category: Disable iCloud services
|
||||
children:
|
||||
-
|
||||
name: Disable iCloud notification agent
|
||||
recommend: strict
|
||||
call:
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.iCloudNotificationAgent
|
||||
type: LaunchAgents
|
||||
-
|
||||
name: Disable Sync Defaults Daemon
|
||||
# Syncs user preferences or other configuration related data via iCloud
|
||||
docs: https://www.unix.com/man-page/mojave/8/syncdefaultsd
|
||||
# Connects to keyvalueservice.icloud.com and p*-keyvalueservice.icloud.com (where * is a number) using HTTPS 443 (TCP)
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.syncdefaultsd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/SyncedDefaults.framework/Support/syncdefaultsd
|
||||
-
|
||||
name: Disable Reminder Daemon that synchronizes the reminder list in "Reminders" with iCloud
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.remindd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /usr/libexec/remindd #TODO: Mb don't require SIP
|
||||
-
|
||||
name: Disable Cloud Daemon used for iCloud syncing
|
||||
# Connects to gateway.icloud.com, metrics.icloud.com using HTTPS 443 (TCP)
|
||||
recommend: strict
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.cloudd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.cloudd
|
||||
type: LaunchDaemons
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd
|
||||
-
|
||||
name: Disable Help Daemon (breaks HelpViewer feature)
|
||||
recommend: strict
|
||||
docs: https://discussions.apple.com/thread/3930621
|
||||
# Connects to cds.apple.com, help.apple.com using HTTPS (TCP 443)
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.helpd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/HelpData.framework/Versions/A/Resources/helpd
|
||||
-
|
||||
name: Disable Rapport Daemon for communication between Apple devices
|
||||
# Rapport Daemon is a macOS system process that enables Phone Call Handoff and other communication features between Apple devices.
|
||||
# Connects to init.ess.apple.com using HTTPS (TCP 443)
|
||||
docs: https://apple.stackexchange.com/questions/308294/what-is-rapportd-and-why-does-it-want-incoming-network-connections
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.rapportd-user
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.rapportd
|
||||
type: LaunchDaemons
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /usr/libexec/rapportd #TODO: No SIP required?
|
||||
-
|
||||
name: Disable App Tracking Transparency framework
|
||||
docs:
|
||||
- https://apple.stackexchange.com/questions/409349/what-is-the-transparencyd-daemon-for
|
||||
- https://developer.apple.com/documentation/apptrackingtransparency
|
||||
# Connects to server kt-prod.apple.com using HTTPS (TCP 443 )
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.transparencyd
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /usr/libexec/transparencyd #TODO: No need for SIP?
|
||||
-
|
||||
category: Disable Calendar Agent that sync Calender App to iCloud and other servers
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.CalendarAgent
|
||||
type: LaunchAgents
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /System/Library/PrivateFrameworks/CalendarAgent.framework/Executables/CalendarAgent
|
||||
-
|
||||
name: Disable advertising services daemon
|
||||
recommend: strict
|
||||
docs: https://www.unix.com/man-page/mojave/8/adservicesd
|
||||
call:
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.ap.adservicesd
|
||||
type: LaunchAgents
|
||||
-
|
||||
name: Disable NetBIOS interactions (might break Microsoft services)
|
||||
# Mostly used for mostly SMB network volumes
|
||||
docs: https://www.manpagez.com/man/8/netbiosd/
|
||||
call:
|
||||
-
|
||||
function: DisableService
|
||||
parameters:
|
||||
name: com.apple.netbiosd
|
||||
type: LaunchDaemons
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: /usr/sbin/netbiosd
|
||||
requireSip: false # TODO: Test
|
||||
|
||||
functions:
|
||||
-
|
||||
name: PersistUserEnvironmentConfiguration
|
||||
@@ -1638,31 +1268,3 @@ functions:
|
||||
echo "[$profile_file] No need for any action, configuration does not exist"
|
||||
fi
|
||||
done
|
||||
-
|
||||
name: DisableService
|
||||
parameters:
|
||||
- name: name
|
||||
- name: type
|
||||
code: |-
|
||||
original_file='/System/Library/{{ $type }}/{{ $name }}.plist'
|
||||
backup_file="$original_file.disabled"
|
||||
if [ -f "$original_file" ]; then
|
||||
sudo launchctl unload -w "$original_file" 2> /dev/null
|
||||
mv "$original_file" "$backup_file"
|
||||
echo 'Disabled successfully'
|
||||
else
|
||||
echo 'Already disabled'
|
||||
fi
|
||||
revertCode: |-
|
||||
original_file='/System/Library/{{ $type }}/{{ $name }}.plist'
|
||||
backup_file="$original_file.disabled"
|
||||
if [ -f "$original_file" ]; then
|
||||
sudo launchctl unload -w "$original_file" 2> /dev/null
|
||||
if mv "$original_file" "$backup_file"; then
|
||||
echo 'Disabled successfully'
|
||||
else
|
||||
>&2 echo 'Failed to disable'
|
||||
fi
|
||||
else
|
||||
echo 'Already disabled'
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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