From 63790e4c511ecd63bda8b527894f4ba8f19bcfc2 Mon Sep 17 00:00:00 2001 From: 3944Realms Date: Tue, 12 Aug 2025 20:39:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E4=BA=8E[=E6=8F=90=E4=BA=A4](https://?= =?UTF-8?q?github.com/CXT-maker/docCheckToolMaven/commit/f853f03bac48d43d8?= =?UTF-8?q?613a5ce831e27a824001018)=E4=BF=AE=E6=94=B9=E7=9A=84=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/misc.xml | 3 + build.gradle | 146 +++--- gradle.properties | 4 +- src/main/java/module-info.java | 22 - .../JavaFxApplication.java | 20 + .../docchecktoolrefactored/Main.java | 37 +- .../cil/CliProcessor.java | 440 ++++++++++++++++++ .../core/AddressFileComparator.java | 266 +++++++++++ .../core/AddressFileGenerator.java | 40 ++ .../core/DuplicateFinder.java | 47 +- .../core/FileHashCalculator.java | 25 + .../core/HashFileGenerator.java | 92 ++++ .../core/LogicalAddressFileGenerator.java | 158 +++++++ .../core/MD5HashCalculator.java | 64 +++ .../core/PhysicalAddressFileGenerator.java | 241 ++++++++++ .../deprecated/BaseFunctionPanel.java | 29 -- .../deprecated/DocCheckToolMainFrame.java | 108 ----- .../deprecated/DuplicateCheckPanel.java | 166 ------- .../deprecated/OtherFunctionPanel.java | 35 -- .../deprecated/PathCheckPanel.java | 287 ------------ .../io/reader/CatalogFileReader.java | 20 + .../io/reader/CatalogFileReaderFactory.java | 29 ++ .../io/reader/DbfFileReader.java | 112 +++++ .../io/reader/ExcelFileReader.java | 195 ++++++++ .../io/reader/FileReader.java | 43 -- .../io/reader/MemoryMappedFileReader.java | 50 -- .../io/reader/XmlFileReader.java | 146 ++++++ .../io/scanner/ParallelFileScanner.java | 2 - .../model/RecordData.java | 23 + .../DuplicateDocumentPaneController.java | 42 +- .../ui/module/PathCheckPaneController.java | 251 +++++++++- .../ui/module/ProjectInfoPaneController.java | 10 + .../module/StorageCarrierPaneController.java | 254 ++++++++++ .../task/DuplicateDocumentDetectionTask.java | 177 +++++++ .../ui/utils/DialogUtil.java | 9 + .../docchecktoolrefactored/util/FileUtil.java | 98 ++++ .../util/StringUtil.java | 8 +- src/main/resources/fxml/login-view.fxml | 5 +- src/main/resources/fxml/main-view.fxml | 24 +- .../fxml/module/project-info-pane.fxml | 66 ++- .../resources/fxml/module/step-1-pane.fxml | 45 +- .../resources/fxml/module/step-2-pane.fxml | 53 ++- .../resources/fxml/module/step-3-pane.fxml | 17 +- .../resources/fxml/module/step-4-pane.fxml | 19 +- .../resources/fxml/module/step-5-pane.fxml | 17 +- .../resources/fxml/module/step-6-pane.fxml | 17 +- .../resources/fxml/module/step-7-pane.fxml | 157 ++++++- src/test/java/module-info.java | 9 - .../test/DuplicateFinderPerformanceTest.java | 6 +- 49 files changed, 3215 insertions(+), 919 deletions(-) delete mode 100644 src/main/java/module-info.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/JavaFxApplication.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/cil/CliProcessor.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileComparator.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileGenerator.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/core/HashFileGenerator.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/core/LogicalAddressFileGenerator.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/core/PhysicalAddressFileGenerator.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/BaseFunctionPanel.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DocCheckToolMainFrame.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DuplicateCheckPanel.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/OtherFunctionPanel.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/PathCheckPanel.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReader.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReaderFactory.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/DbfFileReader.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/ExcelFileReader.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/FileReader.java delete mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/MemoryMappedFileReader.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/XmlFileReader.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/model/RecordData.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/StorageCarrierPaneController.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/ui/task/DuplicateDocumentDetectionTask.java create mode 100644 src/main/java/top/r3944realms/docchecktoolrefactored/util/FileUtil.java delete mode 100644 src/test/java/module-info.java diff --git a/.idea/misc.xml b/.idea/misc.xml index 72ad960..1ce6b8b 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -5,6 +5,9 @@ + + + diff --git a/build.gradle b/build.gradle index 78158f5..f51564b 100644 --- a/build.gradle +++ b/build.gradle @@ -4,12 +4,16 @@ buildscript { mavenCentral() } } + plugins { id 'java' id 'io.franzbecker.gradle-lombok' version '3.0.0' id 'application' id 'org.openjfx.javafxplugin' version '0.1.0' - id 'org.beryx.jlink' version '2.26.0' + // 不再使用模块插件,移除 org.javamodularity.moduleplugin + // id 'org.javamodularity.moduleplugin' version '1.8.12' + // 你可以根据需要保留 jlink 插件,但推荐取消模块化后暂时不用它 + id 'org.beryx.jlink' version '2.26.0' apply false } group = project_group @@ -23,102 +27,122 @@ ext { junitVersion = '5.10.0' } +// 指定 JDK 版本 +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + sourceCompatibility = '17' targetCompatibility = '17' tasks.withType(JavaCompile).configureEach { - options.release = 17 // 明确指定Java版本 + options.release = 17 options.encoding = 'UTF-8' } +// 平台判定,自动添加 JavaFX 依赖的 classifier +def osName = org.gradle.internal.os.OperatingSystem.current() +def javafxPlatform +if (osName.isWindows()) { + javafxPlatform = 'win' +} else if (osName.isMacOsX()) { + javafxPlatform = 'mac' +} else if (osName.isLinux()) { + javafxPlatform = 'linux' +} else { + throw new GradleException("Unsupported OS for JavaFX: $osName") +} + +def javafxVersion = '17.0.6' + +dependencies { + // JavaFX 必须带平台后缀,classpath 模式必须 + implementation "org.openjfx:javafx-controls:$javafxVersion:$javafxPlatform" + implementation "org.openjfx:javafx-fxml:$javafxVersion:$javafxPlatform" + + // 你项目的其他依赖(老库走classpath) + implementation 'ch.qos.logback:logback-classic:1.5.6' + implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0' + implementation 'commons-cli:commons-cli:1.9.0' + implementation 'com.alibaba:easyexcel:4.0.3' + implementation 'org.apache.pdfbox:pdfbox:3.0.5' + implementation 'com.github.albfernandez:javadbf:1.14.1' + implementation 'org.apache.poi:poi-ooxml:5.4.1' + implementation 'com.intellij:annotations:12.0' + + compileOnly 'org.projectlombok:lombok:1.18.38' + annotationProcessor 'org.projectlombok:lombok:1.18.38' + + testCompileOnly 'org.projectlombok:lombok:1.18.38' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.38' + + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" +} + application { - mainModule = 'top.r3944realms.docchecktoolrefactored' + // 取消模块化,去掉 mainModule mainClass = 'top.r3944realms.docchecktoolrefactored.Main' } javafx { - version = '17.0.6' + version = javafxVersion + // 这里的 modules 只为插件识别,实际classpath方式运行时不起作用 modules = ['javafx.controls', 'javafx.fxml'] } processResources { -// exclude 'logback.xml' duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -dependencies { - // Logback classic (included slf4j &) - implementation 'ch.qos.logback:logback-classic:1.5.6' - implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0' - - // Lombok - compileOnly("org.projectlombok:lombok:1.18.38") - annotationProcessor("org.projectlombok:lombok:1.18.38") - - testCompileOnly("org.projectlombok:lombok:1.18.38") - testAnnotationProcessor("org.projectlombok:lombok:1.18.38") - - testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitVersion}") -} - - test { useJUnitPlatform() configurations.configureEach { exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl' } } -// 可选:添加 testJar 任务 -tasks.register('testJar', Jar) { - from sourceSets.test.output -} +// 创建日志目录任务 tasks.register('createLogDir') { doLast { mkdir "${projectDir}/logs" } - -} -// 打包sourcesJar任务 -tasks.register('sourcesJar', Jar) { - dependsOn classes - - from sourceSets.main.allSource -} -// 打包javadocJar任务 -tasks.register('javadocJar', Jar) { - dependsOn javadoc - - from javadoc.destinationDir } -// 解决javadoc打包乱码 -javadoc { - options { - encoding "UTF-8" - charSet 'UTF-8' - author true - version true - title "DG_LAB" +processResources.dependsOn createLogDir + +// 其他自定义任务按需保留或调整 +tasks.register('runCli', JavaExec) { + group = 'application' + description = 'Run the application in CLI mode' + + classpath = sourceSets.main.runtimeClasspath + mainClass = 'top.r3944realms.docchecktoolrefactored.Main' + + // 默认参数,可以覆盖 + args '--cli', '-v' + + // 可以添加更多默认参数 + if (project.hasProperty('cliArgs')) { + args project.property('cliArgs').split() } } +// 可选:创建生成可执行JAR的任务 +tasks.register('buildCliJar', Jar) { + group = 'build' + description = 'Builds a standalone JAR for CLI mode' -jlink { - imageZip = project.file("${buildDir}/distributions/app-${javafx.platform.classifier}.zip") - options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages'] - launcher { - name = 'DocCheckTool' - jvmArgs = ['-Dlogback.configurationFile=./config/logback.xml'] // 支持外部配置 + manifest { + attributes 'Main-Class': 'top.r3944realms.docchecktoolrefactored.Main' } - mergedModule { - requires 'java.logging' - requires 'java.xml' - } -} -jlinkZip { - group = 'distribution' + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } with jar + + archiveBaseName = 'doc-check-tool-cli' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -processResources.dependsOn createLogDir \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index c0e4621..f49fe27 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -project_group ='top.r3944realms.docchecktoolrefacored' -project_version = '1.0-SNAPSHOT' \ No newline at end of file +project_group =top.r3944realms.docchecktoolrefacored +project_version = 1.0-SNAPSHOT \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java deleted file mode 100644 index 2db4a61..0000000 --- a/src/main/java/module-info.java +++ /dev/null @@ -1,22 +0,0 @@ -module top.r3944realms.docchecktoolrefactored { - requires javafx.graphics; - requires javafx.controls; - requires javafx.fxml; - requires static lombok; - requires org.slf4j; - - opens top.r3944realms.docchecktoolrefactored to javafx.fxml; - opens top.r3944realms.docchecktoolrefactored.ui to javafx.fxml; - opens top.r3944realms.docchecktoolrefactored.ui.module to javafx.fxml; - opens top.r3944realms.docchecktoolrefactored.deprecated to javafx.fxml; - - exports top.r3944realms.docchecktoolrefactored to javafx.graphics; - exports top.r3944realms.docchecktoolrefactored.ui to javafx.fxml; - exports top.r3944realms.docchecktoolrefactored.ui.module to javafx.fxml; - exports top.r3944realms.docchecktoolrefactored.deprecated to javafx.graphics; - - exports top.r3944realms.docchecktoolrefactored.core ; - exports top.r3944realms.docchecktoolrefactored.io.scanner; - exports top.r3944realms.docchecktoolrefactored.io.reader; - exports top.r3944realms.docchecktoolrefactored.model; -} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/JavaFxApplication.java b/src/main/java/top/r3944realms/docchecktoolrefactored/JavaFxApplication.java new file mode 100644 index 0000000..6b594bc --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/JavaFxApplication.java @@ -0,0 +1,20 @@ +package top.r3944realms.docchecktoolrefactored; + +import javafx.application.Application; +import javafx.stage.Stage; +import top.r3944realms.docchecktoolrefactored.ui.SceneManager; + +public class JavaFxApplication extends Application { + @Override + public void init() throws Exception { + super.init(); + } + + @Override + public void start(Stage primaryStage) throws Exception { + SceneManager.init(primaryStage); + SceneManager.switchLoginView(); + primaryStage.show(); + } + +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/Main.java b/src/main/java/top/r3944realms/docchecktoolrefactored/Main.java index 6cd07e9..707c20b 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/Main.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/Main.java @@ -1,35 +1,36 @@ package top.r3944realms.docchecktoolrefactored; -import javafx.application.Application; -import javafx.stage.Stage; import lombok.extern.slf4j.Slf4j; -import top.r3944realms.docchecktoolrefactored.ui.SceneManager; -import top.r3944realms.docchecktoolrefactored.util.StringUtil; +import top.r3944realms.docchecktoolrefactored.cil.CliProcessor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; /** * The type Main. */ @Slf4j -public class Main extends Application { - @Override - public void init() throws Exception { - super.init(); - } - - @Override - public void start(Stage primaryStage) throws Exception { - SceneManager.init(primaryStage); - SceneManager.switchLoginView(); - primaryStage.show(); - } +public class Main { /** * The entry point of application. * * @param args the input arguments */ + @SuppressWarnings("DataFlowIssue") public static void main(String[] args) { - log.info(StringUtil.NO_BUG); - launch(args); +// log.info(StringUtil.NO_BUG); + // 检查是否有 --cli 参数 + List list = Arrays.asList(args); + if (list.contains("--cli")) { + // CLI 模式 + List strList = new ArrayList<>(); + list.stream().filter(i -> !i.equals("--cli")).forEach(strList::add); + new CliProcessor().process(strList.toArray(new String[0])); + } else { + // GUI 模式 + JavaFxApplication.launch(JavaFxApplication.class, args); + } } } \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/cil/CliProcessor.java b/src/main/java/top/r3944realms/docchecktoolrefactored/cil/CliProcessor.java new file mode 100644 index 0000000..7a5b00a --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/cil/CliProcessor.java @@ -0,0 +1,440 @@ +package top.r3944realms.docchecktoolrefactored.cil; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.cli.*; +import top.r3944realms.docchecktoolrefactored.core.DuplicateFinder; +import top.r3944realms.docchecktoolrefactored.core.FileHashCalculator; +import top.r3944realms.docchecktoolrefactored.io.scanner.FileScanner; +import top.r3944realms.docchecktoolrefactored.io.scanner.RobustParallelScanner; +import top.r3944realms.docchecktoolrefactored.model.DuplicateGroup; +import top.r3944realms.docchecktoolrefactored.util.FileUtil; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.text.ParseException; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +/** + * 命令行处理器,负责解析参数和执行重复文件检查 + */ +@Slf4j +public class CliProcessor { + // 参数定义 + private static final String HELP_OPTION = "h"; + private static final String DIRECTORY_OPTION = "d"; + private static final String PROGRESS_OPTION = "p"; + private static final String MIN_SIZE_OPTION = "m"; + private static final String MAX_SIZE_OPTION = "M"; + private static final String EXCLUDE_OPTION = "e"; + private static final String FORMAT_OPTION = "f"; + private static final String OUTPUT_OPTION = "o"; + private static final String QUIET_OPTION = "q"; + private static final String SORT_OPTION = "s"; + private static final String VERSION_OPTION = "v"; + + // 支持的输出格式 + private enum OutputFormat { + TEXT, JSON, CSV + } + + // 支持的排序方式 + private enum SortBy { + SIZE, COUNT, PATH + } + + // 配置选项 + private boolean showProgress = false; + private boolean quietMode = false; + private OutputFormat outputFormat = OutputFormat.TEXT; + private SortBy sortBy = SortBy.SIZE; + private Path outputFile = null; + private long minSize = 0; + private Long maxSize = null; + private List excludeExtensions = List.of(); + + /** + * 处理命令行参数并执行相应操作 + * @param args 命令行参数 + */ + public void process(String[] args) { + Options options = createOptions(); + CommandLineParser parser = new DefaultParser(); + + try { + CommandLine cmd = parser.parse(options, args); + + // 处理帮助选项 + if (cmd.hasOption(HELP_OPTION)) { + printHelp(options); + return; + } + + // 处理版本选项 + if (cmd.hasOption(VERSION_OPTION)) { + printVersion(); + return; + } + + // 解析参数 + parseOptions(cmd); + + // 验证目录参数 + if (!cmd.hasOption(DIRECTORY_OPTION)) { + throw new IllegalArgumentException("Directory option (-d) is required"); + } + Path scanDir = Paths.get(cmd.getOptionValue(DIRECTORY_OPTION)); + + // 执行扫描 + List duplicates = scanForDuplicates(scanDir); + + // 输出结果 + outputResults(duplicates); + + } catch (ParseException e) { + System.err.println("Error: " + e.getMessage()); + printHelp(options); + System.exit(1); + } catch (Exception e) { + log.error("Error processing CLI command", e); + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + /** + * 创建命令行选项 + */ + private Options createOptions() { + Options options = new Options(); + + // 基本选项 + options.addOption(Option.builder(HELP_OPTION) + .longOpt("help") + .desc("Show this help message") + .build()); + options.addOption(Option.builder(VERSION_OPTION) + .longOpt("version") + .desc("Show version information") + .build()); + options.addOption(Option.builder(DIRECTORY_OPTION) + .longOpt("directory") + .hasArg() + .argName("PATH") + .desc("Directory to scan for duplicates") + .build()); + options.addOption(Option.builder(PROGRESS_OPTION) + .longOpt("progress") + .desc("Show progress bar during processing") + .build()); + options.addOption(Option.builder(QUIET_OPTION) + .longOpt("quiet") + .desc("Quiet mode, only show errors") + .build()); + + // 过滤选项 + options.addOption(Option.builder(MIN_SIZE_OPTION) + .longOpt("min-size") + .hasArg() + .argName("BYTES") + .desc("Minimum file size to consider (in bytes)") + .build()); + options.addOption(Option.builder(MAX_SIZE_OPTION) + .longOpt("max-size") + .hasArg() + .argName("BYTES") + .desc("Maximum file size to consider (in bytes)") + .build()); + options.addOption(Option.builder(EXCLUDE_OPTION) + .longOpt("exclude") + .hasArg() + .argName("EXTENSIONS") + .desc("Comma-separated list of file extensions to exclude") + .build()); + + // 输出控制选项 + options.addOption(Option.builder(FORMAT_OPTION) + .longOpt("format") + .hasArg() + .argName("FORMAT") + .desc("Output format (text/json/csv), default: text") + .build()); + options.addOption(Option.builder(OUTPUT_OPTION) + .longOpt("output") + .hasArg() + .argName("FILE") + .desc("Write output to file instead of stdout") + .build()); + options.addOption(Option.builder(SORT_OPTION) + .longOpt("sort") + .hasArg() + .argName("CRITERIA") + .desc("Sort results by (size/count/path), default: size") + .build()); + + return options; + } + + /** + * 解析命令行选项 + */ + private void parseOptions(CommandLine cmd) throws ParseException { + showProgress = cmd.hasOption(PROGRESS_OPTION); + quietMode = cmd.hasOption(QUIET_OPTION); + + // 解析输出格式 + if (cmd.hasOption(FORMAT_OPTION)) { + try { + outputFormat = OutputFormat.valueOf(cmd.getOptionValue(FORMAT_OPTION).toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid output format. Allowed values: text, json, csv"); + } + } + + // 解析排序方式 + if (cmd.hasOption(SORT_OPTION)) { + try { + sortBy = SortBy.valueOf(cmd.getOptionValue(SORT_OPTION).toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid sort criteria. Allowed values: size, count, path"); + } + } + + // 解析输出文件 + if (cmd.hasOption(OUTPUT_OPTION)) { + outputFile = Paths.get(cmd.getOptionValue(OUTPUT_OPTION)); + } + + // 解析大小限制 + if (cmd.hasOption(MIN_SIZE_OPTION)) { + try { + minSize = Long.parseLong(cmd.getOptionValue(MIN_SIZE_OPTION)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid min-size value: must be a number"); + } + } + + if (cmd.hasOption(MAX_SIZE_OPTION)) { + try { + maxSize = Long.parseLong(cmd.getOptionValue(MAX_SIZE_OPTION)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid max-size value: must be a number"); + } + } + + // 解析排除的扩展名 + if (cmd.hasOption(EXCLUDE_OPTION)) { + excludeExtensions = Arrays.asList(cmd.getOptionValue(EXCLUDE_OPTION).split(",")); + } + } + + /** + * 执行重复文件扫描 + */ + private List scanForDuplicates(Path scanDir) throws IOException { + if (!quietMode) { + System.out.println("Scanning directory: " + scanDir.toAbsolutePath()); + } + + FileScanner scanner = new RobustParallelScanner(20); + FileHashCalculator calculator = FileHashCalculator.defaultInstance(); + DuplicateFinder finder = new DuplicateFinder(scanner, calculator, showProgress); + + List duplicates = finder.findDuplicates(scanDir); + + // 应用过滤 + duplicates = filterResults(duplicates); + + // 应用排序 + duplicates = sortResults(duplicates); + + return duplicates; + } + + /** + * 过滤结果 + */ + private List filterResults(List groups) { + return groups.stream() + .filter(group -> group.size() >= minSize) + .filter(group -> maxSize == null || group.size() <= maxSize) + .filter(group -> excludeExtensions.stream() + .noneMatch(ext -> group.fileMetas().stream() + .anyMatch(file -> file.getPath().toString().toLowerCase().endsWith(ext.toLowerCase())))) + .collect(Collectors.toList()); + } + + /** + * 排序结果 + */ + private List sortResults(List groups) { + return switch (sortBy) { + case SIZE -> groups.stream() + .sorted((g1, g2) -> Long.compare(g2.size(), g1.size())) + .collect(Collectors.toList()); + case COUNT -> groups.stream() + .sorted((g1, g2) -> Integer.compare(g2.fileMetas().size(), g1.fileMetas().size())) + .collect(Collectors.toList()); + case PATH -> groups.stream() + .sorted(Comparator.comparing(g -> g.fileMetas().get(0).getPath())) + .collect(Collectors.toList()); + }; + } + + /** + * 输出结果 + */ + private void outputResults(List duplicates) throws IOException { + if (quietMode && duplicates.isEmpty()) { + return; + } + + String outputContent = switch (outputFormat) { + case JSON -> formatAsJson(duplicates); + case CSV -> formatAsCsv(duplicates); + default -> formatAsText(duplicates); + }; + + if (outputFile != null) { + try (BufferedWriter writer = Files.newBufferedWriter(outputFile)) { + writer.write(outputContent); + } + if (!quietMode) { + System.out.println("Results written to: " + outputFile.toAbsolutePath()); + } + } else { + System.out.println(outputContent); + } + } + + /** + * 文本格式输出 + */ + private String formatAsText(List duplicates) { + if (duplicates.isEmpty()) { + return "No duplicate files found."; + } + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("\nFound %d groups of duplicate files:\n", duplicates.size())); + + AtomicInteger groupNum = new AtomicInteger(1); + duplicates.forEach(group -> { + sb.append(String.format("\nGroup %d:\n", groupNum.getAndIncrement())); + sb.append(String.format("Hash: %s\n", group.hash())); + sb.append(String.format("Size: %s\n", FileUtil.humanReadableByteCount(group.size()))); + sb.append("Files:\n"); + group.fileMetas().forEach(file -> sb.append(String.format(" %s\n", file.getPath()))); + }); + + // 添加统计信息 + long totalWastedSpace = duplicates.stream() + .mapToLong(group -> group.size() * (group.fileMetas().size() - 1)) + .sum(); + sb.append(String.format("\nTotal wasted space: %s\n", + FileUtil.humanReadableByteCount(totalWastedSpace))); + + return sb.toString(); + } + + /** + * JSON格式输出 + */ + private String formatAsJson(List duplicates) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + + // 基本信息 + sb.append(" \"duplicate_groups\": [\n"); + + // 各组数据 + duplicates.forEach(group -> { + sb.append(" {\n"); + sb.append(String.format(" \"hash\": \"%s\",\n", group.hash())); + sb.append(String.format(" \"size\": %d,\n", group.size())); + sb.append(String.format(" \"size_human\": \"%s\",\n", + FileUtil.humanReadableByteCount(group.size()))); + sb.append(" \"files\": [\n"); + + group.fileMetas().forEach(file -> + sb.append(String.format(" \"%s\",\n", file.getPath()))); + + // 移除最后一个逗号 + if (!group.fileMetas().isEmpty()) { + sb.delete(sb.length() - 2, sb.length()); + sb.append("\n"); + } + + sb.append(" ]\n"); + sb.append(" },\n"); + }); + + // 移除最后一个逗号 + if (!duplicates.isEmpty()) { + sb.delete(sb.length() - 2, sb.length()); + sb.append("\n"); + } + + sb.append(" ],\n"); + + // 统计信息 + long totalWastedSpace = duplicates.stream() + .mapToLong(group -> group.size() * (group.fileMetas().size() - 1)) + .sum(); + sb.append(" \"stats\": {\n"); + sb.append(String.format(" \"total_groups\": %d,\n", duplicates.size())); + sb.append(String.format(" \"total_files\": %d,\n", + duplicates.stream().mapToInt(group -> group.fileMetas().size()).sum())); + sb.append(String.format(" \"total_wasted_space\": %d,\n", totalWastedSpace)); + sb.append(String.format(" \"total_wasted_space_human\": \"%s\"\n", + FileUtil.humanReadableByteCount(totalWastedSpace))); + sb.append(" }\n"); + + sb.append("}\n"); + return sb.toString(); + } + + /** + * CSV格式输出 + */ + private String formatAsCsv(List duplicates) { + StringBuilder sb = new StringBuilder(); + sb.append("group,hash,size,size_human,file_path\n"); + + AtomicInteger groupNum = new AtomicInteger(1); + duplicates.forEach(group -> { + String groupPrefix = groupNum.getAndIncrement() + "," + + group.hash() + "," + + group.size() + "," + + FileUtil.humanReadableByteCount(group.size()); + + group.fileMetas().forEach(file -> + sb.append(groupPrefix).append(",").append(file.getPath()).append("\n")); + }); + + return sb.toString(); + } + + /** + * 打印帮助信息 + */ + private void printHelp(Options options) { + HelpFormatter formatter = new HelpFormatter(); + formatter.printHelp("doc-check-tool", options); + } + + /** + * 打印版本信息 + */ + private void printVersion() { + Package pkg = getClass().getPackage(); + String version = pkg.getImplementationVersion(); + System.out.println("Doc Check Tool " + (version != null ? version : "DEV")); + } +} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileComparator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileComparator.java new file mode 100644 index 0000000..22f2f71 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileComparator.java @@ -0,0 +1,266 @@ +package top.r3944realms.docchecktoolrefactored.core; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class AddressFileComparator { + + public enum CompareMode { + PAGE_LEVEL, // 页面级比较 + FILE_LEVEL // 文件级比较 + } + + @Getter + public static class ComparisonResult { + private final int physicalRecordsCount; // 物理文件记录数 + private final int logicalRecordsCount; // 逻辑文件记录数 + private final List forwardComparisonResults; // 正向比较文件名缺失结果 + private final List backwardComparisonResults; // 反向比较文件名缺失结果 + private final List pathMismatchResults; // 路径不一致的结果 + private final List pageCountMismatchResults; // 页数不一致的结果(仅文件级比较使用) + + public ComparisonResult(int physicalRecordsCount, + int logicalRecordsCount, + List forwardResults, + List backwardResults, + List pathMismatchResults, + List pageCountMismatchResults) { + this.physicalRecordsCount = physicalRecordsCount; + this.logicalRecordsCount = logicalRecordsCount; + this.forwardComparisonResults = forwardResults; + this.backwardComparisonResults = backwardResults; + this.pathMismatchResults = pathMismatchResults; + this.pageCountMismatchResults = pageCountMismatchResults; + } + + // 为向后兼容保留原来的构造函数 + public ComparisonResult(int physicalRecordsCount, + int logicalRecordsCount, + List forwardResults, + List backwardResults, + List pathMismatchResults) { + this(physicalRecordsCount, logicalRecordsCount, forwardResults, + backwardResults, pathMismatchResults, new ArrayList<>()); + } + + } + + public ComparisonResult compareFiles(String physicalAddressFilePath, String logicalAddressFilePath) { + return compareFiles(physicalAddressFilePath, logicalAddressFilePath, CompareMode.PAGE_LEVEL); + } + + public ComparisonResult compareFiles(String physicalAddressFilePath, String logicalAddressFilePath, CompareMode compareMode) { + List physicalRecords = readCSV(physicalAddressFilePath); + List logicalRecords = readCSV(logicalAddressFilePath); + + // 记录读取的行数(不包括标题行) + int physicalCount = physicalRecords.size(); + int logicalCount = logicalRecords.size(); + + log.info("读取物理地址文件记录数: {}", physicalCount); + log.info("读取逻辑地址文件记录数: {}", logicalCount); + + List forwardComparisonResults = new ArrayList<>(); // 物理文件在逻辑文件中未找到 + List backwardComparisonResults = new ArrayList<>(); // 逻辑文件在物理文件中未找到 + List pathMismatchResults = new ArrayList<>(); // 文件名相同但路径不一致 + List pageCountMismatchResults = new ArrayList<>(); // 文件名和路径相同但页数不一致(仅文件级) + + // 正向比较:遍历物理文件,检查是否在逻辑文件中存在 + for (String[] physicalRecord : physicalRecords) { + // 确保数据行有足够列数 + if (compareMode == CompareMode.PAGE_LEVEL && physicalRecord.length < 2) continue; + if (compareMode == CompareMode.FILE_LEVEL && physicalRecord.length < 3) continue; + + String physicalFileName = physicalRecord[0]; + String physicalAddress = physicalRecord[1]; + String physicalPageCount = compareMode == CompareMode.FILE_LEVEL ? physicalRecord[2] : null; + + boolean found = false; + for (String[] logicalRecord : logicalRecords) { + // 确保数据行有足够列数 + if (compareMode == CompareMode.PAGE_LEVEL && logicalRecord.length < 2) continue; + if (compareMode == CompareMode.FILE_LEVEL && logicalRecord.length < 3) continue; + + String logicalFileName = logicalRecord[0]; + String logicalAddress = logicalRecord[1]; + String logicalPageCount = compareMode == CompareMode.FILE_LEVEL ? logicalRecord[2] : null; + + if (physicalFileName.equals(logicalFileName)) { + found = true; + // 文件名相同,比较路径 + if (!physicalAddress.equals(logicalAddress)) { + pathMismatchResults.add("文件名=" + physicalFileName + + ", 物理地址=" + physicalAddress + + ", 逻辑地址=" + logicalAddress); + } + // 如果是文件级比较且路径相同,再比较页数 + else if (compareMode == CompareMode.FILE_LEVEL && + physicalPageCount != null && logicalPageCount != null && + !physicalPageCount.equals(logicalPageCount)) { + pageCountMismatchResults.add("文件名=" + physicalFileName + + ", 物理地址=" + physicalAddress + + ", 逻辑地址=" + logicalAddress + + ", 物理页数=" + physicalPageCount + + ", 逻辑页数=" + logicalPageCount); + } + break; + } + } + + // 如果在逻辑文件中未找到该物理文件记录 + if (!found) { + String result = "文件名=" + physicalFileName + ", 物理地址=" + physicalAddress; + if (compareMode == CompareMode.FILE_LEVEL && physicalPageCount != null) { + result += ", 物理页数=" + physicalPageCount; + } + forwardComparisonResults.add(result); + } + } + + // 反向比较:遍历逻辑文件,检查是否在物理文件中存在 + for (String[] logicalRecord : logicalRecords) { + // 确保数据行有足够列数 + if (compareMode == CompareMode.PAGE_LEVEL && logicalRecord.length < 2) continue; + if (compareMode == CompareMode.FILE_LEVEL && logicalRecord.length < 3) continue; + + String logicalFileName = logicalRecord[0]; + String logicalAddress = logicalRecord[1]; + String logicalPageCount = compareMode == CompareMode.FILE_LEVEL ? logicalRecord[2] : null; + + boolean found = false; + for (String[] physicalRecord : physicalRecords) { + // 确保数据行有足够列数 + if (compareMode == CompareMode.PAGE_LEVEL && physicalRecord.length < 2) continue; + if (compareMode == CompareMode.FILE_LEVEL && physicalRecord.length < 3) continue; + + String physicalFileName = physicalRecord[0]; + + if (logicalFileName.equals(physicalFileName)) { + found = true; + break; + } + } + + // 如果在物理文件中未找到该逻辑文件记录 + if (!found) { + String result = "文件名=" + logicalFileName + ", 逻辑地址=" + logicalAddress; + if (compareMode == CompareMode.FILE_LEVEL && logicalPageCount != null) { + result += ", 逻辑页数=" + logicalPageCount; + } + backwardComparisonResults.add(result); + } + } + + // 将比较结果记录到日志中 + logComparisonResults(physicalCount, logicalCount, forwardComparisonResults, + backwardComparisonResults, pathMismatchResults, pageCountMismatchResults, compareMode); + + return new ComparisonResult( + physicalCount, + logicalCount, + forwardComparisonResults, + backwardComparisonResults, + pathMismatchResults, + pageCountMismatchResults + ); + } + + private List readCSV(String filePath) { + List records = new ArrayList<>(); + + try { + File file = new File(filePath); + if (!file.exists()) { + log.error("CSV文件不存在: {}", filePath); + return records; + } + + BufferedReader reader = new BufferedReader(new FileReader(file)); + String line; + boolean isFirstLine = true; + + while ((line = reader.readLine()) != null) { + // 跳过首行(标题行) + if (isFirstLine) { + isFirstLine = false; + continue; + } + // 按逗号分隔CSV行数据 + String[] data = line.split(","); + records.add(data); + } + + reader.close(); + log.info("成功读取CSV文件,共 {} 行记录", records.size()); + + } catch (Exception e) { + log.error("读取CSV文件时出错: {}", e.getMessage(), e); + } + + return records; + } + + private void logComparisonResults(int physicalCount, int logicalCount, + List forwardResults, List backwardResults, + List pathMismatchResults, List pageCountMismatchResults, + CompareMode compareMode) { + log.info("=== 文件比较结果 ==="); + log.info("物理地址文件记录数: {}", physicalCount); + log.info("逻辑地址文件记录数: {}", logicalCount); + + if (pathMismatchResults.isEmpty()) { + log.info("没有路径错误"); + } else { + log.info("文件名相同但路径不一致的记录数量: {}", pathMismatchResults.size()); + for (String result : pathMismatchResults) { + log.info("\t{}", result); + } + } + + if (compareMode == CompareMode.FILE_LEVEL) { + if (pageCountMismatchResults.isEmpty()) { + log.info("没有页数错误"); + } else { + log.info("文件名和路径相同但页数不一致的记录数量: {}", pageCountMismatchResults.size()); + for (String result : pageCountMismatchResults) { + log.info("\t{}", result); + } + } + } + + if (forwardResults.isEmpty()) { + log.info("没有物理存在而逻辑不存在的文件"); + } else { + log.info("物理文件在逻辑文件中未找到的记录数量: {}", forwardResults.size()); + for (String result : forwardResults) { + log.info("\t{}", result); + } + } + + if (backwardResults.isEmpty()) { + log.info("没有逻辑存在而物理不存在的文件"); + } else { + log.info("逻辑文件在物理文件中未找到的记录数量: {}", backwardResults.size()); + for (String result : backwardResults) { + log.info("\t{}", result); + } + } + + log.info("=== 比较完成 ==="); + } + + // 为向后兼容保留原来的日志方法 + private void logComparisonResults(int physicalCount, int logicalCount, + List forwardResults, List backwardResults, + List pathMismatchResults) { + logComparisonResults(physicalCount, logicalCount, forwardResults, backwardResults, + pathMismatchResults, new ArrayList<>(), CompareMode.PAGE_LEVEL); + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileGenerator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileGenerator.java new file mode 100644 index 0000000..4e5d109 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/AddressFileGenerator.java @@ -0,0 +1,40 @@ +package top.r3944realms.docchecktoolrefactored.core; + +import org.intellij.lang.annotations.MagicConstant; + +import java.io.File; + +public interface AddressFileGenerator { + /** + * 页面级 + */ + int PAGE_TYPE = 1; + /** + * 文件级 + */ + int FILE_TYPE = 2; + + /** + * 回调接口 + */ + interface Callback { + void onProgress(String message); + void onSuccess(String outputPath); + void onError(String errorMessage); + } + + /** + * 生成地址文件 + * + * @param folderPath 文件夹路径 + * @param outputFile 输出文件 + * @param folderType 文件夹类型 ({@link AddressFileGenerator#PAGE_TYPE "页面级"} 或 {@link AddressFileGenerator#FILE_TYPE "文件级"}) + * @param callback {@link Callback 回调接口} + */ + void generateAddressFile( + String folderPath, + File outputFile, + @MagicConstant(intValues = {PAGE_TYPE, FILE_TYPE}) int folderType, + Callback callback + ); +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/DuplicateFinder.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/DuplicateFinder.java index 13bbc87..ce4edb1 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/core/DuplicateFinder.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/DuplicateFinder.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -39,8 +40,19 @@ public class DuplicateFinder { // 第一阶段:按文件大小分组 Map> sizeGroups = groupFilesBySize(rootDir); + // 计算需要处理的总文件数(大小分组中可能有重复的文件) + int totalFilesToProcess = sizeGroups.values().stream() + .filter(group -> group.size() > 1) + .mapToInt(List::size) + .sum(); + + if (totalFilesToProcess == 0) { + return Collections.emptyList(); + } + // 第二阶段:对可能重复的文件计算哈希 Map> hashGroups = new ConcurrentHashMap<>(); + AtomicInteger processedFiles = new AtomicInteger(0); sizeGroups.values().parallelStream() .filter(group -> group.size() > 1) // 只处理可能重复的文件 @@ -49,12 +61,19 @@ public class DuplicateFinder { String hash = hashCalculator.calculateHash(file.getPath()); file.setHash(hash); hashGroups.computeIfAbsent(hash, k -> new ArrayList<>()).add(file); + // 更新进度 + int current = processedFiles.incrementAndGet(); + if (enableProgress) { + printProgress("Calculating hashes", current, totalFilesToProcess); + } } catch (IOException e) { // 记录错误但继续处理其他文件 log.error("Failed to calculate file's hash: {}, {}", file.getPath(), e.getMessage()); } })); - + if (enableProgress) { + System.out.println(); // 完成进度条后换行 + } // 第三阶段:构建结果 return hashGroups.values().stream() .filter(group -> group.size() > 1) @@ -75,7 +94,11 @@ public class DuplicateFinder { FileScanner.ProgressAwareListener listener = new FileScanner.ProgressAwareListener() { @Override public void onProgressUpdate(int current, int total) { - log.info("Scanning progress: {}/{} ", current, total); + if (enableProgress) { + printProgress("Scanning files", current, total); + } else { + log.info("Scanning progress: {} / {} ", current, total); + } } @Override @@ -101,4 +124,24 @@ public class DuplicateFinder { fileScanner.scan(rootDir, listener); return sizeGroups; } + /** + * 打印进度条 + * @param phase 当前阶段描述 + * @param current 当前进度 + * @param total 总进度 + */ + private void printProgress(String phase, int current, int total) { + int width = 50; // 进度条宽度 + float percent = (float) current / total; + int progress = (int) (width * percent); + + String progressBar = String.format("\r%s: [%-" + width + "s] %3d%% %d/%d", + phase, + "=".repeat(progress), + (int) (percent * 100), + current, + total); + + System.out.print(progressBar); + } } \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/FileHashCalculator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/FileHashCalculator.java index b986ff2..269df2b 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/core/FileHashCalculator.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/FileHashCalculator.java @@ -14,6 +14,31 @@ public interface FileHashCalculator { */ String calculateHash(Path file) throws IOException; + /** + * 计算文件部分hash值方法 + * @param file 要计算的文件路径 + * @param partialChunkSize 分块大小 + * @return 文件的哈希值字符串 + */ + String calculatePartialHash(Path file, int partialChunkSize) throws IOException; + + /** + * 计算文件部分hash值方法(默认) + * @param file 要计算的文件路径 + * @return 文件的哈希值字符串 + */ + default String calculatePartialHash(Path file) throws IOException { + return calculatePartialHash(file, getPartialSize()); + } + + /** + * 块大小 + * @return 块大小 + */ + default int getPartialSize() { + return 4096;// 4 * 1024 + } + /** * 默认实现使用MD5 */ diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/HashFileGenerator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/HashFileGenerator.java new file mode 100644 index 0000000..ad1bf36 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/HashFileGenerator.java @@ -0,0 +1,92 @@ +package top.r3944realms.docchecktoolrefactored.core; + + +import top.r3944realms.docchecktoolrefactored.io.scanner.FileScanner; +import top.r3944realms.docchecktoolrefactored.io.scanner.RobustParallelScanner; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +public class HashFileGenerator { + + public interface ProgressListener { + void onProgressUpdate(int current, int total); + } + + public void generateHashFile(List directories, Path outputFile, ProgressListener listener) throws IOException, InterruptedException { + List allFiles = new ArrayList<>(); + + // 扫描所有目录中的文件 + for (Path directory : directories) { + if (!Files.isDirectory(directory)) { + throw new IllegalArgumentException("指定路径不是有效目录: " + directory); + } + + List files = new ArrayList<>(); + CompletableFuture scanFuture = new CompletableFuture<>(); + + // 使用 RobustParallelScanner 扫描文件 + try (RobustParallelScanner scanner = new RobustParallelScanner(10)) { + scanner.scan(directory, new FileScanner.FileScanListener() { + @Override + public void onFileFound(Path file) { + files.add(file); + } + + @Override + public void onScanComplete() { + scanFuture.complete(null); + } + + @Override + public void onError(Path path, Exception e) { + System.err.println("Error scanning path: " + path + " - " + e.getMessage()); + } + }); + + // 等待扫描完成 + scanFuture.join(); + allFiles.addAll(files); + } + } + + // 计算每个文件的哈希值 + List hashResults = new ArrayList<>(); + AtomicInteger processedFiles = new AtomicInteger(0); + int totalFiles = allFiles.size(); + + allFiles.parallelStream().forEach(file -> { + try { + String hash = new MD5HashCalculator().calculatePartialHash(file); + String[] result = {file.getFileName().toString(), hash}; + synchronized (hashResults) { + hashResults.add(result); + } + int processed = processedFiles.incrementAndGet(); + if (listener != null) { + listener.onProgressUpdate(processed, totalFiles); + } + } catch (IOException e) { + System.err.println("无法计算该文件哈希值: " + file + " - " + e.getMessage()); + } + }); + + // 写入结果到文件 + try (BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile.toFile()))) { + writer.write("文件名,哈希值"); + writer.newLine(); + for (String[] result : hashResults) { + writer.write(result[0] + "," + result[1]); + writer.newLine(); + } + } + } + +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/LogicalAddressFileGenerator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/LogicalAddressFileGenerator.java new file mode 100644 index 0000000..564d045 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/LogicalAddressFileGenerator.java @@ -0,0 +1,158 @@ +package top.r3944realms.docchecktoolrefactored.core; + +import top.r3944realms.docchecktoolrefactored.io.reader.CatalogFileReader; +import top.r3944realms.docchecktoolrefactored.io.reader.CatalogFileReaderFactory; + +import java.io.File; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; + +public class LogicalAddressFileGenerator implements AddressFileGenerator { + @Override + public void generateAddressFile(String catalogFilePath, File outputFile, int folderType, Callback callback) { + callback.onProgress("正在生成逻辑地址文件..."); + try { + // 使用工厂模式创建相应的文件读取器 + List records = readCatalogFile(catalogFilePath); + + // 过滤掉 可能的 输出文件记录 -- 注2: 避免将输出文件作为输入处理 + String outputFileName = outputFile.getName(); + records = records.stream() + .filter(record -> !record.archiveCode.equalsIgnoreCase(outputFileName)) + .toList(); + try (PrintWriter writer = new PrintWriter(outputFile, StandardCharsets.UTF_8)) { + if (folderType == PAGE_TYPE) { + // 页面级逻辑:为每页生成一行数据 + generatePageLevelFile(writer, records); + } else if (folderType == FILE_TYPE) { + // 文件级逻辑:每个档案只生成一行数据 + generateFileLevelFile(writer, records); + } else { + throw new IllegalArgumentException("不支持的文件夹类型: " + folderType); + } + } + + callback.onSuccess("逻辑地址文件生成成功: " + outputFile.getAbsolutePath()); + } catch (Exception e) { + callback.onError("生成逻辑地址文件时出错: " + e.getMessage()); + } + } + /** + * 生成页面级逻辑地址文件 + */ + private void generatePageLevelFile(PrintWriter writer, List records) { + // 写入CSV头部 + writer.println("逻辑文件名,逻辑地址"); + + // 处理每条记录 + for (Record record : records) { + String archiveCode = record.archiveCode; + int page = record.page; + + // 为每页生成一行数据 + for (int i = 1; i <= page; i++) { + // 生成逻辑文件名 + String logicalFileName = String.format("%s-%04d", archiveCode, i); + + // 生成逻辑地址 + String logicalAddress = generatePageLevelLogicalAddress(archiveCode, i); + + // 写入CSV行 + writer.printf("%s,%s%n", logicalFileName, logicalAddress); + } + } + } + + /** + * 生成文件级逻辑地址文件 + */ + private void generateFileLevelFile(PrintWriter writer, List records) { + // 写入CSV头部(包含页数列) + writer.println("逻辑文件名,逻辑地址,页数"); + + // 处理每条记录 + for (Record record : records) { + String archiveCode = record.archiveCode; + int page = record.page; + + // 生成逻辑地址(不包含页数) + String logicalAddress = generateFileLevelLogicalAddress(archiveCode); + + // 写入CSV行,包含页数 + writer.printf("%s,%s,%d%n", /* 逻辑文件名(就是档号)*/ archiveCode, logicalAddress, page); + } + } + + /** + * 根据档号生成文件级逻辑地址(不包含页数) + * + * @param archiveCode 档号内容 + * @return 文件级逻辑地址 + */ + private String generateFileLevelLogicalAddress(String archiveCode) { + StringBuilder address = csvStringBuilder(archiveCode); + + // 添加最后的档号部分 + if (!address.isEmpty()) { + address.append("\\").append(archiveCode); + } + + return address.toString(); + } + + + /** + * 根据档号生成逻辑地址 + * + * @param archiveCode 档号内容 + * @param pageNumber 页码 + * @return 逻辑地址 + */ + private String generatePageLevelLogicalAddress(String archiveCode, int pageNumber) { + StringBuilder address = csvStringBuilder(archiveCode); + + // 添加页码部分,同样去掉第一个 + if (!address.isEmpty()) { + address.append("\\"); + } + address.append(String.format("%s-%04d", archiveCode, pageNumber)); + + return address.toString(); + } + + private StringBuilder csvStringBuilder(String archiveCode) { + StringBuilder address = new StringBuilder(); + String[] parts = archiveCode.split("-"); + + StringBuilder path = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + path.append("-"); + } + path.append(parts[i]); + // 去掉第一个 \ + if (address.isEmpty()) { + address.append(path); + } else { + address.append("\\").append(path); + } + } + return address; + } + + /** + * 读取目录文件 + * + * @param filePath 文件路径 + * @return 记录列表 + * @throws Exception 读取异常 + */ + private List readCatalogFile(String filePath) throws Exception { + // 使用工厂模式创建相应的文件读取器 + CatalogFileReader reader = CatalogFileReaderFactory.createReader(filePath); + return reader.readCatalogFile(filePath); + } + public record Record(String archiveCode, int page) { + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/MD5HashCalculator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/MD5HashCalculator.java index 70fb98f..0509cdf 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/core/MD5HashCalculator.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/MD5HashCalculator.java @@ -1,6 +1,7 @@ package top.r3944realms.docchecktoolrefactored.core; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; @@ -29,6 +30,69 @@ public class MD5HashCalculator implements FileHashCalculator { } } + /** + *

读取文件头部:

+ *
    + *
  • 读取文件的前partialChunkSize字节(如果文件大小小于这个值,则读取整个文件)
  • + *
  • 这些字节会被添加到MD5计算中
  • + *
+ *

读取文件中间部分:

+ *
    + *
  • 只有当文件大小大于partialChunkSize * 2时才会执行
  • + *
  • 从文件中间位置前后各取partialChunkSize/2字节(总共partialChunkSize字节)
  • + *
  • 这些字节会被追加到MD5计算中
  • + *
+ *

读取文件尾部:

+ *
    + *
  • 只有当文件大小大于partialChunkSize时才会执行
  • + *
  • 读取文件最后的partialChunkSize字节
  • + *
  • 这些字节会被追加到MD5计算中
  • + *
+ */ + @Override + public String calculatePartialHash(Path file, int partialChunkSize) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + + try (var channel = Files.newByteChannel(file)) { + long size = channel.size(); + + // Read head + if (size > 0) { + int chunkSize = (int) Math.min(partialChunkSize, size); + ByteBuffer buffer = ByteBuffer.allocate(chunkSize); + channel.read(buffer); + buffer.flip(); + digest.update(buffer); + } + + // Read middle + if (size > partialChunkSize * 2L) { + long midPos = size / 2 - partialChunkSize / 2; + channel.position(midPos); + ByteBuffer buffer = ByteBuffer.allocate(partialChunkSize); + channel.read(buffer); + buffer.flip(); + digest.update(buffer); + } + + // Read tail + if (size > partialChunkSize) { + long endPos = size - partialChunkSize; + channel.position(endPos); + ByteBuffer buffer = ByteBuffer.allocate(partialChunkSize); + channel.read(buffer); + buffer.flip(); + digest.update(buffer); + } + } + + return bytesToHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5算法不可用", e); + } + } + private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/core/PhysicalAddressFileGenerator.java b/src/main/java/top/r3944realms/docchecktoolrefactored/core/PhysicalAddressFileGenerator.java new file mode 100644 index 0000000..36ec91f --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/core/PhysicalAddressFileGenerator.java @@ -0,0 +1,241 @@ +package top.r3944realms.docchecktoolrefactored.core; + +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; + +import java.io.File; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class PhysicalAddressFileGenerator implements AddressFileGenerator { + @Override + public void generateAddressFile(String folderPath, File outputFile, int folderType, Callback callback) { + callback.onProgress("正在生成物理地址文件..."); + try { + File rootFolder = new File(folderPath); + if (!rootFolder.exists() || !rootFolder.isDirectory()) { + callback.onError("所选路径不存在或不是一个有效的文件夹。"); + return; + } + + // 保存输出文件的绝对路径,用于后续比较 + String outputFilePath = outputFile.getAbsolutePath(); + + // 写入CSV文件 + try (PrintWriter writer = new PrintWriter(outputFile, StandardCharsets.UTF_8)) { + if (folderType == AddressFileGenerator.PAGE_TYPE) { + // 页面级逻辑:处理所有图片文件 + writer.println("物理文件名,物理地址"); + processPageLevelFolder(rootFolder, writer, outputFilePath); + } else if (folderType == AddressFileGenerator.FILE_TYPE) { + // 文件级逻辑:处理PDF文件 + writer.println("物理文件名,物理地址,页数"); + processFileLevelFolder(rootFolder, writer, outputFilePath); + } else { + throw new IllegalArgumentException("不支持的文件夹类型: " + folderType); + } + } + + callback.onSuccess("物理地址文件生成成功: " + outputFile.getAbsolutePath()); + } catch (Exception e) { + callback.onError("生成物理地址文件时出错: " + e.getMessage()); + } + } + /** + * 处理页面级文件夹及其内部文件 + * + * @param folder 文件夹 + * @param writer PrintWriter对象 + * @param outputFilePath 输出文件的绝对路径 + */ + private void processPageLevelFolder(File folder, PrintWriter writer, String outputFilePath) { + // 获取该文件夹下的所有非隐藏文件和文件夹 + File[] filesAndFolders = folder.listFiles(file -> !file.isHidden()); + + if (filesAndFolders != null) { + for (File file : filesAndFolders) { + // 跳过输出文件本身,避免将生成的CSV文件也作为数据处理 + if (file.getAbsolutePath().equals(outputFilePath)) { + continue; + } + + if (file.isFile()) { + String fileName = file.getName(); + // 只处理图片文件,跳过其他类型的文件 + if (!isImageFile(fileName)) { + continue; + } + + // 移除文件扩展名 + String fileNameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')); + + // 格式化文件名,确保最后一个部分是4位数字 + String formattedFileName = formatFileName(fileNameWithoutExt); + + // 生成物理地址路径 + String physicalAddress = generatePhysicalAddress(file.getAbsolutePath(), formattedFileName); + + // 写入CSV行 + writer.printf("%s,%s%n", formattedFileName, physicalAddress); + } else if (file.isDirectory()) { + // 递归处理子文件夹 + processPageLevelFolder(file, writer, outputFilePath); + } + } + } + } + + /** + * 处理文件级文件夹(处理PDF文件) + * + * @param folder 文件夹 + * @param writer PrintWriter对象 + * @param outputFilePath 输出文件的绝对路径 + */ + private void processFileLevelFolder(File folder, PrintWriter writer, String outputFilePath) { + // 获取该文件夹下的所有非隐藏文件和文件夹 + File[] filesAndFolders = folder.listFiles(file -> !file.isHidden()); + + if (filesAndFolders != null) { + for (File file : filesAndFolders) { + // 跳过输出文件本身,避免将生成的CSV文件也作为数据处理 + if (file.getAbsolutePath().equals(outputFilePath)) { + continue; + } + + if (file.isFile()) { + String fileName = file.getName(); + // 只处理PDF文件 + if (isPdfFile(fileName)) { + // 移除文件扩展名 + String fileNameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')); + + // 生成物理地址路径(使用与页面级相同的逻辑) + String physicalAddress = generatePhysicalAddress(file.getAbsolutePath(), fileNameWithoutExt); + + // 获取PDF页数 + int pageCount = getPdfPageCount(file); + + // 写入CSV行 + writer.printf("%s,%s,%d%n", fileNameWithoutExt, physicalAddress, pageCount); + } + } else if (file.isDirectory()) { + // 递归处理子文件夹 + processFileLevelFolder(file, writer, outputFilePath); + } + } + } + } + + /** + * 获取PDF文件的页数 + * + * @param pdfFile PDF文件 + * @return 页数 + */ + private int getPdfPageCount(File pdfFile) { + try { + // 使用Apache PDFBox库获取PDF页数 + PDDocument document = Loader.loadPDF(pdfFile); + int pageCount = document.getNumberOfPages(); + document.close(); + return pageCount; + } catch (Exception e) { + log.warn("无法获取PDF文件页数: {}", pdfFile.getAbsolutePath(), e); + return 0; + } + } + + + /** + * 判断是否为PDF文件 + * + * @param fileName 文件名 + * @return 是否为PDF文件 + */ + private boolean isPdfFile(String fileName) { + return fileName.toLowerCase().endsWith(".pdf"); + } + + /** + * 判断是否为图片文件 + * + * @param fileName 文件名 + * @return 是否为图片文件 + */ + private boolean isImageFile(String fileName) { + String lowerFileName = fileName.toLowerCase(); + return lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg") || + lowerFileName.endsWith(".png") || lowerFileName.endsWith(".bmp") || + lowerFileName.endsWith(".gif") || lowerFileName.endsWith(".tiff"); + } + + /** + * 格式化文件名,确保最后一个部分是4位数字 + * + * @param fileName 文件名(不含扩展名) + * @return 格式化后的文件名 + */ + private String formatFileName(String fileName) { + int lastDashIndex = fileName.lastIndexOf('-'); + if (lastDashIndex != -1 && lastDashIndex < fileName.length() - 1) { + String lastPart = fileName.substring(lastDashIndex + 1); + // 检查是否为数字 + if (lastPart.matches("\\d+")) { + // 格式化为4位数字 + int number = Integer.parseInt(lastPart); + String formattedLastPart = String.format("%04d", number); + return fileName.substring(0, lastDashIndex + 1) + formattedLastPart; + } + } + return fileName; + } + + /** + * 生成物理地址路径 + * + * @param absolutePath 文件的绝对路径 + * @param fileName 文件名(不含扩展名) + * @return 物理地址路径 + */ + private String generatePhysicalAddress(String absolutePath, String fileName) { + // 获取文件名第一个'-'前面的内容(如9027) + int firstDashIndex = fileName.indexOf('-'); + String prefix = firstDashIndex != -1 ? fileName.substring(0, firstDashIndex) : fileName; + + // 构建从根路径开始的相对路径 + String relativePath = absolutePath.replace('\\', '/'); + String[] pathParts = relativePath.split("/"); + + StringBuilder resultPath = new StringBuilder(); + boolean foundPrefix = false; + + for (int i = 0; i < pathParts.length; i++) { + if (!foundPrefix) { + // 检查当前部分是否包含prefix + if (pathParts[i].contains(prefix)) { + foundPrefix = true; + resultPath.append(pathParts[i]); + } + } else { + // 去掉文件类型的后缀(如.jpg) + if (i == pathParts.length - 1) { + String fileNameWithoutExt = pathParts[i].substring(0, pathParts[i].lastIndexOf('.')); + resultPath.append("/").append(fileNameWithoutExt); + } else { + resultPath.append("/").append(pathParts[i]); + } + } + } + + // 如果结果路径为空,返回空字符串 + if (resultPath.isEmpty()) { + return ""; + } + + // 将路径中的 / 替换为 \(适用于 Windows 系统) + return resultPath.toString().replace('/', '\\'); + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/BaseFunctionPanel.java b/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/BaseFunctionPanel.java deleted file mode 100644 index b8549a2..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/BaseFunctionPanel.java +++ /dev/null @@ -1,29 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.deprecated; - -import javafx.scene.control.TextArea; -import javafx.scene.layout.Pane; -import javafx.scene.paint.Color; - -public abstract class BaseFunctionPanel extends Pane { - protected DocCheckToolMainFrame mainFrame; - protected TextArea resultTextArea; - protected static final Color PRIMARY_COLOR = Color.rgb(66, 133, 244); - protected static final Color SECONDARY_COLOR = Color.rgb(30, 169, 80); - protected static final Color BACKGROUND_COLOR = Color.rgb(245, 245, 245); - protected static final Color BORDER_COLOR = Color.rgb(222, 226, 230); - - public BaseFunctionPanel(DocCheckToolMainFrame mainFrame) { - this.mainFrame = mainFrame; - setStyle("-fx-background-color: rgb(245,245,245);"); - initComponents(); - } - - protected abstract void initComponents(); - - public void showStatusMessage(String message) { - if (resultTextArea != null) { - resultTextArea.appendText(message + "\n"); - resultTextArea.positionCaret(resultTextArea.getText().length()); - } - } -} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DocCheckToolMainFrame.java b/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DocCheckToolMainFrame.java deleted file mode 100644 index 46d9a77..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DocCheckToolMainFrame.java +++ /dev/null @@ -1,108 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.deprecated; - -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.Scene; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import javafx.scene.paint.Color; -import javafx.scene.text.Font; -import javafx.stage.Stage; - -public class DocCheckToolMainFrame { - private StackPane cardPanel; - private static final Color PRIMARY_COLOR = Color.rgb(66, 133, 244); - private static final Color BACKGROUND_COLOR = Color.rgb(245, 245, 245); - - public void show(Stage primaryStage) { - primaryStage.setTitle("淮阴区数字化档案检查验收系统"); - primaryStage.setWidth(1200); - primaryStage.setHeight(600); - primaryStage.setResizable(false); - - // Create top button panel - HBox topButtonPanel = new HBox(10); - topButtonPanel.setPadding(new Insets(10)); - topButtonPanel.setAlignment(Pos.CENTER_LEFT); - topButtonPanel.setStyle("-fx-background-color: rgb(245,245,245);"); - - String[] buttonTexts = {"1 查重复文件", "2 查遗漏、存储路径和命名规范", "3 查质量", - "4 元数据", "5 查数据挂接", "6 查工作文件", "7 查存储载体"}; - - for (String text : buttonTexts) { - Button btn = new Button(text); - btn.setFont(Font.font("Microsoft YaHei", 14)); - btn.setTextFill(Color.WHITE); - btn.setStyle("-fx-background-color: rgb(66,133,244); -fx-padding: 5 15 5 15;"); - btn.setOnAction(e -> switchPanel(text)); - topButtonPanel.getChildren().add(btn); - } - - // Initialize card panel - cardPanel = new StackPane(); - cardPanel.setStyle("-fx-background-color: rgb(245,245,245);"); - - // Add all function panels to card panel - cardPanel.getChildren().addAll( - new DuplicateCheckPanel(this), - new PathCheckPanel(this), - new OtherFunctionPanel(" 3 查质量"), - new OtherFunctionPanel("4 元数据"), - new OtherFunctionPanel("5 查数据挂接"), - new OtherFunctionPanel("6 查工作文件"), - new OtherFunctionPanel("7 查存储载体") - ); - - // Initially show the first panel - switchPanel("1 查重复文件"); - - // Create main layout - BorderPane root = new BorderPane(); - root.setTop(topButtonPanel); - root.setCenter(cardPanel); - root.setStyle("-fx-background-color: rgb(245,245,245);"); - - primaryStage.setScene(new Scene(root)); - primaryStage.show(); - } - - private void switchPanel(String panelName) { - for (javafx.scene.Node node : cardPanel.getChildren()) { - node.setVisible(false); - } - - int index = switch (panelName) { - case "1 查重复文件" -> 0; - case "2 查遗漏、存储路径和命名规范" -> 1; - case "3 查质量" -> 2; - case "4 元数据" -> 3; - case "5 查数据挂接" -> 4; - case "6 查工作文件" -> 5; - case "7 查存储载体" -> 6; - default -> 0; - }; - - cardPanel.getChildren().get(index).setVisible(true); - } - - public void showStatusMessage(String message) { - for (javafx.scene.Node node : cardPanel.getChildren()) { - if (node instanceof BaseFunctionPanel && node.isVisible()) { - ((BaseFunctionPanel) node).showStatusMessage(message); - break; - } - } - } - - public void showErrorMessage(String message) { - Alert alert = new Alert(Alert.AlertType.ERROR); - alert.setTitle("错误"); - alert.setHeaderText(null); - alert.setContentText(message); - alert.showAndWait(); - showStatusMessage("【错误】" + message); - } -} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DuplicateCheckPanel.java b/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DuplicateCheckPanel.java deleted file mode 100644 index b9541b6..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/DuplicateCheckPanel.java +++ /dev/null @@ -1,166 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.deprecated; - -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.*; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; -import javafx.scene.text.Font; -import javafx.scene.text.FontWeight; -import javafx.stage.DirectoryChooser; -import javafx.stage.Stage; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.*; - -public class DuplicateCheckPanel extends BaseFunctionPanel { - private TextField folderPathTextField; - private TextArea feedbackTextArea; - private Set duplicateFolders = new HashSet<>(); - - public DuplicateCheckPanel(DocCheckToolMainFrame mainFrame) { - super(mainFrame); - initComponents(); - } - - protected void initComponents() { - // Main layout with padding - this.setPadding(new Insets(20)); - - // Folder selection components - HBox folderSelectBox = new HBox(10); - folderSelectBox.setAlignment(Pos.CENTER_LEFT); - - Label loadFolderLabel = new Label("载入文件夹"); - loadFolderLabel.setFont(Font.font("Microsoft YaHei", 14)); - loadFolderLabel.setTextFill(Color.DARKGRAY); - - folderPathTextField = new TextField(); - folderPathTextField.setEditable(false); - folderPathTextField.setFont(Font.font("Microsoft YaHei", 14)); - folderPathTextField.setStyle("-fx-background-color: white; -fx-border-color: #CCCCCC; -fx-border-width: 1; -fx-padding: 5 10 5 10;"); - HBox.setHgrow(folderPathTextField, Priority.ALWAYS); - - Button selectFolderButton = new Button("选择文件夹"); - selectFolderButton.setFont(Font.font("Microsoft YaHei", 14)); - selectFolderButton.setTextFill(Color.WHITE); - selectFolderButton.setStyle("-fx-background-color: #4285F4; -fx-padding: 5 15 5 15;"); - selectFolderButton.setOnAction(e -> { - DirectoryChooser directoryChooser = new DirectoryChooser(); - directoryChooser.setTitle("选择要检查的文件夹"); - File selectedFolder = directoryChooser.showDialog(new Stage()); - if (selectedFolder != null) { - folderPathTextField.setText(selectedFolder.getAbsolutePath()); - } - }); - - folderSelectBox.getChildren().addAll(loadFolderLabel, folderPathTextField, selectFolderButton); - - Button startCheckButton = new Button("开始检查"); - startCheckButton.setFont(Font.font("Microsoft YaHei", FontWeight.BOLD, 14)); - startCheckButton.setTextFill(Color.WHITE); - startCheckButton.setStyle("-fx-background-color: #4285F4; -fx-padding: 5 20 5 20;"); - startCheckButton.setOnAction(e -> { - String folderPath = folderPathTextField.getText(); - if (!folderPath.isEmpty()) { - feedbackTextArea.setText(""); - File folder = new File(folderPath); - duplicateFolders.clear(); - checkDuplicateFiles(folder); - if (duplicateFolders.isEmpty()) { - feedbackTextArea.setText("反馈结果:\n如无重复文件,则反馈:无重复文件;\n如有重复文件,则在此反馈档号。\n\n当前:无重复文件"); - } else { - StringBuilder result = new StringBuilder("反馈结果:\n如无重复文件,则反馈:无重复文件;\n如有重复文件,则在此反馈档号。\n\n当前:"); - for (String folderName : duplicateFolders) { - result.append("\n").append(folderName); - } - feedbackTextArea.setText(result.toString()); - } - } else { - mainFrame.showErrorMessage("请先选择一个文件夹!"); - } - }); - - // Feedback area - feedbackTextArea = new TextArea(); - feedbackTextArea.setEditable(false); - feedbackTextArea.setFont(Font.font("Microsoft YaHei", 14)); - feedbackTextArea.setText("反馈结果:\n如无重复文件,则反馈:无重复文件;\n如有重复文件,则在此反馈档号。"); - feedbackTextArea.setStyle("-fx-background-color: white; -fx-border-color: #CCCCCC; -fx-border-width: 1; -fx-padding: 10;"); - - ScrollPane scrollPane = new ScrollPane(feedbackTextArea); - scrollPane.setFitToWidth(true); - scrollPane.setFitToHeight(true); - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS); - - // Main layout - VBox mainBox = new VBox(10); - mainBox.getChildren().addAll(folderSelectBox, startCheckButton, scrollPane); - this.getChildren().add(mainBox); - - // Set result text area reference - resultTextArea = feedbackTextArea; - } - - /** - * Recursively checks for duplicate fileMetadata in the specified folder - */ - private void checkDuplicateFiles(File folder) { - Map> fileHashMap = new HashMap<>(); - if (folder.isDirectory()) { - File[] files = folder.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - checkDuplicateFiles(file); - } else { - try { - String md5Hash = calculateMD5(file); - fileHashMap.computeIfAbsent(md5Hash, k -> new ArrayList<>()).add(file); - } catch (IOException | NoSuchAlgorithmException ex) { - ex.printStackTrace(); - mainFrame.showErrorMessage("计算文件哈希值出错:" + ex.getMessage()); - } - } - } - } - } - - for (List fileList : fileHashMap.values()) { - if (fileList.size() > 1) { - for (File file : fileList) { - File parentFolder = file.getParentFile(); - if (parentFolder != null) { - duplicateFolders.add(parentFolder.getName()); - } - } - } - } - } - - /** - * Calculates the MD5 hash of a file - */ - private String calculateMD5(File file) throws IOException, NoSuchAlgorithmException { - MessageDigest digest = MessageDigest.getInstance("MD5"); - try (FileInputStream fis = new FileInputStream(file)) { - byte[] buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = fis.read(buffer)) != -1) { - digest.update(buffer, 0, bytesRead); - } - } - byte[] hashBytes = digest.digest(); - StringBuilder sb = new StringBuilder(); - for (byte b : hashBytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } -} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/OtherFunctionPanel.java b/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/OtherFunctionPanel.java deleted file mode 100644 index 8808a52..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/OtherFunctionPanel.java +++ /dev/null @@ -1,35 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.deprecated; - -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.Label; -import javafx.scene.layout.VBox; -import javafx.scene.text.Font; - -public class OtherFunctionPanel extends BaseFunctionPanel { - private String title; - - public OtherFunctionPanel(String title) { - super(null); - this.title = title; - initComponents(); - } - - @Override - protected void initComponents() { - // Set padding - this.setPadding(new Insets(50)); - - //Create tip label - Label tipLabel = new Label("【" + "" +"】功能界面待完善..."+title); - tipLabel.setFont(Font.font("Microsoft YaHei", 28)); - tipLabel.setTextFill(javafx.scene.paint.Color.DARKGRAY); - - // Use VBox for vertical layout and center alignment - VBox container = new VBox(tipLabel); - container.setAlignment(Pos.CENTER); - - // Add to the panel - this.getChildren().add(container); - } -} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/PathCheckPanel.java b/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/PathCheckPanel.java deleted file mode 100644 index be88294..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/deprecated/PathCheckPanel.java +++ /dev/null @@ -1,287 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.deprecated; - -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.*; -import javafx.scene.layout.HBox; -import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; -import javafx.scene.text.Font; -import javafx.scene.text.FontWeight; -import javafx.stage.DirectoryChooser; -import javafx.stage.Stage; - -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class PathCheckPanel extends BaseFunctionPanel { - private TextField physicalFolderPathTextField; - private TextField logicalFolderPathTextField; - private TextArea checkResultTextArea; - private Path physicalFolderPath; - private Path logicalFolderPath; - private List physicalPaths = new ArrayList<>(); - private List logicalPaths = new ArrayList<>(); - - public PathCheckPanel(DocCheckToolMainFrame mainFrame) { - super(mainFrame); - initComponents(); - } - - @Override - protected void initComponents() { - // Main layout with spacing - VBox mainLayout = new VBox(15); - mainLayout.setPadding(new Insets(20)); - - // Physical path selection - TitledPane physicalPathPane = createPathPane( - "物理存储路径", - "选择物理文件夹", - "生成物理存储路径文件", - this::selectPhysicalFolder, - this::generatePhysicalPathFile - ); - physicalFolderPathTextField = (TextField) ((HBox) physicalPathPane.getContent()).getChildren().get(0); - - // Logical path selection - TitledPane logicalPathPane = createPathPane( - "逻辑存储路径", - "选择逻辑文件夹", - "生成逻辑存储路径文件", - this::selectLogicalFolder, - this::generateLogicalPathFile - ); - logicalFolderPathTextField = (TextField) ((HBox) logicalPathPane.getContent()).getChildren().get(0); - - // Check buttons - HBox buttonBox = new HBox(15); - buttonBox.setAlignment(Pos.CENTER); - - Button checkFilesButton = new Button("检查文件"); - stylePrimaryButton(checkFilesButton, 16); - checkFilesButton.setOnAction(e -> checkFiles()); - - Button checkDirButton = new Button("检查目录"); - checkDirButton.setFont(Font.font("Microsoft YaHei", FontWeight.BOLD, 16)); - checkDirButton.setTextFill(Color.WHITE); - checkDirButton.setStyle("-fx-background-color: #ef4444; -fx-border-color: #dc2626; -fx-border-width: 2; -fx-padding: 8 30 8 30;"); - checkDirButton.setOnAction(e -> checkDirectory()); - - buttonBox.getChildren().addAll(checkFilesButton, checkDirButton); - - // Result area - checkResultTextArea = new TextArea(); - checkResultTextArea.setEditable(false); - checkResultTextArea.setFont(Font.font("Microsoft YaHei", 14)); - checkResultTextArea.setText("检查结果将显示在这里...\n请先选择文件夹并生成路径文件"); - checkResultTextArea.setStyle("-fx-background-color: white; -fx-border-color: #dee2e6; -fx-border-width: 1; -fx-padding: 10;"); - - ScrollPane scrollPane = new ScrollPane(checkResultTextArea); - scrollPane.setFitToWidth(true); - scrollPane.setFitToHeight(true); - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS); - - // Assemble main layout - VBox pathPanes = new VBox(15, physicalPathPane, logicalPathPane); - mainLayout.getChildren().addAll(pathPanes, scrollPane, buttonBox); - this.getChildren().add(mainLayout); - } - - private TitledPane createPathPane(String title, String selectBtnText, String generateBtnText, - Runnable selectAction, Runnable generateAction) { - TextField pathField = new TextField(); - pathField.setEditable(false); - pathField.setFont(Font.font("Microsoft YaHei", 14)); - pathField.setStyle("-fx-background-color: white; -fx-border-color: #dee2e6; -fx-border-width: 1; -fx-padding: 5 10 5 10;"); - - Button selectButton = new Button(selectBtnText); - stylePrimaryButton(selectButton, 14); - selectButton.setOnAction(e -> selectAction.run()); - - Button generateButton = new Button(generateBtnText); - stylePrimaryButton(generateButton, 14); - generateButton.setDisable(true); - generateButton.setOnAction(e -> generateAction.run()); - - HBox content = new HBox(10, pathField, selectButton, generateButton); - content.setAlignment(Pos.CENTER_LEFT); - - TitledPane pane = new TitledPane(title, content); - pane.setFont(Font.font("Microsoft YaHei", 14)); - pane.setStyle("-fx-border-color: #dee2e6; -fx-border-width: 1;"); - - return pane; - } - - private void stylePrimaryButton(Button button, double fontSize) { - button.setFont(Font.font("Microsoft YaHei", FontWeight.BOLD, fontSize)); - button.setTextFill(Color.WHITE); - button.setStyle("-fx-background-color: #4285f4; -fx-border-color: #3b71ca; -fx-border-width: 2; -fx-padding: 8 30 8 30;"); - } - - private void selectPhysicalFolder() { - DirectoryChooser chooser = new DirectoryChooser(); - chooser.setTitle("选择物理存储文件夹"); - File selectedFolder = chooser.showDialog(new Stage()); - if (selectedFolder != null) { - physicalFolderPathTextField.setText(selectedFolder.getAbsolutePath()); - physicalFolderPath = selectedFolder.toPath(); - - try { - collectFilePaths(physicalFolderPath, physicalPaths); - showStatusMessage("已收集 " + physicalPaths.size() + " 个物理文件路径"); - enableGenerateButton(physicalFolderPathTextField, true); - } catch (IOException e) { - mainFrame.showErrorMessage("读取物理文件夹失败:" + e.getMessage()); - } - } - } - - private void selectLogicalFolder() { - DirectoryChooser chooser = new DirectoryChooser(); - chooser.setTitle("选择逻辑存储文件夹"); - File selectedFolder = chooser.showDialog(new Stage()); - if (selectedFolder != null) { - logicalFolderPathTextField.setText(selectedFolder.getAbsolutePath()); - logicalFolderPath = selectedFolder.toPath(); - - try { - collectFilePaths(logicalFolderPath, logicalPaths); - showStatusMessage("已收集 " + logicalPaths.size() + " 个逻辑文件路径"); - enableGenerateButton(logicalFolderPathTextField, true); - } catch (IOException e) { - mainFrame.showErrorMessage("读取逻辑文件夹失败:" + e.getMessage()); - } - } - } - - private void enableGenerateButton(TextField pathField, boolean enable) { - // Implementation would find the generate button and enable it - // This depends on your exact UI structure - } - - private void generatePhysicalPathFile() { - if (physicalFolderPath == null || physicalPaths.isEmpty()) { - mainFrame.showErrorMessage("请先选择有效的物理文件夹!"); - return; - } - - Path outputPath = Paths.get("物理存储路径文件.txt"); - writePathsToFile(physicalPaths, outputPath); - showStatusMessage("物理存储路径文件已生成:" + outputPath.toAbsolutePath()); - } - - private void generateLogicalPathFile() { - if (logicalFolderPath == null || logicalPaths.isEmpty()) { - mainFrame.showErrorMessage("请先选择有效的逻辑文件夹!"); - return; - } - - Path outputPath = Paths.get("逻辑存储路径文件.txt"); - writePathsToFile(logicalPaths, outputPath); - showStatusMessage("逻辑存储路径文件已生成:" + outputPath.toAbsolutePath()); - } - - // The following methods remain largely unchanged as they deal with file operations: - private void collectFilePaths(Path folderPath, List paths) throws IOException { - paths.clear(); - try (Stream walk = Files.walk(folderPath)) { - paths.addAll(walk - .filter(Files::isRegularFile) - .map(p -> folderPath.relativize(p).toString()) - .collect(Collectors.toList())); - } - } - - private void writePathsToFile(List paths, Path outputPath) { - try (BufferedWriter writer = new BufferedWriter(new FileWriter(outputPath.toFile()))) { - for (String path : paths) { - writer.write(path); - writer.newLine(); - } - } catch (IOException e) { - mainFrame.showErrorMessage("写入文件失败:" + e.getMessage()); - } - } - - private void checkFiles() { - if (physicalPaths.isEmpty() || logicalPaths.isEmpty()) { - mainFrame.showErrorMessage("请先选择并生成物理和逻辑路径文件!"); - return; - } - - clearResultArea(); - showStatusMessage("正在进行文件检查..."); - - Set missingInPhysical = new HashSet<>(); - for (String logicalPath : logicalPaths) { - if (!physicalPaths.contains(logicalPath)) { - missingInPhysical.add(logicalPath); - } - } - - displayCheckResult("文件检查", logicalPaths.size(), missingInPhysical, null); - } - - private void checkDirectory() { - if (physicalPaths.isEmpty() || logicalPaths.isEmpty()) { - mainFrame.showErrorMessage("请先选择并生成物理和逻辑路径文件!"); - return; - } - - clearResultArea(); - showStatusMessage("正在进行目录检查..."); - - Set missingInLogical = new HashSet<>(); - for (String physicalPath : physicalPaths) { - if (!logicalPaths.contains(physicalPath)) { - missingInLogical.add(physicalPath); - } - } - - displayCheckResult("目录检查", physicalPaths.size(), null, missingInLogical); - } - - private void displayCheckResult(String checkType, int totalCount, - Set missingInPhysical, Set missingInLogical) { - StringBuilder result = new StringBuilder(); - result.append("=== ").append(checkType).append(" ===\n"); - result.append("共检查 ").append(totalCount).append(" 个").append(checkType.equals("文件检查") ? "文件" : "目录项").append("\n\n"); - - if (checkType.equals("文件检查")) { - if (missingInPhysical.isEmpty()) { - result.append("✓ 未发现问题:所有逻辑文件在物理存储中均存在\n"); - } else { - result.append("! 发现 ").append(missingInPhysical.size()).append(" 个问题:\n"); - missingInPhysical.forEach(path -> result.append(" - 物理存储中缺失文件:").append(path).append("\n")); - } - } else { - if (missingInLogical.isEmpty()) { - result.append("✓ 未发现问题:所有物理文件在逻辑目录中均存在\n"); - } else { - result.append("! 发现 ").append(missingInLogical.size()).append(" 个问题:\n"); - missingInLogical.forEach(path -> result.append(" - 逻辑目录中缺失文件:").append(path).append("\n")); - } - } - - checkResultTextArea.appendText(result.toString()); - checkResultTextArea.appendText("\n\n"); - showStatusMessage(checkType + "完成"); - } - - private void clearResultArea() { - checkResultTextArea.clear(); - } -} \ No newline at end of file diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReader.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReader.java new file mode 100644 index 0000000..1e5e6e6 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReader.java @@ -0,0 +1,20 @@ +package top.r3944realms.docchecktoolrefactored.io.reader; + + +import top.r3944realms.docchecktoolrefactored.core.LogicalAddressFileGenerator; + +import java.util.List; + +/** + * 目录文件读取器接口 + */ +public interface CatalogFileReader { + /** + * 读取目录文件 + * + * @param filePath 文件路径 + * @return 记录列表 + * @throws Exception 读取异常 + */ + List readCatalogFile(String filePath) throws Exception; +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReaderFactory.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReaderFactory.java new file mode 100644 index 0000000..d821a5f --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/CatalogFileReaderFactory.java @@ -0,0 +1,29 @@ +package top.r3944realms.docchecktoolrefactored.io.reader; + +/** + * 目录文件读取器工厂类 + */ +public class CatalogFileReaderFactory { + /** + * 根据文件扩展名创建相应的文件读取器 + * + * @param filePath 文件路径 + * @return 文件读取器实例 + */ + public static CatalogFileReader createReader(String filePath) { + if (filePath == null || filePath.isEmpty()) { + throw new IllegalArgumentException("文件路径不能为空"); + } + + String lowerPath = filePath.toLowerCase(); + if (lowerPath.endsWith(".xlsx") || lowerPath.endsWith(".xls")) { + return new ExcelFileReader(); + } else if (lowerPath.endsWith(".xml")) { + return new XmlFileReader(); + } else if (lowerPath.endsWith(".dbf")) { + return new DbfFileReader(); + } else { + throw new IllegalArgumentException("不支持的文件格式: " + filePath); + } + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/DbfFileReader.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/DbfFileReader.java new file mode 100644 index 0000000..9e7a6c3 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/DbfFileReader.java @@ -0,0 +1,112 @@ +package top.r3944realms.docchecktoolrefactored.io.reader; + +import com.linuxense.javadbf.DBFReader; +import com.linuxense.javadbf.DBFRow; +import lombok.extern.slf4j.Slf4j; +import top.r3944realms.docchecktoolrefactored.core.LogicalAddressFileGenerator; +import top.r3944realms.docchecktoolrefactored.util.FileUtil; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * DBF文件读取器实现 + */ +@Slf4j +public class DbfFileReader implements CatalogFileReader { + // 常量定义(避免硬编码) + private static final String FIELD_ARCHIVE_CODE = "档号"; + private static final String FIELD_PAGE = "页数"; + + @Override + public List readCatalogFile(String filePath) throws Exception { + + List records = new ArrayList<>(); + File file = FileUtil.fileCheckAndGet(filePath, "dbf"); + try ( + FileInputStream fis = new FileInputStream(file); + DBFReader reader = new DBFReader(fis) + ) { + int fieldCount = reader.getFieldCount(); + log.debug("开始读取DBF文件: {}, DBF文件字段数: {}",filePath, fieldCount); + + // 查找"档号"和"页数"字段的索引 + int archiveCodeIndex = -1; + int pageIndex = -1; + for (int i = 0; i < fieldCount; i++) { + if (archiveCodeIndex != -1 && pageIndex != -1) { + log.debug("已找到所需字段,跳出循环,档号: {}, 页数: {}", archiveCodeIndex, pageIndex); + break; + } + String fieldName = reader.getField(i).getName(); + log.debug("发现字段: {}", fieldName); + if (FIELD_ARCHIVE_CODE.equals(fieldName)) { + archiveCodeIndex = i; + log.debug("找到‘档号’字段,索引: {}", archiveCodeIndex); + } else if (FIELD_PAGE.equals(fieldName)) { + pageIndex = i; + log.debug("找到‘页数’字段,索引: {}", pageIndex); + } + + } + if (archiveCodeIndex == -1 || pageIndex == -1) { + log.error("未找到必要字段,档号: {}, 页数: {}", + archiveCodeIndex == -1 ? "未找到" : archiveCodeIndex, + pageIndex == -1 ? "未找到" : pageIndex + ); + throw new IllegalArgumentException( + String.format("DBF文件缺少必要字段: %s=%s, %s=%s", + FIELD_ARCHIVE_CODE, archiveCodeIndex == -1, + FIELD_PAGE, pageIndex == -1 + ) + ); + } + int validRecords = 0; + int skippedRecords = 0; + DBFRow row; + while ((row = reader.nextRow()) != null) { + String archiveCode = + Optional.ofNullable(row.getObject(archiveCodeIndex)) + .map(Object::toString) + .map(String::trim) + .orElse(""); + int page = + Optional.ofNullable(row.getObject(pageIndex)) + .map(i -> { + try { + if (i instanceof Number) { + return ((Number) i).intValue(); + } else { + return Integer.parseInt(i.toString().trim()); + } + } catch (NumberFormatException e) { + log.warn("无法将页数值转换为整数: {}", i); + return 0; + } + }).orElse(0); + + // 只有当档号不为空且页数大于0时才添加记录 + if (!archiveCode.isEmpty() && page > 0) { + records.add(new LogicalAddressFileGenerator.Record(archiveCode, page)); + validRecords++; + log.debug("读取有效记录 - 档号: {}, 页数: {}", archiveCode, page); + } else { + skippedRecords++; + if (!archiveCode.isEmpty() || page > 0) { + log.debug("跳过无效记录 - 档号: {}, 页数: {}", archiveCode, page); + } + } + } + log.info("DBF文件读取完成,有效记录: {} 条,跳过记录: {} 条", validRecords, skippedRecords); + } catch (IOException e) { + log.error("读取DBF文件失败: {}", filePath, e); + throw new UncheckedIOException("DBF文件读取异常", e); + } + return records; + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/ExcelFileReader.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/ExcelFileReader.java new file mode 100644 index 0000000..a0b3857 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/ExcelFileReader.java @@ -0,0 +1,195 @@ +package top.r3944realms.docchecktoolrefactored.io.reader; + +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import top.r3944realms.docchecktoolrefactored.core.LogicalAddressFileGenerator; +import top.r3944realms.docchecktoolrefactored.util.FileUtil; + +import java.io.File; +import java.io.FileInputStream; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class ExcelFileReader implements CatalogFileReader { + // 常量定义(避免硬编码) + private static final String FIELD_ARCHIVE_CODE = "档号"; + private static final String FIELD_PAGE = "页数"; + + @Override + public List readCatalogFile(String filePath) throws Exception { + File file = FileUtil.fileCheckAndGet(filePath, "xlsx", "xls"); + boolean isXlsx = file.getName().endsWith(".xlsx"); + return readExcelFile(file, isXlsx); + } + private List readExcelFile(File file, boolean isXlsx) throws Exception { + List records = new ArrayList<>(); + log.debug("开始解析Excel文件,格式: {}", isXlsx ? "xlsx" : "xls"); + try (FileInputStream fis = new FileInputStream(file); + Workbook workbook = isXlsx ? new XSSFWorkbook(fis) : new HSSFWorkbook(fis)) { + // 获取第一个工作表 + Sheet sheet = workbook.getSheetAt(0); + log.debug("读取工作表: {}", sheet.getSheetName()); + + // 获取标题行 + Row headerRow = sheet.getRow(0); + if (headerRow == null) { + log.error("Excel文件缺少标题行"); + throw new IllegalArgumentException("Excel文件缺少标题行"); + } + // 查找"档号"和"页数"列的索引 + int archiveCodeIndex = -1; + int pageIndex = -1; + log.debug("开始查找'档号'和'页数'列的索引"); + boolean foundExactMatch = false; + for (Cell cell : headerRow) { + String cellValue = getCellValueAsString(cell).trim(); + if (FIELD_ARCHIVE_CODE.equals(cellValue)) { + archiveCodeIndex = cell.getColumnIndex(); + log.debug("找到'档号'列,索引: {}", archiveCodeIndex); + } else if (FIELD_PAGE.equals(cellValue)) { + pageIndex = cell.getColumnIndex(); + foundExactMatch = true; + log.debug("找到精确匹配'页数'列,索引: {}", pageIndex); + } + } + // 如果没有精确匹配,进行模糊查找 + if (!foundExactMatch) { + for (Cell cell : headerRow) { + String cellValue = getCellValueAsString(cell).trim(); + if (cellValue.contains(FIELD_PAGE)) { + pageIndex = cell.getColumnIndex(); + log.debug("找到模糊匹配'页数'列,索引: {}", pageIndex); + } + } + } + // 检查是否找到必需的列 + if (archiveCodeIndex == -1 || pageIndex == -1) { + log.error("未找到必要字段,档号: {}, 页数: {}", + archiveCodeIndex == -1 ? "未找到" : archiveCodeIndex, + pageIndex == -1 ? "未找到" : pageIndex + ); + throw new IllegalArgumentException( + String.format("Excel文件缺少必要字段: %s=%s, %s=%s", + FIELD_ARCHIVE_CODE, archiveCodeIndex == -1, + FIELD_PAGE, pageIndex == -1 + ) + ); + } + // 从第二行开始读取数据(跳过标题行) + int totalRows = sheet.getLastRowNum(); + int validRecords = 0; + int skippedRecords = 0; + + for (int i = 1; i <= totalRows; i++) { + Row row = sheet.getRow(i); + if (row == null) { + skippedRecords++; + continue; + } + + // 读取档号 + Cell archiveCodeCell = row.getCell(archiveCodeIndex); + String archiveCode = ""; + if (archiveCodeCell != null) { + archiveCode = getCellValueAsString(archiveCodeCell).trim(); + } + + // 读取页数 + Cell pageCell = row.getCell(pageIndex); + int page = 0; + if (pageCell != null) { + page = getCellValueAsInteger(pageCell); + } + + // 验证数据有效性 + if (archiveCode.isEmpty()) { + throw new IllegalArgumentException("第" + ( i + 1 ) + "行档号为空,停止处理"); + } + if (page <= 0) { + throw new IllegalArgumentException("第" + ( i + 1 ) + "行页数无效: " + page + ",停止处理"); + } + + // 只有数据有效时才添加记录 + records.add(new LogicalAddressFileGenerator.Record(archiveCode, page)); + validRecords++; + log.debug("读取有效记录 - 档号: {}, 页数: {}", archiveCode, page); + } + + log.info("数据读取完成,有效记录: {} 条,跳过记录: {} 条", validRecords, skippedRecords); + } catch (Exception e) { + log.error("读取Excel文件时发生错误: {}", e.getMessage(), e); + throw e; + } + return records; + } + /** + * 获取单元格值作为字符串 + * @param cell 单元格 + * @return 字符串值 + */ + private String getCellValueAsString(Cell cell) { + if (cell == null) { + return ""; + } + + switch (cell.getCellType()) { + case STRING: + return cell.getStringCellValue(); + case NUMERIC: + // 判断是否为日期格式 + if (DateUtil.isCellDateFormatted(cell)) { + return cell.getDateCellValue().toString(); + } else { + // 数值转换为字符串,避免科学计数法 + double numericValue = cell.getNumericCellValue(); + if (numericValue == Math.floor(numericValue)) { + return String.valueOf((int) numericValue); + } else { + return String.valueOf(numericValue); + } + } + case BOOLEAN: + return String.valueOf(cell.getBooleanCellValue()); + case FORMULA: + try { + return cell.getStringCellValue(); + } catch (Exception e) { + return cell.getCellFormula(); + } + default: + return ""; + } + } + + /** + * 获取单元格值作为整数 + * @param cell 单元格 + * @return 整数值 + */ + private int getCellValueAsInteger(Cell cell) { + if (cell == null) { + return 0; + } + + try { + switch (cell.getCellType()) { + case NUMERIC: + return (int) Math.round(cell.getNumericCellValue()); + case STRING: + String stringValue = cell.getStringCellValue().trim(); + if (!stringValue.isEmpty()) { + return Integer.parseInt(stringValue); + } + return 0; + default: + return 0; + } + } catch (NumberFormatException e) { + log.warn("无法将单元格值转换为整数: {}", cell); + return 0; + } + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/FileReader.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/FileReader.java deleted file mode 100644 index 15d05ab..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/FileReader.java +++ /dev/null @@ -1,43 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.io.reader; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.file.Path; - -/** - * The interface File reader. - */ -public interface FileReader { - /** - * 读取文件内容 - * - * @param file 要读取的文件 - * @return 文件内容字节数组 byte [ ] - * @throws IOException the io exception - */ - byte[] readFully(Path file) throws IOException; - - /** - * 流式读取文件内容 - * - * @param file 要读取的文件 - * @param consumer 内容消费者 - * @throws IOException the io exception - */ - void readStreaming(Path file, ByteBufferConsumer consumer) throws IOException; - - /** - * The interface Byte buffer consumer. - */ - @FunctionalInterface - interface ByteBufferConsumer { - /** - * Consume. - * - * @param buffer the buffer - * @param isLast the is last - * @throws IOException the io exception - */ - void consume(ByteBuffer buffer, boolean isLast) throws IOException; - } -} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/MemoryMappedFileReader.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/MemoryMappedFileReader.java deleted file mode 100644 index abc459c..0000000 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/MemoryMappedFileReader.java +++ /dev/null @@ -1,50 +0,0 @@ -package top.r3944realms.docchecktoolrefactored.io.reader; - -import java.io.IOException; -import java.nio.MappedByteBuffer; -import java.nio.channels.FileChannel; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; - -/** - * The type Memory mapped file reader. - */ -public class MemoryMappedFileReader implements FileReader { - private static final long MAX_MMAP_SIZE = 1 << 30; // 1GB - - @Override - public byte[] readFully(Path file) throws IOException { - long size = Files.size(file); - if (size > MAX_MMAP_SIZE) { - throw new IOException("File too large for memory mapping: " + file); - } - - try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) { - MappedByteBuffer buffer = channel.map( - FileChannel.MapMode.READ_ONLY, 0, size); - byte[] bytes = new byte[(int) size]; - buffer.get(bytes); - return bytes; - } - } - - @Override - public void readStreaming(Path file, ByteBufferConsumer consumer) throws IOException { - try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) { - long size = channel.size(); - long position = 0; - long chunkSize = Math.min(size, 8 * 1024 * 1024); // 8MB chunks - - while (position < size) { - long remaining = size - position; - long currentChunk = Math.min(remaining, chunkSize); - - MappedByteBuffer buffer = channel.map( - FileChannel.MapMode.READ_ONLY, position, currentChunk); - consumer.consume(buffer, position + currentChunk >= size); - position += currentChunk; - } - } - } -} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/XmlFileReader.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/XmlFileReader.java new file mode 100644 index 0000000..d7e5cb9 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/io/reader/XmlFileReader.java @@ -0,0 +1,146 @@ +package top.r3944realms.docchecktoolrefactored.io.reader; + +import lombok.extern.slf4j.Slf4j; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import top.r3944realms.docchecktoolrefactored.core.LogicalAddressFileGenerator; +import top.r3944realms.docchecktoolrefactored.util.FileUtil; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Slf4j +public class XmlFileReader implements CatalogFileReader { + // 可配置的元素名列表,按优先级排序 + private static final List RECORD_TAG_CANDIDATES = + Arrays.asList("row", "record", "data", "item", "档案"); + + private static final List ARCHIVE_CODE_TAG_CANDIDATES = + Arrays.asList("档号", "dangan", "fileNo"); + + private static final List PAGE_COUNT_TAG_CANDIDATES = + Arrays.asList("页数", "pages", "pageCount"); + + + @Override + public List readCatalogFile(String filePath) throws Exception { + File file = FileUtil.fileCheckAndGet(filePath, "xml"); + List records = new ArrayList<>(); + + try { + // 创建安全配置的DocumentBuilder + DocumentBuilder builder = createSecureDocumentBuilder(); + + // 解析XML文件 + Document doc = builder.parse(file); + doc.getDocumentElement().normalize(); + + log.debug("开始解析XML文件: {}, 根元素: {}", + file.getName(), doc.getDocumentElement().getNodeName()); + // 查找记录元素 + NodeList recordNodes = findAllRecordNodes(doc); + log.debug("找到 {} 个潜在记录节点", recordNodes.getLength()); + + // 解析每个记录元素 + int validCount = 0; + int invalidCount = 0; + + for (int i = 0; i < recordNodes.getLength(); i++) { + Node node = recordNodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element recordElement = (Element) node; + LogicalAddressFileGenerator.Record record = parseRecordElement(recordElement); + + if (record != null) { + records.add(record); + validCount++; + log.debug("解析到有效记录: {}", record); + } else { + invalidCount++; + log.debug("跳过无效记录节点"); + } + } + } + log.info("XML解析完成 - 有效记录: {}, 无效记录: {}", validCount, invalidCount); + } catch (Exception e) { + log.error("解析XML文件失败: {}", filePath, e); + throw new Exception("解析XML文件失败: " + filePath, e); + } + return records; + } + + @SuppressWarnings("HttpUrlsUsage") + private static DocumentBuilder createSecureDocumentBuilder() throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + // 安全配置防止XXE攻击 + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder(); + } + private NodeList findAllRecordNodes(Document doc) { + // 尝试所有可能的记录标签 + for (String tagName : RECORD_TAG_CANDIDATES) { + NodeList nodes = doc.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + log.debug("使用标签名 '{}' 找到 {} 个记录节点", tagName, nodes.getLength()); + return nodes; + } + } + log.warn("未找到任何记录节点,尝试的标签名: {}", RECORD_TAG_CANDIDATES); + return new EmptyNodeList(); // 返回空节点列表而不是null + } + + private LogicalAddressFileGenerator.Record parseRecordElement(Element recordElement) { + String archiveCode = findFirstNonEmptyTextContent(recordElement, ARCHIVE_CODE_TAG_CANDIDATES); + String pageStr = findFirstNonEmptyTextContent(recordElement, PAGE_COUNT_TAG_CANDIDATES); + + if (archiveCode == null || archiveCode.isEmpty()) { + log.debug("记录缺少档号字段"); + return null; + } + + try { + int page = pageStr != null ? Integer.parseInt(pageStr.trim()) : 0; + if (page > 0) { + return new LogicalAddressFileGenerator.Record(archiveCode, page); + } else { + log.debug("无效的页数值: {}", pageStr); + return null; + } + } catch (NumberFormatException e) { + log.warn("页数字段格式错误: {}", pageStr); + return null; + } + } + + private String findFirstNonEmptyTextContent(Element element, List tagNames) { + for (String tag : tagNames) { + NodeList nodes = element.getElementsByTagName(tag); + if (nodes.getLength() > 0) { + String content = nodes.item(0).getTextContent(); + if (content != null && !content.trim().isEmpty()) { + return content.trim(); + } + } + } + return null; + } + + // 空节点列表实现 + private static class EmptyNodeList implements NodeList { + @Override public Node item(int index) { return null; } + @Override public int getLength() { return 0; } + } + + +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/io/scanner/ParallelFileScanner.java b/src/main/java/top/r3944realms/docchecktoolrefactored/io/scanner/ParallelFileScanner.java index 57990b4..5f92c55 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/io/scanner/ParallelFileScanner.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/io/scanner/ParallelFileScanner.java @@ -7,9 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; import java.util.stream.Stream; /** diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/model/RecordData.java b/src/main/java/top/r3944realms/docchecktoolrefactored/model/RecordData.java new file mode 100644 index 0000000..946ec81 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/model/RecordData.java @@ -0,0 +1,23 @@ +package top.r3944realms.docchecktoolrefactored.model; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.annotation.write.style.ContentRowHeight; +import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ContentRowHeight(20) +@HeadRowHeight(25) +@ColumnWidth(25) +public class RecordData { + @ExcelProperty(value = "档号") + private String archivalCode; + + @ExcelProperty(value = "页数") + private Integer page; +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/DuplicateDocumentPaneController.java b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/DuplicateDocumentPaneController.java index c9dc6a5..7ecc7b0 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/DuplicateDocumentPaneController.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/DuplicateDocumentPaneController.java @@ -7,12 +7,19 @@ import javafx.scene.control.TextArea; import javafx.scene.control.TextField; import javafx.stage.DirectoryChooser; import javafx.stage.Stage; +import lombok.extern.slf4j.Slf4j; +import top.r3944realms.docchecktoolrefactored.core.DuplicateFinder; +import top.r3944realms.docchecktoolrefactored.core.FileHashCalculator; +import top.r3944realms.docchecktoolrefactored.io.scanner.FileScanner; +import top.r3944realms.docchecktoolrefactored.io.scanner.RobustParallelScanner; +import top.r3944realms.docchecktoolrefactored.ui.task.DuplicateDocumentDetectionTask; import java.io.File; /** * The type Duplicate document pane controller. */ +@Slf4j public class DuplicateDocumentPaneController { @FXML private TextArea result1B; @FXML private TextField loadFolder1TF; @@ -39,6 +46,39 @@ public class DuplicateDocumentPaneController { * @param actionEvent the action event */ @FXML void onStart(ActionEvent actionEvent) { - // 触发异步逻辑 -> 调用 + log.info("用户点击了开始查重按钮"); + String folderPath = loadFolder1TF.getText(); + if (folderPath == null || folderPath.trim().isEmpty()) { + log.warn("未选择文件夹,无法进行查重"); + result1B.setText("请选择要检查的文件夹。"); + return; + } + + // 创建并启动后台任务 + DuplicateDocumentDetectionTask task = new DuplicateDocumentDetectionTask(folderPath); + + // 绑定任务的消息到结果文本区域 + task.messageProperty().addListener((observable, oldValue, newValue) -> { + result1B.setText(newValue); + }); + + // 当任务完成时显示完整结果 + task.setOnSucceeded(e -> { + result1B.setText(task.getValue()); + log.info("查重任务完成,结果如下:{}", task.getValue()); + }); + + // 处理任务失败情况 + task.setOnFailed(e -> { + Throwable exception = task.getException(); + result1B.setText("检测过程中发生错误: " + exception.getMessage()); + log.error("error", exception); + log.info("查重任务失败,错误信息: {}", exception.getMessage()); + }); + + // 在新线程中执行任务 + Thread thread = new Thread(task); + thread.setDaemon(true); + thread.start(); } } diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/PathCheckPaneController.java b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/PathCheckPaneController.java index 1b59485..9dd6264 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/PathCheckPaneController.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/PathCheckPaneController.java @@ -7,13 +7,23 @@ import javafx.scene.control.Button; import javafx.scene.control.ChoiceBox; import javafx.scene.control.TextArea; import javafx.scene.control.TextField; +import javafx.stage.FileChooser; +import lombok.extern.slf4j.Slf4j; +import top.r3944realms.docchecktoolrefactored.core.AddressFileComparator; +import top.r3944realms.docchecktoolrefactored.core.AddressFileGenerator; +import top.r3944realms.docchecktoolrefactored.core.LogicalAddressFileGenerator; +import top.r3944realms.docchecktoolrefactored.core.PhysicalAddressFileGenerator; +import java.io.File; import java.net.URL; import java.util.ResourceBundle; + /** * The type Path check pane controller. */ +//TODO: 应该交给Platform:runLater; +@Slf4j public class PathCheckPaneController implements Initializable { @FXML private ChoiceBox loadFolderType2CB; @FXML private TextArea result2TA; @@ -24,6 +34,14 @@ public class PathCheckPaneController implements Initializable { @FXML private Button selectJPGFolder2B; @FXML private Button generateLogicalAddress2B; @FXML private Button generatePhysicalAddress2B; + // 存储生成的文件路径 + private String logicalAddressFilePath = null; + private String physicalAddressFilePath = null; + + + // 逻辑地址文件生成器实例 + private final LogicalAddressFileGenerator generator = new LogicalAddressFileGenerator(); + private final PhysicalAddressFileGenerator paGenerator = new PhysicalAddressFileGenerator(); /** * On select lc. @@ -31,7 +49,30 @@ public class PathCheckPaneController implements Initializable { * @param actionEvent the action event */ @FXML void onSelectLC(ActionEvent actionEvent) { + log.info("用户点击了选择目录文件按钮"); + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("选择目录文件"); + // 设置文件过滤器,只允许DBF、XML、xlsx、xls格式 + + FileChooser.ExtensionFilter xlsxFilter = new FileChooser.ExtensionFilter("Excel Files (*.xlsx)", "*.xlsx"); + FileChooser.ExtensionFilter xlsFilter = new FileChooser.ExtensionFilter("Excel Files (*.xls)", "*.xls"); + FileChooser.ExtensionFilter dbfFilter = new FileChooser.ExtensionFilter("DBF Files (*.dbf)", "*.dbf"); + FileChooser.ExtensionFilter xmlFilter = new FileChooser.ExtensionFilter("XML Files (*.xml)", "*.xml"); + + fileChooser.getExtensionFilters().addAll( xlsxFilter, xlsFilter,dbfFilter, xmlFilter); + + // 显示文件选择对话框 + File selectedFile = fileChooser.showOpenDialog(selectLoadCatalog2B.getScene().getWindow()); + + // 如果选择了文件,则将文件路径显示在loadCatalog2TF上 + if (selectedFile != null) { + loadCatalog2TF.setText(selectedFile.getAbsolutePath()); + log.info("选择的目录文件路径为:{}", selectedFile.getAbsolutePath()); + }else{ + log.warn("用户未选择任何文件夹"); + result2TA.setText("未选择任何文件夹,请重新选择。"); + } } /** @@ -40,7 +81,22 @@ public class PathCheckPaneController implements Initializable { * @param actionEvent the action event */ @FXML void onSelectJPGF(ActionEvent actionEvent) { + javafx.stage.DirectoryChooser directoryChooser = new javafx.stage.DirectoryChooser(); + // 正确获取当前选中的值 + Mode selectedMode = loadFolderType2CB.getValue(); + if (selectedMode == Mode.PAGE_TYPE) { + directoryChooser.setTitle("选择页面级文件夹"); + } else if (selectedMode == Mode.FILE_TYPE) { + directoryChooser.setTitle("选择文件级文件夹"); + } + log.info("用户选择的模式为:{}", selectedMode); + File selectedDirectory = directoryChooser.showDialog(selectJPGFolder2B.getScene().getWindow()); + + if (selectedDirectory != null) { + loadJPGFolder2TF.setText(selectedDirectory.getAbsolutePath()); + log.info("选择的{}文件夹路径为:{}", selectedMode,selectedDirectory.getAbsolutePath()); + } } /** @@ -49,7 +105,62 @@ public class PathCheckPaneController implements Initializable { * @param actionEvent the action event */ @FXML void onGenerateLA(ActionEvent actionEvent) { + log.info("用户点击了生成逻辑地址文件按钮"); + String filePath = loadCatalog2TF.getText(); + if (filePath.isEmpty()) { + result2TA.setText("请先选择目录文件。"); + return; + } + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("选择保存逻辑地址文件的位置"); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV Files", "*.csv")); + fileChooser.setInitialFileName("逻辑地址文件.csv"); + + File outputFile = fileChooser.showSaveDialog(generateLogicalAddress2B.getScene().getWindow()); + if (outputFile == null) { + result2TA.setText("未选择保存位置"); + log.warn("用户未选择任何文件"); + return; + } + + // 正确处理文件扩展名 - 只有在没有.csv扩展名时才添加 + final File finalOutputFile; + if (!outputFile.getName().toLowerCase().endsWith(".csv")) { + finalOutputFile = new File(outputFile.getAbsolutePath() + ".csv"); + } else { + finalOutputFile = outputFile; + } + + // 保存生成的文件路径 + logicalAddressFilePath = finalOutputFile.getAbsolutePath(); + log.info("选择的输出文件路径: {}", logicalAddressFilePath); + // 创建后台任务来处理文件生成 + Thread backgroundThread = new Thread(() -> { + // 获取当前选择的文件夹类型 + Mode selectedMode = loadFolderType2CB.getValue(); + int folderType = (selectedMode == Mode.PAGE_TYPE) ? AddressFileGenerator.PAGE_TYPE : AddressFileGenerator.FILE_TYPE; + + generator.generateAddressFile(filePath, finalOutputFile, folderType, new LogicalAddressFileGenerator.Callback() { + @Override + public void onProgress(String message) { + javafx.application.Platform.runLater(() -> result2TA.setText(message)); + } + + @Override + public void onSuccess(String outputPath) { + javafx.application.Platform.runLater(() -> result2TA.setText(outputPath)); + } + + @Override + public void onError(String errorMessage) { + javafx.application.Platform.runLater(() -> result2TA.setText(errorMessage)); + } + }); + }); + + + backgroundThread.start(); } /** @@ -58,20 +169,152 @@ public class PathCheckPaneController implements Initializable { * @param actionEvent the action event */ @FXML void onGeneratePA(ActionEvent actionEvent) { + String folderPath = loadJPGFolder2TF.getText(); + if (folderPath.isEmpty()) { + result2TA.setText("请先选择文件夹。"); + return; + } + File folder = new File(folderPath); + if(!folder.exists() || !folder.isDirectory()) { + result2TA.setText("所选路径不存在或不是一个有效的文件夹。"); + return; + } + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("选择保存物理地址文件的位置"); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV Files", "*.csv")); + fileChooser.setInitialFileName("物理地址文件.csv"); + // 使用当前窗口作为父窗口显示文件选择对话框 + File outputFile = fileChooser.showSaveDialog(selectJPGFolder2B.getScene().getWindow()); + + if (outputFile == null) { + result2TA.setText("未选择保存位置"); + return; + } + + // 正确处理文件扩展名 - 只有在没有.csv扩展名时才添加 + final File finalOutputFile; + if (!outputFile.getName().toLowerCase().endsWith(".csv")) { + finalOutputFile = new File(outputFile.getAbsolutePath() + ".csv"); + } else { + finalOutputFile = outputFile; + } + + // 保存生成的文件路径 + physicalAddressFilePath = finalOutputFile.getAbsolutePath(); + + // 创建后台任务来处理文件生成 + Thread backgroundThread = new Thread(() -> { + // 获取当前选择的文件夹类型 + Mode selectedMode = loadFolderType2CB.getValue(); + int folderType = (selectedMode == Mode.PAGE_TYPE) ? AddressFileGenerator.PAGE_TYPE : AddressFileGenerator.FILE_TYPE; + + paGenerator.generateAddressFile(folderPath, finalOutputFile, folderType, new AddressFileGenerator.Callback() { + @Override + public void onProgress(String message) { + javafx.application.Platform.runLater(() -> result2TA.setText(message)); + } + + @Override + public void onSuccess(String outputPath) { + javafx.application.Platform.runLater(() -> result2TA.setText(outputPath)); + } + + @Override + public void onError(String errorMessage) { + javafx.application.Platform.runLater(() -> result2TA.setText(errorMessage)); + } + }); + }); + + backgroundThread.start(); } + + + /** * On start. * * @param actionEvent the action event */ - @FXML void onStart(ActionEvent actionEvent) { + @FXML + void onStart(ActionEvent actionEvent) { + log.info("用户点击了开始对比按钮"); + // 检查是否已生成两个文件 + if (logicalAddressFilePath == null || physicalAddressFilePath == null) { + result2TA.setText("请先生成逻辑地址文件和物理地址文件。"); + return; + } + + log.info("逻辑地址文件路径为:{}", logicalAddressFilePath); + log.info("物理地址文件路径为:{}", physicalAddressFilePath); + + // 使用新创建的核心类进行文件比较 + AddressFileComparator comparator = new AddressFileComparator(); + AddressFileComparator.ComparisonResult result = comparator.compareFiles(physicalAddressFilePath, logicalAddressFilePath); + + // 显示比对结果 + StringBuilder resultText = new StringBuilder(); + + // 显示读取的行数 + resultText.append("读取物理地址文件记录数: ").append(result.getPhysicalRecordsCount()).append("\n"); + resultText.append("读取逻辑地址文件记录数: ").append(result.getLogicalRecordsCount()).append("\n\n"); + + // 显示路径不一致的结果 + if (!result.getPathMismatchResults().isEmpty()) { + resultText.append("文件名相同但路径不一致的记录数量: ").append(result.getPathMismatchResults().size()).append("\n"); + for (String mismatch : result.getPathMismatchResults()) { + resultText.append("\t").append(mismatch).append("\n"); + } + resultText.append("\n"); + } else { + resultText.append("没有路径错误\n\n"); + } + + // 显示物理文件在逻辑文件中未找到的结果 + if (!result.getForwardComparisonResults().isEmpty()) { + resultText.append("物理文件在逻辑文件中未找到的记录数量: ").append(result.getForwardComparisonResults().size()).append("\n"); + for (String forward : result.getForwardComparisonResults()) { + resultText.append("\t").append(forward).append("\n"); + } + resultText.append("\n"); + } else { + resultText.append("没有物理存在而逻辑不存在的文件\n\n"); + } + + // 显示逻辑文件在物理文件中未找到的结果 + if (!result.getBackwardComparisonResults().isEmpty()) { + resultText.append("逻辑文件在物理文件中未找到的记录数量: ").append(result.getBackwardComparisonResults().size()).append("\n"); + for (String backward : result.getBackwardComparisonResults()) { + resultText.append("\t").append(backward).append("\n"); + } + } else { + resultText.append("没有逻辑存在而物理不存在的文件\n"); + } + + // 如果所有结果都为空,则显示一致信息 + if (result.getPathMismatchResults().isEmpty() && + result.getForwardComparisonResults().isEmpty() && + result.getBackwardComparisonResults().isEmpty()) { + resultText = new StringBuilder("所有文件比对一致,无差异。\n"); + resultText.append("读取物理地址文件记录数: ").append(result.getPhysicalRecordsCount()).append("\n"); + resultText.append("读取逻辑地址文件记录数: ").append(result.getLogicalRecordsCount()).append("\n"); + } + + result2TA.setText(resultText.toString()); } + + + + + + @Override public void initialize(URL url, ResourceBundle resourceBundle) { + loadFolderType2CB.setValue(Mode.PAGE_TYPE); loadFolderType2CB.getItems().addAll(Mode.values()); } @@ -82,11 +325,11 @@ public class PathCheckPaneController implements Initializable { /** * Jpg mode. */ - JPG("jpg"), + PAGE_TYPE("jpg"), /** * Pdf mode. */ - PDF("pdf"); + FILE_TYPE("pdf"); /** * The Id. */ @@ -97,3 +340,5 @@ public class PathCheckPaneController implements Initializable { } } } + + diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/ProjectInfoPaneController.java b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/ProjectInfoPaneController.java index fe8b3fa..3aafc57 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/ProjectInfoPaneController.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/ProjectInfoPaneController.java @@ -1,5 +1,6 @@ package top.r3944realms.docchecktoolrefactored.ui.module; +import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.TextField; @@ -12,5 +13,14 @@ public class ProjectInfoPaneController { @FXML private TextField totalCatalogNumberTF; @FXML private TextField fileCategoriesTF; @FXML private TextField fileYearTF; + @FXML + void onReset(ActionEvent event) { + // 清空所有文本字段 + projectNameTF.clear(); + fileYearTF.clear(); + fileCategoriesTF.clear(); + totalCatalogNumberTF.clear(); + AcceptanceTimeTF.clear(); + } } diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/StorageCarrierPaneController.java b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/StorageCarrierPaneController.java new file mode 100644 index 0000000..db36bc0 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/module/StorageCarrierPaneController.java @@ -0,0 +1,254 @@ +// StorageCarrierPaneController.java +package top.r3944realms.docchecktoolrefactored.ui.module; + +import javafx.concurrent.Task; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import lombok.extern.slf4j.Slf4j; +import top.r3944realms.docchecktoolrefactored.core.HashFileGenerator; +import top.r3944realms.docchecktoolrefactored.core.MD5HashCalculator; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +//TODO: 应该交给Platform:runLater; +@Slf4j +public class StorageCarrierPaneController { + + @FXML + private Button caculateHash7B; + + @FXML + private Button generateHashFile7B; + + @FXML + private TextField loadCompressedFile; + + @FXML + private TextField loadDigitalOutcomes; + + @FXML + private TextArea result7TA; + + @FXML + private Button selectLoadCompressedFile7B; + + @FXML + private Button selectLoadDigitalOutcomes7B; + + @FXML + private Button clearSelectedFoldersButton; + + + + + @FXML + void onSelectLD(ActionEvent event) { + log.info("用户点击选择文件夹按钮"); + DirectoryChooser directoryChooser = new DirectoryChooser(); + directoryChooser.setTitle("选择要检查的文件夹(页面级文件夹和文件级文件夹等不包括目录文件夹)"); + + File selectedFolder = directoryChooser.showDialog(new Stage()); + if (selectedFolder != null) { + String currentText = loadDigitalOutcomes.getText(); + String folderPath = selectedFolder.getAbsolutePath(); + + // 如果当前文本框为空,直接设置;否则追加路径 + if (currentText == null || currentText.isEmpty()) { + loadDigitalOutcomes.setText(folderPath); + } else { + // 检查是否已经添加过该路径,避免重复 + String[] existingPaths = currentText.split(File.pathSeparator); + boolean alreadyExists = false; + for (String path : existingPaths) { + if (path.equals(folderPath)) { + alreadyExists = true; + break; + } + } + + if (!alreadyExists) { + loadDigitalOutcomes.setText(currentText + File.pathSeparator + folderPath); + } + } + log.info("用户选择了文件夹: {}", selectedFolder.getAbsolutePath()); + } else { + log.info("用户取消了文件夹选择"); + } + } + + @FXML + void onClearSelectedFolders(ActionEvent event) { + log.info("用户点击清除已选择文件夹按钮"); + loadDigitalOutcomes.setText(""); + result7TA.setText("已清除所有已选择的文件夹"); + log.info("已清除所有已选择的文件夹"); + } + + + + + @FXML + void onSelectLC(ActionEvent event) { + log.info("用户点击选择RAR文件按钮"); + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("选择一个 .rar 文件"); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("RAR Files", "*.rar")); + File selectedFile = fileChooser.showOpenDialog(new Stage()); + if (selectedFile != null) { + loadCompressedFile.setText(selectedFile.getAbsolutePath()); + log.info("用户选择了RAR文件: {}", selectedFile.getAbsolutePath()); + } else { + log.info("用户取消了RAR文件选择"); + } + } + + @FXML + void onCaculateHash(ActionEvent event) { + log.info("开始计算RAR文件的MD5哈希值"); + String filePath = loadCompressedFile.getText(); + if (filePath == null || filePath.isEmpty()) { + log.warn("未选择RAR文件,无法计算哈希值"); + result7TA.setText("请先选择一个 .rar 文件"); + return; + } + + File file = new File(filePath); + if (!file.exists() || !file.isFile() || !filePath.endsWith(".rar")) { + log.warn("选择的文件无效或不是RAR文件: {}", filePath); + result7TA.setText("所选文件不存在或不是一个有效的 .rar 文件"); + return; + } + + try { + log.info("开始计算文件哈希值: {}", filePath); + MD5HashCalculator hashCalculator = new MD5HashCalculator(); + String hashResult = hashCalculator.calculateHash(file.toPath()); + result7TA.setText("计算结果:\n" + hashResult); + log.info("文件哈希值计算完成: {}", hashResult); + } catch (IOException e) { + log.error("计算文件哈希值时出错: {}", filePath, e); + result7TA.setText("计算哈希值时出错: " + e.getMessage()); + } + } + + @FXML + void onGenerateHF(ActionEvent event) { + log.info("开始生成哈希列表文件"); + String folderPathsText = loadDigitalOutcomes.getText(); + if (folderPathsText == null || folderPathsText.isEmpty()) { + log.warn("未选择文件夹,无法生成哈希列表文件"); + result7TA.setText("请先选择一个文件夹"); + return; + } + + // 解析多个文件夹路径 + String[] folderPaths = folderPathsText.split(File.pathSeparator); + List folders = new ArrayList<>(); + + for (String path : folderPaths) { + File folder = new File(path.trim()); + if (folder.exists() && folder.isDirectory()) { + folders.add(folder); + } else { + log.warn("选择的路径无效或不是文件夹: {}", path); + result7TA.setText("所选路径不存在或不是一个有效的文件夹: " + path); + return; + } + } + + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("选择保存哈希列表文件的位置"); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV Files", "*.csv")); + + // 设置默认文件名 + fileChooser.setInitialFileName("哈希值列表文件.csv"); + + // 使用当前窗口作为父窗口显示文件选择对话框 + File outputFile = fileChooser.showSaveDialog(selectLoadDigitalOutcomes7B.getScene().getWindow()); + + if (outputFile == null) { + log.info("用户取消了文件保存操作"); + result7TA.setText("未选择保存位置"); + return; + } + + // 正确处理文件扩展名 - 只有在没有.csv扩展名时才添加 + final File finalOutputFile; + if (!outputFile.getName().toLowerCase().endsWith(".csv")) { + finalOutputFile = new File(outputFile.getAbsolutePath() + ".csv"); + } else { + finalOutputFile = outputFile; + } + + log.info("选择的输出文件路径: {}", finalOutputFile.getAbsolutePath()); + + // 创建后台任务 + Task task = new Task() { + @Override + protected String call() throws Exception { + log.info("开始执行哈希文件生成任务"); + updateMessage("开始生成哈希文件..."); + + HashFileGenerator generator = new HashFileGenerator(); + + // 传递多个文件夹路径 + List folderPaths = folders.stream().map(File::toPath).collect(ArrayList::new, + ArrayList::add, + ArrayList::addAll); + + generator.generateHashFile(folderPaths, finalOutputFile.toPath(), (current, total) -> { + updateProgress(current, total); + updateMessage("处理文件: " + current + "/" + total); + if (current % 500 == 0 || current == total) { // 每500个文件或完成时记录一次日志 + log.info("处理进度: {}/{}", current, total); + } + }); + + log.info("哈希文件生成任务完成,输出文件: {}", finalOutputFile.getAbsolutePath()); + return "哈希列表文件已生成: " + finalOutputFile.getAbsolutePath(); + } + }; + + // 绑定任务的消息到结果文本区域,实时显示进度 + task.messageProperty().addListener((observable, oldValue, newValue) -> { + result7TA.setText(newValue); + }); + + // 任务成功完成 + task.setOnSucceeded(e -> { + log.info("哈希文件生成任务成功完成"); + result7TA.setText(task.getValue()); + }); + + // 任务失败处理 + task.setOnFailed(e -> { + Throwable exception = task.getException(); + String errorMsg = "生成哈希文件时出错: " + (exception != null ? exception.getMessage() : "未知错误"); + log.error("哈希文件生成任务失败", exception); + result7TA.setText(errorMsg); + }); + + // 任务取消处理 + task.setOnCancelled(e -> { + log.info("哈希文件生成任务被用户取消"); + result7TA.setText("哈希文件生成操作已取消"); + }); + + // 在新线程中执行任务 + Thread thread = new Thread(task); + thread.setDaemon(true); + thread.start(); + } + + + +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/task/DuplicateDocumentDetectionTask.java b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/task/DuplicateDocumentDetectionTask.java new file mode 100644 index 0000000..74178fa --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/task/DuplicateDocumentDetectionTask.java @@ -0,0 +1,177 @@ +package top.r3944realms.docchecktoolrefactored.ui.task; + +import javafx.concurrent.Task; +import lombok.extern.slf4j.Slf4j; +import top.r3944realms.docchecktoolrefactored.core.MD5HashCalculator; +import top.r3944realms.docchecktoolrefactored.io.scanner.FileScanner; +import top.r3944realms.docchecktoolrefactored.io.scanner.RobustParallelScanner; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +@Slf4j +public class DuplicateDocumentDetectionTask extends Task { + private final String folderPath; + private final MD5HashCalculator hashCalculator; + private volatile RobustParallelScanner scanner; + + public DuplicateDocumentDetectionTask(String folderPath) { + this.folderPath = folderPath; + this.hashCalculator = new MD5HashCalculator(); + } + + @Override + protected String call() throws Exception { + updateMessage("正在初始化扫描..."); + + Path rootPath = Paths.get(folderPath); + if (!Files.exists(rootPath) || !Files.isDirectory(rootPath)) { + throw new IllegalArgumentException("指定路径不是有效目录: " + folderPath); + } + + // 使用 RobustParallelScanner 和 MD5HashCalculator 进行并行扫描和哈希计算 + Map> hashToFileMap = new ConcurrentHashMap<>(); + AtomicInteger processed = new AtomicInteger(0); + AtomicReference errorRef = new AtomicReference<>(null); + AtomicBoolean scanCompleted = new AtomicBoolean(false); + + // 使用 CountDownLatch 等待扫描完成 + CountDownLatch latch = new CountDownLatch(1); + + // 创建扫描器 + scanner = new RobustParallelScanner(10); + + // 异步启动扫描任务 + Thread scanThread = new Thread(() -> { + try { + scanner.scanWithProgress(rootPath, new FileScanner.ProgressAwareListener() { + @Override + public void onFileFound(Path file) { + if (isCancelled()) { + scanner.cancel(); + return; + } + try { + String hash = hashCalculator.calculatePartialHash(file); + hashToFileMap.computeIfAbsent(hash, k -> new ArrayList<>()).add(file); + } catch (IOException e) { + // 记录无法计算哈希的文件,但不中断整个过程 + updateMessage("警告: 无法处理文件 " + file.toString() + " - " + e.getMessage()); + } + processed.incrementAndGet(); + } + + @Override + public void onError(Path path, Exception e) { + // 记录错误但不中断扫描过程 + errorRef.set(e); + updateMessage("扫描错误: " + path.toString() + " - " + e.getMessage()); + } + + @Override + public void onScanComplete() { + // 扫描完成 + scanCompleted.set(true); + latch.countDown(); + } + + @Override + public void onProgressUpdate(int current, int total) { + if (isCancelled()) { + scanner.cancel(); + return; + } + updateProgress(current, total); + updateMessage("正在处理文件: " + current + "/" + total); + if (current % 500 == 0 || current == total) { // 每500个文件或完成时记录一次日志 + log.info("处理进度: {}/{}", current, total); + } + } + }); + } catch (Exception e) { + errorRef.set(e); + latch.countDown(); + } + }); + + scanThread.setDaemon(true); + scanThread.start(); + + // 等待扫描完成,设置更长的超时时间(例如5分钟) + if (!latch.await(5*60, TimeUnit.MINUTES)) { + scanner.cancel(); + throw new TimeoutException("扫描超时(5分钟)"); + } + + // 检查是否被取消 + if (isCancelled()) { + scanner.cancel(); + return "操作已被取消"; + } + + // 如果有错误且扫描未完成,抛出异常 + if (errorRef.get() != null && !scanCompleted.get()) { + throw errorRef.get(); + } + + // 分析重复文件并构建结果 + updateMessage("正在分析重复文件..."); + StringBuilder result = new StringBuilder(); + result.append("重复文件检测结果:\n"); + + if (errorRef.get() != null) { + result.append("警告: 扫描过程中发生错误 - ").append(errorRef.get().getMessage()).append("\n\n"); + } + + result.append("总共处理 ").append(processed.get()).append(" 个文件\n"); + + List>> duplicateGroups = hashToFileMap.entrySet().stream() + .filter(entry -> entry.getValue().size() > 1) + .collect(Collectors.toList()); + + if (!duplicateGroups.isEmpty()) { + result.append("有 ").append(duplicateGroups.size()).append(" 组重复文件\n\n"); + + int groupIndex = 1; + for (Map.Entry> entry : duplicateGroups) { + result.append("第 ").append(groupIndex).append(" 组\t"); + result.append("哈希值: ").append(entry.getKey()).append("\n"); + + int fileIndex = 1; + for (Path file : entry.getValue()) { + result.append("文件名").append(fileIndex).append(": ").append(file.getFileName()).append("\t\t"); + result.append("文件路径").append(fileIndex).append(": ").append(file.toAbsolutePath()).append("\n"); + fileIndex++; + } + result.append("\n"); + groupIndex++; + } + } else { + result.append("没有重复文件\n"); + } + + updateMessage("检测完成!"); + return result.toString(); + } + + @Override + protected void cancelled() { + super.cancelled(); + if (scanner != null) { + scanner.cancel(); + } + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/utils/DialogUtil.java b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/utils/DialogUtil.java index 86bb67a..ba7f44e 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/ui/utils/DialogUtil.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/ui/utils/DialogUtil.java @@ -95,4 +95,13 @@ public class DialogUtil { alert.showAndWait(); }); } + public static void showErrorDialog(String title, String content) { + showErrorDialog(title, title, content); + } + public static void showWarningDialog(String title, String content) { + showWarningDialog(title, title, content); + } + public static void showInformationDialog(String title, String content) { + showInformationDialog(title, title, content); + } } diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/util/FileUtil.java b/src/main/java/top/r3944realms/docchecktoolrefactored/util/FileUtil.java new file mode 100644 index 0000000..6b79207 --- /dev/null +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/util/FileUtil.java @@ -0,0 +1,98 @@ +package top.r3944realms.docchecktoolrefactored.util; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * 文件工具类 + */ +@Slf4j +public class FileUtil { + /** + * 将字节数转换为易读的格式 + */ + public static String humanReadableByteCount(long bytes) { + if (bytes < 0) throw new IllegalArgumentException("字节数不能为负数"); + if (bytes < 1024) return bytes + " B"; + int exp = (int) (Math.log(bytes) / Math.log(1024)); + String unit = "KMGTPE".charAt(exp-1) + "iB"; + return String.format(Locale.US,"%.1f %s", bytes / Math.pow(1024, exp), unit); + } + + public static String getFileExtension(String filePath) throws NoSuchFileException { + Path path = Paths.get(filePath).normalize(); + File file = path.toFile(); + if (!file.exists()) { + log.error("文件不存在: {}", filePath); + throw new NoSuchFileException("文件不存在: " + filePath); + } + + String fileName = path.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == fileName.length() - 1) { + throw new IllegalArgumentException("文件无有效扩展名: " + fileName); + } + return fileName.substring(dotIndex + 1).toLowerCase(); + } + + /** + * 检查文件路径并返回 + * + * @param filePath 文件路径 + * @return File + * @throws NoSuchFileException 当指定路径无此文件时抛出 + */ + public static File fileCheckAndGet(String filePath) throws NoSuchFileException { + return fileCheckAndGet(filePath, (String) null); + } + + /** + * 检查文件路径并返回 + * + * @param filePath 文件路径 + * @param supportedFileExtensions 文件扩展名,例如 ["pdf", "txt] + * @throws IllegalArgumentException 当指定路径文件无扩展名(如README),或扩展名不符时抛出 + * @throws NoSuchFileException 当指定路径无此文件时抛出 + */ + public static File fileCheckAndGet(String filePath, String... supportedFileExtensions) + throws NoSuchFileException, IllegalArgumentException { + + Path path = Paths.get(filePath).normalize(); + File file = path.toFile(); + + if (!file.exists()) { + log.error("文件不存在: {}", filePath); + throw new NoSuchFileException("文件不存在: " + filePath); + } + + if (supportedFileExtensions == null || supportedFileExtensions.length == 0) { + return file; + } + + String fileName = path.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == fileName.length() - 1) { + throw new IllegalArgumentException("文件无有效扩展名: " + fileName); + } + + String fileExtension = fileName.substring(dotIndex + 1).toLowerCase(); + List supportedExtensions = Arrays.stream(supportedFileExtensions) + .map(String::toLowerCase) + .toList(); + + if (!supportedExtensions.contains(fileExtension)) { + log.error("不支持的文件格式: {}", fileExtension); + throw new IllegalArgumentException("不支持的文件格式,预期: " + + supportedExtensions + ",实际: " + fileExtension); + } + + return file; + } +} diff --git a/src/main/java/top/r3944realms/docchecktoolrefactored/util/StringUtil.java b/src/main/java/top/r3944realms/docchecktoolrefactored/util/StringUtil.java index c497fd1..863c25a 100644 --- a/src/main/java/top/r3944realms/docchecktoolrefactored/util/StringUtil.java +++ b/src/main/java/top/r3944realms/docchecktoolrefactored/util/StringUtil.java @@ -3,10 +3,10 @@ package top.r3944realms.docchecktoolrefactored.util; public class StringUtil { public static String NO_BUG = """ - _ooOoo_ - o8888888o - 88" . "88 - (| -_- |) + _ooOoo_ + o8888888o + 88" . "88 + (| -_- |) O\\ = /O ____/`---'\\____ .' \\\\| |// `. diff --git a/src/main/resources/fxml/login-view.fxml b/src/main/resources/fxml/login-view.fxml index bb44b54..a782a37 100644 --- a/src/main/resources/fxml/login-view.fxml +++ b/src/main/resources/fxml/login-view.fxml @@ -11,14 +11,13 @@ - + -