add initial macOS support #40

This commit is contained in:
undergroundwires
2021-01-13 16:31:20 +01:00
parent 2428de23ee
commit 8a8b7319d5
99 changed files with 2663 additions and 1135 deletions

View File

@@ -16,7 +16,7 @@ import { Component, Vue } from 'vue-property-decorator';
import TheHeader from '@/presentation/TheHeader.vue';
import TheFooter from '@/presentation/TheFooter/TheFooter.vue';
import TheCodeArea from '@/presentation/TheCodeArea.vue';
import TheCodeButtons from '@/presentation/TheCodeButtons.vue';
import TheCodeButtons from '@/presentation/CodeButtons/TheCodeButtons.vue';
import TheSearchBar from '@/presentation/TheSearchBar.vue';
import TheScripts from '@/presentation/Scripts/TheScripts.vue';

View File

@@ -36,9 +36,8 @@ export class ApplicationContext implements IApplicationContext {
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`);
}
const event: IApplicationContextChangedEvent = {
newState: this.state,
newCollection: this.collection,
newOs: os,
newState: this.states[os],
oldState: this.states[this.currentOs],
};
this.contextChanged.notify(event);
this.currentOs = os;

View File

@@ -24,9 +24,8 @@ function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSy
return currentOs;
}
supportedOsList.sort((os1, os2) => {
const os1SupportLevel = app.collections[os1].totalScripts;
const os2SupportLevel = app.collections[os2].totalScripts;
return os1SupportLevel - os2SupportLevel;
const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts;
return getPriority(os2) - getPriority(os1);
});
return supportedOsList[0];
}

View File

@@ -1,13 +1,10 @@
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISignal } from '@/infrastructure/Events/ISignal';
import { IApplication } from '@/domain/IApplication';
export interface IApplicationContext {
readonly currentOs: OperatingSystem;
readonly app: IApplication;
readonly collection: ICategoryCollection;
readonly state: ICategoryCollectionState;
readonly contextChanged: ISignal<IApplicationContextChangedEvent>;
changeContext(os: OperatingSystem): void;
@@ -15,6 +12,5 @@ export interface IApplicationContext {
export interface IApplicationContextChangedEvent {
readonly newState: ICategoryCollectionState;
readonly newCollection: ICategoryCollection;
readonly newOs: OperatingSystem;
readonly oldState: ICategoryCollectionState;
}

View File

@@ -6,8 +6,10 @@ import { IUserSelection } from './Selection/IUserSelection';
import { ICategoryCollectionState } from './ICategoryCollectionState';
import { IApplicationCode } from './Code/IApplicationCode';
import { ICategoryCollection } from '../../../domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
export class CategoryCollectionState implements ICategoryCollectionState {
public readonly os: OperatingSystem;
public readonly code: IApplicationCode;
public readonly selection: IUserSelection;
public readonly filter: IUserFilter;
@@ -16,5 +18,6 @@ export class CategoryCollectionState implements ICategoryCollectionState {
this.selection = new UserSelection(collection, []);
this.code = new ApplicationCode(this.selection, collection.scripting);
this.filter = new UserFilter(collection);
this.os = collection.os;
}
}

View File

@@ -3,7 +3,7 @@ import { ICodeBuilder } from './ICodeBuilder';
const NewLine = '\n';
const TotalFunctionSeparatorChars = 58;
export class CodeBuilder implements ICodeBuilder {
export abstract class CodeBuilder implements ICodeBuilder {
private readonly lines = new Array<string>();
// Returns current line starting from 0 (no lines), or 1 (have single line)
@@ -29,7 +29,7 @@ export class CodeBuilder implements ICodeBuilder {
}
public appendCommentLine(commentLine?: string): CodeBuilder {
this.lines.push(`:: ${commentLine}`);
this.lines.push(`${this.getCommentDelimiter()} ${commentLine}`);
return this;
}
@@ -37,9 +37,8 @@ export class CodeBuilder implements ICodeBuilder {
if (!name) { throw new Error('name cannot be empty or null'); }
if (!code) { throw new Error('code cannot be empty or null'); }
return this
.appendLine()
.appendCommentLineWithHyphensAround(name)
.appendLine(`echo --- ${name}`)
.appendLine(this.writeStandardOut(`--- ${name}`))
.appendLine(code)
.appendTrailingHyphensCommentLine();
}
@@ -62,4 +61,7 @@ export class CodeBuilder implements ICodeBuilder {
public toString(): string {
return this.lines.join(NewLine);
}
protected abstract getCommentDelimiter(): string;
protected abstract writeStandardOut(text: string): string;
}

View File

@@ -0,0 +1,15 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ICodeBuilder } from './ICodeBuilder';
import { ICodeBuilderFactory } from './ICodeBuilderFactory';
import { BatchBuilder } from './Languages/BatchBuilder';
import { ShellBuilder } from './Languages/ShellBuilder';
export class CodeBuilderFactory implements ICodeBuilderFactory {
public create(language: ScriptingLanguage): ICodeBuilder {
switch (language) {
case ScriptingLanguage.shellscript: return new ShellBuilder();
case ScriptingLanguage.batchfile: return new BatchBuilder();
default: throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`);
}
}
}

View File

@@ -0,0 +1,6 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ICodeBuilder } from './ICodeBuilder';
export interface ICodeBuilderFactory {
create(language: ScriptingLanguage): ICodeBuilder;
}

View File

@@ -0,0 +1,10 @@
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
export class BatchBuilder extends CodeBuilder {
protected getCommentDelimiter(): string {
return '::';
}
protected writeStandardOut(text: string): string {
return `echo ${text}`;
}
}

View File

@@ -0,0 +1,10 @@
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
export class ShellBuilder extends CodeBuilder {
protected getCommentDelimiter(): string {
return '#';
}
protected writeStandardOut(text: string): string {
return `echo '${text}'`;
}
}

View File

