diff --git a/apps/mocksi-lite/content/ContentApp.tsx b/apps/mocksi-lite/content/ContentApp.tsx index 35238ee2..1075e882 100644 --- a/apps/mocksi-lite/content/ContentApp.tsx +++ b/apps/mocksi-lite/content/ContentApp.tsx @@ -1,8 +1,8 @@ -import { htmlElementToJson } from "@repo/reactor"; import { useContext, useEffect, useState } from "react"; import useShadow from "use-shadow-dom"; import type { Recording } from "../background"; import { MOCKSI_LAST_PAGE_DOM } from "../consts"; +import Reactor from "../reactorSingleton"; import { extractStyles, logout } from "../utils"; import { AppEvent, @@ -56,7 +56,7 @@ function ShadowContentApp({ email, initialState, isOpen }: ContentProps) { useEffect(() => { let dom_as_json = ""; try { - dom_as_json = JSON.stringify(htmlElementToJson(document.body)); + dom_as_json = JSON.stringify(Reactor.exportDOM(document.body)); } catch (e) { console.error("Error setting last page dom:", e); } @@ -93,7 +93,7 @@ function ShadowContentApp({ email, initialState, isOpen }: ContentProps) { case AppState.CREATE: return ; case AppState.EDITING: - return ; + return ; case AppState.INIT: case AppState.UNAUTHORIZED: // When initializing the application and loading state we want to show nothing, potentially this is a loading UI in the future diff --git a/apps/mocksi-lite/content/EditMode/actions.ts b/apps/mocksi-lite/content/EditMode/actions.ts index c28c37f2..eb17c57b 100644 --- a/apps/mocksi-lite/content/EditMode/actions.ts +++ b/apps/mocksi-lite/content/EditMode/actions.ts @@ -1,34 +1,59 @@ import { DOMManipulator } from "@repo/dodom"; +import type { ModificationRequest } from "@repo/reactor"; +import Reactor from "../../reactorSingleton"; import type { ApplyAlteration } from "../Toast/EditToast"; import { getHighlighter } from "./highlighter"; -export function cancelEditWithoutChanges(nodeWithTextArea: HTMLElement | null) { +export function cancelEditWithoutChanges( + nodeWithTextArea: HTMLElement | null, +): Text | null { if (nodeWithTextArea) { const parentElement = nodeWithTextArea?.parentElement; + const newChild = document.createTextNode(nodeWithTextArea.innerText); + // cancel previous input. - nodeWithTextArea?.parentElement?.replaceChild( - document.createTextNode(nodeWithTextArea.innerText), - nodeWithTextArea, - ); + nodeWithTextArea?.parentElement?.replaceChild(newChild, nodeWithTextArea); parentElement?.normalize(); + return newChild; } + + return null; } -export function applyChanges( +export async function applyChanges( nodeWithTextArea: HTMLElement | null, newValue: string, oldValue: string, applyAlteration: ApplyAlteration, ) { if (nodeWithTextArea) { - cancelEditWithoutChanges(nodeWithTextArea); + const newChildNode = cancelEditWithoutChanges(nodeWithTextArea); + + const modification: ModificationRequest = { + description: `Change ${oldValue} to ${newValue}`, + modifications: [ + { + //selector: getCssSelector(newChildNode?.parentElement), + selector: "body", + action: "replaceAll", + content: `/${oldValue}/${newValue}/`, + }, + ], + }; + console.log(modification); + + const modifications = await Reactor.pushModification(modification); + for (const modification of modifications) { + modification.setHighlight(true); + } + // TODO: check if we should keep the singleton behavior we had before - const domManipulator = new DOMManipulator( - fragmentTextNode, - getHighlighter(), - applyAlteration, - ); - domManipulator.addPattern(oldValue, newValue); + // const domManipulator = new DOMManipulator( + // fragmentTextNode, + // getHighlighter(), + // applyAlteration, + // ); + // domManipulator.addPattern(oldValue, newValue); } } diff --git a/apps/mocksi-lite/content/EditMode/editMode.ts b/apps/mocksi-lite/content/EditMode/editMode.ts index ac1093c0..e4a3664e 100644 --- a/apps/mocksi-lite/content/EditMode/editMode.ts +++ b/apps/mocksi-lite/content/EditMode/editMode.ts @@ -2,6 +2,7 @@ import { MOCKSI_READONLY_STATE } from "../../consts"; import type { ApplyAlteration } from "../Toast/EditToast"; import { applyImageChanges } from "./actions"; import { decorate } from "./decorator"; +import { getHighlighter } from "./highlighter"; function openImageUploadModal(targetedElement: HTMLImageElement) { // Create a container for the shadow DOM diff --git a/apps/mocksi-lite/content/EditMode/highlighter.ts b/apps/mocksi-lite/content/EditMode/highlighter.ts index f61cf295..abcd5601 100644 --- a/apps/mocksi-lite/content/EditMode/highlighter.ts +++ b/apps/mocksi-lite/content/EditMode/highlighter.ts @@ -1,14 +1,23 @@ +import type { Highlighter } from "@repo/reactor"; import { v4 as uuidv4 } from "uuid"; import { MOCKSI_HIGHLIGHTER_ID } from "../../consts"; import { decorate } from "./decorator"; import { applyStyles } from "./utils"; -class Highlighter { +class HighlighterImpl implements Highlighter { private contentRanger = document.createRange(); private highlightedNodes: { highlightedElem: Node; highlightId: string }[] = []; highlightNode = (elementToHighlight: Node) => { + const visible = elementToHighlight.parentElement + ? elementToHighlight.parentElement.offsetWidth > 0 || + elementToHighlight.parentElement.offsetHeight > 0 + : false; + if (!visible) { + return; + } + this.contentRanger.selectNodeContents(elementToHighlight); const { x, y, width, height } = this.contentRanger.getBoundingClientRect() || {}; @@ -67,11 +76,11 @@ class Highlighter { }; } -let ContentHighlighter: Highlighter; +let ContentHighlighter: HighlighterImpl; export const getHighlighter = () => { if (!ContentHighlighter) { - ContentHighlighter = new Highlighter(); + ContentHighlighter = new HighlighterImpl(); } return ContentHighlighter; }; diff --git a/apps/mocksi-lite/content/Toast/ChatToast.tsx b/apps/mocksi-lite/content/Toast/ChatToast.tsx index 4f436ccf..15a405b4 100644 --- a/apps/mocksi-lite/content/Toast/ChatToast.tsx +++ b/apps/mocksi-lite/content/Toast/ChatToast.tsx @@ -1,12 +1,18 @@ -import { htmlElementToJson, modifyDom } from "@repo/reactor"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useContext, + useRef, + useState, +} from "react"; import Toast from "."; import { CloseButton } from "../../common/Button"; import { ChatWebSocketURL, MOCKSI_RECORDING_STATE } from "../../consts"; import editIcon from "../../public/edit-icon.png"; import mocksiLogo from "../../public/icon/icon48.png"; +import Reactor from "../../reactorSingleton"; import { getEmail, getLastPageDom } from "../../utils"; -import { AppState } from "../AppStateContext"; +import { AppEvent, AppState, AppStateContext } from "../AppStateContext"; interface Message { content: string; @@ -32,6 +38,7 @@ interface DOMModification { const ChatToast: React.FC = React.memo( ({ close, onChangeState }) => { + const { dispatch, state } = useContext(AppStateContext); const [messages, setMessages] = useState([]); const [isTyping, setIsTyping] = useState(false); const [inputValue, setInputValue] = useState(""); @@ -99,10 +106,10 @@ const ChatToast: React.FC = React.memo( ]); try { - await modifyDom(document, data); + await Reactor.pushModification(data); setIsTyping(false); await new Promise((resolve) => window.setTimeout(resolve, 1000)); - const updatedDomJson = htmlElementToJson(document.body); + const updatedDomJson = Reactor.exportDOM(); setDomData(JSON.stringify(updatedDomJson)); } catch (error) { console.error("Error applying DOM modifications:", error); @@ -124,7 +131,7 @@ const ChatToast: React.FC = React.memo( }, []); useEffect(() => { - const dom_as_json = htmlElementToJson(document.body); + const dom_as_json = Reactor.exportDOM(document.body); setDomData(JSON.stringify(dom_as_json)); connectWebSocket(); @@ -172,15 +179,12 @@ const ChatToast: React.FC = React.memo( return (
{ - await chrome.storage.local.set({ - [MOCKSI_RECORDING_STATE]: AppState.CREATE, - }); - close(); + dispatch({ event: AppEvent.START_EDITING }); }} />
diff --git a/apps/mocksi-lite/content/Toast/EditToast.tsx b/apps/mocksi-lite/content/Toast/EditToast.tsx index dcac224b..cb113fad 100644 --- a/apps/mocksi-lite/content/Toast/EditToast.tsx +++ b/apps/mocksi-lite/content/Toast/EditToast.tsx @@ -1,12 +1,13 @@ import { useCallback, useContext, useEffect, useState } from "react"; import type { Alteration } from "../../background"; -import { CloseButton } from "../../common/Button"; +import Button, { CloseButton, Variant } from "../../common/Button"; import TextField from "../../common/TextField"; import { MOCKSI_ALTERATIONS, MOCKSI_READONLY_STATE, MOCKSI_RECORDING_ID, } from "../../consts"; +import Reactor from "../../reactorSingleton"; import { getAlterations, loadAlterations, @@ -29,6 +30,7 @@ import IframeWrapper from "../IframeWrapper"; import Toast from "./index"; type EditToastProps = { + onChat: () => void; initialReadOnlyState?: boolean; }; @@ -39,6 +41,8 @@ export type ApplyAlteration = ( type: "text" | "image", ) => void; +const iconClassName = "mw-mr-1 mw-w-4 mw-h-4"; + const observeUrlChange = (onChange: () => void) => { let oldHref = document.location.href; const body = document.querySelector("body"); @@ -57,7 +61,7 @@ const observeUrlChange = (onChange: () => void) => { observer.observe(body, { childList: true, subtree: true }); }; -const EditToast = ({ initialReadOnlyState }: EditToastProps) => { +const EditToast = ({ onChat, initialReadOnlyState }: EditToastProps) => { const { dispatch, state } = useContext(AppStateContext); const [areChangesHighlighted, setAreChangesHighlighted] = useState(true); @@ -115,6 +119,10 @@ const EditToast = ({ initialReadOnlyState }: EditToastProps) => { setUrl(document.location.href); }); + if (!Reactor.isAttached()) { + await Reactor.attach(document, getHighlighter()); + } + const results = await chrome.storage.local.get([MOCKSI_READONLY_STATE]); // If value exists and is true or if the value doesn't exist at all, apply read-only mode @@ -137,6 +145,8 @@ const EditToast = ({ initialReadOnlyState }: EditToastProps) => { await persistModifications(recordingId, alterations); } + await Reactor.detach(); + undoModifications(alterations); cancelEditWithoutChanges(document.getElementById("mocksiSelectedText")); disableReadOnlyMode(); @@ -292,6 +302,27 @@ const EditToast = ({ initialReadOnlyState }: EditToastProps) => { {isReadOnlyModeEnabled ? "Disable" : "Enable"} Read-Only Mode + +
+ +
{ - const userRequest = JSON.stringify({ + const userRequest: ModificationRequest = { + description: "Update timestamps", modifications: [ { selector: timestamp.selector, action: "updateTimestampReferences", timestampRef: { - recordedAt: createdAt?.toString(), + recordedAt: createdAt?.toString() || now.toISOString(), currentTime: now.toISOString(), }, }, ], - }); + }; console.log("userRequest", userRequest); - const contents = document.querySelectorAll(timestamp.selector); - for (const content of contents) { - try { - const result = await modifyHtml(content.outerHTML, userRequest); - const parser = new DOMParser(); - const doc = parser.parseFromString(result, "text/html"); - - if (doc.body) { - // Replace the original content with the modified content - content.outerHTML = doc.body.innerHTML; - } else { - console.error("Parsed document body is null or undefined"); - } - } catch (error) { - console.error( - "Error updating innerHTML for", - timestamp.selector, - error, - ); - } - } + + await Reactor.pushModification(userRequest); }), ); }; @@ -428,43 +411,6 @@ export const recordingLabel = (currentStatus: AppState) => { } }; -export const innerHTMLToJson = (innerHTML: string): string => { - const parser = new DOMParser(); - const doc = parser.parseFromString(innerHTML, "text/html"); - - function elementToJson(element: Element): object { - // biome-ignore lint/suspicious/noExplicitAny: - const obj: any = {}; - - obj.tag = element.tagName.toLowerCase(); - - if (element.attributes.length > 0) { - obj.attributes = {}; - for (const attr of Array.from(element.attributes)) { - obj.attributes[attr.name] = attr.value; - } - } - - if (element.children.length > 0) { - obj.children = Array.from(element.children).map((child) => - elementToJson(child), - ); - } else { - obj.text = element.textContent; - } - - return obj; - } - - // Convert the body of the parsed document to JSON - const json = Array.from(doc.body.children).map((child) => - elementToJson(child), - ); - const body = json.length === 1 ? json[0] : json; - - return JSON.stringify(body); -}; - // This function is used to extract styles from the stylesheets that contain the "--mcksi-frame-include: true;" rule export const extractStyles = ( stylesheets: DocumentOrShadowRoot["styleSheets"], diff --git a/packages/reactor/index.ts b/packages/reactor/index.ts index cdc493a9..9ac83efd 100644 --- a/packages/reactor/index.ts +++ b/packages/reactor/index.ts @@ -1,2 +1,9 @@ -export { modifyHtml, modifyDom, htmlElementToJson } from "./main"; -export type { Modification, ModificationRequest } from "./interfaces"; +export type { + Modification, + ModificationRequest, + AppliedModifications, + DomJsonExportNode, + TimeStampReference, + Highlighter, +} from "./interfaces"; +export { Reactor } from "./reactor"; diff --git a/packages/reactor/interfaces.ts b/packages/reactor/interfaces.ts index 82ff7ae1..4721ff1b 100644 --- a/packages/reactor/interfaces.ts +++ b/packages/reactor/interfaces.ts @@ -1,4 +1,4 @@ -interface TimeStampReference { +export interface TimeStampReference { // NOTE: this is a iso8601 date string recordedAt: string; // NOTE: this is a iso8601 date string @@ -34,6 +34,16 @@ export interface ModificationRequest { modifications: Modification[]; } +export interface AppliedModifications { + modificationRequest: ModificationRequest; + + /** + * Turn highlighting on or off for the changes made + * by this request + */ + setHighlight(highlight: boolean): void; +} + export interface DomJsonExportNode { tag: string; visible: boolean; @@ -41,3 +51,28 @@ export interface DomJsonExportNode { attributes?: Record; children?: DomJsonExportNode[]; } + +export interface Highlighter { + highlightNode(elementToHighlight: Node): void; + removeHighlightNode(elementToUnhighlight: Node): void; +} + +export abstract class AppliableModification { + doc: Document; + highlightNodes: Node[] = []; + + constructor(doc: Document) { + this.doc = doc; + } + + abstract apply(): void; + abstract unapply(): void; + + getHighlightNodes(): Node[] { + return this.highlightNodes; + } + + addHighlightNode(node: Node): void { + this.highlightNodes.push(node); + } +} diff --git a/packages/reactor/main.ts b/packages/reactor/main.ts index a4211863..2d7a05e6 100644 --- a/packages/reactor/main.ts +++ b/packages/reactor/main.ts @@ -1,5 +1,10 @@ -import type { DomJsonExportNode, ModificationRequest } from "./interfaces"; -import { generateModifications, parseRequest } from "./utils"; +import type { + AppliedModifications, + DomJsonExportNode, + ModificationRequest, +} from "./interfaces"; +import { generateModifications } from "./modifications"; +import { parseRequest } from "./utils"; export async function modifyHtml( htmlString: string, @@ -25,9 +30,9 @@ export async function modifyHtml( export async function modifyDom( root: Document, modificationRequest: ModificationRequest, -): Promise { +): Promise { try { - await generateModifications(modificationRequest, root); + return generateModifications(modificationRequest, root); } catch (e) { console.error("Error modifying DOM:", e); throw new Error(`Error modifying DOM: ${e}`); diff --git a/packages/reactor/modifications.ts b/packages/reactor/modifications.ts new file mode 100644 index 00000000..e5edaa84 --- /dev/null +++ b/packages/reactor/modifications.ts @@ -0,0 +1,202 @@ +import { AdjacentHTMLModification } from "./modifications/adjacentHTML"; +import { HighlightModification } from "./modifications/highlight"; +import { NoopModification } from "./modifications/noop"; +import { RemoveModification } from "./modifications/remove"; +import { ReplaceModification } from "./modifications/replace"; +import { ReplaceAllModification } from "./modifications/replaceAll"; +import { SwapImageModification } from "./modifications/swapImage"; +import { TimestampModification } from "./modifications/timestamp"; +import { ToastModification } from "./modifications/toast"; + +import type { + AppliableModification, + AppliedModifications, + Highlighter, + Modification, + ModificationRequest, +} from "./interfaces"; + +export class AppliedModificationsImpl implements AppliedModifications { + modificationRequest: ModificationRequest; + modifications: Array = []; + highlighter?: Highlighter; + + constructor( + modificationRequest: ModificationRequest, + highlighter?: Highlighter, + ) { + this.modificationRequest = modificationRequest; + this.highlighter = highlighter; + } + + unapply(): void { + const reversedModifications = [...this.modifications].reverse(); + for (const mod of reversedModifications) { + mod.unapply(); + } + } + + setHighlight(highlight: boolean): void { + if (this.highlighter) { + for (const mod of this.modifications) { + for (const node of mod.highlightNodes) { + if (highlight) { + this.highlighter.highlightNode(node); + } else { + this.highlighter.removeHighlightNode(node); + } + } + } + } + } +} + +export async function generateModifications( + request: ModificationRequest, + doc: Document, + highlighter?: Highlighter, +): Promise { + const appliedModifications = new AppliedModificationsImpl( + request, + highlighter, + ); + + try { + for (const mod of request.modifications) { + let elements: Array; + try { + if (mod.selector) { + elements = Array.from(doc.querySelectorAll(mod.selector)); + } else if (mod.xpath) { + // construct a new NodeListOf from items found by the xpath + elements = []; + if (!mod.xpath.startsWith("//html")) { + mod.xpath = `//html/${mod.xpath}`; + } + const xpath = document.evaluate( + mod.xpath, + doc, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + for (let i = 0; i < xpath.snapshotLength; i++) { + const item = xpath.snapshotItem(i); + if (item !== null && item instanceof Element) { + elements.push(item); + } + } + } else { + console.warn("No selector provided for modification."); + continue; + } + } catch (e) { + console.warn( + `Invalid selector: ${mod.selector ? mod.selector : mod.xpath}`, + ); + continue; + } + + if (elements.length === 0) { + console.warn( + `Element not found for selector: ${ + mod.selector ? mod.selector : mod.xpath + }`, + ); + continue; + } + + for (const element of elements) { + const appliedModification = await applyModification(element, mod, doc); + appliedModifications.modifications.push(appliedModification); + } + + // Add a small delay between modifications + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // biome-ignore lint/suspicious/noExplicitAny: exception handling + } catch (error: any) { + console.error("Error generating modifications:", error); + throw new Error(`Error generating modifications: ${error}`); + } + + return appliedModifications; +} + +export async function applyModification( + element: Element, + mod: Modification, + doc: Document, +): Promise { + let modification: AppliableModification; + + switch (mod.action) { + case "replace": + modification = new ReplaceModification(doc, element, mod.content || ""); + break; + case "replaceAll": + modification = new ReplaceAllModification( + doc, + element, + mod.content || "", + ); + break; + case "append": + modification = new AdjacentHTMLModification( + doc, + element, + "beforeend", + mod.content || "", + ); + break; + case "prepend": + modification = new AdjacentHTMLModification( + doc, + element, + "afterbegin", + mod.content || "", + ); + break; + case "remove": + modification = new RemoveModification(doc, element); + break; + case "swapImage": + modification = new SwapImageModification( + doc, + element, + mod.imageUrl || "", + ); + break; + case "highlight": + modification = new HighlightModification( + doc, + element, + mod.highlightStyle || "2px solid red", + ); + break; + case "toast": + modification = new ToastModification( + doc, + mod.toastMessage || "Notification", + mod.duration || 3000, + ); + break; + case "addComponent": + modification = new AdjacentHTMLModification( + doc, + element, + "beforeend", + mod.componentHtml || "", + ); + break; + case "updateTimestampReferences": + modification = new TimestampModification(doc, element, mod.timestampRef); + break; + default: + modification = new NoopModification(doc, mod.action); + break; + } + + modification.apply(); + return modification; +} diff --git a/packages/reactor/modifications/adjacentHTML.ts b/packages/reactor/modifications/adjacentHTML.ts new file mode 100644 index 00000000..6b0dce19 --- /dev/null +++ b/packages/reactor/modifications/adjacentHTML.ts @@ -0,0 +1,31 @@ +import { AppliableModification } from "../interfaces"; + +export class AdjacentHTMLModification extends AppliableModification { + element: Element; + position: InsertPosition; + oldValue: string; + newValue: string; + + constructor( + doc: Document, + element: Element, + position: InsertPosition, + newValue: string, + ) { + super(doc); + this.element = element; + this.position = position; + this.newValue = newValue; + this.oldValue = element.outerHTML; + } + + apply(): void { + this.element.insertAdjacentHTML(this.position, this.newValue); + + // TODO - highlighting + } + + unapply(): void { + this.element.outerHTML = this.oldValue; + } +} diff --git a/packages/reactor/modifications/highlight.ts b/packages/reactor/modifications/highlight.ts new file mode 100644 index 00000000..60a8e8d5 --- /dev/null +++ b/packages/reactor/modifications/highlight.ts @@ -0,0 +1,33 @@ +import { AppliableModification } from "../interfaces"; +const cssSelector = require("css-selector-generator"); + +export class HighlightModification extends AppliableModification { + elementSelector: string; + highlightStyle: string; + prevBorder: string; + + constructor(doc: Document, element: Element, highlightStyle: string) { + super(doc); + this.elementSelector = cssSelector.getCssSelector(element); + this.highlightStyle = highlightStyle; + this.prevBorder = ""; + + if (element instanceof HTMLElement) { + this.prevBorder = element.style.border; + } + } + + apply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (element && element instanceof HTMLElement) { + element.style.border = this.highlightStyle; + } + } + + unapply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (element && element instanceof HTMLElement) { + element.style.border = this.prevBorder; + } + } +} diff --git a/packages/reactor/modifications/noop.ts b/packages/reactor/modifications/noop.ts new file mode 100644 index 00000000..6c142fec --- /dev/null +++ b/packages/reactor/modifications/noop.ts @@ -0,0 +1,16 @@ +import { AppliableModification } from "../interfaces"; + +export class NoopModification extends AppliableModification { + action: string; + + constructor(doc: Document, action: string) { + super(doc); + this.action = action; + } + + apply(): void { + console.warn(`Unknown action: ${this.action}`); + } + + unapply(): void {} +} diff --git a/packages/reactor/modifications/remove.ts b/packages/reactor/modifications/remove.ts new file mode 100644 index 00000000..7cb88fb5 --- /dev/null +++ b/packages/reactor/modifications/remove.ts @@ -0,0 +1,48 @@ +import { AppliableModification } from "../interfaces"; +const cssSelector = require("css-selector-generator"); + +export class RemoveModification extends AppliableModification { + element: Element; + parentSelector: string | null; + nextSiblingSelector: string | null = null; + + constructor(doc: Document, element: Element) { + super(doc); + this.element = element; + this.parentSelector = element.parentElement + ? cssSelector.getCssSelector(element.parentElement) + : null; + } + + apply(): void { + // get the element's next sibling + const nextSibling = this.element.nextElementSibling; + this.element.remove(); + // now get the selector for the sibling after the element was + // removed + this.nextSiblingSelector = nextSibling + ? cssSelector.getCssSelector(nextSibling) + : null; + } + + unapply(): void { + let parent: Element | null = null; + if (this.parentSelector) { + parent = this.doc.querySelector(this.parentSelector); + } + if (!parent) { + return; + } + + let nextSibling: Element | null = null; + if (this.nextSiblingSelector) { + nextSibling = this.doc.querySelector(this.nextSiblingSelector); + } + + if (nextSibling) { + parent.insertBefore(this.element, nextSibling); + } else { + parent.appendChild(this.element); + } + } +} diff --git a/packages/reactor/modifications/replace.ts b/packages/reactor/modifications/replace.ts new file mode 100644 index 00000000..f62ff931 --- /dev/null +++ b/packages/reactor/modifications/replace.ts @@ -0,0 +1,30 @@ +import { AppliableModification } from "../interfaces"; +const cssSelector = require("css-selector-generator"); + +export class ReplaceModification extends AppliableModification { + elementSelector: string; + oldValue: string; + newValue: string; + + constructor(doc: Document, element: Element, newValue: string) { + super(doc); + this.elementSelector = cssSelector.getCssSelector(element); + this.newValue = newValue; + this.oldValue = element.innerHTML; + } + + apply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (element) { + element.innerHTML = this.newValue; + this.addHighlightNode(element); + } + } + + unapply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (element) { + element.innerHTML = this.oldValue; + } + } +} diff --git a/packages/reactor/modifications/replaceAll.ts b/packages/reactor/modifications/replaceAll.ts new file mode 100644 index 00000000..19fb6650 --- /dev/null +++ b/packages/reactor/modifications/replaceAll.ts @@ -0,0 +1,199 @@ +import { AppliableModification } from "../interfaces"; +const cssSelector = require("css-selector-generator"); + +export class ReplaceAllModification extends AppliableModification { + element: Element; + content: string; + changes: TreeChange[] = []; + + constructor(doc: Document, element: Element, content: string) { + super(doc); + this.element = element; + this.content = content; + } + + apply(): void { + this.changes = walkTree( + this.element, + checkText(this.content), + replaceText(this.content, this.addHighlightNode.bind(this)), + ); + } + + unapply(): void { + const reverseChanges = [...this.changes].reverse(); + for (const change of reverseChanges) { + const parentElement = this.doc.querySelector(change.parentSelector); + if (!parentElement) { + continue; + } + + const nextSibling = + parentElement.childNodes[change.replaceStart + change.replaceCount] || + null; + for (let i = change.replaceCount; i > 0; i--) { + const removeNode = + parentElement.childNodes[change.replaceStart + i - 1]; + if (removeNode) { + removeNode.remove(); + } + } + + const newTextNode = this.doc.createTextNode(change.origText); + parentElement.insertBefore(newTextNode, nextSibling); + } + } +} + +type TreeChange = { + parentSelector: string; + origText: string; + replaceStart: number; + replaceCount: number; +}; + +function walkTree( + rootElement: Node, + checker: (textNode: Node) => boolean, + changer: (textNode: Node) => TreeChange | null, +): TreeChange[] { + const changeNodes: Node[] = []; + const changes: TreeChange[] = []; + + const treeWalker = document.createTreeWalker( + rootElement, + NodeFilter.SHOW_TEXT, + (node) => { + if ( + node.parentElement instanceof HTMLScriptElement || + node.parentElement instanceof HTMLStyleElement + ) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + ); + let textNode: Node; + do { + textNode = treeWalker.currentNode; + if (textNode.nodeValue === null || !textNode?.nodeValue?.trim()) { + continue; + } + + if (checker(textNode)) { + changeNodes.push(textNode); + } + } while (treeWalker.nextNode()); + + for (const node of changeNodes) { + const change = changer(node); + if (change) { + changes.push(change); + } + } + + return changes; +} + +function checkText(pattern: string): (node: Node) => boolean { + const { patternRegexp } = toRegExpPattern(pattern); + + return (node: Node) => { + if (!node.textContent || !node.nodeValue) { + return false; + } + + patternRegexp.lastIndex = 0; + return patternRegexp.test(node.nodeValue || ""); + }; +} + +function replaceText( + pattern: string, + addHighlightNode: (node: Node) => void, +): (node: Node) => TreeChange | null { + const { patternRegexp, replacement } = toRegExpPattern(pattern); + + return (node: Node) => { + let split = node.nodeValue?.split(patternRegexp) || []; + split = split.map((part, index) => { + if (index % 2 === 0) { + return part; + } + return replaceFirstLetterCaseAndPlural(replacement)(part); + }); + + const parentElement = node.parentElement; + if (!parentElement) { + return null; + } + const parentSelector = cssSelector.getCssSelector(parentElement); + + let replaceStart = 0; + const nextSibling = node.nextSibling; + if (nextSibling) { + for (let i = 0; i < parentElement.childNodes.length; i++) { + if (parentElement.childNodes[i] === nextSibling) { + replaceStart = i - 1; + break; + } + } + } + + parentElement.removeChild(node); + + for (let i = 0; i < split.length; i++) { + if (typeof split[i] !== "undefined") { + const textNode = document.createTextNode(split[i] || ""); + parentElement.insertBefore(textNode, nextSibling); + + if (i % 2 !== 0) { + addHighlightNode(textNode); + } + } + } + + return { + parentSelector: parentSelector, + origText: node.nodeValue || "", + replaceStart: replaceStart, + replaceCount: split.length, + }; + }; +} + +function replaceFirstLetterCaseAndPlural(value: string) { + return (match: string) => { + let out = value; + + // change the case if the first letter of the match is uppercase + if (match[0]?.toLowerCase() !== match[0]?.toUpperCase()) { + if (match[0] === match[0]?.toUpperCase()) { + out = out.charAt(0).toUpperCase() + out.slice(1); + } + } + + // if the match is plural, add an s + if (match.endsWith("s")) { + out = `${out}s`; + } + + return out; + }; +} + +// Take pattern in the form of /pattern/replacement/ and return {patternRegexp, replacement} +function toRegExpPattern(pattern: string): { + patternRegexp: RegExp; + replacement: string; +} { + const match = /\/(.+)\/(.+)\//.exec(pattern); + if (!match || match.length !== 3 || !match[1] || !match[2]) { + throw new Error(`Invalid pattern: ${pattern}`); + } + + return { + patternRegexp: new RegExp(`(\\b${match[1]}s?\\b)`, "gi"), + replacement: match[2], + }; +} diff --git a/packages/reactor/modifications/swapImage.ts b/packages/reactor/modifications/swapImage.ts new file mode 100644 index 00000000..57f07244 --- /dev/null +++ b/packages/reactor/modifications/swapImage.ts @@ -0,0 +1,35 @@ +import { AppliableModification } from "../interfaces"; +const cssSelector = require("css-selector-generator"); + +export class SwapImageModification extends AppliableModification { + elementSelector: string; + imageUrl: string; + previousUrl: string | null; + + constructor(doc: Document, element: Element, imageUrl: string) { + super(doc); + this.elementSelector = cssSelector.getCssSelector(element); + this.imageUrl = imageUrl; + + if (element instanceof HTMLImageElement) { + this.previousUrl = element.getAttribute("src"); + } else { + this.previousUrl = null; + } + } + + apply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (element && element instanceof HTMLImageElement) { + element.src = this.imageUrl; + this.addHighlightNode(element); + } + } + + unapply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (this.previousUrl && element && element instanceof HTMLImageElement) { + element.setAttribute("src", this.previousUrl); + } + } +} diff --git a/packages/reactor/modifications/timestamp.ts b/packages/reactor/modifications/timestamp.ts new file mode 100644 index 00000000..e6173c17 --- /dev/null +++ b/packages/reactor/modifications/timestamp.ts @@ -0,0 +1,124 @@ +import { AppliableModification, type TimeStampReference } from "../interfaces"; +const cssSelector = require("css-selector-generator"); + +export class TimestampModification extends AppliableModification { + elementSelector: string; + timestampRef: TimeStampReference | undefined; + originalText: string | undefined; + originalLabel: string | undefined; + + constructor( + doc: Document, + element: Element, + timestampRef: TimeStampReference | undefined, + ) { + super(doc); + this.elementSelector = cssSelector.getCssSelector(element); + this.timestampRef = timestampRef; + } + + apply(): void { + if (!this.timestampRef) { + console.warn("No timestamp reference provided for modification."); + return; + } + + const element = this.doc.querySelector(this.elementSelector); + if (!element) { + return; + } + + const { originalText, originalLabel } = modifyTimestamp( + element, + this.timestampRef, + ); + this.originalText = originalText; + this.originalLabel = originalLabel; + + this.addHighlightNode(element); + } + + unapply(): void { + const element = this.doc.querySelector(this.elementSelector); + if (!element) { + return; + } + + if (this.originalText) { + element.textContent = this.originalText; + } + if (this.originalLabel) { + element.setAttribute("aria-label", this.originalLabel); + } + } +} + +export function modifyTimestamp( + element: Element, + timestampRef: TimeStampReference, +): { originalText: string; originalLabel: string } { + const originalText = element.textContent || ""; + const originalLabel = element.getAttribute("aria-label") || ""; + const [originalMonth, originalDay] = originalText.split(" "); + + if (!originalMonth || !originalDay) { + console.warn(`Invalid date format: ${originalText}`); + return { originalText, originalLabel }; + } + + // Calculate the new day and month based on the timestampRef + const { newDay, newMonth } = calculateNewDate( + originalDay, + originalMonth, + timestampRef.recordedAt, + timestampRef.currentTime, + ); + + // Update the element's textContent and aria-label with the new day and month + element.textContent = `${newMonth} ${newDay}`; + // Note the space before the month to avoid concatenation with the day + const newLabel = originalLabel.replace(/ .+,/, ` ${newMonth} ${newDay},`); + element.setAttribute("aria-label", newLabel); + + return { originalText, originalLabel }; +} + +function calculateNewDate( + originalDay: string, + originalMonth: string, + recordedAt: string, + currentTime: string, +): { newDay: string; newMonth: string } { + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const recordedDate = new Date(recordedAt); + const currentDate = new Date(currentTime); + const differenceInDays = Math.ceil( + Math.abs( + (currentDate.getTime() - recordedDate.getTime()) / (1000 * 3600 * 24), + ), + ); + + const originalDate = new Date(recordedDate); + originalDate.setDate(Number.parseInt(originalDay, 10)); + + const newDate = new Date(originalDate); + newDate.setDate(originalDate.getDate() + differenceInDays); + + const newDay = String(newDate.getDate()).padStart(2, "0"); + const newMonth = months[newDate.getMonth()] || originalMonth; + + return { newDay, newMonth }; +} diff --git a/packages/reactor/modifications/toast.ts b/packages/reactor/modifications/toast.ts new file mode 100644 index 00000000..58702b80 --- /dev/null +++ b/packages/reactor/modifications/toast.ts @@ -0,0 +1,35 @@ +import { AppliableModification } from "../interfaces"; + +export class ToastModification extends AppliableModification { + message: string; + duration: number; + + constructor(doc: Document, message: string, duration: number) { + super(doc); + this.message = message; + this.duration = duration; + } + + apply(): void { + createToast(this.message, this.doc, this.duration); + } + + unapply(): void { + // can't undo + } +} + +export function createToast( + message: string, + doc: Document, + duration = 3000, +): void { + const toast = doc.createElement("div"); + toast.className = "fixed bottom-4 right-4 bg-blue-500 text-white p-4 rounded"; + toast.textContent = message; + doc.body.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, duration); +} diff --git a/packages/reactor/mutationObserver.ts b/packages/reactor/mutationObserver.ts new file mode 100644 index 00000000..826b0c12 --- /dev/null +++ b/packages/reactor/mutationObserver.ts @@ -0,0 +1,20 @@ +export class ReactorMutationObserver { + private observer: MutationObserver | undefined; + + attach(root: Document) { + this.observer = new MutationObserver(this.handleMutations.bind(this)); + this.observer.observe(root, { childList: true, subtree: true }); + } + + detach() { + this.observer?.disconnect(); + } + + handleMutations(mutations: MutationRecord[]) { + for (const mutation of mutations) { + this.handleMutation(mutation); + } + } + + handleMutation(mutation: MutationRecord) {} +} diff --git a/packages/reactor/package.json b/packages/reactor/package.json index 0df76a12..b8797144 100644 --- a/packages/reactor/package.json +++ b/packages/reactor/package.json @@ -26,6 +26,7 @@ "vitest": "^2.0.1" }, "dependencies": { + "css-selector-generator": "^3.6.8", "uuid": "^9.0.1" } } diff --git a/packages/reactor/reactor.ts b/packages/reactor/reactor.ts new file mode 100644 index 00000000..d64627ec --- /dev/null +++ b/packages/reactor/reactor.ts @@ -0,0 +1,198 @@ +import type { + AppliedModifications, + DomJsonExportNode, + Highlighter, + ModificationRequest, +} from "./interfaces"; +import { htmlElementToJson } from "./main"; +import { + AppliedModificationsImpl, + generateModifications, +} from "./modifications"; +import { ReactorMutationObserver } from "./mutationObserver"; + +/** + * Reactor applied modifications to the current page. Modifications + * are applied in the order they were added. Removing a modification + * unapplies it. + */ +export class Reactor { + private mutationObserver: ReactorMutationObserver; + private attached = false; + + private doc?: Document = undefined; + private highlighter?: Highlighter = undefined; + private modifications: ModificationRequest[] = []; + private appliedModifications: AppliedModificationsImpl[] = []; + + constructor() { + this.mutationObserver = new ReactorMutationObserver(); + } + + /** + * Attach Reactor to the current tab. Reactor will start generating + * events and apply any modifications. + * + * @param root The document to attach to + */ + async attach(root: Document, highlighter: Highlighter): Promise { + if (this.attached) { + throw new Error("Reactor is already attached"); + } + + this.doc = root; + this.highlighter = highlighter; + this.mutationObserver.attach(root); + this.attached = true; + + // apply all modifications + for (const modification of this.modifications) { + this.appliedModifications.push( + await generateModifications(modification, root, highlighter), + ); + } + } + + /** + * Returns a boolean indicating whether the object is attached. + * + * @return {boolean} A boolean indicating whether the object is attached. + */ + isAttached(): boolean { + return this.attached; + } + + /** + * Detach Reactor from the current tab. Reactor will remove any applied + * modifications and stop generating events. + */ + async detach(clearModifications = true): Promise { + this.mutationObserver.detach(); + + // clear any applied modifications + if (clearModifications) { + await this.clearAppliedModifications(); + } + + this.attached = false; + this.appliedModifications = []; + } + + /** + * Returns an iterable object that allows iteration over the applied modifications. + * + * @return {Iterable} An iterable object that allows iteration over the applied modifications. + */ + getAppliedModifications(): Iterable { + const index = 0; + const outerThis = this; + return { + [Symbol.iterator](): Iterator { + let index = 0; + return { + next: () => { + if (index < outerThis.appliedModifications.length) { + return { + value: + outerThis.appliedModifications[index++] || + new AppliedModificationsImpl( + { + description: "No modifications", + modifications: [], + }, + outerThis.highlighter, + ), + done: false, + }; + } + + return { value: undefined, done: true }; + }, + }; + }, + }; + } + + /** + * Export the DOM as an array of `DomJsonExportNode` objects. + * + * @param {HTMLElement | null} element - The element to export. If not provided, the entire body of the attached document will be exported. + * @throws {Error} If the reactor is not attached and no element is specified. + * @return {DomJsonExportNode[]} An array of `DomJsonExportNode` objects representing the exported DOM. + */ + exportDOM(element: HTMLElement | null = null): DomJsonExportNode[] { + let useElement = element; + + if (!useElement) { + if (this.attached && this.doc) { + useElement = this.doc.body; + } else { + throw new Error("Not attached"); + } + } + + return htmlElementToJson(useElement); + } + + /** + * Pushes a modification request or an array of modification requests to the stack. + * + * @param {ModificationRequest | ModificationRequest[]} modificationRequest - The modification request or array of modification requests to be pushed. + * @return {ModificationRequest | ModificationRequest[]} the applied modifications + */ + async pushModification( + modificationRequest: ModificationRequest | ModificationRequest[], + ): Promise { + const out: AppliedModifications[] = []; + + const toApply = Array.isArray(modificationRequest) + ? modificationRequest + : [modificationRequest]; + for (const modification of toApply) { + this.modifications.push(modification); + + if (this.isAttached() && this.doc) { + const applied = await generateModifications( + modification, + this.doc, + this.highlighter, + ); + out.push(applied); + this.appliedModifications.push(applied); + } + } + + return out; + } + + /** + * Removes the specified number of modifications from the stack. + * + * @param {number} count - The number of modifications to remove. Defaults to 1. + * @return {AppliedModification[]} the applied modifications + */ + async popModification(count = 1): Promise { + const out: AppliedModifications[] = []; + for (let i = 0; i < count; i++) { + const modification = this.modifications.pop(); + + if (this.isAttached()) { + const applied = this.appliedModifications.pop(); + if (applied) { + applied.setHighlight(false); + applied.unapply(); + out.push(applied); + } + } + } + + return out; + } + + /** + * Clear all modifications applied + */ + async clearAppliedModifications(): Promise { + await this.popModification(this.appliedModifications.length); + } +} diff --git a/packages/reactor/tests/index.test.ts b/packages/reactor/tests/index.test.ts index 64b58a7b..030a9955 100644 --- a/packages/reactor/tests/index.test.ts +++ b/packages/reactor/tests/index.test.ts @@ -1,6 +1,7 @@ // Import the function you want to test import { describe, expect, it } from "vitest"; -import { type ModificationRequest, modifyDom, modifyHtml } from "../index"; +import type { ModificationRequest } from "../interfaces"; +import { modifyDom, modifyHtml } from "../main"; describe("modifyHtml should perform basic HTML modification", {}, () => { it("changes text content, swaps image sources, highlights elements, creates toast notifications, adds DaisyUI components, handles multiple modifications, and gracefully handles missing elements", async () => { diff --git a/packages/reactor/tests/main.test.ts b/packages/reactor/tests/main.test.ts index b2b2a213..a883a08e 100644 --- a/packages/reactor/tests/main.test.ts +++ b/packages/reactor/tests/main.test.ts @@ -1,6 +1,8 @@ import { JSDOM } from "jsdom"; import { describe, expect, it } from "vitest"; -import { modifyHtml } from "../main"; +import type { ModificationRequest } from "../interfaces"; +import { modifyDom, modifyHtml } from "../main"; +import type { AppliedModificationsImpl } from "../modifications"; // Set up a mock DOM environment const dom = new JSDOM(""); @@ -160,13 +162,17 @@ describe("modifyHtml", () => { expect(result).toContain('class="card w-96 bg-base-100 shadow-xl"'); }); - it("should handle multiple modifications", async () => { - const html = ` -
Eliza Hart
-
Welcome, Eliza!
- - `; - const userRequest = JSON.stringify({ + it("should handle multiple modifications using modifyDom", async () => { + const htmlString = ` +
Eliza Hart
+
Welcome, Eliza!
+ + `; + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, "text/html"); + + const userRequest: ModificationRequest = { description: "Change all occurrences of the name 'Eliza' to 'Santiago', swap profile picture, and add a toast notification.", modifications: [ @@ -191,13 +197,102 @@ describe("modifyHtml", () => { toastMessage: "Welcome to the new site, Santiago!", }, ], - }); + }; - const result = await modifyHtml(html, userRequest); - expect(result).toContain("Santiago Hart"); - expect(result).toContain("Welcome, Santiago!"); - expect(result).toContain('src="santiago.jpg"'); - expect(result).toContain("Welcome to the new site, Santiago!"); + await modifyDom(doc, userRequest); + + expect(doc.body.innerHTML).toContain("Santiago Hart"); + expect(doc.body.innerHTML).toContain("Welcome, Santiago!"); + expect(doc.body.innerHTML).toContain('src="santiago.jpg"'); + expect(doc.body.innerHTML).toContain("Welcome to the new site, Santiago!"); + }); + + it("should unapply multiple modifications using modifyDom", async () => { + const htmlString = ` +
Eliza Hart
+
Welcome, Eliza!
+ + `; + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, "text/html"); + + const userRequest: ModificationRequest = { + description: + "Change all occurrences of the name 'Eliza' to 'Santiago', swap profile picture, and add a toast notification.", + modifications: [ + { + selector: "#user-info", + action: "replace", + content: "Santiago Hart", + }, + { + selector: "#welcome-message", + action: "replace", + content: "Welcome, Santiago!", + }, + { + selector: "#profile-pic", + action: "swapImage", + imageUrl: "santiago.jpg", + }, + { + selector: "body", + action: "toast", + toastMessage: "Welcome to the new site, Santiago!", + }, + ], + }; + + const modifications = (await modifyDom( + doc, + userRequest, + )) as AppliedModificationsImpl; + modifications.unapply(); + + expect(doc.body.innerHTML).toContain("Eliza Hart"); + expect(doc.body.innerHTML).toContain("Welcome, Eliza!"); + expect(doc.body.innerHTML).toContain('src="eliza.jpg"'); + }); + + it("should unapply a remove of multiple elements correctly", async () => { + const htmlString = ` +
Eliza Hart
+
Welcome, Eliza!
+ + `; + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, "text/html"); + + const userRequest: ModificationRequest = { + description: + "Change all occurrences of the name 'Eliza' to 'Santiago', swap profile picture, and add a toast notification.", + modifications: [ + { + selector: "#user-info", + action: "remove", + }, + { + selector: "#welcome-message", + action: "remove", + }, + { + selector: "#profile-pic", + action: "remove", + }, + ], + }; + + const modifications = (await modifyDom( + doc, + userRequest, + )) as AppliedModificationsImpl; + modifications.unapply(); + + expect(doc.body.innerHTML).toContain("Eliza Hart"); + expect(doc.body.innerHTML).toContain("Welcome, Eliza!"); + expect(doc.body.innerHTML).toContain('src="eliza.jpg"'); }); it("should handle missing elements gracefully", async () => { @@ -217,6 +312,25 @@ describe("modifyHtml", () => { expect(result).not.toContain("This should not appear"); expect(result).toContain("
Some content
"); }); + + it("should ignore invalid selectors", async () => { + const html = "

Old content

"; + const userRequest = JSON.stringify({ + description: "Try to modify an element with a bad selector", + modifications: [ + { + action: "replace", + content: "

New Content

", + selector: "#;3s92hn", + }, + ], + }); + + const result = await modifyHtml(html, userRequest); + expect(result).not.toContain("

New content

"); + expect(result).toContain("

Old content

"); + }); + it("should be able to update timestamps", async () => { const html = `
@@ -282,7 +396,7 @@ describe("modifyHtml", () => {
`; - + const userRequest = JSON.stringify({ modifications: [ { @@ -295,11 +409,13 @@ describe("modifyHtml", () => { }, ], }); - + const result = await modifyHtml(html, userRequest); - + const domResult = new JSDOM(result); - const span = domResult.window.document.querySelector("span[aria-label^='Updated']"); + const span = domResult.window.document.querySelector( + "span[aria-label^='Updated']", + ); expect(span).not.toBeNull(); expect(span?.textContent).toContain("Aug"); expect(span?.getAttribute("aria-label")).toContain("Aug"); diff --git a/packages/reactor/tests/modifications.test.ts b/packages/reactor/tests/modifications.test.ts new file mode 100644 index 00000000..21afe2e5 --- /dev/null +++ b/packages/reactor/tests/modifications.test.ts @@ -0,0 +1,453 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { Modification, ModificationRequest } from "../interfaces"; +// utils.test.ts +import { applyModification, generateModifications } from "../modifications"; +import { createToast } from "../modifications/toast"; + +describe("Utils", () => { + let doc: Document; + + // Vitest beforeEach function for setup + beforeEach(() => { + doc = document.implementation.createHTMLDocument("Test Document"); + }); + + describe("applyModification", () => { + it("should replace content correctly", async () => { + const modification: Modification = { + action: "replace", + content: "

New Content

", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Old Content

"; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe("

New Content

"); + }); + + it("should unapply replaced content correctly", async () => { + const modification: Modification = { + action: "replace", + content: "

New Content

", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Old Content

"; + const modifications = await applyModification(element, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toBe("

Old Content

"); + }); + + it("should replace all content correctly", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/old/new/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Old Content

"; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe("

New Content

"); + }); + + it("should unapply replace all correctly", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/old/new/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Old Content

"; + const modifications = await applyModification(element, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toBe("

Old Content

"); + }); + + it("should preserve capitals in replacement", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/old/new/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Old Content is old

"; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe("

New Content is new

"); + }); + + it("should preserve plurals in replacement", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/train/brain/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Trains are great! I love my train.

"; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe( + "

Brains are great! I love my brain.

", + ); + }); + + it("should only replace whole words", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/train/brain/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = + "

I was in training about trains, but it was a strain to train.

"; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe( + "

I was in training about brains, but it was a strain to brain.

", + ); + }); + + it("should handle more complicated HTML", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/train/brain/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = + '

Trains

Trains are great! A picture of a train

Trains are great! I love my train.

'; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe( + '

Brains

Brains are great! A picture of a brain

Brains are great! I love my brain.

', + ); + }); + + it("should unapply replaceall properly on more complicated HTML", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/train/brain/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = + '

Trains

Trains are great! A picture of a train

Trains are great! I love my train.

'; + const modifications = await applyModification(element, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toBe( + '

Trains

Trains are great! A picture of a train

Trains are great! I love my train.

', + ); + }); + + it("should work with multiple text nodes", async () => { + const modification: Modification = { + action: "replaceAll", + content: "/train/brain/", + }; + + const element = doc.createElement("div"); + doc.body.appendChild(element); + const t1 = doc.createTextNode("Trains node 1 "); + const t2 = doc.createTextNode("Trains node 2 "); + const t3 = doc.createTextNode("Trains node 3"); + element.appendChild(t1); + element.appendChild(t2); + element.appendChild(t3); + + await applyModification(element, modification, doc); + + expect(element.innerHTML).toBe( + "Brains node 1 Brains node 2 Brains node 3", + ); + }); + + it("should append content correctly", async () => { + const modification: Modification = { + action: "append", + content: "

New Content

", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("div"); + inner.innerHTML = "

Initial Content

"; + element.appendChild(inner); + await applyModification(inner, modification, doc); + + expect(element.innerHTML).toBe( + "

Initial Content

New Content

", + ); + }); + + it("should unapply appended content correctly", async () => { + const modification: Modification = { + action: "append", + content: "

New Content

", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("div"); + inner.innerHTML = "

Initial Content

"; + element.appendChild(inner); + const modifications = await applyModification(inner, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toBe("

Initial Content

"); + }); + + it("should prepend content correctly", async () => { + const modification: Modification = { + action: "prepend", + content: "

New Content

", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("div"); + inner.innerHTML = "

Initial Content

"; + element.appendChild(inner); + await applyModification(inner, modification, doc); + + expect(element.innerHTML).toBe( + "

New Content

Initial Content

", + ); + }); + + it("should unapply prepend content correctly", async () => { + const modification: Modification = { + action: "prepend", + content: "

New Content

", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("div"); + inner.innerHTML = "

Initial Content

"; + element.appendChild(inner); + const modifications = await applyModification(inner, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toBe("

Initial Content

"); + }); + + it("should remove the element correctly", async () => { + const modification: Modification = { + action: "remove", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("p"); + inner.innerHTML = "Initial Content"; + element.appendChild(inner); + await applyModification(inner, modification, doc); + + expect(element.outerHTML).toBe("
"); + }); + + it("should unapply the remove element correctly", async () => { + const modification: Modification = { + action: "remove", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("p"); + inner.innerHTML = "Initial Content"; + element.appendChild(inner); + const modifications = await applyModification(inner, modification, doc); + modifications.unapply(); + + expect(element.outerHTML).toBe("

Initial Content

"); + }); + + it("should remove the element correctly with siblings", async () => { + const modification: Modification = { + action: "remove", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner1 = doc.createElement("p"); + inner1.innerHTML = "Inner child 1"; + element.appendChild(inner1); + const inner2 = doc.createElement("p"); + inner2.innerHTML = "Inner child 2"; + element.appendChild(inner2); + const inner3 = doc.createElement("p"); + inner3.innerHTML = "Inner child 3"; + element.appendChild(inner3); + await applyModification(inner2, modification, doc); + + expect(element.outerHTML).toBe( + "

Inner child 1

Inner child 3

", + ); + }); + + it("should unapply the remove element correctly with siblings", async () => { + const modification: Modification = { + action: "remove", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner1 = doc.createElement("p"); + inner1.innerHTML = "Inner child 1"; + element.appendChild(inner1); + const inner2 = doc.createElement("p"); + inner2.innerHTML = "Inner child 2"; + element.appendChild(inner2); + const inner3 = doc.createElement("p"); + inner3.innerHTML = "Inner child 3"; + element.appendChild(inner3); + const modifications = await applyModification(inner2, modification, doc); + modifications.unapply(); + + expect(element.outerHTML).toBe( + "

Inner child 1

Inner child 2

Inner child 3

", + ); + }); + + it("should swap image source correctly", async () => { + const modification: Modification = { + action: "swapImage", + imageUrl: "new-image-url.jpg", + }; + const element = doc.createElement("img"); + doc.body.appendChild(element); + element.src = "old-image-url.jpg"; + await applyModification(element, modification, doc); + + expect(element.src).toBe("new-image-url.jpg"); + }); + + it("should unapply the swap image source correctly", async () => { + const modification: Modification = { + action: "swapImage", + imageUrl: "new-image-url.jpg", + }; + const element = doc.createElement("img"); + doc.body.appendChild(element); + element.src = "old-image-url.jpg"; + const modifications = await applyModification(element, modification, doc); + modifications.unapply(); + + expect(element.src).toBe("old-image-url.jpg"); + }); + + it("should highlight element correctly", async () => { + const modification: Modification = { + action: "highlight", + highlightStyle: "2px solid green", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Content

"; + await applyModification(element, modification, doc); + + expect(element.style.border).toBe("2px solid green"); + }); + + it("should create and display a toast correctly", async () => { + const modification: Modification = { + action: "toast", + toastMessage: "Test Notification", + duration: 100, + }; + createToast(modification.toastMessage ?? "", doc, modification.duration); + + // Simulate checking if the toast exists + const toastElement = doc.querySelector(".bg-blue-500"); // Assuming '.bg-blue-500' is the class for the toast + expect(toastElement?.textContent).toBe("Test Notification"); + + // Simulate waiting for the toast to be removed + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(doc.querySelector(".bg-blue-500")).toBeNull(); + }); + + it("should add a component correctly", async () => { + const modification: Modification = { + action: "addComponent", + componentHtml: "Component Content", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("p"); + inner.innerHTML = "Initial Content"; + element.appendChild(inner); + await applyModification(inner, modification, doc); + + expect(element.innerHTML).toBe( + "

Initial ContentComponent Content

", + ); + }); + + it("should unapply the add component correctly", async () => { + const modification: Modification = { + action: "addComponent", + componentHtml: "Component Content", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + const inner = doc.createElement("p"); + inner.innerHTML = "Initial Content"; + element.appendChild(inner); + const modifications = await applyModification(inner, modification, doc); + modifications.unapply(); + + expect(element.innerHTML).toBe("

Initial Content

"); + }); + + it("should handle unknowns correctly", async () => { + const modification: Modification = { + action: "unknown", + componentHtml: "Component Content", + }; + const element = doc.createElement("div"); + doc.body.appendChild(element); + element.innerHTML = "

Initial Content

"; + await applyModification(element, modification, doc); + + expect(element.innerHTML).toContain("

Initial Content

"); + }); + }); + + describe("createToast", () => { + it("should create and remove a toast correctly", async () => { + const message = "Test Message"; + createToast(message, doc); + + // Check if the toast exists + const toastElement = doc.querySelector(".bg-blue-500"); + expect(toastElement?.textContent).toBe(message); + + // Wait for the timeout before removing the toast + await new Promise((resolve) => setTimeout(resolve, 3100)); // Adjusted slightly above the 3000ms to account for any delays + + expect(doc.querySelector(".bg-blue-500")).toBeNull(); + }); + }); + + describe("generateModifications", () => { + it("should handle empty selectors gracefully", async () => { + const request: ModificationRequest = { + modifications: [ + { + selector: "", + action: "replace", + content: "

New Content

", + }, + ], + description: "", + }; + + await generateModifications(request, doc); + }); + }); +}); diff --git a/packages/reactor/tests/utils.test.ts b/packages/reactor/tests/utils.test.ts deleted file mode 100644 index 29820c18..00000000 --- a/packages/reactor/tests/utils.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import type { Modification, ModificationRequest } from "../interfaces"; -// utils.test.ts -import { applyModification, createToast } from "../utils"; -import { generateModifications } from "../utils"; - -describe("Utils", () => { - let doc: Document; - - // Vitest beforeEach function for setup - beforeEach(() => { - doc = document.implementation.createHTMLDocument("Test Document"); - }); - - describe("applyModification", () => { - it("should replace content correctly", async () => { - const modification: Modification = { - action: "replace", - content: "

New Content

", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Old Content

"; - await applyModification(element, modification, doc); - - expect(element.innerHTML).toBe("

New Content

"); - }); - - it("should append content correctly", async () => { - const modification: Modification = { - action: "append", - content: "

New Content

", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Initial Content

"; - await applyModification(element, modification, doc); - - expect(element.innerHTML).toBe( - "

Initial Content

New Content

", - ); - }); - - it("should prepend content correctly", async () => { - const modification: Modification = { - action: "prepend", - content: "

New Content

", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Initial Content

"; - await applyModification(element, modification, doc); - - expect(element.innerHTML).toBe( - "

New Content

Initial Content

", - ); - }); - - it("should remove the element correctly", async () => { - const modification: Modification = { - action: "remove", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Content

"; - await applyModification(element, modification, doc); - - expect(element.parentElement).toBeNull(); - }); - - it("should ignore invalid selectors", async () => { - const modification: Modification = { - action: "replace", - content: "

New Content

", - selector: "#;3s92hn", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Old Content

"; - await applyModification(element, modification, doc); - - expect(element.parentElement).toBeNull(); - }); - - it("should swap image source correctly", async () => { - const modification: Modification = { - action: "swapImage", - imageUrl: "new-image-url.jpg", - }; - const element = doc.createElement("img"); - element.src = "old-image-url.jpg"; - await applyModification(element, modification, doc); - - expect(element.src).toBe("new-image-url.jpg"); - }); - - it("should highlight element correctly", async () => { - const modification: Modification = { - action: "highlight", - highlightStyle: "2px solid green", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Content

"; - await applyModification(element, modification, doc); - - expect(element.style.border).toBe("2px solid green"); - }); - - it("should create and display a toast correctly", async () => { - const modification: Modification = { - action: "toast", - toastMessage: "Test Notification", - duration: 100, - }; - createToast(modification.toastMessage ?? "", doc, modification.duration); - - // Simulate checking if the toast exists - const toastElement = doc.querySelector(".bg-blue-500"); // Assuming '.bg-blue-500' is the class for the toast - expect(toastElement?.textContent).toBe("Test Notification"); - - // Simulate waiting for the toast to be removed - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(doc.querySelector(".bg-blue-500")).toBeNull(); - }); - - it("should add a component correctly", async () => { - const modification: Modification = { - action: "addComponent", - componentHtml: "Component Content", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Initial Content

"; - await applyModification(element, modification, doc); - - expect(element.innerHTML).toContain("Component Content"); - }); - - it("should handle unknowns correctly", async () => { - const modification: Modification = { - action: "unknown", - componentHtml: "Component Content", - }; - const element = doc.createElement("div"); - element.innerHTML = "

Initial Content

"; - await applyModification(element, modification, doc); - - expect(element.innerHTML).toContain("

Initial Content

"); - }); - }); - - describe("createToast", () => { - it("should create and remove a toast correctly", async () => { - const message = "Test Message"; - createToast(message, doc); - - // Check if the toast exists - const toastElement = doc.querySelector(".bg-blue-500"); - expect(toastElement?.textContent).toBe(message); - - // Wait for the timeout before removing the toast - await new Promise((resolve) => setTimeout(resolve, 3100)); // Adjusted slightly above the 3000ms to account for any delays - - expect(doc.querySelector(".bg-blue-500")).toBeNull(); - }); - }); - - describe("generateModifications", () => { - it("should handle empty selectors gracefully", async () => { - const request: ModificationRequest = { - modifications: [ - { - selector: "", - action: "replace", - content: "

New Content

", - }, - ], - description: "", - }; - - await generateModifications(request, doc); - }); - }); -}); diff --git a/packages/reactor/utils.ts b/packages/reactor/utils.ts index 51742294..4cf95272 100644 --- a/packages/reactor/utils.ts +++ b/packages/reactor/utils.ts @@ -1,4 +1,11 @@ -import type { Modification, ModificationRequest } from "./interfaces"; +// utils.ts +const cssSelector = require("css-selector-generator"); +import type { + AppliedModifications, + Modification, + ModificationRequest, + TimeStampReference, +} from "./interfaces"; export function parseRequest(userRequest: string): ModificationRequest { try { @@ -9,263 +16,3 @@ export function parseRequest(userRequest: string): ModificationRequest { throw new Error("Invalid user request format"); } } - -function calculateNewDate( - originalDay: string, - originalMonth: string, - recordedAt: string, - currentTime: string, -): { newDay: string, newMonth: string } { - const months = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - ]; - const recordedDate = new Date(recordedAt); - const currentDate = new Date(currentTime); - const differenceInDays = Math.ceil( - Math.abs( - (currentDate.getTime() - recordedDate.getTime()) / (1000 * 3600 * 24), - ), - ); - - const originalDate = new Date(recordedDate); - originalDate.setDate(Number.parseInt(originalDay, 10)); - - const newDate = new Date(originalDate); - newDate.setDate(originalDate.getDate() + differenceInDays); - - const newDay = String(newDate.getDate()).padStart(2, "0"); - const newMonth = months[newDate.getMonth()] || originalMonth; - - return { newDay, newMonth }; -} - -export async function generateModifications( - request: ModificationRequest, - doc: Document, -): Promise { - try { - for (const mod of request.modifications) { - let elements: Array; - try { - if (mod.selector) { - elements = Array.from(doc.querySelectorAll(mod.selector)); - } else if (mod.xpath) { - // construct a new NodeListOf from items found by the xpath - elements = []; - if (!mod.xpath.startsWith("//html")) { - mod.xpath = `//html/${mod.xpath}`; - } - const xpath = document.evaluate( - mod.xpath, - doc, - null, - XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, - null, - ); - for (let i = 0; i < xpath.snapshotLength; i++) { - const item = xpath.snapshotItem(i); - if (item !== null && item instanceof Element) { - elements.push(item); - } - } - } else { - console.warn("No selector provided for modification."); - continue; - } - } catch (e) { - console.warn( - `Invalid selector: ${mod.selector ? mod.selector : mod.xpath}`, - ); - continue; - } - - if (elements.length === 0) { - console.warn( - `Element not found for selector: ${ - mod.selector ? mod.selector : mod.xpath - }`, - ); - continue; - } - - for (const element of elements) { - await applyModification(element, mod, doc); - } - - // Add a small delay between modifications - await new Promise((resolve) => setTimeout(resolve, 100)); - } - // biome-ignore lint/suspicious/noExplicitAny: exception handling - } catch (error: any) { - console.error("Error generating modifications:", error); - throw new Error(`Error generating modifications: ${error}`); - } -} - -export async function applyModification( - element: Element, - mod: Modification, - doc: Document, -): Promise { - switch (mod.action) { - case "replace": - element.innerHTML = mod.content || ""; - break; - case "replaceAll": - walkTree(element, replaceText(mod.content || "")); - break; - case "append": - element.insertAdjacentHTML("beforeend", mod.content || ""); - break; - case "prepend": - element.insertAdjacentHTML("afterbegin", mod.content || ""); - break; - case "remove": - element.remove(); - break; - case "swapImage": - if (element instanceof HTMLImageElement) { - element.src = mod.imageUrl || ""; - } - break; - case "highlight": - if (element instanceof HTMLElement) { - element.style.border = mod.highlightStyle || "2px solid red"; - } - break; - case "toast": - createToast(mod.toastMessage || "Notification", doc, mod.duration); - break; - case "addComponent": - element.insertAdjacentHTML("beforeend", mod.componentHtml || ""); - break; - case "updateTimestampReferences": { - if (!mod.timestampRef) { - console.warn("No timestamp reference provided for modification."); - return; - } - let targetElement = element; - if (mod.selector) { - targetElement = element.querySelector(mod.selector) || element; - } - if (!targetElement) { - console.warn( - `Element not found for selector: ${mod.selector || "self"}`, - ); - return; - } - const originalText = targetElement.textContent || ""; - const originalLabel = targetElement.getAttribute("aria-label") || ""; - const [originalMonth, originalDay] = originalText.split(" "); - - if (!originalMonth || !originalDay) { - console.warn(`Invalid date format: ${originalText}`); - return; - } - - // Calculate the new day and month based on the timestampRef - const { newDay, newMonth } = calculateNewDate( - originalDay, - originalMonth, - mod.timestampRef.recordedAt, - mod.timestampRef.currentTime, - ); - - // Update the element's textContent and aria-label with the new day and month - targetElement.textContent = `${newMonth} ${newDay}`; - // Note the space before the month to avoid concatenation with the day - const newLabel = originalLabel.replace(/ .+,/, ` ${newMonth} ${newDay},`); - targetElement.setAttribute("aria-label", newLabel); - - break; - } - default: - console.warn(`Unknown action: ${mod.action}`); - } -} - -export function createToast( - message: string, - doc: Document, - duration = 3000, -): void { - const toast = doc.createElement("div"); - toast.className = "fixed bottom-4 right-4 bg-blue-500 text-white p-4 rounded"; - toast.textContent = message; - doc.body.appendChild(toast); - - setTimeout(() => { - toast.remove(); - }, duration); -} - -function walkTree(rootElement: Node, iterator: (textNode: Node) => void) { - const treeWalker = document.createTreeWalker( - rootElement, - NodeFilter.SHOW_TEXT, - (node) => { - if ( - node.parentElement instanceof HTMLScriptElement || - node.parentElement instanceof HTMLStyleElement - ) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }, - ); - let textNode: Node; - do { - textNode = treeWalker.currentNode; - if (textNode.nodeValue === null || !textNode?.textContent?.trim()) { - continue; - } - - iterator(textNode); - } while (treeWalker.nextNode()); -} - -function replaceText(pattern: string): (node: Node) => void { - const { patternRegexp, replacement } = toRegExpPattern(pattern); - - return (node: Node) => { - if (!node.textContent || !node.nodeValue) { - return; - } - - if (patternRegexp.test(node.textContent)) { - node.nodeValue = node.nodeValue.replace( - patternRegexp, - replaceFirstLetterCase(replacement), - ); - } - }; -} - -function replaceFirstLetterCase(value: string) { - return (match: string) => { - if (match[0]?.toLowerCase() !== match[0]?.toUpperCase()) { - // Check if the first character is alphabetical - if (match[0] === match[0]?.toUpperCase()) { - return value.charAt(0).toUpperCase() + value.slice(1); - } - } - return value; - }; -} - -// Take pattern in the form of /pattern/replacement/ and return {patternRegexp, replacement} -function toRegExpPattern(pattern: string): { - patternRegexp: RegExp; - replacement: string; -} { - const match = /\/(.+)\/(.+)\//.exec(pattern); - if (!match || match.length !== 3 || !match[1] || !match[2]) { - throw new Error(`Invalid pattern: ${pattern}`); - } - - return { - patternRegexp: new RegExp(match[1], "gi"), - replacement: match[2], - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a954c18..ba7b89b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: packages/reactor: dependencies: + css-selector-generator: + specifier: ^3.6.8 + version: 3.6.8 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -2650,6 +2653,9 @@ packages: peerDependencies: postcss: ^8.4 + css-selector-generator@3.6.8: + resolution: {integrity: sha512-LFWoA20j0rcwGUa38OD6qFaQGKLpFG1xBUzx+wJr/0++34aJ71/YIw2jj6qOaVxiaCEQNrj3HOSepVwiShvyhg==} + css-selector-tokenizer@0.8.0: resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==} @@ -9195,6 +9201,8 @@ snapshots: dependencies: postcss: 8.4.39 + css-selector-generator@3.6.8: {} + css-selector-tokenizer@0.8.0: dependencies: cssesc: 3.0.0