Skip to content

Commit cf3f32a

Browse files
author
张星宇
committed
feat: enhance problem import functionality with local folder support
- Implement new features for importing problems from local folders, allowing users to load JSON files directly. - Update the Manage Problems page to include options for importing from both default and custom folders. - Add API endpoint for handling folder imports, including validation and error handling for JSON files. - Update localization files to reflect new import options in English and Chinese. Change-Id: If31b5ce7a753e49afcb6842bd2d9769d56d7f465 Co-developed-by: Cursor <noreply@cursor.com> Signed-off-by: 张星宇 <neil.zxy@alibaba-inc.com>
1 parent b20ef5f commit cf3f32a

File tree

5 files changed

+504
-87
lines changed

5 files changed

+504
-87
lines changed

.github/workflows/release.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,11 @@ jobs:
102102
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103103
# 签名相关环境变量
104104
# 如果没有配置证书,CSC_IDENTITY_AUTO_DISCOVERY=false 会跳过签名
105-
CSC_IDENTITY_AUTO_DISCOVERY: ${{ secrets.APPLE_CERTIFICATE_BASE64 != '' }}
106-
# 公证相关环境变量
107-
APPLE_ID: ${{ secrets.APPLE_ID }}
108-
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
109-
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
105+
# CSC_IDENTITY_AUTO_DISCOVERY: ${{ secrets.APPLE_CERTIFICATE_BASE64 != '' }}
106+
# # 公证相关环境变量
107+
# APPLE_ID: ${{ secrets.APPLE_ID }}
108+
# APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
109+
# APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
110110

111111
# 清理钥匙串
112112
- name: Cleanup Keychain

locales/en.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,17 @@
215215
"backToProblems": "Back to Problems",
216216
"importTab": "Import Problems",
217217
"listTab": "Problem List",
218+
"importFromFolder": "Import from Local Folder",
219+
"importFromFolderDescription": "Load all JSON files from a local folder. Each JSON file can contain a single problem or an array of problems.",
220+
"problemsFolder": "Default Problems Folder",
221+
"problemsFolderHint": "Load all JSON files from the 'problems' folder in the application directory",
222+
"loadFromProblemsFolder": "Load from Problems Folder",
223+
"or": "OR",
224+
"customFolderPath": "Custom Folder Path",
225+
"customFolderHint": "Enter the full path to a folder containing JSON problem files",
226+
"importFromCustomFolder": "Import from Folder",
227+
"filesScanned": "{{count}} JSON file(s) scanned",
228+
"errorDetails": "Error details",
218229
"importFromUrl": "Import from Remote URL",
219230
"importDescription": "Enter one or more URLs to JSON files containing problems. Each URL should point to an array of problem objects. You can enter multiple URLs, one per line.",
220231
"remoteUrl": "Remote JSON URL(s)",
@@ -226,7 +237,7 @@
226237
"importResultSkipped": "{{count}} problem(s) skipped (already exist)",
227238
"importResultFailed": "{{count}} problem(s) failed (invalid format)",
228239
"jsonFormat": "Expected JSON Format",
229-
"jsonFormatDescription": "The remote JSON should be an array of problem objects with the following structure:",
240+
"jsonFormatDescription": "JSON files should contain an array of problem objects (or a single problem object) with the following structure:",
230241
"searchPlaceholder": "Search by ID or title...",
231242
"selectedCount": "{{count}} selected",
232243
"deleteSelected": "Delete Selected",

locales/zh.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,17 @@
215215
"backToProblems": "返回题目列表",
216216
"importTab": "导入题目",
217217
"listTab": "题目列表",
218+
"importFromFolder": "从本地文件夹导入",
219+
"importFromFolderDescription": "从本地文件夹加载所有JSON文件。每个JSON文件可以包含单个题目或题目数组。",
220+
"problemsFolder": "默认题目文件夹",
221+
"problemsFolderHint": "从应用目录中的 'problems' 文件夹加载所有JSON文件",
222+
"loadFromProblemsFolder": "从题目文件夹加载",
223+
"or": "或者",
224+
"customFolderPath": "自定义文件夹路径",
225+
"customFolderHint": "输入包含JSON题目文件的文件夹完整路径",
226+
"importFromCustomFolder": "从文件夹导入",
227+
"filesScanned": "扫描了 {{count}} 个JSON文件",
228+
"errorDetails": "错误详情",
218229
"importFromUrl": "从远程URL导入",
219230
"importDescription": "输入一个或多个包含题目的JSON文件的URL。每个URL应指向一个题目对象数组。可以输入多个URL,每行一个。",
220231
"remoteUrl": "远程JSON URL",
@@ -226,7 +237,7 @@
226237
"importResultSkipped": "跳过 {{count}} 道题目(已存在)",
227238
"importResultFailed": "失败 {{count}} 道题目(格式无效)",
228239
"jsonFormat": "预期的JSON格式",
229-
"jsonFormatDescription": "远程JSON应该是具有以下结构的题目对象数组",
240+
"jsonFormatDescription": "JSON文件应该包含题目对象数组(或单个题目对象),具有以下结构",
230241
"searchPlaceholder": "按ID或标题搜索...",
231242
"selectedCount": "已选择 {{count}} 项",
232243
"deleteSelected": "删除所选",