@@ -1,14 +1,15 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { CodeBuilder } from './CodeBuilder';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScript } from './IUserScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ICodeBuilder } from './ICodeBuilder';
import { ICodeBuilderFactory } from './ICodeBuilderFactory';
import { CodeBuilderFactory } from './CodeBuilderFactory';
export class UserScriptGenerator implements IUserScriptGenerator {
constructor(private readonly codeBuilderFactory: () => ICodeBuilder = () => new CodeBuilder()) {
constructor(private readonly codeBuilderFactory: ICodeBuilderFactory = new CodeBuilderFactory()) {
}
public buildCode(
@@ -20,7 +21,7 @@ export class UserScriptGenerator implements IUserScriptGenerator {
if (!selectedScripts.length) {
return { code: '', scriptPositions };
}
let builder = this.codeBuilderFactory();
let builder = this.codeBuilderFactory.create(scriptingDefinition.language);
builder = initializeCode(scriptingDefinition.startCode, builder);
for (const selection of selectedScripts) {
scriptPositions = appendSelection(selection, scriptPositions, builder);
@@ -52,16 +53,19 @@ function appendSelection(
selection: SelectedScript,
scriptPositions: Map<SelectedScript, ICodePosition>,
builder: ICodeBuilder): Map<SelectedScript, ICodePosition> {
const startPosition = builder.currentLine + 1;
appendCode(selection, builder);
const startPosition = builder.currentLine + 1; // Because first line will be empty to separate scripts
builder = appendCode(selection, builder);
const endPosition = builder.currentLine - 1;
builder.appendLine();
scriptPositions.set(selection, new CodePosition(startPosition, endPosition));
const position = new CodePosition(startPosition, endPosition);
scriptPositions.set(selection, position);
return scriptPositions;
}
function appendCode(selection: SelectedScript, builder: ICodeBuilder) {
function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const scriptCode = selection.revert ? selection.script.code.revert : selection.script.code.execute;
builder.appendFunction(name, scriptCode);
return builder
.appendLine()
.appendFunction(name, scriptCode);
}

View File

@@ -1,6 +1,6 @@
import { ICodePosition } from './ICodePosition';
export class CodePosition implements ICodePosition {
export class CodePosition implements ICodePosition {
public get totalLines(): number {
return this.endLine - this.startLine;
}

View File

@@ -1,5 +1,5 @@
import { ISignal } from '@/infrastructure/Events/ISignal';
import { IFilterResult } from './IFilterResult';
import { ISignal } from '@/infrastructure/Events/Signal';
export interface IUserFilter {
readonly currentFilter: IFilterResult | undefined;

View File

@@ -1,10 +1,13 @@
import { IUserFilter } from './Filter/IUserFilter';
import { IUserSelection } from './Selection/IUserSelection';
import { IApplicationCode } from './Code/IApplicationCode';
export { IUserSelection, IApplicationCode, IUserFilter };
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface ICategoryCollectionState {
readonly code: IApplicationCode;
readonly filter: IUserFilter;
readonly selection: IUserSelection;
readonly collection: ICategoryCollection;
readonly os: OperatingSystem;
}

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from './SelectedScript';
import { ISignal } from '@/infrastructure/Events/Signal';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ISignal } from '@/infrastructure/Events/ISignal';
export interface IUserSelection {
readonly changed: ISignal<ReadonlyArray<SelectedScript>>;

View File

@@ -3,6 +3,7 @@ import { IProjectInformation } from '@/domain/IProjectInformation';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { parseCategoryCollection } from './CategoryCollectionParser';
import WindowsData from 'js-yaml-loader!@/application/collections/windows.yaml';
import MacOsData from 'js-yaml-loader!@/application/collections/macos.yaml';
import { CollectionData } from 'js-yaml-loader!@/*';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { Application } from '@/domain/Application';
@@ -10,10 +11,11 @@ import { Application } from '@/domain/Application';
export function parseApplication(
parser = CategoryCollectionParser,
processEnv: NodeJS.ProcessEnv = process.env,
collectionData = LoadedCollectionData): IApplication {
collectionsData = PreParsedCollections): IApplication {
validateCollectionsData(collectionsData);
const information = parseProjectInformation(processEnv);
const collection = parser(collectionData, information);
const app = new Application(information, [ collection ]);
const collections = collectionsData.map((collection) => parser(collection, information));
const app = new Application(information, collections);
return app;
}
@@ -23,5 +25,14 @@ export type CategoryCollectionParserType
const CategoryCollectionParser: CategoryCollectionParserType
= (file, info) => parseCategoryCollection(file, info);
const LoadedCollectionData: CollectionData
= WindowsData;
const PreParsedCollections: readonly CollectionData []
= [ WindowsData, MacOsData ];
function validateCollectionsData(collections: readonly CollectionData[]) {
if (!collections.length) {
throw new Error('no collection provided');
}
if (collections.some((collection) => !collection)) {
throw new Error('undefined collection provided');
}
}

View File

@@ -1,27 +1,27 @@
import { Category } from '@/domain/Category';
import { CollectionData } from 'js-yaml-loader!@/*';
import { parseCategory } from './CategoryParser';
import { ScriptCompiler } from './Compiler/ScriptCompiler';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { parseScriptingDefinition } from './ScriptingDefinitionParser';
import { createEnumParser } from '../Common/Enum';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext';
export function parseCategoryCollection(
content: CollectionData,
info: IProjectInformation,
osParser = createEnumParser(OperatingSystem)): ICategoryCollection {
validate(content);
const compiler = new ScriptCompiler(content.functions);
const scripting = parseScriptingDefinition(content.scripting, info);
const context = new CategoryCollectionParseContext(content.functions, scripting);
const categories = new Array<Category>();
for (const action of content.actions) {
const category = parseCategory(action, compiler);
const category = parseCategory(action, context);
categories.push(category);
}
const os = osParser.parseEnum(content.os, 'os');
const scripting = parseScriptingDefinition(content.scripting, info);
const collection = new CategoryCollection(
os,
categories,

View File

@@ -2,8 +2,8 @@ import { CategoryData, ScriptData, CategoryOrScriptData } from 'js-yaml-loader!@
import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { parseDocUrls } from './DocumentationParser';
import { parseScript } from './ScriptParser';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
import { parseScript } from './Script/ScriptParser';
let categoryIdCounter: number = 0;
@@ -12,17 +12,15 @@ interface ICategoryChildren {
subScripts: Script[];
}
export function parseCategory(category: CategoryData, compiler: IScriptCompiler): Category {
if (!compiler) {
throw new Error('undefined compiler');
}
export function parseCategory(category: CategoryData, context: ICategoryCollectionParseContext): Category {
if (!context) { throw new Error('undefined context'); }
ensureValid(category);
const children: ICategoryChildren = {
subCategories: new Array<Category>(),
subScripts: new Array<Script>(),
};
for (const data of category.children) {
parseCategoryChild(data, children, category, compiler);
parseCategoryChild(data, children, category, context);
}
return new Category(
/*id*/ categoryIdCounter++,
@@ -49,13 +47,13 @@ function parseCategoryChild(
data: CategoryOrScriptData,
children: ICategoryChildren,
parent: CategoryData,
compiler: IScriptCompiler) {
context: ICategoryCollectionParseContext) {
if (isCategory(data)) {
const subCategory = parseCategory(data as CategoryData, compiler);
const subCategory = parseCategory(data as CategoryData, context);
children.subCategories.push(subCategory);
} else if (isScript(data)) {
const scriptData = data as ScriptData;
const script = parseScript(scriptData, compiler);
const script = parseScript(scriptData, context);
children.subScripts.push(script);
} else {
throw new Error(`Child element is neither a category or a script.

View File

@@ -0,0 +1,22 @@
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ILanguageSyntax } from '@/domain/ScriptCode';
import { FunctionData } from 'js-yaml-loader!*';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { ScriptCompiler } from './Compiler/ScriptCompiler';
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
import { SyntaxFactory } from './Syntax/SyntaxFactory';
import { ISyntaxFactory } from './Syntax/ISyntaxFactory';
export class CategoryCollectionParseContext implements ICategoryCollectionParseContext {
public readonly compiler: IScriptCompiler;
public readonly syntax: ILanguageSyntax;
constructor(
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory: ISyntaxFactory = new SyntaxFactory()) {
if (!scripting) { throw new Error('undefined scripting'); }
this.syntax = syntaxFactory.create(scripting.language);
this.compiler = new ScriptCompiler(functionsData, this.syntax);
}
}

View File

@@ -3,6 +3,7 @@ import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import { ScriptData, FunctionData, FunctionCallData, ScriptFunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!@/*';
import { IScriptCompiler } from './IScriptCompiler';
import { ILanguageSyntax } from '@/domain/ScriptCode';
interface ICompiledCode {
readonly code: string;
@@ -10,8 +11,11 @@ interface ICompiledCode {
}
export class ScriptCompiler implements IScriptCompiler {
constructor(private readonly functions: readonly FunctionData[]) {
constructor(
private readonly functions: readonly FunctionData[] | undefined,
private syntax: ILanguageSyntax) {
ensureValidFunctions(functions);
if (!syntax) { throw new Error('undefined syntax'); }
}
public canCompile(script: ScriptData): boolean {
if (!script.call) {
@@ -33,7 +37,7 @@ export class ScriptCompiler implements IScriptCompiler {
compiledCodes.push(functionCode);
});
const scriptCode = merge(compiledCodes);
return new ScriptCode(script.name, scriptCode.code, scriptCode.revertCode);
return new ScriptCode(scriptCode.code, scriptCode.revertCode, script.name, this.syntax);
}
private getFunctionByName(name: string): FunctionData {
@@ -43,7 +47,6 @@ export class ScriptCompiler implements IScriptCompiler {
}
return func;
}
private ensureCompilable(call: ScriptFunctionCallData) {
if (!this.functions || this.functions.length === 0) {
throw new Error('cannot compile without shared functions');
@@ -69,7 +72,11 @@ function ensureNoDuplicatesInFunctionNames(functions: readonly FunctionData[]) {
throw new Error(`duplicate function name: ${printList(duplicateFunctionNames)}`);
}
}
function ensureNoUndefinedItem(functions: readonly FunctionData[]) {
if (functions.some((func) => !func)) {
throw new Error(`some functions are undefined`);
}
}
function ensureNoDuplicatesInParameterNames(functions: readonly FunctionData[]) {
const functionsWithParameters = functions
.filter((func) => func.parameters && func.parameters.length > 0);
@@ -95,9 +102,10 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
}
function ensureValidFunctions(functions: readonly FunctionData[]) {
if (!functions) {
if (!functions || functions.length === 0) {
return;
}
ensureNoUndefinedItem(functions);
ensureNoDuplicatesInFunctionNames(functions);
ensureNoDuplicatesInParameterNames(functions);
ensureNoDuplicateCode(functions);

View File

@@ -0,0 +1,7 @@
import { ILanguageSyntax } from '@/domain/ScriptCode';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
export interface ICategoryCollectionParseContext {
readonly compiler: IScriptCompiler;
readonly syntax: ILanguageSyntax;
}

View File

@@ -1,22 +1,20 @@
import { Script } from '@/domain/Script';
import { ScriptData } from 'js-yaml-loader!@/*';
import { parseDocUrls } from './DocumentationParser';
import { parseDocUrls } from '../DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import { createEnumParser, IEnumParser } from '../Common/Enum';
import { createEnumParser, IEnumParser } from '../../Common/Enum';
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
export function parseScript(
data: ScriptData, compiler: IScriptCompiler,
data: ScriptData, context: ICategoryCollectionParseContext,
levelParser = createEnumParser(RecommendationLevel)): Script {
validateScript(data);
if (!compiler) {
throw new Error('undefined compiler');
}
if (!context) { throw new Error('undefined context'); }
const script = new Script(
/* name */ data.name,
/* code */ parseCode(data, compiler),
/* code */ parseCode(data, context),
/* docs */ parseDocUrls(data),
/* level */ parseLevel(data.recommend, levelParser));
return script;
@@ -29,11 +27,11 @@ function parseLevel(level: string, parser: IEnumParser<RecommendationLevel>): Re
return parser.parseEnum(level, 'level');
}
function parseCode(script: ScriptData, compiler: IScriptCompiler): IScriptCode {
if (compiler.canCompile(script)) {
return compiler.compile(script);
function parseCode(script: ScriptData, context: ICategoryCollectionParseContext): IScriptCode {
if (context.compiler.canCompile(script)) {
return context.compiler.compile(script);
}
return new ScriptCode(script.name, script.code, script.revertCode);
return new ScriptCode(script.code, script.revertCode, script.name, context.syntax);
}
function ensureNotBothCallAndCode(script: ScriptData) {

View File

@@ -0,0 +1,6 @@
import { ILanguageSyntax } from '@/domain/ScriptCode';
export class BatchFileSyntax implements ILanguageSyntax {
public readonly commentDelimiters = [ 'REM', '::' ];
public readonly commonCodeParts = [ '(', ')', 'else' ];
}

View File

@@ -0,0 +1,6 @@
import { ILanguageSyntax } from '@/domain/ScriptCode';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface ISyntaxFactory {
create(language: ScriptingLanguage): ILanguageSyntax;
}

View File

@@ -0,0 +1,6 @@
import { ILanguageSyntax } from '@/domain/ScriptCode';
export class ShellScriptSyntax implements ILanguageSyntax {
public readonly commentDelimiters = [ '#' ];
public readonly commonCodeParts = [ '(', ')', 'else' ];
}

View File

@@ -0,0 +1,15 @@
import { ILanguageSyntax } from '@/domain/ScriptCode';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ISyntaxFactory } from './ISyntaxFactory';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
export class SyntaxFactory implements ISyntaxFactory {
public create(language: ScriptingLanguage): ILanguageSyntax {
switch (language) {
case ScriptingLanguage.batchfile: return new BatchFileSyntax();
case ScriptingLanguage.shellscript: return new ShellScriptSyntax();
default: throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`);
}
}
}

View File

@@ -4,7 +4,7 @@ import { ScriptingDefinition } from '@/domain/ScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { createEnumParser } from '../Common/Enum';
import { generateIlCode } from './Compiler/ILCode';
import { generateIlCode } from './Script/Compiler/ILCode';
export function parseScriptingDefinition(
definition: ScriptingDefinitionData,

View File

@@ -38,10 +38,10 @@ declare module 'js-yaml-loader!*' {
export interface ScriptData extends DocumentableData {
name: string;
code: string | undefined;
revertCode: string | undefined;
code?: string;
revertCode?: string;
call: ScriptFunctionCallData;
recommend: string | undefined;
recommend?: string;
}
export interface ScriptingDefinitionData {

View File

@@ -0,0 +1,225 @@
# Structure documented in "docs/collections.md"
os: macos
scripting:
language: shellscript
startCode: |-
#!/usr/bin/env bash
# {{ $homepage }} — v{{ $version }} — {{ $date }}
if [ "$EUID" -ne 0 ]; then
script_path=$([[ "$0" = /* ]] && echo "$0" || echo "$PWD/${0#./}")
sudo "$script_path" || (
echo 'Administrator privileges are required.'
exit 1
)
exit 0
fi
endCode: |-
echo 'Your privacy and security is now hardened 🎉💪'
echo 'Press any key to exit.'
read -n 1 -s
actions:
-
category: Privacy cleanup
children:
-
category: Clear terminal history
children:
-
name: Clear bash history
recommend: standard
code: rm -f ~/.bash_history
-
name: Clear zsh history
recommend: standard
code: rm -f ~/.zsh_history
-
name: Clear CUPS printer job cache
recommend: strict
code: |-
sudo rm -rfv /var/spool/cups/c0*
sudo rm -rfv /var/spool/cups/tmp/*
sudo rm -rfv /var/spool/cups/cache/job.cache*
-
name: Clear the list of iOS devices connected
recommend: strict
code: |-
sudo defaults delete /Users/$USER/Library/Preferences/com.apple.iPod.plist "conn:128:Last Connect"
sudo defaults delete /Users/$USER/Library/Preferences/com.apple.iPod.plist Devices
sudo defaults delete /Library/Preferences/com.apple.iPod.plist "conn:128:Last Connect"
sudo defaults delete /Library/Preferences/com.apple.iPod.plist Devices
sudo rm -rfv /var/db/lockdown/*
-
name: Reset privacy database (remove all permissions)
code: sudo tccutil reset All
-
category: Configure programs
children:
-
name: Disable Firefox telemetry
recommend: standard
docs: https://github.com/mozilla/policy-templates/blob/master/README.md
code: |-
# Enable Firefox policies so the telemetry can be configured.
sudo defaults write /Library/Preferences/org.mozilla.firefox EnterprisePoliciesEnabled -bool TRUE
# Disable sending usage data
sudo defaults write /Library/Preferences/org.mozilla.firefox DisableTelemetry -bool TRUE
revertCode: |-
sudo defaults delete /Library/Preferences/org.mozilla.firefox EnterprisePoliciesEnabled
sudo defaults delete /Library/Preferences/org.mozilla.firefox DisableTelemetry
-
name: Disable Microsoft Office diagnostics data sending
recommend: standard
code: defaults write com.microsoft.office DiagnosticDataTypePreference -string ZeroDiagnosticData
revertCode: defaults delete com.microsoft.office DiagnosticDataTypePreference
-
name: Uninstall Google update
recommend: strict
code: |-
googleUpdateFile=~/Library/Google/GoogleSoftwareUpdate/GoogleSoftwareUpdate.bundle/Contents/Resources/ksinstall
if [ -f "$googleUpdateFile" ]; then
$googleUpdateFile --nuke
echo Uninstalled google update
else
echo Google update file does not exist
fi
-
name: Disable Homebrew user behavior analytics
recommend: standard
docs: https://docs.brew.sh/Analytics
call:
-
function: PersistUserEnvironmentConfiguration
parameters:
configuration: export HOMEBREW_NO_ANALYTICS=1
-
name: Disable NET Core CLI telemetry
recommend: standard
call:
-
function: PersistUserEnvironmentConfiguration
parameters:
configuration: export DOTNET_CLI_TELEMETRY_OPTOUT=1
-
name: Disable PowerShell Core telemetry
recommend: standard
docs: https://github.com/PowerShell/PowerShell/tree/release/v7.1.1#telemetry
call:
-
function: PersistUserEnvironmentConfiguration
parameters:
configuration: export POWERSHELL_TELEMETRY_OPTOUT=1
-
category: Configure OS
children:
-
category: Configure Apple Remote Desktop
children:
-
name: Deactivate the Remote Management Service
recommend: strict
code: sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart -deactivate -stop
revertCode: sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart -activate -restart -agent -console
-
name: Remove Apple Remote Desktop Settings
recommend: strict
code: |-
sudo rm -rf /var/db/RemoteManagement
sudo defaults delete /Library/Preferences/com.apple.RemoteDesktop.plist
defaults delete ~/Library/Preferences/com.apple.RemoteDesktop.plist
sudo rm -r /Library/Application\ Support/Apple/Remote\ Desktop/
rm -r ~/Library/Application\ Support/Remote\ Desktop/
rm -r ~/Library/Containers/com.apple.RemoteDesktop
-
name: Disable Internet based spell correction
code: defaults write NSGlobalDomain WebAutomaticSpellingCorrectionEnabled -bool false
revertCode: defaults delete NSGlobalDomain WebAutomaticSpellingCorrectionEnabled
-
name: Disable Remote Apple Events
recommend: strict
code: sudo systemsetup -setremoteappleevents off
revertCode: sudo systemsetup -setremoteappleevents on
-
name: Do not store documents to iCloud Drive by default
docs: https://macos-defaults.com/finder/nsdocumentsavenewdocumentstocloud.html
recommend: standard
code: defaults write NSGlobalDomain NSDocumentSaveNewDocumentsToCloud -bool false
revertCode: defaults delete NSGlobalDomain NSDocumentSaveNewDocumentsToCloud
-
category: Security improvements
children:
-
category: Configure macOS Application Firewall
children:
-
name: Enable firewall
recommend: standard
docs: https://www.stigviewer.com/stig/apple_os_x_10.13/2018-10-01/finding/V-81681
code: /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
revertCode: /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off
-
name: Turn on firewall logging
recommend: standard
docs: https://www.stigviewer.com/stig/apple_os_x_10.13/2018-10-01/finding/V-81671
code: /usr/libexec/ApplicationFirewall/socketfilterfw --setloggingmode on
revertCode: /usr/libexec/ApplicationFirewall/socketfilterfw --setloggingmode off
-
name: Turn on stealth mode
recommend: standard
docs: https://www.stigviewer.com/stig/apple_os_x_10.8_mountain_lion_workstation/2015-02-10/finding/V-51327
code: /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on
revertCode: /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode off
-
name: Disable Spotlight indexing
code: sudo mdutil -i off -d /
revertCode: sudo mdutil -i on /
-
name: Disable Captive portal
docs:
- https://web.archive.org/web/20171008071031if_/http://blog.erratasec.com/2010/09/apples-secret-wispr-request.html#.WdnPa5OyL6Y
- https://web.archive.org/web/20130407200745/http://www.divertednetworks.net/apple-captiveportal.html
- https://web.archive.org/web/20170622064304/https://grpugh.wordpress.com/2014/10/29/an-undocumented-change-to-captive-network-assistant-settings-in-os-x-10-10-yosemite/
code: sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.captive.control.plist Active -bool false
revertCode: sudo defaults delete /Library/Preferences/SystemConfiguration/com.apple.captive.control.plist Active
-
name: Require a password to wake the computer from sleep or screen saver
code: defaults write /Library/Preferences/com.apple.screensaver askForPassword -bool true
revertCode: sudo defaults delete /Library/Preferences/com.apple.screensaver askForPassword
-
name: Do not show recent items on dock
docs: https://developer.apple.com/documentation/devicemanagement/dock
code: defaults write com.apple.dock show-recents -bool false
revertCode: defaults delete com.apple.dock show-recents
-
name: Disable AirDrop file sharing
recommend: strict
code: defaults write com.apple.NetworkBrowser DisableAirDrop -bool true
revertCode: defaults write com.apple.NetworkBrowser DisableAirDrop -bool false
functions:
-
name: PersistUserEnvironmentConfiguration
parameters: [ configuration ]
code: |-
command='{{ $configuration }}'
declare -a profile_files=("$HOME/.bash_profile" "$HOME/.zprofile")
for profile_file in "${profile_files[@]}"
do
touch "$profile_file"
if ! grep -q "$command" "${profile_file}"; then
echo "$command" >> "$profile_file"
echo "[$profile_file] Configured"
else
echo "[$profile_file] No need for any action, already configured"
fi
done
revertCode: |-
command='{{ $configuration }}'
declare -a profile_files=("$HOME/.bash_profile" "$HOME/.zprofile")
for profile_file in "${profile_files[@]}"
do
if grep -q "$command" "${profile_file}" 2>/dev/null; then
sed -i '' "/$command/d" "$profile_file"
echo "[$profile_file] Reverted configuration"
else
echo "[$profile_file] No need for any action, configuration does not exist"
fi
done

View File

@@ -2,16 +2,16 @@ import { IScriptCode } from './IScriptCode';
export class ScriptCode implements IScriptCode {
constructor(
scriptName: string,
public readonly execute: string,
public readonly revert: string) {
if (!scriptName) {
throw new Error('script name is undefined');
}
validateCode(scriptName, execute);
public readonly revert: string,
scriptName: string,
syntax: ILanguageSyntax) {
if (!scriptName) { throw new Error('script name is undefined'); }
if (!syntax) { throw new Error('syntax is undefined'); }
validateCode(scriptName, execute, syntax);
if (revert) {
scriptName = `${scriptName} (revert)`;
validateCode(scriptName, revert);
validateCode(scriptName, revert, syntax);
if (execute === revert) {
throw new Error(`${scriptName}: Code itself and its reverting code cannot be the same`);
}
@@ -19,12 +19,17 @@ export class ScriptCode implements IScriptCode {
}
}
function validateCode(name: string, code: string): void {
export interface ILanguageSyntax {
readonly commentDelimiters: string[];
readonly commonCodeParts: string[];
}
function validateCode(name: string, code: string, syntax: ILanguageSyntax): void {
if (!code || code.length === 0) {
throw new Error(`code of ${name} is empty or undefined`);
}
ensureNoEmptyLines(name, code);
ensureCodeHasUniqueLines(name, code);
ensureCodeHasUniqueLines(name, code, syntax);
}
function ensureNoEmptyLines(name: string, code: string): void {
@@ -33,9 +38,9 @@ function ensureNoEmptyLines(name: string, code: string): void {
}
}
function ensureCodeHasUniqueLines(name: string, code: string): void {
function ensureCodeHasUniqueLines(name: string, code: string, syntax: ILanguageSyntax): void {
const lines = code.split('\n')
.filter((line) => !shouldIgnoreLine(line));
.filter((line) => !shouldIgnoreLine(line, syntax));
if (lines.length === 0) {
return;
}
@@ -45,13 +50,12 @@ function ensureCodeHasUniqueLines(name: string, code: string): void {
}
}
function shouldIgnoreLine(codeLine: string): boolean {
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
codeLine = codeLine.toLowerCase();
const isCommentLine = () => codeLine.startsWith(':: ') || codeLine.startsWith('rem ');
const isCommentLine = () => syntax.commentDelimiters.some((delimiter) => codeLine.startsWith(delimiter));
const consistsOfFrequentCommands = () => {
const frequentCodeParts = [ '(', ')', 'else' ];
const trimmed = codeLine.trim().split(' ');
return trimmed.every((part) => frequentCodeParts.includes(part));
return trimmed.every((part) => syntax.commonCodeParts.includes(part));
};
return isCommentLine() || consistsOfFrequentCommands();
}

View File

@@ -16,7 +16,7 @@ export class ScriptingDefinition implements IScriptingDefinition {
function findExtension(language: ScriptingLanguage): string {
switch (language) {
case ScriptingLanguage.bash:
case ScriptingLanguage.shellscript:
return 'sh';
case ScriptingLanguage.batchfile:
return 'bat';

View File

@@ -1,4 +1,4 @@
export enum ScriptingLanguage {
batchfile = 0,
bash = 1,
shellscript = 1,
}

View File

@@ -1,4 +1,6 @@
import { IEventSubscription } from './ISubscription';
export interface ISignal<T> {
on(handler: (data: T) => void): void;
off(handler: (data: T) => void): void;
on(handler: EventHandler<T>): IEventSubscription;
}
export type EventHandler<T> = (data: T) => void;

View File

@@ -0,0 +1,3 @@
export interface IEventSubscription {
unsubscribe(): void;
}

View File

@@ -1,18 +1,28 @@
import { ISignal } from './ISignal';
export { ISignal };
import { EventHandler, ISignal } from './ISignal';
import { IEventSubscription } from './ISubscription';
export class Signal<T> implements ISignal<T> {
private handlers: Array<(data: T) => void> = [];
private handlers = new Map<number, EventHandler<T>>();
public on(handler: (data: T) => void): void {
this.handlers.push(handler);
}
public off(handler: (data: T) => void): void {
this.handlers = this.handlers.filter((h) => h !== handler);
public on(handler: EventHandler<T>): IEventSubscription {
const id = this.getUniqueEventHandlerId();
this.handlers.set(id, handler);
return {
unsubscribe: () => this.handlers.delete(id),
};
}
public notify(data: T) {
this.handlers.slice(0).forEach((h) => h(data));
for (const handler of Array.from(this.handlers.values())) {
handler(data);
}
}
private getUniqueEventHandlerId(): number {
const id = Math.random();
if (this.handlers.has(id)) {
return this.getUniqueEventHandlerId();
}
return id;
}
}

View File

@@ -2,6 +2,7 @@ import fileSaver from 'file-saver';
export enum FileType {
BatchFile,
ShellScript,
}
export class SaveFileDialog {
public static saveFile(text: string, fileName: string, type: FileType): void {
@@ -11,7 +12,8 @@ export class SaveFileDialog {
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
[ FileType.BatchFile, 'application/bat' ], // https://en.wikipedia.org/wiki/Batch_file
[ FileType.ShellScript, 'text/x-shellscript' ], // https://de.wikipedia.org/wiki/Shellskript#MIME-Typ
]);
private static saveBlob(file: BlobPart, fileType: string, fileName: string): void {

View File

@@ -0,0 +1,55 @@
<template>
<span class="code-wrapper">
<span class="dollar">$</span>
<code><slot></slot></code>
<font-awesome-icon
class="copy-button"
:icon="['fas', 'copy']"
@click="copyCode"
v-tooltip.top-center="'Copy'"
/>
</span>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Clipboard } from '@/infrastructure/Clipboard';
@Component
export default class Code extends Vue {
public copyCode(): void {
const code = this.$slots.default[0].text;
Clipboard.copyText(code);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.code-wrapper {
white-space: nowrap;
justify-content: space-between;
font-family: $normal-font;
background-color: $slate;
color: $light-gray;
padding-left: 0.3rem;
padding-right: 0.3rem;
.dollar {
margin-right: 0.5rem;
font-size: 0.8rem;
user-select: none;
}
.copy-button {
margin-left: 1rem;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
code {
font-size: 1.2rem;
}
}
</style>

View File

@@ -8,11 +8,10 @@
</template>
<script lang="ts">
import { Component, Prop, Emit } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import { Component, Prop, Emit, Vue } from 'vue-property-decorator';
@Component
export default class IconButton extends StatefulVue {
export default class IconButton extends Vue {
@Prop() public text!: number;
@Prop() public iconPrefix!: string;
@Prop() public iconName!: string;
@@ -21,7 +20,6 @@ export default class IconButton extends StatefulVue {
public onClicked() {
return;
}
}
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="instructions">
<!-- <p>
Since you're using online version of {{ this.appName }}, you will need to do additional steps after downloading the file to execute your script on macOS:
</p> -->
<p>
<ol>
<li>
<span>Download the file</span>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="'You should be prompted to save the script file now, otherwise try to download it again'"
/>
</li>
<li>
<span>Open terminal</span>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="'Type Terminal into Spotlight or open from the Applications -> Utilities folder'"
/>
</li>
<li>
<span>Navigate to the folder where you downloaded the file e.g.:</span>
<div>
<Code>cd ~/Downloads</Code>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="
'Press on Enter/Return key after running the command.<br/>' +
'If the file is not downloaded on Downloads folder, change `Downloads` to path where the file is downloaded.<br/>' +
' `cd` will change the current folder.<br/>' +
' `~` is the user home directory.'"
/>
</div>
</li>
<li>
<span>Give the file execute permissions:</span>
<div>
<Code>chmod +x {{ this.fileName }}</Code>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="
'Press on Enter/Return key after running the command.<br/>' +
'It will make the file executable.'"
/>
</div>
</li>
<li>
<span>Execute the file:</span>
<div>
<Code>./{{ this.fileName }}</Code>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="'Alternatively you can double click on the file'"
/>
</div>
</li>
<li>
<span>If asked, enter your administrator password</span>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="
'Press on Enter/Return key after typing your password<br/>' +
'Your password will not be shown by default.<br/>' +
'Administor privileges are required to configure OS.'"
/>
</li>
</ol>
</p>
<!-- <p>
Or download the <a :href="this.macOsDownloadUrl">offline version</a> to run your scripts directly to skip these steps.
</p> -->
</div>
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import Code from './Code.vue';
import { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem';
@Component({
components: {
Code,
},
})
export default class MacOsInstructions extends StatefulVue {
@Prop() public fileName: string;
public appName = '';
public macOsDownloadUrl = '';
protected initialize(app: IApplication): void {
this.appName = app.info.name;
this.macOsDownloadUrl = app.info.getDownloadUrl(OperatingSystem.macOS);
}
protected handleCollectionState(): void {
return;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
li {
margin: 10px 0;
}
.explanation {
margin-left: 0.5em;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
:text="this.isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCodeAsync"
icon-prefix="fas"
:icon-name="this.isDesktopVersion ? 'save' : 'file-download'">
</IconButton>
<IconButton
text="Copy"
v-on:click="copyCodeAsync"
icon-prefix="fas" icon-name="copy">
</IconButton>
<modal :name="macOsModalName" height="auto" :scrollable="true" :adaptive="true"
v-if="this.isMacOsCollection">
<div class="modal">
<div class="modal__content">
<MacOsInstructions :fileName="this.fileName" />
</div>
<div class="modal__close-button">
<font-awesome-icon :icon="['fas', 'times']" @click="$modal.hide(macOsModalName)"/>
</div>
</div>
</modal>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import IconButton from './IconButton.vue';
import MacOsInstructions from './MacOsInstructions.vue';
import { Environment } from '@/application/Environment/Environment';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplication } from '@/domain/IApplication';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IEventSubscription } from '@/infrastructure/Events/ISubscription';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
@Component({
components: {
IconButton,
MacOsInstructions,
},
})
export default class TheCodeButtons extends StatefulVue {
public readonly macOsModalName = 'macos-instructions';
public hasCode = false;
public isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
public isMacOsCollection = false;
public fileName = '';
private codeListener: IEventSubscription;
public async copyCodeAsync() {
const code = await this.getCurrentCodeAsync();
Clipboard.copyText(code.current);
}
public async saveCodeAsync() {
const context = await this.getCurrentContextAsync();
saveCode(this.fileName, context.state);
if (this.isMacOsCollection) {
this.$modal.show(this.macOsModalName);
}
}
public destroyed() {
if (this.codeListener) {
this.codeListener.unsubscribe();
}
}
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.isMacOsCollection = newState.collection.os === OperatingSystem.macOS;
this.fileName = buildFileName(newState.collection.scripting);
this.react(newState.code);
}
private async getCurrentCodeAsync(): Promise<IApplicationCode> {
const context = await this.getCurrentContextAsync();
const code = context.state.code;
return code;
}
private async react(code: IApplicationCode) {
this.hasCode = code.current && code.current.length > 0;
if (this.codeListener) {
this.codeListener.unsubscribe();
}
this.codeListener = code.changed.on((newCode) => {
this.hasCode = newCode && newCode.code.length > 0;
});
}
}
function saveCode(fileName: string, state: ICategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.container {
display: flex;
flex-direction: row;
justify-content: center;
}
.container > * + * {
margin-left: 30px;
}
.modal {
font-family: $normal-font;
margin-bottom: 10px;
display: flex;
flex-direction: row;
&__content {
width: 100%;
margin: 5%;
}
&__close-button {
width: auto;
font-size: 1.5em;
margin-right:0.25em;
align-self: flex-start;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
}
</style>

View File

@@ -21,6 +21,8 @@ import CardListItem from './CardListItem.vue';
import { StatefulVue } from '@/presentation/StatefulVue';
import { ICategory } from '@/domain/ICategory';
import { hasDirective } from './NonCollapsingDirective';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
@Component({
components: {
@@ -31,9 +33,7 @@ export default class CardList extends StatefulVue {
public categoryIds: number[] = [];
public activeCategoryId?: number = null;
public async mounted() {
const context = await this.getCurrentContextAsync();
this.setCategories(context.collection.actions);
public created() {
this.onOutsideOfActiveCardClicked((element) => {
if (hasDirective(element)) {
return;
@@ -41,15 +41,21 @@ export default class CardList extends StatefulVue {
this.activeCategoryId = null;
});
}
public onSelected(categoryId: number, isExpanded: boolean) {
this.activeCategoryId = isExpanded ? categoryId : undefined;
}
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
this.setCategories(newState.collection.actions);
this.activeCategoryId = undefined;
}
private setCategories(categories: ReadonlyArray<ICategory>): void {
this.categoryIds = categories.map((category) => category.id);
}
private onOutsideOfActiveCardClicked(callback: (clickedElement: Element) => void) {
const outsideClickListener = (event) => {
if (!this.activeCategoryId) {

View File

@@ -49,16 +49,17 @@ export default class CardListItem extends StatefulVue {
public isAnyChildSelected = false;
public areAllChildrenSelected = false;
@Emit('selected')
public async mounted() {
this.updateStateAsync(this.categoryId);
}
@Emit('selected')
public onSelected(isExpanded: boolean) {
this.isExpanded = isExpanded;
}
@Watch('activeCategoryId')
public async onActiveCategoryChanged(value: |number) {
this.isExpanded = value === this.categoryId;
}
@Watch('isExpanded')
public async onExpansionChangedAsync(newValue: number, oldValue: number) {
if (!oldValue && newValue) {
@@ -67,24 +68,22 @@ export default class CardListItem extends StatefulVue {
(focusElement as HTMLElement).scrollIntoView({behavior: 'smooth'});
}
}
public async mounted() {
const context = await this.getCurrentContextAsync();
context.state.selection.changed.on(() => {
this.updateStateAsync(this.categoryId);
});
this.updateStateAsync(this.categoryId);
}
@Watch('categoryId')
public async updateStateAsync(value: |number) {
const context = await this.getCurrentContextAsync();
const category = !value ? undefined : context.collection.findCategory(this.categoryId);
const category = !value ? undefined : context.state.collection.findCategory(this.categoryId);
this.cardTitle = category ? category.name : undefined;
const currentSelection = context.state.selection;
this.isAnyChildSelected = category ? currentSelection.isAnySelected(category) : false;
this.areAllChildrenSelected = category ? currentSelection.areAllSelected(category) : false;
}
protected initialize(): void {
return;
}
protected handleCollectionState(): void {
// No need, as categoryId will be updated instead
return;
}
}
</script>

View File

@@ -15,15 +15,13 @@
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Component, Vue } from 'vue-property-decorator';
import { Grouping } from './Grouping';
const DefaultGrouping = Grouping.Cards;
@Component
export default class TheGrouper extends StatefulVue {
export default class TheGrouper extends Vue {
public cardsSelected = false;
public noneSelected = false;
@@ -32,11 +30,9 @@ export default class TheGrouper extends StatefulVue {
public mounted() {
this.changeGrouping(DefaultGrouping);
}
public groupByCard() {
this.changeGrouping(Grouping.Cards);
}
public groupByNone() {
this.changeGrouping(Grouping.None);
}

View File

@@ -15,123 +15,129 @@
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { parseAllCategories, parseSingleCategory, getScriptNodeId,
getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
import { IApplication } from '@/domain/IApplication';
import { IEventSubscription } from '@/infrastructure/Events/ISubscription';
@Component({
components: {
SelectableTree,
},
})
export default class ScriptsTree extends StatefulVue {
@Prop() public categoryId?: number;
@Component({
components: {
SelectableTree,
},
})
export default class ScriptsTree extends StatefulVue {
@Prop() public categoryId?: number;
public nodes?: ReadonlyArray<INode> = null;
public selectedNodeIds?: ReadonlyArray<string> = [];
public filterText?: string = null;
public nodes?: ReadonlyArray<INode> = null;
public selectedNodeIds?: ReadonlyArray<string> = [];
public filterText?: string = null;
private filtered?: IFilterResult;
private filtered?: IFilterResult;
private listeners = new Array<IEventSubscription>();
public async mounted() {
public async toggleNodeSelectionAsync(event: INodeSelectedEvent) {
const context = await this.getCurrentContextAsync();
this.beginReactingToStateChanges(context.state);
this.setInitialState(context.state);
}
public async toggleNodeSelectionAsync(event: INodeSelectedEvent) {
const context = await this.getCurrentContextAsync();
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, context.state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, context.state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
}
@Watch('categoryId')
public async initializeNodesAsync(categoryId?: number) {
const context = await this.getCurrentContextAsync();
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, context.collection);
} else {
this.nodes = parseAllCategories(context.collection);
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, context.state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, context.state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
this.selectedNodeIds = context.state.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
public filterPredicate(node: INode): boolean {
return this.filtered.scriptMatches.some(
(script: IScript) => node.id === getScriptNodeId(script))
|| this.filtered.categoryMatches.some(
(category: ICategory) => node.id === getCategoryNodeId(category));
}
private beginReactingToStateChanges(state: ICategoryCollectionState) {
state.selection.changed.on(this.handleSelectionChanged);
state.filter.filterRemoved.on(this.handleFilterRemoved);
state.filter.filtered.on(this.handleFiltered);
}
private setInitialState(state: ICategoryCollectionState) {
this.initializeNodesAsync(this.categoryId);
this.initializeFilter(state.filter.currentFilter);
}
private initializeFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
this.handleFilterRemoved();
} else {
this.handleFiltered(currentFilter);
}
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts
.map((node) => node.id);
}
private handleFilterRemoved() {
this.filterText = '';
}
private handleFiltered(result: IFilterResult) {
this.filterText = result.query;
this.filtered = result;
}
}
function toggleCategoryNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): void {
const categoryId = getCategoryId(event.node.id);
if (event.isSelected) {
state.selection.addOrUpdateAllInCategory(categoryId, false);
@Watch('categoryId', { immediate: true })
public async setNodesAsync(categoryId?: number) {
const context = await this.getCurrentContextAsync();
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, context.state.collection);
} else {
state.selection.removeAllInCategory(categoryId);
this.nodes = parseAllCategories(context.state.collection);
}
this.selectedNodeIds = context.state.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
public filterPredicate(node: INode): boolean {
return this.filtered.scriptMatches.some(
(script: IScript) => node.id === getScriptNodeId(script))
|| this.filtered.categoryMatches.some(
(category: ICategory) => node.id === getCategoryNodeId(category));
}
public destroyed() {
this.unsubscribeAll();
}
protected initialize(app: IApplication): void {
return;
}
protected async handleCollectionState(newState: ICategoryCollectionState) {
this.setCurrentFilter(newState.filter.currentFilter);
if (!this.categoryId) {
this.nodes = parseAllCategories(newState.collection);
}
this.unsubscribeAll();
this.subscribe(newState);
}
private subscribe(state: ICategoryCollectionState) {
this.listeners.push(state.selection.changed.on(this.handleSelectionChanged));
this.listeners.push(state.filter.filterRemoved.on(this.handleFilterRemoved));
this.listeners.push(state.filter.filtered.on(this.handleFiltered));
}
private unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
}
private setCurrentFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
this.handleFilterRemoved();
} else {
this.handleFiltered(currentFilter);
}
}
function toggleScriptNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): 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);
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts
.map((node) => node.id);
}
private handleFilterRemoved() {
this.filterText = '';
}
private handleFiltered(result: IFilterResult) {
this.filterText = result.query;
this.filtered = result;
}
}
function toggleCategoryNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): 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: ICategoryCollectionState): 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>
<style scoped lang="scss">

View File

@@ -19,6 +19,9 @@
import { INode } from './INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { getReverter } from './Reverter/ReverterFactory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
import { IEventSubscription } from '@/infrastructure/Events/ISubscription';
@Component
export default class RevertToggle extends StatefulVue {
@@ -26,26 +29,30 @@
public isReverted = false;
private handler: IReverter;
private selectionChangeListener: IEventSubscription;
public async mounted() {
await this.onNodeChangedAsync(this.node);
@Watch('node', {immediate: true}) public async onNodeChangedAsync(node: INode) {
const context = await this.getCurrentContextAsync();
const currentSelection = context.state.selection;
this.updateState(currentSelection.selectedScripts);
currentSelection.changed.on((scripts) => this.updateState(scripts));
this.handler = getReverter(node, context.state.collection);
}
@Watch('node') public async onNodeChangedAsync(node: INode) {
const context = await this.getCurrentContextAsync();
this.handler = getReverter(node, context.collection);
}
public async onRevertToggledAsync() {
const context = await this.getCurrentContextAsync();
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
}
private updateState(scripts: ReadonlyArray<SelectedScript>) {
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
this.updateStatus(newState.selection.selectedScripts);
if (this.selectionChangeListener) {
this.selectionChangeListener.unsubscribe();
}
this.selectionChangeListener = newState.selection.changed.on(
(scripts) => this.updateStatus(scripts));
}
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
this.isReverted = this.handler.getState(scripts);
}
}

View File

@@ -1,5 +1,5 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/ICategoryCollectionState';
export interface IReverter {
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;

View File

@@ -1,7 +1,7 @@
import { IReverter } from './IReverter';
import { getScriptId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/ICategoryCollectionState';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
export class ScriptReverter implements IReverter {
private readonly scriptId: string;

View File

@@ -17,102 +17,121 @@
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import LiquorTree from 'liquor-tree';
import Node from './Node/Node.vue';
import { INode } from './Node/INode';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
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';
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import LiquorTree from 'liquor-tree';
import Node from './Node/Node.vue';
import { INode } from './Node/INode';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
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 */
@Component({
components: {
LiquorTree,
Node,
},
})
export default class SelectableTree extends Vue { // Keep it stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate;
@Prop() public filterText?: string;
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Prop() public initialNodes?: ReadonlyArray<INode>;
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
LiquorTree,
Node,
},
})
export default class SelectableTree extends Vue { // Keep it stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate;
@Prop() public filterText?: string;
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Prop() public initialNodes?: ReadonlyArray<INode>;
public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = new LiquorTreeOptions(new NodePredicateFilter((node) => this.filterPredicate(node)));
public convertExistingToNode = convertExistingToNode;
public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = new LiquorTreeOptions(new NodePredicateFilter((node) => this.filterPredicate(node)));
public convertExistingToNode = convertExistingToNode;
public mounted() {
if (this.initialNodes) {
const initialNodes = this.initialNodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(initialNodes,
(node) => node.state = updateState(node.state, node, this.selectedNodeIds));
}
this.initialLiquourTreeNodes = initialNodes;
} else {
throw new Error('Initial nodes are null or empty');
}
if (this.filterText) {
this.updateFilterText(this.filterText);
}
public nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
return;
}
@Watch('initialNodes', { immediate: true })
public async updateNodesAsync(nodes: readonly INode[]) {
if (!nodes) {
throw new Error('undefined initial nodes');
}
public nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
return;
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(initialNodes,
(node) => node.state = updateState(node.state, node, this.selectedNodeIds));
}
@Watch('filterText')
public updateFilterText(filterText: |string) {
const api = this.getLiquorTreeApi();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
}
}
@Watch('selectedNodeIds')
public setSelectedStatusAsync(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('SelectedrecurseDown nodes are undefined');
}
this.getLiquorTreeApi().recurseDown(
(node) => node.states = updateState(node.states, node, selectedNodeIds),
);
}
private getLiquorTreeApi(): ILiquorTree {
if (!this.$refs.treeElement) {
throw new Error('Referenced tree element cannot be found. Probably it\'s not rendered?');
}
return (this.$refs.treeElement as any).tree;
this.initialLiquourTreeNodes = initialNodes;
const api = await this.getLiquorTreeApiAsync();
api.setModel(this.initialLiquourTreeNodes); // as liquor tree is not reactive to data after initialization
}
@Watch('filterText', { immediate: true })
public async updateFilterTextAsync(filterText: |string) {
const api = await this.getLiquorTreeApiAsync();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
}
}
function updateState(
old: ILiquorTreeNodeState,
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
return {...old, ...getNewState(node, selectedNodeIds)};
@Watch('selectedNodeIds')
public async setSelectedStatusAsync(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('SelectedrecurseDown nodes are undefined');
}
const tree = await this.getLiquorTreeApiAsync();
tree.recurseDown(
(node) => node.states = updateState(node.states, node, selectedNodeIds),
);
}
function recurseDown(
nodes: ReadonlyArray<ILiquorTreeNewNode>,
handler: (node: ILiquorTreeNewNode) => void) {
for (const node of nodes) {
handler(node);
if (node.children) {
recurseDown(node.children, handler);
}
private async getLiquorTreeApiAsync(): Promise<ILiquorTree> {
const accessor = (): ILiquorTree => {
const uiElement = this.$refs.treeElement;
return uiElement ? (uiElement as any).tree : undefined;
};
const treeElement = await tryUntilDefinedAsync(accessor, 5, 20); // Wait for it to render
if (!treeElement) {
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
}
return treeElement;
}
}
function updateState(
old: ILiquorTreeNodeState,
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
return {...old, ...getNewState(node, selectedNodeIds)};
}
function recurseDown(
nodes: ReadonlyArray<ILiquorTreeNewNode>,
handler: (node: ILiquorTreeNewNode) => void) {
for (const node of nodes) {
handler(node);
if (node.children) {
recurseDown(node.children, handler);
}
}
}
async function tryUntilDefinedAsync<T>(
accessor: () => T | undefined,
delayInMs: number, maxTries: number): Promise<T | undefined> {
const sleepAsync = () => new Promise(((resolve) => setTimeout(resolve, delayInMs)));
let triesLeft = maxTries;
let value: T;
while (triesLeft !== 0) {
value = accessor();
if (value) {
return value;
}
triesLeft--;
await sleepAsync();
}
return value;
}
</script>

View File

@@ -6,14 +6,13 @@
</template>
<script lang="ts">
import { Component, Prop, Emit } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Component, Prop, Emit, Vue } from 'vue-property-decorator';
import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective';
@Component({
directives: { NonCollapsing },
})
export default class SelectableOption extends StatefulVue {
export default class SelectableOption extends Vue {
@Prop() public enabled: boolean;
@Prop() public label: string;
@Emit('click') public onClicked() { return; }

View File

@@ -48,7 +48,8 @@ import SelectableOption from './SelectableOption.vue';
import { IScript } from '@/domain/IScript';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
enum SelectionState {
Standard,
@@ -66,80 +67,80 @@ export default class TheSelector extends StatefulVue {
public SelectionState = SelectionState;
public currentSelection = SelectionState.None;
public async mounted() {
const context = await this.getCurrentContextAsync();
this.updateSelections(context);
this.beginReactingToChanges(context);
}
public async selectAsync(type: SelectionState): Promise<void> {
if (this.currentSelection === type) {
return;
}
const context = await this.getCurrentContextAsync();
selectType(context, type);
selectType(context.state, type);
}
private updateSelections(context: IApplicationContext) {
this.currentSelection = getCurrentSelectionState(context);
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
this.updateSelections(newState);
newState.selection.changed.on(() => this.updateSelections(newState));
if (oldState) {
oldState.selection.changed.on(() => this.updateSelections(oldState));
}
}
private beginReactingToChanges(context: IApplicationContext) {
context.state.selection.changed.on(() => {
this.updateSelections(context);
});
private updateSelections(state: ICategoryCollectionState) {
this.currentSelection = getCurrentSelectionState(state);
}
}
interface ITypeSelector {
isSelected: (context: IApplicationContext) => boolean;
select: (context: IApplicationContext) => void;
isSelected: (state: ICategoryCollectionState) => boolean;
select: (state: ICategoryCollectionState) => void;
}
const selectors = new Map<SelectionState, ITypeSelector>([
[SelectionState.None, {
select: (context) =>
context.state.selection.deselectAll(),
isSelected: (context) =>
context.state.selection.totalSelected === 0,
select: (state) =>
state.selection.deselectAll(),
isSelected: (state) =>
state.selection.totalSelected === 0,
}],
[SelectionState.Standard, {
select: (context) =>
context.state.selection.selectOnly(
context.collection.getScriptsByLevel(RecommendationLevel.Standard)),
isSelected: (context) =>
hasAllSelectedLevelOf(RecommendationLevel.Standard, context),
select: (state) =>
state.selection.selectOnly(
state.collection.getScriptsByLevel(RecommendationLevel.Standard)),
isSelected: (state) =>
hasAllSelectedLevelOf(RecommendationLevel.Standard, state),
}],
[SelectionState.Strict, {
select: (context) =>
context.state.selection.selectOnly(context.collection.getScriptsByLevel(RecommendationLevel.Strict)),
isSelected: (context) =>
hasAllSelectedLevelOf(RecommendationLevel.Strict, context),
select: (state) =>
state.selection.selectOnly(state.collection.getScriptsByLevel(RecommendationLevel.Strict)),
isSelected: (state) =>
hasAllSelectedLevelOf(RecommendationLevel.Strict, state),
}],
[SelectionState.All, {
select: (context) =>
context.state.selection.selectAll(),
isSelected: (context) =>
context.state.selection.totalSelected === context.collection.totalScripts,
select: (state) =>
state.selection.selectAll(),
isSelected: (state) =>
state.selection.totalSelected === state.collection.totalScripts,
}],
]);
function selectType(context: IApplicationContext, type: SelectionState) {
function selectType(state: ICategoryCollectionState, type: SelectionState) {
const selector = selectors.get(type);
selector.select(context);
selector.select(state);
}
function getCurrentSelectionState(context: IApplicationContext): SelectionState {
function getCurrentSelectionState(state: ICategoryCollectionState): SelectionState {
for (const [type, selector] of Array.from(selectors.entries())) {
if (selector.isSelected(context)) {
if (selector.isSelected(state)) {
return type;
}
}
return SelectionState.Custom;
}
function hasAllSelectedLevelOf(level: RecommendationLevel, context: IApplicationContext) {
const scripts = context.collection.getScriptsByLevel(level);
const selectedScripts = context.state.selection.selectedScripts;
function hasAllSelectedLevelOf(level: RecommendationLevel, state: ICategoryCollectionState) {
const scripts = state.collection.getScriptsByLevel(level);
const selectedScripts = state.selection.selectedScripts;
return areAllSelected(scripts, selectedScripts);
}

View File

@@ -0,0 +1,74 @@
<template>
<div class="container">
<div v-for="os in this.allOses" :key="os.name">
<span
class="name"
v-bind:class="{ 'current': currentOs === os.os }"
v-on:click="changeOsAsync(os.os)">
{{ os.name }}
</span>
</div>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { StatefulVue } from '@/presentation/StatefulVue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
@Component
export default class TheOsChanger extends StatefulVue {
public allOses: Array<{ name: string, os: OperatingSystem }> = [];
public currentOs: OperatingSystem = undefined;
public async changeOsAsync(newOs: OperatingSystem) {
const context = await this.getCurrentContextAsync();
context.changeContext(newOs);
}
protected initialize(app: IApplication): void {
this.allOses = app.getSupportedOsList()
.map((os) => ({ os, name: renderOsName(os) }));
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
this.currentOs = newState.os;
this.$forceUpdate(); // v-bind:class is not updated otherwise
}
}
function renderOsName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS (preview)';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/colors.scss";
.container {
font-family: $normal-font;
display: flex;
align-items: center;
div + div::before {
content: "|";
margin-left: 0.5rem;
}
.name {
&:not(.current) {
cursor: pointer;
&:hover {
font-weight: bold;
text-decoration: underline;
}
}
&.current {
color: $gray;
}
}
}
</style>

View File

@@ -1,10 +1,12 @@
<template>
<div>
<div class="help-container">
<TheSelector />
<div class="heading">
<TheSelector class="item"/>
<TheOsChanger class="item"/>
<TheGrouper
class="item"
v-on:groupingChanged="onGroupingChanged($event)"
v-show="!this.isSearching" />
v-if="!this.isSearching" />
</div>
<div class="scripts">
<div v-if="!isSearching">
@@ -37,72 +39,93 @@
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Grouping } from './Grouping/Grouping';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import TheGrouper from '@/presentation/Scripts/Grouping/TheGrouper.vue';
import TheSelector from '@/presentation/Scripts/Selector/TheSelector.vue';
import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/Scripts/Cards/CardList.vue';
import TheGrouper from '@/presentation/Scripts/Grouping/TheGrouper.vue';
import TheOsChanger from '@/presentation/Scripts/TheOsChanger.vue';
import TheSelector from '@/presentation/Scripts/Selector/TheSelector.vue';
import ScriptsTree from '@/presentation/Scripts/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/Scripts/Cards/CardList.vue';
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Grouping } from './Grouping/Grouping';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
import { IEventSubscription } from '@/infrastructure/Events/ISubscription';
/** Shows content of single category or many categories */
@Component({
components: {
TheGrouper,
TheSelector,
ScriptsTree,
CardList,
/** Shows content of single category or many categories */
@Component({
components: {
TheGrouper,
TheSelector,
ScriptsTree,
CardList,
TheOsChanger,
},
filters: {
threeDotsTrim(query: string) {
const threshold = 30;
if (query.length <= threshold - 3) {
return query;
}
return `${query.substr(0, threshold)}...`;
},
filters: {
threeDotsTrim(query: string) {
const threshold = 30;
if (query.length <= threshold - 3) {
return query;
}
return `${query.substr(0, threshold)}...`;
},
},
})
export default class TheScripts extends StatefulVue {
public repositoryUrl = '';
public Grouping = Grouping; // Make it accessible from view
public currentGrouping = Grouping.Cards;
public searchQuery = '';
public isSearching = false;
public searchHasMatches = false;
},
})
export default class TheScripts extends StatefulVue {
public repositoryUrl = '';
public Grouping = Grouping; // Make it accessible from view
public currentGrouping = Grouping.Cards;
public searchQuery = '';
public isSearching = false;
public searchHasMatches = false;
public async mounted() {
const context = await this.getCurrentContextAsync();
this.repositoryUrl = context.app.info.repositoryWebUrl;
const filter = context.state.filter;
filter.filterRemoved.on(() => {
this.isSearching = false;
});
filter.filtered.on((result: IFilterResult) => {
this.searchQuery = result.query;
this.isSearching = true;
this.searchHasMatches = result.hasAnyMatches();
});
}
private listeners = new Array<IEventSubscription>();
public async clearSearchQueryAsync() {
const context = await this.getCurrentContextAsync();
const filter = context.state.filter;
filter.removeFilter();
}
public onGroupingChanged(group: Grouping) {
this.currentGrouping = group;
}
public destroyed() {
this.unsubscribeAll();
}
public async clearSearchQueryAsync() {
const context = await this.getCurrentContextAsync();
const filter = context.state.filter;
filter.removeFilter();
}
public onGroupingChanged(group: Grouping) {
this.currentGrouping = group;
}
protected initialize(app: IApplication): void {
this.repositoryUrl = app.info.repositoryWebUrl;
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.unsubscribeAll();
this.subscribe(newState);
}
private subscribe(state: ICategoryCollectionState) {
this.listeners.push(state.filter.filterRemoved.on(() => {
this.isSearching = false;
}));
state.filter.filtered.on((result: IFilterResult) => {
this.searchQuery = result.query;
this.isSearching = true;
this.searchHasMatches = result.hasAnyMatches();
});
}
private unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
$inner-margin: 4px;
.scripts {
margin-top:10px;
margin-top: $inner-margin;
.tree {
padding-left: 3%;
padding-top: 15px;
@@ -151,9 +174,22 @@
}
}
.help-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
.heading {
margin-top: $inner-margin;
display: flex;
flex-wrap: wrap;
.item {
flex: 1;
white-space: nowrap;
display: flex;
justify-content: center;
margin: 0 5px 0 5px;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
}
</style>
</style>

View File

@@ -1,13 +1,40 @@
import { Vue } from 'vue-property-decorator';
import { Component, Vue } from 'vue-property-decorator';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { buildContext } from '@/application/Context/ApplicationContextProvider';
import { IApplicationContextChangedEvent } from '../application/Context/IApplicationContext';
import { IApplication } from '@/domain/IApplication';
import { ICategoryCollectionState } from '../application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '../infrastructure/Events/ISubscription';
// @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91
@Component
export abstract class StatefulVue extends Vue {
private static instance = new AsyncLazy<IApplicationContext>(
public static instance = new AsyncLazy<IApplicationContext>(
() => Promise.resolve(buildContext()));
private listener: IEventSubscription;
public async mounted() {
const context = await this.getCurrentContextAsync();
this.listener = context.contextChanged.on((event) => this.handleStateChangedEvent(event));
this.initialize(context.app);
this.handleCollectionState(context.state, undefined);
}
public destroyed() {
if (this.listener) {
this.listener.unsubscribe();
}
}
protected abstract initialize(app: IApplication): void;
protected abstract handleCollectionState(
newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined): void;
protected getCurrentContextAsync(): Promise<IApplicationContext> {
return StatefulVue.instance.getValueAsync();
}
private handleStateChangedEvent(event: IApplicationContextChangedEvent) {
this.handleCollectionState(event.newState, event.oldState);
}
}

View File

@@ -7,27 +7,14 @@ import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import ace from 'ace-builds';
import 'ace-builds/webpack-resolver';
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
const NothingChosenCode =
new CodeBuilder()
.appendCommentLine('privacy.sexy — 🔐 Enforce privacy & security best-practices on Windows')
.appendLine()
.appendCommentLine('-- 🤔 How to use')
.appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.')
.appendCommentLine(' 📙 On top left, you can apply predefined selections for privacy level you\'d like.')
.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()
.appendCommentLine('-- 🧐 Why privacy.sexy')
.appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.')
.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(' ✔️ Open-source and free (both free as in beer and free as in speech).')
.toString();
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
import { IEventSubscription } from '@/infrastructure/Events/ISubscription';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
@Component
export default class TheCodeArea extends StatefulVue {
@@ -35,21 +22,41 @@ export default class TheCodeArea extends StatefulVue {
private editor!: ace.Ace.Editor;
private currentMarkerId?: number;
private codeListener: IEventSubscription;
@Prop() private theme!: string;
public async mounted() {
const context = await this.getCurrentContextAsync();
this.editor = initializeEditor(this.theme, this.editorId, context.collection.scripting.language);
const appCode = context.state.code;
this.editor.setValue(appCode.current || NothingChosenCode, 1);
appCode.changed.on((code) => this.updateCode(code));
public destroyed() {
this.unsubscribeCodeListening();
this.destroyEditor();
}
private updateCode(event: ICodeChangedEvent) {
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.destroyEditor();
this.editor = initializeEditor(this.theme, this.editorId, newState.collection.scripting.language);
const appCode = newState.code;
this.editor.setValue(appCode.current || getDefaultCode(newState.collection.scripting.language), 1);
this.unsubscribeCodeListening();
this.subscribe(appCode);
}
private subscribe(appCode: IApplicationCode) {
this.codeListener = appCode.changed.on((code) => this.updateCodeAsync(code));
}
private unsubscribeCodeListening() {
if (this.codeListener) {
this.codeListener.unsubscribe();
}
}
private async updateCodeAsync(event: ICodeChangedEvent) {
this.removeCurrentHighlighting();
if (event.isEmpty()) {
this.editor.setValue(NothingChosenCode, 1);
const context = await this.getCurrentContextAsync();
const defaultCode = getDefaultCode(context.state.collection.scripting.language);
this.editor.setValue(defaultCode, 1);
return;
}
this.editor.setValue(event.code, 1);
@@ -60,7 +67,6 @@ export default class TheCodeArea extends StatefulVue {
this.reactToChanges(event, event.changedScripts);
}
}
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts
.map((script) => event.getScriptPositionInCode(script));
@@ -73,19 +79,16 @@ export default class TheCodeArea extends StatefulVue {
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;
@@ -93,6 +96,11 @@ export default class TheCodeArea extends StatefulVue {
this.editor.session.removeMarker(this.currentMarkerId);
this.currentMarkerId = undefined;
}
private destroyEditor() {
if (this.editor) {
this.editor.destroy();
}
}
}
function initializeEditor(theme: string, editorId: string, language: ScriptingLanguage): ace.Ace.Editor {
@@ -109,13 +117,32 @@ function initializeEditor(theme: string, editorId: string, language: ScriptingLa
function getLanguage(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return 'batchfile';
case ScriptingLanguage.batchfile: return 'batchfile';
case ScriptingLanguage.shellscript: return 'sh';
default:
throw new Error('unkown language');
throw new Error('unknown language');
}
}
function getDefaultCode(language: ScriptingLanguage): string {
return new CodeBuilderFactory()
.create(language)
.appendCommentLine('privacy.sexy — 🔐 Enforce privacy & security best-practices on Windows and macOS')
.appendLine()
.appendCommentLine('-- 🤔 How to use')
.appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.')
.appendCommentLine(' 📙 On top left, you can apply predefined selections for privacy level you\'d like.')
.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()
.appendCommentLine('-- 🧐 Why privacy.sexy')
.appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.')
.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(' ✔️ Open-source and free (both free as in beer and free as in speech).')
.toString();
}
</script>
<style lang="scss">

View File

@@ -1,92 +0,0 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
:text="this.isDesktop ? 'Save' : 'Download'"
v-on:click="saveCodeAsync"
icon-prefix="fas"
:icon-name="this.isDesktop ? 'save' : 'file-download'">
</IconButton>
<IconButton
text="Copy"
v-on:click="copyCodeAsync"
icon-prefix="fas" icon-name="copy">
</IconButton>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import IconButton from './IconButton.vue';
import { Environment } from '@/application/Environment/Environment';
import { IApplicationCode } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
@Component({
components: {
IconButton,
},
})
export default class TheCodeButtons extends StatefulVue {
public hasCode = false;
public isDesktop = false;
public async mounted() {
const code = await this.getCurrentCodeAsync();
this.hasCode = code.current && code.current.length > 0;
code.changed.on((newCode) => {
this.hasCode = newCode && newCode.code.length > 0;
});
this.isDesktop = Environment.CurrentEnvironment.isDesktop;
}
public async copyCodeAsync() {
const code = await this.getCurrentCodeAsync();
Clipboard.copyText(code.current);
}
public async saveCodeAsync() {
const context = await this.getCurrentContextAsync();
saveCode(context);
}
private async getCurrentCodeAsync(): Promise<IApplicationCode> {
const context = await this.getCurrentContextAsync();
const code = context.state.code;
return code;
}
}
function saveCode(context: IApplicationContext) {
const fileName = `privacy-script.${context.collection.scripting.fileExtension}`;
const content = context.state.code.current;
const type = getType(context.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
default:
throw new Error('unknown file type');
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
.container {
display: flex;
flex-direction: row;
justify-content: center;
}
.container > * + * {
margin-left: 30px;
}
</style>

View File

@@ -13,11 +13,12 @@ import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
@Component
export default class DownloadUrlListItem extends StatefulVue {
@Prop() public operatingSystem!: OperatingSystem;
public OperatingSystem = OperatingSystem;
public downloadUrl: string = '';
public operatingSystemName: string = '';
@@ -37,6 +38,13 @@ export default class DownloadUrlListItem extends StatefulVue {
this.hasCurrentOsDesktopVersion = hasDesktopVersion(currentOs);
}
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
return;
}
private async getDownloadUrlAsync(os: OperatingSystem): Promise<string> {
const context = await this.getCurrentContextAsync();
return context.app.info.getDownloadUrl(os);

View File

@@ -34,24 +34,22 @@
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Environment } from '@/application/Environment/Environment';
import { IApplication } from '@/domain/IApplication';
@Component
export default class TheFooter extends StatefulVue {
export default class PrivacyPolicy extends StatefulVue {
public repositoryUrl: string = '';
public feedbackUrl: string = '';
public isDesktop: boolean = false;
public isDesktop = Environment.CurrentEnvironment.isDesktop;
constructor() {
super();
this.isDesktop = Environment.CurrentEnvironment.isDesktop;
}
public async mounted() {
const context = await this.getCurrentContextAsync();
const info = context.app.info;
protected initialize(app: IApplication): void {
const info = app.info;
this.repositoryUrl = info.repositoryWebUrl;
this.feedbackUrl = info.feedbackUrl;
}
protected handleCollectionState(): void {
return;
}
}
</script>

View File

@@ -52,6 +52,8 @@ import { StatefulVue } from '@/presentation/StatefulVue';
import { Environment } from '@/application/Environment/Environment';
import PrivacyPolicy from './PrivacyPolicy.vue';
import DownloadUrlList from './DownloadUrlList.vue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
@Component({
components: {
@@ -73,15 +75,18 @@ export default class TheFooter extends StatefulVue {
this.isDesktop = Environment.CurrentEnvironment.isDesktop;
}
public async mounted() {
const context = await this.getCurrentContextAsync();
const info = context.app.info;
protected initialize(app: IApplication): void {
const info = app.info;
this.version = info.version;
this.homepageUrl = info.homepage;
this.repositoryUrl = info.repositoryWebUrl;
this.releaseUrl = info.releaseUrl;
this.feedbackUrl = info.feedbackUrl;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
return;
}
}
</script>

View File

@@ -1,11 +1,13 @@
<template>
<div id="container">
<h1 class="child title" >{{ title }}</h1>
<h2 class="child subtitle">Enforce privacy & security on Windows</h2>
<h2 class="child subtitle">Enforce privacy & security on Windows and macOS</h2>
</div>
</template>
<script lang="ts">
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
import { Component } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
@@ -14,9 +16,11 @@ export default class TheHeader extends StatefulVue {
public title = '';
public subtitle = '';
public async mounted() {
const context = await this.getCurrentContextAsync();
this.title = context.app.info.name;
protected initialize(app: IApplication): void {
this.title = app.info.name;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
return;
}
}
</script>

View File

@@ -13,7 +13,11 @@
import { Component, Watch } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective';
import { IUserFilter } from '@/application/Context/State/ICategoryCollectionState';
import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IApplication } from '@/domain/IApplication';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/ISubscription';
@Component( {
directives: { NonCollapsing },
@@ -23,12 +27,7 @@ export default class TheSearchBar extends StatefulVue {
public searchPlaceHolder = 'Search';
public searchQuery = '';
public async mounted() {
const context = await this.getCurrentContextAsync();
const totalScripts = context.collection.totalScripts;
this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
this.beginReacting(context.state.filter);
}
private readonly listeners = new Array<IEventSubscription>();
@Watch('searchQuery')
public async updateFilterAsync(newFilter: |string) {
@@ -40,10 +39,34 @@ export default class TheSearchBar extends StatefulVue {
filter.setFilter(newFilter);
}
}
public destroyed() {
this.unsubscribeAll();
}
private beginReacting(filter: IUserFilter) {
filter.filtered.on((result) => this.searchQuery = result.query);
filter.filterRemoved.on(() => this.searchQuery = '');
protected initialize(app: IApplication): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined) {
const totalScripts = newState.collection.totalScripts;
this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
this.searchQuery = newState.filter.currentFilter ? newState.filter.currentFilter.query : '';
this.unsubscribeAll();
this.subscribe(newState.filter);
}
private subscribe(filter: IUserFilter) {
this.listeners.push(filter.filtered.on((result) => this.handleFiltered(result)));
this.listeners.push(filter.filterRemoved.on(() => this.handleFilterRemoved()));
}
private unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
}
private handleFiltered(result: IFilterResult) {
this.searchQuery = result.query;
}
private handleFilterRemoved() {
this.searchQuery = '';
}
}
</script>