Compare commits

...

44 Commits

Author SHA1 Message Date
undergroundwires
0900492ccb win: fix Defender service #128 #385 #393 #402 #426
This commit adds disabling missing low-level Defender service/drivers,
improve disabling existing ones, and improve their documentation.

Key changes:

- Add disabling missing Defender services.
- Add disabling missing Defender processes.
- Add soft-deleting of missing service files
- Fix `ServiceKeepAlive` value #393, #426
- Add disabling system modification restrictions for persistent Disable
  service disabling.
- Recommend more Defender scripts on 'Strict' level

Other supporting changes:

- Add more documentation for related scripts.
- Move disabling `SecHealthUI` to disabling Windows Security.
- Fix `DisableService` attempting to disable the service even though its
  disabled.
- Add ability to disable service on revert in
  `DisableServiceInRegistry`.
- Improve categorization for simplicity, add new categories for new
  scripts.
- Add ability to run `DeleteRegistryValue` as `TrustedInstaller`.
- Rename some scripts/categories for simplicity and clarity.
2024-10-29 20:58:04 +01:00
undergroundwires
5db8c6b591 Fix HTML semantics in script run instructions
This commit corrects HTML semantic errors in browser instruction dialogs
displayed when a script is downloaded via browser.

Key changes:

- Fix HTML structure by removing `<ul>` and `<ol>` tags from `<p>`
  elements
- Replace `<code>` with `<kbd>` for keyboard inputs

Other supporting changes:

- Improve clarity readability of some instructions
- Add CSS styles for `<kbd>`
2024-10-17 14:42:15 +02:00
undergroundwires
9e8bad0084 Fix browser instructions appearing on desktop
Previously, the app showed browser download/run instructions on the
desktop version, which was irrelevant for desktop users. This change
improves the user experience by displaying these instructions only
in browser environments.

This change streamlines the script saving process for desktop users
while maintaining the necessary guidance for browser users.

The commit:

- Prevents the instruction modal from appearing on desktop
- Renames components for clarity (e.g., 'RunInstructions' to
  'BrowserRunInstructions')
- Adds a check to ensure browser environment before showing
  instructions
2024-10-14 13:03:44 +02:00
undergroundwires
eb8812b26e Update Saas and Vite to fix deprecation warnings
This commit updates Dart Sass to version 1.79.4 to address deprecation
warnings. It also updates Vite to 5.4.x to be able to use the modern
Sass compiler.

Changes:

- Update `saas` to latest
- Update `vite` from 5.3.x to 5.4.x
- Replace `lighten` and `darken` with `color.adjust` due
  to deprecation.
- Set Vite to use the modern compiler instead of the deprecated legacy
  one
- Pin `sass` to patch versions to prevent unexpected deprecations using
  tilde (~) for in package.json.
2024-10-13 02:13:52 +02:00
undergroundwires
3f56166655 Fix CI/CD runtime checks failing on Ubuntu 24.04
GitHub runners now use Ubuntu 24.04, which introduces two issues
affecting Electron application runtime checks:

1. AppArmor restrictions on unprivileged user namespaces
2. Outdated Mesa drivers

This commit resolves both with workarounds.

Changes:

- Disable AppArmor restrictions on unprivileged user namespaces:
  - Resolves the following error:
    ```
    [5475:1011/121711.489417:FATAL:setuid_sandbox_host.cc(158)] The SUID sandbox helper binary was found, but is not configured correctly. Rather than run without sandboxing I'm aborting now. You need to make sure that /tmp/.mount_privacv1kcOj/chrome-sandbox is owned by root and has mode 4755.
    ```
  - Related key Electron issues:
    - electron/electron#41066
    - electron/electron#42510
    - electron-userland/electron-builder#8440
- Update Mesa drivers
  - Fixes following errors:
    ```
    MESA: error: ZINK: failed to choose pdev
    glx: failed to create drisw screen
    ```
  - Installs latest Mesa drivers from Kisak PPA
2024-10-12 13:03:39 +02:00
undergroundwires
69e7e0adf1 Fix CI/CD fail by installing ImageMagick on runner
This commit addresses an issue where CI/CD jobs fail due to the removal of
`imagemagick` from GitHub's preinstalled software list for Ubuntu
runners. As a result, commands relying on `imagemagick` were not found.

To resolve this, the commit installs `imagemagick` across all platforms
(Linux, macOS, and Windows). This safeguards against future changes to
the preinstalled software list on GitHub runners.

This commit also centralizes installing of ImageMagick as its own action
for reusability.
2024-10-11 23:01:30 +02:00
undergroundwires
74378f74bf Hide code highlight and cursor until interaction
By default, the code area displays line highlighting (active line and
gutter indicators) and shows the cursor even before user interaction,
which clutters the initial UI.

This commit hides the highlighting and cursor until the user interacts
with the code area, providing a cleaner initial UI similar to modern
editors when code is first displayed.

When code is generated automatically, the code is already highlighted
with a custom marker. Therefore, indicating a specific line by default
is unnecessary and clutters the view.

Key changes:

- Hide active line highlighting before user interaction.
- Hide cursor before user interaction.

Other supporting changes:

- Refactor `TheCodeArea` to extract third-party component (`Ace`)
  related logic for better maintainability.
- Simplify code editor theme setting by removing the component property.
- Remove unnecessary `github` theme import for the code editor component
  as it's unused.
2024-10-10 17:16:32 +02:00
undergroundwires
2f31bc7b06 Fix file retention after updates on macOS #417
This fixes issue #417 where autoupdate installer files were not deleted
on macOS, leading to accumulation of old installers.

Key changes:

- Store update files in application-specific directory
- Clear update files directory on every app launch

Other supporting changes:

- Refactor file system operations to be more testable and reusable
- Improve separation of concerns in directory management
- Enhance dependency injection for auto-update logic
- Fix async completion to support `await` operations
- Add additional logging and revise some log messages during updates
2024-10-07 17:33:47 +02:00
undergroundwires
4e06d543b3 Add external URL linting for markdown files
This commit integrates `remark-lint-no-dead-urls` into the project's
linting process, improving documentation quality by checking for dead
links.

Key changes:

- Add NPM command to verify external URLs in markdown files
- Include new command in the main lint script

Other supporting changes:

- Replace archive.ph link with Wayback Machine for better verification
- Update `remark-lint-no-dead-urls` to latest version (2.0.0)
- Update Browserslist DB to latest to avoid build errors
2024-10-01 16:54:56 +02:00
undergroundwires
a536c6970f win: add disabling Phishing Protection #385
This commit adds options to disable Enhanced Phishing Protection
features in Defender SmartScreen. This includes disabling background
services, automatic data collection and various notification types.

Key changes:

- Add disabling of W11-only "Enhanced Phishing Protection"
- Add disabling of Web Threat Defense services.

Supporting changes:

- Add minimum version constraint for `DisablePerUserService`
- Use less characters in `RunPowerShellWithWindowsVersionConstraints` to
  avoid reaching the max batchfile line lengths.
2024-09-30 15:23:46 +02:00
undergroundwires
e17744faf0 Bump to TypeScript 5.5 and enable noImplicitAny
This commit upgrades TypeScript from 5.4 to 5.5 and enables the
`noImplicitAny` option for stricter type checking. It refactors code to
comply with `noImplicitAny` and adapts to new TypeScript features and
limitations.

Key changes:

- Migrate from TypeScript 5.4 to 5.5
- Enable `noImplicitAny` for stricter type checking
- Refactor code to comply with new TypeScript features and limitations

Other supporting changes:

- Refactor progress bar handling for type safety
- Drop 'I' prefix from interfaces to align with new code convention
- Update TypeScript target from `ES2017` and `ES2018`.
  This allows named capturing groups. Otherwise, new TypeScript compiler
  does not compile the project and shows the following error:
  ```
  ...
  TimestampedFilenameGenerator.spec.ts:105:23 - error TS1503: Named capturing groups are only available when targeting 'ES2018' or later
  const pattern = /^(?<timestamp>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?<scriptName>[^.]+?)(?:\.(?<extension>[^.]+))?$/;// timestamp-scriptName.extension
  ...
  ```
- Refactor usage of `electron-progressbar` for type safety and
  less complexity.
2024-09-26 16:07:37 +02:00
undergroundwires
a05a600071 win: add CLSID/COM object removal #412
This commit improves existing scripts (or adds new ones) to add COM
object removal to scripts. This fixes slow application launches that
occur when SmartScreen is removed by privacy.sexy, resolving #412.

Key changes:

- Introduce `SoftDeleteRegistryKey` to preserve complex registry trees
  and their permissions.
- Add missing CLSIDs for Defender/Windows Update components.

Other supporting changes:

- Improve documentation for related categories and scripts.
- Introduce categories as necessary to structure new scripts.
- Add supporting actions along with COM object removal, such as deleting
  related files or configuring registry settings.
- Add ability to constrain soft deletion of files based on Windows
  version.
- Shorten dependent functions to avoid hitting the max character limit
  in `SoftDeleteRegistryKey`.
2024-09-24 14:00:43 +02:00
undergroundwires
8b6067f83f Improve compiler output for line validation
This commit improves feedback when a line is too long to enhance
developer/maintainer productivity.

- Trim long lines in output to 500 characters
- Show character count exceeding max line length
- Refactor line formatting for better readability
2024-09-16 12:39:52 +02:00
undergroundwires
98e8dc0a67 win: add missing system apps #279 #316 #343
This commit adds missing system apps for Windows 10 19H2 to 22H2 and
Windows 11 21H2 to 23H2.

Changes:

