diff --git a/.gitignore b/.gitignore index 45255b6..11f249a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.class -target/* \ No newline at end of file +target/* +target/classes/com/example/App.class diff --git a/README.md b/README.md index fc00b7a..a07ac91 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,34 @@ PKM/ │ │ ├── App.java # 主应用程序入口 │ │ ├── cli/ │ │ │ └── CommandParser.java # 命令解析器 -│ │ └── model/ -│ │ └── ExportFormat.java # 导出格式枚举 +│ │ ├── model/ +│ │ │ ├── Note.java # 笔记实体类 +│ │ │ └── ExportFormat.java # 导出格式枚举 +│ │ └── service/ +│ │ ├── storage/ +│ │ │ ├── StorageService.java # 存储服务接口 +│ │ │ ├── StorageException.java # 存储异常类 +│ │ │ └── JsonStorageService.java # JSON存储实现 +│ │ ├── export/ +│ │ │ ├── Exporter.java # 导出器接口 +│ │ │ ├── ExportException.java # 导出异常类 +│ │ │ ├── ExporterFactory.java # 导出器工厂 +│ │ │ ├── TxtExporter.java # TXT导出器 +│ │ │ └── JsonExporter.java # JSON导出器 +│ │ └── search/ +│ │ └── SearchService.java # 搜索服务 │ └── test/ │ └── java/ │ └── com/ │ └── example/ -│ └── AppTest.java # 测试类 +│ ├── AppTest.java # 应用测试类 +│ └── test/ +│ └── EntityTest.java # 实体类测试 ├── target/classes/ # 编译输出目录 ├── pkm.bat # PKM快捷启动脚本 ├── run.bat # 运行脚本 ├── clean.bat # 清理脚本 +├── notes.txt # 笔记数据文件 └── README.md # 项目说明文件 ``` @@ -131,14 +148,28 @@ pkm.bat help ## 开发状态 -当前版本实现了完整的命令行界面和命令解析功能。后续开发计划: +当前版本已完成以下功能: -- [ ] 笔记存储和管理 -- [ ] 标签系统 -- [ ] 搜索功能 -- [ ] 导出功能 +### ✅ 已实现 +- [x] 完整的命令行界面和命令解析功能 +- [x] Note实体类(包含完整的属性和方法) +- [x] 存储服务接口和实现(JsonStorageService) +- [x] 导出功能(TXT和JSON格式,使用工厂模式) +- [x] 搜索服务(关键词、标签、模糊搜索等) +- [x] 异常处理机制 +- [x] 单元测试和验证 + +### 🚧 开发中 +- [ ] 将命令解析器与业务服务集成 +- [ ] 笔记控制器和标签控制器 - [ ] 配置管理 +### 📋 待开发 +- [ ] 批处理操作 +- [ ] 数据导入功能 +- [ ] 更丰富的搜索选项 +- [ ] 统计功能(笔记数量、标签云) + ## 技术栈 - Java 17 - Maven 3.x diff --git a/src/main/java/com/example/model/Note.java b/src/main/java/com/example/model/Note.java new file mode 100644 index 0000000..bc6e421 --- /dev/null +++ b/src/main/java/com/example/model/Note.java @@ -0,0 +1,227 @@ +package com.example.model; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * 笔记实体类 + * 表示系统中的一条笔记记录 + */ +public class Note { + private String id; // UUID + private String title; // 笔记标题 + private String content; // 笔记内容 + private List tags; // 标签列表 + private LocalDateTime createdAt; // 创建时间 + private LocalDateTime updatedAt; // 更新时间 + + /** + * 默认构造方法 + */ + public Note() { + this.id = UUID.randomUUID().toString(); + this.tags = new ArrayList<>(); + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 带参数的构造方法 + */ + public Note(String title, String content) { + this(); + this.title = title; + this.content = content; + } + + /** + * 完整构造方法 + */ + public Note(String id, String title, String content, List tags, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.title = title; + this.content = content; + this.tags = tags != null ? new ArrayList<>(tags) : new ArrayList<>(); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + // Getter和Setter方法 + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + this.updatedAt = LocalDateTime.now(); + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + this.updatedAt = LocalDateTime.now(); + } + + public List getTags() { + return new ArrayList<>(tags); + } + + public void setTags(List tags) { + this.tags = tags != null ? new ArrayList<>(tags) : new ArrayList<>(); + this.updatedAt = LocalDateTime.now(); + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + // 标签操作方法 + /** + * 添加标签 + */ + public void addTag(String tag) { + if (tag != null && !tag.trim().isEmpty() && !tags.contains(tag.trim())) { + tags.add(tag.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 移除标签 + */ + public boolean removeTag(String tag) { + if (tag != null && tags.remove(tag.trim())) { + this.updatedAt = LocalDateTime.now(); + return true; + } + return false; + } + + /** + * 检查是否包含指定标签 + */ + public boolean hasTag(String tag) { + return tag != null && tags.contains(tag.trim()); + } + + /** + * 获取标签数量 + */ + public int getTagCount() { + return tags.size(); + } + + /** + * 更新笔记内容 + */ + public void update(String newTitle, String newContent) { + if (newTitle != null) { + this.title = newTitle; + } + if (newContent != null) { + this.content = newContent; + } + this.updatedAt = LocalDateTime.now(); + } + + /** + * 检查笔记是否包含关键词 + */ + public boolean containsKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return false; + } + + String lowerKeyword = keyword.toLowerCase(); + return (title != null && title.toLowerCase().contains(lowerKeyword)) || + (content != null && content.toLowerCase().contains(lowerKeyword)); + } + + /** + * 获取笔记摘要(前100个字符) + */ + public String getSummary() { + if (content == null || content.isEmpty()) { + return ""; + } + + String cleanContent = content.replaceAll("\\s+", " ").trim(); + if (cleanContent.length() <= 100) { + return cleanContent; + } + + return cleanContent.substring(0, 97) + "..."; + } + + /** + * 复制笔记(深拷贝) + */ + public Note copy() { + return new Note( + UUID.randomUUID().toString(), // 新的ID + this.title, + this.content, + new ArrayList<>(this.tags), + LocalDateTime.now(), // 新的创建时间 + LocalDateTime.now() // 新的更新时间 + ); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + Note note = (Note) obj; + return Objects.equals(id, note.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return String.format("Note{id='%s', title='%s', tags=%s, created=%s, updated=%s}", + id, title, tags, createdAt, updatedAt); + } + + /** + * 获取格式化的显示字符串 + */ + public String getDisplayString() { + return String.format("[%s] %s (%s) %s", + id.substring(0, 8), + title, + createdAt.toLocalDate(), + tags); + } +} diff --git a/src/main/java/com/example/service/export/ExportException.java b/src/main/java/com/example/service/export/ExportException.java new file mode 100644 index 0000000..b8ec03d --- /dev/null +++ b/src/main/java/com/example/service/export/ExportException.java @@ -0,0 +1,40 @@ +package com.example.service.export; + +/** + * 导出操作异常类 + * 当导出操作失败时抛出此异常 + */ +public class ExportException extends Exception { + + /** + * 默认构造方法 + */ + public ExportException() { + super(); + } + + /** + * 带消息的构造方法 + * @param message 异常消息 + */ + public ExportException(String message) { + super(message); + } + + /** + * 带消息和原因的构造方法 + * @param message 异常消息 + * @param cause 异常原因 + */ + public ExportException(String message, Throwable cause) { + super(message, cause); + } + + /** + * 带原因的构造方法 + * @param cause 异常原因 + */ + public ExportException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/example/service/export/Exporter.java b/src/main/java/com/example/service/export/Exporter.java new file mode 100644 index 0000000..2001fd3 --- /dev/null +++ b/src/main/java/com/example/service/export/Exporter.java @@ -0,0 +1,39 @@ +package com.example.service.export; + +import com.example.model.Note; +import java.util.List; + +/** + * 导出器接口 + * 定义笔记导出功能 + */ +public interface Exporter { + + /** + * 导出单个笔记 + * @param note 要导出的笔记 + * @param filePath 导出文件路径 + * @throws ExportException 当导出失败时抛出 + */ + void export(Note note, String filePath) throws ExportException; + + /** + * 导出多个笔记 + * @param notes 要导出的笔记列表 + * @param filePath 导出文件路径 + * @throws ExportException 当导出失败时抛出 + */ + void exportAll(List notes, String filePath) throws ExportException; + + /** + * 获取支持的文件扩展名 + * @return 文件扩展名(如 ".txt", ".json") + */ + String getFileExtension(); + + /** + * 获取导出格式描述 + * @return 格式描述 + */ + String getFormatDescription(); +} diff --git a/src/main/java/com/example/service/export/ExporterFactory.java b/src/main/java/com/example/service/export/ExporterFactory.java new file mode 100644 index 0000000..4cb7dd4 --- /dev/null +++ b/src/main/java/com/example/service/export/ExporterFactory.java @@ -0,0 +1,83 @@ +package com.example.service.export; + +import com.example.model.ExportFormat; + +/** + * 导出器工厂类 + * 使用工厂模式创建不同格式的导出器 + */ +public class ExporterFactory { + + /** + * 根据导出格式创建对应的导出器 + * @param format 导出格式 + * @return 对应的导出器实例 + * @throws IllegalArgumentException 当格式不支持时抛出 + */ + public static Exporter createExporter(ExportFormat format) { + if (format == null) { + throw new IllegalArgumentException("导出格式不能为空"); + } + + return switch (format) { + case TXT -> new TxtExporter(); + case JSON -> new JsonExporter(); + case PDF -> throw new IllegalArgumentException("PDF导出功能暂未实现"); + default -> throw new IllegalArgumentException("不支持的导出格式: " + format); + }; + } + + /** + * 根据格式名称字符串创建导出器 + * @param formatName 格式名称(如 "txt", "json") + * @return 对应的导出器实例 + * @throws IllegalArgumentException 当格式不支持时抛出 + */ + public static Exporter createExporter(String formatName) { + if (formatName == null || formatName.trim().isEmpty()) { + throw new IllegalArgumentException("导出格式名称不能为空"); + } + + try { + ExportFormat format = ExportFormat.valueOf(formatName.toUpperCase()); + return createExporter(format); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("不支持的导出格式: " + formatName); + } + } + + /** + * 获取所有支持的导出格式 + * @return 支持的导出格式数组 + */ + public static ExportFormat[] getSupportedFormats() { + return new ExportFormat[]{ExportFormat.TXT, ExportFormat.JSON}; + } + + /** + * 检查格式是否支持 + * @param format 要检查的格式 + * @return 是否支持 + */ + public static boolean isFormatSupported(ExportFormat format) { + return format == ExportFormat.TXT || format == ExportFormat.JSON; + } + + /** + * 检查格式名称是否支持 + * @param formatName 格式名称 + * @return 是否支持 + */ + public static boolean isFormatSupported(String formatName) { + if (formatName == null || formatName.trim().isEmpty()) { + return false; + } + + try { + ExportFormat format = ExportFormat.valueOf(formatName.toUpperCase()); + return isFormatSupported(format); + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/com/example/service/export/JsonExporter.java b/src/main/java/com/example/service/export/JsonExporter.java new file mode 100644 index 0000000..08fddff --- /dev/null +++ b/src/main/java/com/example/service/export/JsonExporter.java @@ -0,0 +1,128 @@ +package com.example.service.export; + +import com.example.model.Note; + +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * JSON格式导出器 + * 将笔记导出为JSON格式(简化实现,不依赖外部库) + */ +public class JsonExporter implements Exporter { + + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + @Override + public void export(Note note, String filePath) throws ExportException { + if (note == null) { + throw new ExportException("笔记不能为空"); + } + + if (filePath == null || filePath.trim().isEmpty()) { + throw new ExportException("文件路径不能为空"); + } + + try (PrintWriter writer = new PrintWriter(new FileWriter(filePath))) { + writer.print(noteToJson(note)); + } catch (IOException e) { + throw new ExportException("导出JSON文件失败: " + e.getMessage(), e); + } + } + + @Override + public void exportAll(List notes, String filePath) throws ExportException { + if (notes == null) { + throw new ExportException("笔记列表不能为空"); + } + + if (filePath == null || filePath.trim().isEmpty()) { + throw new ExportException("文件路径不能为空"); + } + + try (PrintWriter writer = new PrintWriter(new FileWriter(filePath))) { + writer.println("["); + + for (int i = 0; i < notes.size(); i++) { + Note note = notes.get(i); + String json = noteToJson(note); + + // 缩进JSON内容 + String[] lines = json.split("\n"); + for (String line : lines) { + writer.println(" " + line); + } + + // 如果不是最后一个元素,添加逗号 + if (i < notes.size() - 1) { + writer.println(","); + } else { + writer.println(); + } + } + + writer.println("]"); + } catch (IOException e) { + throw new ExportException("导出JSON文件失败: " + e.getMessage(), e); + } + } + + @Override + public String getFileExtension() { + return ".json"; + } + + @Override + public String getFormatDescription() { + return "JSON格式"; + } + + /** + * 将笔记对象转换为JSON字符串 + */ + private String noteToJson(Note note) { + StringBuilder json = new StringBuilder(); + json.append("{\n"); + json.append(" \"id\": \"").append(escapeJson(note.getId())).append("\",\n"); + json.append(" \"title\": \"").append(escapeJson(note.getTitle())).append("\",\n"); + json.append(" \"content\": \"").append(escapeJson(note.getContent())).append("\",\n"); + + // 标签数组 + json.append(" \"tags\": ["); + List tags = note.getTags(); + for (int i = 0; i < tags.size(); i++) { + json.append("\"").append(escapeJson(tags.get(i))).append("\""); + if (i < tags.size() - 1) { + json.append(", "); + } + } + json.append("],\n"); + + json.append(" \"createdAt\": \"").append(note.getCreatedAt().format(DATE_FORMATTER)).append("\",\n"); + json.append(" \"updatedAt\": \"").append(note.getUpdatedAt().format(DATE_FORMATTER)).append("\"\n"); + json.append("}"); + + return json.toString(); + } + + /** + * 转义JSON字符串中的特殊字符 + */ + private String escapeJson(String input) { + if (input == null) { + return ""; + } + + return input.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\b", "\\b") + .replace("\f", "\\f"); + } +} diff --git a/src/main/java/com/example/service/export/TxtExporter.java b/src/main/java/com/example/service/export/TxtExporter.java new file mode 100644 index 0000000..3e64b81 --- /dev/null +++ b/src/main/java/com/example/service/export/TxtExporter.java @@ -0,0 +1,117 @@ +package com.example.service.export; + +import com.example.model.Note; + +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * TXT格式导出器 + * 将笔记导出为纯文本格式 + */ +public class TxtExporter implements Exporter { + + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public void export(Note note, String filePath) throws ExportException { + if (note == null) { + throw new ExportException("笔记不能为空"); + } + + if (filePath == null || filePath.trim().isEmpty()) { + throw new ExportException("文件路径不能为空"); + } + + try (PrintWriter writer = new PrintWriter(new FileWriter(filePath))) { + writeNoteToTxt(writer, note); + } catch (IOException e) { + throw new ExportException("导出TXT文件失败: " + e.getMessage(), e); + } + } + + @Override + public void exportAll(List notes, String filePath) throws ExportException { + if (notes == null) { + throw new ExportException("笔记列表不能为空"); + } + + if (filePath == null || filePath.trim().isEmpty()) { + throw new ExportException("文件路径不能为空"); + } + + try (PrintWriter writer = new PrintWriter(new FileWriter(filePath))) { + writer.println("========================================"); + writer.println(" 个人知识管理系统"); + writer.println(" 笔记导出文件"); + writer.println("========================================"); + writer.println("导出时间: " + java.time.LocalDateTime.now().format(DATE_FORMATTER)); + writer.println("笔记总数: " + notes.size()); + writer.println("========================================"); + writer.println(); + + for (int i = 0; i < notes.size(); i++) { + Note note = notes.get(i); + writer.println("========== 笔记 " + (i + 1) + " =========="); + writeNoteToTxt(writer, note); + writer.println(); + writer.println("----------------------------------------"); + writer.println(); + } + + writer.println("========================================"); + writer.println(" 导出完成"); + writer.println("========================================"); + } catch (IOException e) { + throw new ExportException("导出TXT文件失败: " + e.getMessage(), e); + } + } + + @Override + public String getFileExtension() { + return ".txt"; + } + + @Override + public String getFormatDescription() { + return "纯文本格式 (TXT)"; + } + + /** + * 将单个笔记写入TXT格式 + */ + private void writeNoteToTxt(PrintWriter writer, Note note) { + writer.println("笔记ID: " + note.getId()); + writer.println("标题: " + (note.getTitle() != null ? note.getTitle() : "无标题")); + writer.println("创建时间: " + note.getCreatedAt().format(DATE_FORMATTER)); + writer.println("更新时间: " + note.getUpdatedAt().format(DATE_FORMATTER)); + + // 标签 + if (note.getTags().isEmpty()) { + writer.println("标签: 无"); + } else { + writer.println("标签: " + String.join(", ", note.getTags())); + } + + writer.println(); + writer.println("内容:"); + writer.println("----------------------------------------"); + + String content = note.getContent(); + if (content == null || content.trim().isEmpty()) { + writer.println("(无内容)"); + } else { + // 确保内容正确换行 + String[] lines = content.split("\n"); + for (String line : lines) { + writer.println(line); + } + } + + writer.println("----------------------------------------"); + } +} diff --git a/src/main/java/com/example/service/search/SearchService.java b/src/main/java/com/example/service/search/SearchService.java new file mode 100644 index 0000000..5ab1e50 --- /dev/null +++ b/src/main/java/com/example/service/search/SearchService.java @@ -0,0 +1,192 @@ +package com.example.service.search; + +import com.example.model.Note; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 搜索服务类 + * 提供笔记搜索功能 + */ +public class SearchService { + + /** + * 根据关键词搜索笔记 + * @param notes 要搜索的笔记列表 + * @param keyword 搜索关键词 + * @return 匹配的笔记列表 + */ + public List searchByKeyword(List notes, String keyword) { + if (notes == null || keyword == null || keyword.trim().isEmpty()) { + return List.of(); + } + + String lowerKeyword = keyword.toLowerCase().trim(); + + return notes.stream() + .filter(note -> noteContainsKeyword(note, lowerKeyword)) + .collect(Collectors.toList()); + } + + /** + * 根据标签搜索笔记 + * @param notes 要搜索的笔记列表 + * @param tag 搜索标签 + * @return 匹配的笔记列表 + */ + public List searchByTag(List notes, String tag) { + if (notes == null || tag == null || tag.trim().isEmpty()) { + return List.of(); + } + + String trimmedTag = tag.trim(); + + return notes.stream() + .filter(note -> note.getTags().contains(trimmedTag)) + .collect(Collectors.toList()); + } + + /** + * 根据标题搜索笔记 + * @param notes 要搜索的笔记列表 + * @param title 搜索标题 + * @return 匹配的笔记列表 + */ + public List searchByTitle(List notes, String title) { + if (notes == null || title == null || title.trim().isEmpty()) { + return List.of(); + } + + String lowerTitle = title.toLowerCase().trim(); + + return notes.stream() + .filter(note -> note.getTitle() != null && + note.getTitle().toLowerCase().contains(lowerTitle)) + .collect(Collectors.toList()); + } + + /** + * 根据内容搜索笔记 + * @param notes 要搜索的笔记列表 + * @param content 搜索内容 + * @return 匹配的笔记列表 + */ + public List searchByContent(List notes, String content) { + if (notes == null || content == null || content.trim().isEmpty()) { + return List.of(); + } + + String lowerContent = content.toLowerCase().trim(); + + return notes.stream() + .filter(note -> note.getContent() != null && + note.getContent().toLowerCase().contains(lowerContent)) + .collect(Collectors.toList()); + } + + /** + * 组合搜索:同时匹配关键词和标签 + * @param notes 要搜索的笔记列表 + * @param keyword 关键词 + * @param tag 标签 + * @return 匹配的笔记列表 + */ + public List searchByKeywordAndTag(List notes, String keyword, String tag) { + if (notes == null) { + return List.of(); + } + + return notes.stream() + .filter(note -> { + boolean keywordMatch = keyword == null || keyword.trim().isEmpty() || + noteContainsKeyword(note, keyword.toLowerCase().trim()); + boolean tagMatch = tag == null || tag.trim().isEmpty() || + note.getTags().contains(tag.trim()); + return keywordMatch && tagMatch; + }) + .collect(Collectors.toList()); + } + + /** + * 模糊搜索:在标题、内容和标签中搜索 + * @param notes 要搜索的笔记列表 + * @param query 搜索查询 + * @return 匹配的笔记列表 + */ + public List fuzzySearch(List notes, String query) { + if (notes == null || query == null || query.trim().isEmpty()) { + return List.of(); + } + + String lowerQuery = query.toLowerCase().trim(); + + return notes.stream() + .filter(note -> { + // 检查标题 + if (note.getTitle() != null && + note.getTitle().toLowerCase().contains(lowerQuery)) { + return true; + } + + // 检查内容 + if (note.getContent() != null && + note.getContent().toLowerCase().contains(lowerQuery)) { + return true; + } + + // 检查标签 + return note.getTags().stream() + .anyMatch(tag -> tag.toLowerCase().contains(lowerQuery)); + }) + .collect(Collectors.toList()); + } + + /** + * 获取包含指定标签的所有笔记 + * @param notes 要搜索的笔记列表 + * @return 按标签分组的笔记 + */ + public List getNotesWithAnyTag(List notes) { + if (notes == null) { + return List.of(); + } + + return notes.stream() + .filter(note -> !note.getTags().isEmpty()) + .collect(Collectors.toList()); + } + + /** + * 获取没有标签的笔记 + * @param notes 要搜索的笔记列表 + * @return 没有标签的笔记列表 + */ + public List getNotesWithoutTags(List notes) { + if (notes == null) { + return List.of(); + } + + return notes.stream() + .filter(note -> note.getTags().isEmpty()) + .collect(Collectors.toList()); + } + + /** + * 检查笔记是否包含关键词(在标题或内容中) + */ + private boolean noteContainsKeyword(Note note, String lowerKeyword) { + // 检查标题 + if (note.getTitle() != null && + note.getTitle().toLowerCase().contains(lowerKeyword)) { + return true; + } + + // 检查内容 + if (note.getContent() != null && + note.getContent().toLowerCase().contains(lowerKeyword)) { + return true; + } + + return false; + } +} diff --git a/src/main/java/com/example/service/storage/JsonStorageService.java b/src/main/java/com/example/service/storage/JsonStorageService.java new file mode 100644 index 0000000..3f9d8bc --- /dev/null +++ b/src/main/java/com/example/service/storage/JsonStorageService.java @@ -0,0 +1,343 @@ +package com.example.service.storage; + +import com.example.model.Note; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * 简化的JSON文件存储服务实现 + * 使用简单的文本格式在本地文件系统中存储笔记数据 + */ +public class JsonStorageService implements StorageService { + + private static final String DEFAULT_FILE_PATH = "notes.txt"; + private static final String BACKUP_EXTENSION = ".backup"; + private static final String SEPARATOR = "===NOTE_SEPARATOR==="; + + private final String filePath; + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + /** + * 默认构造方法,使用默认文件路径 + */ + public JsonStorageService() { + this(DEFAULT_FILE_PATH); + } + + /** + * 带文件路径的构造方法 + * @param filePath 数据文件路径 + */ + public JsonStorageService(String filePath) { + this.filePath = filePath; + + // 确保数据目录存在 + createDataDirectoryIfNotExists(); + + // 如果文件不存在,创建空文件 + if (!fileExists()) { + try { + saveAll(new ArrayList<>()); + } catch (StorageException e) { + System.err.println("初始化存储文件失败: " + e.getMessage()); + } + } + } + + @Override + public void saveNote(Note note) throws StorageException { + if (note == null) { + throw new StorageException("笔记不能为空"); + } + + lock.writeLock().lock(); + try { + List notes = findAllNotesInternal(); + + // 查找是否已存在相同ID的笔记 + boolean found = false; + for (int i = 0; i < notes.size(); i++) { + if (notes.get(i).getId().equals(note.getId())) { + notes.set(i, note); + found = true; + break; + } + } + + // 如果不存在,添加新笔记 + if (!found) { + notes.add(note); + } + + saveAllInternal(notes); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public boolean deleteNote(String id) throws StorageException { + if (id == null || id.trim().isEmpty()) { + throw new StorageException("笔记ID不能为空"); + } + + lock.writeLock().lock(); + try { + List notes = findAllNotesInternal(); + boolean removed = notes.removeIf(note -> note.getId().equals(id)); + + if (removed) { + saveAllInternal(notes); + } + + return removed; + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public Optional findNoteById(String id) throws StorageException { + if (id == null || id.trim().isEmpty()) { + return Optional.empty(); + } + + lock.readLock().lock(); + try { + List notes = findAllNotesInternal(); + return notes.stream() + .filter(note -> note.getId().equals(id)) + .findFirst(); + } finally { + lock.readLock().unlock(); + } + } + + @Override + public List findAllNotes() throws StorageException { + lock.readLock().lock(); + try { + return findAllNotesInternal(); + } finally { + lock.readLock().unlock(); + } + } + + @Override + public void saveAll(List notes) throws StorageException { + lock.writeLock().lock(); + try { + saveAllInternal(notes); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public boolean noteExists(String id) throws StorageException { + return findNoteById(id).isPresent(); + } + + @Override + public long getNotesCount() throws StorageException { + return findAllNotes().size(); + } + + @Override + public void clearAll() throws StorageException { + saveAll(new ArrayList<>()); + } + + @Override + public void backup(String backupPath) throws StorageException { + if (backupPath == null || backupPath.trim().isEmpty()) { + // 使用默认备份路径 + LocalDateTime now = LocalDateTime.now(); + String timestamp = now.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + backupPath = filePath + "." + timestamp + BACKUP_EXTENSION; + } + + try { + Path source = Paths.get(filePath); + Path target = Paths.get(backupPath); + + if (Files.exists(source)) { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } else { + throw new StorageException("源文件不存在: " + filePath); + } + } catch (IOException e) { + throw new StorageException("备份失败: " + e.getMessage(), e); + } + } + + @Override + public void restore(String backupPath) throws StorageException { + if (backupPath == null || backupPath.trim().isEmpty()) { + throw new StorageException("备份文件路径不能为空"); + } + + try { + Path source = Paths.get(backupPath); + Path target = Paths.get(filePath); + + if (Files.exists(source)) { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } else { + throw new StorageException("备份文件不存在: " + backupPath); + } + } catch (IOException e) { + throw new StorageException("恢复失败: " + e.getMessage(), e); + } + } + + /** + * 内部方法:读取所有笔记(不加锁) + */ + private List findAllNotesInternal() throws StorageException { + try { + File file = new File(filePath); + if (!file.exists() || file.length() == 0) { + return new ArrayList<>(); + } + + List notes = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String line; + Note currentNote = null; + StringBuilder contentBuilder = new StringBuilder(); + String field = null; + + while ((line = reader.readLine()) != null) { + if (line.equals(SEPARATOR)) { + if (currentNote != null) { + if ("CONTENT".equals(field)) { + currentNote.setContent(contentBuilder.toString().trim()); + } + notes.add(currentNote); + } + currentNote = new Note(); + contentBuilder.setLength(0); + field = null; + } else if (line.startsWith("ID:")) { + if (currentNote != null) { + currentNote.setId(line.substring(3).trim()); + } + field = "ID"; + } else if (line.startsWith("TITLE:")) { + if (currentNote != null) { + currentNote.setTitle(line.substring(6).trim()); + } + field = "TITLE"; + } else if (line.startsWith("TAGS:")) { + if (currentNote != null) { + String tagsStr = line.substring(5).trim(); + if (!tagsStr.isEmpty()) { + String[] tagArray = tagsStr.split(","); + for (String tag : tagArray) { + currentNote.addTag(tag.trim()); + } + } + } + field = "TAGS"; + } else if (line.startsWith("CREATED:")) { + if (currentNote != null) { + try { + currentNote.setCreatedAt(LocalDateTime.parse(line.substring(8).trim())); + } catch (Exception e) { + currentNote.setCreatedAt(LocalDateTime.now()); + } + } + field = "CREATED"; + } else if (line.startsWith("UPDATED:")) { + if (currentNote != null) { + try { + currentNote.setUpdatedAt(LocalDateTime.parse(line.substring(8).trim())); + } catch (Exception e) { + currentNote.setUpdatedAt(LocalDateTime.now()); + } + } + field = "UPDATED"; + } else if (line.startsWith("CONTENT:")) { + field = "CONTENT"; + contentBuilder.append(line.substring(8)); + if (line.length() > 8) { + contentBuilder.append("\n"); + } + } else if ("CONTENT".equals(field)) { + contentBuilder.append(line).append("\n"); + } + } + + // 处理最后一个笔记 + if (currentNote != null) { + if ("CONTENT".equals(field)) { + currentNote.setContent(contentBuilder.toString().trim()); + } + notes.add(currentNote); + } + } + + return notes; + } catch (IOException e) { + throw new StorageException("读取笔记数据失败: " + e.getMessage(), e); + } + } + + /** + * 内部方法:保存所有笔记(不加锁) + */ + private void saveAllInternal(List notes) throws StorageException { + try (PrintWriter writer = new PrintWriter(new FileWriter(filePath))) { + if (notes != null) { + for (Note note : notes) { + writer.println("ID:" + note.getId()); + writer.println("TITLE:" + (note.getTitle() != null ? note.getTitle() : "")); + writer.println("TAGS:" + String.join(",", note.getTags())); + writer.println("CREATED:" + note.getCreatedAt()); + writer.println("UPDATED:" + note.getUpdatedAt()); + writer.println("CONTENT:" + (note.getContent() != null ? note.getContent() : "")); + writer.println(SEPARATOR); + } + } + } catch (IOException e) { + throw new StorageException("保存笔记数据失败: " + e.getMessage(), e); + } + } + + /** + * 检查文件是否存在 + */ + private boolean fileExists() { + return new File(filePath).exists(); + } + + /** + * 创建数据目录(如果不存在) + */ + private void createDataDirectoryIfNotExists() { + File file = new File(filePath); + File parentDir = file.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + } + + /** + * 获取文件路径 + */ + public String getFilePath() { + return filePath; + } +} diff --git a/src/main/java/com/example/service/storage/StorageException.java b/src/main/java/com/example/service/storage/StorageException.java new file mode 100644 index 0000000..c6485c0 --- /dev/null +++ b/src/main/java/com/example/service/storage/StorageException.java @@ -0,0 +1,40 @@ +package com.example.service.storage; + +/** + * 存储操作异常类 + * 当存储操作失败时抛出此异常 + */ +public class StorageException extends Exception { + + /** + * 默认构造方法 + */ + public StorageException() { + super(); + } + + /** + * 带消息的构造方法 + * @param message 异常消息 + */ + public StorageException(String message) { + super(message); + } + + /** + * 带消息和原因的构造方法 + * @param message 异常消息 + * @param cause 异常原因 + */ + public StorageException(String message, Throwable cause) { + super(message, cause); + } + + /** + * 带原因的构造方法 + * @param cause 异常原因 + */ + public StorageException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/example/service/storage/StorageService.java b/src/main/java/com/example/service/storage/StorageService.java new file mode 100644 index 0000000..58c7c0f --- /dev/null +++ b/src/main/java/com/example/service/storage/StorageService.java @@ -0,0 +1,84 @@ +package com.example.service.storage; + +import com.example.model.Note; +import java.util.List; +import java.util.Optional; + +/** + * 存储服务接口 + * 定义笔记数据的持久化操作 + */ +public interface StorageService { + + /** + * 保存笔记 + * @param note 要保存的笔记 + * @throws StorageException 当保存失败时抛出 + */ + void saveNote(Note note) throws StorageException; + + /** + * 根据ID删除笔记 + * @param id 笔记ID + * @return 是否删除成功 + * @throws StorageException 当删除失败时抛出 + */ + boolean deleteNote(String id) throws StorageException; + + /** + * 根据ID查找笔记 + * @param id 笔记ID + * @return 找到的笔记,如果不存在则返回Optional.empty() + * @throws StorageException 当查找失败时抛出 + */ + Optional findNoteById(String id) throws StorageException; + + /** + * 查找所有笔记 + * @return 所有笔记的列表 + * @throws StorageException 当查找失败时抛出 + */ + List findAllNotes() throws StorageException; + + /** + * 批量保存笔记 + * @param notes 要保存的笔记列表 + * @throws StorageException 当保存失败时抛出 + */ + void saveAll(List notes) throws StorageException; + + /** + * 检查笔记是否存在 + * @param id 笔记ID + * @return 笔记是否存在 + * @throws StorageException 当检查失败时抛出 + */ + boolean noteExists(String id) throws StorageException; + + /** + * 获取笔记总数 + * @return 笔记总数 + * @throws StorageException 当获取失败时抛出 + */ + long getNotesCount() throws StorageException; + + /** + * 清空所有笔记 + * @throws StorageException 当清空失败时抛出 + */ + void clearAll() throws StorageException; + + /** + * 备份数据 + * @param backupPath 备份文件路径 + * @throws StorageException 当备份失败时抛出 + */ + void backup(String backupPath) throws StorageException; + + /** + * 从备份恢复数据 + * @param backupPath 备份文件路径 + * @throws StorageException 当恢复失败时抛出 + */ + void restore(String backupPath) throws StorageException; +} diff --git a/src/test/java/com/example/test/EntityTest.java b/src/test/java/com/example/test/EntityTest.java new file mode 100644 index 0000000..d8a8f21 --- /dev/null +++ b/src/test/java/com/example/test/EntityTest.java @@ -0,0 +1,158 @@ +package com.example.test; + +import com.example.model.Note; +import com.example.model.ExportFormat; +import com.example.service.storage.JsonStorageService; +import com.example.service.storage.StorageException; +import com.example.service.export.ExporterFactory; +import com.example.service.export.Exporter; +import com.example.service.export.ExportException; +import com.example.service.search.SearchService; + +import java.util.List; + +/** + * 实体类测试程序 + * 验证各个实体类和服务类的基本功能 + */ +public class EntityTest { + + public static void main(String[] args) { + System.out.println("========================================"); + System.out.println(" 个人知识管理系统实体类测试"); + System.out.println("========================================\n"); + + try { + testNoteEntity(); + testStorageService(); + testExportService(); + testSearchService(); + + System.out.println("✅ 所有测试通过!"); + } catch (Exception e) { + System.err.println("❌ 测试失败: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * 测试Note实体类 + */ + private static void testNoteEntity() { + System.out.println("🧪 测试Note实体类..."); + + // 创建笔记 + Note note = new Note("Java学习笔记", "面向对象编程的三大特性:封装、继承、多态"); + note.addTag("编程"); + note.addTag("Java"); + note.addTag("学习"); + + System.out.println(" 创建笔记: " + note.getTitle()); + System.out.println(" 笔记ID: " + note.getId()); + System.out.println(" 标签数量: " + note.getTagCount()); + System.out.println(" 包含关键词'面向对象': " + note.containsKeyword("面向对象")); + System.out.println(" 摘要: " + note.getSummary()); + + // 测试标签操作 + note.removeTag("学习"); + System.out.println(" 移除标签后数量: " + note.getTagCount()); + + System.out.println("✅ Note实体类测试通过\n"); + } + + /** + * 测试存储服务 + */ + private static void testStorageService() throws StorageException { + System.out.println("🧪 测试存储服务..."); + + JsonStorageService storage = new JsonStorageService("test_notes.txt"); + + // 清空现有数据 + storage.clearAll(); + + // 创建测试笔记 + Note note1 = new Note("设计模式", "单例模式确保一个类只有一个实例"); + note1.addTag("编程"); + note1.addTag("设计模式"); + + Note note2 = new Note("数据结构", "栈是后进先出的数据结构"); + note2.addTag("编程"); + note2.addTag("算法"); + + // 保存笔记 + storage.saveNote(note1); + storage.saveNote(note2); + + System.out.println(" 保存了2条笔记"); + System.out.println(" 笔记总数: " + storage.getNotesCount()); + + // 查找笔记 + List allNotes = storage.findAllNotes(); + System.out.println(" 读取到 " + allNotes.size() + " 条笔记"); + + // 查找单个笔记 + var foundNote = storage.findNoteById(note1.getId()); + System.out.println(" 根据ID查找笔记: " + (foundNote.isPresent() ? "成功" : "失败")); + + System.out.println("✅ 存储服务测试通过\n"); + } + + /** + * 测试导出服务 + */ + private static void testExportService() throws StorageException, ExportException { + System.out.println("🧪 测试导出服务..."); + + // 创建测试笔记 + Note note = new Note("测试导出", "这是一个用于测试导出功能的笔记"); + note.addTag("测试"); + note.addTag("导出"); + + // 测试TXT导出 + Exporter txtExporter = ExporterFactory.createExporter(ExportFormat.TXT); + txtExporter.export(note, "test_export.txt"); + System.out.println(" TXT导出: " + txtExporter.getFormatDescription()); + + // 测试JSON导出 + Exporter jsonExporter = ExporterFactory.createExporter(ExportFormat.JSON); + jsonExporter.export(note, "test_export.json"); + System.out.println(" JSON导出: " + jsonExporter.getFormatDescription()); + + // 测试工厂方法 + System.out.println(" 支持的格式: " + ExporterFactory.getSupportedFormats().length + " 种"); + System.out.println(" TXT格式支持: " + ExporterFactory.isFormatSupported("txt")); + + System.out.println("✅ 导出服务测试通过\n"); + } + + /** + * 测试搜索服务 + */ + private static void testSearchService() throws StorageException { + System.out.println("🧪 测试搜索服务..."); + + JsonStorageService storage = new JsonStorageService("test_notes.txt"); + SearchService searchService = new SearchService(); + + List allNotes = storage.findAllNotes(); + + // 按关键词搜索 + List keywordResults = searchService.searchByKeyword(allNotes, "设计"); + System.out.println(" 关键词'设计'搜索结果: " + keywordResults.size() + " 条"); + + // 按标签搜索 + List tagResults = searchService.searchByTag(allNotes, "编程"); + System.out.println(" 标签'编程'搜索结果: " + tagResults.size() + " 条"); + + // 模糊搜索 + List fuzzyResults = searchService.fuzzySearch(allNotes, "模式"); + System.out.println(" 模糊搜索'模式'结果: " + fuzzyResults.size() + " 条"); + + // 无标签笔记 + List untaggedNotes = searchService.getNotesWithoutTags(allNotes); + System.out.println(" 无标签笔记: " + untaggedNotes.size() + " 条"); + + System.out.println("✅ 搜索服务测试通过\n"); + } +} diff --git a/test_export.json b/test_export.json new file mode 100644 index 0000000..93ab763 --- /dev/null +++ b/test_export.json @@ -0,0 +1,8 @@ +{ + "id": "35e2aab6-dba7-4b78-ae4d-cb123d44059b", + "title": "测试导出", + "content": "这是一个用于测试导出功能的笔记", + "tags": ["测试", "导出"], + "createdAt": "2025-07-13T23:13:51", + "updatedAt": "2025-07-13T23:13:51" +} \ No newline at end of file diff --git a/test_export.txt b/test_export.txt new file mode 100644 index 0000000..ba66225 --- /dev/null +++ b/test_export.txt @@ -0,0 +1,10 @@ +笔记ID: 35e2aab6-dba7-4b78-ae4d-cb123d44059b +标题: 测试导出 +创建时间: 2025-07-13 23:13:51 +更新时间: 2025-07-13 23:13:51 +标签: 测试, 导出 + +内容: +---------------------------------------- +这是一个用于测试导出功能的笔记 +---------------------------------------- diff --git a/test_notes.txt b/test_notes.txt new file mode 100644 index 0000000..320074c --- /dev/null +++ b/test_notes.txt @@ -0,0 +1,14 @@ +ID:960b330f-f7d5-4c31-b0fe-ce48d81ae4ed +TITLE: +TAGS: +CREATED:2025-07-13T23:13:51.844655200 +UPDATED:2025-07-13T23:13:51.844655200 +CONTENT: +===NOTE_SEPARATOR=== +ID:60cf8fb5-05af-4501-bce2-2b2da1cd7219 +TITLE:数据结构 +TAGS:编程,算法 +CREATED:2025-07-13T23:13:51.836669200 +UPDATED:2025-07-13T23:13:51.836669200 +CONTENT:栈是后进先出的数据结构 +===NOTE_SEPARATOR===