diff --git a/package-lock.json b/package-lock.json index 2ac17cf5..aa5d5153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-leetcode", - "version": "0.18.1", + "version": "0.18.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-leetcode", - "version": "0.18.1", + "version": "0.18.4", "license": "MIT", "dependencies": { "axios": "^1.6.8", diff --git a/package.json b/package.json index 53552b74..c46e8c7e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "onCommand:leetcode.submitSolution", "onCommand:leetcode.switchDefaultLanguage", "onCommand:leetcode.problems.sort", + "onCommand:leetcode.toggleLanguage", "onView:leetCodeExplorer" ], "main": "./out/src/extension", @@ -141,6 +142,12 @@ "title": "Sort Problems", "category": "LeetCode", "icon": "$(sort-precedence)" + }, + { + "command": "leetcode.toggleLanguage", + "title": "Switch UI Language", + "category": "LeetCode", + "icon": "$(languages)" } ], "viewsContainers": { @@ -196,6 +203,11 @@ "command": "leetcode.problems.sort", "when": "view == leetCodeExplorer", "group": "overflow@3" + }, + { + "command": "leetcode.toggleLanguage", + "when": "view == leetCodeExplorer", + "group": "overflow@4" } ], "view/item/context": [ @@ -705,6 +717,31 @@ "default": true, "scope": "application", "description": "Allow LeetCode to report anonymous usage data to improve the product." + }, + "leetcode.language": { + "type": "string", + "default": "auto", + "scope": "application", + "enum": [ + "auto", + "en", + "zh-CN" + ], + "enumDescriptions": [ + "Follow VS Code's display language", + "English", + "Chinese (Simplified)" + ], + "description": "UI language for the LeetCode extension." + }, + "leetcode.studyPlans": { + "type": "array", + "default": [], + "scope": "application", + "items": { + "type": "string" + }, + "description": "Additional study plan slugs to show in the explorer (e.g. \"sql-free-50\"). See https://leetcode.cn/studyplan/ for available plans." } } } diff --git a/src/commands/list.ts b/src/commands/list.ts index 3ebe236a..55cfdb3b 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -6,6 +6,7 @@ import { leetCodeManager } from "../leetCodeManager"; import { IProblem, ProblemState, UserStatus } from "../shared"; import * as settingUtils from "../utils/settingUtils"; import { DialogType, promptForOpenOutputChannel } from "../utils/uiUtils"; +import { t } from "../i18n"; export async function listProblems(): Promise { try { @@ -38,7 +39,7 @@ export async function listProblems(): Promise { } return problems.reverse(); } catch (error) { - await promptForOpenOutputChannel("Failed to list problems. Please open the output channel for details.", DialogType.error); + await promptForOpenOutputChannel(t("failed_to_list_problems"), DialogType.error); return []; } } diff --git a/src/commands/plugin.ts b/src/commands/plugin.ts index d2ed4b6c..d74e07a8 100644 --- a/src/commands/plugin.ts +++ b/src/commands/plugin.ts @@ -8,6 +8,7 @@ import { IQuickItemEx } from "../shared"; import { Endpoint, SortingStrategy } from "../shared"; import { DialogType, promptForOpenOutputChannel, promptForSignIn } from "../utils/uiUtils"; import { deleteCache } from "./cache"; +import { Language, getCurrentLanguage, t } from "../i18n"; export async function switchEndpoint(): Promise { const isCnEnabled: boolean = getLeetCodeEndpoint() === Endpoint.LeetCodeCN; @@ -16,13 +17,13 @@ export async function switchEndpoint(): Promise { { label: `${isCnEnabled ? "" : "$(check) "}LeetCode`, description: "leetcode.com", - detail: `Enable LeetCode US`, + detail: t("enable_leetcode_us"), value: Endpoint.LeetCode, }, { label: `${isCnEnabled ? "$(check) " : ""}力扣`, description: "leetcode.cn", - detail: `启用中国版 LeetCode`, + detail: t("enable_leetcode_cn"), value: Endpoint.LeetCodeCN, }, ); @@ -35,9 +36,9 @@ export async function switchEndpoint(): Promise { const endpoint: string = choice.value; await leetCodeExecutor.switchEndpoint(endpoint); await leetCodeConfig.update("endpoint", endpoint, true /* UserSetting */); - vscode.window.showInformationMessage(`Switched the endpoint to ${endpoint}`); + vscode.window.showInformationMessage(t("switched_endpoint", endpoint)); } catch (error) { - await promptForOpenOutputChannel("Failed to switch endpoint. Please open the output channel for details.", DialogType.error); + await promptForOpenOutputChannel(t("failed_to_switch_endpoint"), DialogType.error); } try { @@ -45,7 +46,7 @@ export async function switchEndpoint(): Promise { await deleteCache(); await promptForSignIn(); } catch (error) { - await promptForOpenOutputChannel("Failed to sign in. Please open the output channel for details.", DialogType.error); + await promptForOpenOutputChannel(t("failed_to_sign_in_after_switch"), DialogType.error); } } @@ -86,3 +87,31 @@ export function getSortingStrategy(): SortingStrategy { const leetCodeConfig: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration("leetcode"); return leetCodeConfig.get("problems.sortStrategy", SortingStrategy.None); } + +export async function toggleLanguage(): Promise { + const current: Language = getCurrentLanguage(); + const picks: Array> = [ + { + label: `${current === Language.English ? "$(check) " : ""}${t("language_english")}`, + value: "en", + }, + { + label: `${current === Language.Chinese ? "$(check) " : ""}${t("language_chinese")}`, + value: "zh-CN", + }, + { + label: `${current !== Language.English && current !== Language.Chinese ? "$(check) " : ""}${t("language_auto")}`, + value: "auto", + }, + ]; + const choice: IQuickItemEx | undefined = await vscode.window.showQuickPick(picks, { + placeHolder: t("select_ui_language"), + }); + if (!choice) { + return; + } + const leetCodeConfig: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration("leetcode"); + await leetCodeConfig.update("language", choice.value, true /* UserSetting */); + vscode.window.showInformationMessage(t("switched_language", choice.value === "zh-CN" ? t("language_chinese") : choice.value === "en" ? t("language_english") : t("language_auto"))); + leetCodeTreeDataProvider.refresh(); +} diff --git a/src/commands/show.ts b/src/commands/show.ts index eccf5571..86cf525c 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -30,6 +30,7 @@ import { leetCodeSolutionProvider } from "../webview/leetCodeSolutionProvider"; import * as list from "./list"; import { getLeetCodeEndpoint } from "./plugin"; import { globalState } from "../globalState"; +import { t } from "../i18n"; export async function previewProblem(input: IProblem | vscode.Uri, isSideMode: boolean = false): Promise { let node: IProblem; @@ -133,7 +134,7 @@ async function fetchProblemLanguage(): Promise { const language: string | undefined = defaultLanguage || (await vscode.window.showQuickPick(languages, { - placeHolder: "Select the language you want to use", + placeHolder: t("select_language"), ignoreFocusOut: true, })); // fire-and-forget default language query diff --git a/src/commands/studyPlan.ts b/src/commands/studyPlan.ts new file mode 100644 index 00000000..1cc35827 --- /dev/null +++ b/src/commands/studyPlan.ts @@ -0,0 +1,51 @@ +// Copyright (c) jdneo. All rights reserved. +// Licensed under the MIT license. + +import { leetCodeChannel } from "../leetCodeChannel"; +import { Endpoint } from "../shared"; +import { getLeetCodeEndpoint } from "./plugin"; +import { queryStudyPlan } from "../request/query-study-plan"; +import { t } from "../i18n"; + +// Fallback problem IDs per study plan slug (used when page fetch fails) +const FALLBACK_IDS: { [slug: string]: string[] } = { + "top-100-liked": [ + "1", "49", "128", "283", "11", "15", "42", "3", "438", "560", + "239", "76", "53", "56", "189", "238", "41", "73", "54", "48", + "215", "912", "155", "20", "3", "21", "1", "25", "138", "169", + "11", "23", "199", "142", "124", "5", "121", "136", "55", "46", + "543", "144", "94", "145", "104", "226", "101", "530", "108", "110", + "232", "20", "155", "141", "136", "142", "242", "1", "49", "128", + "283", "11", "15", "42", "3", "438", "560", "239", "76", "53", + "56", "189", "238", "41", "73", "54", "48", "215", "912", "155", + "20", "21", "25", "138", "169", "23", "199", "142", "124", "5", + "121", "136", "55", "46", "543", "144", "94", "145", "104", "226", + ], + "sql-free-50": [ + "1757", "584", "595", "1148", "1683", "1378", "1068", "1581", "197", "1661", + "577", "1280", "570", "1934", "620", "1251", "1075", "1633", "1211", "1193", + "1174", "550", "2356", "1141", "1084", "596", "1729", "619", "1045", "1731", + "1789", "610", "180", "1164", "1204", "1907", "1978", "626", "1341", "1321", + "602", "585", "185", "1667", "1527", "196", "176", "1484", "1327", "1517", + ], +}; + +export async function fetchStudyPlanProblemIds(slug: string): Promise { + try { + // Study plan is only available on leetcode.cn + if (getLeetCodeEndpoint() !== Endpoint.LeetCodeCN) { + leetCodeChannel.appendLine(t("study_plan_not_available")); + return []; + } + + const result = await queryStudyPlan(slug); + if (result.problemIds && result.problemIds.length > 0) { + return result.problemIds; + } + } catch (error) { + leetCodeChannel.appendLine(`Failed to fetch study plan '${slug}': ${error}`); + } + + // Fallback to hardcoded list + return FALLBACK_IDS[slug] || []; +} diff --git a/src/explorer/LeetCodeNode.ts b/src/explorer/LeetCodeNode.ts index 3d2cc74f..823b1617 100644 --- a/src/explorer/LeetCodeNode.ts +++ b/src/explorer/LeetCodeNode.ts @@ -3,6 +3,7 @@ import { Command, Uri } from "vscode"; import { IProblem, ProblemState } from "../shared"; +import { t } from "../i18n"; export class LeetCodeNode { @@ -49,7 +50,7 @@ export class LeetCodeNode { public get previewCommand(): Command { return { - title: "Preview Problem", + title: t("preview_problem"), command: "leetcode.previewProblem", arguments: [this], }; diff --git a/src/explorer/LeetCodeTreeDataProvider.ts b/src/explorer/LeetCodeTreeDataProvider.ts index 9c298944..b83b9002 100644 --- a/src/explorer/LeetCodeTreeDataProvider.ts +++ b/src/explorer/LeetCodeTreeDataProvider.ts @@ -9,6 +9,7 @@ import { Category, defaultProblem, ProblemState } from "../shared"; import { explorerNodeManager } from "./explorerNodeManager"; import { LeetCodeNode } from "./LeetCodeNode"; import { globalState } from "../globalState"; +import { t } from "../i18n"; export class LeetCodeTreeDataProvider implements vscode.TreeDataProvider { private context: vscode.ExtensionContext; @@ -35,7 +36,7 @@ export class LeetCodeTreeDataProvider implements vscode.TreeDataProvider = new Map(); private companySet: Set = new Set(); private tagSet: Set = new Set(); + private studyPlanProblemIdsCache: Map = new Map(); + + private studyPlans: IStudyPlanItem[] = [ + { slug: "top-100-liked", name: "Hot 100" }, + { slug: "sql-free-50", name: "SQL 50" }, + ]; public async refreshCache(): Promise { this.dispose(); - const shouldHideSolved: boolean = shouldHideSolvedProblem(); + // Store all problems (including solved) so study plan can access them. + // The hide-solved filter is applied at display time, not cache time. for (const problem of await list.listProblems()) { - if (shouldHideSolved && problem.state === ProblemState.AC) { - continue; - } this.explorerNodeMap.set(problem.id, new LeetCodeNode(problem)); for (const company of problem.companies) { this.companySet.add(company); @@ -32,33 +39,43 @@ class ExplorerNodeManager implements Disposable { } public getRootNodes(): LeetCodeNode[] { - return [ + const nodes: LeetCodeNode[] = [ new LeetCodeNode(Object.assign({}, defaultProblem, { id: Category.All, - name: Category.All, + name: t("category_all"), }), false), new LeetCodeNode(Object.assign({}, defaultProblem, { id: Category.Difficulty, - name: Category.Difficulty, + name: t("category_difficulty"), }), false), new LeetCodeNode(Object.assign({}, defaultProblem, { id: Category.Tag, - name: Category.Tag, + name: t("category_tag"), }), false), new LeetCodeNode(Object.assign({}, defaultProblem, { id: Category.Company, - name: Category.Company, + name: t("category_company"), }), false), new LeetCodeNode(Object.assign({}, defaultProblem, { id: Category.Favorite, - name: Category.Favorite, + name: t("category_favorite"), }), false), ]; + + // Study plan is only available on leetcode.cn + if (getLeetCodeEndpoint() === Endpoint.LeetCodeCN) { + nodes.push(new LeetCodeNode(Object.assign({}, defaultProblem, { + id: Category.StudyPlan, + name: t("category_study_plan"), + }), false)); + } + + return nodes; } public getAllNodes(): LeetCodeNode[] { return this.applySortingStrategy( - Array.from(this.explorerNodeMap.values()), + this.filterSolvedNodes(Array.from(this.explorerNodeMap.values())), ); } @@ -117,7 +134,46 @@ class ExplorerNodeManager implements Disposable { res.push(node); } } - return this.applySortingStrategy(res); + return this.applySortingStrategy(this.filterSolvedNodes(res)); + } + + public getStudyPlanNodes(): LeetCodeNode[] { + // Start with built-in plans + const plans: IStudyPlanItem[] = [...this.studyPlans]; + + // Merge user-configured custom study plan slugs + const customSlugs: string[] = workspace.getConfiguration("leetcode").get("studyPlans", []); + for (const slug of customSlugs) { + if (!plans.find((p: IStudyPlanItem) => p.slug === slug)) { + plans.push({ slug, name: slug }); + } + } + + return plans.map((plan: IStudyPlanItem) => + new LeetCodeNode(Object.assign({}, defaultProblem, { + id: `studyplan:${plan.slug}`, + name: plan.name, + }), false), + ); + } + + public async getStudyPlanProblemNodes(planSlug: string): Promise { + let problemIds: string[]; + if (this.studyPlanProblemIdsCache.has(planSlug)) { + problemIds = this.studyPlanProblemIdsCache.get(planSlug)!; + } else { + problemIds = await fetchStudyPlanProblemIds(planSlug); + this.studyPlanProblemIdsCache.set(planSlug, problemIds); + } + + const res: LeetCodeNode[] = []; + for (const id of problemIds) { + const node: LeetCodeNode | undefined = this.explorerNodeMap.get(id); + if (node) { + res.push(node); + } + } + return res; } public getChildrenNodesById(id: string): LeetCodeNode[] { @@ -145,13 +201,14 @@ class ExplorerNodeManager implements Disposable { break; } } - return this.applySortingStrategy(res); + return this.applySortingStrategy(this.filterSolvedNodes(res)); } public dispose(): void { this.explorerNodeMap.clear(); this.companySet.clear(); this.tagSet.clear(); + this.studyPlanProblemIdsCache.clear(); } private sortSubCategoryNodes(subCategoryNodes: LeetCodeNode[], category: Category): void { @@ -198,6 +255,13 @@ class ExplorerNodeManager implements Disposable { default: return nodes; } } + + private filterSolvedNodes(nodes: LeetCodeNode[]): LeetCodeNode[] { + if (!shouldHideSolvedProblem()) { + return nodes; + } + return nodes.filter((node: LeetCodeNode) => node.state !== ProblemState.AC); + } } export const explorerNodeManager: ExplorerNodeManager = new ExplorerNodeManager(); diff --git a/src/extension.ts b/src/extension.ts index 439673f8..d0c7a511 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { codeLensController } from "./codelens/CodeLensController"; import * as cache from "./commands/cache"; import { switchDefaultLanguage } from "./commands/language"; import * as plugin from "./commands/plugin"; +import { toggleLanguage } from "./commands/plugin"; import * as session from "./commands/session"; import * as show from "./commands/show"; import * as star from "./commands/star"; @@ -26,11 +27,12 @@ import { leetCodeSubmissionProvider } from "./webview/leetCodeSubmissionProvider import { markdownEngine } from "./webview/markdownEngine"; import TrackData from "./utils/trackingUtils"; import { globalState } from "./globalState"; +import { t } from "./i18n"; export async function activate(context: vscode.ExtensionContext): Promise { try { if (!(await leetCodeExecutor.meetRequirements(context))) { - throw new Error("The environment doesn't meet requirements."); + throw new Error(t("environment_not_met")); } leetCodeManager.on("statusChanged", () => { @@ -97,7 +99,8 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand("leetcode.switchDefaultLanguage", () => switchDefaultLanguage()), vscode.commands.registerCommand("leetcode.addFavorite", (node: LeetCodeNode) => star.addFavorite(node)), vscode.commands.registerCommand("leetcode.removeFavorite", (node: LeetCodeNode) => star.removeFavorite(node)), - vscode.commands.registerCommand("leetcode.problems.sort", () => plugin.switchSortingStrategy()) + vscode.commands.registerCommand("leetcode.problems.sort", () => plugin.switchSortingStrategy()), + vscode.commands.registerCommand("leetcode.toggleLanguage", () => toggleLanguage()) ); await leetCodeExecutor.switchEndpoint(plugin.getLeetCodeEndpoint()); @@ -105,7 +108,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.registerUriHandler({ handleUri: leetCodeManager.handleUriSignIn }); } catch (error) { leetCodeChannel.appendLine(error.toString()); - promptForOpenOutputChannel("Extension initialization failed. Please open output channel for details.", DialogType.error); + promptForOpenOutputChannel(t("extension_init_failed"), DialogType.error); } } diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 00000000..591c8ee4 --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,77 @@ +// Copyright (c) jdneo. All rights reserved. +// Licensed under the MIT license. + +export const en = { + // Category names + category_all: "All", + category_difficulty: "Difficulty", + category_tag: "Tag", + category_company: "Company", + category_favorite: "Favorite", + category_study_plan: "Study Plan", + category_hot100: "Hot 100", + + // Explorer + sign_in_to_leetcode: "Sign in to LeetCode", + ac_label: "AC: {0}", + failed_label: "Failed: {0}", + total_label: "Total: {0}", + + // Common UI + open: "Open", + yes: "Yes", + no: "No", + never: "Never", + sign_up: "Sign up", + select: "Select", + dont_show_again: "Don't show again", + + // Sign in + please_sign_in: "Please sign in to LeetCode.", + failed_to_obtain_cookie: "Failed to obtain the cookie. Please log in again.", + failed_to_login: "Failed to log in. Please open the output channel for details.", + select_language: "Select the language you want to use", + + // Endpoint + enable_leetcode_us: "Enable LeetCode US", + enable_leetcode_cn: "Enable LeetCode CN", + switched_endpoint: "Switched the endpoint to {0}", + failed_to_switch_endpoint: "Failed to switch endpoint. Please open the output channel for details.", + failed_to_sign_in_after_switch: "Failed to sign in. Please open the output channel for details.", + + // Sorting + sorting_none: "None", + sorting_acceptance_asc: "Acceptance Rate (Ascending)", + sorting_acceptance_desc: "Acceptance Rate (Descending)", + + // Executor messages + fetching_solution: "Fetching top voted solution from discussions...", + fetching_description: "Fetching problem description...", + submitting_to_leetcode: "Submitting to LeetCode...", + updating_favorite: "Updating the favorite list...", + + // Error messages + failed_to_list_problems: "Failed to list problems. Please open the output channel for details.", + environment_not_met: "The environment doesn't meet requirements.", + extension_init_failed: "Extension initialization failed. Please open output channel for details.", + failed_to_fetch_study_plan: "Failed to fetch study plan. Please check your network and endpoint settings.", + no_problems_in_study_plan: "No problems found in the study plan.", + + // Misc + unknown: "Unknown", + problems: "Problems", + + // Toggle language + switched_language: "Switched UI language to {0}", + select_ui_language: "Select UI language", + language_english: "English", + language_chinese: "中文", + language_auto: "Auto (Follow VS Code)", + + // Study plan + fetching_study_plan: "Fetching study plan...", + study_plan_not_available: "Study plan is only available on leetcode.cn endpoint.", + + // Node + preview_problem: "Preview Problem", +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..ed580a0d --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) jdneo. All rights reserved. +// Licensed under the MIT license. + +import * as vscode from "vscode"; +import { en } from "./en"; +import { zhCN } from "./zh-cn"; + +export enum Language { + English = "en", + Chinese = "zh-CN", +} + +export type StringKey = keyof typeof en; + +const languageMap: { [key: string]: { [key in StringKey]: string } } = { + [Language.English]: en, + [Language.Chinese]: zhCN, +}; + +export function getCurrentLanguage(): Language { + const config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration("leetcode"); + const setting: string = config.get("language", "auto"); + if (setting === "auto") { + const vscodeLang: string = vscode.env.language; + if (vscodeLang.startsWith("zh")) { + return Language.Chinese; + } + return Language.English; + } + return setting === "zh-CN" ? Language.Chinese : Language.English; +} + +export function t(key: StringKey, ...args: (string | number)[]): string { + const lang: Language = getCurrentLanguage(); + const strings = languageMap[lang] || en; + let result: string = strings[key] || en[key] || key; + // Replace {0}, {1}, etc. with args + for (let i: number = 0; i < args.length; i++) { + result = result.replace(`{${i}}`, String(args[i])); + } + return result; +} diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts new file mode 100644 index 00000000..60da78f9 --- /dev/null +++ b/src/i18n/zh-cn.ts @@ -0,0 +1,77 @@ +// Copyright (c) jdneo. All rights reserved. +// Licensed under the MIT license. + +export const zhCN = { + // Category names + category_all: "全部", + category_difficulty: "难度", + category_tag: "标签", + category_company: "公司", + category_favorite: "收藏", + category_study_plan: "学习计划", + category_hot100: "热题 100", + + // Explorer + sign_in_to_leetcode: "登录 LeetCode", + ac_label: "已通过: {0}", + failed_label: "未通过: {0}", + total_label: "总计: {0}", + + // Common UI + open: "打开", + yes: "是", + no: "否", + never: "不再提示", + sign_up: "注册", + select: "选择", + dont_show_again: "不再显示", + + // Sign in + please_sign_in: "请登录 LeetCode。", + failed_to_obtain_cookie: "获取 Cookie 失败,请重新登录。", + failed_to_login: "登录失败,请打开输出通道查看详情。", + select_language: "选择你要使用的编程语言", + + // Endpoint + enable_leetcode_us: "启用 LeetCode 国际站", + enable_leetcode_cn: "启用 LeetCode 中国版", + switched_endpoint: "已切换站点至 {0}", + failed_to_switch_endpoint: "切换站点失败,请打开输出通道查看详情。", + failed_to_sign_in_after_switch: "登录失败,请打开输出通道查看详情。", + + // Sorting + sorting_none: "无", + sorting_acceptance_asc: "通过率(升序)", + sorting_acceptance_desc: "通过率(降序)", + + // Executor messages + fetching_solution: "正在获取题解...", + fetching_description: "正在获取题目描述...", + submitting_to_leetcode: "正在提交到 LeetCode...", + updating_favorite: "正在更新收藏列表...", + + // Error messages + failed_to_list_problems: "获取题目列表失败,请打开输出通道查看详情。", + environment_not_met: "环境不满足要求。", + extension_init_failed: "插件初始化失败,请打开输出通道查看详情。", + failed_to_fetch_study_plan: "获取学习计划失败,请检查网络和站点设置。", + no_problems_in_study_plan: "学习计划中没有找到题目。", + + // Misc + unknown: "未知", + problems: "题目", + + // Toggle language + switched_language: "已切换界面语言至 {0}", + select_ui_language: "选择界面语言", + language_english: "English", + language_chinese: "中文", + language_auto: "自动(跟随 VS Code)", + + // Study plan + fetching_study_plan: "正在获取学习计划...", + study_plan_not_available: "学习计划仅在 leetcode.cn 站点可用。", + + // Node + preview_problem: "预览题目", +}; diff --git a/src/leetCodeExecutor.ts b/src/leetCodeExecutor.ts index d2332c7a..5e353914 100644 --- a/src/leetCodeExecutor.ts +++ b/src/leetCodeExecutor.ts @@ -13,6 +13,7 @@ import { executeCommand, executeCommandWithProgress } from "./utils/cpUtils"; import { DialogOptions, openUrl } from "./utils/uiUtils"; import * as wsl from "./utils/wslUtils"; import { toWslPath, useWsl } from "./utils/wslUtils"; +import { t } from "./i18n"; class LeetCodeExecutor implements Disposable { private leetCodeRootPath: string; @@ -134,7 +135,7 @@ class LeetCodeExecutor implements Disposable { if (!needTranslation) { cmd.push("-T"); } - const solution: string = await this.executeCommandWithProgressEx("Fetching top voted solution from discussions...", this.nodeExecutable, cmd); + const solution: string = await this.executeCommandWithProgressEx(t("fetching_solution"), this.nodeExecutable, cmd); return solution; } @@ -143,7 +144,7 @@ class LeetCodeExecutor implements Disposable { if (!needTranslation) { cmd.push("-T"); } - return await this.executeCommandWithProgressEx("Fetching problem description...", this.nodeExecutable, cmd); + return await this.executeCommandWithProgressEx(t("fetching_description"), this.nodeExecutable, cmd); } public async listSessions(): Promise { @@ -175,9 +176,9 @@ class LeetCodeExecutor implements Disposable { public async testSolution(filePath: string, testString?: string): Promise { if (testString) { - return await this.executeCommandWithProgressEx("Submitting to LeetCode...", this.nodeExecutable, [await this.getLeetCodeBinaryPath(), "test", `"${filePath}"`, "-t", `${testString}`]); + return await this.executeCommandWithProgressEx(t("submitting_to_leetcode"), this.nodeExecutable, [await this.getLeetCodeBinaryPath(), "test", `"${filePath}"`, "-t", `${testString}`]); } - return await this.executeCommandWithProgressEx("Submitting to LeetCode...", this.nodeExecutable, [await this.getLeetCodeBinaryPath(), "test", `"${filePath}"`]); + return await this.executeCommandWithProgressEx(t("submitting_to_leetcode"), this.nodeExecutable, [await this.getLeetCodeBinaryPath(), "test", `"${filePath}"`]); } public async switchEndpoint(endpoint: string): Promise { @@ -195,7 +196,7 @@ class LeetCodeExecutor implements Disposable { if (!addToFavorite) { commandParams.push("-d"); } - await this.executeCommandWithProgressEx("Updating the favorite list...", "node", commandParams); + await this.executeCommandWithProgressEx(t("updating_favorite"), "node", commandParams); } public async getCompaniesAndTags(): Promise<{ companies: { [key: string]: string[] }, tags: { [key: string]: string[] } }> { diff --git a/src/leetCodeManager.ts b/src/leetCodeManager.ts index 16ec3782..a645d50b 100644 --- a/src/leetCodeManager.ts +++ b/src/leetCodeManager.ts @@ -14,6 +14,7 @@ import { getLeetCodeEndpoint } from "./commands/plugin"; import { globalState } from "./globalState"; import { queryUserData } from "./request/query-user-data"; import { parseQuery } from "./utils/toolUtils"; +import { t } from "./i18n"; class LeetCodeManager extends EventEmitter { private currentUser: string | undefined; @@ -70,7 +71,7 @@ class LeetCodeManager extends EventEmitter { }); } catch (error) { - promptForOpenOutputChannel(`Failed to log in. Please open the output channel for details`, DialogType.error); + promptForOpenOutputChannel(t("failed_to_login"), DialogType.error); } } diff --git a/src/request/query-study-plan.ts b/src/request/query-study-plan.ts new file mode 100644 index 00000000..2b1adaf9 --- /dev/null +++ b/src/request/query-study-plan.ts @@ -0,0 +1,38 @@ +// Copyright (c) jdneo. All rights reserved. +// Licensed under the MIT license. + +import { getUrl } from "../shared"; +import { LcAxios } from "../utils/httpUtils"; + +export const queryStudyPlan = async (slug: string): Promise<{ problemIds: string[] }> => { + // leetcode.cn study plan GraphQL API is not publicly documented. + // Instead, we fetch the study plan HTML page and parse problem IDs from SSR data. + const url: string = `${getUrl("base")}/studyplan/${slug}/`; + const res = await LcAxios(url, { + method: "GET", + responseType: "text", + headers: { + "content-type": "text/html", + }, + }); + const html: string = typeof res.data === "string" ? res.data : JSON.stringify(res.data); + + // Extract questionFrontendId values from the page's embedded JSON data + const idPattern: RegExp = /"questionFrontendId":"?(\d+)"?/g; + const ids: string[] = []; + let match: RegExpExecArray | null; + const seen: Set = new Set(); + while ((match = idPattern.exec(html)) !== null) { + const id: string = match[1]; + if (!seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + + if (ids.length === 0) { + throw new Error(`No problems found for study plan: ${slug}`); + } + + return { problemIds: ids }; +}; diff --git a/src/shared.ts b/src/shared.ts index e8b59d89..82c2150e 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -101,6 +101,12 @@ export enum Category { Tag = "Tag", Company = "Company", Favorite = "Favorite", + StudyPlan = "StudyPlan", +} + +export interface IStudyPlanItem { + slug: string; + name: string; } export const supportedPlugins: string[] = ["company", "solution.discuss", "leetcode.cn"]; diff --git a/src/utils/httpUtils.ts b/src/utils/httpUtils.ts index b7771734..ae23a215 100644 --- a/src/utils/httpUtils.ts +++ b/src/utils/httpUtils.ts @@ -2,6 +2,7 @@ import axios, { AxiosRequestConfig, AxiosPromise } from "axios"; import { omit } from "lodash"; import { globalState } from "../globalState"; import { DialogType, promptForOpenOutputChannel } from "./uiUtils"; +import { t } from "../i18n"; const referer = "vscode-lc-extension"; @@ -9,7 +10,7 @@ export function LcAxios(path: string, settings?: AxiosRequestConfig): A const cookie = globalState.getCookie(); if (!cookie) { promptForOpenOutputChannel( - `Failed to obtain the cookie. Please log in again.`, + t("failed_to_obtain_cookie"), DialogType.error ); return Promise.reject("Failed to obtain the cookie."); diff --git a/src/utils/uiUtils.ts b/src/utils/uiUtils.ts index 9e251a55..23f55bf0 100644 --- a/src/utils/uiUtils.ts +++ b/src/utils/uiUtils.ts @@ -5,13 +5,16 @@ import * as vscode from "vscode"; import { getLeetCodeEndpoint } from "../commands/plugin"; import { leetCodeChannel } from "../leetCodeChannel"; import { getWorkspaceConfiguration } from "./settingUtils"; +import { t } from "../i18n"; +import { Endpoint } from "../shared"; export namespace DialogOptions { - export const open: vscode.MessageItem = { title: "Open" }; - export const yes: vscode.MessageItem = { title: "Yes" }; - export const no: vscode.MessageItem = { title: "No", isCloseAffordance: true }; - export const never: vscode.MessageItem = { title: "Never" }; - export const singUp: vscode.MessageItem = { title: "Sign up" }; + // Use getters so that language changes take effect without reload + export const open: vscode.MessageItem = { get title() { return t("open"); } }; + export const yes: vscode.MessageItem = { get title() { return t("yes"); } }; + export const no: vscode.MessageItem = { get title() { return t("no"); }, isCloseAffordance: true } as vscode.MessageItem; + export const never: vscode.MessageItem = { get title() { return t("never"); } }; + export const singUp: vscode.MessageItem = { get title() { return t("sign_up"); } }; } export async function promptForOpenOutputChannel(message: string, type: DialogType): Promise { @@ -37,7 +40,7 @@ export async function promptForOpenOutputChannel(message: string, type: DialogTy export async function promptForSignIn(): Promise { const choice: vscode.MessageItem | undefined = await vscode.window.showInformationMessage( - "Please sign in to LeetCode.", + t("please_sign_in"), DialogOptions.yes, DialogOptions.no, DialogOptions.singUp, @@ -47,7 +50,7 @@ export async function promptForSignIn(): Promise { await vscode.commands.executeCommand("leetcode.signin"); break; case DialogOptions.singUp: - if (getLeetCodeEndpoint()) { + if (getLeetCodeEndpoint() === Endpoint.LeetCodeCN) { openUrl("https://leetcode.cn"); } else { openUrl("https://leetcode.com"); @@ -60,7 +63,7 @@ export async function promptForSignIn(): Promise { export async function promptHintMessage(config: string, message: string, choiceConfirm: string, onConfirm: () => Promise): Promise { if (getWorkspaceConfiguration().get(config)) { - const choiceNoShowAgain: string = "Don't show again"; + const choiceNoShowAgain: string = t("dont_show_again"); const choice: string | undefined = await vscode.window.showInformationMessage( message, choiceConfirm, choiceNoShowAgain, ); @@ -87,7 +90,7 @@ export async function showFileSelectDialog(fsPath?: string): Promise