pages/api/import-from-folder.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
interface Problem {
6+
id: string;
7+
title: { en: string; zh: string };
8+
difficulty: string;
9+
tags?: string[];
10+
description: { en: string; zh: string };
11+
examples?: Array<{ input: string; output: string }>;
12+
template: Record<string, string>;
13+
tests: Array<{ input: string; output: string }>;
14+
solution?: Record<string, string>;
15+
solutions?: Array<{
16+
title: { en: string; zh: string };
17+
content: { en: string; zh: string };
18+
}>;
19+
}
20+
21+
function validateProblem(problem: any): problem is Problem {
22+
if (!problem || typeof problem !== 'object') return false;
23+
if (typeof problem.id !== 'string' || !problem.id) return false;
24+
if (!problem.title || typeof problem.title.en !== 'string') return false;
25+
if (!['Easy', 'Medium', 'Hard'].includes(problem.difficulty)) return false;
26+
if (!problem.description || typeof problem.description.en !== 'string') return false;
27+
if (!problem.template || typeof problem.template !== 'object') return false;
28+
if (!Array.isArray(problem.tests) || problem.tests.length === 0) return false;
29+
if (!/^[a-z0-9-]+$/.test(problem.id)) return false;
30+
return true;
31+
}
32+
33+
function normalizeProblem(problem: any): Problem {
34+
return {
35+
id: problem.id,
36+
title: {
37+
en: problem.title.en || problem.title.zh || 'Untitled',
38+
zh: problem.title.zh || problem.title.en || '无标题',
39+
},
40+
difficulty: problem.difficulty,
41+
tags: Array.isArray(problem.tags) ? problem.tags : [],
42+
description: {
43+
en: problem.description.en || problem.description.zh || '',
44+
zh: problem.description.zh || problem.description.en || '',
45+
},
46+
examples: Array.isArray(problem.examples) ? problem.examples : [],
47+
template: problem.template,
48+
tests: problem.tests,
49+
...(problem.solution && { solution: problem.solution }),
50+
...(problem.solutions && { solutions: problem.solutions }),
51+
};
52+
}
53+
54+
function findJsonFiles(dir: string): string[] {
55+
const jsonFiles: string[] = [];
56+
57+
try {
58+
const entries = fs.readdirSync(dir, { withFileTypes: true });
59+
60+
for (const entry of entries) {
61+
const fullPath = path.join(dir, entry.name);
62+
63+
if (entry.isFile() && entry.name.endsWith('.json')) {
64+
jsonFiles.push(fullPath);
65+
} else if (entry.isDirectory()) {
66+
// Recursively search subdirectories
67+
jsonFiles.push(...findJsonFiles(fullPath));
68+
}
69+
}
70+
} catch (error) {
71+
console.error(`Error reading directory ${dir}:`, error);
72+
}
73+
74+
return jsonFiles;
75+
}
76+
77+
function loadProblemsFromJsonFile(filePath: string): { problems: any[]; error?: string } {
78+
try {
79+
const content = fs.readFileSync(filePath, 'utf8');
80+
const data = JSON.parse(content);
81+
82+
// Handle both array and single object
83+
if (Array.isArray(data)) {
84+
return { problems: data };
85+
} else if (data && typeof data === 'object' && data.id) {
86+
return { problems: [data] };
87+
} else {
88+
return { problems: [], error: 'Invalid JSON structure' };
89+
}
90+
} catch (error) {
91+
return { problems: [], error: error instanceof Error ? error.message : 'Failed to parse JSON' };
92+
}
93+
}
94+
95+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
96+
if (req.method !== 'POST') {
97+
return res.status(405).json({ error: 'Method not allowed' });
98+
}
99+
100+
try {
101+
const { folderPath, useProblemsFolder } = req.body;
102+
const appRoot = process.env.APP_ROOT || process.cwd();
103+
104+
let targetFolder: string;
105+
106+
if (useProblemsFolder) {
107+
// Use the default problems folder
108+
targetFolder = path.join(appRoot, 'problems');
109+
} else if (folderPath && typeof folderPath === 'string') {
110+
// Use user-specified folder
111+
targetFolder = folderPath;
112+
113+
// Security check: ensure the path exists and is a directory
114+
if (!fs.existsSync(targetFolder)) {
115+
return res.status(400).json({ error: 'Folder does not exist' });
116+
}
117+
118+
const stats = fs.statSync(targetFolder);
119+
if (!stats.isDirectory()) {
120+
return res.status(400).json({ error: 'Path is not a directory' });
121+
}
122+
} else {
123+
return res.status(400).json({ error: 'Either folderPath or useProblemsFolder is required' });
124+
}
125+
126+
// Find all JSON files in the folder
127+
const jsonFiles = findJsonFiles(targetFolder);
128+
129+
if (jsonFiles.length === 0) {
130+
return res.status(200).json({
131+
success: 0,
132+
failed: 0,
133+
skipped: 0,
134+
total: 0,
135+
message: 'No JSON files found in the folder',
136+
fileResults: [],
137+
});
138+
}
139+
140+
// Read current problems
141+
const problemsPath = path.join(appRoot, 'public', 'problems.json');
142+
let currentProblems: Problem[] = [];
143+
144+
try {
145+
const problemsData = fs.readFileSync(problemsPath, 'utf8');
146+
currentProblems = JSON.parse(problemsData);
147+
} catch {
148+
// If file doesn't exist or is invalid, start with empty array
149+
currentProblems = [];
150+
}
151+
152+
const existingIds = new Set(currentProblems.map(p => p.id));
153+
154+
// Process each JSON file
155+
let totalSuccess = 0;
156+
let totalFailed = 0;
157+
let totalSkipped = 0;
158+
const fileResults: Array<{ file: string; success: number; failed: number; skipped: number; error?: string }> = [];
159+
160+
for (const jsonFile of jsonFiles) {
161+
const relativePath = path.relative(targetFolder, jsonFile);
162+
const { problems, error } = loadProblemsFromJsonFile(jsonFile);
163+
164+
if (error) {
165+
fileResults.push({ file: relativePath, success: 0, failed: 0, skipped: 0, error });
166+
continue;
167+
}
168+
169+
let fileSuccess = 0;
170+
let fileFailed = 0;
171+
let fileSkipped = 0;
172+
173+
for (const problem of problems) {
174+
if (existingIds.has(problem.id)) {
175+
fileSkipped++;
176+
totalSkipped++;
177+
continue;
178+
}
179+
180+
if (!validateProblem(problem)) {
181+
fileFailed++;
182+
totalFailed++;
183+
continue;
184+
}
185+
186+
const normalizedProblem = normalizeProblem(problem);
187+
currentProblems.push(normalizedProblem);
188+
existingIds.add(problem.id);
189+
fileSuccess++;
190+
totalSuccess++;
191+
}
192+
193+
fileResults.push({ file: relativePath, success: fileSuccess, failed: fileFailed, skipped: fileSkipped });
194+
}
195+
196+
// Save updated problems
197+
if (totalSuccess > 0) {
198+
fs.writeFileSync(problemsPath, JSON.stringify(currentProblems, null, 2));
199+
200+
// Also sync to problems/problems.json
201+
const sourceProblemsPath = path.join(appRoot, 'problems', 'problems.json');
202+
try {
203+
fs.writeFileSync(sourceProblemsPath, JSON.stringify(currentProblems, null, 2));
204+
} catch {
205+
// Ignore if problems folder doesn't exist
206+
}
207+
}
208+
209+
return res.status(200).json({
210+
success: totalSuccess,
211+
failed: totalFailed,
212+
skipped: totalSkipped,
213+
total: jsonFiles.length,
214+
fileResults,
215+
});
216+
} catch (error) {
217+
console.error('Error importing from folder:', error);
218+
return res.status(500).json({
219+
error: error instanceof Error ? error.message : 'Failed to import from folder'
220+
});
221+
}
222+
}
223+

0 commit comments

Comments
 (0)