Skip to content

Commit a42843d

Browse files
authored
Mark everything easy on LeetCode user profile problems solved section (#466)
This was a bit more involved, because there was a GraphQL piece as well as a React element piece.
1 parent ad7c4d0 commit a42843d

23 files changed

+345
-52
lines changed

workspaces/adventure-pack/src/app/stringifyGoody.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export function stringifyGoody(goody: Goody): string {
4242
}
4343
}
4444

45-
// @ts-expect-error Switch should be exhaustive.
45+
// @ts-expect-error Unreachable code, switch should be exhaustive.
4646
console.error("Unsupported goody language:", goody);
47-
// @ts-expect-error Switch should be exhaustive.
47+
// @ts-expect-error Unreachable code, switch should be exhaustive.
4848
return goody.code;
4949
}

workspaces/adventure-pack/src/app/useAppState.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ function reducer(state: AppState, action: Action): AppState {
6868
}
6969
}
7070

71-
// @ts-expect-error Switch should be exhaustive.
71+
// @ts-expect-error Unreachable code, switch should be exhaustive.
7272
console.error("Unhandled action type:", action);
73-
// @ts-expect-error Switch should be exhaustive.
73+
// @ts-expect-error Unreachable code, switch should be exhaustive.
7474
return state;
7575
}
7676

workspaces/leetcode-zen-mode/src/extension/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mapJsonBlobData } from "@code-chronicles/util/mapJsonBlobData";
22

33
import { injectXhrBlobResponseMiddleware } from "./injectXhrBlobResponseMiddleware.ts";
44
import { patchWebpackChunkLoading } from "./patchWebpackChunkLoading.ts";
5-
import { rewriteGraphQLData } from "./rewriteGraphQLData.ts";
5+
import { rewriteLeetCodeGraphQLData } from "./rewriteLeetCodeGraphQLData.ts";
66

