Merge branch 'master' of https://github.com/metersphere/metersphere
This commit is contained in:
commit
9845acf786
|
@ -27,7 +27,7 @@ import io.metersphere.i18n.Translator;
|
|||
import io.metersphere.track.dto.TestCaseDTO;
|
||||
import io.metersphere.track.request.testcase.QueryTestCaseRequest;
|
||||
import io.metersphere.track.request.testcase.TestCaseBatchRequest;
|
||||
import io.metersphere.xmind.XmindToTestCaseParser;
|
||||
import io.metersphere.xmind.XmindCaseParser;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.ibatis.session.ExecutorType;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
|
@ -273,13 +273,18 @@ public class TestCaseService {
|
|||
if (multipartFile.getOriginalFilename().endsWith(".xmind")) {
|
||||
try {
|
||||
errList = new ArrayList<>();
|
||||
String processLog = new XmindToTestCaseParser(this, userId, projectId, testCaseNames).importXmind(multipartFile);
|
||||
XmindCaseParser xmindParser = new XmindCaseParser(this, userId, projectId, testCaseNames);
|
||||
String processLog = xmindParser.parse(multipartFile);
|
||||
if (!StringUtils.isEmpty(processLog)) {
|
||||
excelResponse.setSuccess(false);
|
||||
ExcelErrData excelErrData = new ExcelErrData(null, 1, Translator.get("upload_fail")+":"+ processLog);
|
||||
ExcelErrData excelErrData = new ExcelErrData(null, 1, Translator.get("upload_fail") + ":" + processLog);
|
||||
errList.add(excelErrData);
|
||||
excelResponse.setErrList(errList);
|
||||
} else {
|
||||
if (!xmindParser.testCases.isEmpty()) {
|
||||
this.saveImportData(xmindParser.testCases, projectId);
|
||||
xmindParser.clear();
|
||||
}
|
||||
excelResponse.setSuccess(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
@ -345,7 +350,7 @@ public class TestCaseService {
|
|||
// 发送给客户端的数据
|
||||
byte[] buff = new byte[1024];
|
||||
try (OutputStream outputStream = res.getOutputStream();
|
||||
BufferedInputStream bis = new BufferedInputStream(TestCaseService.class.getResourceAsStream("/io/metersphere/xmind/template/testcase.xml"));) {
|
||||
BufferedInputStream bis = new BufferedInputStream(TestCaseService.class.getResourceAsStream("/io/metersphere/xmind/template/xmind.xml"));) {
|
||||
int i = bis.read(buff);
|
||||
while (i != -1) {
|
||||
outputStream.write(buff, 0, buff.length);
|
||||
|
|
|
@ -11,15 +11,11 @@ import io.metersphere.excel.domain.TestCaseExcelData;
|
|||
import io.metersphere.i18n.Translator;
|
||||
import io.metersphere.track.service.TestCaseService;
|
||||
import io.metersphere.xmind.parser.XmindParser;
|
||||
import io.metersphere.xmind.parser.domain.Attached;
|
||||
import io.metersphere.xmind.parser.domain.JsonRootBean;
|
||||
import io.metersphere.xmind.parser.pojo.Attached;
|
||||
import io.metersphere.xmind.parser.pojo.JsonRootBean;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
@ -27,33 +23,40 @@ import java.util.regex.Pattern;
|
|||
/**
|
||||
* 数据转换
|
||||
* 1 解析Xmind文件 XmindParser.parseJson
|
||||
* 2 解析后的JSON 转成测试用例
|
||||
* 2 解析后的JSON this.parse 转成测试用例
|
||||
*/
|
||||
public class XmindToTestCaseParser {
|
||||
public class XmindCaseParser {
|
||||
|
||||
private TestCaseService testCaseService;
|
||||
private String maintainer;
|
||||
private String projectId;
|
||||
private StringBuffer process; // 过程校验记录
|
||||
// 已存在用例名称
|
||||
private Set<String> testCaseNames;
|
||||
// 案例详情重写了hashCode方法去重用
|
||||
public List<TestCaseWithBLOBs> testCases;
|
||||
// 用于重复对比
|
||||
private List<TestCaseExcelData> xmindDataList;
|
||||
|
||||
public XmindToTestCaseParser(TestCaseService testCaseService, String userId, String projectId, Set<String> testCaseNames) {
|
||||
public XmindCaseParser(TestCaseService testCaseService, String userId, String projectId, Set<String> testCaseNames) {
|
||||
this.testCaseService = testCaseService;
|
||||
this.maintainer = userId;
|
||||
this.projectId = projectId;
|
||||
this.testCaseNames = testCaseNames;
|
||||
testCaseWithBLOBs = new LinkedList<>();
|
||||
testCases = new LinkedList<>();
|
||||
xmindDataList = new ArrayList<>();
|
||||
process = new StringBuffer();
|
||||
}
|
||||
|
||||
// 案例详情
|
||||
private List<TestCaseWithBLOBs> testCaseWithBLOBs;
|
||||
// 用于重复对比
|
||||
protected List<TestCaseExcelData> xmindDataList;
|
||||
// 这里清理是为了 加快jvm 回收
|
||||
public void clear() {
|
||||
xmindDataList.clear();
|
||||
testCases.clear();
|
||||
testCaseNames.clear();
|
||||
}
|
||||
|
||||
// 递归处理案例数据
|
||||
private void makeXmind(StringBuffer processBuffer, Attached parent, int level, String nodePath, List<Attached> attacheds) {
|
||||
private void recursion(StringBuffer processBuffer, Attached parent, int level, String nodePath, List<Attached> attacheds) {
|
||||
for (Attached item : attacheds) {
|
||||
if (isBlack(item.getTitle(), "(?:tc:|tc:|tc)")) { // 用例
|
||||
item.setParent(parent);
|
||||
|
@ -63,7 +66,7 @@ public class XmindToTestCaseParser {
|
|||
item.setPath(nodePath);
|
||||
if (item.getChildren() != null && !item.getChildren().getAttached().isEmpty()) {
|
||||
item.setParent(parent);
|
||||
makeXmind(processBuffer, item, level + 1, nodePath, item.getChildren().getAttached());
|
||||
recursion(processBuffer, item, level + 1, nodePath, item.getChildren().getAttached());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,7 +91,7 @@ public class XmindToTestCaseParser {
|
|||
}
|
||||
|
||||
// 获取步骤数据
|
||||
public String getSteps(List<Attached> attacheds) {
|
||||
private String getSteps(List<Attached> attacheds) {
|
||||
JSONArray jsonArray = new JSONArray();
|
||||
for (int i = 0; i < attacheds.size(); i++) {
|
||||
// 保持插入顺序,判断用例是否有相同的steps
|
||||
|
@ -177,43 +180,13 @@ public class XmindToTestCaseParser {
|
|||
testCase.setId(UUID.randomUUID().toString());
|
||||
testCase.setCreateTime(System.currentTimeMillis());
|
||||
testCase.setUpdateTime(System.currentTimeMillis());
|
||||
testCaseWithBLOBs.add(testCase);
|
||||
testCases.add(testCase);
|
||||
}
|
||||
xmindDataList.add(compartData);
|
||||
}
|
||||
|
||||
//获取流文件
|
||||
private static void inputStreamToFile(InputStream ins, File file) {
|
||||
try (OutputStream os = new FileOutputStream(file);) {
|
||||
int bytesRead = 0;
|
||||
byte[] buffer = new byte[8192];
|
||||
while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
|
||||
os.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LogUtil.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MultipartFile 转 File
|
||||
*
|
||||
* @param file
|
||||
* @throws Exception
|
||||
*/
|
||||
private File multipartFileToFile(MultipartFile file) throws Exception {
|
||||
if (file != null && file.getSize() > 0) {
|
||||
try (InputStream ins = file.getInputStream();) {
|
||||
File toFile = new File(file.getOriginalFilename());
|
||||
inputStreamToFile(ins, toFile);
|
||||
return toFile;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public boolean validate(TestCaseWithBLOBs data) {
|
||||
// 验证合法性
|
||||
private boolean validate(TestCaseWithBLOBs data) {
|
||||
String nodePath = data.getNodePath();
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
|
@ -237,8 +210,6 @@ public class XmindToTestCaseParser {
|
|||
|
||||
if (testCaseNames.contains(data.getName())) {
|
||||
boolean dbExist = testCaseService.exist(data);
|
||||
boolean excelExist = false;
|
||||
|
||||
if (dbExist) {
|
||||
// db exist
|
||||
stringBuilder.append(Translator.get("test_case_already_exists_excel") + ":" + data.getName() + "; ");
|
||||
|
@ -255,22 +226,11 @@ public class XmindToTestCaseParser {
|
|||
}
|
||||
|
||||
// 导入思维导图处理
|
||||
public String importXmind(MultipartFile multipartFile) {
|
||||
public String parse(MultipartFile multipartFile) {
|
||||
StringBuffer processBuffer = new StringBuffer();
|
||||
File file = null;
|
||||
try {
|
||||
file = multipartFileToFile(multipartFile);
|
||||
if (file == null || !file.exists())
|
||||
return Translator.get("incorrect_format");
|
||||
|
||||
// 获取思维导图内容
|
||||
String content = XmindParser.parseJson(file);
|
||||
if (StringUtils.isEmpty(content) || content.split("(?:tc:|tc:|TC:|TC:|tc|TC)").length == 1) {
|
||||
return Translator.get("import_xmind_not_found");
|
||||
}
|
||||
if (!StringUtils.isEmpty(content) && content.split("(?:tc:|tc:|TC:|TC:|tc|TC)").length > 500) {
|
||||
return Translator.get("import_xmind_count_error");
|
||||
}
|
||||
String content = XmindParser.parseJson(multipartFile);
|
||||
JsonRootBean root = JSON.parseObject(content, JsonRootBean.class);
|
||||
|
||||
if (root != null && root.getRootTopic() != null && root.getRootTopic().getChildren() != null) {
|
||||
|
@ -282,22 +242,18 @@ public class XmindToTestCaseParser {
|
|||
item.setPath(item.getTitle());
|
||||
if (item.getChildren() != null && !item.getChildren().getAttached().isEmpty()) {
|
||||
item.setPath(item.getTitle());
|
||||
makeXmind(processBuffer, item, 1, item.getPath(), item.getChildren().getAttached());
|
||||
recursion(processBuffer, item, 1, item.getPath(), item.getChildren().getAttached());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isEmpty(process.toString()) && !testCaseWithBLOBs.isEmpty()) {
|
||||
testCaseService.saveImportData(testCaseWithBLOBs, projectId);
|
||||
}
|
||||
//if (StringUtils.isEmpty(process.toString()) && !testCaseWithBLOBs.isEmpty()) {
|
||||
// testCaseService.saveImportData(testCaseWithBLOBs, projectId);
|
||||
//}
|
||||
} catch (Exception ex) {
|
||||
processBuffer.append(Translator.get("incorrect_format"));
|
||||
LogUtil.error(ex.getMessage());
|
||||
ex.printStackTrace();
|
||||
} finally {
|
||||
if (file != null)
|
||||
file.delete();
|
||||
testCaseWithBLOBs.clear();
|
||||
return ex.getMessage();
|
||||
}
|
||||
return process.toString();
|
||||
}
|
|
@ -1,9 +1,14 @@
|
|||
package io.metersphere.xmind.parser;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import io.metersphere.xmind.parser.domain.JsonRootBean;
|
||||
import io.metersphere.commons.exception.MSException;
|
||||
import io.metersphere.i18n.Translator;
|
||||
import io.metersphere.xmind.parser.pojo.JsonRootBean;
|
||||
import io.metersphere.xmind.utils.FileUtil;
|
||||
import org.apache.commons.compress.archivers.ArchiveException;
|
||||
import org.dom4j.DocumentException;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
@ -16,101 +21,98 @@ import java.util.Objects;
|
|||
* @Description 解析主体
|
||||
*/
|
||||
public class XmindParser {
|
||||
public static final String xmindZenJson = "content.json";
|
||||
public static final String xmindLegacyContent = "content.xml";
|
||||
public static final String xmindLegacyComments = "comments.xml";
|
||||
public static final String xmindZenJson = "content.json";
|
||||
public static final String xmindLegacyContent = "content.xml";
|
||||
public static final String xmindLegacyComments = "comments.xml";
|
||||
|
||||
/**
|
||||
* 解析脑图文件,返回content整合后的内容
|
||||
*
|
||||
* @param file
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @throws ArchiveException
|
||||
* @throws DocumentException
|
||||
*/
|
||||
public static String parseJson(File file) throws IOException, ArchiveException, DocumentException {
|
||||
String res = ZipUtils.extract(file);
|
||||
/**
|
||||
* 解析脑图文件,返回content整合后的内容
|
||||
*
|
||||
* @param multipartFile
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @throws ArchiveException
|
||||
* @throws DocumentException
|
||||
*/
|
||||
public static String parseJson(MultipartFile multipartFile) throws IOException, ArchiveException, DocumentException {
|
||||
|
||||
String content = null;
|
||||
if (isXmindZen(res, file)) {
|
||||
content = getXmindZenContent(file, res);
|
||||
} else {
|
||||
content = getXmindLegacyContent(file, res);
|
||||
}
|
||||
File file = FileUtil.multipartFileToFile(multipartFile);
|
||||
if (file == null || !file.exists())
|
||||
MSException.throwException(Translator.get("incorrect_format"));
|
||||
|
||||
// 删除生成的文件夹
|
||||
File dir = new File(res);
|
||||
boolean flag = deleteDir(dir);
|
||||
if (flag) {
|
||||
// do something
|
||||
}
|
||||
JsonRootBean jsonRootBean = JSON.parseObject(content, JsonRootBean.class);
|
||||
return (JSON.toJSONString(jsonRootBean, false));
|
||||
}
|
||||
String res = ZipUtils.extract(file);
|
||||
String content = null;
|
||||
if (isXmindZen(res, file)) {
|
||||
content = getXmindZenContent(file, res);
|
||||
} else {
|
||||
content = getXmindLegacyContent(file, res);
|
||||
}
|
||||
|
||||
public static JsonRootBean parseObject(File file) throws DocumentException, ArchiveException, IOException {
|
||||
String content = parseJson(file);
|
||||
JsonRootBean jsonRootBean = JSON.parseObject(content, JsonRootBean.class);
|
||||
return jsonRootBean;
|
||||
}
|
||||
// 删除生成的文件夹
|
||||
File dir = new File(res);
|
||||
FileUtil.deleteDir(dir);
|
||||
JsonRootBean jsonRootBean = JSON.parseObject(content, JsonRootBean.class);
|
||||
// 删除零时文件
|
||||
if (file != null)
|
||||
file.delete();
|
||||
String json = (JSON.toJSONString(jsonRootBean, false));
|
||||
|
||||
public static boolean deleteDir(File dir) {
|
||||
if (dir.isDirectory()) {
|
||||
String[] children = dir.list();
|
||||
// 递归删除目录中的子目录下
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
boolean success = deleteDir(new File(dir, children[i]));
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 目录此时为空,可以删除
|
||||
return dir.delete();
|
||||
}
|
||||
if (StringUtils.isEmpty(content) || content.split("(?:tc:|tc:|TC:|TC:|tc|TC)").length == 1) {
|
||||
MSException.throwException(Translator.get("import_xmind_not_found"));
|
||||
}
|
||||
if (!StringUtils.isEmpty(content) && content.split("(?:tc:|tc:|TC:|TC:|tc|TC)").length > 500) {
|
||||
MSException.throwException(Translator.get("import_xmind_count_error"));
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return
|
||||
*/
|
||||
public static String getXmindZenContent(File file, String extractFileDir)
|
||||
throws IOException, ArchiveException {
|
||||
List<String> keys = new ArrayList<>();
|
||||
keys.add(xmindZenJson);
|
||||
Map<String, String> map = ZipUtils.getContents(keys, file, extractFileDir);
|
||||
String content = map.get(xmindZenJson);
|
||||
content = XmindZen.getContent(content);
|
||||
return content;
|
||||
}
|
||||
public static JsonRootBean parseObject(MultipartFile multipartFile) throws DocumentException, ArchiveException, IOException {
|
||||
String content = parseJson(multipartFile);
|
||||
JsonRootBean jsonRootBean = JSON.parseObject(content, JsonRootBean.class);
|
||||
return jsonRootBean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return
|
||||
*/
|
||||
public static String getXmindLegacyContent(File file, String extractFileDir)
|
||||
throws IOException, ArchiveException, DocumentException {
|
||||
List<String> keys = new ArrayList<>();
|
||||
keys.add(xmindLegacyContent);
|
||||
keys.add(xmindLegacyComments);
|
||||
Map<String, String> map = ZipUtils.getContents(keys, file, extractFileDir);
|
||||
/**
|
||||
* @return
|
||||
*/
|
||||
public static String getXmindZenContent(File file, String extractFileDir)
|
||||
throws IOException, ArchiveException {
|
||||
List<String> keys = new ArrayList<>();
|
||||
keys.add(xmindZenJson);
|
||||
Map<String, String> map = ZipUtils.getContents(keys, file, extractFileDir);
|
||||
String content = map.get(xmindZenJson);
|
||||
content = XmindZen.getContent(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
String contentXml = map.get(xmindLegacyContent);
|
||||
String commentsXml = map.get(xmindLegacyComments);
|
||||
String xmlContent = XmindLegacy.getContent(contentXml, commentsXml);
|
||||
/**
|
||||
* @return
|
||||
*/
|
||||
public static String getXmindLegacyContent(File file, String extractFileDir)
|
||||
throws IOException, ArchiveException, DocumentException {
|
||||
List<String> keys = new ArrayList<>();
|
||||
keys.add(xmindLegacyContent);
|
||||
keys.add(xmindLegacyComments);
|
||||
Map<String, String> map = ZipUtils.getContents(keys, file, extractFileDir);
|
||||
|
||||
return xmlContent;
|
||||
}
|
||||
String contentXml = map.get(xmindLegacyContent);
|
||||
String commentsXml = map.get(xmindLegacyComments);
|
||||
String xmlContent = XmindLegacy.getContent(contentXml, commentsXml);
|
||||
|
||||
private static boolean isXmindZen(String res, File file) throws IOException, ArchiveException {
|
||||
// 解压
|
||||
File parent = new File(res);
|
||||
if (parent.isDirectory()) {
|
||||
String[] files = parent.list(new ZipUtils.FileFilter());
|
||||
for (int i = 0; i < Objects.requireNonNull(files).length; i++) {
|
||||
if (files[i].equals(xmindZenJson)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return xmlContent;
|
||||
}
|
||||
|
||||
private static boolean isXmindZen(String res, File file) throws IOException, ArchiveException {
|
||||
// 解压
|
||||
File parent = new File(res);
|
||||
if (parent.isDirectory()) {
|
||||
String[] files = parent.list(new ZipUtils.FileFilter());
|
||||
for (int i = 0; i < Objects.requireNonNull(files).length; i++) {
|
||||
if (files[i].equals(xmindZenJson)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
package io.metersphere.xmind.parser.domain;
|
||||
package io.metersphere.xmind.parser.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package io.metersphere.xmind.parser.domain;
|
||||
package io.metersphere.xmind.parser.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package io.metersphere.xmind.parser.domain;
|
||||
package io.metersphere.xmind.parser.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package io.metersphere.xmind.parser.domain;
|
||||
package io.metersphere.xmind.parser.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package io.metersphere.xmind.parser.domain;
|
||||
package io.metersphere.xmind.parser.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package io.metersphere.xmind.parser.domain;
|
||||
package io.metersphere.xmind.parser.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package io.metersphere.xmind.utils;
|
||||
|
||||
import io.metersphere.commons.utils.LogUtil;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class FileUtil {
|
||||
|
||||
//获取流文件
|
||||
private static void inputStreamToFile(InputStream ins, File file) {
|
||||
try (OutputStream os = new FileOutputStream(file);) {
|
||||
int bytesRead = 0;
|
||||
byte[] buffer = new byte[8192];
|
||||
while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
|
||||
os.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LogUtil.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MultipartFile 转 File
|
||||
*
|
||||
* @param file
|
||||
* @throws Exception
|
||||
*/
|
||||
public static File multipartFileToFile(MultipartFile file) {
|
||||
if (file != null && file.getSize() > 0) {
|
||||
try (InputStream ins = file.getInputStream();) {
|
||||
File toFile = new File(file.getOriginalFilename());
|
||||
inputStreamToFile(ins, toFile);
|
||||
return toFile;
|
||||
} catch (Exception e) {
|
||||
LogUtil.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean deleteDir(File dir) {
|
||||
if (dir.isDirectory()) {
|
||||
String[] children = dir.list();
|
||||
// 递归删除目录中的子目录下
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
boolean success = deleteDir(new File(dir, children[i]));
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 目录此时为空,可以删除
|
||||
return dir.delete();
|
||||
}
|
||||
|
||||
|
||||
}
|
Loading…
Reference in New Issue