Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions workspaces/adventure-pack/src/app/stringifyGoody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions workspaces/adventure-pack/src/app/useAppState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions workspaces/leetcode-zen-mode/src/extension/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
56 changes: 31 additions & 25 deletions workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts
Original file line number Diff line number Diff line change
@@ -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<TThis, TArgs extends unknown[], TRes>(
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<string, unknown>) => it.value === "EASY")
props.items.some(
(it: Record<string, unknown>) =>
isString(it.value) && /^easy$/i.test(it.value),
)
) {
return createFn.apply(this, [Null, {}] as Parameters<typeof createFn>);
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<string, unknown>) => 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<typeof createFn>,
arguments as unknown as Parameters<CreateElementFn>,
);
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isString } from "@code-chronicles/util/isString";

import { patchPageModule } from "./patchPageModule.ts";

/**
Expand All @@ -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;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions workspaces/leetcode-zen-mode/src/extension/stringCase.ts
Original file line number Diff line number Diff line change
@@ -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];
13 changes: 13 additions & 0 deletions workspaces/util/src/and.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function and<T>(values: readonly [T, ...T[]]): T;
export function and<T>(values: Iterable<T>): T | undefined;
export function and<T>(values: Iterable<T>): T | undefined {
let value: T | undefined;

for (value of values) {
if (!value) {
return value;
}
}

return value;
}
21 changes: 21 additions & 0 deletions workspaces/util/src/groupBy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// TODO: keep in sync with `Map.groupBy` goody

export function groupBy<K, V>(
iterable: Iterable<V>,
callbackFn: (value: V, index: number) => K,
): Map<K, V[]> {
const groups = new Map<K, V[]>();

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;
}
5 changes: 5 additions & 0 deletions workspaces/util/src/isArrayOfNumbers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 5 additions & 0 deletions workspaces/util/src/isNonArrayObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function isNonArrayObject(
value: unknown,
): value is Partial<Record<PropertyKey, unknown>> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
3 changes: 3 additions & 0 deletions workspaces/util/src/isNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isNumber(value: unknown): value is number {
return typeof value === "number";
}
1 change: 1 addition & 0 deletions workspaces/util/src/isObject.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TODO: deprecate in favor of the clearer name `isNonArrayObject`
export function isObject(
value: unknown,
): value is Record<PropertyKey, unknown> {
Expand Down
3 changes: 3 additions & 0 deletions workspaces/util/src/isStringLowerCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isStringLowerCase(s: string): boolean {
return s === s.toLowerCase();
}
5 changes: 5 additions & 0 deletions workspaces/util/src/isStringTitleCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { toTitleCase } from "@code-chronicles/util/toTitleCase";

export function isStringTitleCase(s: string): boolean {
return s === toTitleCase(s);
}
3 changes: 3 additions & 0 deletions workspaces/util/src/isStringUpperCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isStringUpperCase(s: string): boolean {
return s === s.toUpperCase();
}
Loading
Loading