Compare commits

...

66 Commits
0.6.2 ... 0.7.6

Author SHA1 Message Date
undergroundwires
d9d7f62d81 run tests on all operating systems: macos, ubuntu, windows 2020-10-18 02:10:20 +01:00
undergroundwires
11e0613165 update dependencies to latest 2020-10-17 23:17:44 +01:00
undergroundwires
77c3d2bbb8 simplify "why" section 2020-09-23 20:42:05 +01:00
undergroundwires
784a67afff refactor to read more from package.json 2020-09-22 20:41:12 +01:00
undergroundwires
19a092dd31 add more reversibility 2020-09-21 23:05:31 +01:00
undergroundwires
4c2f74949b add robots.txt to explicitly allow indexing 2020-09-19 01:23:33 +01:00
undergroundwires
a3fc3782ef add docs for default0 pointing to github discussion (#30) 2020-09-20 13:58:19 +01:00
undergroundwires-bot
cdc93f032a ⬆️ bumped to 0.7.5 2020-09-19 13:41:58 +00:00
undergroundwires
7dd15ed064 fix typo 2020-09-19 15:39:48 +01:00
undergroundwires
d169434157 fix pasting in search bar after page load showing no results 2020-09-18 20:07:03 +01:00
undergroundwires
6efed72bf2 fix rendering issue in older edge/IE 2020-09-17 15:41:46 +01:00
Clayton Errington
15db311801 fix the recycling bin option (#32)
* update the recycling bin option

Powershell has a module in PS 5.1+ called Clear-Recyclebin that works better than the CMD method - rd /s %systemdrive%\$Recycle.bin

* update recycling bin delete command

one liner of ComObject in powershell instead of cmd asking for confirmation. adds better backwards compatibility.
2020-09-16 20:22:33 +00:00
undergroundwires
82d509129b fix tests and checks are not running on PRs 2020-09-16 19:10:25 +01:00
undergroundwires
939d838e35 fix reverting (reinstalling) capabilities not working 2020-09-16 02:09:23 +01:00
undergroundwires-bot
6de4ce58c4 ⬆️ bumped to 0.7.4 2020-09-14 13:57:39 +00:00
undergroundwires
ee66196d9a fix wrong path in clear all firefox user profile settings 2020-09-14 16:04:38 +01:00
undergroundwires
3c13a9e837 fix missing reg value in denying app access to account 2020-09-14 16:03:03 +01:00
undergroundwires
22b23a9ece fix spectre protection getting single lined #31 2020-09-14 16:00:20 +01:00
undergroundwires
4ae385b7fc fix checked checkbox has blue border 2020-09-13 18:42:19 +01:00
undergroundwires-bot
d9abc7f0b2 ⬆️ bumped to 0.7.3 2020-09-12 13:14:13 +00:00
undergroundwires
1f19b2528a fix typo in a test 2020-09-12 14:42:27 +01:00
undergroundwires
1f11c39773 add more detailed error message 2020-09-12 00:14:27 +01:00
undergroundwires
b6ccb5927a fix comment lines are being detected as duplicate in validation 2020-09-12 00:13:58 +01:00
undergroundwires
1d465ee318 add reversibility and more scripts to denying app access with better structure 2020-09-12 00:11:10 +01:00
undergroundwires
3ab48b1cf5 fix naming of firefox cleanup to mention profiles 2020-09-11 14:26:32 +01:00
undergroundwires
de4ac978bd fix wrong path to the main telemetry file 2020-09-10 12:52:29 +01:00
undergroundwires
8df5faf4ef improve CPU specific tweaks by conditional platform checks and reversibility 2020-09-09 13:55:21 +01:00
undergroundwires
99a2035fdb fix nvidia tweak error message, categorize and add reversibility 2020-09-08 19:41:03 +01:00
undergroundwires
a0d61728ea fix vscode settings file override and add more configs 2020-09-07 13:42:59 +01:00
undergroundwires-bot
312bf6102c ⬆️ bumped to 0.7.2 2020-09-06 18:10:12 +00:00
undergroundwires
f4885b6f1c add best practice suggestion to come back 2020-09-06 02:41:11 +01:00
undergroundwires
ca63a0979e fix wording in default text in text area 2020-09-06 02:37:47 +01:00
undergroundwires
1f266c3353 fix indeterminate state being lost 2020-09-06 15:26:19 +01:00
undergroundwires
c7b2a70312 add reversibility to removing bloatware 2020-09-06 19:03:36 +01:00
undergroundwires
255133af4d fix bad highlighting of selected nodes when using keyboard navigation 2020-09-04 01:24:35 +01:00
undergroundwires
db74531cd4 add reversibility for biometric disabling and do not recommend it 2020-09-06 16:39:48 +01:00
undergroundwires
f36d8bfc78 update onesync documentation and do not recommend it as it breaks other apps 2020-09-05 23:31:31 +01:00
undergroundwires-bot
3b31ace726 ⬆️ bumped to 0.7.1 2020-09-04 11:42:03 +00:00
undergroundwires
6badfef9da refactor unused imports 2020-09-04 13:29:42 +01:00
undergroundwires
8c38dd73d8 fix new/changed script higlighting not working on production builds 2020-09-04 13:26:35 +01:00
undergroundwires
b8682a852a rename screenshot image file 2020-09-04 13:25:36 +01:00
undergroundwires
8c17929151 fix some browsers (including firefox) downloading the script as a text file 2020-09-04 12:20:41 +01:00
undergroundwires-bot
bb92c9ec28 ⬆️ bumped to 0.7.0 2020-09-02 21:26:40 +00:00
undergroundwires
b4aacea2a3 update the screenshot to show off highlighting 2020-09-02 19:12:24 +01:00
undergroundwires
8bbe6ebf75 fix search (got broken in b789250) with tests and refactorings 2020-09-02 22:44:20 +01:00
undergroundwires
a23d28f2cf refactor unused imports & variables 2020-09-01 21:32:31 +01:00
undergroundwires
f51e8859ee add reversibility on category level 2020-09-01 21:18:16 +01:00
undergroundwires
d235dee955 exclude paint, wordpad and notepad from bloatware removal 2020-09-01 21:03:14 +01:00
undergroundwires
2afef4ea3d do not hardcode capability versions and make them reversible 2020-08-26 00:58:52 +01:00
undergroundwires
f709d6a566 fix "Configure Defender" being in wrong category #28 2020-09-01 20:06:44 +01:00
undergroundwires
532915b95d categorize, fix, make scripts reversible in "UI for privacy", "security improvements" and "configure browsers" 2020-09-01 20:03:43 +01:00
Francesco Saltori
456e40bedf Add disabling of PowerShell 7+ telemetry (#29) 2020-09-01 19:57:43 +02:00
undergroundwires
018b7e270f add disabling ccleaner telemetry 2020-08-20 00:29:35 +01:00
undergroundwires
f8b8b4c97a add disabling firefox telemetry 2020-08-30 17:42:05 +01:00
undergroundwires
978d7d0863 add more OneDrive cleanup scripts and categorize them 2020-08-30 16:05:02 +01:00
undergroundwires
594a14d6ca categorize, fix and extend windows log files cleanup 2020-08-24 21:21:21 +01:00
undergroundwires
c628aa9aef updated dependencies to latest and audit fixes (#25) 2020-08-28 16:46:09 +01:00
undergroundwires
3060ebf79c fix NTP script documentation is on wrong place 2020-08-28 14:16:11 +01:00
undergroundwires
1a34c7374b add more windows defender tweaks, categorization and reversibility 2020-08-27 20:32:19 +01:00
undergroundwires
c262681011 add removal of ghost (default0) telemetry user 2020-08-26 21:31:12 +01:00
undergroundwires
f8ba5c46e4 prompt admin priviliges automatically 2020-08-26 01:58:29 +01:00
undergroundwires
b789250cb8 add auto-highlighting of selected/updated code 2020-08-25 16:52:38 +01:00
undergroundwires
5df458739d move script generation to /generation 2020-08-25 16:44:47 +01:00
undergroundwires
d6fa9a2a03 [search] added clear/close button 2020-08-24 02:50:47 +01:00
undergroundwires
ec15af01dd [search] better (multilined) message when there are no results 2020-08-24 02:50:47 +01:00
undergroundwires-bot
7073336f81 ⬆️ bumped to 0.6.2 2020-08-16 16:16:50 +00:00
111 changed files with 6146 additions and 2034 deletions

View File

@@ -1,6 +1,6 @@
name: Quality checks name: Quality checks
on: push on: [ push, pull_request ]
jobs: jobs:
lint: lint:

View File

@@ -2,6 +2,7 @@ name: Security checks
on: on:
push: push:
pull_request:
schedule: schedule:
- cron: '0 0 * * 0' - cron: '0 0 * * 0'

View File

@@ -1,10 +1,13 @@
name: Test name: Test
on: push on: [ push, pull_request ]
jobs: jobs:
run-tests: run-tests:
runs-on: ubuntu-latest strategy:
matrix:
os: [macos, ubuntu, windows]
runs-on: ${{ matrix.os }}-latest
steps: steps:
- -
name: Checkout name: Checkout

View File

@@ -1,5 +1,99 @@
# Changelog # Changelog
## 0.7.5 (2020-09-14)
* fix reverting (reinstalling) capabilities not working | [commit](https://github.com/undergroundwires/privacy.sexy/commit/939d838e3535bb1c9b00c8ea9dacb735ae41d700)
* fix tests and checks are not running on PRs | [commit](https://github.com/undergroundwires/privacy.sexy/commit/82d509129b4e4a5df4b84786a0d6842a7d26e888)
* fix the recycling bin option (#32) | [commit](https://github.com/undergroundwires/privacy.sexy/commit/15db3118012a172a2191a2afad57084a65b34642)
* fix rendering issue in older edge/IE | [commit](https://github.com/undergroundwires/privacy.sexy/commit/6efed72bf25c2ddf0901caab7f22966ca13cd47a)
* fix pasting in search bar after page load showing no results | [commit](https://github.com/undergroundwires/privacy.sexy/commit/d1694341578288eeaf8b80caf9296a38d76789f0)
* fix typo | [commit](https://github.com/undergroundwires/privacy.sexy/commit/7dd15ed06433e0e6583ab0fa46a683ce6554bbea)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.7.4...0.7.5)
## 0.7.4 (2020-09-12)
* fix checked checkbox has blue border | [commit](https://github.com/undergroundwires/privacy.sexy/commit/4ae385b7fcea9014a68442714b7d99e2ee7df7d0)
* fix spectre protection getting single lined #31 | [commit](https://github.com/undergroundwires/privacy.sexy/commit/22b23a9ece446c7f9abd4ede293051eb616ad50a)
* fix missing reg value in denying app access to account | [commit](https://github.com/undergroundwires/privacy.sexy/commit/3c13a9e837e06e097450b31d7eb0c0e6bf20cefb)
* fix wrong path in clear all firefox user profile settings | [commit](https://github.com/undergroundwires/privacy.sexy/commit/ee66196d9a60f27d17ae7f62d02b4f119a47e6e0)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.7.3...0.7.4)
## 0.7.3 (2020-09-12)
* fix vscode settings file override and add more configs | [commit](https://github.com/undergroundwires/privacy.sexy/commit/a0d61728ead04b4455437f85820121a848db9e00)
* fix nvidia tweak error message, categorize and add reversibility | [commit](https://github.com/undergroundwires/privacy.sexy/commit/99a2035fdb0766a4dfc2753133eab0d7666516cd)
* improve CPU specific tweaks by conditional platform checks and reversibility | [commit](https://github.com/undergroundwires/privacy.sexy/commit/8df5faf4ef05a49da63973bd0fbb5c5d07d5bd93)
* fix wrong path to the main telemetry file | [commit](https://github.com/undergroundwires/privacy.sexy/commit/de4ac978bdda79573b36d355697b8a028d2c0beb)
* fix naming of firefox cleanup to mention profiles | [commit](https://github.com/undergroundwires/privacy.sexy/commit/3ab48b1cf5f7f934f07e468ef2318ccee07f530c)
* add reversibility and more scripts to denying app access with better structure | [commit](https://github.com/undergroundwires/privacy.sexy/commit/1d465ee3189d0e5a827453b3f0eb4361efe23770)
* fix comment lines are being detected as duplicate in validation | [commit](https://github.com/undergroundwires/privacy.sexy/commit/b6ccb5927a20412976a54fd2215eb645092f98a8)
* add more detailed error message | [commit](https://github.com/undergroundwires/privacy.sexy/commit/1f11c39773c12eccfb3efb898b58c2f6f37ab9ca)
* fix typo in a test | [commit](https://github.com/undergroundwires/privacy.sexy/commit/1f19b2528a69383e63e579d2885f01cd804abf6c)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.7.2...0.7.3)
## 0.7.2 (2020-09-06)
* update onesync documentation and do not recommend it as it breaks other apps | [commit](https://github.com/undergroundwires/privacy.sexy/commit/f36d8bfc7848bb65ac0c641e318a689bf3816ccf)
* add reversibility for biometric disabling and do not recommend it | [commit](https://github.com/undergroundwires/privacy.sexy/commit/db74531cd4139615c6d595959217d3651f099019)
* fix bad highlighting of selected nodes when using keyboard navigation | [commit](https://github.com/undergroundwires/privacy.sexy/commit/255133af4dfae40171406648a3e2920f16d71cb3)
* add reversibility to removing bloatware | [commit](https://github.com/undergroundwires/privacy.sexy/commit/c7b2a703128470a05f12c9c6e8002444def37ef8)
* fix indeterminate state being lost | [commit](https://github.com/undergroundwires/privacy.sexy/commit/1f266c33535f72b69c65985bf2eff27cd2c5a104)
* fix wording in default text in text area | [commit](https://github.com/undergroundwires/privacy.sexy/commit/ca63a0979ef55d07d09d9443e5cea9aa888870a5)
* add best practice suggestion to come back | [commit](https://github.com/undergroundwires/privacy.sexy/commit/f4885b6f1c82752f2143934e336d6d1b1af03015)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.7.1...0.7.2)
## 0.7.1 (2020-09-04)
* fix some browsers (including firefox) downloading the script as a text file | [commit](https://github.com/undergroundwires/privacy.sexy/commit/8c17929151f9c4fa5f48564492bbf400ced95eea)
* rename screenshot image file | [commit](https://github.com/undergroundwires/privacy.sexy/commit/b8682a852a14ed6cf49986695d9510b840ac9d3d)
* fix new/changed script higlighting not working on production builds | [commit](https://github.com/undergroundwires/privacy.sexy/commit/8c38dd73d8c7b77d8d341c0389f4d7229f9b97fd)
* refactor unused imports | [commit](https://github.com/undergroundwires/privacy.sexy/commit/6badfef9daace0c5de3fd33652a82bfe22261b11)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.7.0...0.7.1)
## 0.7.0 (2020-09-02)
* [search] better (multilined) message when there are no results | [commit](https://github.com/undergroundwires/privacy.sexy/commit/ec15af01dd020b364c2174fe562fd66227c2320c)
* [search] added clear/close button | [commit](https://github.com/undergroundwires/privacy.sexy/commit/d6fa9a2a03c0ebe68b94f0b80cc52b4e200c9213)
* move script generation to /generation | [commit](https://github.com/undergroundwires/privacy.sexy/commit/5df458739d076719e350ba194c4f3f772884fcdb)
* add auto-highlighting of selected/updated code | [commit](https://github.com/undergroundwires/privacy.sexy/commit/b789250cb89e2130b08e1a927df8181cf945dfeb)
* prompt admin priviliges automatically | [commit](https://github.com/undergroundwires/privacy.sexy/commit/f8ba5c46e4923d9c35f200f8a08aa6437f7c0ecc)
* add removal of ghost (default0) telemetry user | [commit](https://github.com/undergroundwires/privacy.sexy/commit/c262681011f39b4412669b6cf233476f676ca550)
* add more windows defender tweaks, categorization and reversibility | [commit](https://github.com/undergroundwires/privacy.sexy/commit/1a34c7374ba56bafa0209bbb55c81b233bb419ed)
* fix NTP script documentation is on wrong place | [commit](https://github.com/undergroundwires/privacy.sexy/commit/3060ebf79cf242370433495cc3e1878b7581b202)
* updated dependencies to latest and audit fixes (#25) | [commit](https://github.com/undergroundwires/privacy.sexy/commit/c628aa9aef8ab7c815661d3c1711e7fbc65c69a2)
* categorize, fix and extend windows log files cleanup | [commit](https://github.com/undergroundwires/privacy.sexy/commit/594a14d6ca76cbd27a21877b8c373c1930589ca6)
* add more OneDrive cleanup scripts and categorize them | [commit](https://github.com/undergroundwires/privacy.sexy/commit/978d7d08638dd161082f239ed088b12302f29458)
* add disabling firefox telemetry | [commit](https://github.com/undergroundwires/privacy.sexy/commit/f8b8b4c97ab734d5ba7370894b694993924388da)
* add disabling ccleaner telemetry | [commit](https://github.com/undergroundwires/privacy.sexy/commit/018b7e270f207aac926cb12f8069ebfcdce193ce)
* Add disabling of PowerShell 7+ telemetry (#29) | [commit](https://github.com/undergroundwires/privacy.sexy/commit/456e40bedf9afcc846f9b13f1ea144cef6115cf6)
* categorize, fix, make scripts reversible in "UI for privacy", "security improvements" and "configure browsers" | [commit](https://github.com/undergroundwires/privacy.sexy/commit/532915b95da9fecd6b981d91bf489359e4e53caa)
* fix "Configure Defender" being in wrong category #28 | [commit](https://github.com/undergroundwires/privacy.sexy/commit/f709d6a566ed7846b677b383863deda9680a2a9c)
* do not hardcode capability versions and make them reversible | [commit](https://github.com/undergroundwires/privacy.sexy/commit/2afef4ea3d0d3d09aa1fa1eedba8493680bd8f10)
* exclude paint, wordpad and notepad from bloatware removal | [commit](https://github.com/undergroundwires/privacy.sexy/commit/d235dee95514a01745aef9479d07f88ffb4b40b8)
* add reversibility on category level | [commit](https://github.com/undergroundwires/privacy.sexy/commit/f51e8859eeb32c944126d692cfe03a0320c8b568)
* refactor unused imports & variables | [commit](https://github.com/undergroundwires/privacy.sexy/commit/a23d28f2cfa2d64d45460697cf5ee9d6b5920752)
* fix search (got broken in b789250) with tests and refactorings | [commit](https://github.com/undergroundwires/privacy.sexy/commit/8bbe6ebf750f1a1cbab493fb99b5ea91f4e21609)
* update the screenshot to show off highlighting | [commit](https://github.com/undergroundwires/privacy.sexy/commit/b4aacea2a3e0bbcf2d8a79ff67f51c0f19e888a6)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.6.2...0.7.0)
## 0.6.2 (2020-08-16)
* 🐛 fixed disabling error reporting for november 2019 update | [commit](https://github.com/undergroundwires/privacy.sexy/commit/5967347b80976a519f6f4eb1972a62f3e600df2b)
* 🐛 fixed blank screen and icons on mac | [commit](https://github.com/undergroundwires/privacy.sexy/commit/7fac0fe79f252e8f9dda4f6f83cd6fa4ba2b539f)
* 🐛 fixed removing onedrive does not delete scheduled tasks | [commit](https://github.com/undergroundwires/privacy.sexy/commit/b6bfc2572740c0cd46d3bc0058fa767dd5fa862e)
* ⚙️ enhanced tweak to disable for office telemetry | [commit](https://github.com/undergroundwires/privacy.sexy/commit/afc3bfb3b8896f332c9a196973ded3dce8fd21e4)
* ✨ added script to clear dotnet telemery | [commit](https://github.com/undergroundwires/privacy.sexy/commit/1663bfeac7b6580b1335ca5fcf3587b69c080c72)
* 🐛 fixed changing time server not working | [commit](https://github.com/undergroundwires/privacy.sexy/commit/c69998c7cb29ffcf40f0af03b73150736581da69)
* 🔥 removed disabling ClickToRun as it breaks office | [commit](https://github.com/undergroundwires/privacy.sexy/commit/3d3380f27ebeea53f17f49974aaa89300ffaf2dd)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.6.1...0.6.2)
## 0.6.1 (2020-08-09) ## 0.6.1 (2020-08-09)
* updated documentation | [commit](https://github.com/undergroundwires/privacy.sexy/commit/5963d2bac551083f9d16cce6b851abf0e8b88ce7) * updated documentation | [commit](https://github.com/undergroundwires/privacy.sexy/commit/5963d2bac551083f9d16cce6b851abf0e8b88ce7)

View File

@@ -15,20 +15,20 @@
## Get started ## Get started
- Online version: [https://privacy.sexy](https://privacy.sexy) - Online version: [https://privacy.sexy](https://privacy.sexy)
- or download latest desktop version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.6.1/privacy.sexy-Setup-0.6.1.exe), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.6.1/privacy.sexy-0.6.1.AppImage), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.6.1/privacy.sexy-0.6.1.dmg) - or download latest desktop version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.5/privacy.sexy-Setup-0.7.5.exe), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.5/privacy.sexy-0.7.5.AppImage), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.5/privacy.sexy-0.7.5.dmg)
- 💡 Come back regularly to apply latest version for stronger privacy and security.
![privacy.sexy application](img/app.png) [![privacy.sexy application](img/screenshot.png)](https://privacy.sexy)
## Why ## Why
- You don't need to run any compiled software that has access to your system, just run the generated scripts. - Rich tweak pool to harden security & privacy of the OS and other software on it
- Have full visibility into what the tweaks do as you enable them. - Free (both free as in beer and free as in speech)
- Ability to revert applied scripts - No need to run any compiled software that has access to your system, just run the generated scripts
- Have full visibility into what the tweaks do as you enable them
- Ability to revert (undo) applied scripts
- Easily extendable - Easily extendable
- Everything is open-sourced including both application and infrastructure - Everything is open-source and automated (both application and its infrastructure)
- Fully automated CI/CD pipeline using GitHub actions
- to AWS for provisioning serverless infrastructure
- for building and sharing the desktop applications
## Extend scripts ## Extend scripts
@@ -48,8 +48,8 @@
- Development: `npm run serve` to compile & hot-reload for development. - Development: `npm run serve` to compile & hot-reload for development.
- Production: `npm run build` to prepare files for distribution. - Production: `npm run build` to prepare files for distribution.
- Or run using Docker: - Or run using Docker:
1. Build: `docker build -t undergroundwires/privacy.sexy:0.6.1 .` 1. Build: `docker build -t undergroundwires/privacy.sexy:0.7.5 .`
2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy-0.6.1 undergroundwires/privacy.sexy:0.6.1` 2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy-0.7.5 undergroundwires/privacy.sexy:0.7.5`
## Architecture ## Architecture

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

BIN
img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

2092
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,14 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.6.1", "version": "0.7.5",
"author": "undergroundwires", "author": "undergroundwires",
"description": "Enforce privacy & security best-practices on Windows, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows, because privacy is sexy 🍑🍆",
"homepage": "https://privacy.sexy",
"private": true, "private": true,
"repository": {
"type": "git",
"url": "https://github.com/undergroundwires/privacy.sexy.git"
},
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
@@ -21,47 +26,46 @@
}, },
"main": "background.js", "main": "background.js",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-brands-svg-icons": "^5.14.0", "@fortawesome/free-brands-svg-icons": "^5.15.1",
"@fortawesome/free-regular-svg-icons": "^5.14.0", "@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.14.0", "@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/vue-fontawesome": "^0.1.10", "@fortawesome/vue-fontawesome": "^2.0.0",
"ace-builds": "^1.4.12", "ace-builds": "^1.4.12",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"inversify": "^5.0.1", "inversify": "^5.0.1",
"liquor-tree": "^0.2.70", "liquor-tree": "^0.2.70",
"v-tooltip": "^2.0.2", "v-tooltip": "2.0.2",
"vue": "^2.6.11", "vue": "^2.6.12",
"vue-class-component": "^7.2.5", "vue-class-component": "^7.2.6",
"vue-js-modal": "^2.0.0-rc.6", "vue-js-modal": "^2.0.0-rc.6",
"vue-property-decorator": "^9.0.0" "vue-property-decorator": "^9.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/ace": "0.0.43", "@types/ace": "0.0.44",
"@types/chai": "^4.2.12", "@types/chai": "^4.2.14",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/mocha": "^8.0.0", "@types/mocha": "^8.0.3",
"@types/node": "12.0.0", "@vue/cli-plugin-typescript": "^4.5.7",
"@vue/cli-plugin-typescript": "^4.4.6", "@vue/cli-plugin-unit-mocha": "^4.5.7",
"@vue/cli-plugin-unit-mocha": "^4.4.6", "@vue/cli-service": "^4.5.7",
"@vue/cli-service": "^4.4.6", "@vue/test-utils": "1.1.0",
"@vue/test-utils": "1.0.3",
"chai": "^4.2.0", "chai": "^4.2.0",
"electron": "^9.1.1", "electron": "^10.1.3",
"electron-devtools-installer": "^3.1.1", "electron-devtools-installer": "^3.1.1",
"electron-log": "^4.2.2", "electron-log": "^4.2.4",
"electron-updater": "^4.3.4", "electron-updater": "^4.3.5",
"js-yaml-loader": "^1.2.2", "js-yaml-loader": "^1.2.2",
"markdownlint-cli": "^0.23.2", "markdownlint-cli": "^0.24.0",
"remark-cli": "^8.0.1", "remark-cli": "^9.0.0",
"remark-lint-no-dead-urls": "^1.1.0", "remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^3.0.1", "remark-preset-lint-consistent": "^4.0.0",
"remark-validate-links": "^10.0.2", "remark-validate-links": "^10.0.2",
"sass": "^1.26.10", "sass": "^1.27.0",
"sass-loader": "^9.0.2", "sass-loader": "^10.0.3",
"typescript": "^3.9.7", "typescript": "^4.0.3",
"vue-cli-plugin-electron-builder": "^2.0.0-rc.4", "vue-cli-plugin-electron-builder": "^2.0.0-rc.4",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.12",
"yaml-lint": "^1.2.4" "yaml-lint": "^1.2.4"
} }
} }

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@@ -12,8 +12,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { ApplicationState } from '@/application/State/ApplicationState';
import TheHeader from '@/presentation/TheHeader.vue'; import TheHeader from '@/presentation/TheHeader.vue';
import TheFooter from '@/presentation/TheFooter/TheFooter.vue'; import TheFooter from '@/presentation/TheFooter/TheFooter.vue';
import TheCodeArea from '@/presentation/TheCodeArea.vue'; import TheCodeArea from '@/presentation/TheCodeArea.vue';

View File

@@ -1,4 +1,4 @@
import { OperatingSystem } from '../OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { DetectorBuilder } from './DetectorBuilder'; import { DetectorBuilder } from './DetectorBuilder';
import { IBrowserOsDetector } from './IBrowserOsDetector'; import { IBrowserOsDetector } from './IBrowserOsDetector';

View File

@@ -1,5 +1,5 @@
import { IBrowserOsDetector } from './IBrowserOsDetector'; import { IBrowserOsDetector } from './IBrowserOsDetector';
import { OperatingSystem } from '../OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
export class DetectorBuilder { export class DetectorBuilder {
private readonly existingPartsInUserAgent = new Array<string>(); private readonly existingPartsInUserAgent = new Array<string>();

View File

@@ -1,4 +1,4 @@
import { OperatingSystem } from '../OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IBrowserOsDetector { export interface IBrowserOsDetector {
detect(userAgent: string): OperatingSystem; detect(userAgent: string): OperatingSystem;

View File

@@ -1,7 +1,7 @@
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector'; import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
import { IEnvironment } from './IEnvironment'; import { IEnvironment } from './IEnvironment';
import { OperatingSystem } from './OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
interface IEnvironmentVariables { interface IEnvironmentVariables {
readonly window: Window & typeof globalThis; readonly window: Window & typeof globalThis;

View File

@@ -1,4 +1,4 @@
import { OperatingSystem } from './OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IEnvironment { export interface IEnvironment {
isDesktop: boolean; isDesktop: boolean;

View File

@@ -1,24 +1,35 @@
import { Category } from '@/domain/Category'; import { Category } from '@/domain/Category';
import { Application } from '@/domain/Application'; import { Application } from '@/domain/Application';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { ApplicationYaml } from 'js-yaml-loader!./../application.yaml'; import { ApplicationYaml } from 'js-yaml-loader!./../application.yaml';
import { parseCategory } from './CategoryParser'; import { parseCategory } from './CategoryParser';
import { ProjectInformation } from '../../domain/ProjectInformation';
export function parseApplication(content: ApplicationYaml): IApplication {
export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEnv = process.env): IApplication {
validate(content); validate(content);
const categories = new Array<Category>(); const categories = new Array<Category>();
for (const action of content.actions) { for (const action of content.actions) {
const category = parseCategory(action); const category = parseCategory(action);
categories.push(category); categories.push(category);
} }
const info = readAppInformation(env);
const app = new Application( const app = new Application(
content.name, info,
content.repositoryUrl,
process.env.VUE_APP_VERSION,
categories); categories);
return app; return app;
} }
function readAppInformation(environment): IProjectInformation {
return new ProjectInformation(
environment.VUE_APP_NAME,
environment.VUE_APP_VERSION,
environment.VUE_APP_REPOSITORY_URL,
environment.VUE_APP_HOMEPAGE_URL,
);
}
function validate(content: ApplicationYaml): void { function validate(content: ApplicationYaml): void {
if (!content) { if (!content) {
throw new Error('application is null or undefined'); throw new Error('application is null or undefined');

View File

@@ -52,7 +52,7 @@ function parseCategoryChild(
children.subScripts.push(script); children.subScripts.push(script);
} else { } else {
throw new Error(`Child element is neither a category or a script. throw new Error(`Child element is neither a category or a script.
Parent: ${parent.category}, element: ${categoryOrScript}`); Parent: ${parent.category}, element: ${JSON.stringify(categoryOrScript)}`);
} }
} }

View File

@@ -11,6 +11,7 @@ import { Script } from '@/domain/Script';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
import applicationFile from 'js-yaml-loader!@/application/application.yaml'; import applicationFile from 'js-yaml-loader!@/application/application.yaml';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
/** Mutatable singleton application state that's the single source of truth throughout the application */ /** Mutatable singleton application state that's the single source of truth throughout the application */
export class ApplicationState implements IApplicationState { export class ApplicationState implements IApplicationState {
@@ -37,8 +38,8 @@ export class ApplicationState implements IApplicationState {
public readonly app: IApplication, public readonly app: IApplication,
/** Initially selected scripts */ /** Initially selected scripts */
public readonly defaultScripts: Script[]) { public readonly defaultScripts: Script[]) {
this.selection = new UserSelection(app, defaultScripts); this.selection = new UserSelection(app, defaultScripts.map((script) => new SelectedScript(script, false)));
this.code = new ApplicationCode(this.selection, app.version); this.code = new ApplicationCode(this.selection, app.info.version);
this.filter = new UserFilter(app); this.filter = new UserFilter(app);
} }
} }

View File

@@ -1,30 +1,38 @@
import { CodeChangedEvent } from './Event/CodeChangedEvent';
import { CodePosition } from './Position/CodePosition';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { SelectedScript } from '@/application/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/State/Selection/IUserSelection'; import { IUserSelection } from '@/application/State/Selection/IUserSelection';
import { UserScriptGenerator } from './UserScriptGenerator'; import { UserScriptGenerator } from './Generation/UserScriptGenerator';
import { Signal } from '@/infrastructure/Events/Signal'; import { Signal } from '@/infrastructure/Events/Signal';
import { IApplicationCode } from './IApplicationCode'; import { IApplicationCode } from './IApplicationCode';
import { IUserScriptGenerator } from './IUserScriptGenerator'; import { IUserScriptGenerator } from './Generation/IUserScriptGenerator';
export class ApplicationCode implements IApplicationCode { export class ApplicationCode implements IApplicationCode {
public readonly changed = new Signal<string>(); public readonly changed = new Signal<ICodeChangedEvent>();
public current: string; public current: string;
private readonly generator: IUserScriptGenerator = new UserScriptGenerator(); private scriptPositions = new Map<SelectedScript, CodePosition>();
constructor( constructor(
userSelection: IUserSelection, userSelection: IUserSelection,
private readonly version: string) { private readonly version: string,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator()) {
if (!userSelection) { throw new Error('userSelection is null or undefined'); } if (!userSelection) { throw new Error('userSelection is null or undefined'); }
if (!version) { throw new Error('version is null or undefined'); } if (!version) { throw new Error('version is null or undefined'); }
this.generator = new UserScriptGenerator(); if (!generator) { throw new Error('generator is null or undefined'); }
this.setCode(userSelection.selectedScripts); this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => { userSelection.changed.on((scripts) => {
this.setCode(scripts); this.setCode(scripts);
}); });
} }
private setCode(scripts: ReadonlyArray<SelectedScript>) { private setCode(scripts: ReadonlyArray<SelectedScript>): void {
this.current = scripts.length === 0 ? '' : this.generator.buildCode(scripts, this.version); const oldScripts = Array.from(this.scriptPositions.keys());
this.changed.notify(this.current); const code = this.generator.buildCode(scripts, this.version);
this.current = code.code;
this.scriptPositions = code.scriptPositions;
const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions);
this.changed.notify(event);
} }
} }

View File

@@ -0,0 +1,64 @@
import { ICodeChangedEvent } from './ICodeChangedEvent';
import { SelectedScript } from '../../Selection/SelectedScript';
import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
export class CodeChangedEvent implements ICodeChangedEvent {
public readonly code: string;
public readonly addedScripts: ReadonlyArray<IScript>;
public readonly removedScripts: ReadonlyArray<IScript>;
public readonly changedScripts: ReadonlyArray<IScript>;
private readonly scripts: Map<IScript, ICodePosition>;
constructor(
code: string,
oldScripts: ReadonlyArray<SelectedScript>,
scripts: Map<SelectedScript, ICodePosition>) {
ensureAllPositionsExist(code, Array.from(scripts.values()));
this.code = code;
const newScripts = Array.from(scripts.keys());
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
}
public isEmpty(): boolean {
return this.scripts.size === 0;
}
public getScriptPositionInCode(script: IScript): ICodePosition {
return this.scripts.get(script);
}
}
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = script.split(/\r\n|\r|\n/).length;
for (const position of positions) {
if (position.endLine > totalLines) {
throw new Error(`script end line (${position.endLine}) is out of range.` +
`(total code lines: ${totalLines}`);
}
}
}
function getChangedScripts(
oldScripts: ReadonlyArray<SelectedScript>,
newScripts: ReadonlyArray<SelectedScript>): ReadonlyArray<IScript> {
return newScripts
.filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id
&& oldScript.revert !== newScript.revert ))
.map((selection) => selection.script);
}
function selectIfNotExists(
selectableContainer: ReadonlyArray<SelectedScript>,
test: ReadonlyArray<SelectedScript>) {
return selectableContainer
.filter((script) => !test.find((oldScript) => oldScript.id === script.id))
.map((selection) => selection.script);
}

View File

@@ -0,0 +1,11 @@
import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
export interface ICodeChangedEvent {
readonly code: string;
addedScripts: ReadonlyArray<IScript>;
removedScripts: ReadonlyArray<IScript>;
changedScripts: ReadonlyArray<IScript>;
isEmpty(): boolean;
getScriptPositionInCode(script: IScript): ICodePosition;
}

View File

@@ -4,8 +4,20 @@ const TotalFunctionSeparatorChars = 58;
export class CodeBuilder { export class CodeBuilder {
private readonly lines = new Array<string>(); private readonly lines = new Array<string>();
// Returns current line starting from 0 (no lines), or 1 (have single line)
public get currentLine(): number {
return this.lines.length;
}
public appendLine(code?: string): CodeBuilder { public appendLine(code?: string): CodeBuilder {
this.lines.push(code); if (!code) {
this.lines.push('');
return this;
}
const lines = code.match(/[^\r\n]+/g);
for (const line of lines) {
this.lines.push(line);
}
return this; return this;
} }

View File

@@ -0,0 +1,7 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
export interface IUserScript {
code: string;
scriptPositions: Map<SelectedScript, ICodePosition>;
}

View File

@@ -0,0 +1,7 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserScript } from './IUserScript';
export interface IUserScriptGenerator {
buildCode(
selectedScripts: ReadonlyArray<SelectedScript>,
version: string): IUserScript;
}

View File

@@ -0,0 +1,68 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { CodeBuilder } from './CodeBuilder';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScript } from './IUserScript';
export const adminRightsScript = {
name: 'Ensure admin privileges',
code: 'fltmc >nul 2>&1 || (\n' +
' echo Administrator privileges are required.\n' +
' PowerShell Start -Verb RunAs \'%0\' 2> nul || (\n' +
' echo Right-click on the script and select "Run as administrator".\n' +
' pause & exit 1\n' +
' )\n' +
' exit 0\n' +
')',
};
export class UserScriptGenerator implements IUserScriptGenerator {
public buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): IUserScript {
if (!selectedScripts) { throw new Error('scripts is undefined'); }
if (!version) { throw new Error('version is undefined'); }
let scriptPositions = new Map<SelectedScript, ICodePosition>();
if (!selectedScripts.length) {
return { code: '', scriptPositions };
}
const builder = initializeCode(version);
for (const selection of selectedScripts) {
scriptPositions = appendSelection(selection, scriptPositions, builder);
}
const code = finalizeCode(builder);
return { code, scriptPositions };
}
}
function initializeCode(version: string): CodeBuilder {
return new CodeBuilder()
.appendLine('@echo off')
.appendCommentLine(`https://privacy.sexy — v${version}${new Date().toUTCString()}`)
.appendFunction(adminRightsScript.name, adminRightsScript.code)
.appendLine();
}
function finalizeCode(builder: CodeBuilder): string {
return builder.appendLine()
.appendLine('pause')
.appendLine('exit /b 0')
.toString();
}
function appendSelection(
selection: SelectedScript,
scriptPositions: Map<SelectedScript, ICodePosition>,
builder: CodeBuilder): Map<SelectedScript, ICodePosition> {
const startPosition = builder.currentLine + 1;
appendCode(selection, builder);
const endPosition = builder.currentLine - 1;
builder.appendLine();
scriptPositions.set(selection, new CodePosition(startPosition, endPosition));
return scriptPositions;
}
function appendCode(selection: SelectedScript, builder: CodeBuilder) {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const scriptCode = selection.revert ? selection.script.revertCode : selection.script.code;
builder.appendFunction(name, scriptCode);
}

View File

@@ -1,6 +1,7 @@
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { ISignal } from '@/infrastructure/Events/ISignal'; import { ISignal } from '@/infrastructure/Events/ISignal';
export interface IApplicationCode { export interface IApplicationCode {
readonly changed: ISignal<string>; readonly changed: ISignal<ICodeChangedEvent>;
readonly current: string; readonly current: string;
} }

View File

@@ -1,5 +0,0 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
export interface IUserScriptGenerator {
buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): string;
}

View File

@@ -0,0 +1,24 @@
import { ICodePosition } from './ICodePosition';
export class CodePosition implements ICodePosition {
public get totalLines(): number {
return this.endLine - this.startLine;
}
constructor(
public readonly startLine: number,
public readonly endLine: number) {
if (startLine < 0) {
throw new Error('Code cannot start in a negative line');
}
if (endLine < 0) {
throw new Error('Code cannot end in a negative line');
}
if (endLine === startLine) {
throw new Error('Empty code');
}
if (endLine < startLine) {
throw new Error('End line cannot be less than start line');
}
}
}

View File

@@ -0,0 +1,5 @@
export interface ICodePosition {
readonly startLine: number;
readonly endLine: number;
readonly totalLines: number;
}

View File

@@ -1,34 +0,0 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { CodeBuilder } from './CodeBuilder';
export const adminRightsScript = {
name: 'Ensure admin privileges',
code: 'fltmc >nul 2>&1 || (\n' +
' echo This batch script requires administrator privileges. Right-click on\n' +
' echo the script and select "Run as administrator".\n' +
' pause\n' +
' exit 1\n' +
')',
};
export class UserScriptGenerator implements IUserScriptGenerator {
public buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): string {
if (!selectedScripts) { throw new Error('scripts is undefined'); }
if (!selectedScripts.length) { throw new Error('scripts are empty'); }
if (!version) { throw new Error('version is undefined'); }
const builder = new CodeBuilder()
.appendLine('@echo off')
.appendCommentLine(`https://privacy.sexy — v${version}${new Date().toUTCString()}`)
.appendFunction(adminRightsScript.name, adminRightsScript.code).appendLine();
for (const selection of selectedScripts) {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const code = selection.revert ? selection.script.revertCode : selection.script.code;
builder.appendFunction(name, code).appendLine();
}
return builder.appendLine()
.appendLine('pause')
.appendLine('exit /b 0')
.toString();
}
}

View File

@@ -2,6 +2,7 @@ import { IFilterResult } from './IFilterResult';
import { ISignal } from '@/infrastructure/Events/Signal'; import { ISignal } from '@/infrastructure/Events/Signal';
export interface IUserFilter { export interface IUserFilter {
readonly currentFilter: IFilterResult | undefined;
readonly filtered: ISignal<IFilterResult>; readonly filtered: ISignal<IFilterResult>;
readonly filterRemoved: ISignal<void>; readonly filterRemoved: ISignal<void>;
setFilter(filter: string): void; setFilter(filter: string): void;

View File

@@ -8,6 +8,7 @@ import { Signal } from '@/infrastructure/Events/Signal';
export class UserFilter implements IUserFilter { export class UserFilter implements IUserFilter {
public readonly filtered = new Signal<IFilterResult>(); public readonly filtered = new Signal<IFilterResult>();
public readonly filterRemoved = new Signal<void>(); public readonly filterRemoved = new Signal<void>();
public currentFilter: IFilterResult | undefined;
constructor(private application: IApplication) { constructor(private application: IApplication) {
@@ -28,11 +29,12 @@ export class UserFilter implements IUserFilter {
filteredCategories, filteredCategories,
filter, filter,
); );
this.currentFilter = matches;
this.filtered.notify(matches); this.filtered.notify(matches);
} }
public removeFilter(): void { public removeFilter(): void {
this.currentFilter = undefined;
this.filterRemoved.notify(); this.filterRemoved.notify();
} }
} }

View File

@@ -6,11 +6,13 @@ export interface IUserSelection {
readonly changed: ISignal<ReadonlyArray<SelectedScript>>; readonly changed: ISignal<ReadonlyArray<SelectedScript>>;
readonly selectedScripts: ReadonlyArray<SelectedScript>; readonly selectedScripts: ReadonlyArray<SelectedScript>;
readonly totalSelected: number; readonly totalSelected: number;
removeAllInCategory(categoryId: number): void;
addOrUpdateAllInCategory(categoryId: number, revert: boolean): void;
addSelectedScript(scriptId: string, revert: boolean): void; addSelectedScript(scriptId: string, revert: boolean): void;
addOrUpdateSelectedScript(scriptId: string, revert: boolean): void; addOrUpdateSelectedScript(scriptId: string, revert: boolean): void;
removeSelectedScript(scriptId: string): void; removeSelectedScript(scriptId: string): void;
selectOnly(scripts: ReadonlyArray<IScript>): void; selectOnly(scripts: ReadonlyArray<IScript>): void;
isSelected(script: IScript): boolean; isSelected(scriptId: string): boolean;
selectAll(): void; selectAll(): void;
deselectAll(): void; deselectAll(): void;
} }

View File

@@ -8,20 +8,49 @@ import { IRepository } from '@/infrastructure/Repository/IRepository';
export class UserSelection implements IUserSelection { export class UserSelection implements IUserSelection {
public readonly changed = new Signal<ReadonlyArray<SelectedScript>>(); public readonly changed = new Signal<ReadonlyArray<SelectedScript>>();
private readonly scripts: IRepository<string, SelectedScript> = new InMemoryRepository<string, SelectedScript>(); private readonly scripts: IRepository<string, SelectedScript>;
constructor( constructor(
private readonly app: IApplication, private readonly app: IApplication,
/** Initially selected scripts */ selectedScripts: ReadonlyArray<SelectedScript>) {
selectedScripts: ReadonlyArray<IScript>) { this.scripts = new InMemoryRepository<string, SelectedScript>();
if (selectedScripts && selectedScripts.length > 0) { if (selectedScripts && selectedScripts.length > 0) {
for (const script of selectedScripts) { for (const script of selectedScripts) {
const selected = new SelectedScript(script, false); this.scripts.addItem(script);
this.scripts.addItem(selected);
} }
} }
} }
public removeAllInCategory(categoryId: number): void {
const category = this.app.findCategory(categoryId);
const scriptsToRemove = category.getAllScriptsRecursively()
.filter((script) => this.scripts.exists(script.id));
if (!scriptsToRemove.length) {
return;
}
for (const script of scriptsToRemove) {
this.scripts.removeItem(script.id);
}
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateAllInCategory(categoryId: number, revert: boolean = false): void {
const category = this.app.findCategory(categoryId);
const scriptsToAddOrUpdate = category.getAllScriptsRecursively()
.filter((script) =>
!this.scripts.exists(script.id)
|| this.scripts.getById(script.id).revert !== revert,
);
if (!scriptsToAddOrUpdate.length) {
return;
}
for (const script of scriptsToAddOrUpdate) {
const selectedScript = new SelectedScript(script, revert);
this.scripts.addOrUpdateItem(selectedScript);
}
this.changed.notify(this.scripts.getItems());
}
public addSelectedScript(scriptId: string, revert: boolean): void { public addSelectedScript(scriptId: string, revert: boolean): void {
const script = this.app.findScript(scriptId); const script = this.app.findScript(scriptId);
if (!script) { if (!script) {
@@ -44,8 +73,8 @@ export class UserSelection implements IUserSelection {
this.changed.notify(this.scripts.getItems()); this.changed.notify(this.scripts.getItems());
} }
public isSelected(script: IScript): boolean { public isSelected(scriptId: string): boolean {
return this.scripts.exists(script.id); return this.scripts.exists(scriptId);
} }
/** Get users scripts based on his/her selections */ /** Get users scripts based on his/her selections */

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,6 @@ declare module 'js-yaml-loader!*' {
} }
export interface ApplicationYaml { export interface ApplicationYaml {
name: string;
repositoryUrl: string;
actions: ReadonlyArray<YamlCategory>; actions: ReadonlyArray<YamlCategory>;
} }

View File

@@ -2,6 +2,7 @@ import { IEntity } from '../infrastructure/Entity/IEntity';
import { ICategory } from './ICategory'; import { ICategory } from './ICategory';
import { IScript } from './IScript'; import { IScript } from './IScript';
import { IApplication } from './IApplication'; import { IApplication } from './IApplication';
import { IProjectInformation } from './IProjectInformation';
export class Application implements IApplication { export class Application implements IApplication {
public get totalScripts(): number { return this.flattened.allScripts.length; } public get totalScripts(): number { return this.flattened.allScripts.length; }
@@ -10,13 +11,11 @@ export class Application implements IApplication {
private readonly flattened: IFlattenedApplication; private readonly flattened: IFlattenedApplication;
constructor( constructor(
public readonly name: string, public readonly info: IProjectInformation,
public readonly repositoryUrl: string,
public readonly version: string,
public readonly actions: ReadonlyArray<ICategory>) { public readonly actions: ReadonlyArray<ICategory>) {
if (!name) { throw Error('Application has no name'); } if (!info) {
if (!repositoryUrl) { throw Error('Application has no repository url'); } throw new Error('info is undefined');
if (!version) { throw Error('Version cannot be empty'); } }
this.flattened = flatten(actions); this.flattened = flatten(actions);
ensureValid(this.flattened); ensureValid(this.flattened);
ensureNoDuplicates(this.flattened.allCategories); ensureNoDuplicates(this.flattened.allCategories);

View File

@@ -3,15 +3,7 @@ import { IScript } from './IScript';
import { ICategory } from './ICategory'; import { ICategory } from './ICategory';
export class Category extends BaseEntity<number> implements ICategory { export class Category extends BaseEntity<number> implements ICategory {
private static validate(category: ICategory) { private allSubScripts: ReadonlyArray<IScript> = undefined;
if (!category.name) {
throw new Error('name is null or empty');
}
if ((!category.subCategories || category.subCategories.length === 0) &&
(!category.scripts || category.scripts.length === 0)) {
throw new Error('A category must have at least one sub-category or scripts');
}
}
constructor( constructor(
id: number, id: number,
@@ -20,6 +12,27 @@ export class Category extends BaseEntity<number> implements ICategory {
public readonly subCategories?: ReadonlyArray<ICategory>, public readonly subCategories?: ReadonlyArray<ICategory>,
public readonly scripts?: ReadonlyArray<IScript>) { public readonly scripts?: ReadonlyArray<IScript>) {
super(id); super(id);
Category.validate(this); validateCategory(this);
}
public getAllScriptsRecursively(): readonly IScript[] {
return this.allSubScripts || (this.allSubScripts = parseScriptsRecursively(this));
}
}
function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
return [
...category.scripts,
...category.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
];
}
function validateCategory(category: ICategory) {
if (!category.name) {
throw new Error('undefined or empty name');
}
if ((!category.subCategories || category.subCategories.length === 0) &&
(!category.scripts || category.scripts.length === 0)) {
throw new Error('A category must have at least one sub-category or script');
} }
} }

View File

@@ -1,10 +1,9 @@
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { IProjectInformation } from './IProjectInformation';
export interface IApplication { export interface IApplication {
readonly name: string; readonly info: IProjectInformation;
readonly repositoryUrl: string;
readonly version: string;
readonly totalScripts: number; readonly totalScripts: number;
readonly totalCategories: number; readonly totalCategories: number;
readonly actions: ReadonlyArray<ICategory>; readonly actions: ReadonlyArray<ICategory>;

View File

@@ -7,6 +7,7 @@ export interface ICategory extends IEntity<number>, IDocumentable {
readonly name: string; readonly name: string;
readonly subCategories?: ReadonlyArray<ICategory>; readonly subCategories?: ReadonlyArray<ICategory>;
readonly scripts?: ReadonlyArray<IScript>; readonly scripts?: ReadonlyArray<IScript>;
getAllScriptsRecursively(): ReadonlyArray<IScript>;
} }
export { IEntity } from '../infrastructure/Entity/IEntity'; export { IEntity } from '../infrastructure/Entity/IEntity';

View File

@@ -0,0 +1,11 @@
import { OperatingSystem } from './OperatingSystem';
export interface IProjectInformation {
readonly name: string;
readonly version: string;
readonly repositoryUrl: string;
readonly homepage: string;
readonly feedbackUrl: string;
readonly releaseUrl: string;
readonly repositoryWebUrl: string;
getDownloadUrl(os: OperatingSystem): string;
}

View File

@@ -0,0 +1,55 @@
import { IProjectInformation } from './IProjectInformation';
import { OperatingSystem } from './OperatingSystem';
export class ProjectInformation implements IProjectInformation {
public readonly repositoryWebUrl: string;
constructor(
public readonly name: string,
public readonly version: string,
public readonly repositoryUrl: string,
public readonly homepage: string,
) {
if (!name) {
throw new Error('name is undefined');
}
if (!version || +version <= 0) {
throw new Error('version should be higher than zero');
}
if (!repositoryUrl) {
throw new Error('repositoryUrl is undefined');
}
if (!homepage) {
throw new Error('homepage is undefined');
}
this.repositoryWebUrl = getWebUrl(this.repositoryUrl);
}
public getDownloadUrl(os: OperatingSystem): string {
return `${this.repositoryWebUrl}/releases/download/${this.version}/${getFileName(os, this.version)}`;
}
public get feedbackUrl(): string {
return `${this.repositoryWebUrl}/issues`;
}
public get releaseUrl(): string {
return `${this.repositoryWebUrl}/releases/tag/${this.version}`;
}
}
function getWebUrl(gitUrl: string) {
if (gitUrl.endsWith('.git')) {
return gitUrl.substring(0, gitUrl.length - 4);
}
return gitUrl;
}
function getFileName(os: OperatingSystem, version: string): string {
switch (os) {
case OperatingSystem.Linux:
return `privacy.sexy-${version}.AppImage`;
case OperatingSystem.macOS:
return `privacy.sexy-${version}.dmg`;
case OperatingSystem.Windows:
return `privacy.sexy-Setup-${version}.exe`;
default:
throw new Error(`Unsupported os: ${OperatingSystem[os]}`);
}
}

View File

@@ -41,6 +41,9 @@ function mayBeUniqueLine(codeLine: string): boolean {
if (trimmed === ')' || trimmed === '(') { // "(" and ")" are used often in batch code if (trimmed === ')' || trimmed === '(') { // "(" and ")" are used often in batch code
return false; return false;
} }
if (codeLine.startsWith(':: ') || codeLine.startsWith('REM ')) { // Is comment?
return false;
}
return true; return true;
} }

View File

@@ -3,6 +3,7 @@ import { IEntity } from '../Entity/IEntity';
export interface IRepository<TKey, TEntity extends IEntity<TKey>> { export interface IRepository<TKey, TEntity extends IEntity<TKey>> {
readonly length: number; readonly length: number;
getItems(predicate?: (entity: TEntity) => boolean): TEntity[]; getItems(predicate?: (entity: TEntity) => boolean): TEntity[];
getById(id: TKey): TEntity | undefined;
addItem(item: TEntity): void; addItem(item: TEntity): void;
addOrUpdateItem(item: TEntity): void; addOrUpdateItem(item: TEntity): void;
removeItem(id: TKey): void; removeItem(id: TKey): void;

View File

@@ -16,6 +16,14 @@ export class InMemoryRepository<TKey, TEntity extends IEntity<TKey>> implements
return predicate ? this.items.filter(predicate) : this.items; return predicate ? this.items.filter(predicate) : this.items;
} }
public getById(id: TKey): TEntity | undefined {
const items = this.getItems((entity) => entity.id === id);
if (!items.length) {
return undefined;
}
return items[0];
}
public addItem(item: TEntity): void { public addItem(item: TEntity): void {
if (!item) { if (!item) {
throw new Error('item is null or undefined'); throw new Error('item is null or undefined');

View File

@@ -1,9 +1,18 @@
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
export enum FileType {
BatchFile,
}
export class SaveFileDialog { export class SaveFileDialog {
public static saveText(text: string, fileName: string): void { public static saveFile(text: string, fileName: string, type: FileType): void {
this.saveBlob(text, 'text/plain;charset=utf-8', fileName); const mimeType = this.mimeTypes.get(type);
this.saveBlob(text, mimeType, fileName);
} }
private static readonly mimeTypes = new Map<FileType, string>([
// Some browsers (including firefox + IE) require right mime type
// otherwise they ignore extension and save the file as text.
[ FileType.BatchFile, 'application/bat' ], // https://en.wikipedia.org/wiki/Batch_file
]);
private static saveBlob(file: BlobPart, fileType: string, fileName: string): void { private static saveBlob(file: BlobPart, fileType: string, fileName: string): void {
try { try {

View File

@@ -1,6 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import App from './App.vue'; import App from './App.vue';
import { ApplicationBootstrapper } from './presentation/Bootstrapping/ApplicationBootstrapper'; import { ApplicationBootstrapper } from './presentation/Bootstrapping/ApplicationBootstrapper';
import 'core-js/fn/array/flat-map'; // Here until Vue 3 & CLI v4 https://github.com/vuejs/vue-cli/issues/3834
new ApplicationBootstrapper() new ApplicationBootstrapper()
.bootstrap(Vue); .bootstrap(Vue);

View File

@@ -8,10 +8,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit } from 'vue-property-decorator'; import { Component, Prop, Emit } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue'; import { StatefulVue } from './StatefulVue';
import { SaveFileDialog } from './../infrastructure/SaveFileDialog';
import { Clipboard } from './../infrastructure/Clipboard';
@Component @Component
export default class IconButton extends StatefulVue { export default class IconButton extends StatefulVue {

View File

@@ -16,7 +16,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import CardListItem from './CardListItem.vue'; import CardListItem from './CardListItem.vue';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';

View File

@@ -24,7 +24,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch, Emit } from 'vue-property-decorator'; import { Component, Prop, Watch, Emit } from 'vue-property-decorator';
import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';

View File

@@ -15,9 +15,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { IApplicationState } from '@/application/State/IApplicationState';
import { Grouping } from './Grouping'; import { Grouping } from './Grouping';
const DefaultGrouping = Grouping.Cards; const DefaultGrouping = Grouping.Cards;

View File

@@ -1,6 +1,6 @@
import { IApplication } from './../../../domain/IApplication'; import { IApplication } from './../../../domain/IApplication';
import { ICategory, IScript } from '@/domain/ICategory'; import { ICategory, IScript } from '@/domain/ICategory';
import { INode } from './SelectableTree/INode'; import { INode, NodeType } from './SelectableTree/Node/INode';
export function parseAllCategories(app: IApplication): INode[] | undefined { export function parseAllCategories(app: IApplication): INode[] | undefined {
const nodes = new Array<INode>(); const nodes = new Array<INode>();
@@ -23,9 +23,15 @@ export function parseSingleCategory(categoryId: number, app: IApplication): INod
export function getScriptNodeId(script: IScript): string { export function getScriptNodeId(script: IScript): string {
return script.id; return script.id;
} }
export function getScriptId(nodeId: string): string {
return nodeId;
}
export function getCategoryId(nodeId: string): number {
return +nodeId;
}
export function getCategoryNodeId(category: ICategory): string { export function getCategoryNodeId(category: ICategory): string {
return `Category${category.id}`; return `${category.id}`;
} }
function parseCategoryRecursively( function parseCategoryRecursively(
@@ -64,16 +70,18 @@ function convertCategoryToNode(
category: ICategory, children: readonly INode[]): INode { category: ICategory, children: readonly INode[]): INode {
return { return {
id: getCategoryNodeId(category), id: getCategoryNodeId(category),
type: NodeType.Category,
text: category.name, text: category.name,
children, children,
documentationUrls: category.documentationUrls, documentationUrls: category.documentationUrls,
isReversible: false, isReversible: children && children.every((child) => child.isReversible),
}; };
} }
function convertScriptToNode(script: IScript): INode { function convertScriptToNode(script: IScript): INode {
return { return {
id: getScriptNodeId(script), id: getScriptNodeId(script),
type: NodeType.Script,
text: script.name, text: script.name,
children: undefined, children: undefined,
documentationUrls: script.documentationUrls, documentationUrls: script.documentationUrls,

View File

@@ -7,7 +7,6 @@
:filterPredicate="filterPredicate" :filterPredicate="filterPredicate"
:filterText="filterText" :filterText="filterText"
v-on:nodeSelected="toggleNodeSelectionAsync($event)" v-on:nodeSelected="toggleNodeSelectionAsync($event)"
v-on:nodeRevertToggled="handleNodeRevertToggleAsync($event)"
> >
</SelectableTree> </SelectableTree>
</span> </span>
@@ -16,18 +15,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { Category } from '@/domain/Category';
import { IRepository } from '@/infrastructure/Repository/IRepository';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { IApplicationState, IUserSelection } from '@/application/State/IApplicationState'; import { IApplicationState } from '@/application/State/IApplicationState';
import { IFilterResult } from '@/application/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/State/Filter/IFilterResult';
import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId } from './ScriptNodeParser'; import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser';
import SelectableTree, { FilterPredicate } from './SelectableTree/SelectableTree.vue'; import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode } from './SelectableTree/INode'; import { INode, NodeType } from './SelectableTree/Node/INode';
import { SelectedScript } from '@/application/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
@Component({ @Component({
components: { components: {
@@ -51,17 +49,20 @@
state.filter.filtered.on(this.handleFiltered); state.filter.filtered.on(this.handleFiltered);
// Update initial state // Update initial state
await this.initializeNodesAsync(this.categoryId); await this.initializeNodesAsync(this.categoryId);
await this.initializeFilter(state.filter.currentFilter);
} }
public async toggleNodeSelectionAsync(node: INode) { public async toggleNodeSelectionAsync(event: INodeSelectedEvent) {
if (node.children != null && node.children.length > 0) {
return; // only interested in script nodes
}
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
if (!this.selectedNodeIds.some((id) => id === node.id)) { switch (event.node.type) {
state.selection.addSelectedScript(node.id, false); case NodeType.Category:
} else { toggleCategoryNodeSelection(event, state);
state.selection.removeSelectedScript(node.id); break;
case NodeType.Script:
toggleScriptNodeSelection(event, state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
} }
} }
@@ -84,6 +85,14 @@
(category: ICategory) => node.id === getCategoryNodeId(category)); (category: ICategory) => node.id === getCategoryNodeId(category));
} }
private initializeFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
this.handleFilterRemoved();
} else {
this.handleFiltered(currentFilter);
}
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void { private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts this.selectedNodeIds = selectedScripts
.map((node) => node.id); .map((node) => node.id);
@@ -99,6 +108,24 @@
} }
} }
function toggleCategoryNodeSelection(event: INodeSelectedEvent, state: IApplicationState): void {
const categoryId = getCategoryId(event.node.id);
if (event.isSelected) {
state.selection.addOrUpdateAllInCategory(categoryId, false);
} else {
state.selection.removeAllInCategory(categoryId);
}
}
function toggleScriptNodeSelection(event: INodeSelectedEvent, state: IApplicationState): void {
const scriptId = getScriptId(event.node.id);
const actualToggleState = state.selection.isSelected(scriptId);
const targetToggleState = event.isSelected;
if (targetToggleState && !actualToggleState) {
state.selection.addSelectedScript(scriptId, false);
} else if (!targetToggleState && actualToggleState) {
state.selection.removeSelectedScript(scriptId);
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -0,0 +1,6 @@
import { INode } from './Node/INode';
export interface INodeSelectedEvent {
isSelected: boolean;
node: INode;
}

View File

@@ -1,4 +1,3 @@
// Two ways of typing other libraries: https://stackoverflow.com/a/53070501
declare module 'liquor-tree' { declare module 'liquor-tree' {
import { PluginObject } from 'vue'; import { PluginObject } from 'vue';
@@ -10,33 +9,44 @@ declare module 'liquor-tree' {
filter(query: string): void; filter(query: string): void;
clearFilter(): void; clearFilter(): void;
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void; setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
// getNodeById(id: string): ILiquorTreeExistingNode;
recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void;
} }
interface ICustomLiquorTreeData { export interface ICustomLiquorTreeData {
type: number;
documentationUrls: ReadonlyArray<string>; documentationUrls: ReadonlyArray<string>;
isReversible: boolean; isReversible: boolean;
} }
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
export interface ILiquorTreeNodeState {
checked: boolean;
indeterminate: boolean;
}
export interface ILiquorTreeNode {
id: string;
data: ICustomLiquorTreeData;
children: ReadonlyArray<ILiquorTreeNode> | undefined;
}
/** /**
* Returned from Node tree view events. * Returned from Node tree view events.
* See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js * See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/ */
export interface ILiquorTreeExistingNode { export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
id: string;
data: ILiquorTreeNodeData; data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined; states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined; children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
// expand(): void;
} }
/** /**
* Sent to liquor tree to define of new nodes. * Sent to liquor tree to define of new nodes.
* https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js * https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/ */
export interface ILiquorTreeNewNode { export interface ILiquorTreeNewNode extends ILiquorTreeNode {
id: string;
text: string; text: string;
state: ILiquorTreeNodeState | undefined; state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined; children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
data: ICustomLiquorTreeData;
} }
// https://amsik.github.io/liquor-tree/#Component-Options // https://amsik.github.io/liquor-tree/#Component-Options
@@ -47,29 +57,16 @@ declare module 'liquor-tree' {
autoCheckChildren: boolean; autoCheckChildren: boolean;
parentSelect: boolean; parentSelect: boolean;
keyboardNavigation: boolean; keyboardNavigation: boolean;
deletion: (node: ILiquorTreeExistingNode) => void;
filter: ILiquorTreeFilter; filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNode): boolean;
} }
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js export interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
interface ILiquorTreeNodeState {
checked: boolean;
}
interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
text: string; text: string;
} }
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue // https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
interface ILiquorTreeOptions { export interface ILiquorTreeFilter {
checkbox: boolean;
checkOnSelect: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNewNode): boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
interface ILiquorTreeFilter {
emptyText: string; emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean; matcher(query: string, node: ILiquorTreeExistingNode): boolean;
} }

View File

@@ -0,0 +1,23 @@
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode, ILiquorTreeExistingNode } from 'liquor-tree';
export class LiquorTreeOptions implements ILiquorTreeOptions {
public readonly multiple = true;
public readonly checkbox = true;
public readonly checkOnSelect = true;
/* For checkbox mode only. Children will have the same checked state as their parent.
⚠️ Setting this false, does not update indeterminate state of nodes.
This is false as it's handled manually to be able to batch select for performance + highlighting */
public readonly autoCheckChildren = false;
public readonly parentSelect = true;
public readonly keyboardNavigation = true;
public readonly filter = { // Wrap this in an arrow function as setting filter directly does not work JS APIs
emptyText: this.liquorTreeFilter.emptyText,
matcher: (query: string, node: ILiquorTreeExistingNode) => {
return this.liquorTreeFilter.matcher(query, node);
},
};
constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { }
public deletion(node: ILiquorTreeNode): boolean {
return false; // no op
}
}

View File

@@ -0,0 +1,17 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { convertExistingToNode } from './NodeTranslator';
import { INode } from './../../Node/INode';
export type FilterPredicate = (node: INode) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter {
public emptyText = ''; // Does not matter as a custom mesage is shown
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {
throw new Error('filterPredicate is undefined');
}
}
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
return this.filterPredicate(convertExistingToNode(node));
}
}

View File

@@ -0,0 +1,61 @@
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
import { NodeType } from './../../Node/INode';
export function getNewState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
const checked = getNewCheckedState(node, selectedNodeIds);
const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds);
return {
checked, indeterminate,
};
}
function getNewIndeterminateState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (node.data.type) {
case NodeType.Script:
return false;
case NodeType.Category:
return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function getNewCheckedState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (node.data.type) {
case NodeType.Script:
return selectedNodeIds.some((id) => id === node.id);
case NodeType.Category:
return parseAllSubScriptIds(node).every((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray<string> {
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
return categoryNode
.children
.flatMap((child) => getNodeIds(child));
}
function getNodeIds(node: ILiquorTreeNode): ReadonlyArray<string> {
switch (node.data.type) {
case NodeType.Script:
return [ node.id ];
case NodeType.Category:
return parseAllSubScriptIds(node);
default:
throw new Error('Unknown node type');
}
}

View File

@@ -1,5 +1,5 @@
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree'; import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
import { INode } from './INode'; import { INode } from './../../Node/INode';
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption // Functions to translate INode to LiqourTree models and vice versa for anti-corruption
@@ -7,6 +7,7 @@ export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode):
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); } if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return { return {
id: liquorTreeNode.id, id: liquorTreeNode.id,
type: liquorTreeNode.data.type,
text: liquorTreeNode.data.text, text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked, // selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode), children: convertChildren(liquorTreeNode.children, convertExistingToNode),
@@ -22,11 +23,13 @@ export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
text: node.text, text: node.text,
state: { state: {
checked: false, checked: false,
indeterminate: false,
}, },
children: convertChildren(node.children, toNewLiquorTreeNode), children: convertChildren(node.children, toNewLiquorTreeNode),
data: { data: {
documentationUrls: node.documentationUrls, documentationUrls: node.documentationUrls,
isReversible: node.isReversible, isReversible: node.isReversible,
type: node.type,
}, },
}; };
} }

View File

@@ -1,7 +1,13 @@
export enum NodeType {
Script,
Category,
}
export interface INode { export interface INode {
readonly id: string; readonly id: string;
readonly text: string; readonly text: string;
readonly isReversible: boolean; readonly isReversible: boolean;
readonly documentationUrls: ReadonlyArray<string>; readonly documentationUrls: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>; readonly children?: ReadonlyArray<INode>;
readonly type: NodeType;
} }

View File

@@ -4,7 +4,7 @@
<RevertToggle <RevertToggle
class="item" class="item"
v-if="data.isReversible" v-if="data.isReversible"
:scriptId="data.id" /> :node="data" />
<DocumentationUrls <DocumentationUrls
class="item" class="item"
v-if="data.documentationUrls && data.documentationUrls.length > 0" v-if="data.documentationUrls && data.documentationUrls.length > 0"

View File

@@ -0,0 +1,151 @@
<template>
<div class="checkbox-switch" >
<input type="checkbox" class="input-checkbox"
v-model="isReverted"
@change="onRevertToggledAsync()"
v-on:click.stop>
<div class="checkbox-animate">
<span class="checkbox-off">revert</span>
<span class="checkbox-on">revert</span>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { IReverter } from './Reverter/IReverter';
import { StatefulVue } from '@/presentation/StatefulVue';
import { INode } from './INode';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { getReverter } from './Reverter/ReverterFactory';
@Component
export default class RevertToggle extends StatefulVue {
@Prop() public node: INode;
public isReverted = false;
private handler: IReverter;
public async mounted() {
await this.onNodeChangedAsync(this.node);
const state = await this.getCurrentStateAsync();
this.updateState(state.selection.selectedScripts);
state.selection.changed.on((scripts) => this.updateState(scripts));
}
@Watch('node') public async onNodeChangedAsync(node: INode) {
const state = await this.getCurrentStateAsync();
this.handler = getReverter(node, state.app);
}
public async onRevertToggledAsync() {
const state = await this.getCurrentStateAsync();
this.handler.selectWithRevertState(this.isReverted, state.selection);
}
private updateState(scripts: ReadonlyArray<SelectedScript>) {
this.isReverted = this.handler.getState(scripts);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
$width: 85px;
$height: 30px;
// https://www.designlabthemes.com/css-toggle-switch/
.checkbox-switch {
cursor: pointer;
display: inline-block;
overflow: hidden;
position: relative;
width: $width;
height: $height;
-webkit-border-radius: $height;
border-radius: $height;
line-height: $height;
font-size: $height / 2;
display: inline-block;
input.input-checkbox {
position: absolute;
left: 0;
top: 0;
width: $width;
height: $height;
padding: 0;
margin: 0;
opacity: 0;
z-index: 2;
cursor: pointer;
}
.checkbox-animate {
position: relative;
width: $width;
height: $height;
background-color: $gray;
-webkit-transition: background-color 0.25s ease-out 0s;
transition: background-color 0.25s ease-out 0s;
// Circle
&:before {
$circle-size: $height * 0.66;
content: "";
display: block;
position: absolute;
width: $circle-size;
height: $circle-size;
border-radius: $circle-size * 2;
-webkit-border-radius: $circle-size * 2;
background-color: $slate;
top: $height * 0.16;
left: $width * 0.05;
-webkit-transition: left 0.3s ease-out 0s;
transition: left 0.3s ease-out 0s;
z-index: 10;
}
}
input.input-checkbox:checked {
+ .checkbox-animate {
background-color: $accent;
}
+ .checkbox-animate:before {
left: ($width - $width/3.5);
background-color: $light-gray;
}
+ .checkbox-animate .checkbox-off {
display: none;
opacity: 0;
}
+ .checkbox-animate .checkbox-on {
display: block;
opacity: 1;
}
}
.checkbox-off, .checkbox-on {
float: left;
color: $white;
font-weight: 700;
-webkit-transition: all 0.3s ease-out 0s;
transition: all 0.3s ease-out 0s;
}
.checkbox-off {
margin-left: $width / 3;
opacity: 1;
}
.checkbox-on {
display: none;
float: right;
margin-right: $width / 3;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import { IReverter } from './IReverter';
import { getCategoryId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IApplication } from '@/domain/IApplication';
import { ScriptReverter } from './ScriptReverter';
import { IUserSelection } from '@/application/State/Selection/IUserSelection';
export class CategoryReverter implements IReverter {
private readonly categoryId: number;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: string, app: IApplication) {
this.categoryId = getCategoryId(nodeId);
this.scriptReverters = getAllSubScriptReverters(this.categoryId, app);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
return this.scriptReverters.every((script) => script.getState(selectedScripts));
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateAllInCategory(this.categoryId, newState);
}
}
function getAllSubScriptReverters(categoryId: number, app: IApplication) {
const category = app.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id "${categoryId}" does not exist`);
}
const scripts = category.getAllScriptsRecursively();
return scripts.map((script) => new ScriptReverter(script.id));
}

View File

@@ -0,0 +1,7 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/State/IApplicationState';
export interface IReverter {
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
selectWithRevertState(newState: boolean, selection: IUserSelection): void;
}

View File

@@ -0,0 +1,16 @@
import { INode, NodeType } from '../INode';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { IApplication } from '@/domain/IApplication';
import { CategoryReverter } from './CategoryReverter';
export function getReverter(node: INode, app: IApplication): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, app);
case NodeType.Script:
return new ScriptReverter(node.id);
default:
throw new Error('Unknown script type');
}
}

View File

@@ -0,0 +1,21 @@
import { IReverter } from './IReverter';
import { getScriptId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/State/IApplicationState';
export class ScriptReverter implements IReverter {
private readonly scriptId: string;
constructor(nodeId: string) {
this.scriptId = getScriptId(nodeId);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
const selectedScript = selectedScripts.find((selected) => selected.id === this.scriptId);
if (!selectedScript) {
return false;
}
return selectedScript.revert;
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateSelectedScript(this.scriptId, newState);
}
}

View File

@@ -1,141 +0,0 @@
<template>
<div class="checkbox-switch" >
<input type="checkbox" class="input-checkbox"
v-model="isReverted"
@change="onRevertToggledAsync()" >
<div class="checkbox-animate">
<span class="checkbox-off">revert</span>
<span class="checkbox-on">revert</span>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { INode } from './INode';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
@Component
export default class RevertToggle extends StatefulVue {
@Prop() public scriptId: string;
public isReverted = false;
public async mounted() {
const state = await this.getCurrentStateAsync();
state.selection.changed.on(this.handleSelectionChanged);
}
public async onRevertToggledAsync() {
const state = await this.getCurrentStateAsync();
state.selection.addOrUpdateSelectedScript(this.scriptId, this.isReverted);
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
const selectedScript = selectedScripts.find((script) => script.id === this.scriptId);
if (!selectedScript) {
this.isReverted = false;
} else {
this.isReverted = selectedScript.revert;
}
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
$width: 85px;
$height: 30px;
// https://www.designlabthemes.com/css-toggle-switch/
.checkbox-switch {
cursor: pointer;
display: inline-block;
overflow: hidden;
position: relative;
width: $width;
height: $height;
-webkit-border-radius: $height;
border-radius: $height;
line-height: $height;
font-size: $height / 2;
display: inline-block;
input.input-checkbox {
position: absolute;
left: 0;
top: 0;
width: $width;
height: $height;
padding: 0;
margin: 0;
opacity: 0;
z-index: 2;
cursor: pointer;
}
.checkbox-animate {
position: relative;
width: $width;
height: $height;
background-color: $gray;
-webkit-transition: background-color 0.25s ease-out 0s;
transition: background-color 0.25s ease-out 0s;
// Circle
&:before {
$circle-size: $height * 0.66;
content: "";
display: block;
position: absolute;
width: $circle-size;
height: $circle-size;
border-radius: $circle-size * 2;
-webkit-border-radius: $circle-size * 2;
background-color: $slate;
top: $height * 0.16;
left: $width * 0.05;
-webkit-transition: left 0.3s ease-out 0s;
transition: left 0.3s ease-out 0s;
z-index: 10;
}
}
input.input-checkbox:checked {
+ .checkbox-animate {
background-color: $accent;
}
+ .checkbox-animate:before {
left: ($width - $width/3.5);
background-color: $light-gray;
}
+ .checkbox-animate .checkbox-off {
display: none;
opacity: 0;
}
+ .checkbox-animate .checkbox-on {
display: block;
opacity: 1;
}
}
.checkbox-off, .checkbox-on {
float: left;
color: $white;
font-weight: 700;
-webkit-transition: all 0.3s ease-out 0s;
transition: all 0.3s ease-out 0s;
}
.checkbox-off {
margin-left: $width / 3;
opacity: 1;
}
.checkbox-on {
display: none;
float: right;
margin-right: $width / 3;
opacity: 0;
}
}
</style>

View File

@@ -7,7 +7,7 @@
v-on:node:unchecked="nodeSelected($event)" v-on:node:unchecked="nodeSelected($event)"
ref="treeElement" ref="treeElement"
> >
<span class="tree-text" slot-scope="{ node }"> <span class="tree-text" slot-scope="{ node }" >
<Node :data="convertExistingToNode(node)" /> <Node :data="convertExistingToNode(node)" />
</span> </span>
</tree> </tree>
@@ -17,28 +17,32 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from 'vue-property-decorator'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import LiquorTree, { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeOptions } from 'liquor-tree'; import LiquorTree from 'liquor-tree';
import Node from './Node.vue'; import Node from './Node/Node.vue';
import { INode } from './INode'; import { INode } from './Node/INode';
import { convertExistingToNode, toNewLiquorTreeNode } from './NodeTranslator'; import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
export type FilterPredicate = (node: INode) => boolean; import { INodeSelectedEvent } from './/INodeSelectedEvent';
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
import { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */ /** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({ @Component({
components: { components: {
LiquorTree, LiquorTree,
Node, Node,
}, },
}) })
export default class SelectableTree extends Vue { export default class SelectableTree extends Vue { // Keep it stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate; @Prop() public filterPredicate?: FilterPredicate;
@Prop() public filterText?: string; @Prop() public filterText?: string;
@Prop() public selectedNodeIds?: ReadonlyArray<string>; @Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Prop() public initialNodes?: ReadonlyArray<INode>; @Prop() public initialNodes?: ReadonlyArray<INode>;
public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null; public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = this.getDefaults(); public liquorTreeOptions = new LiquorTreeOptions(new NodePredicateFilter((node) => this.filterPredicate(node)));
public convertExistingToNode = convertExistingToNode; public convertExistingToNode = convertExistingToNode;
public mounted() { public mounted() {
@@ -46,7 +50,7 @@
const initialNodes = this.initialNodes.map((node) => toNewLiquorTreeNode(node)); const initialNodes = this.initialNodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) { if (this.selectedNodeIds) {
recurseDown(initialNodes, recurseDown(initialNodes,
(node) => node.state.checked = this.selectedNodeIds.includes(node.id)); (node) => node.state = updateState(node.state, node, this.selectedNodeIds));
} }
this.initialLiquourTreeNodes = initialNodes; this.initialLiquourTreeNodes = initialNodes;
} else { } else {
@@ -58,7 +62,11 @@
} }
public nodeSelected(node: ILiquorTreeExistingNode) { public nodeSelected(node: ILiquorTreeExistingNode) {
this.$emit('nodeSelected', convertExistingToNode(node)); const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
return; return;
} }
@@ -73,23 +81,13 @@
} }
@Watch('selectedNodeIds') @Watch('selectedNodeIds')
public setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) { public setSelectedStatusAsync(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) { if (!selectedNodeIds) {
throw new Error('Selected nodes are undefined'); throw new Error('SelectedrecurseDown nodes are undefined');
} }
const newNodes = updateCheckedState(this.getLiquorTreeApi().model, selectedNodeIds); this.getLiquorTreeApi().recurseDown(
this.getLiquorTreeApi().setModel(newNodes); (node) => node.states = updateState(node.states, node, selectedNodeIds),
/* Alternative: );
this.getLiquorTreeApi().recurseDown((node) => {
node.states.checked = selectedNodeIds.includes(node.id);
});
Problem: Does not check their parent if all children are checked, because it does not
trigger update on parent as we work with scripts not categories. */
/* Alternative:
this.getLiquorTreeApi().recurseDown((node) => {
if(selectedNodeIds.includes(node.id)) { node.select(); } else { node.unselect(); }
});
Problem: Emits nodeSelected() event again which will cause an infinite loop. */
} }
private getLiquorTreeApi(): ILiquorTree { private getLiquorTreeApi(): ILiquorTree {
@@ -98,27 +96,13 @@
} }
return (this.$refs.treeElement as any).tree; return (this.$refs.treeElement as any).tree;
} }
}
private getDefaults(): ILiquorTreeOptions { function updateState(
return { old: ILiquorTreeNodeState,
multiple: true, node: ILiquorTreeNode,
checkbox: true, selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
checkOnSelect: true, return {...old, ...getNewState(node, selectedNodeIds)};
autoCheckChildren: true,
parentSelect: false,
keyboardNavigation: true,
deletion: (node) => !node.children || node.children.length === 0,
filter: {
matcher: (query: string, node: ILiquorTreeExistingNode) => {
if (!this.filterPredicate) {
throw new Error('Cannot filter as predicate is null');
}
return this.filterPredicate(convertExistingToNode(node));
},
emptyText: '🕵Hmm.. Can not see one 🧐',
},
};
}
} }
function recurseDown( function recurseDown(
@@ -131,27 +115,4 @@
} }
} }
} }
function updateCheckedState(
oldNodes: ReadonlyArray<ILiquorTreeExistingNode>,
selectedNodeIds: ReadonlyArray<string>): ReadonlyArray<ILiquorTreeNewNode> {
const result = new Array<ILiquorTreeNewNode>();
for (const oldNode of oldNodes) {
const newState = oldNode.states;
newState.checked = selectedNodeIds.some((id) => id === oldNode.id);
const newNode: ILiquorTreeNewNode = {
id: oldNode.id,
text: oldNode.data.text,
data: {
documentationUrls: oldNode.data.documentationUrls,
isReversible: oldNode.data.isReversible,
},
children: oldNode.children == null ? [] :
updateCheckedState(oldNode.children, selectedNodeIds),
state: newState,
};
result.push(newNode);
}
return result;
}
</script> </script>

View File

@@ -6,7 +6,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit } from 'vue-property-decorator'; import { Component, Prop, Emit } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective'; import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective';

View File

@@ -28,7 +28,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import SelectableOption from './SelectableOption.vue'; import SelectableOption from './SelectableOption.vue';
import { IApplicationState } from '@/application/State/IApplicationState'; import { IApplicationState } from '@/application/State/IApplicationState';

View File

@@ -7,25 +7,37 @@
v-show="!this.isSearching" /> v-show="!this.isSearching" />
</div> </div>
<div class="scripts"> <div class="scripts">
<div v-if="!isSearching || searchHasMatches"> <div v-if="!isSearching">
<CardList v-if="this.showCards" /> <CardList v-if="currentGrouping === Grouping.Cards"/>
<div v-else-if="this.showList" class="tree"> <div class="tree" v-if="currentGrouping === Grouping.None">
<div v-if="this.isSearching" class="search-query"> <ScriptsTree />
Searching for "{{this.searchQuery | threeDotsTrim}}"</div> </div>
<ScriptsTree /> </div>
<div v-else> <!-- Searching -->
<div class="search">
<div class="search__query">
<div>Searching for "{{this.searchQuery | threeDotsTrim}}"</div>
<div class="search__query__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="clearSearchQueryAsync()"/>
</div>
</div> </div>
<div v-if="!searchHasMatches" class="search-no-matches">
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞</div>
<div>Feel free to extend the scripts <a :href="repositoryUrl" target="_blank" class="child github" >here</a> </div>
</div> </div>
<div v-else class="search-no-matches"> </div>
Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞 <div v-if="searchHasMatches" class="tree tree--searching">
Feel free to extend the scripts <a :href="repositoryUrl" target="_blank" class="child github" >here</a>. <ScriptsTree />
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { Category } from '@/domain/Category';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { Grouping } from './Grouping/Grouping'; import { Grouping } from './Grouping/Grouping';
import { IFilterResult } from '@/application/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/State/Filter/IFilterResult';
@@ -34,7 +46,6 @@
import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/Scripts/Cards/CardList.vue'; import CardList from '@/presentation/Scripts/Cards/CardList.vue';
/** Shows content of single category or many categories */ /** Shows content of single category or many categories */
@Component({ @Component({
components: { components: {
@@ -54,38 +65,33 @@
}, },
}) })
export default class TheScripts extends StatefulVue { export default class TheScripts extends StatefulVue {
public showCards = false;
public showList = false;
public repositoryUrl = ''; public repositoryUrl = '';
private searchQuery = ''; public Grouping = Grouping; // Make it accessible from view
private isSearching = false; public currentGrouping = Grouping.Cards;
private searchHasMatches = false; public searchQuery = '';
public isSearching = false;
private currentGrouping: Grouping; public searchHasMatches = false;
public async mounted() { public async mounted() {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
this.repositoryUrl = state.app.repositoryUrl; this.repositoryUrl = state.app.info.repositoryWebUrl;
state.filter.filterRemoved.on(() => { state.filter.filterRemoved.on(() => {
this.isSearching = false; this.isSearching = false;
this.updateGroups();
}); });
state.filter.filtered.on((result: IFilterResult) => { state.filter.filtered.on((result: IFilterResult) => {
this.searchQuery = result.query; this.searchQuery = result.query;
this.isSearching = true; this.isSearching = true;
this.searchHasMatches = result.hasAnyMatches(); this.searchHasMatches = result.hasAnyMatches();
this.updateGroups();
}); });
} }
public async clearSearchQueryAsync() {
const state = await this.getCurrentStateAsync();
state.filter.removeFilter();
}
public onGroupingChanged(group: Grouping) { public onGroupingChanged(group: Grouping) {
this.currentGrouping = group; this.currentGrouping = group;
this.updateGroups();
}
private updateGroups(): void {
this.showCards = !this.isSearching && this.currentGrouping === Grouping.Cards;
this.showList = this.isSearching || this.currentGrouping === Grouping.None;
} }
} }
</script> </script>
@@ -95,26 +101,49 @@
@import "@/presentation/styles/fonts.scss"; @import "@/presentation/styles/fonts.scss";
.scripts { .scripts {
margin-top:10px; margin-top:10px;
.search-no-matches {
word-break:break-word;
color: $white;
text-transform: uppercase;
color: $light-gray;
font-size: 1.5em;
background-color: $slate;
padding:5%;
text-align:center;
> a {
color: $gray;
}
}
.tree { .tree {
padding-left: 3%; padding-left: 3%;
padding-top: 15px; padding-top: 15px;
padding-bottom: 15px; padding-bottom: 15px;
.search-query { &--searching {
display: flex; padding-top: 0px;
justify-content: center; }
}
}
.search {
display: flex;
flex-direction: column;
background-color: $slate;
&__query {
display: flex;
justify-content: center;
flex-direction: row;
align-items: center;
margin-top: 1em;
color: $gray;
&__close-button {
cursor: pointer;
font-size: 1.25em;
margin-left: 0.25rem;
&:hover {
opacity: 0.9;
}
}
}
&-no-matches {
display:flex;
flex-direction: column;
word-break:break-word;
text-transform: uppercase;
color: $light-gray;
font-size: 1.5em;
padding:10px;
text-align:center;
> div {
padding-bottom:13px;
}
a {
color: $gray; color: $gray;
} }
} }

View File

@@ -3,11 +3,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch, Vue } from 'vue-property-decorator'; import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue'; import { StatefulVue } from './StatefulVue';
import ace from 'ace-builds'; import ace from 'ace-builds';
import 'ace-builds/webpack-resolver'; import 'ace-builds/webpack-resolver';
import { CodeBuilder } from '../application/State/Code/CodeBuilder'; import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder';
import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
const NothingChosenCode = const NothingChosenCode =
new CodeBuilder() new CodeBuilder()
@@ -16,31 +18,78 @@ const NothingChosenCode =
.appendCommentLine('-- 🤔 How to use') .appendCommentLine('-- 🤔 How to use')
.appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.') .appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.')
.appendCommentLine(' 📙 You can select "Recommended" on the top to select "safer" tweaks. Always double check!') .appendCommentLine(' 📙 You can select "Recommended" on the top to select "safer" tweaks. Always double check!')
.appendCommentLine(' 📙 After you choose any tweak, you can download & copy to execute your script.') .appendCommentLine(' 📙 After you choose any tweak, you can download or copy to execute your script.')
.appendCommentLine(' 📙 Come back regularly to apply latest version for stronger privacy and security.')
.appendLine() .appendLine()
.appendCommentLine('-- 🧐 Why privacy.sexy') .appendCommentLine('-- 🧐 Why privacy.sexy')
.appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other softwares on it.') .appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.')
.appendCommentLine(' ✔️ You don\'t need to run any compiled software on your system, just run the generated scripts.') .appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.')
.appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.') .appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.')
.appendCommentLine(' ✔️ Free software, 100% transparency: both application & infrastructure code are open-sourced.') .appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).')
.toString(); .toString();
@Component @Component
export default class TheCodeArea extends StatefulVue { export default class TheCodeArea extends StatefulVue {
public readonly editorId = 'codeEditor'; public readonly editorId = 'codeEditor';
private editor!: ace.Ace.Editor; private editor!: ace.Ace.Editor;
private currentMarkerId?: number;
@Prop() private theme!: string; @Prop() private theme!: string;
public async mounted() { public async mounted() {
this.editor = initializeEditor(this.theme, this.editorId); this.editor = initializeEditor(this.theme, this.editorId);
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
this.updateCode(state.code.current); this.editor.setValue(state.code.current || NothingChosenCode, 1);
state.code.changed.on((code) => this.updateCode(code)); state.code.changed.on((code) => this.updateCode(code));
} }
private updateCode(code: string) { private updateCode(event: ICodeChangedEvent) {
this.editor.setValue(code || NothingChosenCode, 1); this.removeCurrentHighlighting();
if (event.isEmpty()) {
this.editor.setValue(NothingChosenCode, 1);
return;
}
this.editor.setValue(event.code, 1);
if (event.addedScripts && event.addedScripts.length) {
this.reactToChanges(event, event.addedScripts);
} else if (event.changedScripts && event.changedScripts.length) {
this.reactToChanges(event, event.changedScripts);
}
}
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts
.map((script) => event.getScriptPositionInCode(script));
const start = Math.min(
...positions.map((position) => position.startLine),
);
const end = Math.max(
...positions.map((position) => position.endLine),
);
this.scrollToLine(end + 2);
this.highlight(start, end);
}
private highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range;
this.currentMarkerId = this.editor.session.addMarker(
new AceRange(startRow, 0, endRow, 0), 'code-area__highlight', 'fullLine',
);
}
private scrollToLine(row: number) {
const column = this.editor.session.getLine(row).length;
this.editor.gotoLine(row, column, true);
}
private removeCurrentHighlighting() {
if (!this.currentMarkerId) {
return;
}
this.editor.session.removeMarker(this.currentMarkerId);
this.currentMarkerId = undefined;
} }
} }
@@ -58,12 +107,16 @@ function initializeEditor(theme: string, editorId: string): ace.Ace.Editor {
</script> </script>
<style scoped lang="scss"> <style lang="scss">
@import "@/presentation/styles/colors.scss";
.code-area { .code-area {
/* ----- Fill its parent div ------ */
width: 100%; width: 100%;
/* height */
max-height: 1000px; max-height: 1000px;
min-height: 200px; min-height: 200px;
&__highlight {
background-color:$accent;
opacity: 0.2; // having procent fails in production (minified) build
position:absolute;
}
} }
</style> </style>

View File

@@ -14,13 +14,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue'; import { StatefulVue } from './StatefulVue';
import { SaveFileDialog } from './../infrastructure/SaveFileDialog'; import { SaveFileDialog, FileType } from './../infrastructure/SaveFileDialog';
import { Clipboard } from './../infrastructure/Clipboard'; import { Clipboard } from './../infrastructure/Clipboard';
import IconButton from './IconButton.vue'; import IconButton from './IconButton.vue';
@Component({ @Component({
components: { components: {
IconButton, IconButton,
@@ -33,7 +32,7 @@ export default class TheCodeButtons extends StatefulVue {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
this.hasCode = state.code.current && state.code.current.length > 0; this.hasCode = state.code.current && state.code.current.length > 0;
state.code.changed.on((code) => { state.code.changed.on((code) => {
this.hasCode = code && code.length > 0; this.hasCode = code && code.code.length > 0;
}); });
} }
@@ -44,7 +43,7 @@ export default class TheCodeButtons extends StatefulVue {
public async saveCodeAsync() { public async saveCodeAsync() {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
SaveFileDialog.saveText(state.code.current, 'privacy-script.bat'); SaveFileDialog.saveFile(state.code.current, 'privacy-script.bat', FileType.BatchFile);
} }
} }
</script> </script>

View File

@@ -15,9 +15,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/application/Environment/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import DownloadUrlListItem from './DownloadUrlListItem.vue'; import DownloadUrlListItem from './DownloadUrlListItem.vue';
@Component({ @Component({

View File

@@ -9,10 +9,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/application/Environment/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
@Component @Component
export default class DownloadUrlListItem extends StatefulVue { export default class DownloadUrlListItem extends StatefulVue {
@@ -39,7 +39,7 @@ export default class DownloadUrlListItem extends StatefulVue {
private async getDownloadUrlAsync(os: OperatingSystem): Promise<string> { private async getDownloadUrlAsync(os: OperatingSystem): Promise<string> {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
return `${state.app.repositoryUrl}/releases/download/${state.app.version}/${getFileName(os, state.app.version)}`; return state.app.info.getDownloadUrl(os);
} }
} }
@@ -62,18 +62,6 @@ function getOperatingSystemName(os: OperatingSystem): string {
} }
} }
function getFileName(os: OperatingSystem, version: string): string {
switch (os) {
case OperatingSystem.Linux:
return `privacy.sexy-${version}.AppImage`;
case OperatingSystem.macOS:
return `privacy.sexy-${version}.dmg`;
case OperatingSystem.Windows:
return `privacy.sexy-Setup-${version}.exe`;
default:
throw new Error(`Unsupported os: ${OperatingSystem[os]}`);
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -31,7 +31,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
@@ -48,8 +48,8 @@ export default class TheFooter extends StatefulVue {
public async mounted() { public async mounted() {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
this.repositoryUrl = state.app.repositoryUrl; this.repositoryUrl = state.app.info.repositoryWebUrl;
this.feedbackUrl = `${state.app.repositoryUrl}/issues`; this.feedbackUrl = state.app.info.feedbackUrl;
} }
} }
</script> </script>

View File

@@ -4,7 +4,7 @@
<div class="footer__section"> <div class="footer__section">
<span v-if="isDesktop" class="footer__section__item"> <span v-if="isDesktop" class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'globe']" /> <font-awesome-icon class="icon" :icon="['fas', 'globe']" />
<span>Online version at <a href="https://privacy.sexy" target="_blank">https://privacy.sexy</a></span> <span>Online version at <a :href="homepageUrl" target="_blank">{{ homepageUrl }}</a></span>
</span> </span>
<span v-else class="footer__section__item"> <span v-else class="footer__section__item">
<DownloadUrlList /> <DownloadUrlList />
@@ -47,12 +47,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue'; import { StatefulVue } from '@/presentation/StatefulVue';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
import PrivacyPolicy from './PrivacyPolicy.vue'; import PrivacyPolicy from './PrivacyPolicy.vue';
import DownloadUrlList from './DownloadUrlList.vue'; import DownloadUrlList from './DownloadUrlList.vue';
import { OperatingSystem } from '@/application/Environment/OperatingSystem';
@Component({ @Component({
components: { components: {
@@ -67,6 +66,7 @@ export default class TheFooter extends StatefulVue {
public repositoryUrl: string = ''; public repositoryUrl: string = '';
public releaseUrl: string = ''; public releaseUrl: string = '';
public feedbackUrl: string = ''; public feedbackUrl: string = '';
public homepageUrl: string = '';
constructor() { constructor() {
super(); super();
@@ -75,12 +75,15 @@ export default class TheFooter extends StatefulVue {
public async mounted() { public async mounted() {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
this.version = state.app.version; const info = state.app.info;
this.repositoryUrl = state.app.repositoryUrl; this.version = info.version;
this.releaseUrl = `${state.app.repositoryUrl}/releases/tag/${state.app.version}`; this.homepageUrl = info.homepage;
this.feedbackUrl = `${state.app.repositoryUrl}/issues`; this.repositoryUrl = info.repositoryWebUrl;
this.releaseUrl = info.releaseUrl;
this.feedbackUrl = info.feedbackUrl;
} }
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -6,7 +6,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue'; import { StatefulVue } from './StatefulVue';
@Component @Component
@@ -16,7 +16,7 @@ export default class TheHeader extends StatefulVue {
public async mounted() { public async mounted() {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
this.title = state.app.name; this.title = state.app.info.name;
} }
} }
</script> </script>

View File

@@ -2,7 +2,7 @@
<div class="search" v-non-collapsing> <div class="search" v-non-collapsing>
<input type="search" class="searchTerm" <input type="search" class="searchTerm"
:placeholder="searchPlaceHolder" :placeholder="searchPlaceHolder"
@input="updateFilterAsync($event.target.value)" > v-model="searchQuery" >
<div class="iconWrapper"> <div class="iconWrapper">
<font-awesome-icon :icon="['fas', 'search']" /> <font-awesome-icon :icon="['fas', 'search']" />
</div> </div>
@@ -10,9 +10,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Watch } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue'; import { StatefulVue } from './StatefulVue';
import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective'; import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective';
import { IUserFilter } from '@/application/State/IApplicationState';
@Component( { @Component( {
directives: { NonCollapsing }, directives: { NonCollapsing },
@@ -20,14 +21,16 @@ import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirecti
) )
export default class TheSearchBar extends StatefulVue { export default class TheSearchBar extends StatefulVue {
public searchPlaceHolder = 'Search'; public searchPlaceHolder = 'Search';
public searchQuery = '';
public async mounted() { public async mounted() {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
const totalScripts = state.app.totalScripts; const totalScripts = state.app.totalScripts;
const totalCategories = state.app.totalCategories;
this.searchPlaceHolder = `Search in ${totalScripts} scripts`; this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
this.beginReacting(state.filter);
} }
@Watch('searchQuery')
public async updateFilterAsync(filter: |string) { public async updateFilterAsync(filter: |string) {
const state = await this.getCurrentStateAsync(); const state = await this.getCurrentStateAsync();
if (!filter) { if (!filter) {
@@ -37,6 +40,10 @@ export default class TheSearchBar extends StatefulVue {
} }
} }
private beginReacting(filter: IUserFilter) {
filter.filtered.on((result) => this.searchQuery = result.query);
filter.filterRemoved.on(() => this.searchQuery = '');
}
} }
</script> </script>
@@ -80,5 +87,4 @@ export default class TheSearchBar extends StatefulVue {
font-size: 20px; font-size: 20px;
padding:5px; padding:5px;
} }
</style> </style>

View File

@@ -2,42 +2,41 @@
@import "@/presentation/styles/colors.scss"; @import "@/presentation/styles/colors.scss";
.tree { .tree {
background-color: $slate; background: $slate;
} &-node {
white-space: normal !important;
.tree-node > .tree-content > .tree-anchor > span { > .tree-content {
color: $white !important; > .tree-anchor > span {
text-transform: uppercase; color: $white;
color: $light-gray; text-transform: uppercase;
font-size: 1.5em; font-size: 1.5em;
} }
&:hover {
.tree-node { background: $dark-gray !important;
white-space: normal !important; }
} }
&.selected { // When using keyboard navigation it higlights current item and its child items
.tree-arrow.has-child { background: $gray;
&.rtl:after, &:after { .tree-text {
border-color: $white !important; color: $black !important;
}
}
}
&-checkbox {
&.checked {
background: $accent !important;
border-color: $accent !important;
}
&.indeterminate {
border-color: $gray !important;
}
background: $dark-slate !important;
}
&-arrow {
&.has-child {
&.rtl:after, &:after {
border-color: $white !important;
}
}
} }
} }
.tree-node.selected > .tree-content {
> .tree-anchor > span {
font-weight: bolder;
}
}
.tree-content:hover {
background: $dark-gray !important;
}
.tree-checkbox {
&.checked {
background: $accent !important;
}
&.indeterminate {
border-color: $gray !important;
}
background: $dark-slate !important;
}

View File

@@ -1,5 +1,5 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { OperatingSystem } from '@/application/Environment/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserOsDetector } from '@/application/Environment/BrowserOs/BrowserOsDetector'; import { BrowserOsDetector } from '@/application/Environment/BrowserOs/BrowserOsDetector';
import { BrowserOsTestCases } from './BrowserOsTestCases'; import { BrowserOsTestCases } from './BrowserOsTestCases';

View File

@@ -1,4 +1,4 @@
import { OperatingSystem } from '@/application/Environment/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
interface IBrowserOsTestCase { interface IBrowserOsTestCase {
userAgent: string; userAgent: string;

View File

@@ -1,4 +1,4 @@
import { OperatingSystem } from '@/application/Environment/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
interface IDesktopTestCase { interface IDesktopTestCase {
processPlatform: string; processPlatform: string;

View File

@@ -1,5 +1,5 @@
import { IBrowserOsDetector } from '@/application/Environment/BrowserOs/IBrowserOsDetector'; import { IBrowserOsDetector } from '@/application/Environment/BrowserOs/IBrowserOsDetector';
import { OperatingSystem } from '@/application/Environment/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { DesktopOsTestCases } from './DesktopOsTestCases'; import { DesktopOsTestCases } from './DesktopOsTestCases';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
import { expect } from 'chai'; import { expect } from 'chai';

View File

@@ -5,8 +5,6 @@ import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { parseCategory } from '@/application/Parser/CategoryParser'; import { parseCategory } from '@/application/Parser/CategoryParser';
declare var process;
describe('ApplicationParser', () => { describe('ApplicationParser', () => {
describe('parseApplication', () => { describe('parseApplication', () => {
it('can parse current application file', () => { it('can parse current application file', () => {
@@ -16,74 +14,64 @@ describe('ApplicationParser', () => {
expect(() => parseApplication(undefined)).to.throw('application is null or undefined'); expect(() => parseApplication(undefined)).to.throw('application is null or undefined');
}); });
it('throws when undefined actions', () => { it('throws when undefined actions', () => {
const sut: ApplicationYaml = { const sut: ApplicationYaml = { actions: undefined };
name: 'test',
repositoryUrl: 'https://privacy.sexy',
actions: undefined,
};
expect(() => parseApplication(sut)).to.throw('application does not define any action'); expect(() => parseApplication(sut)).to.throw('application does not define any action');
}); });
it('throws when has no actions', () => { it('throws when has no actions', () => {
const sut: ApplicationYaml = { const sut: ApplicationYaml = { actions: [] };
name: 'test',
repositoryUrl: 'https://privacy.sexy',
actions: [],
};
expect(() => parseApplication(sut)).to.throw('application does not define any action'); expect(() => parseApplication(sut)).to.throw('application does not define any action');
}); });
it('returns expected name', () => { describe('information', () => {
// arrange it('returns expected repository version', () => {
const expected = 'test-app-name'; // arrange
const sut: ApplicationYaml = { const expected = 'expected-version';
name: expected, const env = getProcessEnvironmentStub();
repositoryUrl: 'https://privacy.sexy', env.VUE_APP_VERSION = expected;
actions: [ getTestCategory() ], const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
}; // act
// act const actual = parseApplication(sut, env).info.version;
const actual = parseApplication(sut).name; // assert
// assert expect(actual).to.be.equal(expected);
expect(actual).to.be.equal(actual); });
}); it('returns expected repository url', () => {
it('returns expected repository url', () => { // arrange
// arrange const expected = 'https://expected-repository.url';
const expected = 'https://privacy.sexy'; const env = getProcessEnvironmentStub();
const sut: ApplicationYaml = { env.VUE_APP_REPOSITORY_URL = expected;
name: 'name', const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
repositoryUrl: expected, // act
actions: [ getTestCategory() ], const actual = parseApplication(sut, env).info.repositoryUrl;
}; // assert
// act expect(actual).to.be.equal(expected);
const actual = parseApplication(sut).repositoryUrl; });
// assert it('returns expected name', () => {
expect(actual).to.be.equal(actual); // arrange
}); const expected = 'expected-app-name';
it('returns expected repository version', () => { const env = getProcessEnvironmentStub();
// arrange env.VUE_APP_NAME = expected;
const expected = '1.0.0'; const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
process = { // act
env: { const actual = parseApplication(sut, env).info.name;
VUE_APP_VERSION: expected, // assert
}, expect(actual).to.be.equal(expected);
}; });
const sut: ApplicationYaml = { it('returns expected homepage url', () => {
name: 'name', // arrange
repositoryUrl: 'https://privacy.sexy', const expected = 'https://expected.sexy';
actions: [ getTestCategory() ], const env = getProcessEnvironmentStub();
}; env.VUE_APP_HOMEPAGE_URL = expected;
// act const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
const actual = parseApplication(sut).version; // act
// assert const actual = parseApplication(sut, env).info.homepage;
expect(actual).to.be.equal(actual); // assert
expect(actual).to.be.equal(expected);
});
}); });
it('parses actions', () => { it('parses actions', () => {
// arrange // arrange
const actions = [ getTestCategory('test1'), getTestCategory('test2') ]; const actions = [ getTestCategory('test1'), getTestCategory('test2') ];
const expected = [ parseCategory(actions[0]), parseCategory(actions[1]) ]; const expected = [ parseCategory(actions[0]), parseCategory(actions[1]) ];
const sut: ApplicationYaml = { const sut: ApplicationYaml = { actions };
name: 'name',
repositoryUrl: 'https://privacy.sexy',
actions,
};
// act // act
const actual = parseApplication(sut).actions; const actual = parseApplication(sut).actions;
// assert // assert
@@ -113,3 +101,12 @@ function getTestScript(scriptName: string): YamlScript {
recommend: true, recommend: true,
}; };
} }
function getProcessEnvironmentStub(): NodeJS.ProcessEnv {
return {
VUE_APP_VERSION: 'stub-version',
VUE_APP_NAME: 'stub-name',
VUE_APP_REPOSITORY_URL: 'stub-repository-url',
VUE_APP_HOMEPAGE_URL: 'stub-homepage-url',
};
}

View File

@@ -1,11 +1,15 @@
import 'mocha';
import { expect } from 'chai';
import { CategoryStub } from './../../../stubs/CategoryStub'; import { CategoryStub } from './../../../stubs/CategoryStub';
import { ScriptStub } from './../../../stubs/ScriptStub'; import { ScriptStub } from './../../../stubs/ScriptStub';
import { ApplicationStub } from './../../../stubs/ApplicationStub'; import { ApplicationStub } from './../../../stubs/ApplicationStub';
import { UserSelection } from '@/application/State/Selection/UserSelection'; import { UserSelection } from '@/application/State/Selection/UserSelection';
import { ApplicationCode } from '@/application/State/Code/ApplicationCode'; import { ApplicationCode } from '@/application/State/Code/ApplicationCode';
import 'mocha';
import { expect } from 'chai';
import { SelectedScript } from '@/application/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEvent';
import { IUserScriptGenerator } from '@/application/State/Code/Generation/IUserScriptGenerator';
import { CodePosition } from '@/application/State/Code/Position/CodePosition';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
describe('ApplicationCode', () => { describe('ApplicationCode', () => {
describe('ctor', () => { describe('ctor', () => {
@@ -22,7 +26,7 @@ describe('ApplicationCode', () => {
// arrange // arrange
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts)); const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts); const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const version = 'version-string'; const version = 'version-string';
const sut = new ApplicationCode(selection, version); const sut = new ApplicationCode(selection, version);
// act // act
@@ -31,35 +35,76 @@ describe('ApplicationCode', () => {
expect(actual).to.have.length.greaterThan(0).and.include(version); expect(actual).to.have.length.greaterThan(0).and.include(version);
}); });
}); });
describe('user selection changes', () => { describe('changed event', () => {
it('empty when selection is empty', () => { describe('code', () => {
// arrange it('empty when nothing is selected', () => {
let signaled: string; // arrange
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; let signaled: ICodeChangedEvent;
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts)); const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const selection = new UserSelection(app, scripts); const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const sut = new ApplicationCode(selection, 'version'); const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
sut.changed.on((code) => signaled = code); const sut = new ApplicationCode(selection, 'version');
// act sut.changed.on((code) => signaled = code);
selection.changed.notify([]); // act
// assert selection.changed.notify([]);
expect(signaled).to.have.lengthOf(0); // assert
expect(signaled).to.equal(sut.current); expect(signaled.code).to.have.lengthOf(0);
expect(signaled.code).to.equal(sut.current);
});
it('has code when some are selected', () => {
// arrange
let signaled: ICodeChangedEvent;
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const version = 'version-string';
const sut = new ApplicationCode(selection, version);
sut.changed.on((code) => signaled = code);
// act
selection.changed.notify(scripts.map((s) => new SelectedScript(s, false)));
// assert
expect(signaled.code).to.have.length.greaterThan(0).and.include(version);
expect(signaled.code).to.equal(sut.current);
});
}); });
it('has code when selection is not empty', () => { it('sets positions from the generator', () => {
// arrange // arrange
let signaled: string; let signaled: ICodeChangedEvent;
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts)); const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts); const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const version = 'version-string'; const expectedVersion = 'version-string';
const sut = new ApplicationCode(selection, version); const scriptsToSelect = scripts.map((s) => new SelectedScript(s, false));
const totalLines = 20;
const expected = new Map<SelectedScript, ICodePosition>(
[
[ scriptsToSelect[0], new CodePosition(0, totalLines / 2)],
[ scriptsToSelect[1], new CodePosition(totalLines / 2, totalLines)],
],
);
const generatorMock: IUserScriptGenerator = {
buildCode: (selectedScripts, version) => {
if (version !== expectedVersion) {
throw new Error('Unexpected version');
}
if (JSON.stringify(selectedScripts) !== JSON.stringify(scriptsToSelect)) {
throw new Error('Unexpected scripts');
}
return {
code: '\nREM LINE'.repeat(totalLines),
scriptPositions: expected,
};
},
};
const sut = new ApplicationCode(selection, expectedVersion, generatorMock);
sut.changed.on((code) => signaled = code); sut.changed.on((code) => signaled = code);
// act // act
selection.changed.notify(scripts.map((s) => new SelectedScript(s, false))); selection.changed.notify(scriptsToSelect);
// assert // assert
expect(signaled).to.have.length.greaterThan(0).and.include(version); expect(signaled.getScriptPositionInCode(scripts[0]))
expect(signaled).to.equal(sut.current); .to.deep.equal(expected.get(scriptsToSelect[0]));
expect(signaled.getScriptPositionInCode(scripts[1]))
.to.deep.equal(expected.get(scriptsToSelect[1]));
}); });
}); });
}); });

View File

@@ -0,0 +1,147 @@
import 'mocha';
import { expect } from 'chai';
import { CodeChangedEvent } from '@/application/State/Code/Event/CodeChangedEvent';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
import { CodePosition } from '@/application/State/Code/Position/CodePosition';
import { SelectedScriptStub } from '../../../../stubs/SelectedScriptStub';
import { ScriptStub } from '../../../../stubs/ScriptStub';
describe('CodeChangedEvent', () => {
describe('ctor', () => {
describe('position validation', () => {
it('throws when code position is out of range', () => {
const act = () => new CodeChangedEvent(
'singleline code', [], new Map<SelectedScript, ICodePosition>([
[ new SelectedScriptStub('1'), new CodePosition(0, 2) ],
]),
);
expect(act).to.throw();
});
it('does not throw with valid code position', () => {
const act = () => new CodeChangedEvent(
'singleline code', [], new Map<SelectedScript, ICodePosition>([
[ new SelectedScriptStub('1'), new CodePosition(0, 1) ],
]),
);
expect(act).to.not.throw();
});
});
});
it('code returns expected', () => {
// arrange
const expected = 'code';
// act
const sut = new CodeChangedEvent(
expected, [], new Map<SelectedScript, ICodePosition>(),
);
const actual = sut.code;
// assert
expect(actual).to.equal(expected);
});
describe('addedScripts', () => {
it('returns new scripts when scripts are added', () => {
// arrange
const expected = [ new ScriptStub('3'), new ScriptStub('4') ];
const initialScripts = [ new SelectedScriptStub('1'), new SelectedScriptStub('2') ];
const newScripts = new Map<SelectedScript, ICodePosition>([
[initialScripts[0], new CodePosition(0, 1) ],
[initialScripts[1], new CodePosition(0, 1) ],
[new SelectedScript(expected[0], false), new CodePosition(0, 1) ],
[new SelectedScript(expected[1], false), new CodePosition(0, 1) ],
]);
// act
const sut = new CodeChangedEvent(
'code', initialScripts, newScripts,
);
const actual = sut.addedScripts;
// assert
expect(actual).to.have.lengthOf(2);
expect(actual[0]).to.deep.equal(expected[0]);
expect(actual[1]).to.deep.equal(expected[1]);
});
});
describe('removedScripts', () => {
it('returns removed scripts when script are removed', () => {
// arrange
const existingScripts = [ new SelectedScriptStub('0'), new SelectedScriptStub('1') ];
const removedScripts = [ new SelectedScriptStub('2') ];
const initialScripts = [ ...existingScripts, ...removedScripts ];
const newScripts = new Map<SelectedScript, ICodePosition>([
[initialScripts[0], new CodePosition(0, 1) ],
[initialScripts[1], new CodePosition(0, 1) ],
]);
// act
const sut = new CodeChangedEvent(
'code', initialScripts, newScripts,
);
const actual = sut.removedScripts;
// assert
expect(actual).to.have.lengthOf(removedScripts.length);
expect(actual[0]).to.deep.equal(removedScripts[0].script);
});
});
describe('changedScripts', () => {
it('returns changed scripts when scripts are changed', () => {
// arrange
const initialScripts = [ new SelectedScriptStub('1', false), new SelectedScriptStub('2', false) ];
const newScripts = new Map<SelectedScript, ICodePosition>([
[new SelectedScriptStub('1', true), new CodePosition(0, 1) ],
[new SelectedScriptStub('2', false), new CodePosition(0, 1) ],
]);
// act
const sut = new CodeChangedEvent(
'code', initialScripts, newScripts,
);
const actual = sut.changedScripts;
// assert
expect(actual).to.have.lengthOf(1);
expect(actual[0]).to.deep.equal(initialScripts[0].script);
});
});
describe('isEmpty', () => {
it('returns true when empty', () => {
// arrange
const newScripts = new Map<SelectedScript, ICodePosition>();
const oldScripts = [ new SelectedScriptStub('1', false) ];
const sut = new CodeChangedEvent(
'code', oldScripts, newScripts,
);
// act
const actual = sut.isEmpty();
// assert
expect(actual).to.equal(true);
});
it('returns false when not empty', () => {
// arrange
const oldScripts = [ new SelectedScriptStub('1') ];
const newScripts = new Map<SelectedScript, ICodePosition>( [
[oldScripts[0], new CodePosition(0, 1) ],
]);
const sut = new CodeChangedEvent(
'code', oldScripts, newScripts,
);
// act
const actual = sut.isEmpty();
// assert
expect(actual).to.equal(false);
});
});
describe('getScriptPositionInCode', () => {
it('returns expected position for existing script', () => {
// arrange
const script = new ScriptStub('1');
const expected = new CodePosition(0, 1);
const newScripts = new Map<SelectedScript, ICodePosition>( [
[new SelectedScript(script, false), expected ],
]);
const sut = new CodeChangedEvent(
'code', [], newScripts,
);
// act
const actual = sut.getScriptPositionInCode(script);
// assert
expect(actual).to.deep.equal(expected);
});
});
});

View File

@@ -0,0 +1,112 @@
import 'mocha';
import { expect } from 'chai';
import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder';
describe('CodeBuilder', () => {
describe('appendLine', () => {
it('when empty appends empty line', () => {
// arrange
const sut = new CodeBuilder();
// act
sut.appendLine().appendLine().appendLine();
// assert
expect(sut.toString()).to.equal('\n\n');
});
it('when not empty append string in new line', () => {
// arrange
const sut = new CodeBuilder();
const expected = 'str';
// act
sut.appendLine()
.appendLine(expected);
// assert
const result = sut.toString();
const lines = getLines(result);
expect(lines[1]).to.equal('str');
});
});
it('appendFunction', () => {
// arrange
const sut = new CodeBuilder();
const functionName = 'function';
const code = 'code';
// act
sut.appendFunction(functionName, code);
// assert
const result = sut.toString();
expect(result).to.include(functionName);
expect(result).to.include(code);
});
it('appendTrailingHyphensCommentLine', () => {
// arrange
const sut = new CodeBuilder();
const totalHypens = 5;
const expected = `:: ${'-'.repeat(totalHypens)}`;
// act
sut.appendTrailingHyphensCommentLine(totalHypens);
// assert
const result = sut.toString();
const lines = getLines(result);
expect(lines[0]).to.equal(expected);
});
it('appendCommentLine', () => {
// arrange
const sut = new CodeBuilder();
const comment = 'comment';
const expected = ':: comment';
// act
sut.appendCommentLine(comment);
// assert
const result = sut.toString();
const lines = getLines(result);
expect(lines[0]).to.equal(expected);
});
it('appendCommentLineWithHyphensAround', () => {
// arrange
const sut = new CodeBuilder();
const sectionName = 'section';
const totalHypens = sectionName.length + 3 * 2;
const expected = ':: ---section---';
sut.appendCommentLineWithHyphensAround(sectionName, totalHypens);
// assert
const result = sut.toString();
const lines = getLines(result);
expect(lines[1]).to.equal(expected);
});
describe('currentLine', () => {
it('no lines returns zero', () => {
// arrange & act
const sut = new CodeBuilder();
// assert
expect(sut.currentLine).to.equal(0);
});
it('single line returns one', () => {
// arrange
const sut = new CodeBuilder();
// act
sut.appendLine();
// assert
expect(sut.currentLine).to.equal(1);
});
it('multiple lines returns as expected', () => {
// arrange
const sut = new CodeBuilder();
// act
sut.appendLine('1').appendCommentLine('2').appendLine();
// assert
expect(sut.currentLine).to.equal(3);
});
it('multiple lines in code', () => {
// arrange
const sut = new CodeBuilder();
// act
sut.appendLine('hello\ncode-here\nwith-3-lines');
// assert
expect(sut.currentLine).to.equal(3);
});
});
});
function getLines(text: string): string[] {
return text.split(/\r\n|\r|\n/);
}

View File

@@ -0,0 +1,94 @@
import { ScriptStub } from '../../../../stubs/ScriptStub';
import { UserScriptGenerator, adminRightsScript } from '@/application/State/Code/Generation/UserScriptGenerator';
import 'mocha';
import { expect } from 'chai';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { SelectedScriptStub } from '../../../../stubs/SelectedScriptStub';
describe('UserScriptGenerator', () => {
it('adds version', () => {
// arrange
const sut = new UserScriptGenerator();
const version = '1.5.0';
const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)];
// act
const actual = sut.buildCode(selectedScripts, version);
// assert
expect(actual.code).to.include(version);
});
it('adds admin rights function', () => {
// arrange
const sut = new UserScriptGenerator();
const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual.code).to.include(adminRightsScript.code);
expect(actual.code).to.include(adminRightsScript.name);
});
it('appends revert script', () => {
// arrange
const sut = new UserScriptGenerator();
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withRevertCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, true)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual.code).to.include(`${scriptName} (revert)`);
expect(actual.code).to.include(scriptCode);
});
it('appends non-revert script', () => {
const sut = new UserScriptGenerator();
// arrange
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, false)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual.code).to.include(scriptName);
expect(actual.code).to.include(scriptCode);
});
describe('scriptPositions', () => {
it('single script', () => {
// arrange
const sut = new UserScriptGenerator();
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop\nREM nop2';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, false)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual.scriptPositions.size).to.equal(1);
const position = actual.scriptPositions.get(selectedScripts[0]);
expect(position.endLine).to.be.greaterThan(position.startLine + 2);
});
it('multiple scripts', () => {
// arrange
const sut = new UserScriptGenerator();
const selectedScripts = [ new SelectedScriptStub('1'), new SelectedScriptStub('2') ];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
const firstPosition = actual.scriptPositions.get(selectedScripts[0]);
const secondPosition = actual.scriptPositions.get(selectedScripts[1]);
expect(actual.scriptPositions.size).to.equal(2);
expect(firstPosition.endLine).to.be.greaterThan(firstPosition.startLine + 1);
expect(secondPosition.startLine).to.be.greaterThan(firstPosition.endLine);
expect(secondPosition.endLine).to.be.greaterThan(secondPosition.startLine + 1);
});
it('no script', () => {
// arrange
const sut = new UserScriptGenerator();
const selectedScripts = [ ];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual.scriptPositions.size).to.equal(0);
});
});
});

View File

@@ -0,0 +1,54 @@
import { CodePosition } from '@/application/State/Code/Position/CodePosition';
import 'mocha';
import { expect } from 'chai';
describe('CodePosition', () => {
describe('ctor', () => {
it('creates with valid parameters', () => {
// arrange
const startPosition = 0;
const endPosition = 5;
// act
const sut = new CodePosition(startPosition, endPosition);
// assert
expect(sut.startLine).to.equal(startPosition);
expect(sut.endLine).to.equal(endPosition);
});
it('throws with negative start position', () => {
// arrange
const startPosition = -1;
const endPosition = 5;
// act
const getSut = () => new CodePosition(startPosition, endPosition);
// assert
expect(getSut).to.throw('Code cannot start in a negative line');
});
it('throws with negative end position', () => {
// arrange
const startPosition = 1;
const endPosition = -5;
// act
const getSut = () => new CodePosition(startPosition, endPosition);
// assert
expect(getSut).to.throw('Code cannot end in a negative line');
});
it('throws when start and end position is same', () => {
// arrange
const startPosition = 0;
const endPosition = 0;
// act
const getSut = () => new CodePosition(startPosition, endPosition);
// assert
expect(getSut).to.throw('Empty code');
});
it('throws when ends before start', () => {
// arrange
const startPosition = 3;
const endPosition = 2;
// act
const getSut = () => new CodePosition(startPosition, endPosition);
// assert
expect(getSut).to.throw('End line cannot be less than start line');
});
});
});

View File

@@ -1,54 +0,0 @@
import { ScriptStub } from './../../../stubs/ScriptStub';
import { UserScriptGenerator, adminRightsScript } from '@/application/State/Code/UserScriptGenerator';
import 'mocha';
import { expect } from 'chai';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
describe('UserScriptGenerator', () => {
it('adds version', () => {
const sut = new UserScriptGenerator();
// arrange
const version = '1.5.0';
const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)];
// act
const actual = sut.buildCode(selectedScripts, version);
// assert
expect(actual).to.include(version);
});
it('adds admin rights function', () => {
const sut = new UserScriptGenerator();
// arrange
const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual).to.include(adminRightsScript.code);
expect(actual).to.include(adminRightsScript.name);
});
it('appends revert script', () => {
const sut = new UserScriptGenerator();
// arrange
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withRevertCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, true)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual).to.include(`${scriptName} (revert)`);
expect(actual).to.include(scriptCode);
});
it('appends non-revert script', () => {
const sut = new UserScriptGenerator();
// arrange
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, false)];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual).to.include(scriptName);
expect(actual).to.include(scriptCode);
});
});

View File

@@ -7,129 +7,156 @@ import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
describe('UserFilter', () => { describe('UserFilter', () => {
it('signals when removing filter', () => { describe('removeFilter', () => {
// arrange it('signals when removing filter', () => {
let isCalled = false;
const sut = new UserFilter(new ApplicationStub());
sut.filterRemoved.on(() => isCalled = true);
// act
sut.removeFilter();
// assert
expect(isCalled).to.be.equal(true);
});
it('signals when no matches', () => {
// arrange
let actual: IFilterResult;
const nonMatchingFilter = 'non matching filter';
const sut = new UserFilter(new ApplicationStub());
sut.filtered.on((filterResult) => actual = filterResult);
// act
sut.setFilter(nonMatchingFilter);
// assert
expect(actual.hasAnyMatches()).be.equal(false);
expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.scriptMatches).to.have.lengthOf(0);
expect(actual.query).to.equal(nonMatchingFilter);
});
describe('signals when script matches', () => {
it('code matches', () => {
// arrange // arrange
const code = 'HELLO world'; let isCalled = false;
const filter = 'Hello WoRLD'; const sut = new UserFilter(new ApplicationStub());
let actual: IFilterResult; sut.filterRemoved.on(() => isCalled = true);
const script = new ScriptStub('id').withCode(code);
const category = new CategoryStub(33).withScript(script);
const sut = new UserFilter(new ApplicationStub()
.withAction(category));
sut.filtered.on((filterResult) => actual = filterResult);
// act // act
sut.setFilter(filter); sut.removeFilter();
// assert // assert
expect(actual.hasAnyMatches()).be.equal(true); expect(isCalled).to.be.equal(true);
expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
}); });
it('revertCode matches', () => { it('currentFilter is undefined', () => {
// arrange // arrange
const revertCode = 'HELLO world'; const sut = new UserFilter(new ApplicationStub());
const filter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withRevertCode(revertCode);
const category = new CategoryStub(33).withScript(script);
const sut = new UserFilter(new ApplicationStub()
.withAction(category));
sut.filtered.on((filterResult) => actual = filterResult);
// act // act
sut.setFilter(filter); sut.removeFilter();
// assert // assert
expect(actual.hasAnyMatches()).be.equal(true); expect(sut.currentFilter).to.be.equal(undefined);
expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
});
it('name matches', () => {
// arrange
const name = 'HELLO world';
const filter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withName(name);
const category = new CategoryStub(33).withScript(script);
const sut = new UserFilter(new ApplicationStub()
.withAction(category));
sut.filtered.on((filterResult) => actual = filterResult);
// act
sut.setFilter(filter);
// assert
expect(actual.hasAnyMatches()).be.equal(true);
expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
}); });
}); });
it('signals when category matches', () => { describe('setFilter', () => {
// arrange it('signals when no matches', () => {
const categoryName = 'HELLO world'; // arrange
const filter = 'Hello WoRLD'; let actual: IFilterResult;
let actual: IFilterResult; const nonMatchingFilter = 'non matching filter';
const category = new CategoryStub(55).withName(categoryName); const sut = new UserFilter(new ApplicationStub());
const sut = new UserFilter(new ApplicationStub() sut.filtered.on((filterResult) => actual = filterResult);
.withAction(category)); // act
sut.filtered.on((filterResult) => actual = filterResult); sut.setFilter(nonMatchingFilter);
// act // assert
sut.setFilter(filter); expect(actual.hasAnyMatches()).be.equal(false);
// assert expect(actual.query).to.equal(nonMatchingFilter);
expect(actual.hasAnyMatches()).be.equal(true); });
expect(actual.categoryMatches).to.have.lengthOf(1); it('sets currentFilter as expected when no matches', () => {
expect(actual.categoryMatches[0]).to.deep.equal(category); // arrange
expect(actual.scriptMatches).to.have.lengthOf(0); const nonMatchingFilter = 'non matching filter';
expect(actual.query).to.equal(filter); const sut = new UserFilter(new ApplicationStub());
}); // act
it('signals when category and script matches', () => { sut.setFilter(nonMatchingFilter);
// arrange // assert
const matchingText = 'HELLO world'; const actual = sut.currentFilter;
const filter = 'Hello WoRLD'; expect(actual.hasAnyMatches()).be.equal(false);
let actual: IFilterResult; expect(actual.query).to.equal(nonMatchingFilter);
const script = new ScriptStub('script') });
.withName(matchingText); describe('signals when script matches', () => {
const category = new CategoryStub(55) it('code matches', () => {
.withName(matchingText) // arrange
.withScript(script); const code = 'HELLO world';
const app = new ApplicationStub() const filter = 'Hello WoRLD';
.withAction(category); let actual: IFilterResult;
const sut = new UserFilter(app); const script = new ScriptStub('id').withCode(code);
sut.filtered.on((filterResult) => actual = filterResult); const category = new CategoryStub(33).withScript(script);
// act const sut = new UserFilter(new ApplicationStub()
sut.setFilter(filter); .withAction(category));
// assert sut.filtered.on((filterResult) => actual = filterResult);
expect(actual.hasAnyMatches()).be.equal(true); // act
expect(actual.categoryMatches).to.have.lengthOf(1); sut.setFilter(filter);
expect(actual.categoryMatches[0]).to.deep.equal(category); // assert
expect(actual.scriptMatches).to.have.lengthOf(1); expect(actual.hasAnyMatches()).be.equal(true);
expect(actual.scriptMatches[0]).to.deep.equal(script); expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.query).to.equal(filter); expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
expect(sut.currentFilter).to.deep.equal(actual);
});
it('revertCode matches', () => {
// arrange
const revertCode = 'HELLO world';
const filter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withRevertCode(revertCode);
const category = new CategoryStub(33).withScript(script);
const sut = new UserFilter(new ApplicationStub()
.withAction(category));
sut.filtered.on((filterResult) => actual = filterResult);
// act
sut.setFilter(filter);
// assert
expect(actual.hasAnyMatches()).be.equal(true);
expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
expect(sut.currentFilter).to.deep.equal(actual);
});
it('name matches', () => {
// arrange
const name = 'HELLO world';
const filter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withName(name);
const category = new CategoryStub(33).withScript(script);
const sut = new UserFilter(new ApplicationStub()
.withAction(category));
sut.filtered.on((filterResult) => actual = filterResult);
// act
sut.setFilter(filter);
// assert
expect(actual.hasAnyMatches()).be.equal(true);
expect(actual.categoryMatches).to.have.lengthOf(0);
expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
expect(sut.currentFilter).to.deep.equal(actual);
});
it('signals when category matches', () => {
// arrange
const categoryName = 'HELLO world';
const filter = 'Hello WoRLD';
let actual: IFilterResult;
const category = new CategoryStub(55).withName(categoryName);
const sut = new UserFilter(new ApplicationStub()
.withAction(category));
sut.filtered.on((filterResult) => actual = filterResult);
// act
sut.setFilter(filter);
// assert
expect(actual.hasAnyMatches()).be.equal(true);
expect(actual.categoryMatches).to.have.lengthOf(1);
expect(actual.categoryMatches[0]).to.deep.equal(category);
expect(actual.scriptMatches).to.have.lengthOf(0);
expect(actual.query).to.equal(filter);
expect(sut.currentFilter).to.deep.equal(actual);
});
it('signals when category and script matches', () => {
// arrange
const matchingText = 'HELLO world';
const filter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('script')
.withName(matchingText);
const category = new CategoryStub(55)
.withName(matchingText)
.withScript(script);
const app = new ApplicationStub()
.withAction(category);
const sut = new UserFilter(app);
sut.filtered.on((filterResult) => actual = filterResult);
// act
sut.setFilter(filter);
// assert
expect(actual.hasAnyMatches()).be.equal(true);
expect(actual.categoryMatches).to.have.lengthOf(1);
expect(actual.categoryMatches[0]).to.deep.equal(category);
expect(actual.scriptMatches).to.have.lengthOf(1);
expect(actual.scriptMatches[0]).to.deep.equal(script);
expect(actual.query).to.equal(filter);
expect(sut.currentFilter).to.deep.equal(actual);
});
});
}); });
}); });

View File

@@ -1,20 +1,47 @@
import { ScriptStub } from './../../../stubs/ScriptStub'; import 'mocha';
import { expect } from 'chai';
import { IScript } from '@/domain/IScript';
import { SelectedScriptStub } from '../../../stubs/SelectedScriptStub';
import { ScriptStub } from '../../../stubs/ScriptStub';
import { SelectedScript } from '@/application/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { CategoryStub } from '../../../stubs/CategoryStub'; import { CategoryStub } from '../../../stubs/CategoryStub';
import { ApplicationStub } from '../../../stubs/ApplicationStub'; import { ApplicationStub } from '../../../stubs/ApplicationStub';
import { UserSelection } from '@/application/State/Selection/UserSelection'; import { UserSelection } from '@/application/State/Selection/UserSelection';
import 'mocha';
import { expect } from 'chai';
import { IScript } from '@/domain/IScript';
describe('UserSelection', () => { describe('UserSelection', () => {
describe('ctor', () => {
it('has nothing with no initial selection', () => {
// arrange
const app = new ApplicationStub().withAction(new CategoryStub(1).withScriptIds('s1'));
const selection = [];
// act
const sut = new UserSelection(app, selection);
// assert
expect(sut.selectedScripts).to.have.lengthOf(0);
});
it('has initial selection', () => {
// arrange
const firstScript = new ScriptStub('1');
const secondScript = new ScriptStub('2');
const app = new ApplicationStub().withAction(
new CategoryStub(1).withScript(firstScript).withScripts(secondScript));
const expected = [ new SelectedScript(firstScript, false), new SelectedScript(secondScript, true) ];
// act
const sut = new UserSelection(app, expected);
// assert
expect(sut.selectedScripts).to.deep.include(expected[0]);
expect(sut.selectedScripts).to.deep.include(expected[1]);
});
});
it('deselectAll removes all items', () => { it('deselectAll removes all items', () => {
// arrange // arrange
const events: Array<readonly SelectedScript[]> = []; const events: Array<readonly SelectedScript[]> = [];
const app = new ApplicationStub() const app = new ApplicationStub()
.withAction(new CategoryStub(1) .withAction(new CategoryStub(1)
.withScriptIds('s1', 's2', 's3', 's4')); .withScriptIds('s1', 's2', 's3', 's4'));
const selectedScripts = [new ScriptStub('s1'), new ScriptStub('s2'), new ScriptStub('s3')]; const selectedScripts = [
new SelectedScriptStub('s1'), new SelectedScriptStub('s2'), new SelectedScriptStub('s3'),
];
const sut = new UserSelection(app, selectedScripts); const sut = new UserSelection(app, selectedScripts);
sut.changed.on((newScripts) => events.push(newScripts)); sut.changed.on((newScripts) => events.push(newScripts));
// act // act
@@ -30,15 +57,20 @@ describe('UserSelection', () => {
const app = new ApplicationStub() const app = new ApplicationStub()
.withAction(new CategoryStub(1) .withAction(new CategoryStub(1)
.withScriptIds('s1', 's2', 's3', 's4')); .withScriptIds('s1', 's2', 's3', 's4'));
const selectedScripts = [new ScriptStub('s1'), new ScriptStub('s2'), new ScriptStub('s3')]; const selectedScripts = [
new SelectedScriptStub('s1'), new SelectedScriptStub('s2'), new SelectedScriptStub('s3'),
];
const sut = new UserSelection(app, selectedScripts); const sut = new UserSelection(app, selectedScripts);
sut.changed.on((newScripts) => events.push(newScripts)); sut.changed.on((newScripts) => events.push(newScripts));
const scripts = [new ScriptStub('s2'), new ScriptStub('s3'), new ScriptStub('s4')]; const scripts = [new ScriptStub('s2'), new ScriptStub('s3'), new ScriptStub('s4')];
const expected = scripts.map((script) => new SelectedScript(script, false)); const expected = [ new SelectedScriptStub('s2'), new SelectedScriptStub('s3'),
new SelectedScript(scripts[2], false)];
// act // act
sut.selectOnly(scripts); sut.selectOnly(scripts);
// assert // assert
expect(sut.selectedScripts).to.deep.equal(expected); expect(sut.selectedScripts).to.have.deep.members(expected,
`Expected: ${JSON.stringify(sut.selectedScripts)}\n` +
`Actual: ${JSON.stringify(expected)}`);
expect(events).to.have.lengthOf(1); expect(events).to.have.lengthOf(1);
expect(events[0]).to.deep.equal(expected); expect(events[0]).to.deep.equal(expected);
}); });
@@ -93,4 +125,171 @@ describe('UserSelection', () => {
expect(events[0]).to.deep.equal(expected); expect(events[0]).to.deep.equal(expected);
}); });
}); });
describe('removeAllInCategory', () => {
it('does nothing when nothing exists', () => {
// arrange
const events: Array<readonly SelectedScript[]> = [];
const categoryId = 1;
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(new ScriptStub('s1'), new ScriptStub('s2')));
const sut = new UserSelection(app, []);
sut.changed.on((s) => events.push(s));
// act
sut.removeAllInCategory(categoryId);
// assert
expect(events).to.have.lengthOf(0);
expect(sut.selectedScripts).to.have.lengthOf(0);
});
it('removes all when all exists', () => {
// arrange
const categoryId = 1;
const scripts = [new SelectedScriptStub('s1'), new SelectedScriptStub('s2')];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...scripts.map((script) => script.script)));
const sut = new UserSelection(app, scripts);
// act
sut.removeAllInCategory(categoryId);
// assert
expect(sut.totalSelected).to.equal(0);
expect(sut.selectedScripts.length).to.equal(0);
});
it('removes existing some exists', () => {
// arrange
const categoryId = 1;
const existing = [new ScriptStub('s1'), new ScriptStub('s2')];
const notExisting = [new ScriptStub('s3'), new ScriptStub('s4')];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...existing, ...notExisting));
const sut = new UserSelection(app, existing.map((script) => new SelectedScript(script, false)));
// act
sut.removeAllInCategory(categoryId);
// assert
expect(sut.totalSelected).to.equal(0);
expect(sut.selectedScripts.length).to.equal(0);
});
});
describe('addOrUpdateAllInCategory', () => {
it('does nothing when all already exists', () => {
// arrange
const events: Array<readonly SelectedScript[]> = [];
const categoryId = 1;
const scripts = [new ScriptStub('s1'), new ScriptStub('s2')];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...scripts));
const sut = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
sut.changed.on((s) => events.push(s));
// act
sut.addOrUpdateAllInCategory(categoryId);
// assert
expect(events).to.have.lengthOf(0);
expect(sut.selectedScripts.map((script) => script.id))
.to.have.deep.members(scripts.map((script) => script.id));
});
it('adds all when nothing exists', () => {
// arrange
const categoryId = 1;
const expected = [new ScriptStub('s1'), new ScriptStub('s2')];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...expected));
const sut = new UserSelection(app, []);
// act
sut.addOrUpdateAllInCategory(categoryId);
// assert
expect(sut.selectedScripts.map((script) => script.id))
.to.have.deep.members(expected.map((script) => script.id));
});
it('adds all with given revert status when nothing exists', () => {
// arrange
const categoryId = 1;
const expected = [new ScriptStub('s1'), new ScriptStub('s2')];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...expected));
const sut = new UserSelection(app, []);
// act
sut.addOrUpdateAllInCategory(categoryId, true);
// assert
expect(sut.selectedScripts.every((script) => script.revert))
.to.equal(true);
});
it('changes revert status of all when some exists', () => {
// arrange
const categoryId = 1;
const notExisting = [ new ScriptStub('notExisting1'), new ScriptStub('notExisting2') ];
const existing = [ new ScriptStub('existing1'), new ScriptStub('existing2') ];
const allScripts = [ ...existing, ...notExisting ];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...allScripts));
const sut = new UserSelection(app, existing.map((script) => new SelectedScript(script, false)));
// act
sut.addOrUpdateAllInCategory(categoryId, true);
// assert
expect(sut.selectedScripts.every((script) => script.revert))
.to.equal(true);
});
it('changes revert status of all when some exists', () => {
// arrange
const categoryId = 1;
const notExisting = [ new ScriptStub('notExisting1'), new ScriptStub('notExisting2') ];
const existing = [ new ScriptStub('existing1'), new ScriptStub('existing2') ];
const allScripts = [ ...existing, ...notExisting ];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...allScripts));
const sut = new UserSelection(app, existing.map((script) => new SelectedScript(script, false)));
// act
sut.addOrUpdateAllInCategory(categoryId, true);
// assert
expect(sut.selectedScripts.every((script) => script.revert))
.to.equal(true);
});
it('changes revert status of all when all already exists', () => {
// arrange
const categoryId = 1;
const scripts = [ new ScriptStub('existing1'), new ScriptStub('existing2') ];
const app = new ApplicationStub()
.withAction(new CategoryStub(categoryId)
.withScripts(...scripts));
const sut = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
// act
sut.addOrUpdateAllInCategory(categoryId, true);
// assert
expect(sut.selectedScripts.every((script) => script.revert))
.to.equal(true);
});
});
describe('isSelected', () => {
it('returns false when not selected', () => {
// arrange
const selectedScript = new ScriptStub('selected');
const notSelectedScript = new ScriptStub('not selected');
const app = new ApplicationStub()
.withAction(new CategoryStub(1)
.withScripts(selectedScript, notSelectedScript));
const sut = new UserSelection(app, [ new SelectedScript(selectedScript, false) ]);
// act
const actual = sut.isSelected(notSelectedScript.id);
// assert
expect(actual).to.equal(false);
});
it('returns true when selected', () => {
// arrange
const selectedScript = new ScriptStub('selected');
const notSelectedScript = new ScriptStub('not selected');
const app = new ApplicationStub()
.withAction(new CategoryStub(1)
.withScripts(selectedScript, notSelectedScript));
const sut = new UserSelection(app, [ new SelectedScript(selectedScript, false) ]);
// act
const actual = sut.isSelected(selectedScript.id);
// assert
expect(actual).to.equal(true);
});
});
}); });

View File

@@ -1,8 +1,10 @@
import { ScriptStub } from './../stubs/ScriptStub'; import { ScriptStub } from './../stubs/ScriptStub';
import { CategoryStub } from './../stubs/CategoryStub'; import { CategoryStub } from './../stubs/CategoryStub';
import { Application } from './../../../src/domain/Application'; import { Application } from '@/domain/Application';
import 'mocha'; import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { IProjectInformation } from '@/domain/IProjectInformation';
describe('Application', () => { describe('Application', () => {
it('getRecommendedScripts returns as expected', () => { it('getRecommendedScripts returns as expected', () => {
@@ -11,53 +13,56 @@ describe('Application', () => {
new ScriptStub('S3').withIsRecommended(true), new ScriptStub('S3').withIsRecommended(true),
new ScriptStub('S4').withIsRecommended(true), new ScriptStub('S4').withIsRecommended(true),
]; ];
const sut = new Application('name', 'repo', '0.1.0', [ const sut = new Application(createInformation(), [
new CategoryStub(3).withScripts(expected[0], new ScriptStub('S1').withIsRecommended(false)), new CategoryStub(3).withScripts(expected[0], new ScriptStub('S1').withIsRecommended(false)),
new CategoryStub(2).withScripts(expected[1], new ScriptStub('S2').withIsRecommended(false)), new CategoryStub(2).withScripts(expected[1], new ScriptStub('S2').withIsRecommended(false)),
]); ]);
// act // act
const actual = sut.getRecommendedScripts(); const actual = sut.getRecommendedScripts();
// assert // assert
expect(expected[0]).to.deep.equal(actual[0]); expect(expected[0]).to.deep.equal(actual[0]);
expect(expected[1]).to.deep.equal(actual[1]); expect(expected[1]).to.deep.equal(actual[1]);
}); });
it('cannot construct without categories', () => { describe('parameter validation', () => {
// arrange it('cannot construct without categories', () => {
const categories = []; // arrange
const categories = [];
// act // act
function construct() { return new Application('name', 'repo', '0.1.0', categories); } function construct() { return new Application(createInformation(), categories); }
// assert
// assert expect(construct).to.throw('Application must consist of at least one category');
expect(construct).to.throw('Application must consist of at least one category'); });
}); it('cannot construct without scripts', () => {
it('cannot construct without scripts', () => { // arrange
// arrange const categories = [
const categories = [ new CategoryStub(3),
new CategoryStub(3), new CategoryStub(2),
new CategoryStub(2), ];
]; // act
function construct() { return new Application(createInformation(), categories); }
// act // assert
function construct() { return new Application('name', 'repo', '0.1.0', categories); } expect(construct).to.throw('Application must consist of at least one script');
});
// assert it('cannot construct without any recommended scripts', () => {
expect(construct).to.throw('Application must consist of at least one script'); // arrange
}); const categories = [
it('cannot construct without any recommended scripts', () => { new CategoryStub(3).withScripts(new ScriptStub('S1').withIsRecommended(false)),
// arrange new CategoryStub(2).withScripts(new ScriptStub('S2').withIsRecommended(false)),
const categories = [ ];
new CategoryStub(3).withScripts(new ScriptStub('S1').withIsRecommended(false)), // act
new CategoryStub(2).withScripts(new ScriptStub('S2').withIsRecommended(false)), function construct() { return new Application(createInformation(), categories); }
]; // assert
expect(construct).to.throw('Application must consist of at least one recommended script');
// act });
function construct() { return new Application('name', 'repo', '0.1.0', categories); } it('cannot construct without information', () => {
// arrange
// assert const categories = [new CategoryStub(1).withScripts(new ScriptStub('S1').withIsRecommended(true))];
expect(construct).to.throw('Application must consist of at least one recommended script'); const information = undefined;
// act
function construct() { return new Application(information, categories); }
// assert
expect(construct).to.throw('info is undefined');
});
}); });
it('totalScripts counts right', () => { it('totalScripts counts right', () => {
// arrange // arrange
@@ -67,9 +72,9 @@ describe('Application', () => {
new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))), new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))),
]; ];
// act // act
const application = new Application('name', 'repo', '0.1.0', categories); const sut = new Application(createInformation(), categories);
// assert // assert
expect(application.totalScripts).to.equal(4); expect(sut.totalScripts).to.equal(4);
}); });
it('totalCategories counts right', () => { it('totalCategories counts right', () => {
// arrange // arrange
@@ -79,8 +84,22 @@ describe('Application', () => {
new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))), new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))),
]; ];
// act // act
const application = new Application('name', 'repo', '0.1.0', categories); const sut = new Application(createInformation(), categories);
// assert // assert
expect(application.totalCategories).to.equal(4); expect(sut.totalCategories).to.equal(4);
});
it('sets information as expected', () => {
// arrange
const expected = createInformation();
// act
const sut = new Application(
expected,
[new CategoryStub(1).withScripts(new ScriptStub('S1').withIsRecommended(true))]);
// assert
expect(sut.info).to.deep.equal(expected);
}); });
}); });
function createInformation(): IProjectInformation {
return new ProjectInformation('name', 'repo', '0.1.0', 'homepage');
}

View File

@@ -0,0 +1,89 @@
import 'mocha';
import { expect } from 'chai';
import { Category } from '@/domain/Category';
import { CategoryStub } from '../stubs/CategoryStub';
import { ScriptStub } from '../stubs/ScriptStub';
describe('Category', () => {
describe('ctor', () => {
it('throws when name is empty', () => {
const expectedError = 'undefined or empty name';
const construct = () => new Category(5, '', [], [new CategoryStub(5)], []);
expect(construct).to.throw(expectedError);
});
it('throws when has no children', () => {
const expectedError = 'A category must have at least one sub-category or script';
const construct = () => new Category(5, 'category', [], [], []);
expect(construct).to.throw(expectedError);
});
});
describe('getAllScriptsRecursively', () => {
it('gets child scripts', () => {
// arrange
const expected = [ new ScriptStub('1'), new ScriptStub('2') ];
const sut = new Category(0, 'category', [], [], expected);
// act
const actual = sut.getAllScriptsRecursively();
// assert
expect(actual).to.have.deep.members(expected);
});
it('gets child categories', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4'];
const categories = [
new CategoryStub(31).withScriptIds('1', '2'),
new CategoryStub(32).withScriptIds('3', '4'),
];
const sut = new Category(0, 'category', [], categories, []);
// act
const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('gets child scripts and categories', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4', '5' , '6'];
const categories = [
new CategoryStub(31).withScriptIds('1', '2'),
new CategoryStub(32).withScriptIds('3', '4'),
];
const scripts = [ new ScriptStub('5'), new ScriptStub('6') ];
const sut = new Category(0, 'category', [], categories, scripts);
// act
const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('gets child categories recursively', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [
new CategoryStub(31)
.withScriptIds('1', '2')
.withCategory(
new CategoryStub(32)
.withScriptIds('3', '4'),
),
new CategoryStub(33)
.withCategories(
new CategoryStub(34)
.withScriptIds('5')
.withCategory(
new CategoryStub(35)
.withCategory(
new CategoryStub(35).withScriptIds('6'),
),
),
),
];
// assert
const sut = new Category(0, 'category', [], categories, []);
// act
const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
});
});

View File

@@ -0,0 +1,128 @@
import 'mocha';
import { expect } from 'chai';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
describe('ProjectInformation', () => {
it('sets name as expected', () => {
// arrange
const expected = 'expected-name';
const sut = new ProjectInformation(expected, 'version', 'repositoryUrl', 'homepage');
// act
const actual = sut.name;
// assert
expect(actual).to.equal(expected);
});
it('sets version as expected', () => {
// arrange
const expected = 'expected-version';
const sut = new ProjectInformation('name', expected, 'repositoryUrl', 'homepage');
// act
const actual = sut.version;
// assert
expect(actual).to.equal(expected);
});
it('sets repositoryUrl as expected', () => {
// arrange
const expected = 'expected-repository-url';
const sut = new ProjectInformation('name', 'version', expected, 'homepage');
// act
const actual = sut.repositoryUrl;
// assert
expect(actual).to.equal(expected);
});
describe('sets repositoryWebUrl as expected', () => {
it('sets repositoryUrl when it does not end with .git', () => {
// arrange
const expected = 'expected-repository-url';
const sut = new ProjectInformation('name', 'version', expected, 'homepage');
// act
const actual = sut.repositoryWebUrl;
// assert
expect(actual).to.equal(expected);
});
it('removes ".git" from the end when it ends with ".git"', () => {
// arrange
const expected = 'expected-repository-url';
const sut = new ProjectInformation('name', 'version', `${expected}.git`, 'homepage');
// act
const actual = sut.repositoryWebUrl;
// assert
expect(actual).to.equal(expected);
});
});
it('sets homepage as expected', () => {
// arrange
const expected = 'expected-homepage';
const sut = new ProjectInformation('name', 'version', 'repositoryUrl', expected);
// act
const actual = sut.homepage;
// assert
expect(actual).to.equal(expected);
});
it('sets feedbackUrl to github issues page', () => {
// arrange
const repositoryUrl = 'https://github.com/undergroundwires/privacy.sexy.git';
const expected = 'https://github.com/undergroundwires/privacy.sexy/issues';
const sut = new ProjectInformation('name', 'version', repositoryUrl, 'homepage');
// act
const actual = sut.feedbackUrl;
// assert
expect(actual).to.equal(expected);
});
it('sets releaseUrl to github releases page', () => {
// arrange
const repositoryUrl = 'https://github.com/undergroundwires/privacy.sexy.git';
const version = '0.7.2';
const expected = 'https://github.com/undergroundwires/privacy.sexy/releases/tag/0.7.2';
const sut = new ProjectInformation('name', version, repositoryUrl, 'homepage');
// act
const actual = sut.releaseUrl;
// assert
expect(actual).to.equal(expected);
});
describe('getDownloadUrl', () => {
it('gets expected url for macOS', () => {
// arrange
const expected = 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-0.7.2.dmg';
const repositoryUrl = 'https://github.com/undergroundwires/privacy.sexy.git';
const version = '0.7.2';
const sut = new ProjectInformation('name', version, repositoryUrl, 'homepage');
// act
const actual = sut.getDownloadUrl(OperatingSystem.macOS);
// assert
expect(actual).to.equal(expected);
});
it('gets expected url for Linux', () => {
// arrange
const expected = 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-0.7.2.AppImage';
const repositoryUrl = 'https://github.com/undergroundwires/privacy.sexy.git';
const version = '0.7.2';
const sut = new ProjectInformation('name', version, repositoryUrl, 'homepage');
// act
const actual = sut.getDownloadUrl(OperatingSystem.Linux);
// assert
expect(actual).to.equal(expected);
});
it('gets expected url for Windows', () => {
// arrange
const expected = 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-Setup-0.7.2.exe';
const repositoryUrl = 'https://github.com/undergroundwires/privacy.sexy.git';
const version = '0.7.2';
const sut = new ProjectInformation('name', version, repositoryUrl, 'homepage');
// act
const actual = sut.getDownloadUrl(OperatingSystem.Windows);
// assert
expect(actual).to.equal(expected);
});
it('throws when OS is unknown', () => {
// arrange
const sut = new ProjectInformation('name', 'version', 'repositoryUrl', 'homepage');
const os = OperatingSystem.Unknown;
// act
const act = () => sut.getDownloadUrl(os);
// assert
expect(act).to.throw(`Unsupported os: ${OperatingSystem[os]}`);
});
});
});

View File

@@ -95,4 +95,26 @@ describe('InMemoryRepository', () => {
expect(actual).to.deep.equal(expected); expect(actual).to.deep.equal(expected);
}); });
}); });
describe('getById', () => {
it('gets entity if it exists', () => {
// arrange
const expected = new NumericEntityStub(1).withCustomProperty('bca');
const sut = new InMemoryRepository<number, NumericEntityStub>([
expected, new NumericEntityStub(2).withCustomProperty('bca'),
new NumericEntityStub(3).withCustomProperty('bca'), new NumericEntityStub(4).withCustomProperty('bca'),
]);
// act
const actual = sut.getById(expected.id);
// assert
expect(actual).to.deep.equal(expected);
});
it('gets undefined if it does not exist', () => {
// arrange
const sut = new InMemoryRepository<number, NumericEntityStub>([]);
// act
const actual = sut.getById(31);
// assert
expect(actual).to.equal(undefined);
});
});
}); });

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