Files
privacy.sexy/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts
undergroundwires c138f74460 Refactor to unify scripts/categories as Executable
This commit consolidates scripts and categories under a unified
'Executable' concept. This simplifies the architecture and improves code
readability.

- Introduce subfolders within `src/domain` to segregate domain elements.
- Update class and interface names by removing the 'I' prefix in
  alignment with new coding standards.
- Replace 'Node' with 'Executable' to clarify usage; reserve 'Node'
  exclusively for the UI's tree component.
2024-06-12 12:36:40 +02:00

176 lines
5.6 KiB
TypeScript

import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import type { Script } from '@/domain/Executables/Script/Script';
import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
import type { SelectedScript } from './SelectedScript';
const DEBOUNCE_DELAY_IN_MS = 100;
export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeCommand>;
export class DebouncedScriptSelection implements ScriptSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: Repository<string, SelectedScript>;
public readonly processChanges: ScriptSelection['processChanges'];
constructor(
private readonly collection: ICategoryCollection,
selectedScripts: ReadonlyArray<SelectedScript>,
debounce: DebounceFunction = batchedDebounce,
) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
this.processChanges = debounce(
(batchedRequests: readonly ScriptSelectionChangeCommand[]) => {
const consolidatedChanges = batchedRequests.flatMap((request) => request.changes);
this.processScriptChanges(consolidatedChanges);
},
DEBOUNCE_DELAY_IN_MS,
);
}
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
public get selectedScripts(): readonly SelectedScript[] {
return this.scripts.getItems();
}
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new UserSelectedScript(script, false));
if (scriptsToSelect.length === 0) {
return;
}
this.processChanges({
changes: scriptsToSelect.map((script): ScriptSelectionChange => ({
scriptId: script.id,
newStatus: {
isSelected: true,
isReverted: false,
},
})),
});
}
public deselectAll(): void {
if (this.scripts.length === 0) {
return;
}
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
this.processChanges({
changes: selectedScriptIds.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: false,
},
})),
});
}
public selectOnly(scripts: readonly Script[]): void {
assertNonEmptyScriptSelection(scripts);
this.processChanges({
changes: [
...getScriptIdsToBeDeselected(this.scripts, scripts)
.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: false,
},
})),
...getScriptIdsToBeSelected(this.scripts, scripts)
.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: true,
isReverted: false,
},
})),
],
});
}
private processScriptChanges(changes: readonly ScriptSelectionChange[]): void {
let totalChanged = 0;
for (const change of changes) {
totalChanged += this.applyChange(change);
}
if (totalChanged > 0) {
this.changed.notify(this.scripts.getItems());
}
}
private applyChange(change: ScriptSelectionChange): number {
const script = this.collection.getScript(change.scriptId);
if (change.newStatus.isSelected) {
return this.addOrUpdateScript(script.id, change.newStatus.isReverted);
}
return this.removeScript(script.id);
}
private addOrUpdateScript(scriptId: string, revert: boolean): number {
const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) {
this.scripts.addItem(selectedScript);
return 1;
}
const existingSelectedScript = this.scripts.getById(selectedScript.id);
if (equals(selectedScript, existingSelectedScript)) {
return 0;
}
this.scripts.addOrUpdateItem(selectedScript);
return 1;
}
private removeScript(scriptId: string): number {
if (!this.scripts.exists(scriptId)) {
return 0;
}
this.scripts.removeItem(scriptId);
return 1;
}
}
function assertNonEmptyScriptSelection(selectedItems: readonly Script[]) {
if (selectedItems.length === 0) {
throw new Error('Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.');
}
}
function getScriptIdsToBeSelected(
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly Script[],
): string[] {
return desiredScripts
.filter((script) => !existingItems.exists(script.id))
.map((script) => script.id);
}
function getScriptIdsToBeDeselected(
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly Script[],
): string[] {
return existingItems
.getItems()
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id))
.map((script) => script.id);
}
function equals(a: SelectedScript, b: SelectedScript): boolean {
return a.script.equals(b.script.id) && a.revert === b.revert;
}