- Add missing system apps.
- Improve documentation and naming of existing apps
- Add documentation about excluded system apps (#343)
- Adjust recommendation levels
- Enhance disabling of some apps with extra configurations

New apps added:

- Microsoft.MicrosoftEdge.Stable
- MicrosoftWindows.UndockedDevKit
- Microsoft.Windows.XGpuEjectDialog
- NarratorQuickStart

New apps excluded:

- Microsoft.Windows.StartMenuExperienceHost (#316)
- Microsoft.Windows.ShellExperienceHost (#316)
- MicrosoftWindows.Client.Core
- MicrosoftWindows.Client.CBS
- MicrosoftWindows.Client.FileExp
- Microsoft.Windows.Cortana

Other supporting changes:

- Add conditional store app removal when constraining Windows version
- Add more generated comments in code for better script readability
2024-08-30 11:51:20 +02:00
undergroundwires
6b8f6aae81 win: enable PowerShell as TI runs #128 #412 #421
Refactor Windows scripts to run as TrustedInstaller using PowerShell
instead of batch files. This improves code reuse and enables more
complex logic for system modifications.

Key changes:

- Add function to run any PowerShell script as TrustedInstaller
- Refactor existing functions to use new TrustedInstaller capability
- Enable soft deletion of protected registry keys and files (#412).
- Resolve issues with renaming Defender files (#128).

Other supporting changes:

- Enhance service disabling to handle dependent services
- Use base64 encoding of 'privacy.sexy' to avoid Defender alerts (#421).
- Add comments to generated code for better documentation
2024-08-28 14:01:54 +02:00
undergroundwires
dc5c87376b Add validation for max line length in compiler
This commit adds validation logic in compiler to check for max allowed
characters per line for scripts. This allows preventing bugs caused by
limitation of terminal emulators.

Other supporting changes:

- Rename/refactor related code for clarity and better maintainability.
- Drop `I` prefix from interfaces to align with latest convention.
- Refactor CodeValidator to be functional rather than object-oriented
  for simplicity.
- Refactor syntax definition construction to be functional and be part
  of rule for better separation of concerns.
- Refactored validation logic to use an enum-based factory pattern for
  improved maintainability and scalability.
2024-08-27 11:32:52 +02:00
undergroundwires
db090f3696 win: add disabling Defender core service #385
This commit adds disabling Microsoft Defender Core Service (MDCoreSvc)
and its related telemetry.

Key changes:

- Add disabling MDCoreSvc, resolving #385
- Add disabling its telemetry
- Add disabling its ECS integration

Supporting changes:

- Update script names/docs to clarify Defender Antivirus data
  collection
2024-08-23 12:12:14 +02:00
undergroundwires
aee24cdaa1 win: categorize disabling Defender components
This commit restructures disabling Defender components. This improves
organization and clarity for users by grouping related scripts together.
It also updates names and docs to match latest Defender branding.

Changes:

- Add new parent categories for disabling Defender Antivirus, user
  interface, Exploit Guard and Defender for Endpoint.
- Move relevant scripts under new categories.
- Update script names for clarity and consistency
- Add more documentation explaining Defender components.
- Reorder subcategories based on impact
- Simplify naming, e.g. "Defender" instead of "Microsoft Defender"
2024-08-21 13:02:23 +02:00
undergroundwires-bot
be0ab9b125 ⬆️ bump everywhere to 0.13.6 2024-08-13 12:01:49 +00:00
undergroundwires
50ba00b0af win: fix, constrain and document WNS #227 #314
This change addresses issues #227 and #314 by preventing unintended side
effects on newer Windows versions while still offering WNS control on
supported systems.

Changes:

- Constrain `WpnUserService` disabling to Windows 10 v1909 and earlier.
- Update documentation for WNS and related services.
- Remove redundant warnings (in generated code and script title).
- Improve DisablePerUserService function:
  - Add documentation and generated comments
  - Implement Windows version constraint capability
2024-08-13 11:19:46 +02:00
undergroundwires
29e1069bf2 win, mac: fix minor typos, formatting, dead URLs
- Update dead URLs to archived versions
- Correct Windows version references (22H3 to 23H2)
- Correct reference order
- Fix incorrect usage of double quotes
2024-08-12 09:28:55 +02:00
undergroundwires
c7e57b8913 win: improve disabling NCSI #189, #216, #279
- Group NCSI disabling under single category for better organization.
- Remove NCSI from 'Strict' recommendations due to side effects
  (addressing #189, #216).
- Improve documentation with cautions about breaking internet status and
  captive portals (addressing #189, #216).
- Add removal of new `NcsiUwpApp` system app #279.
- Add more ways to disable the feature.
- Add ability to constrain Windows version in `DisableService`.
2024-08-10 12:16:33 +02:00
undergroundwires
4cea6b26ec win: unify registry data setting, fix #380
This commit unifies and centralizes registry data operations. This
improves reliability and robustness by avoiding bugs caused by incorrect
syntax when making modifications, as operations are now centralized.
This change resolves issue #380.

It also enhances reversibility by adding all missing revert codes and
correcting existing revert codes, tested against default values on fresh
OS and software installations.

Key changes:

- Add ability to revert to OS default data in `SetRegistryValue`
- Refactor manual registry operations to use shared functions
- Improve documentation for some affected scripts
- Fix incorrect revert codes (adding value instead of deleting) for some scripts
- Add missing revert logic for affected scripts

Other supporting changes:

- Fix revert code generation in `SetRegistryValueAsTrustedInstaller` to
  avoid generating empty revert code when no actions are needed.
- Add ability to delete keys on revert in `CreateRegistryKey`.
- Change `HKCR` key modifications to `HKLM|HKCU\Software\Classes` keys
  to always generate correct revert logic.
2024-08-09 17:13:00 +02:00
undergroundwires
c2f4b68786 win: improve Microsoft Edge associations removal
This commit refactors the removal of Edge associations to use shared
registry functions, streamlining all registry operations. It also
enhances the logic for removing associations with several improvements.

Key changes:

- Create separate shared functions for each association modification.
- Prefer modifying real keys over `HKCR` keys for reliability
- Add more documentation for affected scripts and new shared functions
- Replace loops with explicit calls for clarity and maintainability
- Extend handling of registry keys to both HKCU/HKLM hives
- Add missing association removals
- Add OS checks to apply only on correct Windows versions
- Split scripts for more granularity and maintainability
- Handle permission errors for user choice keys on recent Windows

Other supporting changes:

- Add `REG_DWORD` revert support for `DeleteRegistryKey`
- Add `grantPermissions` support for `DeleteRegistryValue`
- Add delete data only if undesired for `DeleteRegistryValue`
- Shorten generated code in `DeleteRegistryValue` to prevent reaching
  `cmd.exe` limits
- Add more Windows versions for script constraints
2024-08-08 17:57:19 +02:00
undergroundwires
e8add5ec08 win: improve folder hiding in "This PC" #16
This commit improves how folders are hidden under "This PC" on Windows.
It introduces shared functions to improve maintainability. This increases
the robustness and simplifies future updates and maintenance.

Key changes:

- Fix revert codes to match the default operating system state.
- Implement shared functions for higher maintainability.
- Add more documentation.
- Introduce more methods to hide folders, adding the suggestion from
  #16.

Other supporting changes:

- Add ability to revert `DeleteRegistryKey`.
- Add ability to contrain Windows version in `DeleteRegistryKey`.
- Add generated comment by default in `DeleteRegistryKey`.
2024-08-07 11:51:28 +02:00
undergroundwires
55c23e9d4c win: improve registry value deletion #381
This commit enhances the deletion of registry values with improved
robustness and better error handling. One-line `reg.exe` calls where
errors were suppressed are replaced with PowerShell commands that
provide proper error handling. This fixes #381 where wrong `reg delete`
syntax was used.

Key changes:

- Introduce `DeleteRegistryValue` and change registry value deletion
  logic to use it.
- Fix Windows version comparison to ignore patch numbers.
  This ensures versions like `10.0.19045.0` are treated the same as
  `10.0.19045`, resolving issues where scripts were incorrectly skipped
  due to patch number differences in Windows versions.

Other supporting changes:

- Add missing revert codes.
- Include more comments in the generated code.
- Use `-LiteralPath` in all registry deletion commands to prevent
  unintended wildcard expansion when '*' is used in registry paths.
- Remove unused `revertCodeComment` parameter from `DeleteRegistryKey`.

Changed scripts:

- 'Remove "Scan with Microsoft Defender" from context menu':
  - Use `DeleteRegistryKey` in script.
  - Remove problematic `HKCR\*\shellex\ContextMenuHandlers` key deletion.
    This caused errors on both Windows 10 (22H2) and Windows 11 (23H2).
    The wildcard usage made this operation potentially risky, so it's
    replaced with more specific registry cleanup.
  - Remove modifications to `HKCR` values. `HKCR` is a virtual hive,
    and changes to `HKLM` are automatically reflected in `HKCR`.
- Update 'Disable automatic OneDrive installation' to target only
  Windows 1909, improve documentation, and recommend in 'Standard'.
- Simplify 'Disable Diagnostics Hub log collection' by removing VS
  version check, enhance documentation, recommend in 'Standard'.
2024-08-06 07:15:26 +02:00
undergroundwires
d77c3cbbe2 Fix PowerShell code block inlining in compiler
This commit enhances the compiler's ability to inline PowerShell code
blocks. Previously, the compiler attempted to inline all lines ending
with brackets (`}` and `{`) using semicolons, which leads to syntax
errors. This improvement allows for more flexible PowerShell code
writing with reliable outcomes.

Key Changes:

- Update InlinePowerShell pipe to handle code blocks specifically
- Extend unit tests for the InlinePowerShell pipe

Other supporting changes:

- Refactor InlinePowerShell tests for improved scalability
- Enhance pipe unit test running with regex support
- Expand test coverage for various PowerShell syntax used in
  privacy.sexy
- Update related interfaces to align with new code conventions, dropping
  `I` prefix
- Optimize line merging to skip lines already ending with semicolons
- Increase timeout in E2E tests to accommodate for slower application
  load caused by more processing introduced in this commit.
2024-08-05 19:44:30 +02:00
undergroundwires
f89c2322b0 win: fix, improve and unify Windows version logic
This commit centralizes Windows version constraints through a new
function for improved clarity, maintainability and reusability.

Changes:

- Add `RunPowerShellWithWindowsVersionConstraints` function
- Support specifying minimum and maximum Windows versions
- Introduce user-friendly tags like `Windows11-FirstRelease`
- Fix version logic by correcting incorrect block syntax in various
  functions.
2024-08-04 15:29:29 +02:00
undergroundwires
ded55a66d6 Refactor executable IDs to use strings #262
This commit unifies executable ID structure across categories and
scripts, paving the way for more complex ID solutions for #262.
It also refactors related code to adapt to the changes.

Key changes:

- Change numeric IDs to string IDs for categories
- Use named types for string IDs to improve code clarity
- Add unit tests to verify ID uniqueness

Other supporting changes:

- Separate concerns in entities for data access and executables by using
  separate abstractions (`Identifiable` and `RepositoryEntity`)
- Simplify usage and construction of entities.
- Remove `BaseEntity` for simplicity.
- Move creation of categories/scripts to domain layer
- Refactor CategoryCollection for better validation logic isolation
- Rename some categories to keep the names (used as pseudo-IDs) unique
  on Windows.
2024-08-03 16:54:14 +02:00
undergroundwires
6fbc81675f Relax linting to allow null recommendation
This commit updates the YAML schema to permit explicitly setting
`recommend: null` or `recommend: ~` in scripts within collection files.

Previously, the schema only allowed string values for the recommendation
field, restricting it to 'standard' or 'strict'. By introducing the
`RecommendationLevel` definition, the schema now supports both string
and null values, providing more flexibility in specifying
recommendations in collection YAML files.
2024-08-02 16:44:15 +02:00
undergroundwires
48d97afdf6 win: improve registry/recent cleaning
This commit introduces a new shared function to centralize all usages of
`reg delete .. /va`. The new function generates comments in code and can
recurse through subkeys. This enhances maintainability and reliability
by avoiding potential misuse or syntax errors.

Key changes:

- Add `ClearRegistryValues` function
- Update scripts to use the new function
- Add ability to recurse subkeys for registry value deletion, addressing
  issues where desired data was not deleted.

Other supporting changes:

- Improve documentation of the changed scripts.
- Add missing registry paths in scripts.
- Change value removal to value/subkey removal for correct behavior.
- Remove removal of undocumented keys.
- Rename related scripts for clarity.
- Adjust script recommendations.
2024-08-01 23:02:01 +02:00
undergroundwires
109fc01c9a win: fix and document VStudio license removal
Before this commit, license deletion used `reg delete .. /va /f`, which
deletes all valued under a key. However, the license data did not exist
under the specified subkeys, making the logic ineffective. This commit
changes it to delete the license registry key completely to correctly
remove the license data.

Changes:

- Change `reg delete /va` to delete the correct registry data.
- Create shared function for deleting license data for better
  maintainability.
- Add comment in generated script code for license removal.
- Add documentation for the scripts.
- Include a missing script to clear Visual Studio 2013 telemetry.
- Remove redundant lines of code in `CreateRegistryKey` and
  `DeleteRegistryKey` that initialize an unused variable.
2024-07-31 13:35:39 +02:00
undergroundwires
b185255a0a win: centralize, improve Defender data collection
This commit reorganizes scripts related to disabling Defender's data
collection and telemetry into a dedicated category. This improves
usability for users focused on enhancing privacy without needing to
understand technical details of each option.

Changes:

- Create "Disable Defender data collection" category
- Move related scripts under new category
- Improve script documentation and naming
- Add alternate configurations to some scripts
- Fix extended cloud check feature being enabled instead of disabled
- Update script recommendations to 'Strict'
2024-07-28 23:50:38 +02:00
undergroundwires
c2d3cddc47 win: improve, fix, restructure CEIP disabling
- Restructure and expand rename CEIP-related scripts for clarity and
  granularity.
- Add missing tasks and registry keys for comprehensive CEIP disabling.
- Improve documentation with detailed explanations and references.
- Rename scripts for better user understanding and consistency
- Fix incorrect revert behavior in some scripts
2024-07-26 15:45:33 +02:00
undergroundwires
8526d2510b win: unify registry setting as TrustedInstaller
- Introduce SetRegistryValueAsTrustedInstaller function to unify setting
  registry values as TrustedInstaller.
- Introduce RunPowerShellWithMinimumWindowsVersion function to unify
  Windows version specific registry modifications.
- Add more documentation for scripts using TrustedInstaller.
- Correct revert code for affected scripts to match default OS behavior
  (setting registry value back) instead of just deleting keys.
2024-07-25 14:23:31 +02:00
undergroundwires
11e566d0e5 win: improve disabling SmartScreen #385
- Add comprehensive documentation with security cautions
- Expand SmartScreen disabling for Internet Explorer
- Fix registry data for Internet Explorer SmartScreen disabling
- Add disabling of `smartscreen.exe` process, resolving #385
- Implement additional SmartScreen disabling methods
- Correct registry key for Store apps
- Simplify script names for clarity
2024-07-24 16:23:28 +02:00
undergroundwires
ae0165f1fe Ensure tests do not log warning or errors
This commit increases strictnes of tests by failing on tests (even
though they pass) if `console.warn` or `console.error` is used. This is
used to fix warning outputs from Vue, cleaning up test output and
preventing potential issues with tests.

This commit fixes all of the failing tests, including refactoring in
code to make them more testable through injecting Vue lifecycle
hook function stubs. This removes `shallowMount`ing done on places,
improving the speed of executing unit tests. It also reduces complexity
and increases maintainability by removing `@vue/test-utils` dependency
for these tests.

Changes:

- Register global hook for all tests to fail if console.error or
  console.warn is being used.
- Fix all issues with failing tests.
- Create test helper function for running code in a wrapper component to
  run code in reliable/unified way to surpress Vue warnings about code
  not running inside `setup`.
2024-07-23 16:08:04 +02:00
undergroundwires
a6505587bf Fix intermittent ModalDialog unit test failures
Refactor `ModalDialog` unit tests to use `shallowMount` consistently.
Previously, tests sometimes failed due to the `UseSvgLoader` hook
attempting icon loads during component teardown, which occasionally led
led to errors when the `window` object became unavailable. By
switching to `shallowMount`, tests no longer deeply render child
components, mitigating the risk of such errors and aligning with
unit testing best practices.

Additionally, this commit sets a default value for the `modelValue`
prop in test setups to address Vue warnings about missing required
props, further stabilizing the test environment.
2024-07-22 15:10:12 +02:00
undergroundwires
b16e13678c Improve compiler error display for latest Chromium
This commit addresses the issue of Chromium v126 and later not displaying
error messages correctly when the error object's `message` property uses
a getter. It refactors the code to utilize an immutable Error object with
recursive context, improves error message formatting and leverages the
`cause` property.

Changes:

- Refactor error wrapping internals to use an immutable error object,
  eliminating `message` getters.
- Utilize the `cause` property in contextual errors for enhanced error
  display in the console.
- Enhance message formatting with better indentation and listing.
- Improve clarity by renaming values thrown during validations.
2024-07-21 10:18:27 +02:00
undergroundwires
abe03cef3f Refactor styles to match new CSS nesting behavior
This commit refactors SCSS to resolve deprecation warnings related to
mixed declaration after nested rules.

Sass is changing how it processes declarations that appear after nested
rules to align with CSS standards. Previously, Sass would hoist
declarations to avoid duplicating selectors, However, this behavior will
soon change to make declarations apply in the order they appear, as per
CSS standards.
2024-07-20 11:56:31 +02:00
undergroundwires
dd7239b8c1 Bump dependencies to latest 2024-07-19 10:29:38 +02:00
undergroundwires
851917e049 Refactor text utilities and expand their usage
This commit refactors existing text utility functions into the
application layer for broad reuse and integrates them across
the codebase. Initially, these utilities were confined to test
code, which limited their application.

Changes:

- Move text utilities to the application layer.
- Centralize text utilities into dedicated files for better
  maintainability.
- Improve robustness of utility functions with added type checks.
- Replace duplicated logic with centralized utility functions
  throughout the codebase.
- Expand unit tests to cover refactored code parts.
2024-07-18 20:49:21 +02:00
undergroundwires
8d7a7eb434 win: support Microsoft Store Firefox installations
This commit updates the Windows scripts to handle Firefox installations
acquired through the Microsoft Store. It adds support by modifying
script functions to clear and delete profile directories specific to
this version of Firefox.
2024-07-10 08:37:59 +02:00
undergroundwires
0239b52385 win: refactor version-specific actions
Optimize PowerShell script invocation to differentiate actions based on
Windows version. This revision introduces a more efficient way to handle
version-specific scripting within Windows collection by abstraction
complexity into dedicated shared functions.
2024-07-09 19:09:06 +02:00
421 changed files with 35102 additions and 13414 deletions

View File

@@ -0,0 +1,12 @@
inputs:
project-root:
required: false
default: '.'
runs:
using: composite
steps:
-
name: Install ImageMagick
shell: bash
run: ./.github/actions/install-imagemagick/install-imagemagick.sh
working-directory: ${{ inputs.project-root }}

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
main() {
local install_command
if ! install_command=$(get_install_command); then
fatal_error 'Could not find available command to install'
fi
if ! eval "$install_command"; then
echo "Failed to install ImageMagick. Command: ${install_command}"
exit 1
fi
echo 'ImageMagick installation completed successfully'
}
get_install_command() {
case "$OSTYPE" in
darwin*)
ensure_command_exists 'brew'
echo 'brew install imagemagick'
;;
linux-gnu*)
if is_ubuntu; then
ensure_command_exists 'apt'
echo 'sudo apt install -y imagemagick'
else
fatal_error 'Unsupported Linux distribution'
fi
;;
msys*|cygwin*)
ensure_command_exists 'choco'
echo 'choco install -y imagemagick'
;;
*)
fatal_error "Unsupported operating system: $OSTYPE"
;;
esac
}
ensure_command_exists() {
local -r command="$1"
if ! command -v "$command" >/dev/null 2>&1; then
fatal_error "Command missing: $command"
fi
}
fatal_error() {
local -r error_message="$1"
>&2 echo "$error_message"
exit 1
}
is_ubuntu() {
[ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release
}
main

View File

@@ -26,9 +26,12 @@ jobs:
- -
name: Install dependencies name: Install dependencies
uses: ./.github/actions/npm-install-dependencies uses: ./.github/actions/npm-install-dependencies
-
name: Install ImageMagick # For screenshots
uses: ./.github/actions/install-imagemagick
- -
name: Configure Ubuntu name: Configure Ubuntu
if: contains(matrix.os, 'ubuntu') # macOS runner is missing Docker if: contains(matrix.os, 'ubuntu')
shell: bash shell: bash
run: |- run: |-
sudo apt update sudo apt update
@@ -56,11 +59,20 @@ jobs:
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
echo "DISPLAY=:99" >> $GITHUB_ENV echo "DISPLAY=:99" >> $GITHUB_ENV
# Install ImageMagick for screenshots
sudo apt install -y imagemagick
# Install xdotool and xprop (from x11-utils) for window title capturing # Install xdotool and xprop (from x11-utils) for window title capturing
sudo apt install -y xdotool x11-utils sudo apt install -y xdotool x11-utils
# Workaround for Electron AppImage apps failing to initialize on Ubuntu 24.04 due to AppArmor restrictions
# Disables unprivileged user namespaces restriction to allow Electron apps to run
# Reference: https://github.com/electron/electron/issues/42510
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Workaround for Mesa driver issues on Ubuntu 24.04
# Installs latest Mesa drivers from Kisak PPA
# Reference: https://askubuntu.com/q/1516040
sudo add-apt-repository ppa:kisak/kisak-mesa
sudo apt update
sudo apt upgrade
- -
name: Test name: Test
shell: bash shell: bash

View File

@@ -11,8 +11,9 @@ jobs:
- npm run lint:eslint - npm run lint:eslint
- npm run lint:yaml - npm run lint:yaml
- npm run lint:md - npm run lint:md
- npm run lint:md:relative-urls
- npm run lint:md:consistency - npm run lint:md:consistency
- npm run lint:md:relative-urls
- npm run lint:md:external-urls
os: [ macos, ubuntu, windows ] os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations fail-fast: false # Still interested to see results from other combinations
steps: steps:

View File

@@ -9,16 +9,15 @@ jobs:
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-latest
strategy: strategy:
matrix: matrix:
os: [ macos, ubuntu, windows ] os: [macos, ubuntu, windows]
fail-fast: false # Still interested to see results from other combinations fail-fast: false # Still interested to see results from other combinations
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- -
name: Install ImageMagick on macOS name: Install ImageMagick
if: matrix.os == 'macos' uses: ./.github/actions/install-imagemagick
run: brew install imagemagick
- -
name: Setup node name: Setup node
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node

View File

@@ -1,5 +1,38 @@
# Changelog # Changelog
## 0.13.6 (2024-08-13)
* win: improve service disabling as TrustedInstaller | [5d365f6](https://github.com/undergroundwires/privacy.sexy/commit/5d365f65fa0e34925b16b2eac2af53c31e34e99a)
* Fix documentation button spacing on small screens | [70959cc](https://github.com/undergroundwires/privacy.sexy/commit/70959ccadafac5abcfa83e90cdb0537890b05f14)
* Fix close button overlap by scrollbar | [19ea8db](https://github.com/undergroundwires/privacy.sexy/commit/19ea8dbc5bc2dc436200cd40bf2a84c3fc3c6471)
* win: refactor version-specific actions | [0239b52](https://github.com/undergroundwires/privacy.sexy/commit/0239b523859d5c2b80033cc03f0248a9af35f28f)
* win: support Microsoft Store Firefox installations | [8d7a7eb](https://github.com/undergroundwires/privacy.sexy/commit/8d7a7eb434b2d83e32fa758db7e6798849bad41c)
* Refactor text utilities and expand their usage | [851917e](https://github.com/undergroundwires/privacy.sexy/commit/851917e049c41c679644ddbe8ad4b6e45e5c8f35)
* Bump dependencies to latest | [dd7239b](https://github.com/undergroundwires/privacy.sexy/commit/dd7239b8c14027274926279a4c8c7e5845b55558)
* Refactor styles to match new CSS nesting behavior | [abe03ce](https://github.com/undergroundwires/privacy.sexy/commit/abe03cef3f691f6e56faee991cd2da9c45244279)
* Improve compiler error display for latest Chromium | [b16e136](https://github.com/undergroundwires/privacy.sexy/commit/b16e13678ce1b8a6871eba8196e82bb321410067)
* Fix intermittent `ModalDialog` unit test failures | [a650558](https://github.com/undergroundwires/privacy.sexy/commit/a6505587bf4a448f5f3de930004a95ee203416b8)
* Ensure tests do not log warning or errors | [ae0165f](https://github.com/undergroundwires/privacy.sexy/commit/ae0165f1fe7dba9dd8ddaa1afa722a939772d3b6)
* win: improve disabling SmartScreen #385 | [11e566d](https://github.com/undergroundwires/privacy.sexy/commit/11e566d0e5177214a2600f3fd2097aea62373b24)
* win: unify registry setting as TrustedInstaller | [8526d25](https://github.com/undergroundwires/privacy.sexy/commit/8526d2510b34cbd7e79342f79d444419f601b186)
* win: improve, fix, restructure CEIP disabling | [c2d3cdd](https://github.com/undergroundwires/privacy.sexy/commit/c2d3cddc47d8d4b34bff63d959612919fa971012)
* win: centralize, improve Defender data collection | [b185255](https://github.com/undergroundwires/privacy.sexy/commit/b185255a0a72d5bfa96d6cf60f868ecc67149d68)
* win: fix and document VStudio license removal | [109fc01](https://github.com/undergroundwires/privacy.sexy/commit/109fc01c9a047002c4309e7f8a2ca4647c494a8a)
* win: improve registry/recent cleaning | [48d97af](https://github.com/undergroundwires/privacy.sexy/commit/48d97afdf6c2964cab7951208e1b0a02c3fd4c9b)
* Relax linting to allow null recommendation | [6fbc816](https://github.com/undergroundwires/privacy.sexy/commit/6fbc81675f7f063c4ee2502b8d9f169aacb39ae4)
* Refactor executable IDs to use strings #262 | [ded55a6](https://github.com/undergroundwires/privacy.sexy/commit/ded55a66d6044a03d4e18330e146b69d159509a3)
* win: fix, improve and unify Windows version logic | [f89c232](https://github.com/undergroundwires/privacy.sexy/commit/f89c2322b05d19b82914b20416ecefd7bc7e3702)
* Fix PowerShell code block inlining in compiler | [d77c3cb](https://github.com/undergroundwires/privacy.sexy/commit/d77c3cbbe212d9929e083181cc331b45d01e2883)
* win: improve registry value deletion #381 | [55c23e9](https://github.com/undergroundwires/privacy.sexy/commit/55c23e9d4cee3b7f74c26a4ac8516535048d67f2)
* win: improve folder hiding in "This PC" #16 | [e8add5e](https://github.com/undergroundwires/privacy.sexy/commit/e8add5ec08d2e8b7636cc9c8f0f9a33e4b004265)
* win: improve Microsoft Edge associations removal | [c2f4b68](https://github.com/undergroundwires/privacy.sexy/commit/c2f4b6878635e97f9c4be7bf2ee194a2deebb38a)
* win: unify registry data setting, fix #380 | [4cea6b2](https://github.com/undergroundwires/privacy.sexy/commit/4cea6b26ec2717c792c2471cc587f370274f90c4)
* win: improve disabling NCSI #189, #216, #279 | [c7e57b8](https://github.com/undergroundwires/privacy.sexy/commit/c7e57b8913f409a1c149ba598dc2f8786df0f9a9)
* win, mac: fix minor typos, formatting, dead URLs | [29e1069](https://github.com/undergroundwires/privacy.sexy/commit/29e1069bf2bc317e3c255b38c1ba0ab078b42d98)
* win: fix, constrain and document WNS #227 #314 | [50ba00b](https://github.com/undergroundwires/privacy.sexy/commit/50ba00b0af6232fc9187532635b04c4d9d9a68af)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.5...0.13.6)
## 0.13.5 (2024-06-26) ## 0.13.5 (2024-06-26)
* ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703) * ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703)

View File

@@ -122,7 +122,7 @@
## Get started ## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy). - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-Setup-0.13.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.AppImage). For more options, see [here](#additional-install-options). - 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-Setup-0.13.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.AppImage). For more options, see [here](#additional-install-options).
See also: See also:

View File

@@ -30,6 +30,8 @@ Related documentation:
### Executables ### Executables
They represent independently executable actions with documentation and reversibility.
An Executable is a logical entity that can An Executable is a logical entity that can
- execute once compiled, - execute once compiled,

View File

@@ -34,8 +34,10 @@ The desktop version ensures secure delivery through cryptographic signatures and
[Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy. [Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs. > **Note for macOS users:**
> On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually. > Users get notified about updates but might need to complete the installation manually.
> Updater stores update installation files temporarily at `$HOME/Library/Application Support/privacy.sexy/updates`.
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️. > Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
### Logging ### Logging

View File

@@ -39,6 +39,7 @@ See [ci-cd.md](./ci-cd.md) for more information.
- Markdown: `npm run lint:md` - Markdown: `npm run lint:md`
- Markdown consistency `npm run lint:md:consistency` - Markdown consistency `npm run lint:md:consistency`
- Markdown relative URLs: `npm run lint:md:relative-urls` - Markdown relative URLs: `npm run lint:md:relative-urls`
- Markdown external URLs: `npm run lint:md:external-urls`
- JavaScript/TypeScript: `npm run lint:eslint` - JavaScript/TypeScript: `npm run lint:eslint`
- Yaml: `npm run lint:yaml` - Yaml: `npm run lint:yaml`

10099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.5", "version": "0.13.6",
"private": true, "private": true,
"slogan": "Privacy is sexy", "slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
@@ -14,7 +14,7 @@
"test:integration": "vitest run --dir tests/integration", "test:integration": "vitest run --dir tests/integration",
"test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"", "test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"",
"test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"", "test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint", "lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:md:external-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint",
"install-deps": "node scripts/npm-install.js", "install-deps": "node scripts/npm-install.js",
"icons:build": "node scripts/logo-update.js", "icons:build": "node scripts/logo-update.js",
"check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node", "check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node",
@@ -28,60 +28,61 @@
"lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links", "lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:md:external-urls": "remark . --frail --use remark-lint-no-dead-urls",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"lint:pylint": "pylint **/*.py", "lint:pylint": "pylint **/*.py",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps" "postuninstall": "electron-builder install-app-deps"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.0.6", "@floating-ui/vue": "^1.1.1",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.33.0", "ace-builds": "^1.35.3",
"electron-log": "^5.1.2", "electron-log": "^5.1.6",
"electron-progressbar": "^2.2.1", "electron-progressbar": "^2.2.1",
"electron-updater": "^6.1.9", "electron-updater": "^6.2.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"vue": "^3.4.27" "vue": "^3.4.32"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@rushstack/eslint-patch": "^1.10.2", "@rushstack/eslint-patch": "^1.10.3",
"@types/ace": "^0.0.52", "@types/ace": "^0.0.52",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/markdown-it": "^14.0.1", "@types/markdown-it": "^14.1.1",
"@typescript-eslint/eslint-plugin": "6.21.0", "@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0", "@typescript-eslint/parser": "6.21.0",
"@vitejs/plugin-legacy": "^5.3.2", "@vitejs/plugin-legacy": "^5.4.1",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0", "@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
"@vue/eslint-config-typescript": "12.0.0", "@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "^2.4.5", "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"cypress": "^13.7.3", "cypress": "^13.13.1",
"electron": "^31.0.2", "electron": "^31.2.1",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.1.0", "electron-vite": "^2.3.0",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^3.3.0",
"eslint-plugin-vue": "^9.25.0", "eslint-plugin-vue": "^9.27.0",
"eslint-plugin-vuejs-accessibility": "^2.2.1", "eslint-plugin-vuejs-accessibility": "^2.4.0",
"jsdom": "^24.0.0", "jsdom": "^24.1.0",
"markdownlint-cli": "^0.39.0", "markdownlint-cli": "^0.41.0",
"postcss": "^8.4.38", "postcss": "^8.4.39",
"remark-cli": "^12.0.0", "remark-cli": "^12.0.1",
"remark-lint-no-dead-urls": "^1.1.0", "remark-lint-no-dead-urls": "^2.0.0",
"remark-preset-lint-consistent": "^6.0.0", "remark-preset-lint-consistent": "^6.0.0",
"remark-validate-links": "^13.0.1", "remark-validate-links": "^13.0.1",
"sass": "^1.75.0", "sass": "~1.79.4",
"start-server-and-test": "^2.0.3", "start-server-and-test": "^2.0.4",
"terser": "^5.30.3", "terser": "^5.31.3",
"tslib": "^2.6.2", "tslib": "^2.6.3",
"typescript": "^5.4.5", "typescript": "~5.5.4",
"vite": "^5.2.8", "vite": "^5.4.8",
"vitest": "^1.5.0", "vitest": "^2.0.3",
"vue-tsc": "^2.0.13", "vue-tsc": "^2.0.26",
"yaml-lint": "^1.7.0" "yaml-lint": "^1.7.0"
}, },
"//devDependencies": { "//devDependencies": {

View File

@@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) {
if (!match) { if (!match) {
die( die(
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``, `No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
`\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`, `\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`,
); );
} }
} }

View File

@@ -33,23 +33,25 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
if (!casedValue) { if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`); throw new Error(`unknown ${enumName}: "${value}"`);
} }
return enumVariable[casedValue as keyof typeof enumVariable]; return enumVariable[casedValue as keyof EnumVariable<T, TEnumValue>];
} }
export function getEnumNames export function getEnumNames
<T extends EnumType, TEnumValue extends EnumType>( <T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): string[] { ): (string & keyof EnumVariable<T, TEnumValue>)[] {
return Object return Object
.values(enumVariable) .values(enumVariable)
.filter((enumMember): enumMember is string => isString(enumMember)); .filter((
enumMember,
): enumMember is string & (keyof EnumVariable<T, TEnumValue>) => isString(enumMember));
} }
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>( export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): TEnumValue[] { ): TEnumValue[] {
return getEnumNames(enumVariable) return getEnumNames(enumVariable)
.map((level) => enumVariable[level]) as TEnumValue[]; .map((name) => enumVariable[name]) as TEnumValue[];
} }
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>( export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(

View File

@@ -0,0 +1,25 @@
import { isArray } from '@/TypeHelpers';
export type OptionalString = string | undefined | null;
export function filterEmptyStrings(
texts: readonly OptionalString[],
isArrayType: typeof isArray = isArray,
): string[] {
if (!isArrayType(texts)) {
throw new Error(`Invalid input: Expected an array, but received type ${typeof texts}.`);
}
assertArrayItemsAreStringLike(texts);
return texts
.filter((title): title is string => Boolean(title));
}
function assertArrayItemsAreStringLike(
texts: readonly unknown[],
): asserts texts is readonly OptionalString[] {
const invalidItems = texts.filter((item) => !(typeof item === 'string' || item === undefined || item === null));
if (invalidItems.length > 0) {
const invalidTypes = invalidItems.map((item) => typeof item).join(', ');
throw new Error(`Invalid array items: Expected items as string, undefined, or null. Received invalid types: ${invalidTypes}.`);
}
}

View File

@@ -0,0 +1,29 @@
import { isString } from '@/TypeHelpers';
import { splitTextIntoLines } from './SplitTextIntoLines';
export function indentText(
text: string,
indentLevel = 1,
utilities: TextIndentationUtilities = DefaultUtilities,
): string {
if (!utilities.isStringType(text)) {
throw new Error(`Indentation error: The input must be a string. Received type: ${typeof text}.`);
}
if (indentLevel <= 0) {
throw new Error(`Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`);
}
const indentation = '\t'.repeat(indentLevel);
return utilities.splitIntoLines(text)
.map((line) => (line ? `${indentation}${line}` : line))
.join('\n');
}
interface TextIndentationUtilities {
readonly splitIntoLines: typeof splitTextIntoLines;
readonly isStringType: typeof isString;
}
const DefaultUtilities: TextIndentationUtilities = {
splitIntoLines: splitTextIntoLines,
isStringType: isString,
};

View File

@@ -0,0 +1,11 @@
import { isString } from '@/TypeHelpers';
export function splitTextIntoLines(
text: string,
isStringType = isString,
): string[] {
if (!isStringType(text)) {
throw new Error(`Line splitting error: Expected a string but received type '${typeof text}'.`);
}
return text.split(/\r\n|\r|\n/);
}

View File

@@ -1,6 +1,6 @@
import type { IApplication } from '@/domain/IApplication'; import type { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { assertInRange } from '@/application/Common/Enum'; import { assertInRange } from '@/application/Common/Enum';
import { CategoryCollectionState } from './State/CategoryCollectionState'; import { CategoryCollectionState } from './State/CategoryCollectionState';
@@ -17,7 +17,7 @@ export class ApplicationContext implements IApplicationContext {
public currentOs: OperatingSystem; public currentOs: OperatingSystem;
public get state(): ICategoryCollectionState { public get state(): ICategoryCollectionState {
return this.states[this.collection.os]; return this.getState(this.collection.os);
} }
private readonly states: StateMachine; private readonly states: StateMachine;
@@ -26,30 +26,51 @@ export class ApplicationContext implements IApplicationContext {
public readonly app: IApplication, public readonly app: IApplication,
initialContext: OperatingSystem, initialContext: OperatingSystem,
) { ) {
this.setContext(initialContext);
this.states = initializeStates(app); this.states = initializeStates(app);
this.changeContext(initialContext);
} }
public changeContext(os: OperatingSystem): void { public changeContext(os: OperatingSystem): void {
assertInRange(os, OperatingSystem);
if (this.currentOs === os) { if (this.currentOs === os) {
return; return;
} }
const collection = this.app.getCollection(os);
this.collection = collection;
const event: IApplicationContextChangedEvent = { const event: IApplicationContextChangedEvent = {
newState: this.states[os], newState: this.getState(os),
oldState: this.states[this.currentOs], oldState: this.getState(this.currentOs),
}; };
this.setContext(os);
this.contextChanged.notify(event); this.contextChanged.notify(event);
}
private setContext(os: OperatingSystem): void {
validateOperatingSystem(os, this.app);
this.collection = this.app.getCollection(os);
this.currentOs = os; this.currentOs = os;
} }
private getState(os: OperatingSystem): ICategoryCollectionState {
const state = this.states.get(os);
if (!state) {
throw new Error(`Operating system "${OperatingSystem[os]}" state is unknown.`);
}
return state;
}
}
function validateOperatingSystem(
os: OperatingSystem,
app: IApplication,
): void {
assertInRange(os, OperatingSystem);
if (!app.getSupportedOsList().includes(os)) {
throw new Error(`Operating system "${OperatingSystem[os]}" is not supported.`);
}
} }
function initializeStates(app: IApplication): StateMachine { function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>(); const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) { for (const collection of app.collections) {
machine[collection.os] = new CategoryCollectionState(collection); machine.set(collection.os, new CategoryCollectionState(collection));
} }
return machine; return machine;
} }

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { AdaptiveFilterContext } from './Filter/AdaptiveFilterContext'; import { AdaptiveFilterContext } from './Filter/AdaptiveFilterContext';
import { ApplicationCode } from './Code/ApplicationCode'; import { ApplicationCode } from './Code/ApplicationCode';

View File

@@ -1,6 +1,8 @@
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { ICodeChangedEvent } from './ICodeChangedEvent'; import type { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
@@ -36,12 +38,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
} }
public getScriptPositionInCode(script: Script): ICodePosition { public getScriptPositionInCode(script: Script): ICodePosition {
return this.getPositionById(script.id); return this.getPositionById(script.executableId);
} }
private getPositionById(scriptId: string): ICodePosition { private getPositionById(scriptId: ExecutableId): ICodePosition {
const position = [...this.scripts.entries()] const position = [...this.scripts.entries()]
.filter(([s]) => s.id === scriptId) .filter(([s]) => s.executableId === scriptId)
.map(([, pos]) => pos) .map(([, pos]) => pos)
.at(0); .at(0);
if (!position) { if (!position) {
@@ -52,12 +54,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
} }
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) { function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = script.split(/\r\n|\r|\n/).length; const totalLines = splitTextIntoLines(script).length;
const missingPositions = positions.filter((position) => position.endLine > totalLines); const missingPositions = positions.filter((position) => position.endLine > totalLines);
if (missingPositions.length > 0) { if (missingPositions.length > 0) {
throw new Error( throw new Error(
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"` `Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
+ `(total code lines: ${totalLines}).`, + ` (total code lines: ${totalLines}).`,
); );
} }
} }

View File

@@ -1,3 +1,4 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeBuilder } from './ICodeBuilder'; import type { ICodeBuilder } from './ICodeBuilder';
const TotalFunctionSeparatorChars = 58; const TotalFunctionSeparatorChars = 58;
@@ -15,7 +16,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
this.lines.push(''); this.lines.push('');
return this; return this;
} }
const lines = code.match(/[^\r\n]+/g); const lines = splitTextIntoLines(code);
if (lines) { if (lines) {
this.lines.push(...lines); this.lines.push(...lines);
} }

View File

@@ -1,5 +1,5 @@
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { FilterChange } from './Event/FilterChange'; import { FilterChange } from './Event/FilterChange';
import { LinearFilterStrategy } from './Strategy/LinearFilterStrategy'; import { LinearFilterStrategy } from './Strategy/LinearFilterStrategy';
import type { FilterResult } from './Result/FilterResult'; import type { FilterResult } from './Result/FilterResult';

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { FilterResult } from '../Result/FilterResult'; import type { FilterResult } from '../Result/FilterResult';
export interface FilterStrategy { export interface FilterStrategy {

View File

@@ -1,7 +1,7 @@
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { Documentable } from '@/domain/Executables/Documentable'; import type { Documentable } from '@/domain/Executables/Documentable';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { AppliedFilterResult } from '../Result/AppliedFilterResult'; import { AppliedFilterResult } from '../Result/AppliedFilterResult';
import type { FilterStrategy } from './FilterStrategy'; import type { FilterStrategy } from './FilterStrategy';

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { IApplicationCode } from './Code/IApplicationCode'; import type { IApplicationCode } from './Code/IApplicationCode';
import type { ReadonlyFilterContext, FilterContext } from './Filter/FilterContext'; import type { ReadonlyFilterContext, FilterContext } from './Filter/FilterContext';

View File

@@ -1,3 +1,5 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
type CategorySelectionStatus = { type CategorySelectionStatus = {
readonly isSelected: true; readonly isSelected: true;
readonly isReverted: boolean; readonly isReverted: boolean;
@@ -6,7 +8,7 @@ type CategorySelectionStatus = {
}; };
export interface CategorySelectionChange { export interface CategorySelectionChange {
readonly categoryId: number; readonly categoryId: ExecutableId;
readonly newStatus: CategorySelectionStatus; readonly newStatus: CategorySelectionStatus;
} }

View File

@@ -1,5 +1,5 @@
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange'; import type { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange';
import type { CategorySelection } from './CategorySelection'; import type { CategorySelection } from './CategorySelection';
import type { ScriptSelection } from '../Script/ScriptSelection'; import type { ScriptSelection } from '../Script/ScriptSelection';
@@ -23,7 +23,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
return false; return false;
} }
return scripts.every( return scripts.every(
(script) => selectedScripts.some((selected) => selected.id === script.id), (script) => selectedScripts.some((selected) => selected.id === script.executableId),
); );
} }
@@ -50,7 +50,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
const scripts = category.getAllScriptsRecursively(); const scripts = category.getAllScriptsRecursively();
const scriptsChangesInCategory = scripts const scriptsChangesInCategory = scripts
.map((script): ScriptSelectionChange => ({ .map((script): ScriptSelectionChange => ({
scriptId: script.id, scriptId: script.executableId,
newStatus: { newStatus: {
...change.newStatus, ...change.newStatus,
}, },

View File

@@ -2,8 +2,9 @@ import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryReposito
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository'; import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce'; import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { UserSelectedScript } from './UserSelectedScript'; import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection'; import type { ScriptSelection } from './ScriptSelection';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange'; import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
@@ -16,7 +17,7 @@ export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeComma
export class DebouncedScriptSelection implements ScriptSelection { export class DebouncedScriptSelection implements ScriptSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>(); public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: Repository<string, SelectedScript>; private readonly scripts: Repository<SelectedScript>;
public readonly processChanges: ScriptSelection['processChanges']; public readonly processChanges: ScriptSelection['processChanges'];
@@ -25,7 +26,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
selectedScripts: ReadonlyArray<SelectedScript>, selectedScripts: ReadonlyArray<SelectedScript>,
debounce: DebounceFunction = batchedDebounce, debounce: DebounceFunction = batchedDebounce,
) { ) {
this.scripts = new InMemoryRepository<string, SelectedScript>(); this.scripts = new InMemoryRepository<SelectedScript>();
for (const script of selectedScripts) { for (const script of selectedScripts) {
this.scripts.addItem(script); this.scripts.addItem(script);
} }
@@ -38,8 +39,8 @@ export class DebouncedScriptSelection implements ScriptSelection {
); );
} }
public isSelected(scriptId: string): boolean { public isSelected(scriptExecutableId: ExecutableId): boolean {
return this.scripts.exists(scriptId); return this.scripts.exists(scriptExecutableId);
} }
public get selectedScripts(): readonly SelectedScript[] { public get selectedScripts(): readonly SelectedScript[] {
@@ -49,7 +50,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
public selectAll(): void { public selectAll(): void {
const scriptsToSelect = this.collection const scriptsToSelect = this.collection
.getAllScripts() .getAllScripts()
.filter((script) => !this.scripts.exists(script.id)) .filter((script) => !this.scripts.exists(script.executableId))
.map((script) => new UserSelectedScript(script, false)); .map((script) => new UserSelectedScript(script, false));
if (scriptsToSelect.length === 0) { if (scriptsToSelect.length === 0) {
return; return;
@@ -116,12 +117,12 @@ export class DebouncedScriptSelection implements ScriptSelection {
private applyChange(change: ScriptSelectionChange): number { private applyChange(change: ScriptSelectionChange): number {
const script = this.collection.getScript(change.scriptId); const script = this.collection.getScript(change.scriptId);
if (change.newStatus.isSelected) { if (change.newStatus.isSelected) {
return this.addOrUpdateScript(script.id, change.newStatus.isReverted); return this.addOrUpdateScript(script.executableId, change.newStatus.isReverted);
} }
return this.removeScript(script.id); return this.removeScript(script.executableId);
} }
private addOrUpdateScript(scriptId: string, revert: boolean): number { private addOrUpdateScript(scriptId: ExecutableId, revert: boolean): number {
const script = this.collection.getScript(scriptId); const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert); const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) { if (!this.scripts.exists(selectedScript.id)) {
@@ -136,7 +137,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return 1; return 1;
} }
private removeScript(scriptId: string): number { private removeScript(scriptId: ExecutableId): number {
if (!this.scripts.exists(scriptId)) { if (!this.scripts.exists(scriptId)) {
return 0; return 0;
} }
@@ -152,24 +153,24 @@ function assertNonEmptyScriptSelection(selectedItems: readonly Script[]) {
} }
function getScriptIdsToBeSelected( function getScriptIdsToBeSelected(
existingItems: ReadonlyRepository<string, SelectedScript>, existingItems: ReadonlyRepository<SelectedScript>,
desiredScripts: readonly Script[], desiredScripts: readonly Script[],
): string[] { ): string[] {
return desiredScripts return desiredScripts
.filter((script) => !existingItems.exists(script.id)) .filter((script) => !existingItems.exists(script.executableId))
.map((script) => script.id); .map((script) => script.executableId);
} }
function getScriptIdsToBeDeselected( function getScriptIdsToBeDeselected(
existingItems: ReadonlyRepository<string, SelectedScript>, existingItems: ReadonlyRepository<SelectedScript>,
desiredScripts: readonly Script[], desiredScripts: readonly Script[],
): string[] { ): string[] {
return existingItems return existingItems
.getItems() .getItems()
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id)) .filter((existing) => !desiredScripts.some((script) => existing.id === script.executableId))
.map((script) => script.id); .map((script) => script.id);
} }
function equals(a: SelectedScript, b: SelectedScript): boolean { function equals(a: SelectedScript, b: SelectedScript): boolean {
return a.script.equals(b.script.id) && a.revert === b.revert; return a.script.executableId === b.script.executableId && a.revert === b.revert;
} }

View File

@@ -1,12 +1,13 @@
import type { IEventSource } from '@/infrastructure/Events/IEventSource'; import type { IEventSource } from '@/infrastructure/Events/IEventSource';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { SelectedScript } from './SelectedScript'; import type { SelectedScript } from './SelectedScript';
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange'; import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection { export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>; readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[]; readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptId: string): boolean; isSelected(scriptExecutableId: ExecutableId): boolean;
} }
export interface ScriptSelection extends ReadonlyScriptSelection { export interface ScriptSelection extends ReadonlyScriptSelection {

View File

@@ -1,3 +1,5 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export type ScriptSelectionStatus = { export type ScriptSelectionStatus = {
readonly isSelected: true; readonly isSelected: true;
readonly isReverted: boolean; readonly isReverted: boolean;
@@ -7,7 +9,7 @@ export type ScriptSelectionStatus = {
}; };
export interface ScriptSelectionChange { export interface ScriptSelectionChange {
readonly scriptId: string; readonly scriptId: ExecutableId;
readonly newStatus: ScriptSelectionStatus; readonly newStatus: ScriptSelectionStatus;
} }

View File

@@ -1,9 +1,7 @@
import type { IEntity } from '@/infrastructure/Entity/IEntity';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { RepositoryEntity } from '@/application/Repository/RepositoryEntity';
type ScriptId = Script['id']; export interface SelectedScript extends RepositoryEntity {
export interface SelectedScript extends IEntity<ScriptId> {
readonly script: Script; readonly script: Script;
readonly revert: boolean; readonly revert: boolean;
} }

View File

@@ -1,17 +1,16 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { SelectedScript } from './SelectedScript'; import type { RepositoryEntity } from '@/application/Repository/RepositoryEntity';
type SelectedScriptId = SelectedScript['id']; export class UserSelectedScript implements RepositoryEntity {
public readonly id: string;
export class UserSelectedScript extends BaseEntity<SelectedScriptId> {
constructor( constructor(
public readonly script: Script, public readonly script: Script,
public readonly revert: boolean, public readonly revert: boolean,
) { ) {
super(script.id); this.id = script.executableId;
if (revert && !script.canRevert()) { if (revert && !script.canRevert()) {
throw new Error(`The script with ID '${script.id}' is not reversible and cannot be reverted.`); throw new Error(`The script with ID '${script.executableId}' is not reversible and cannot be reverted.`);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper'; import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper';
import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection'; import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection';
import type { CategorySelection } from './Category/CategorySelection'; import type { CategorySelection } from './Category/CategorySelection';

View File

@@ -31,7 +31,7 @@ function validateCollectionsData(
) { ) {
validator.assertNonEmptyCollection({ validator.assertNonEmptyCollection({
value: collections, value: collections,
valueName: 'collections', valueName: 'Collections',
}); });
} }

View File

@@ -1,13 +1,13 @@
import type { CollectionData } from '@/application/collections/'; import type { CollectionData } from '@/application/collections/';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection'; import { CategoryCollection } from '@/domain/Collection/CategoryCollection';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { createEnumParser, type EnumParser } from '../Common/Enum'; import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities'; import { createCategoryCollectionContext, type CategoryCollectionContextFactory } from './Executable/CategoryCollectionContext';
export const parseCategoryCollection: CategoryCollectionParser = ( export const parseCategoryCollection: CategoryCollectionParser = (
content, content,
@@ -16,9 +16,9 @@ export const parseCategoryCollection: CategoryCollectionParser = (
) => { ) => {
validateCollection(content, utilities.validator); validateCollection(content, utilities.validator);
const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails); const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails);
const collectionUtilities = utilities.createUtilities(content.functions, scripting); const collectionContext = utilities.createContext(content.functions, scripting.language);
const categories = content.actions.map( const categories = content.actions.map(
(action) => utilities.parseCategory(action, collectionUtilities), (action) => utilities.parseCategory(action, collectionContext),
); );
const os = utilities.osParser.parseEnum(content.os, 'os'); const os = utilities.osParser.parseEnum(content.os, 'os');
const collection = utilities.createCategoryCollection({ const collection = utilities.createCategoryCollection({
@@ -45,14 +45,14 @@ function validateCollection(
): void { ): void {
validator.assertObject({ validator.assertObject({
value: content, value: content,
valueName: 'collection', valueName: 'Collection',
allowedProperties: [ allowedProperties: [
'os', 'scripting', 'actions', 'functions', 'os', 'scripting', 'actions', 'functions',
], ],
}); });
validator.assertNonEmptyCollection({ validator.assertNonEmptyCollection({
value: content.actions, value: content.actions,
valueName: '"actions" in collection', valueName: '\'actions\' in collection',
}); });
} }
@@ -60,7 +60,7 @@ interface CategoryCollectionParserUtilities {
readonly osParser: EnumParser<OperatingSystem>; readonly osParser: EnumParser<OperatingSystem>;
readonly validator: TypeValidator; readonly validator: TypeValidator;
readonly parseScriptingDefinition: ScriptingDefinitionParser; readonly parseScriptingDefinition: ScriptingDefinitionParser;
readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory; readonly createContext: CategoryCollectionContextFactory;
readonly parseCategory: CategoryParser; readonly parseCategory: CategoryParser;
readonly createCategoryCollection: CategoryCollectionFactory; readonly createCategoryCollection: CategoryCollectionFactory;
} }
@@ -69,7 +69,7 @@ const DefaultUtilities: CategoryCollectionParserUtilities = {
osParser: createEnumParser(OperatingSystem), osParser: createEnumParser(OperatingSystem),
validator: createTypeValidator(), validator: createTypeValidator(),
parseScriptingDefinition, parseScriptingDefinition,
createUtilities: createCollectionUtilities, createContext: createCategoryCollectionContext,
parseCategory, parseCategory,
createCategoryCollection: (...args) => new CategoryCollection(...args), createCategoryCollection: (...args) => new CategoryCollection(...args),
}; };

View File

@@ -1,42 +1,116 @@
import { CustomError } from '@/application/Common/CustomError'; import { CustomError } from '@/application/Common/CustomError';
import { indentText } from '@/application/Common/Text/IndentText';
export interface ErrorWithContextWrapper { export interface ErrorWithContextWrapper {
( (
error: Error, innerError: Error,
additionalContext: string, additionalContext: string,
): Error; ): Error;
} }
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = ( export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
error: Error, innerError,
additionalContext: string, additionalContext,
) => { ) => {
return (error instanceof ContextualError ? error : new ContextualError(error)) if (!additionalContext) {
.withAdditionalContext(additionalContext); throw new Error('Missing additional context');
}
return new ContextualError({
innerError,
additionalContext,
});
}; };
/* AggregateError is similar but isn't well-serialized or displayed by browsers */ /**
* Class for building a detailed error trace.
*
* Alternatives considered:
* - `AggregateError`:
* Similar but not well-serialized or displayed by browsers such as Chromium (last tested v126).
* - `cause` property:
* Not displayed by all browsers (last tested v126).
* Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
*
* This is immutable where the constructor sets the values because using getter functions such as
* `get cause()`, `get message()` does not work on Chromium (last tested v126), but works fine on
* Firefox (last tested v127).
*/
class ContextualError extends CustomError { class ContextualError extends CustomError {
private readonly additionalContext = new Array<string>(); constructor(public readonly context: ErrorContext) {
super(
constructor( generateDetailedErrorMessageWithContext(context),
public readonly innerError: Error, {
) { cause: context.innerError,
super(); },
} );
public withAdditionalContext(additionalContext: string): this {
this.additionalContext.push(additionalContext);
return this;
}
public get message(): string { // toString() is not used when Chromium logs it on console
return [
'\n',
this.innerError.message,
'\n',
'Additional context:',
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
].join('\n');
} }
} }
interface ErrorContext {
readonly innerError: Error;
readonly additionalContext: string;
}
function generateDetailedErrorMessageWithContext(
context: ErrorContext,
): string {
return [
'\n',
// Display the current error message first, then the root cause.
// This prevents repetitive main messages for errors with a `cause:` chain,
// aligning with browser error display conventions.
context.additionalContext,
'\n',
'Error Trace (starting from root cause):',
indentText(
formatErrorTrace(
// Displaying contexts from the top frame (deepest, most recent) aligns with
// common debugger/compiler standard.
extractErrorTraceAscendingFromDeepest(context),
),
),
'\n',
].join('\n');
}
function extractErrorTraceAscendingFromDeepest(
context: ErrorContext,
): string[] {
const originalError = findRootError(context.innerError);
const contextsDescendingFromMostRecent: string[] = [
context.additionalContext,
...gatherContextsFromErrorChain(context.innerError),
originalError.toString(),
];
const contextsAscendingFromDeepest = contextsDescendingFromMostRecent.reverse();
return contextsAscendingFromDeepest;
}
function findRootError(error: Error): Error {
if (error instanceof ContextualError) {
return findRootError(error.context.innerError);
}
return error;
}
function gatherContextsFromErrorChain(
error: Error,
accumulatedContexts: string[] = [],
): string[] {
if (error instanceof ContextualError) {
accumulatedContexts.push(error.context.additionalContext);
return gatherContextsFromErrorChain(error.context.innerError, accumulatedContexts);
}
return accumulatedContexts;
}
function formatErrorTrace(
errorMessages: readonly string[],
): string {
if (errorMessages.length === 1) {
return errorMessages[0];
}
return errorMessages
.map((context, index) => `${index + 1}.${indentText(context)}`)
.join('\n');
}

View File

@@ -108,7 +108,7 @@ function assertArray(
valueName: string, valueName: string,
): asserts value is Array<unknown> { ): asserts value is Array<unknown> {
if (!isArray(value)) { if (!isArray(value)) {
throw new Error(`'${valueName}' should be of type 'array', but is of type '${typeof value}'.`); throw new Error(`${valueName} should be of type 'array', but is of type '${typeof value}'.`);
} }
} }
@@ -117,7 +117,7 @@ function assertString(
valueName: string, valueName: string,
): asserts value is string { ): asserts value is string {
if (!isString(value)) { if (!isString(value)) {
throw new Error(`'${valueName}' should be of type 'string', but is of type '${typeof value}'.`); throw new Error(`${valueName} should be of type 'string', but is of type '${typeof value}'.`);
} }
} }

View File

@@ -0,0 +1,33 @@
import type { FunctionData } from '@/application/collections/';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createScriptCompiler, type ScriptCompilerFactory } from './Script/Compiler/ScriptCompilerFactory';
import type { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
export interface CategoryCollectionContext {
readonly compiler: ScriptCompiler;
readonly language: ScriptingLanguage;
}
export interface CategoryCollectionContextFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory?: ScriptCompilerFactory,
): CategoryCollectionContext;
}
export const createCategoryCollectionContext: CategoryCollectionContextFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory: ScriptCompilerFactory = createScriptCompiler,
) => {
return {
compiler: compilerFactory({
categoryContext: {
functions: functionsData ?? [],
language,
},
}),
language,
};
};

View File

@@ -1,35 +0,0 @@
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import type { FunctionData } from '@/application/collections/';
import { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
import { SyntaxFactory } from './Script/Validation/Syntax/SyntaxFactory';
import type { IScriptCompiler } from './Script/Compiler/IScriptCompiler';
import type { ILanguageSyntax } from './Script/Validation/Syntax/ILanguageSyntax';
import type { ISyntaxFactory } from './Script/Validation/Syntax/ISyntaxFactory';
export interface CategoryCollectionSpecificUtilities {
readonly compiler: IScriptCompiler;
readonly syntax: ILanguageSyntax;
}
export const createCollectionUtilities: CategoryCollectionSpecificUtilitiesFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
) => {
const syntax = syntaxFactory.create(scripting.language);
return {
compiler: new ScriptCompiler({
functions: functionsData ?? [],
syntax,
}),
syntax,
};
};
export interface CategoryCollectionSpecificUtilitiesFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory?: ISyntaxFactory,
): CategoryCollectionSpecificUtilities;
}

View File

@@ -3,24 +3,22 @@ import type {
} from '@/application/collections/'; } from '@/application/collections/';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createCategory, type CategoryFactory } from '@/domain/Executables/Category/CategoryFactory';
import { parseDocs, type DocsParser } from './DocumentationParser'; import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
import { ExecutableType } from './Validation/ExecutableType'; import { ExecutableType } from './Validation/ExecutableType';
import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities'; import type { CategoryCollectionContext } from './CategoryCollectionContext';
let categoryIdCounter = 0;
export const parseCategory: CategoryParser = ( export const parseCategory: CategoryParser = (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
) => { ) => {
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
collectionUtilities, collectionContext,
categoryUtilities, categoryUtilities,
}); });
}; };
@@ -28,14 +26,14 @@ export const parseCategory: CategoryParser = (
export interface CategoryParser { export interface CategoryParser {
( (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
categoryUtilities?: CategoryParserUtilities, categoryUtilities?: CategoryParserUtilities,
): Category; ): Category;
} }
interface CategoryParseContext { interface CategoryParseContext {
readonly categoryData: CategoryData; readonly categoryData: CategoryData;
readonly collectionUtilities: CategoryCollectionSpecificUtilities; readonly collectionContext: CategoryCollectionContext;
readonly parentCategory?: CategoryData; readonly parentCategory?: CategoryData;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -54,12 +52,12 @@ function parseCategoryRecursively(
children, children,
parent: context.categoryData, parent: context.categoryData,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
collectionUtilities: context.collectionUtilities, collectionContext: context.collectionContext,
}); });
} }
try { try {
return context.categoryUtilities.createCategory({ return context.categoryUtilities.createCategory({
id: categoryIdCounter++, executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support
name: context.categoryData.category, name: context.categoryData.category,
docs: context.categoryUtilities.parseDocs(context.categoryData), docs: context.categoryUtilities.parseDocs(context.categoryData),
subcategories: children.subcategories, subcategories: children.subcategories,
@@ -84,7 +82,7 @@ function ensureValidCategory(
}); });
validator.assertType((v) => v.assertObject({ validator.assertType((v) => v.assertObject({
value: category, value: category,
valueName: category.category ?? 'category', valueName: category.category ? `Category '${category.category}'` : 'Category',
allowedProperties: [ allowedProperties: [
'docs', 'children', 'category', 'docs', 'children', 'category',
], ],
@@ -106,7 +104,7 @@ interface ExecutableParseContext {
readonly data: ExecutableData; readonly data: ExecutableData;
readonly children: CategoryChildren; readonly children: CategoryChildren;
readonly parent: CategoryData; readonly parent: CategoryData;
readonly collectionUtilities: CategoryCollectionSpecificUtilities; readonly collectionContext: CategoryCollectionContext;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -126,13 +124,13 @@ function parseUnknownExecutable(context: ExecutableParseContext) {
if (isCategory(context.data)) { if (isCategory(context.data)) {
const subCategory = parseCategoryRecursively({ const subCategory = parseCategoryRecursively({
categoryData: context.data, categoryData: context.data,
collectionUtilities: context.collectionUtilities, collectionContext: context.collectionContext,
parentCategory: context.parent, parentCategory: context.parent,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
}); });
context.children.subcategories.push(subCategory); context.children.subcategories.push(subCategory);
} else { // A script } else { // A script
const script = context.categoryUtilities.parseScript(context.data, context.collectionUtilities); const script = context.categoryUtilities.parseScript(context.data, context.collectionContext);
context.children.subscripts.push(script); context.children.subscripts.push(script);
} }
} }
@@ -166,10 +164,6 @@ function hasProperty(
return Object.prototype.hasOwnProperty.call(object, propertyName); return Object.prototype.hasOwnProperty.call(object, propertyName);
} }
export type CategoryFactory = (
...parameters: ConstructorParameters<typeof CollectionCategory>
) => Category;
interface CategoryParserUtilities { interface CategoryParserUtilities {
readonly createCategory: CategoryFactory; readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
@@ -179,7 +173,7 @@ interface CategoryParserUtilities {
} }
const DefaultCategoryParserUtilities: CategoryParserUtilities = { const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new CollectionCategory(...parameters), createCategory,
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator, createValidator: createExecutableDataValidator,
parseScript, parseScript,

View File

@@ -1,4 +1,4 @@
export interface IPipe { export interface Pipe {
readonly name: string; readonly name: string;
apply(input: string): string; apply(input: string): string;
} }

View File

@@ -1,11 +1,11 @@
import type { IPipe } from '../IPipe'; import type { Pipe } from '../Pipe';
export class EscapeDoubleQuotes implements IPipe { export class EscapeDoubleQuotes implements Pipe {
public readonly name: string = 'escapeDoubleQuotes'; public readonly name: string = 'escapeDoubleQuotes';
public apply(raw: string): string { public apply(raw: string): string {
if (!raw) { if (!raw) {
return raw; return '';
} }
return raw.replaceAll('"', '"^""'); return raw.replaceAll('"', '"^""');
/* eslint-disable vue/max-len */ /* eslint-disable vue/max-len */

View File

@@ -1,6 +1,7 @@
import type { IPipe } from '../IPipe'; import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { Pipe } from '../Pipe';
export class InlinePowerShell implements IPipe { export class InlinePowerShell implements Pipe {
public readonly name: string = 'inlinePowerShell'; public readonly name: string = 'inlinePowerShell';
public apply(code: string): string { public apply(code: string): string {
@@ -8,9 +9,11 @@ export class InlinePowerShell implements IPipe {
return code; return code;
} }
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent" const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
// Order is important
inlineComments, inlineComments,
mergeLinesWithBacktick,
mergeHereStrings, mergeHereStrings,
mergeLinesWithBacktick,
mergeLinesWithBracketCodeBlocks,
mergeNewLines, mergeNewLines,
]).reduce((a, b) => (data) => b(a(data))); ]).reduce((a, b) => (data) => b(a(data)));
const newCode = processor(code); const newCode = processor(code);
@@ -89,10 +92,6 @@ function inlineComments(code: string): string {
*/ */
} }
function getLines(code: string): string[] {
return (code?.split(/\r\n|\r|\n/) || []);
}
/* /*
Merges inline here-strings to a single lined string with Windows line terminator (\r\n) Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
@@ -102,18 +101,18 @@ function mergeHereStrings(code: string) {
return code.replaceAll(regex, (_$, quotes, scope) => { return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes); const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes); const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = getLines(escaped); const lines = splitTextIntoLines(escaped);
const inlined = lines.join(newString.separator); const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`; const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted; return quoted;
}); });
} }
interface IInlinedHereString { interface InlinedHereString {
readonly quotesAround: string; readonly quotesAround: string;
readonly escapedQuotes: string; readonly escapedQuotes: string;
readonly separator: string; readonly separator: string;
} }
function getHereStringHandler(quotes: string): IInlinedHereString { function getHereStringHandler(quotes: string): InlinedHereString {
/* /*
We handle @' and @" differently. We handle @' and @" differently.
Single quotes are interpreted literally and doubles are expandable. Single quotes are interpreted literally and doubles are expandable.
@@ -158,9 +157,33 @@ function mergeLinesWithBacktick(code: string) {
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' '); return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
} }
function mergeNewLines(code: string) { /**
return getLines(code) * Inlines code blocks in PowerShell scripts while preserving correct syntax.
.map((line) => line.trim()) * It removes unnecessary newlines and spaces around brackets,
.filter((line) => line.length > 0) * inlining the code where possible.
.join('; '); * This prevents syntax errors like "Unexpected token '}'" when inlining brackets.
*/
function mergeLinesWithBracketCodeBlocks(code: string): string {
return code
// Opening bracket: [whitespace] Opening bracket (newline)
.replace(/(?<=.*)\s*{[\r\n][\s\r\n]*/g, ' { ')
// Closing bracket: [whitespace] Closing bracket (newline) (continuation keyword)
.replace(/\s*}[\r\n][\s\r\n]*(?=elseif|else|catch|finally|until)/g, ' } ')
.replace(/(?<=do\s*{.*)[\r\n\s]*}[\r\n][\r\n\s]*(?=while)/g, ' } '); // Do-While
}
function mergeNewLines(code: string) {
const nonEmptyLines = splitTextIntoLines(code)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return nonEmptyLines
.map((line, index) => {
const isLastLine = index === nonEmptyLines.length - 1;
if (isLastLine) {
return line;
}
return line.endsWith(';') ? line : `${line};`;
})
.join(' ');
} }

View File

@@ -1,6 +1,6 @@
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell'; import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes'; import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
import type { IPipe } from './IPipe'; import type { Pipe } from './Pipe';
const RegisteredPipes = [ const RegisteredPipes = [
new EscapeDoubleQuotes(), new EscapeDoubleQuotes(),
@@ -8,19 +8,19 @@ const RegisteredPipes = [
]; ];
export interface IPipeFactory { export interface IPipeFactory {
get(pipeName: string): IPipe; get(pipeName: string): Pipe;
} }
export class PipeFactory implements IPipeFactory { export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, IPipe>(); private readonly pipes = new Map<string, Pipe>();
constructor(pipes: readonly IPipe[] = RegisteredPipes) { constructor(pipes: readonly Pipe[] = RegisteredPipes) {
for (const pipe of pipes) { for (const pipe of pipes) {
this.registerPipe(pipe); this.registerPipe(pipe);
} }
} }
public get(pipeName: string): IPipe { public get(pipeName: string): Pipe {
validatePipeName(pipeName); validatePipeName(pipeName);
const pipe = this.pipes.get(pipeName); const pipe = this.pipes.get(pipeName);
if (!pipe) { if (!pipe) {
@@ -29,7 +29,7 @@ export class PipeFactory implements IPipeFactory {
return pipe; return pipe;
} }
private registerPipe(pipe: IPipe): void { private registerPipe(pipe: Pipe): void {
validatePipeName(pipe.name); validatePipeName(pipe.name);
if (this.pipes.has(pipe.name)) { if (this.pipes.has(pipe.name)) {
throw new Error(`Pipe name must be unique: "${pipe.name}"`); throw new Error(`Pipe name must be unique: "${pipe.name}"`);

View File

@@ -22,7 +22,7 @@ export const createFunctionCallArgument: FunctionCallArgumentFactory = (
utilities.validateParameterName(parameterName); utilities.validateParameterName(parameterName);
utilities.typeValidator.assertNonEmptyString({ utilities.typeValidator.assertNonEmptyString({
value: argumentValue, value: argumentValue,
valueName: `Missing argument value for the parameter "${parameterName}".`, valueName: `Function parameter '${parameterName}'`,
}); });
return { return {
parameterName, parameterName,

View File

@@ -1,3 +1,4 @@
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { CompiledCode } from '../CompiledCode'; import type { CompiledCode } from '../CompiledCode';
import type { CodeSegmentMerger } from './CodeSegmentMerger'; import type { CodeSegmentMerger } from './CodeSegmentMerger';
@@ -8,11 +9,9 @@ export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
} }
return { return {
code: joinCodeParts(codeSegments.map((f) => f.code)), code: joinCodeParts(codeSegments.map((f) => f.code)),
revertCode: joinCodeParts( revertCode: joinCodeParts(filterEmptyStrings(
codeSegments codeSegments.map((f) => f.revertCode),
.map((f) => f.revertCode) )),
.filter((code): code is string => Boolean(code)),
),
}; };
} }
} }

View File

@@ -3,6 +3,7 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip
import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction'; import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { indentText } from '@/application/Common/Text/IndentText';
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy'; import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy { export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
@@ -22,10 +23,12 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
if (calledFunction.body.type !== FunctionBodyType.Code) { if (calledFunction.body.type !== FunctionBodyType.Code) {
throw new Error([ throw new Error([
'Unexpected function body type.', 'Unexpected function body type.',
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`, indentText([
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`, `Expected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`Actual: "${FunctionBodyType[calledFunction.body.type]}"`,
].join('\n')),
'Function:', 'Function:',
`\t${JSON.stringify(callToFunction)}`, indentText(JSON.stringify(callToFunction)),
].join('\n')); ].join('\n'));
} }
const { code } = calledFunction.body; const { code } = calledFunction.body;

View File

@@ -42,7 +42,7 @@ function getCallSequence(calls: FunctionCallsData, validator: TypeValidator): Fu
if (isArray(calls)) { if (isArray(calls)) {
validator.assertNonEmptyCollection({ validator.assertNonEmptyCollection({
value: calls, value: calls,
valueName: 'function call sequence', valueName: 'Function call sequence',
}); });
return calls as FunctionCallData[]; return calls as FunctionCallData[];
} }
@@ -56,7 +56,7 @@ function parseFunctionCall(
): FunctionCall { ): FunctionCall {
utilities.typeValidator.assertObject({ utilities.typeValidator.assertObject({
value: call, value: call,
valueName: 'function call', valueName: 'Function call',
allowedProperties: ['function', 'parameters'], allowedProperties: ['function', 'parameters'],
}); });
const callArgs = parseArgs(call.parameters, utilities.createCallArgument); const callArgs = parseArgs(call.parameters, utilities.createCallArgument);

View File

@@ -13,7 +13,7 @@ export const validateParameterName = (
) => { ) => {
typeValidator.assertNonEmptyString({ typeValidator.assertNonEmptyString({
value: parameterName, value: parameterName,
valueName: 'parameter name', valueName: 'Parameter name',
rule: { rule: {
expectedMatch: /^[0-9a-zA-Z]+$/, expectedMatch: /^[0-9a-zA-Z]+$/,
errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`, errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`,

View File

@@ -2,13 +2,12 @@ import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
CallInstruction, ParameterDefinitionData, CallInstruction, ParameterDefinitionData,
} from '@/application/collections/'; } from '@/application/collections/';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection'; import { SharedFunctionCollection } from './SharedFunctionCollection';
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser'; import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
@@ -22,14 +21,14 @@ import type { ISharedFunction } from './ISharedFunction';
export interface SharedFunctionsParser { export interface SharedFunctionsParser {
( (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities?: SharedFunctionsParsingUtilities, utilities?: SharedFunctionsParsingUtilities,
): ISharedFunctionCollection; ): ISharedFunctionCollection;
} }
export const parseSharedFunctions: SharedFunctionsParser = ( export const parseSharedFunctions: SharedFunctionsParser = (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities = DefaultUtilities, utilities = DefaultUtilities,
) => { ) => {
const collection = new SharedFunctionCollection(); const collection = new SharedFunctionCollection();
@@ -38,7 +37,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
} }
ensureValidFunctions(functions); ensureValidFunctions(functions);
return functions return functions
.map((func) => parseFunction(func, syntax, utilities)) .map((func) => parseFunction(func, language, utilities))
.reduce((acc, func) => { .reduce((acc, func) => {
acc.addFunction(func); acc.addFunction(func);
return acc; return acc;
@@ -48,7 +47,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
const DefaultUtilities: SharedFunctionsParsingUtilities = { const DefaultUtilities: SharedFunctionsParsingUtilities = {
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
parseParameter: parseFunctionParameter, parseParameter: parseFunctionParameter,
codeValidator: CodeValidator.instance, codeValidator: validateCode,
createParameterCollection: createFunctionParameterCollection, createParameterCollection: createFunctionParameterCollection,
parseFunctionCalls, parseFunctionCalls,
}; };
@@ -56,20 +55,20 @@ const DefaultUtilities: SharedFunctionsParsingUtilities = {
interface SharedFunctionsParsingUtilities { interface SharedFunctionsParsingUtilities {
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly parseParameter: FunctionParameterParser; readonly parseParameter: FunctionParameterParser;
readonly codeValidator: ICodeValidator; readonly codeValidator: CodeValidator;
readonly createParameterCollection: FunctionParameterCollectionFactory; readonly createParameterCollection: FunctionParameterCollectionFactory;
readonly parseFunctionCalls: FunctionCallsParser; readonly parseFunctionCalls: FunctionCallsParser;
} }
function parseFunction( function parseFunction(
data: FunctionData, data: FunctionData,
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities: SharedFunctionsParsingUtilities, utilities: SharedFunctionsParsingUtilities,
): ISharedFunction { ): ISharedFunction {
const { name } = data; const { name } = data;
const parameters = parseParameters(data, utilities); const parameters = parseParameters(data, utilities);
if (hasCode(data)) { if (hasCode(data)) {
validateCode(data, syntax, utilities.codeValidator); validateNonEmptyCode(data, language, utilities.codeValidator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
} }
// Has call // Has call
@@ -77,17 +76,20 @@ function parseFunction(
return createCallerFunction(name, parameters, calls); return createCallerFunction(name, parameters, calls);
} }
function validateCode( function validateNonEmptyCode(
data: CodeFunctionData, data: CodeFunctionData,
syntax: ILanguageSyntax, language: ScriptingLanguage,
validator: ICodeValidator, validate: CodeValidator,
): void { ): void {
[data.code, data.revertCode] filterEmptyStrings([data.code, data.revertCode])
.filter((code): code is string => Boolean(code))
.forEach( .forEach(
(code) => validator.throwIfInvalid( (code) => validate(
code, code,
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
],
), ),
); );
} }
@@ -204,9 +206,9 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
if (duplicateCodes.length > 0) { if (duplicateCodes.length > 0) {
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`); throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
} }
const duplicateRevertCodes = getDuplicates(callFunctions const duplicateRevertCodes = getDuplicates(filterEmptyStrings(
.map((func) => func.revertCode) callFunctions.map((func) => func.revertCode),
.filter((code): code is string => Boolean(code))); ));
if (duplicateRevertCodes.length > 0) { if (duplicateRevertCodes.length > 0) {
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`); throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
} }

View File

@@ -1,7 +0,0 @@
import type { ScriptData } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
export interface IScriptCompiler {
canCompile(script: ScriptData): boolean;
compile(script: ScriptData): ScriptCode;
}

View File

@@ -1,87 +1,7 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; import type { ScriptData } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { IScriptCompiler } from './IScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
interface ScriptCompilerUtilities { export interface ScriptCompiler {
readonly sharedFunctionsParser: SharedFunctionsParser; canCompile(script: ScriptData): boolean;
readonly callCompiler: FunctionCallCompiler; compile(script: ScriptData): ScriptCode;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly syntax: ILanguageSyntax;
}
export class ScriptCompiler implements IScriptCompiler {
private readonly functions: ISharedFunctionCollection;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.syntax,
);
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.utilities.codeValidator);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
[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;
} }

View File

@@ -0,0 +1,119 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { ScriptCompiler } from './ScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
export interface ScriptCompilerInitParameters {
readonly categoryContext: CategoryCollectionDataContext;
readonly utilities?: ScriptCompilerUtilities;
}
export interface ScriptCompilerFactory {
(parameters: ScriptCompilerInitParameters): ScriptCompiler;
}
export const createScriptCompiler: ScriptCompilerFactory = (
parameters,
) => {
return new FunctionCallScriptCompiler(
parameters.categoryContext,
parameters.utilities ?? DefaultUtilities,
);
};
interface ScriptCompilerUtilities {
readonly sharedFunctionsParser: SharedFunctionsParser;
readonly callCompiler: FunctionCallCompiler;
readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly language: ScriptingLanguage;
}
class FunctionCallScriptCompiler implements ScriptCompiler {
private readonly functions: ISharedFunctionCollection;
private readonly language: ScriptingLanguage;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.language,
);
this.language = categoryContext.language;
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(
compiledCode,
this.language,
this.utilities.codeValidator,
);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(
compiledCode: CompiledCode,
language: ScriptingLanguage,
validate: CodeValidator,
): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validate(
code,
language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoTooLongLines,
// Allow duplicated lines to enable calling same function multiple times
],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
}

View File

@@ -1,33 +1,32 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CollectionScript } from '@/domain/Executables/Script/CollectionScript';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type EnumParser } from '@/application/Common/Enum'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { parseDocs, type DocsParser } from '../DocumentationParser'; import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType'; import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
import { CodeValidator } from './Validation/CodeValidator'; import type { CategoryCollectionContext } from '../CategoryCollectionContext';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities';
export interface ScriptParser { export interface ScriptParser {
( (
data: ScriptData, data: ScriptData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
scriptUtilities?: ScriptParserUtilities, scriptUtilities?: ScriptParserUtilities,
): Script; ): Script;
} }
export const parseScript: ScriptParser = ( export const parseScript: ScriptParser = (
data, data,
collectionUtilities, collectionContext,
scriptUtilities = DefaultUtilities, scriptUtilities = DefaultUtilities,
) => { ) => {
const validator = scriptUtilities.createValidator({ const validator = scriptUtilities.createValidator({
@@ -37,10 +36,11 @@ export const parseScript: ScriptParser = (
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = scriptUtilities.createScript({ const script = scriptUtilities.createScript({
executableId: data.name, // Pseudo-ID for uniqueness until real ID support
name: data.name, name: data.name,
code: parseCode( code: parseCode(
data, data,
collectionUtilities, collectionContext,
scriptUtilities.codeValidator, scriptUtilities.codeValidator,
scriptUtilities.createCode, scriptUtilities.createCode,
), ),
@@ -68,30 +68,34 @@ function parseLevel(
function parseCode( function parseCode(
script: ScriptData, script: ScriptData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
codeValidator: ICodeValidator, codeValidator: CodeValidator,
createCode: ScriptCodeFactory, createCode: ScriptCodeFactory,
): ScriptCode { ): ScriptCode {
if (collectionUtilities.compiler.canCompile(script)) { if (collectionContext.compiler.canCompile(script)) {
return collectionUtilities.compiler.compile(script); return collectionContext.compiler.compile(script);
} }
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = createCode(codeScript.code, codeScript.revertCode); const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax); validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode, scriptCode: ScriptCode,
validator: ICodeValidator, validate: CodeValidator,
syntax: ILanguageSyntax, language: ScriptingLanguage,
) { ) {
[scriptCode.execute, scriptCode.revert] filterEmptyStrings([scriptCode.execute, scriptCode.revert])
.filter((code): code is string => Boolean(code))
.forEach( .forEach(
(code) => validator.throwIfInvalid( (code) => validate(
code, code,
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
CodeValidationRule.NoTooLongLines,
],
), ),
); );
} }
@@ -102,7 +106,7 @@ function validateScript(
): asserts script is NonNullable<ScriptData> { ): asserts script is NonNullable<ScriptData> {
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({ validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
value: script, value: script,
valueName: script.name ?? 'script', valueName: script.name ? `Script '${script.name}'` : 'Script',
allowedProperties: [ allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs', 'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
], ],
@@ -125,25 +129,17 @@ function validateScript(
interface ScriptParserUtilities { interface ScriptParserUtilities {
readonly levelParser: EnumParser<RecommendationLevel>; readonly levelParser: EnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory; readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator; readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: ExecutableValidatorFactory; readonly createValidator: ExecutableValidatorFactory;
readonly createCode: ScriptCodeFactory; readonly createCode: ScriptCodeFactory;
readonly parseDocs: DocsParser; readonly parseDocs: DocsParser;
} }
export type ScriptFactory = (
...parameters: ConstructorParameters<typeof CollectionScript>
) => Script;
const createScript: ScriptFactory = (...parameters) => {
return new CollectionScript(...parameters);
};
const DefaultUtilities: ScriptParserUtilities = { const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel), levelParser: createEnumParser(RecommendationLevel),
createScript, createScript,
codeValidator: CodeValidator.instance, codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator, createValidator: createExecutableDataValidator,
createCode: createScriptCode, createCode: createScriptCode,

View File

@@ -0,0 +1,63 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createSyntax, type SyntaxFactory } from './Syntax/SyntaxFactory';
import type { CodeLine, CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export type DuplicateLinesAnalyzer = CodeValidationAnalyzer & {
(
...args: [
...Parameters<CodeValidationAnalyzer>,
syntaxFactory?: SyntaxFactory,
]
): ReturnType<CodeValidationAnalyzer>;
};
export const analyzeDuplicateLines: DuplicateLinesAnalyzer = (
lines: readonly CodeLine[],
language: ScriptingLanguage,
syntaxFactory: SyntaxFactory = createSyntax,
) => {
const syntax = syntaxFactory(language);
return lines
.map((line): CodeLineWithDuplicateOccurrences => ({
lineNumber: line.lineNumber,
shouldBeIgnoredInAnalysis: shouldIgnoreLine(line.text, syntax),
duplicateLineNumbers: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.lineNumber),
}))
.filter((line) => isNonIgnorableDuplicateLine(line))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: `Line is duplicated at line numbers ${line.duplicateLineNumbers.join(',')}.`,
}));
};
interface CodeLineWithDuplicateOccurrences {
readonly lineNumber: number;
readonly duplicateLineNumbers: readonly number[];
readonly shouldBeIgnoredInAnalysis: boolean;
}
function isNonIgnorableDuplicateLine(line: CodeLineWithDuplicateOccurrences): boolean {
return !line.shouldBeIgnoredInAnalysis && line.duplicateLineNumbers.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: LanguageSyntax): boolean {
return isCommentLine(codeLine, syntax)
|| isLineComposedEntirelyOfCommonCodeParts(codeLine, syntax);
}
function isCommentLine(codeLine: string, syntax: LanguageSyntax): boolean {
return syntax.commentDelimiters.some(
(delimiter) => codeLine.startsWith(delimiter),
);
}
function isLineComposedEntirelyOfCommonCodeParts(
codeLine: string,
syntax: LanguageSyntax,
): boolean {
const codeLineParts = codeLine.toLowerCase().trim().split(' ');
return codeLineParts.every((part) => syntax.commonCodeParts.includes(part));
}

View File

@@ -0,0 +1,24 @@
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeEmptyLines: CodeValidationAnalyzer = (
lines,
) => {
return lines
.filter((line) => isEmptyLine(line.text))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
};
function isEmptyLine(line: string): boolean {
return line.trim().length === 0;
}

View File

@@ -0,0 +1,44 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeTooLongLines: CodeValidationAnalyzer = (
lines,
language,
) => {
const maxLineLength = getMaxAllowedLineLength(language);
return lines
.filter((line) => line.text.length > maxLineLength)
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: [
`Line is too long (${line.text.length}).`,
`It exceed maximum allowed length ${maxLineLength} by ${line.text.length - maxLineLength} characters.`,
'This may cause bugs due to unintended trimming by operating system, shells or terminal emulators.',
].join(' '),
}));
};
function getMaxAllowedLineLength(language: ScriptingLanguage): number {
switch (language) {
case ScriptingLanguage.batchfile:
/*
The maximum length of the string that you can use at the command prompt is 8191 characters.
https://web.archive.org/web/20240815120224/https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation
*/
return 8191;
case ScriptingLanguage.shellscript:
/*
Tests show:
| OS | Command | Value |
| --- | ------- | ----- |
| Pop!_OS 22.04 | xargs --show-limits | 2088784 |
| macOS Sonoma 14.3 on Intel | getconf ARG_MAX | 1048576 |
| macOS Sonoma 14.3 on Apple Silicon M1 | getconf ARG_MAX | 1048576 |
| Android 12 (4.14.180) with Termux | xargs --show-limits | 2087244 |
*/
return 1048576; // Minimum value for reliability
default:
throw new Error(`Unsupported language: ${ScriptingLanguage[language]} (${language})`);
}
}

View File

@@ -0,0 +1,18 @@
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface CodeValidationAnalyzer {
(
lines: readonly CodeLine[],
language: ScriptingLanguage,
): InvalidCodeLine[];
}
export interface InvalidCodeLine {
readonly lineNumber: number;
readonly error: string;
}
export interface CodeLine {
readonly lineNumber: number;
readonly text: string;
}

View File

@@ -1,9 +1,9 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
const BatchFileCommonCodeParts = ['(', ')', 'else', '||']; const BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
const PowerShellCommonCodeParts = ['{', '}']; const PowerShellCommonCodeParts = ['{', '}'];
export class BatchFileSyntax implements ILanguageSyntax { export class BatchFileSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['REM', '::']; public readonly commentDelimiters = ['REM', '::'];
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts]; public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];

View File

@@ -0,0 +1,4 @@
export interface LanguageSyntax {
readonly commentDelimiters: readonly string[];
readonly commonCodeParts: readonly string[];
}

View File

@@ -0,0 +1,7 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
export class ShellScriptSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -0,0 +1,19 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
export interface SyntaxFactory {
(language: ScriptingLanguage): LanguageSyntax;
}
export const createSyntax: SyntaxFactory = (language: ScriptingLanguage): LanguageSyntax => {
switch (language) {
case ScriptingLanguage.batchfile:
return new BatchFileSyntax();
case ScriptingLanguage.shellscript:
return new ShellScriptSyntax();
default:
throw new RangeError(`Invalid language: "${ScriptingLanguage[language]}"`);
}
};

View File

@@ -0,0 +1,5 @@
export enum CodeValidationRule {
NoEmptyLines,
NoDuplicatedLines,
NoTooLongLines,
}

View File

@@ -1,46 +1,78 @@
import type { ICodeLine } from './ICodeLine'; import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule'; import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { ICodeValidator } from './ICodeValidator'; import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory';
import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
import type { CodeValidationRule } from './CodeValidationRule';
export class CodeValidator implements ICodeValidator { export interface CodeValidator {
public static readonly instance: ICodeValidator = new CodeValidator(); (
public throwIfInvalid(
code: string, code: string,
rules: readonly ICodeValidationRule[], language: ScriptingLanguage,
): void { rules: readonly CodeValidationRule[],
if (rules.length === 0) { throw new Error('missing rules'); } analyzerFactory?: ValidationRuleAnalyzerFactory,
): void;
}
export const validateCode: CodeValidator = (
code,
language,
rules,
analyzerFactory = createValidationAnalyzers,
) => {
const analyzers = analyzerFactory(rules);
if (!code) { if (!code) {
return; return;
} }
const lines = extractLines(code); const lines = extractLines(code);
const invalidLines = rules.flatMap((rule) => rule.analyze(lines)); const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language));
if (invalidLines.length === 0) { if (invalidLines.length === 0) {
return; return;
} }
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`; const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`;
throw new Error(errorText); throw new Error(errorText);
} };
}
function extractLines(code: string): ICodeLine[] { function extractLines(code: string): CodeLine[] {
return code const lines = splitTextIntoLines(code);
.split(/\r\n|\r|\n/) return lines.map((lineText, lineIndex): CodeLine => ({
.map((lineText, lineIndex): ICodeLine => ({ lineNumber: lineIndex + 1,
index: lineIndex + 1,
text: lineText, text: lineText,
})); }));
} }
function printLines( function formatLines(
lines: readonly ICodeLine[], lines: readonly CodeLine[],
invalidLines: readonly IInvalidCodeLine[], invalidLines: readonly InvalidCodeLine[],
): string { ): string {
return lines.map((line) => { return lines.map((line) => {
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index); const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber);
if (!badLine) { return formatLine({
return `[${line.index}] ✅ ${line.text}`; lineNumber: line.lineNumber,
} text: line.text,
return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`; error: badLine?.error,
});
}).join('\n'); }).join('\n');
} }
function formatLine(
line: {
readonly lineNumber: number;
readonly text: string;
readonly error?: string;
},
): string {
let text = `[${line.lineNumber}] `;
text += line.error ? '❌' : '✅';
text += ` ${trimLine(line.text)}`;
if (line.error) {
text += `\n\t⟶ ${line.error}`;
}
return text;
}
function trimLine(line: string) {
const maxLength = 500;
if (line.length > maxLength) {
line = `${line.substring(0, maxLength)}... [Rest of the line trimmed]`;
}
return line;
}

View File

@@ -1,4 +0,0 @@
export interface ICodeLine {
readonly index: number;
readonly text: string;
}

View File

@@ -1,10 +0,0 @@
import type { ICodeLine } from './ICodeLine';
export interface IInvalidCodeLine {
readonly index: number;
readonly error: string;
}
export interface ICodeValidationRule {
analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[];
}

View File

@@ -1,8 +0,0 @@
import type { ICodeValidationRule } from './ICodeValidationRule';
export interface ICodeValidator {
throwIfInvalid(
code: string,
rules: readonly ICodeValidationRule[],
): void;
}

View File

@@ -1,45 +0,0 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import type { ICodeLine } from '../ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
export class NoDuplicatedLines implements ICodeValidationRule {
constructor(private readonly syntax: ILanguageSyntax) { }
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines
.map((line): IDuplicateAnalyzedLine => ({
index: line.index,
isIgnored: shouldIgnoreLine(line.text, this.syntax),
occurrenceIndices: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.index),
}))
.filter((line) => hasInvalidDuplicates(line))
.map((line): IInvalidCodeLine => ({
index: line.index,
error: `Line is duplicated at line numbers ${line.occurrenceIndices.join(',')}.`,
}));
}
}
interface IDuplicateAnalyzedLine {
readonly index: number;
readonly occurrenceIndices: readonly number[];
readonly isIgnored: boolean;
}
function hasInvalidDuplicates(line: IDuplicateAnalyzedLine): boolean {
return !line.isIgnored && line.occurrenceIndices.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
const lowerCaseCodeLine = codeLine.toLowerCase();
const isCommentLine = () => syntax.commentDelimiters.some(
(delimiter) => lowerCaseCodeLine.startsWith(delimiter),
);
const consistsOfFrequentCommands = () => {
const trimmed = lowerCaseCodeLine.trim().split(' ');
return trimmed.every((part) => syntax.commonCodeParts.includes(part));
};
return isCommentLine() || consistsOfFrequentCommands();
}

View File

@@ -1,21 +0,0 @@
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
import type { ICodeLine } from '../ICodeLine';
export class NoEmptyLines implements ICodeValidationRule {
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines
.filter((line) => (line.text?.trim().length ?? 0) === 0)
.map((line): IInvalidCodeLine => ({
index: line.index,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
}
}

View File

@@ -1,4 +0,0 @@
export interface ILanguageSyntax {
readonly commentDelimiters: string[];
readonly commonCodeParts: string[];
}

View File

@@ -1,4 +0,0 @@
import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
import type { ILanguageSyntax } from './ILanguageSyntax';
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;

View File

@@ -1,7 +0,0 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
export class ShellScriptSyntax implements ILanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -1,16 +0,0 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
import type { ISyntaxFactory } from './ISyntaxFactory';
export class SyntaxFactory
extends ScriptingLanguageFactory<ILanguageSyntax>
implements ISyntaxFactory {
constructor() {
super();
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax());
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax());
}
}

View File

@@ -0,0 +1,47 @@
import { CodeValidationRule } from './CodeValidationRule';
import { analyzeDuplicateLines } from './Analyzers/AnalyzeDuplicateLines';
import { analyzeEmptyLines } from './Analyzers/AnalyzeEmptyLines';
import { analyzeTooLongLines } from './Analyzers/AnalyzeTooLongLines';
import type { CodeValidationAnalyzer } from './Analyzers/CodeValidationAnalyzer';
export interface ValidationRuleAnalyzerFactory {
(
rules: readonly CodeValidationRule[],
): CodeValidationAnalyzer[];
}
export const createValidationAnalyzers: ValidationRuleAnalyzerFactory = (
rules,
): CodeValidationAnalyzer[] => {
if (rules.length === 0) { throw new Error('missing rules'); }
validateUniqueRules(rules);
return rules.map((rule) => createValidationRule(rule));
};
function createValidationRule(rule: CodeValidationRule): CodeValidationAnalyzer {
switch (rule) {
case CodeValidationRule.NoEmptyLines:
return analyzeEmptyLines;
case CodeValidationRule.NoDuplicatedLines:
return analyzeDuplicateLines;
case CodeValidationRule.NoTooLongLines:
return analyzeTooLongLines;
default:
throw new Error(`Unknown rule: ${rule}`);
}
}
function validateUniqueRules(
rules: readonly CodeValidationRule[],
): void {
const ruleCounts = new Map<CodeValidationRule, number>();
rules.forEach((rule) => {
ruleCounts.set(rule, (ruleCounts.get(rule) || 0) + 1);
});
const duplicates = Array.from(ruleCounts.entries())
.filter(([, count]) => count > 1)
.map(([rule, count]) => `${CodeValidationRule[rule]} (${count} times)`);
if (duplicates.length > 0) {
throw new Error(`Duplicate rules are not allowed. Duplicates found: ${duplicates.join(', ')}`);
}
}

View File

@@ -37,7 +37,7 @@ function validateData(
): void { ): void {
validator.assertObject({ validator.assertObject({
value: data, value: data,
valueName: 'scripting definition', valueName: 'Scripting definition',
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'], allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
}); });
} }

View File

@@ -1,17 +1,19 @@
import type { IEntity } from '@/infrastructure/Entity/IEntity'; import type { RepositoryEntity } from './RepositoryEntity';
export interface ReadonlyRepository<TKey, TEntity extends IEntity<TKey>> { type EntityId = RepositoryEntity['id'];
export interface ReadonlyRepository<TEntity extends RepositoryEntity> {
readonly length: number; readonly length: number;
getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[]; getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[];
getById(id: TKey): TEntity; getById(id: EntityId): TEntity;
exists(id: TKey): boolean; exists(id: EntityId): boolean;
} }
export interface MutableRepository<TKey, TEntity extends IEntity<TKey>> { export interface MutableRepository<TEntity extends RepositoryEntity> {
addItem(item: TEntity): void; addItem(item: TEntity): void;
addOrUpdateItem(item: TEntity): void; addOrUpdateItem(item: TEntity): void;
removeItem(id: TKey): void; removeItem(id: EntityId): void;
} }
export interface Repository<TKey, TEntity extends IEntity<TKey>> export interface Repository<TEntity extends RepositoryEntity>
extends ReadonlyRepository<TKey, TEntity>, MutableRepository<TKey, TEntity> { } extends ReadonlyRepository<TEntity>, MutableRepository<TEntity> { }

View File

@@ -0,0 +1,6 @@
/** Aggregate root */
export type RepositoryEntityId = string;
export interface RepositoryEntity {
readonly id: RepositoryEntityId;
}

View File

@@ -69,6 +69,12 @@ definitions:
- $ref: '#/definitions/CodeScript' - $ref: '#/definitions/CodeScript'
- $ref: '#/definitions/CallScript' - $ref: '#/definitions/CallScript'
RecommendationLevel:
oneOf:
- type: string
enum: [standard, strict]
- type: 'null'
ScriptDefinition: ScriptDefinition:
type: object type: object
allOf: allOf:
@@ -78,8 +84,7 @@ definitions:
name: name:
type: string type: string
recommend: recommend:
type: string $ref: '#/definitions/RecommendationLevel'
enum: [standard, strict]
CodeScript: CodeScript:
type: object type: object

View File

@@ -1800,7 +1800,7 @@ actions:
# References for spctl --master-disable # References for spctl --master-disable
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/ - https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/
# References for /var/db/SystemPolicy-prefs.plist # References for /var/db/SystemPolicy-prefs.plist
- https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/ - https://web.archive.org/web/20240810103202/https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
- https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228 - https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228
code: |- code: |-
os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}') os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}')
@@ -1842,10 +1842,10 @@ actions:
fi fi
- -
name: Disable library validation entitlement (library signature validation) name: Disable library validation entitlement (library signature validation)
docs: docs: |-
- https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation - [Disable Library Validation Entitlement | Apple Developer Documentation | developer.apple.com](https://archive.ph/2024.07.19-101811/https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation)
- https://www.macenhance.com/docs/general/sip-library-validation.html - [Forbidden Commands to Speed Up macOS | www.naut.ca](https://web.archive.org/web/20240625020749/https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/)
- https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/ - [macEnhance | macEnhance.com](https://web.archive.org/web/20220622212008/https://www.macenhance.com/docs/general/sip-library-validation.html)
code: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool true code: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool true
revertCode: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool false revertCode: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool false
- -

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { OperatingSystem } from './OperatingSystem'; import { OperatingSystem } from './OperatingSystem';
import type { IApplication } from './IApplication'; import type { IApplication } from './IApplication';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './Collection/ICategoryCollection';
import type { ProjectDetails } from './Project/ProjectDetails'; import type { ProjectDetails } from './Project/ProjectDetails';
export class Application implements IApplication { export class Application implements IApplication {

View File

@@ -1,11 +1,13 @@
import { getEnumValues, assertInRange } from '@/application/Common/Enum'; import { getEnumValues, assertInRange } from '@/application/Common/Enum';
import { RecommendationLevel } from './Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
import { OperatingSystem } from './OperatingSystem'; import { OperatingSystem } from '../OperatingSystem';
import type { IEntity } from '../infrastructure/Entity/IEntity'; import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator';
import type { Category } from './Executables/Category/Category'; import type { ExecutableId } from '../Executables/Identifiable';
import type { Script } from './Executables/Script/Script'; import type { Category } from '../Executables/Category/Category';
import type { IScriptingDefinition } from './IScriptingDefinition'; import type { Script } from '../Executables/Script/Script';
import type { IScriptingDefinition } from '../IScriptingDefinition';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './ICategoryCollection';
import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator';
export class CategoryCollection implements ICategoryCollection { export class CategoryCollection implements ICategoryCollection {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
@@ -22,22 +24,24 @@ export class CategoryCollection implements ICategoryCollection {
constructor( constructor(
parameters: CategoryCollectionInitParameters, parameters: CategoryCollectionInitParameters,
validate: CategoryCollectionValidator = validateCategoryCollection,
) { ) {
this.os = parameters.os; this.os = parameters.os;
this.actions = parameters.actions; this.actions = parameters.actions;
this.scripting = parameters.scripting; this.scripting = parameters.scripting;
this.queryable = makeQueryable(this.actions); this.queryable = makeQueryable(this.actions);
assertInRange(this.os, OperatingSystem); validate({
ensureValid(this.queryable); allScripts: this.queryable.allScripts,
ensureNoDuplicates(this.queryable.allCategories); allCategories: this.queryable.allCategories,
ensureNoDuplicates(this.queryable.allScripts); operatingSystem: this.os,
});
} }
public getCategory(categoryId: number): Category { public getCategory(executableId: ExecutableId): Category {
const category = this.queryable.allCategories.find((c) => c.id === categoryId); const category = this.queryable.allCategories.find((c) => c.executableId === executableId);
if (!category) { if (!category) {
throw new Error(`Missing category with ID: "${categoryId}"`); throw new Error(`Missing category with ID: "${executableId}"`);
} }
return category; return category;
} }
@@ -48,10 +52,10 @@ export class CategoryCollection implements ICategoryCollection {
return scripts ?? []; return scripts ?? [];
} }
public getScript(scriptId: string): Script { public getScript(executableId: ExecutableId): Script {
const script = this.queryable.allScripts.find((s) => s.id === scriptId); const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
if (!script) { if (!script) {
throw new Error(`missing script: ${scriptId}`); throw new Error(`Missing script: ${executableId}`);
} }
return script; return script;
} }
@@ -65,21 +69,6 @@ export class CategoryCollection implements ICategoryCollection {
} }
} }
function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) {
const isUniqueInArray = (id: TKey, index: number, array: readonly TKey[]) => array
.findIndex((otherId) => otherId === id) !== index;
const duplicatedIds = entities
.map((entity) => entity.id)
.filter((id, index, array) => !isUniqueInArray(id, index, array))
.filter(isUniqueInArray);
if (duplicatedIds.length > 0) {
const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(',');
throw new Error(
`Duplicate entities are detected with following id(s): ${duplicatedIdsText}`,
);
}
}
export interface CategoryCollectionInitParameters { export interface CategoryCollectionInitParameters {
readonly os: OperatingSystem; readonly os: OperatingSystem;
readonly actions: ReadonlyArray<Category>; readonly actions: ReadonlyArray<Category>;
@@ -92,35 +81,12 @@ interface QueryableCollection {
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>; readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
} }
function ensureValid(application: QueryableCollection) { function flattenCategoryHierarchy(
ensureValidCategories(application.allCategories);
ensureValidScripts(application.allScripts);
}
function ensureValidCategories(allCategories: readonly Category[]) {
if (!allCategories.length) {
throw new Error('must consist of at least one category');
}
}
function ensureValidScripts(allScripts: readonly Script[]) {
if (!allScripts.length) {
throw new Error('must consist of at least one script');
}
const missingRecommendationLevels = getEnumValues(RecommendationLevel)
.filter((level) => allScripts.every((script) => script.level !== level));
if (missingRecommendationLevels.length > 0) {
throw new Error('none of the scripts are recommended as'
+ ` "${missingRecommendationLevels.map((level) => RecommendationLevel[level]).join(', "')}".`);
}
}
function flattenApplication(
categories: ReadonlyArray<Category>, categories: ReadonlyArray<Category>,
): [Category[], Script[]] { ): [Category[], Script[]] {
const [subCategories, subScripts] = (categories || []) const [subCategories, subScripts] = (categories || [])
// Parse children // Parse children
.map((category) => flattenApplication(category.subCategories)) .map((category) => flattenCategoryHierarchy(category.subcategories))
// Flatten results // Flatten results
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => { .reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
return [ return [
@@ -143,7 +109,7 @@ function flattenApplication(
function makeQueryable( function makeQueryable(
actions: ReadonlyArray<Category>, actions: ReadonlyArray<Category>,
): QueryableCollection { ): QueryableCollection {
const flattened = flattenApplication(actions); const flattened = flattenCategoryHierarchy(actions);
return { return {
allCategories: flattened[0], allCategories: flattened[0],
allScripts: flattened[1], allScripts: flattened[1],

View File

@@ -3,6 +3,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ExecutableId } from '../Executables/Identifiable';
export interface ICategoryCollection { export interface ICategoryCollection {
readonly scripting: IScriptingDefinition; readonly scripting: IScriptingDefinition;
@@ -12,8 +13,8 @@ export interface ICategoryCollection {
readonly actions: ReadonlyArray<Category>; readonly actions: ReadonlyArray<Category>;
getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<Script>; getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<Script>;
getCategory(categoryId: number): Category; getCategory(categoryId: ExecutableId): Category;
getScript(scriptId: string): Script; getScript(scriptId: ExecutableId): Script;
getAllScripts(): ReadonlyArray<Script>; getAllScripts(): ReadonlyArray<Script>;
getAllCategories(): ReadonlyArray<Category>; getAllCategories(): ReadonlyArray<Category>;
} }

View File

@@ -0,0 +1,15 @@
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import type { OperatingSystem } from '@/domain/OperatingSystem';
export interface CategoryCollectionValidationContext {
readonly allScripts: readonly Script[];
readonly allCategories: readonly Category[];
readonly operatingSystem: OperatingSystem;
}
export interface CategoryCollectionValidator {
(
context: CategoryCollectionValidationContext,
): void;
}

View File

@@ -0,0 +1,33 @@
import { ensurePresenceOfAtLeastOneScript } from './Rules/EnsurePresenceOfAtLeastOneScript';
import { ensurePresenceOfAtLeastOneCategory } from './Rules/EnsurePresenceOfAtLeastOneCategory';
import { ensureUniqueIdsAcrossExecutables } from './Rules/EnsureUniqueIdsAcrossExecutables';
import { ensureKnownOperatingSystem } from './Rules/EnsureKnownOperatingSystem';
import type { CategoryCollectionValidationContext, CategoryCollectionValidator } from './CategoryCollectionValidator';
export type CompositeCategoryCollectionValidator = CategoryCollectionValidator & {
(
...args: [
...Parameters<CategoryCollectionValidator>,
(readonly CategoryCollectionValidator[])?,
]
): ReturnType<CategoryCollectionValidator>;
};
export const validateCategoryCollection: CompositeCategoryCollectionValidator = (
context: CategoryCollectionValidationContext,
validators: readonly CategoryCollectionValidator[] = DefaultValidators,
) => {
if (!validators.length) {
throw new Error('No validators provided.');
}
for (const validate of validators) {
validate(context);
}
};
const DefaultValidators: readonly CategoryCollectionValidator[] = [
ensureKnownOperatingSystem,
ensurePresenceOfAtLeastOneScript,
ensurePresenceOfAtLeastOneCategory,
ensureUniqueIdsAcrossExecutables,
];

View File

@@ -0,0 +1,9 @@
import { assertInRange } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensureKnownOperatingSystem: CategoryCollectionValidator = (
context,
) => {
assertInRange(context.operatingSystem, OperatingSystem);
};

View File

@@ -0,0 +1,35 @@
import { getEnumValues } from '@/application/Common/Enum';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAllRecommendationLevels: CategoryCollectionValidator = (
context,
) => {
const unrepresentedRecommendationLevels = getUnrepresentedRecommendationLevels(
context.allScripts,
);
if (unrepresentedRecommendationLevels.length === 0) {
return;
}
const formattedRecommendationLevels = unrepresentedRecommendationLevels
.map((level) => getDisplayName(level))
.join(', ');
throw new Error(`Missing recommendation levels: ${formattedRecommendationLevels}.`);
};
function getUnrepresentedRecommendationLevels(
scripts: readonly Script[],
): (RecommendationLevel | undefined)[] {
const expectedLevels = [
undefined,
...getEnumValues(RecommendationLevel),
];
return expectedLevels.filter(
(level) => scripts.every((script) => script.level !== level),
);
}
function getDisplayName(level: RecommendationLevel | undefined): string {
return level === undefined ? 'None' : RecommendationLevel[level];
}

View File

@@ -0,0 +1,9 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneCategory: CategoryCollectionValidator = (
context,
) => {
if (!context.allCategories.length) {
throw new Error('Collection must have at least one category');
}
};

View File

@@ -0,0 +1,9 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneScript: CategoryCollectionValidator = (
context,
) => {
if (!context.allScripts.length) {
throw new Error('Collection must have at least one script');
}
};

View File

@@ -0,0 +1,43 @@
import type { Identifiable } from '@/domain/Executables/Identifiable';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensureUniqueIdsAcrossExecutables: CategoryCollectionValidator = (
context,
) => {
const allExecutables: readonly Identifiable[] = [
...context.allCategories,
...context.allScripts,
];
ensureNoDuplicateIds(allExecutables);
};
function ensureNoDuplicateIds(
executables: readonly Identifiable[],
) {
const duplicateExecutables = getExecutablesWithDuplicateIds(executables);
if (duplicateExecutables.length === 0) {
return;
}
const formattedDuplicateIds = duplicateExecutables.map(
(executable) => `"${executable.executableId}"`,
).join(', ');
throw new Error(`Duplicate executable IDs found: ${formattedDuplicateIds}`);
}
function getExecutablesWithDuplicateIds(
executables: readonly Identifiable[],
): Identifiable[] {
return executables
.filter(
(executable, index, array) => {
const otherIndex = array.findIndex(
(otherExecutable) => haveIdenticalIds(executable, otherExecutable),
);
return otherIndex !== index;
},
);
}
function haveIdenticalIds(first: Identifiable, second: Identifiable): boolean {
return first.executableId === second.executableId;
}

View File

@@ -1,10 +1,9 @@
import type { Script } from '../Script/Script'; import type { Script } from '../Script/Script';
import type { Executable } from '../Executable'; import type { Executable } from '../Executable';
export interface Category extends Executable<number> { export interface Category extends Executable {
readonly id: number;
readonly name: string; readonly name: string;
readonly subCategories: ReadonlyArray<Category>; readonly subcategories: ReadonlyArray<Category>;
readonly scripts: ReadonlyArray<Script>; readonly scripts: ReadonlyArray<Script>;
includes(script: Script): boolean; includes(script: Script): boolean;
getAllScriptsRecursively(): ReadonlyArray<Script>; getAllScriptsRecursively(): ReadonlyArray<Script>;

View File

@@ -1,29 +1,51 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity'; import type { Category } from '@/domain/Executables/Category/Category';
import type { Category } from './Category'; import type { Script } from '@/domain/Executables/Script/Script';
import type { Script } from '../Script/Script'; import type { ExecutableId } from '../Identifiable';
export class CollectionCategory extends BaseEntity<number> implements Category { export type CategoryFactory = (
private allSubScripts?: ReadonlyArray<Script> = undefined; parameters: CategoryInitParameters,
) => Category;
export interface CategoryInitParameters {
readonly executableId: ExecutableId;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<Category>;
readonly scripts: ReadonlyArray<Script>;
}
export const createCategory: CategoryFactory = (
parameters,
) => {
return new CollectionCategory(parameters);
};
class CollectionCategory implements Category {
public readonly executableId: ExecutableId;
public readonly name: string; public readonly name: string;
public readonly docs: ReadonlyArray<string>; public readonly docs: ReadonlyArray<string>;
public readonly subCategories: ReadonlyArray<Category>; public readonly subcategories: ReadonlyArray<Category>;
public readonly scripts: ReadonlyArray<Script>; public readonly scripts: ReadonlyArray<Script>;
private allSubScripts?: ReadonlyArray<Script> = undefined;
constructor(parameters: CategoryInitParameters) { constructor(parameters: CategoryInitParameters) {
super(parameters.id);
validateParameters(parameters); validateParameters(parameters);
this.executableId = parameters.executableId;
this.name = parameters.name; this.name = parameters.name;
this.docs = parameters.docs; this.docs = parameters.docs;
this.subCategories = parameters.subcategories; this.subcategories = parameters.subcategories;
this.scripts = parameters.scripts; this.scripts = parameters.scripts;
} }
public includes(script: Script): boolean { public includes(script: Script): boolean {
return this.getAllScriptsRecursively().some((childScript) => childScript.id === script.id); return this
.getAllScriptsRecursively()
.some((childScript) => childScript.executableId === script.executableId);
} }
public getAllScriptsRecursively(): readonly Script[] { public getAllScriptsRecursively(): readonly Script[] {
@@ -34,22 +56,17 @@ export class CollectionCategory extends BaseEntity<number> implements Category {
} }
} }
export interface CategoryInitParameters {
readonly id: number;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<Category>;
readonly scripts: ReadonlyArray<Script>;
}
function parseScriptsRecursively(category: Category): ReadonlyArray<Script> { function parseScriptsRecursively(category: Category): ReadonlyArray<Script> {
return [ return [
...category.scripts, ...category.scripts,
...category.subCategories.flatMap((c) => c.getAllScriptsRecursively()), ...category.subcategories.flatMap((c) => c.getAllScriptsRecursively()),
]; ];
} }
function validateParameters(parameters: CategoryInitParameters) { function validateParameters(parameters: CategoryInitParameters) {
if (!parameters.executableId) {
throw new Error('missing ID');
}
if (!parameters.name) { if (!parameters.name) {
throw new Error('missing name'); throw new Error('missing name');
} }

View File

@@ -1,6 +1,6 @@
import type { IEntity } from '@/infrastructure/Entity/IEntity';
import type { Documentable } from './Documentable'; import type { Documentable } from './Documentable';
import type { Identifiable } from './Identifiable';
export interface Executable<TExecutableKey> export interface Executable
extends Documentable, IEntity<TExecutableKey> { extends Documentable, Identifiable {
} }

View File

@@ -0,0 +1,5 @@
export type ExecutableId = string;
export interface Identifiable {
readonly executableId: ExecutableId;
}

View File

@@ -3,7 +3,7 @@ import type { Executable } from '../Executable';
import type { Documentable } from '../Documentable'; import type { Documentable } from '../Documentable';
import type { ScriptCode } from './Code/ScriptCode'; import type { ScriptCode } from './Code/ScriptCode';
export interface Script extends Executable<string>, Documentable { export interface Script extends Executable, Documentable {
readonly name: string; readonly name: string;
readonly level?: RecommendationLevel; readonly level?: RecommendationLevel;
readonly code: ScriptCode; readonly code: ScriptCode;

View File

@@ -1,9 +1,27 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { RecommendationLevel } from './RecommendationLevel'; import { RecommendationLevel } from './RecommendationLevel';
import type { Script } from './Script';
import type { ScriptCode } from './Code/ScriptCode'; import type { ScriptCode } from './Code/ScriptCode';
import type { Script } from './Script';
import type { ExecutableId } from '../Identifiable';
export interface ScriptInitParameters {
readonly executableId: ExecutableId;
readonly name: string;
readonly code: ScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
export type ScriptFactory = (
parameters: ScriptInitParameters,
) => Script;
export const createScript: ScriptFactory = (parameters) => {
return new CollectionScript(parameters);
};
class CollectionScript implements Script {
public readonly executableId: ExecutableId;
export class CollectionScript extends BaseEntity<string> implements Script {
public readonly name: string; public readonly name: string;
public readonly code: ScriptCode; public readonly code: ScriptCode;
@@ -13,7 +31,7 @@ export class CollectionScript extends BaseEntity<string> implements Script {
public readonly level?: RecommendationLevel; public readonly level?: RecommendationLevel;
constructor(parameters: ScriptInitParameters) { constructor(parameters: ScriptInitParameters) {
super(parameters.name); this.executableId = parameters.executableId;
this.name = parameters.name; this.name = parameters.name;
this.code = parameters.code; this.code = parameters.code;
this.docs = parameters.docs; this.docs = parameters.docs;
@@ -26,13 +44,6 @@ export class CollectionScript extends BaseEntity<string> implements Script {
} }
} }
export interface ScriptInitParameters {
readonly name: string;
readonly code: ScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
function validateLevel(level?: RecommendationLevel) { function validateLevel(level?: RecommendationLevel) {
if (level !== undefined && !(level in RecommendationLevel)) { if (level !== undefined && !(level in RecommendationLevel)) {
throw new Error(`invalid level: ${level}`); throw new Error(`invalid level: ${level}`);

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './Collection/ICategoryCollection';
import type { ProjectDetails } from './Project/ProjectDetails'; import type { ProjectDetails } from './Project/ProjectDetails';
import type { OperatingSystem } from './OperatingSystem'; import type { OperatingSystem } from './OperatingSystem';

View File

@@ -1,23 +0,0 @@
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
export interface ScriptDirectoryProvider {
provideScriptDirectory(): Promise<ScriptDirectoryOutcome>;
}
export type ScriptDirectoryOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
interface ScriptDirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: CodeRunError;
}
interface SuccessfulDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: false;
readonly error: CodeRunError;
}

View File

@@ -1,21 +1,22 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator'; import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
import type { SystemOperations } from '../System/SystemOperations';
import type { FilenameGenerator } from './Filename/FilenameGenerator'; import type { FilenameGenerator } from './Filename/FilenameGenerator';
import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator'; import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
import type { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
export class ScriptFileCreationOrchestrator implements ScriptFileCreator { export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(), private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(), private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
@@ -26,9 +27,12 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): Promise<ScriptFileCreationOutcome> { ): Promise<ScriptFileCreationOutcome> {
const { const {
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath, success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
} = await this.directoryProvider.provideScriptDirectory(); } = await this.directoryProvider.provideDirectory('script-runs');
if (!isDirectoryCreated) { if (!isDirectoryCreated) {
return createFailure(directoryCreationError); return createFailure({
type: 'DirectoryCreationError',
message: `[${directoryCreationError.type}] ${directoryCreationError.message}`,
});
} }
const { const {
success: isFilePathConstructed, error: filePathGenerationError, filePath, success: isFilePathConstructed, error: filePathGenerationError, filePath,
@@ -54,7 +58,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): FilePathConstructionOutcome { ): FilePathConstructionOutcome {
try { try {
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts); const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
const filePath = this.system.location.combinePaths(directoryPath, filename); const filePath = this.fileSystem.combinePaths(directoryPath, filename);
return { success: true, filePath }; return { success: true, filePath };
} catch (error) { } catch (error) {
return { return {

View File

@@ -7,7 +7,7 @@ import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter { export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly system: SystemOperations = NodeElectronSystemOperations,
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }

Some files were not shown because too many files have changed in this diff Show More