Merge branch 'github-master' into github-develop

This commit is contained in:
RememBerBer
2026-06-04 20:29:32 +08:00
21 changed files with 1006 additions and 88 deletions
+3 -1
View File
@@ -32,4 +32,6 @@ build/
.vscode/
logs/
logs/
.codegraph/
.cursor/
+26 -1
View File
@@ -56,6 +56,9 @@
<tika.version>3.0.0</tika.version>
<jackson-databind.version>2.13.3</jackson-databind.version>
<java-diff.version>4.16</java-diff.version>
<mac.outputDirectory>${project.build.directory}</mac.outputDirectory>
<mac.startup>UNIVERSAL</mac.startup>
<mac.jdkPath>${java.home}</mac.jdkPath>
</properties>
<repositories>
@@ -77,6 +80,25 @@
</repository>
</repositories>
<profiles>
<profile>
<id>mac-intel</id>
<properties>
<mac.outputDirectory>${project.build.directory}/mac-intel</mac.outputDirectory>
<mac.startup>X86_64</mac.startup>
<mac.jdkPath>${env.MACOS_INTEL_JDK}</mac.jdkPath>
</properties>
</profile>
<profile>
<id>mac-apple-silicon</id>
<properties>
<mac.outputDirectory>${project.build.directory}/mac-apple-silicon</mac.outputDirectory>
<mac.startup>ARM64</mac.startup>
<mac.jdkPath>${env.MACOS_APPLE_SILICON_JDK}</mac.jdkPath>
</properties>
</profile>
</profiles>
<dependencies>
<!-- 行级 diffMyers+ 生成 unified diff -->
@@ -412,7 +434,10 @@
<artifactId>javapackager</artifactId>
<version>1.7.5</version>
<configuration>
<outputDirectory>${mac.outputDirectory}</outputDirectory>
<displayName>${project.name}</displayName>
<bundleJre>true</bundleJre>
<jdkPath>${mac.jdkPath}</jdkPath>
<mainClass>com.luoboduner.moo.tool.App</mainClass>
<generateInstaller>true</generateInstaller>
<!-- 这行不能被格式化为多行,否则会出错-->
@@ -478,7 +503,7 @@
<platform>mac</platform>
<createTarball>true</createTarball>
<macConfig>
<macStartup>UNIVERSAL</macStartup>
<macStartup>${mac.startup}</macStartup>
<!-- <developerId>rememberber@163.com</developerId>-->
</macConfig>
<additionalModules>jdk.crypto.ec,jdk.charsets</additionalModules>
+28 -2
View File
@@ -315,6 +315,32 @@ Windows • Linux • macOS
[iconfont](https://www.iconfont.cn/)
## 开发温馨提示
最低JDK版本要求:**17**
最低JDK版本要求:**21**
在你开始开发之前, **请按下图设置IntelliJ IDEA**, 然后 **maven clean**:
![considerations](assets/material/gui_build.png)
![considerations](assets/material/gui_build.png)
### macOS打包
默认打包使用当前运行 Maven 的 JDK:
```bash
mvn clean package -Dmaven.test.skip=true
```
Intel 芯片包需要使用 x86_64 JDK 21
```bash
MACOS_INTEL_JDK=/path/to/jdk-21-x86_64 mvn -Pmac-intel clean package -Dmaven.test.skip=true
```
Apple Silicon 包需要使用 arm64/aarch64 JDK 21
```bash
MACOS_APPLE_SILICON_JDK=/path/to/jdk-21-aarch64 mvn -Pmac-apple-silicon clean package -Dmaven.test.skip=true
```
对应产物目录:
- 默认包:`target/`
- Intel 包:`target/mac-intel/`
- Apple Silicon 包:`target/mac-apple-silicon/`
@@ -53,12 +53,38 @@ public class JPopupMenuMouseAdapter extends MouseAdapter {
private void maybeShowPopup(MouseEvent e) {
if (e.isPopupTrigger()) {
Dimension size = popupMenu.getPreferredSize();
popupMenu.setLocation(e.getX() - size.width, e.getY() - size.height);
Point mousePoint = new Point(e.getXOnScreen(), e.getYOnScreen());
Point popupLocation = calculatePopupLocation(mousePoint, size, getScreenBounds(mousePoint));
popupMenu.setLocation(popupLocation);
popupMenu.setInvoker(popupMenu);
popupMenu.setVisible(true);
}
}
static Point calculatePopupLocation(Point mousePoint, Dimension popupSize, Rectangle screenBounds) {
int x = mousePoint.x - popupSize.width;
int y = mousePoint.y - popupSize.height;
int maxX = Math.max(screenBounds.x, screenBounds.x + screenBounds.width - popupSize.width);
int maxY = Math.max(screenBounds.y, screenBounds.y + screenBounds.height - popupSize.height);
x = Math.max(screenBounds.x, Math.min(x, maxX));
y = Math.max(screenBounds.y, Math.min(y, maxY));
return new Point(x, y);
}
private static Rectangle getScreenBounds(Point point) {
GraphicsEnvironment graphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment();
for (GraphicsDevice screenDevice : graphicsEnvironment.getScreenDevices()) {
Rectangle bounds = screenDevice.getDefaultConfiguration().getBounds();
if (bounds.contains(point)) {
return bounds;
}
}
return graphicsEnvironment.getDefaultScreenDevice().getDefaultConfiguration().getBounds();
}
public static void showMainFrame() {
App.mainFrame.setVisible(true);
if (App.mainFrame.getExtendedState() == Frame.ICONIFIED) {
@@ -68,4 +94,4 @@ public class JPopupMenuMouseAdapter extends MouseAdapter {
}
App.mainFrame.requestFocus();
}
}
}
@@ -1,10 +1,5 @@
package com.luoboduner.moo.tool.ui.dialog;
import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinition;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;
import com.formdev.flatlaf.util.SystemInfo;
import com.intellij.uiDesigner.core.GridConstraints;
import com.intellij.uiDesigner.core.GridLayoutManager;
@@ -18,6 +13,7 @@ import com.luoboduner.moo.tool.ui.form.MainWindow;
import com.luoboduner.moo.tool.ui.form.func.CronForm;
import com.luoboduner.moo.tool.ui.form.func.FavoriteCronForm;
import com.luoboduner.moo.tool.util.ComponentUtil;
import com.luoboduner.moo.tool.util.CronExpressionUtil;
import com.luoboduner.moo.tool.util.MybatisUtil;
import com.luoboduner.moo.tool.util.SqliteUtil;
import com.luoboduner.moo.tool.util.SystemUtil;
@@ -157,10 +153,7 @@ public class FavoriteCronDialog extends JDialog {
default -> {
}
}
CronDescriptor descriptor = CronDescriptor.instance(selectedLocale);
CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ);
CronParser parser = new CronParser(cronDefinition);
String description = descriptor.describe(parser.parse(cron));
String description = CronExpressionUtil.describe(cron, selectedLocale);
nameTextField.setText(description);
} catch (Exception e) {
@@ -13,6 +13,7 @@ import com.jayway.jsonpath.JsonPath;
import com.luoboduner.moo.tool.App;
import com.luoboduner.moo.tool.ui.UiConsts;
import com.luoboduner.moo.tool.util.ComponentUtil;
import com.luoboduner.moo.tool.util.DownloadLinkSelector;
import com.luoboduner.moo.tool.util.SystemUtil;
import org.apache.commons.lang3.StringUtils;
@@ -102,15 +103,7 @@ public class UpdateDialog extends JDialog {
return;
} else {
DocumentContext parse = JsonPath.parse(downloadLinkInfo);
if (SystemUtil.isWindowsOs()) {
fileUrl = parse.read("$.windows");
} else if (SystemUtil.isMacOs()) {
fileUrl = parse.read("$.mac");
} else if (SystemUtil.isMacSilicon()) {
fileUrl = parse.read("$.macSilicon");
} else if (SystemUtil.isLinuxOs()) {
fileUrl = parse.read("$.linux");
}
fileUrl = DownloadLinkSelector.select(parse);
}
String fileName = FileUtil.getName(fileUrl);
@@ -4,10 +4,12 @@ import com.formdev.flatlaf.extras.FlatSVGIcon;
import com.intellij.uiDesigner.core.GridConstraints;
import com.intellij.uiDesigner.core.GridLayoutManager;
import com.intellij.uiDesigner.core.Spacer;
import com.luoboduner.moo.tool.util.ConfigUtil;
import com.luoboduner.moo.tool.util.UndoUtil;
import com.luoboduner.moo.tool.util.translator.Translator;
import com.luoboduner.moo.tool.util.translator.TranslatorFactory;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import javax.swing.*;
import javax.swing.event.DocumentEvent;
@@ -28,9 +30,15 @@ public class TranslationLayoutForm {
private JComboBox comboBox1;
private JButton exchangeButton;
private JComboBox comboBox2;
private JComboBox translatorComboBox;
private static AtomicInteger changeCount = new AtomicInteger(0);
// Constants for translator names
private static final String TRANSLATOR_GOOGLE = "Google翻译";
private static final String TRANSLATOR_BING = "Bing翻译";
private static final String TRANSLATOR_MICROSOFT = "微软翻译";
public TranslationLayoutForm() {
exchangeButton = new JButton();
exchangeButton.setIcon(new FlatSVGIcon("icon/exchange.svg"));
@@ -49,10 +57,30 @@ public class TranslationLayoutForm {
defaultComboBoxModel2.addElement("英语");
comboBox2.setModel(defaultComboBoxModel2);
translatorComboBox = new JComboBox();
final DefaultComboBoxModel translatorComboBoxModel = new DefaultComboBoxModel();
translatorComboBoxModel.addElement(TRANSLATOR_GOOGLE);
translatorComboBoxModel.addElement(TRANSLATOR_BING);
translatorComboBoxModel.addElement(TRANSLATOR_MICROSOFT);
translatorComboBox.setModel(translatorComboBoxModel);
translatorComboBox.setToolTipText("选择翻译源。注意:微软翻译暂时回退到Google翻译");
// Load saved translator preference
String savedTranslator = ConfigUtil.getInstance().getTranslatorType();
if ("MICROSOFT".equals(savedTranslator)) {
translatorComboBox.setSelectedItem(TRANSLATOR_MICROSOFT);
} else if ("BING".equals(savedTranslator)) {
translatorComboBox.setSelectedItem(TRANSLATOR_BING);
} else {
translatorComboBox.setSelectedItem(TRANSLATOR_GOOGLE);
}
leftMenuToolBar = new JToolBar();
leftMenuToolBar.add(comboBox1);
leftMenuToolBar.add(exchangeButton);
leftMenuToolBar.add(comboBox2);
leftMenuToolBar.addSeparator();
leftMenuToolBar.add(new JLabel("翻译源: "));
leftMenuToolBar.add(translatorComboBox);
leftMenuPanel.add(leftMenuToolBar);
@@ -116,6 +144,25 @@ public class TranslationLayoutForm {
}
});
translatorComboBox.addItemListener(e -> {
if (e.getStateChange() == ItemEvent.SELECTED) {
String itemName = e.getItem().toString();
String translatorType;
if (TRANSLATOR_GOOGLE.equals(itemName)) {
translatorType = "GOOGLE";
} else if (TRANSLATOR_BING.equals(itemName)) {
translatorType = "BING";
} else {
translatorType = "MICROSOFT";
}
ConfigUtil.getInstance().setTranslatorType(translatorType);
// Only translate if there's actual text to translate
if (!StringUtils.isEmpty(textArea1.getText())) {
translateControl();
}
}
});
textArea1.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
@@ -155,8 +202,34 @@ public class TranslationLayoutForm {
String targetLanguage = comboBox2.getSelectedItem().toString();
String text = textArea1.getText();
TranslatorFactory translatorFactory = new TranslatorFactory();
String result = translatorFactory.getTranslator(TranslatorFactory.TranslatorType.GOOGLE).translate(text, Translator.languageNameToCodeMap.get(sourceLanguage), Translator.languageNameToCodeMap.get(targetLanguage));
// Skip translation if text is empty
if (StringUtils.isEmpty(text)) {
textArea2.setText("");
return;
}
// Get the selected translator type from config
String translatorTypeStr = ConfigUtil.getInstance().getTranslatorType();
TranslatorFactory.TranslatorType translatorType = TranslatorFactory.TranslatorType.GOOGLE;
try {
translatorType = TranslatorFactory.TranslatorType.valueOf(translatorTypeStr);
} catch (IllegalArgumentException e) {
// Default to GOOGLE if invalid
translatorType = TranslatorFactory.TranslatorType.GOOGLE;
}
// Get language codes, with fallback for null values
String sourceLangCode = Translator.languageNameToCodeMap.get(sourceLanguage);
String targetLangCode = Translator.languageNameToCodeMap.get(targetLanguage);
if (sourceLangCode == null) {
sourceLangCode = "auto"; // Default to auto-detect
}
if (targetLangCode == null) {
targetLangCode = "zh-CN"; // Default to Simplified Chinese
}
String result = TranslatorFactory.getTranslator(translatorType).translate(text, sourceLangCode, targetLangCode);
textArea2.setText(result);
}
@@ -25,6 +25,7 @@ import javax.swing.text.StyleContext;
import java.awt.*;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
/**
* <pre>
@@ -54,6 +55,9 @@ public class TimeConvertForm {
private JSplitPane splitPane;
private JScrollPane leftScrollPane;
private JButton clockButton;
private JComboBox<String> timezoneComboBox;
private JLabel gmtLabel;
private JPanel timezoneQuickPanel;
private static final Log logger = LogFactory.get();
@@ -61,6 +65,43 @@ public class TimeConvertForm {
public static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
/**
* Common timezone IDs for the combo box
*/
private static final String[] COMMON_TIMEZONE_IDS = {
"UTC",
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Seoul",
"Asia/Singapore",
"Asia/Hong_Kong",
"Asia/Kolkata",
"Asia/Dubai",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Moscow",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Australia/Sydney",
"Pacific/Auckland"
};
/**
* Quick timezone buttons: display label -> timezone ID
*/
private static final String[][] QUICK_TIMEZONE_BUTTONS = {
{"UTC", "UTC"},
{"+8", "Asia/Shanghai"},
{"+9", "Asia/Tokyo"},
{"-5", "America/New_York"},
{"-8", "America/Los_Angeles"},
{"+1", "Europe/Paris"},
{"+3", "Europe/Moscow"},
};
private static TFuncContentMapper funcContentMapper = MybatisUtil.getSqlSession().getMapper(TFuncContentMapper.class);
private TimeConvertForm() {
@@ -74,9 +115,51 @@ public class TimeConvertForm {
return timeConvertForm;
}
/**
* Get the currently selected TimeZone from the combo box.
*/
public TimeZone getSelectedTimeZone() {
if (timezoneComboBox == null || timezoneComboBox.getSelectedItem() == null) {
return TimeZone.getDefault();
}
String selected = (String) timezoneComboBox.getSelectedItem();
// Extract timezone ID from display string like "Asia/Shanghai (GMT+08:00)"
int parenIndex = selected.indexOf(" (");
String tzId = parenIndex > 0 ? selected.substring(0, parenIndex) : selected;
return TimeZone.getTimeZone(tzId);
}
/**
* Format a timezone ID for display: "Asia/Shanghai (GMT+08:00)"
*/
private static String formatTimezoneDisplay(String tzId) {
TimeZone tz = TimeZone.getTimeZone(tzId);
int rawOffset = tz.getRawOffset();
int hours = rawOffset / 3600000;
int minutes = Math.abs((rawOffset % 3600000) / 60000);
return String.format("%s (GMT%+03d:%02d)", tzId, hours, minutes);
}
/**
* Update the gmtLabel to show the selected timezone.
*/
private void updateGmtLabel() {
TimeZone tz = getSelectedTimeZone();
int rawOffset = tz.getRawOffset();
int hours = rawOffset / 3600000;
int minutes = Math.abs((rawOffset % 3600000) / 60000);
String offsetStr = String.format("GMT%+03d:%02d", hours, minutes);
if (gmtLabel != null) {
gmtLabel.setText("时间(" + offsetStr + ")");
}
}
public static void init() {
timeConvertForm = getInstance();
// Initialize timezone combo box
initTimezoneComponents();
ThreadUtil.execute(() -> {
while (true) {
timeConvertForm.getCurrentTimestampLabel().setText(String.valueOf(System.currentTimeMillis() / 1000));
@@ -89,7 +172,8 @@ public class TimeConvertForm {
timeConvertForm.getTimestampTextField().setText(String.valueOf(System.currentTimeMillis()));
}
if ("".equals(timeConvertForm.getGmtTextField().getText())) {
timeConvertForm.getGmtTextField().setText(DateFormatUtils.format(new Date(), TIME_FORMAT));
TimeZone tz = timeConvertForm.getSelectedTimeZone();
timeConvertForm.getGmtTextField().setText(DateFormatUtils.format(new Date(), TIME_FORMAT, tz));
}
Style.emphaticIndicatorFont(timeConvertForm.getCurrentGmtLabel());
@@ -120,6 +204,112 @@ public class TimeConvertForm {
TimeConvertListener.addListeners();
}
/**
* Initialize timezone selection components and add them to the UI.
*/
private static void initTimezoneComponents() {
// Create timezone combo box with common timezones
timeConvertForm.timezoneComboBox = new JComboBox<>();
for (String tzId : COMMON_TIMEZONE_IDS) {
timeConvertForm.timezoneComboBox.addItem(formatTimezoneDisplay(tzId));
}
// Set default selection to system default timezone
String systemTzId = TimeZone.getDefault().getID();
String systemDisplay = formatTimezoneDisplay(systemTzId);
boolean found = false;
for (int i = 0; i < timeConvertForm.timezoneComboBox.getItemCount(); i++) {
if (timeConvertForm.timezoneComboBox.getItemAt(i).equals(systemDisplay)) {
timeConvertForm.timezoneComboBox.setSelectedIndex(i);
found = true;
break;
}
}
if (!found) {
// Add system timezone if not in the common list
timeConvertForm.timezoneComboBox.insertItemAt(systemDisplay, 0);
timeConvertForm.timezoneComboBox.setSelectedIndex(0);
}
// Create quick timezone buttons panel
timeConvertForm.timezoneQuickPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0));
JLabel tzLabel = new JLabel("时区:");
timeConvertForm.timezoneQuickPanel.add(tzLabel);
timeConvertForm.timezoneQuickPanel.add(timeConvertForm.timezoneComboBox);
for (String[] btnDef : QUICK_TIMEZONE_BUTTONS) {
JButton btn = new JButton(btnDef[0]);
btn.setMargin(new Insets(2, 6, 2, 6));
String tzId = btnDef[1];
btn.setToolTipText(formatTimezoneDisplay(tzId));
btn.addActionListener(e -> {
String display = formatTimezoneDisplay(tzId);
for (int i = 0; i < timeConvertForm.timezoneComboBox.getItemCount(); i++) {
if (timeConvertForm.timezoneComboBox.getItemAt(i).equals(display)) {
timeConvertForm.timezoneComboBox.setSelectedIndex(i);
return;
}
}
// If not found, add and select
timeConvertForm.timezoneComboBox.addItem(display);
timeConvertForm.timezoneComboBox.setSelectedItem(display);
});
timeConvertForm.timezoneQuickPanel.add(btn);
}
// Listen for timezone changes to update label
timeConvertForm.timezoneComboBox.addActionListener(e -> {
timeConvertForm.updateGmtLabel();
});
// Add timezone panel to panel5 (the conversion panel, parent of gmtTextField)
Container gmtParent = timeConvertForm.getGmtTextField().getParent();
if (gmtParent instanceof JPanel) {
JPanel panel5 = (JPanel) gmtParent;
LayoutManager lm = panel5.getLayout();
if (lm instanceof GridLayoutManager) {
// Store all existing components and their constraints
Component[] components = panel5.getComponents();
GridLayoutManager glm = (GridLayoutManager) lm;
GridConstraints[] storedConstraints = new GridConstraints[components.length];
for (int i = 0; i < components.length; i++) {
storedConstraints[i] = glm.getConstraintsForComponent(components[i]);
}
// Rebuild panel5 with 4 rows (was 3 rows x 3 cols)
panel5.removeAll();
panel5.setLayout(new GridLayoutManager(4, 3, new Insets(10, 10, 10, 10), -1, -1));
// Re-add existing components with their original constraints
for (int i = 0; i < components.length; i++) {
panel5.add(components[i], storedConstraints[i]);
}
// Add timezone panel at row 3, spanning all 3 columns
panel5.add(timeConvertForm.timezoneQuickPanel,
new GridConstraints(3, 0, 1, 3,
GridConstraints.ANCHOR_WEST, GridConstraints.FILL_HORIZONTAL,
GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW,
GridConstraints.SIZEPOLICY_FIXED,
null, null, null, 0, false));
}
// Find and store reference to the "本地时间" label for dynamic updates
for (Component comp : panel5.getComponents()) {
if (comp instanceof JLabel) {
JLabel label = (JLabel) comp;
if (label.getText() != null && label.getText().contains("本地时间")) {
timeConvertForm.gmtLabel = label;
break;
}
}
}
}
// Update the label to show current timezone
timeConvertForm.updateGmtLabel();
}
public static int saveContent() {
timeConvertForm = getInstance();
String timeHisText = timeConvertForm.getTimeHisTextArea().getText();
@@ -3,28 +3,20 @@ package com.luoboduner.moo.tool.ui.listener.func;
import cn.hutool.core.date.DateUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinition;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.model.time.ExecutionTime;
import com.cronutils.parser.CronParser;
import com.google.common.collect.Lists;
import com.luoboduner.moo.tool.App;
import com.luoboduner.moo.tool.ui.dialog.CommonCronDialog;
import com.luoboduner.moo.tool.ui.dialog.FavoriteCronDialog;
import com.luoboduner.moo.tool.ui.form.func.CronForm;
import com.luoboduner.moo.tool.ui.frame.FavoriteCronFrame;
import com.luoboduner.moo.tool.util.CronExpressionUtil;
import org.apache.commons.lang3.exception.ExceptionUtils;
import javax.swing.*;
import javax.swing.event.DocumentEvent;
import java.awt.event.ActionListener;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* <pre>
@@ -54,10 +46,7 @@ public class CronListener {
default -> {
}
}
CronDescriptor descriptor = CronDescriptor.instance(selectedLocale);
CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ);
CronParser parser = new CronParser(cronDefinition);
String description = descriptor.describe(parser.parse(cronExpression));
String description = CronExpressionUtil.describe(cronExpression, selectedLocale);
cronForm.getHumanReadableTextField().setText(description);
} catch (Exception ex) {
@@ -102,23 +91,12 @@ public class CronListener {
try {
String cronExpression = cronForm.getCronExpressionTextField().getText();
CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ);
CronParser parser = new CronParser(cronDefinition);
// 获取未来10次运行时间:
List<String> nextExecutionTimes = Lists.newArrayList();
ExecutionTime executionTime = ExecutionTime.forCron(parser.parse(cronExpression));
ZonedDateTime now = ZonedDateTime.now();
Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(now);
for (int i = 0; i < 10; i++) {
if (nextExecution.isPresent()) {
// yyyy-MM-dd HH:mm:ss
LocalDateTime localDateTime = nextExecution.get().toLocalDateTime();
String format = DateUtil.format(localDateTime, "yyyy-MM-dd HH:mm:ss");
nextExecutionTimes.add(format);
nextExecution = executionTime.nextExecution(nextExecution.get());
}
}
List<String> nextExecutionTimes = CronExpressionUtil.nextExecutions(cronExpression, ZonedDateTime.now(), 10)
.stream()
.map(ZonedDateTime::toLocalDateTime)
.map(localDateTime -> DateUtil.format(localDateTime, "yyyy-MM-dd HH:mm:ss"))
.toList();
cronForm.getNextExecutionTimeTextArea().setText("最近10次运行时间:\n" + String.join("\n", nextExecutionTimes));
} catch (Exception ex) {
cronForm.getNextExecutionTimeTextArea().setText("最近10次运行时间:\n" + ex.getMessage());
@@ -10,12 +10,13 @@ import com.luoboduner.moo.tool.util.AlertUtil;
import com.luoboduner.moo.tool.util.ConsoleUtil;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.apache.commons.lang3.time.DateUtils;
import javax.swing.*;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
/**
* <pre>
@@ -141,7 +142,10 @@ public class TimeConvertListener {
try {
String localTime = timeConvertForm.getGmtTextField().getText();
String unit = (String) timeConvertForm.getUnitComboBox().getSelectedItem();
Date date = DateUtils.parseDate(localTime, TimeConvertForm.TIME_FORMAT);
TimeZone tz = timeConvertForm.getSelectedTimeZone();
SimpleDateFormat sdf = new SimpleDateFormat(TimeConvertForm.TIME_FORMAT);
sdf.setTimeZone(tz);
Date date = sdf.parse(localTime);
long timeStamp = date.getTime();
if ("秒(s)".equals(unit)) {
timeStamp = timeStamp / 1000;
@@ -149,7 +153,7 @@ public class TimeConvertListener {
timeConvertForm.getTimestampTextField().setText(String.valueOf(timeStamp));
timeConvertForm.getTimestampTextField().grabFocus();
output("本地时间: " + localTime + " --> 时间戳: " + timeStamp);
output("时间(" + tz.getID() + "): " + localTime + " --> 时间戳: " + timeStamp);
} catch (Exception ex) {
ex.printStackTrace();
logger.error(ExceptionUtils.getStackTrace(ex));
@@ -170,11 +174,12 @@ public class TimeConvertListener {
if ("秒(s)".equals(unit)) {
timeStamp = timeStamp * 1000;
}
String localTime = DateFormatUtils.format(new Date(timeStamp), TimeConvertForm.TIME_FORMAT);
TimeZone tz = timeConvertForm.getSelectedTimeZone();
String localTime = DateFormatUtils.format(new Date(timeStamp), TimeConvertForm.TIME_FORMAT, tz);
timeConvertForm.getGmtTextField().setText(localTime);
timeConvertForm.getGmtTextField().grabFocus();
output("时间戳: " + timeStamp + " --> 本地时间: " + localTime);
output("时间戳: " + timeStamp + " --> 时间(" + tz.getID() + "): " + localTime);
} catch (Exception ex) {
ex.printStackTrace();
logger.error(ExceptionUtils.getStackTrace(ex));
@@ -522,4 +522,12 @@ public class ConfigUtil extends ConfigBaseUtil {
public void setRegexText(String regexText) {
setting.putByGroup("regexText", "func.regex", regexText);
}
public String getTranslatorType() {
return setting.getStr("translatorType", "func.translation", "GOOGLE");
}
public void setTranslatorType(String translatorType) {
setting.putByGroup("translatorType", "func.translation", translatorType);
}
}
@@ -0,0 +1,76 @@
package com.luoboduner.moo.tool.util;
import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinition;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.model.time.ExecutionTime;
import com.cronutils.parser.CronParser;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public class CronExpressionUtil {
private CronExpressionUtil() {
}
public static String describe(String cronExpression, Locale locale) {
CronDescriptor descriptor = CronDescriptor.instance(locale);
return descriptor.describe(parse(cronExpression));
}
public static List<ZonedDateTime> nextExecutions(String cronExpression, ZonedDateTime startTime, int count) {
List<ZonedDateTime> nextExecutionTimes = new ArrayList<>();
if (count <= 0) {
return nextExecutionTimes;
}
ExecutionTime executionTime = ExecutionTime.forCron(parse(cronExpression));
Optional<ZonedDateTime> nextExecution = executionTime.nextExecution(startTime);
for (int i = 0; i < count && nextExecution.isPresent(); i++) {
ZonedDateTime execution = nextExecution.get();
nextExecutionTimes.add(execution);
nextExecution = executionTime.nextExecution(execution);
}
return nextExecutionTimes;
}
public static Cron parse(String cronExpression) {
String normalizedCronExpression = normalize(cronExpression);
CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(resolveCronType(normalizedCronExpression));
CronParser parser = new CronParser(cronDefinition);
return parser.parse(normalizedCronExpression);
}
private static CronType resolveCronType(String cronExpression) {
if (cronExpression.startsWith("@")) {
return CronType.UNIX;
}
int fieldCount = cronExpression.split("\\s+").length;
if (fieldCount == 5) {
return CronType.UNIX;
}
if (fieldCount == 6 || fieldCount == 7) {
return CronType.QUARTZ;
}
throw new IllegalArgumentException("Cron表达式格式错误,仅支持Linux 5段或Quartz 6/7段表达式");
}
private static String normalize(String cronExpression) {
if (cronExpression == null) {
throw new IllegalArgumentException("Cron表达式不能为空");
}
String normalizedCronExpression = cronExpression.trim();
if (normalizedCronExpression.isEmpty()) {
throw new IllegalArgumentException("Cron表达式不能为空");
}
return normalizedCronExpression;
}
}
@@ -0,0 +1,46 @@
package com.luoboduner.moo.tool.util;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.PathNotFoundException;
import org.apache.commons.lang3.StringUtils;
public class DownloadLinkSelector {
public static String select(DocumentContext links) {
return select(System.getProperty("os.name"), System.getProperty("os.arch"), links);
}
static String select(String osName, String osArch, DocumentContext links) {
if (contains(osName, "Windows")) {
return links.read("$.windows");
}
if (contains(osName, "Mac")) {
if ("aarch64".equals(osArch)) {
String appleSiliconLink = readOptional(links, "$.macSilicon");
if (StringUtils.isNotEmpty(appleSiliconLink)) {
return appleSiliconLink;
}
}
return links.read("$.mac");
}
if (contains(osName, "Linux")) {
return links.read("$.linux");
}
return "";
}
private static boolean contains(String value, String searchText) {
return value != null && value.contains(searchText);
}
private static String readOptional(DocumentContext links, String path) {
try {
return links.read(path);
} catch (PathNotFoundException e) {
return "";
}
}
}
@@ -6,7 +6,7 @@ import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea;
import javax.swing.text.JTextComponent;
import javax.swing.undo.UndoManager;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.KeyAdapter;
import java.lang.reflect.Field;
/**
@@ -26,6 +26,10 @@ public class UndoUtil {
* @param object
*/
public static void register(Object object) {
if (object instanceof JTextComponent) {
registerTextComponent((JTextComponent) object);
return;
}
Class strClass = object.getClass();
Field[] declaredFields = strClass.getDeclaredFields();
for (Field field : declaredFields) {
@@ -33,37 +37,38 @@ public class UndoUtil {
if (RSyntaxTextArea.class.isAssignableFrom(field.getType())) {
continue;
}
UndoManager undoManager = new UndoManager();
try {
field.setAccessible(true);
((JTextComponent) field.get(object)).getDocument().addUndoableEditListener(undoManager);
((JTextComponent) field.get(object)).addKeyListener(new KeyListener() {
@Override
public void keyReleased(KeyEvent arg0) {
}
@Override
public void keyPressed(KeyEvent evt) {
if ((evt.isControlDown() || evt.isMetaDown()) && evt.getKeyCode() == KeyEvent.VK_Z) {
if (undoManager.canUndo()) {
undoManager.undo();
}
}
if ((evt.isControlDown() || evt.isMetaDown()) && evt.getKeyCode() == KeyEvent.VK_Y) {
if (undoManager.canRedo()) {
undoManager.redo();
}
}
}
@Override
public void keyTyped(KeyEvent arg0) {
}
});
registerTextComponent((JTextComponent) field.get(object));
} catch (IllegalAccessException e) {
log.error(e.toString());
}
}
}
}
private static void registerTextComponent(JTextComponent textComponent) {
if (textComponent == null) {
return;
}
UndoManager undoManager = new UndoManager();
textComponent.getDocument().addUndoableEditListener(undoManager);
textComponent.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent evt) {
if ((evt.isControlDown() || evt.isMetaDown()) && evt.getKeyCode() == KeyEvent.VK_Z) {
if (undoManager.canUndo()) {
undoManager.undo();
}
evt.consume();
}
if ((evt.isControlDown() || evt.isMetaDown()) && evt.getKeyCode() == KeyEvent.VK_Y) {
if (undoManager.canRedo()) {
undoManager.redo();
}
evt.consume();
}
}
});
}
}
@@ -0,0 +1,230 @@
package com.luoboduner.moo.tool.util.translator;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.net.ssl.SSLHandshakeException;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* Bing翻译工具类 (使用Bing翻译API,在中国大陆可访问)
*/
@Slf4j
public class BingTranslatorUtil implements Translator {
/**
* Bing Translator API endpoint identifier
* This value is used by the Bing Translator public API
* It may need to be updated if Bing changes their API
*/
private static final String BING_TRANSLATOR_IID = "translator.5028.1";
/**
* @param word
* @param sourceLanguage 源语言 默认auto 英文为 en
* @param targetLanguage 目标语言 默认zh-CN
* @return
*/
public String translate(String word, String sourceLanguage, String targetLanguage) {
try {
if (StringUtils.isEmpty(word)) {
return "";
}
if (StringUtils.isEmpty(sourceLanguage)) {
sourceLanguage = "auto-detect";
}
if (StringUtils.isEmpty(targetLanguage)) {
targetLanguage = "zh-Hans";
}
// Convert language codes to Bing format
sourceLanguage = convertToBingLanguageCode(sourceLanguage);
targetLanguage = convertToBingLanguageCode(targetLanguage);
/**
* Build Bing Translator API URL with required parameters
* The IG and IID parameters are required by Bing Translator API
*/
String url = "https://www.bing.com/ttranslatev3?isVertical=1&IG=" +
generateIG() + "&IID=" + BING_TRANSLATOR_IID;
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("POST");
con.setConnectTimeout(10000);
con.setReadTimeout(10000);
con.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0");
con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
con.setRequestProperty("Referer", "https://www.bing.com/translator");
con.setRequestProperty("Origin", "https://www.bing.com");
con.setRequestProperty("Accept", "*/*");
con.setRequestProperty("Accept-Language", "en-US,en;q=0.9");
con.setDoOutput(true);
String postData = "fromLang=" + sourceLanguage +
"&to=" + targetLanguage +
"&text=" + URLEncoder.encode(word, StandardCharsets.UTF_8);
log.debug("Bing translation request - from: {}, to: {}, text: {}", sourceLanguage, targetLanguage, word);
// Write request body with proper resource management
try (java.io.OutputStream os = con.getOutputStream()) {
os.write(postData.getBytes(StandardCharsets.UTF_8));
}
// Check response code
int responseCode = con.getResponseCode();
log.debug("Bing API response code: {}", responseCode);
if (responseCode != 200) {
// Try to read error stream for more details
StringBuilder errorResponse = new StringBuilder();
try {
java.io.InputStream errorStream = con.getErrorStream();
if (errorStream != null) {
try (BufferedReader errorReader = new BufferedReader(
new InputStreamReader(errorStream, StandardCharsets.UTF_8))) {
String line;
while ((line = errorReader.readLine()) != null) {
errorResponse.append(line);
}
}
}
} catch (Exception ex) {
log.warn("Failed to read error stream", ex);
}
log.warn("Bing API error response (code {}): {}", responseCode, errorResponse);
return "Bing翻译接口返回错误状态码: " + responseCode;
}
// Read response with proper resource management
StringBuilder response = new StringBuilder();
try (BufferedReader in = new BufferedReader(
new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8))) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
}
String responseStr = response.toString();
if (responseStr.isEmpty()) {
log.warn("Bing API returned empty response body despite 200 status code");
}
return parseResult(responseStr);
} catch (SSLHandshakeException e) {
log.error("SSLHandshakeException", e);
return "访问Bing翻译接口网络异常:" + e.getMessage();
} catch (SocketTimeoutException e) {
log.error("SocketTimeoutException", e);
return "访问Bing翻译接口超时:" + e.getMessage();
} catch (Exception e) {
log.error("访问Bing翻译异常", e);
return "访问Bing翻译接口异常:" + e.getMessage();
}
}
/**
* Generate IG parameter for Bing Translator API
* This is a hash-like value that changes over time
* @return IG parameter value
*/
private String generateIG() {
// Simple IG generation based on timestamp
// Format: hexadecimal string based on current time
long timestamp = System.currentTimeMillis();
return Long.toHexString(timestamp).toUpperCase();
}
private String convertToBingLanguageCode(String code) {
if (StringUtils.isEmpty(code) || "auto".equals(code)) {
return "auto-detect";
}
// Convert common codes to Bing format
switch (code) {
case "zh-CN":
return "zh-Hans";
case "cht":
return "zh-Hant";
case "en":
return "en";
case "jp":
return "ja";
case "kor":
return "ko";
case "fra":
return "fr";
case "spa":
return "es";
case "th":
return "th";
case "ara":
return "ar";
case "ru":
return "ru";
case "pt":
return "pt";
case "de":
return "de";
case "it":
return "it";
case "el":
return "el";
case "nl":
return "nl";
case "pl":
return "pl";
case "cs":
return "cs";
case "swe":
return "sv";
case "hu":
return "hu";
case "vie":
return "vi";
default:
return code;
}
}
private String parseResult(String inputJson) {
try {
if (StringUtils.isEmpty(inputJson)) {
log.warn("Bing API returned empty response");
return "翻译返回结果为空";
}
log.debug("Bing API response: {}", inputJson);
JSONArray jsonArray = new JSONArray(inputJson);
if (jsonArray.size() > 0) {
JSONObject firstItem = jsonArray.getJSONObject(0);
if (firstItem.containsKey("translations")) {
JSONArray translations = firstItem.getJSONArray("translations");
if (translations.size() > 0) {
JSONObject translation = translations.getJSONObject(0);
String result = translation.getStr("text");
if (!StringUtils.isEmpty(result)) {
return result;
}
}
}
}
log.warn("Bing API response format unexpected: {}", inputJson);
return "解析翻译结果失败,返回格式不符合预期";
} catch (Exception e) {
log.error("解析翻译结果异常,原始响应: {}", inputJson, e);
return "解析翻译结果异常:" + e.getMessage();
}
}
}
@@ -3,6 +3,7 @@ package com.luoboduner.moo.tool.util.translator;
public class TranslatorFactory {
public enum TranslatorType {
GOOGLE,
BING,
MICROSOFT
}
@@ -10,8 +11,12 @@ public class TranslatorFactory {
switch (type) {
case GOOGLE:
return new GoogleTranslatorUtil();
case BING:
return new BingTranslatorUtil();
case MICROSOFT:
// return new MicrosoftTranslatorUtil();
// Microsoft translator requires API key configuration
// Falling back to Google translator for now
return new GoogleTranslatorUtil();
default:
throw new IllegalArgumentException("Unknown translator type: " + type);
}
@@ -0,0 +1,42 @@
package com.luoboduner.moo.tool.ui.component;
import org.junit.Test;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.Rectangle;
import static org.junit.Assert.assertEquals;
public class JPopupMenuMouseAdapterTest {
@Test
public void calculatePopupLocationUsesAbsoluteCoordinatesOnSecondaryScreen() {
Point location = JPopupMenuMouseAdapter.calculatePopupLocation(
new Point(2500, 1040),
new Dimension(180, 120),
new Rectangle(1920, 0, 1920, 1080));
assertEquals(new Point(2320, 920), location);
}
@Test
public void calculatePopupLocationKeepsPopupInScreenWithNegativeOrigin() {
Point location = JPopupMenuMouseAdapter.calculatePopupLocation(
new Point(-10, 1040),
new Dimension(180, 120),
new Rectangle(-1920, 0, 1920, 1080));
assertEquals(new Point(-190, 920), location);
}
@Test
public void calculatePopupLocationClampsToCurrentScreenBounds() {
Point location = JPopupMenuMouseAdapter.calculatePopupLocation(
new Point(1930, 10),
new Dimension(180, 120),
new Rectangle(1920, 0, 1920, 1080));
assertEquals(new Point(1920, 0), location);
}
}
@@ -0,0 +1,43 @@
package com.luoboduner.moo.tool.util;
import org.junit.Test;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class CronExpressionUtilTest {
private static final ZoneId ZONE_ID = ZoneId.of("Asia/Shanghai");
@Test
public void shouldCalculateNextExecutionsForUnixCron() {
ZonedDateTime now = ZonedDateTime.of(2026, 6, 3, 10, 1, 30, 0, ZONE_ID);
List<ZonedDateTime> nextExecutionTimes = CronExpressionUtil.nextExecutions("*/5 * * * *", now, 3);
assertEquals(ZonedDateTime.of(2026, 6, 3, 10, 5, 0, 0, ZONE_ID), nextExecutionTimes.get(0));
assertEquals(ZonedDateTime.of(2026, 6, 3, 10, 10, 0, 0, ZONE_ID), nextExecutionTimes.get(1));
assertEquals(ZonedDateTime.of(2026, 6, 3, 10, 15, 0, 0, ZONE_ID), nextExecutionTimes.get(2));
}
@Test
public void shouldKeepQuartzCronCompatibility() {
ZonedDateTime now = ZonedDateTime.of(2026, 6, 3, 11, 59, 0, 0, ZONE_ID);
List<ZonedDateTime> nextExecutionTimes = CronExpressionUtil.nextExecutions("0 0 12 * * ?", now, 1);
assertEquals(ZonedDateTime.of(2026, 6, 3, 12, 0, 0, 0, ZONE_ID), nextExecutionTimes.get(0));
}
@Test
public void shouldDescribeUnixCron() {
String description = CronExpressionUtil.describe("*/5 * * * *", Locale.ENGLISH);
assertFalse(description.isBlank());
}
}
@@ -0,0 +1,44 @@
package com.luoboduner.moo.tool.util;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Assert;
import org.junit.Test;
public class DownloadLinkSelectorTest {
@Test
public void selectsAppleSiliconDownloadBeforeGenericMacDownload() {
DocumentContext links = JsonPath.parse("{"
+ "\"mac\":\"https://example.com/MooTool.dmg\","
+ "\"macSilicon\":\"https://example.com/MooTool-AppleSilicon.dmg\""
+ "}");
String selected = DownloadLinkSelector.select("Mac OS X", "aarch64", links);
Assert.assertEquals("https://example.com/MooTool-AppleSilicon.dmg", selected);
}
@Test
public void fallsBackToGenericMacDownloadWhenAppleSiliconDownloadIsMissing() {
DocumentContext links = JsonPath.parse("{"
+ "\"mac\":\"https://example.com/MooTool.dmg\""
+ "}");
String selected = DownloadLinkSelector.select("Mac OS X", "aarch64", links);
Assert.assertEquals("https://example.com/MooTool.dmg", selected);
}
@Test
public void selectsGenericMacDownloadForIntelMac() {
DocumentContext links = JsonPath.parse("{"
+ "\"mac\":\"https://example.com/MooTool.dmg\","
+ "\"macSilicon\":\"https://example.com/MooTool-AppleSilicon.dmg\""
+ "}");
String selected = DownloadLinkSelector.select("Mac OS X", "x86_64", links);
Assert.assertEquals("https://example.com/MooTool.dmg", selected);
}
}
@@ -0,0 +1,51 @@
package com.luoboduner.moo.tool.util;
import org.junit.Test;
import javax.swing.JTextArea;
import java.awt.event.KeyEvent;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class UndoUtilTest {
@Test
public void ctrlZUndoConsumesEvent() {
JTextArea textArea = new JTextArea();
TextAreaHolder holder = new TextAreaHolder(textArea);
UndoUtil.register(holder);
textArea.setText("before");
textArea.append(" after");
KeyEvent ctrlZ = new KeyEvent(textArea, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
KeyEvent.CTRL_DOWN_MASK, KeyEvent.VK_Z, KeyEvent.CHAR_UNDEFINED);
textArea.getKeyListeners()[0].keyPressed(ctrlZ);
assertEquals("before", textArea.getText());
assertTrue(ctrlZ.isConsumed());
}
@Test
public void directTextComponentCanBeRegistered() {
JTextArea textArea = new JTextArea();
UndoUtil.register(textArea);
textArea.setText("before");
textArea.append(" after");
KeyEvent ctrlZ = new KeyEvent(textArea, KeyEvent.KEY_PRESSED, System.currentTimeMillis(),
KeyEvent.CTRL_DOWN_MASK, KeyEvent.VK_Z, KeyEvent.CHAR_UNDEFINED);
textArea.getKeyListeners()[0].keyPressed(ctrlZ);
assertEquals("before", textArea.getText());
assertTrue(ctrlZ.isConsumed());
}
private static class TextAreaHolder {
private final JTextArea textArea;
private TextAreaHolder(JTextArea textArea) {
this.textArea = textArea;
}
}
}
@@ -0,0 +1,57 @@
package com.luoboduner.moo.tool.util.translator;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Test for BingTranslatorUtil
*
* Note: These tests focus on the parsing logic and parameter handling
* rather than actual API calls which require network access.
*/
public class BingTranslatorUtilTest {
private BingTranslatorUtil translator = new BingTranslatorUtil();
@Test
public void testTranslateEmptyString() {
String result = translator.translate("", "auto", "zh-CN");
assertEquals("Empty string should return empty result", "", result);
}
@Test
public void testTranslateNull() {
String result = translator.translate(null, "auto", "zh-CN");
assertEquals("Null string should return empty result", "", result);
}
@Test
public void testLanguageCodeConversion() {
// Test that language code conversion doesn't cause crashes
// This is important because the translator needs to convert between
// the app's language codes and Bing's expected format
// We can't easily test the actual conversion without making it public,
// but we can verify the translator handles various language codes without exceptions
// Note: These calls will attempt to reach the API, so they may fail with network errors
// That's acceptable - we're mainly testing that language code handling doesn't crash
String result;
// Test Chinese to English
result = translator.translate("test", "zh-CN", "en");
assertNotNull("Result should not be null", result);
// Test English to Chinese
result = translator.translate("test", "en", "zh-CN");
assertNotNull("Result should not be null", result);
// Test auto-detect
result = translator.translate("test", "auto", "zh-CN");
assertNotNull("Result should not be null", result);
// Even if API fails, we should get an error message, not crash or return null
assertNotNull("Result should not be null even on API failure", result);
}
}