From 6a028359c43789f4946a8f22b22edec21de0c764 Mon Sep 17 00:00:00 2001 From: Miorel-Lucian Palii Date: Sun, 13 Oct 2024 18:16:11 -0700 Subject: [PATCH] Mark everything easy on LeetCode user profile problems solved section --- .../adventure-pack/src/app/stringifyGoody.ts | 4 +- .../adventure-pack/src/app/useAppState.ts | 4 +- .../leetcode-zen-mode/src/extension/main.ts | 4 +- .../src/extension/patchJsxFactory.ts | 56 +++++---- .../src/extension/patchWebpackChunkLoading.ts | 4 +- .../src/extension/rewriteGraphQLData.ts | 20 ---- ...writeLeetCodeAggregateDataForDifficulty.ts | 112 ++++++++++++++++++ .../extension/rewriteLeetCodeGraphQLData.ts | 36 ++++++ .../src/extension/stringCase.ts | 13 ++ workspaces/util/src/and.ts | 13 ++ workspaces/util/src/groupBy.ts | 21 ++++ workspaces/util/src/isArrayOfNumbers.ts | 5 + workspaces/util/src/isNonArrayObject.ts | 5 + workspaces/util/src/isNumber.ts | 3 + workspaces/util/src/isObject.ts | 1 + workspaces/util/src/isStringLowerCase.ts | 3 + workspaces/util/src/isStringTitleCase.ts | 5 + workspaces/util/src/isStringUpperCase.ts | 3 + workspaces/util/src/mergeObjects.ts | 35 ++++++ workspaces/util/src/or.ts | 13 ++ workspaces/util/src/stringToCase.ts | 22 ++++ workspaces/util/src/sum.ts | 9 ++ workspaces/util/src/toTitleCase.ts | 6 + 23 files changed, 345 insertions(+), 52 deletions(-) delete mode 100644 workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts create mode 100644 workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeAggregateDataForDifficulty.ts create mode 100644 workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts create mode 100644 workspaces/leetcode-zen-mode/src/extension/stringCase.ts create mode 100644 workspaces/util/src/and.ts create mode 100644 workspaces/util/src/groupBy.ts create mode 100644 workspaces/util/src/isArrayOfNumbers.ts create mode 100644 workspaces/util/src/isNonArrayObject.ts create mode 100644 workspaces/util/src/isNumber.ts create mode 100644 workspaces/util/src/isStringLowerCase.ts create mode 100644 workspaces/util/src/isStringTitleCase.ts create mode 100644 workspaces/util/src/isStringUpperCase.ts create mode 100644 workspaces/util/src/mergeObjects.ts create mode 100644 workspaces/util/src/or.ts create mode 100644 workspaces/util/src/stringToCase.ts create mode 100644 workspaces/util/src/sum.ts create mode 100644 workspaces/util/src/toTitleCase.ts diff --git a/workspaces/adventure-pack/src/app/stringifyGoody.ts b/workspaces/adventure-pack/src/app/stringifyGoody.ts index 654bf39b..3bbc063d 100644 --- a/workspaces/adventure-pack/src/app/stringifyGoody.ts +++ b/workspaces/adventure-pack/src/app/stringifyGoody.ts @@ -42,8 +42,8 @@ export function stringifyGoody(goody: Goody): string { } } - // @ts-expect-error Switch should be exhaustive. + // @ts-expect-error Unreachable code, switch should be exhaustive. console.error("Unsupported goody language:", goody); - // @ts-expect-error Switch should be exhaustive. + // @ts-expect-error Unreachable code, switch should be exhaustive. return goody.code; } diff --git a/workspaces/adventure-pack/src/app/useAppState.ts b/workspaces/adventure-pack/src/app/useAppState.ts index 0a74aaad..b96a7c31 100644 --- a/workspaces/adventure-pack/src/app/useAppState.ts +++ b/workspaces/adventure-pack/src/app/useAppState.ts @@ -68,9 +68,9 @@ function reducer(state: AppState, action: Action): AppState { } } - // @ts-expect-error Switch should be exhaustive. + // @ts-expect-error Unreachable code, switch should be exhaustive. console.error("Unhandled action type:", action); - // @ts-expect-error Switch should be exhaustive. + // @ts-expect-error Unreachable code, switch should be exhaustive. return state; } diff --git a/workspaces/leetcode-zen-mode/src/extension/main.ts b/workspaces/leetcode-zen-mode/src/extension/main.ts index 1e715e38..8979efc8 100644 --- a/workspaces/leetcode-zen-mode/src/extension/main.ts +++ b/workspaces/leetcode-zen-mode/src/extension/main.ts @@ -2,7 +2,7 @@ import { mapJsonBlobData } from "@code-chronicles/util/mapJsonBlobData"; import { injectXhrBlobResponseMiddleware } from "./injectXhrBlobResponseMiddleware.ts"; import { patchWebpackChunkLoading } from "./patchWebpackChunkLoading.ts"; -import { rewriteGraphQLData } from "./rewriteGraphQLData.ts"; +import { rewriteLeetCodeGraphQLData } from "./rewriteLeetCodeGraphQLData.ts"; function main() { // LeetCode's GraphQL client makes requests through `XMLHttpRequest`, then @@ -13,7 +13,7 @@ function main() { injectXhrBlobResponseMiddleware((xhr, blob) => { if (xhr.responseURL === "https://leetcode.com/graphql/") { try { - return mapJsonBlobData(blob, rewriteGraphQLData); + return mapJsonBlobData(blob, rewriteLeetCodeGraphQLData); } catch (err) { console.error(err); } diff --git a/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts b/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts index 0f8794e0..26f7acd8 100644 --- a/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts +++ b/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts @@ -1,47 +1,53 @@ +import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; +import { isString } from "@code-chronicles/util/isString"; + function Null() { return null; } -export function patchJsxFactory( - createFn: (this: TThis, ...args: TArgs) => TRes, -): (this: TThis, ...args: TArgs) => TRes { - return function () { - try { - const props = arguments[1] ?? {}; +type CreateElementFn = ( + this: unknown, + elementType: unknown, + props: unknown, + ...children: unknown[] +) => unknown; +export function patchJsxFactory( + createElementFn: CreateElementFn, +): CreateElementFn { + return function (_elementType, props) { + try { // Remove the Difficulty dropdown on `/problemset/`. The dropdown is // implemented as a React element with an `items` prop which is an // array. We'll replace it with a React element that renders nothing. if ( + isNonArrayObject(props) && Array.isArray(props.items) && - props.items.some((it: Record) => it.value === "EASY") + props.items.some( + (it: Record) => + isString(it.value) && /^easy$/i.test(it.value), + ) ) { - return createFn.apply(this, [Null, {}] as Parameters); + return createElementFn.apply(this, [Null, {}]); } - // Update the session progress component on `/problemset/` to show a - // single entry, based on the total number of problems. - if (props.userSessionProgress) { - for (const key of ["progresses", "submitPercentages"]) { - if (Array.isArray(props.userSessionProgress[key])) { - const total = props.userSessionProgress[key].find( - (it: Record) => it.difficulty === "TOTAL", - ); - - props.userSessionProgress[key] = [ - total, - { ...total, difficulty: "EASY" }, - ]; - } - } + // Remove the non-Easy sections of the problems solved panel on user + // profiles. These are implemented as React elements with a `category` + // prop which is a problem difficulty. + if ( + isNonArrayObject(props) && + isString(props.category) && + /^(?:medium|hard)$/i.test(props.category) + ) { + return createElementFn.apply(this, [Null, {}]); } } catch (err) { console.error(err); } - return createFn.apply( + return createElementFn.apply( this, - Array.from(arguments) as Parameters, + arguments as unknown as Parameters, ); }; } diff --git a/workspaces/leetcode-zen-mode/src/extension/patchWebpackChunkLoading.ts b/workspaces/leetcode-zen-mode/src/extension/patchWebpackChunkLoading.ts index 46720673..a42490e7 100644 --- a/workspaces/leetcode-zen-mode/src/extension/patchWebpackChunkLoading.ts +++ b/workspaces/leetcode-zen-mode/src/extension/patchWebpackChunkLoading.ts @@ -1,3 +1,5 @@ +import { isString } from "@code-chronicles/util/isString"; + import { patchPageModule } from "./patchPageModule.ts"; /** @@ -14,7 +16,7 @@ export function patchWebpackChunkLoading() { // subscribe to pushes into this array. window.self = new Proxy(originalSelf, { set(target, prop, webpackChunk) { - if (typeof prop === "string" && prop.startsWith("webpackChunk")) { + if (isString(prop) && prop.startsWith("webpackChunk")) { // Once we've found the magic `webpack` array we remove the proxy, // parts of the page seem to break without this. window.self = originalSelf; diff --git a/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts b/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts deleted file mode 100644 index da972fb0..00000000 --- a/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { JsonValue } from "type-fest"; - -import { isObject } from "@code-chronicles/util/isObject"; -import { mapObjectValues } from "@code-chronicles/util/mapObjectValues"; - -export function rewriteGraphQLData(value: JsonValue): JsonValue { - if (Array.isArray(value)) { - return value.map(rewriteGraphQLData); - } - - if (isObject(value)) { - return mapObjectValues(value, rewriteGraphQLData); - } - - if (value === "Hard" || value === "Medium") { - return "Easy"; - } - - return value; -} diff --git a/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeAggregateDataForDifficulty.ts b/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeAggregateDataForDifficulty.ts new file mode 100644 index 00000000..f99afa21 --- /dev/null +++ b/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeAggregateDataForDifficulty.ts @@ -0,0 +1,112 @@ +import type { JsonArray, JsonObject } from "type-fest"; +import nullthrows from "nullthrows"; + +import { firstOrThrow } from "@code-chronicles/util/firstOrThrow"; +import { groupBy } from "@code-chronicles/util/groupBy"; +import { isArrayOfNumbers } from "@code-chronicles/util/isArrayOfNumbers"; +import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; +import { isString } from "@code-chronicles/util/isString"; +import { mergeObjects } from "@code-chronicles/util/mergeObjects"; +import { only } from "@code-chronicles/util/only"; +import { sum } from "@code-chronicles/util/sum"; +import { stringToCase, type Case } from "@code-chronicles/util/stringToCase"; + +import { PREFERRED_STRING_CASE, STRING_CASE_CHECKERS } from "./stringCase.ts"; + +function isArrayOfDataByDifficulty( + arr: JsonArray, +): arr is ({ difficulty: string } & JsonObject)[] { + return arr.every( + (elem) => + isNonArrayObject(elem) && + Object.hasOwn(elem, "difficulty") && + isString(elem.difficulty), + ); +} + +/** + * Some of the LeetCode GraphQL data is aggregate statistics about problems + * by difficulty. This function detects instances of this and tries to + * re-aggregate. + */ +export function rewriteLeetCodeAggregateDataForDifficulty( + arr: JsonArray, +): JsonArray { + // Do nothing if it's not the kind of data we're looking for. + if (!isArrayOfDataByDifficulty(arr)) { + return arr; + } + + // Detect the casing of the difficulties. It could be "Easy", "EASY", or + // perhaps even "easy". + const difficultyStringCase = ((): Case => { + for (const [stringCase, checker] of STRING_CASE_CHECKERS) { + if (arr.every((elem) => checker(elem.difficulty))) { + return stringCase; + } + } + + return PREFERRED_STRING_CASE; + })(); + + // Prepare some difficulty strings that will come in handy below. + const allDifficulty = stringToCase("all", difficultyStringCase); + const easyDifficulty = stringToCase("easy", difficultyStringCase); + + const elementsByDifficulty = groupBy(arr, (elem) => + stringToCase(elem.difficulty, difficultyStringCase), + ); + + // If we have a single "All" item and items with difficulties besides + // "All" and "Easy", we will get rid of the extra items, and instead use + // a single "Easy" item that's a copy of the "All" item with an updated + // difficulty. + if ( + elementsByDifficulty.get(allDifficulty)?.length === 1 && + [...elementsByDifficulty.keys()].some( + (difficulty) => + difficulty !== allDifficulty && difficulty !== easyDifficulty, + ) + ) { + const allElement = only( + nullthrows(elementsByDifficulty.get(allDifficulty)), + ); + return [allElement, { ...allElement, difficulty: easyDifficulty }]; + } + + // Another option is that we don't have an "All" item. In this case we + // will merge the elements into a single one, summing numeric values when + // the same key appears in multiple elements, and averaging percentages. + // + // We also check that there's at most one entry for each difficulty, because + // if there is more than one then this might not be aggregated data, it's + // probably just a list of questions, which will be rewritten separately. + if ( + [...elementsByDifficulty.values()].every((group) => group.length === 1) && + [...elementsByDifficulty.keys()].some( + (difficulty) => difficulty !== easyDifficulty, + ) + ) { + return [ + mergeObjects(arr, (values, key) => { + if (key === "difficulty") { + return easyDifficulty; + } + + if (isArrayOfNumbers(values)) { + const total = sum(values); + + if (key === "percentage") { + return total / (values.length || 1); + } + + return total; + } + + return firstOrThrow(values); + }), + ]; + } + + return arr; +} diff --git a/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts b/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts new file mode 100644 index 00000000..5e503e6c --- /dev/null +++ b/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts @@ -0,0 +1,36 @@ +import type { JsonValue } from "type-fest"; + +import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; +import { isString } from "@code-chronicles/util/isString"; +import { mapObjectValues } from "@code-chronicles/util/mapObjectValues"; +import { stringToCase } from "@code-chronicles/util/stringToCase"; + +import { rewriteLeetCodeAggregateDataForDifficulty } from "./rewriteLeetCodeAggregateDataForDifficulty.ts"; + +import { PREFERRED_STRING_CASE, STRING_CASE_CHECKERS } from "./stringCase.ts"; + +export function rewriteLeetCodeGraphQLData(value: JsonValue): JsonValue { + if (Array.isArray(value)) { + // Arrays get some extra processing. + const rewrittenValue = rewriteLeetCodeAggregateDataForDifficulty(value); + + // Recursively process array values. + return rewrittenValue.map(rewriteLeetCodeGraphQLData); + } + + if (isNonArrayObject(value)) { + // Recursively process object values. + return mapObjectValues(value, rewriteLeetCodeGraphQLData); + } + + // Rewrite difficulty strings! + if (isString(value) && /^(?:medium|hard)$/i.test(value)) { + const stringCase = + STRING_CASE_CHECKERS.find(([, checker]) => checker(value))?.[0] ?? + PREFERRED_STRING_CASE; + return stringToCase("easy", stringCase); + } + + // Pass everything else through unchanged. + return value; +} diff --git a/workspaces/leetcode-zen-mode/src/extension/stringCase.ts b/workspaces/leetcode-zen-mode/src/extension/stringCase.ts new file mode 100644 index 00000000..c9e1fe11 --- /dev/null +++ b/workspaces/leetcode-zen-mode/src/extension/stringCase.ts @@ -0,0 +1,13 @@ +import { isStringTitleCase } from "@code-chronicles/util/isStringTitleCase"; +import { isStringUpperCase } from "@code-chronicles/util/isStringUpperCase"; +import { isStringLowerCase } from "@code-chronicles/util/isStringLowerCase"; + +// Note: The order is significant, earlier entries will take precedence in our +// checks. +export const STRING_CASE_CHECKERS = [ + ["title", isStringTitleCase], + ["upper", isStringUpperCase], + ["lower", isStringLowerCase], +] as const; + +export const PREFERRED_STRING_CASE = STRING_CASE_CHECKERS[0][0]; diff --git a/workspaces/util/src/and.ts b/workspaces/util/src/and.ts new file mode 100644 index 00000000..369149f8 --- /dev/null +++ b/workspaces/util/src/and.ts @@ -0,0 +1,13 @@ +export function and(values: readonly [T, ...T[]]): T; +export function and(values: Iterable): T | undefined; +export function and(values: Iterable): T | undefined { + let value: T | undefined; + + for (value of values) { + if (!value) { + return value; + } + } + + return value; +} diff --git a/workspaces/util/src/groupBy.ts b/workspaces/util/src/groupBy.ts new file mode 100644 index 00000000..b2e04635 --- /dev/null +++ b/workspaces/util/src/groupBy.ts @@ -0,0 +1,21 @@ +// TODO: keep in sync with `Map.groupBy` goody + +export function groupBy( + iterable: Iterable, + callbackFn: (value: V, index: number) => K, +): Map { + const groups = new Map(); + + let index = 0; + for (const value of iterable) { + const key = callbackFn(value, index++); + const group = groups.get(key); + if (group == null) { + groups.set(key, [value]); + } else { + group.push(value); + } + } + + return groups; +} diff --git a/workspaces/util/src/isArrayOfNumbers.ts b/workspaces/util/src/isArrayOfNumbers.ts new file mode 100644 index 00000000..e5a594d3 --- /dev/null +++ b/workspaces/util/src/isArrayOfNumbers.ts @@ -0,0 +1,5 @@ +import { isNumber } from "@code-chronicles/util/isNumber"; + +export function isArrayOfNumbers(value: unknown): value is number[] { + return Array.isArray(value) && value.every(isNumber); +} diff --git a/workspaces/util/src/isNonArrayObject.ts b/workspaces/util/src/isNonArrayObject.ts new file mode 100644 index 00000000..d794d6a4 --- /dev/null +++ b/workspaces/util/src/isNonArrayObject.ts @@ -0,0 +1,5 @@ +export function isNonArrayObject( + value: unknown, +): value is Partial> { + return value != null && typeof value === "object" && !Array.isArray(value); +} diff --git a/workspaces/util/src/isNumber.ts b/workspaces/util/src/isNumber.ts new file mode 100644 index 00000000..12a33c65 --- /dev/null +++ b/workspaces/util/src/isNumber.ts @@ -0,0 +1,3 @@ +export function isNumber(value: unknown): value is number { + return typeof value === "number"; +} diff --git a/workspaces/util/src/isObject.ts b/workspaces/util/src/isObject.ts index 29564a31..ff91ca1c 100644 --- a/workspaces/util/src/isObject.ts +++ b/workspaces/util/src/isObject.ts @@ -1,3 +1,4 @@ +// TODO: deprecate in favor of the clearer name `isNonArrayObject` export function isObject( value: unknown, ): value is Record { diff --git a/workspaces/util/src/isStringLowerCase.ts b/workspaces/util/src/isStringLowerCase.ts new file mode 100644 index 00000000..f1f81f60 --- /dev/null +++ b/workspaces/util/src/isStringLowerCase.ts @@ -0,0 +1,3 @@ +export function isStringLowerCase(s: string): boolean { + return s === s.toLowerCase(); +} diff --git a/workspaces/util/src/isStringTitleCase.ts b/workspaces/util/src/isStringTitleCase.ts new file mode 100644 index 00000000..e1eb3972 --- /dev/null +++ b/workspaces/util/src/isStringTitleCase.ts @@ -0,0 +1,5 @@ +import { toTitleCase } from "@code-chronicles/util/toTitleCase"; + +export function isStringTitleCase(s: string): boolean { + return s === toTitleCase(s); +} diff --git a/workspaces/util/src/isStringUpperCase.ts b/workspaces/util/src/isStringUpperCase.ts new file mode 100644 index 00000000..e89749f7 --- /dev/null +++ b/workspaces/util/src/isStringUpperCase.ts @@ -0,0 +1,3 @@ +export function isStringUpperCase(s: string): boolean { + return s === s.toUpperCase(); +} diff --git a/workspaces/util/src/mergeObjects.ts b/workspaces/util/src/mergeObjects.ts new file mode 100644 index 00000000..783df292 --- /dev/null +++ b/workspaces/util/src/mergeObjects.ts @@ -0,0 +1,35 @@ +import { groupBy } from "@code-chronicles/util/groupBy"; +import { only } from "@code-chronicles/util/only"; + +export function mergeObjects>( + objects: Iterable, + mergeFn: ( + values: [TObj[TKey], TObj[TKey], ...TObj[TKey][]], + key: TKey, + ) => TObj[TKey], +): TObj { + const groupedEntries = [ + ...groupBy( + [...objects].flatMap(Object.entries) as [string, TObj][], + ([key]) => key, + ), + ]; + + return Object.fromEntries( + groupedEntries.map(([key, entries]) => + entries.length === 1 + ? only(entries) + : [ + key, + mergeFn( + entries.map(([, value]) => value) as [ + TObj[typeof key], + TObj[typeof key], + ...TObj[typeof key][], + ], + key, + ), + ], + ), + ) as TObj; +} diff --git a/workspaces/util/src/or.ts b/workspaces/util/src/or.ts new file mode 100644 index 00000000..6516ef46 --- /dev/null +++ b/workspaces/util/src/or.ts @@ -0,0 +1,13 @@ +export function or(values: readonly [T, ...T[]]): T; +export function or(values: Iterable): T | undefined; +export function or(values: Iterable): T | undefined { + let value: T | undefined; + + for (value of values) { + if (value) { + return value; + } + } + + return value; +} diff --git a/workspaces/util/src/stringToCase.ts b/workspaces/util/src/stringToCase.ts new file mode 100644 index 00000000..020277ac --- /dev/null +++ b/workspaces/util/src/stringToCase.ts @@ -0,0 +1,22 @@ +import { toTitleCase } from "@code-chronicles/util/toTitleCase"; + +export type Case = "lower" | "title" | "upper"; + +export function stringToCase(s: string, stringCase: Case): string { + switch (stringCase) { + case "lower": { + return s.toLowerCase(); + } + case "title": { + return toTitleCase(s); + } + case "upper": { + return s.toUpperCase(); + } + } + + // @ts-expect-error Unreachable code, switch should be exhaustive. + console.error(`Unsupported case: ${stringCase}`); + // @ts-expect-error Unreachable code, switch should be exhaustive. + return s; +} diff --git a/workspaces/util/src/sum.ts b/workspaces/util/src/sum.ts new file mode 100644 index 00000000..32920240 --- /dev/null +++ b/workspaces/util/src/sum.ts @@ -0,0 +1,9 @@ +export function sum(nums: Iterable): number { + let res = 0; + + for (const num of nums) { + res += num; + } + + return res; +} diff --git a/workspaces/util/src/toTitleCase.ts b/workspaces/util/src/toTitleCase.ts new file mode 100644 index 00000000..02d4fbd9 --- /dev/null +++ b/workspaces/util/src/toTitleCase.ts @@ -0,0 +1,6 @@ +export function toTitleCase(s: string): string { + return s.replaceAll( + /\S+/g, + (word) => word[0].toUpperCase() + word.slice(1).toLowerCase(), + ); +}