77
function main() {
88
// LeetCode's GraphQL client makes requests through `XMLHttpRequest`, then
@@ -13,7 +13,7 @@ function main() {
1313
injectXhrBlobResponseMiddleware((xhr, blob) => {
1414
if (xhr.responseURL === "https://leetcode.com/graphql/") {
1515
try {
16-
return mapJsonBlobData(blob, rewriteGraphQLData);
16+
return mapJsonBlobData(blob, rewriteLeetCodeGraphQLData);
1717
} catch (err) {
1818
console.error(err);
1919
}
Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,53 @@
1+
import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject";
2+
import { isString } from "@code-chronicles/util/isString";
3+
14
function Null() {
25
return null;
36
}
47

5-
export function patchJsxFactory<TThis, TArgs extends unknown[], TRes>(
6-
createFn: (this: TThis, ...args: TArgs) => TRes,
7-
): (this: TThis, ...args: TArgs) => TRes {
8-
return function () {
9-
try {
10-
const props = arguments[1] ?? {};
8+
type CreateElementFn = (
9+
this: unknown,
10+
elementType: unknown,
11+
props: unknown,
12+
...children: unknown[]
13+
) => unknown;
1114

15+
export function patchJsxFactory(
16+
createElementFn: CreateElementFn,
17+
): CreateElementFn {
18+
return function (_elementType, props) {
19+
try {
1220
// Remove the Difficulty dropdown on `/problemset/`. The dropdown is
1321
// implemented as a React element with an `items` prop which is an
1422
// array. We'll replace it with a React element that renders nothing.
1523
if (
24+
isNonArrayObject(props) &&
1625
Array.isArray(props.items) &&
17-
props.items.some((it: Record<string, unknown>) => it.value === "EASY")
26+
props.items.some(
27+
(it: Record<string, unknown>) =>
28+
isString(it.value) && /^easy$/i.test(it.value),
29+
)
1830
) {
19-
return createFn.apply(this, [Null, {}] as Parameters<typeof createFn>);
31+
return createElementFn.apply(this, [Null, {}]);
2032
}
2133

22-
// Update the session progress component on `/problemset/` to show a
23-
// single entry, based on the total number of problems.
24-
if (props.userSessionProgress) {
25-
for (const key of ["progresses", "submitPercentages"]) {
26-
if (Array.isArray(props.userSessionProgress[key])) {
27-
const total = props.userSessionProgress[key].find(
28-
(it: Record<string, unknown>) => it.difficulty === "TOTAL",
29-
);
30-
31-
props.userSessionProgress[key] = [
32-
total,
33-
{ ...total, difficulty: "EASY" },
34-
];
35-
}
36-
}
34+
// Remove the non-Easy sections of the problems solved panel on user
35+
// profiles. These are implemented as React elements with a `category`
36+
// prop which is a problem difficulty.
37+
if (
38+
isNonArrayObject(props) &&
39+
isString(props.category) &&
40+
/^(?:medium|hard)$/i.test(props.category)
41+
) {
42+
return createElementFn.apply(this, [Null, {}]);
3743
}
3844
} catch (err) {
3945
console.error(err);
4046
}
4147

42-
return createFn.apply(
48+
return createElementFn.apply(
4349
this,
44-
Array.from(arguments) as Parameters<typeof createFn>,
50+
arguments as unknown as Parameters<CreateElementFn>,
4551
);
4652
};
4753
}

workspaces/leetcode-zen-mode/src/extension/patchWebpackChunkLoading.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isString } from "@code-chronicles/util/isString";
2+
13
import { patchPageModule } from "./patchPageModule.ts";
24

35
/**
@@ -14,7 +16,7 @@ export function patchWebpackChunkLoading() {
1416
// subscribe to pushes into this array.
1517
window.self = new Proxy(originalSelf, {
1618
set(target, prop, webpackChunk) {
17-
if (typeof prop === "string" && prop.startsWith("webpackChunk")) {
19+
if (isString(prop) && prop.startsWith("webpackChunk")) {
1820
// Once we've found the magic `webpack` array we remove the proxy,
1921
// parts of the page seem to break without this.
2022
window.self = originalSelf;

workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { JsonArray, JsonObject } from "type-fest";
2+
import nullthrows from "nullthrows";
3+
4+
import { firstOrThrow } from "@code-chronicles/util/firstOrThrow";
5+
import { groupBy } from "@code-chronicles/util/groupBy";
6+
import { isArrayOfNumbers } from "@code-chronicles/util/isArrayOfNumbers";
7+
import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject";
8+
import { isString } from "@code-chronicles/util/isString";
9+
import { mergeObjects } from "@code-chronicles/util/mergeObjects";
10+
import { only } from "@code-chronicles/util/only";
11+
import { sum } from "@code-chronicles/util/sum";
12+
import { stringToCase, type Case } from "@code-chronicles/util/stringToCase";
13+
14+
import { PREFERRED_STRING_CASE, STRING_CASE_CHECKERS } from "./stringCase.ts";
15+
16+
function isArrayOfDataByDifficulty(
17+
arr: JsonArray,
18+
): arr is ({ difficulty: string } & JsonObject)[] {
19+
return arr.every(
20+
(elem) =>
21+
isNonArrayObject(elem) &&
22+
Object.hasOwn(elem, "difficulty") &&
23+
isString(elem.difficulty),
24+
);
25+
}
26+
27+
/**
28+
* Some of the LeetCode GraphQL data is aggregate statistics about problems
29+
* by difficulty. This function detects instances of this and tries to
30+
* re-aggregate.
31+
*/
32+
export function rewriteLeetCodeAggregateDataForDifficulty(
33+
arr: JsonArray,
34+
): JsonArray {
35+
// Do nothing if it's not the kind of data we're looking for.
36+
if (!isArrayOfDataByDifficulty(arr)) {
37+
return arr;
38+
}
39+
40+
// Detect the casing of the difficulties. It could be "Easy", "EASY", or
41+
// perhaps even "easy".
42+
const difficultyStringCase = ((): Case => {
43+
for (const [stringCase, checker] of STRING_CASE_CHECKERS) {
44+
if (arr.every((elem) => checker(elem.difficulty))) {
45+
return stringCase;
46+
}
47+
}
48+
49+
return PREFERRED_STRING_CASE;
50+
})();
51+
52+
// Prepare some difficulty strings that will come in handy below.
53+
const allDifficulty = stringToCase("all", difficultyStringCase);
54+
const easyDifficulty = stringToCase("easy", difficultyStringCase);
55+
56+
const elementsByDifficulty = groupBy(arr, (elem) =>
57+
stringToCase(elem.difficulty, difficultyStringCase),
58+
);
59+
60+
// If we have a single "All" item and items with difficulties besides
61+
// "All" and "Easy", we will get rid of the extra items, and instead use
62+
// a single "Easy" item that's a copy of the "All" item with an updated
63+
// difficulty.
64+
if (
65+
elementsByDifficulty.get(allDifficulty)?.length === 1 &&
66+
[...elementsByDifficulty.keys()].some(
67+
(difficulty) =>
68+
difficulty !== allDifficulty && difficulty !== easyDifficulty,
69+
)
70+
) {
71+
const allElement = only(
72+
nullthrows(elementsByDifficulty.get(allDifficulty)),
73+
);
74+
return [allElement, { ...allElement, difficulty: easyDifficulty }];
75+
}
76+
77+
// Another option is that we don't have an "All" item. In this case we
78+
// will merge the elements into a single one, summing numeric values when
79+
// the same key appears in multiple elements, and averaging percentages.
80+
//
81+
// We also check that there's at most one entry for each difficulty, because
82+
// if there is more than one then this might not be aggregated data, it's
83+
// probably just a list of questions, which will be rewritten separately.
84+
if (
85+
[...elementsByDifficulty.values()].every((group) => group.length === 1) &&
86+
[...elementsByDifficulty.keys()].some(
87+
(difficulty) => difficulty !== easyDifficulty,
88+
)
89+
) {
90+
return [
91+
mergeObjects(arr, (values, key) => {
92+
if (key === "difficulty") {
93+
return easyDifficulty;
94+
}
95+
96+
if (isArrayOfNumbers(values)) {
97+
const total = sum(values);
98+
99+
if (key === "percentage") {
100+
return total / (values.length || 1);
101+
}
102+
103+
return total;
104+
}
105+
106+
return firstOrThrow(values);
107+
}),
108+
];
109+
}
110+
111+
return arr;
112+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { JsonValue } from "type-fest";
2+
3+
import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject";
4+
import { isString } from "@code-chronicles/util/isString";
5+
import { mapObjectValues } from "@code-chronicles/util/mapObjectValues";
6+
import { stringToCase } from "@code-chronicles/util/stringToCase";
7+
8+
import { rewriteLeetCodeAggregateDataForDifficulty } from "./rewriteLeetCodeAggregateDataForDifficulty.ts";
9+
10+
import { PREFERRED_STRING_CASE, STRING_CASE_CHECKERS } from "./stringCase.ts";
11+
12+
export function rewriteLeetCodeGraphQLData(value: JsonValue): JsonValue {
13+
if (Array.isArray(value)) {
14+
// Arrays get some extra processing.
15+
const rewrittenValue = rewriteLeetCodeAggregateDataForDifficulty(value);
16+
17+
// Recursively process array values.
18+
return rewrittenValue.map(rewriteLeetCodeGraphQLData);
19+
}
20+
21+
if (isNonArrayObject(value)) {
22+
// Recursively process object values.
23+
return mapObjectValues(value, rewriteLeetCodeGraphQLData);
24+
}
25+
26+
// Rewrite difficulty strings!
27+
if (isString(value) && /^(?:medium|hard)$/i.test(value)) {
28+
const stringCase =
29+
STRING_CASE_CHECKERS.find(([, checker]) => checker(value))?.[0] ??
30+
PREFERRED_STRING_CASE;
31+
return stringToCase("easy", stringCase);
32+
}
33+
34+
// Pass everything else through unchanged.
35+
return value;
36+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { isStringTitleCase } from "@code-chronicles/util/isStringTitleCase";
2+
import { isStringUpperCase } from "@code-chronicles/util/isStringUpperCase";
3+
import { isStringLowerCase } from "@code-chronicles/util/isStringLowerCase";
4+
5+
// Note: The order is significant, earlier entries will take precedence in our
6+
// checks.
7+
export const STRING_CASE_CHECKERS = [
8+
["title", isStringTitleCase],
9+
["upper", isStringUpperCase],
10+
["lower", isStringLowerCase],
11+
] as const;
12+
13+
export const PREFERRED_STRING_CASE = STRING_CASE_CHECKERS[0][0];

workspaces/util/src/and.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function and<T>(values: readonly [T, ...T[]]): T;
2+
export function and<T>(values: Iterable<T>): T | undefined;
3+
export function and<T>(values: Iterable<T>): T | undefined {
4+
let value: T | undefined;
5+
6+
for (value of values) {
7+
if (!value) {
8+
return value;
9+
}
10+
}
11+
12+
return value;
13+
}

0 commit comments

Comments
 (0)