commit a6b2905ad25c65ea7f57eba1e17f1491a7968436 Author: slhafzjw Date: Sun Oct 5 00:30:37 2025 +0800 代码片段管理工具:rofi前端+Java守护进程 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ad0f1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/CodeSnippetRofi/.idea/ +/CodeSnippetRofi/.venv/ +/CodeSnippetDaemon/.idea/ +/CodeSnippetDaemon/.mvn/ +/CodeSnippetDaemon/target/ +/CodeSnippetDaemon/dependency-reduced-pom.xml +/CodeSnippetDaemon/pom.xml +/.idea/ diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/App.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/App.java new file mode 100644 index 0000000..ea476a9 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/App.java @@ -0,0 +1,98 @@ +package work.slhaf.snippet; + +import cn.hutool.core.bean.BeanUtil; +import lombok.extern.slf4j.Slf4j; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.entity.db.Index; +import work.slhaf.snippet.entity.file.RebuildEntity; +import work.slhaf.snippet.gateway.CodeSnippetSocketServer; +import work.slhaf.snippet.service.IndexManager; +import work.slhaf.snippet.service.SnippetManager; + +import java.io.File; +import java.io.IOException; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +public class App{ + + void main() throws IOException, SQLException { + beforeLaunch(); + CodeSnippetSocketServer server = new CodeSnippetSocketServer(); + server.launch(); + } + + private void beforeLaunch() throws SQLException, IOException { + log.info("启动前检查环境"); + //检查对应目录是否存在并创建 + checkEnv(); + checkDB(); + } + + private void checkDB() throws SQLException, IOException { + log.info("检查索引数据库"); + //获取当前数据路径下的文件目录,查看文件路径、sha值与索引数据库是否一致,如果不一致需要重建 + IndexManager indexManager = IndexManager.getInstance(); + SnippetManager snippetManager = new SnippetManager(); + + File data = new File(System.getenv(Constant.Property.DIR)); + File[] files = data.listFiles(); + if (files == null || files.length == 0) { + if (!indexManager.isEmpty()) { + log.info("数据库为空,但文件目录不为空,删除数据库"); + indexManager.resetIndex(); + } + return; + } + + HashMap sha2PathDB = indexManager.getIndexStatus(); + HashMap sha2PathMD = snippetManager.getFileStatus(); + if (!sha2PathMD.equals(sha2PathDB)) { + log.info("数据库与文件目录不一致,重建索引数据库"); + List snippets = snippetManager.listAll(); + Set indexSet = snippets.stream().map(entity -> { + Index index = new Index(); + BeanUtil.copyProperties(entity, index); + return index; + }) + .collect(Collectors.toSet()); + indexManager.rebuildIndex(indexSet); + } + log.info("索引数据库检查通过"); + } + + private void checkEnv() { + log.info("检查环境变量"); + getEnvOrThrow(Constant.Property.PORT); + getEnvOrThrow(Constant.Property.API_KEY); + getEnvOrThrow(Constant.Property.BASE_URL); + getEnvOrThrow(Constant.Property.MODEL); + checkDir(Constant.Property.DIR); + checkDir(Constant.Property.CONF); + log.info("环境变量检查通过"); + } + + private void checkDir(String dir) { + File file = new File(getEnvOrThrow(dir)); + if (file.exists()) return; + + boolean ok = file.mkdirs(); + if (!ok) { + throw new RuntimeException("创建目录失败: " + dir); + } else { + log.info("创建目录成功: {}", dir); + } + + } + + private String getEnvOrThrow(String key) { + String value = System.getenv(key); + if (value == null) throw new RuntimeException("未找到环境变量: " + key); + return value; + } + +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/Constant.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/Constant.java new file mode 100644 index 0000000..9cd27f2 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/Constant.java @@ -0,0 +1,22 @@ +package work.slhaf.snippet.common; + +public class Constant { + + public static class Property { + private static final String BASE = "CODE_SNIPPET_"; + public static final String DIR = BASE + "DIR"; + public static final String CONF = BASE + "CONF"; + public static final String PORT = BASE + "PORT"; + public static final String API_KEY = BASE + "API_KEY"; + public static final String BASE_URL = BASE + "BASE_URL"; + public static final String MODEL = BASE + "MODEL"; + } + + public enum Action { + LIST, EDIT, ADD, DELETE + } + + public enum Status { + SUCCESS, FAILED + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/SnippetUtil.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/SnippetUtil.java new file mode 100644 index 0000000..0c0303e --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/SnippetUtil.java @@ -0,0 +1,13 @@ +package work.slhaf.snippet.common; + +public class SnippetUtil { + public static String extractJson(String jsonStr) { + jsonStr = jsonStr.replace("“", "\"").replace("”", "\""); + int start = jsonStr.indexOf("{"); + int end = jsonStr.lastIndexOf("}"); + if (start != -1 && end != -1 && start < end) { + return jsonStr.substring(start, end + 1); + } + return jsonStr; + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/ChatClient.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/ChatClient.java new file mode 100644 index 0000000..ff9ef37 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/ChatClient.java @@ -0,0 +1,70 @@ +package work.slhaf.snippet.common.chat; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONUtil; +import lombok.Data; +import lombok.NoArgsConstructor; +import work.slhaf.snippet.common.chat.constant.ChatConstant; +import work.slhaf.snippet.common.chat.pojo.ChatBody; +import work.slhaf.snippet.common.chat.pojo.ChatResponse; +import work.slhaf.snippet.common.chat.pojo.Message; +import work.slhaf.snippet.common.chat.pojo.PrimaryChatResponse; + +import java.util.List; + +@Data +@NoArgsConstructor +public class ChatClient { + private String clientId; + + private String url; + private String apikey; + private String model; + + private double top_p; + private double temperature; + private int max_tokens; + + public ChatClient(String url, String apikey, String model) { + this.url = url; + this.apikey = apikey; + this.model = model; + } + + public ChatResponse runChat(List messages) { + HttpRequest request = HttpRequest.post(url); + request.header("Content-Type", "application/json"); + request.header("Authorization", "Bearer " + apikey); + + ChatBody body; + if (top_p > 0) { + body = ChatBody.builder() + .model(model) + .messages(messages) + .top_p(top_p) + .temperature(temperature) + .max_tokens(max_tokens) + .build(); + } else { + body = ChatBody.builder() + .model(model) + .messages(messages) + .build(); + } + + HttpResponse response = request.body(JSONUtil.toJsonStr(body)).execute(); + ChatResponse finalResponse; + + PrimaryChatResponse primaryChatResponse = JSONUtil.toBean(response.body(), PrimaryChatResponse.class); + finalResponse = ChatResponse.builder() + .type(ChatConstant.Response.SUCCESS) + .message(primaryChatResponse.getChoices().get(0).getMessage().getContent()) + .usageBean(primaryChatResponse.getUsage()) + .build(); + + response.close(); + return finalResponse; + } + +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/constant/ChatConstant.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/constant/ChatConstant.java new file mode 100644 index 0000000..6516da1 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/constant/ChatConstant.java @@ -0,0 +1,15 @@ +package work.slhaf.snippet.common.chat.constant; + +public class ChatConstant { + + public static class Character { + public static final String USER = "user"; + public static final String SYSTEM = "system"; + public static final String ASSISTANT = "assistant"; + } + + public static class Response { + public static final String SUCCESS = "success"; + public static final String ERROR = "error"; + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/ChatBody.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/ChatBody.java new file mode 100644 index 0000000..ab18f37 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/ChatBody.java @@ -0,0 +1,25 @@ +package work.slhaf.snippet.common.chat.pojo; + +import lombok.*; + +import java.util.List; + +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChatBody { + @NonNull + private String model; + @NonNull + private List messages; + @Builder.Default + private double temperature = 1; + @Builder.Default + private double top_p = 1; + private boolean stream; + @Builder.Default + private int max_tokens = 1024; + private int presence_penalty; + private int frequency_penalty; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/ChatResponse.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/ChatResponse.java new file mode 100644 index 0000000..0de2e90 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/ChatResponse.java @@ -0,0 +1,16 @@ +package work.slhaf.snippet.common.chat.pojo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatResponse { + private String type; + private String message; + private PrimaryChatResponse.UsageBean usageBean; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/Message.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/Message.java new file mode 100644 index 0000000..7d6cc7e --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/Message.java @@ -0,0 +1,15 @@ +package work.slhaf.snippet.common.chat.pojo; + +import lombok.*; + +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Message { + + @NonNull + private String role; + @NonNull + private String content; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/PrimaryChatResponse.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/PrimaryChatResponse.java new file mode 100644 index 0000000..7e3ec4b --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/common/chat/pojo/PrimaryChatResponse.java @@ -0,0 +1,111 @@ +package work.slhaf.snippet.common.chat.pojo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class PrimaryChatResponse { + + /** + * id + */ + private String id; + /** + * object + */ + private String object; + /** + * created + */ + private int created; + /** + * model + */ + private String model; + /** + * choices + */ + private List choices; + /** + * usage + */ + private UsageBean usage; + /** + * system_fingerprint + */ + private String system_fingerprint; + + @Setter + @Getter + public static class UsageBean { + /** + * prompt_tokens + */ + private int prompt_tokens; + /** + * completion_tokens + */ + private int completion_tokens; + /** + * total_tokens + */ + private int total_tokens; + /** + * prompt_cache_hit_tokens + */ + private int prompt_cache_hit_tokens; + /** + * prompt_cache_miss_tokens + */ + private int prompt_cache_miss_tokens; + + @Override + public String toString() { + return "UsageBean{" + + "prompt_tokens=" + prompt_tokens + + ", completion_tokens=" + completion_tokens + + ", total_tokens=" + total_tokens + + ", prompt_cache_hit_tokens=" + prompt_cache_hit_tokens + + ", prompt_cache_miss_tokens=" + prompt_cache_miss_tokens + + '}'; + } + } + + @Setter + @Getter + public static class ChoicesBean { + /** + * index + */ + private int index; + /** + * message + */ + private MessageBean message; + /** + * logprobs + */ + private Object logprobs; + /** + * finish_reason + */ + private String finish_reason; + + @Setter + @Getter + public static class MessageBean { + /** + * role + */ + private String role; + /** + * content + */ + private String content; + + } + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/Snippet.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/Snippet.java new file mode 100644 index 0000000..4212dbe --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/Snippet.java @@ -0,0 +1,42 @@ +package work.slhaf.snippet.entity; + +import lombok.Data; + +/** + * 片段完整数据,存储至完整的markdown文件中 + * 而数据库条目只需要元信息即可 + */ +@Data +public class Snippet { + + private String language; + private String[] tags; + private String description; + + private String content; + + public String toMarkdown() { + StringBuilder sb = new StringBuilder(); + + // Add Snippet content + sb.append("## Snippet\n"); + sb.append("```").append(language).append("\n"); + sb.append(content).append("\n"); + sb.append("```\n\n"); + + // Add MetaData section + sb.append("## MetaData\n"); + sb.append("- Language\n"); + sb.append(" - ").append(language).append("\n"); + + sb.append("- Tags\n"); + for (String tag : tags) { + sb.append(" - ").append(tag).append("\n"); + } + + sb.append("- Description\n"); + sb.append(" - ").append(description).append("\n"); + + return sb.toString(); + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/SocketInputData.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/SocketInputData.java new file mode 100644 index 0000000..b3cdcbb --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/SocketInputData.java @@ -0,0 +1,10 @@ +package work.slhaf.snippet.entity; + +import lombok.Data; +import work.slhaf.snippet.common.Constant; + +@Data +public class SocketInputData { + private Constant.Action action; + private String data; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/SocketOutputData.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/SocketOutputData.java new file mode 100644 index 0000000..cb08328 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/SocketOutputData.java @@ -0,0 +1,12 @@ +package work.slhaf.snippet.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import work.slhaf.snippet.common.Constant; + +@Data +@AllArgsConstructor +public class SocketOutputData { + private Constant.Status status; + private String data; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/db/Index.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/db/Index.java new file mode 100644 index 0000000..8514838 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/db/Index.java @@ -0,0 +1,14 @@ +package work.slhaf.snippet.entity.db; + +import lombok.Data; + +@Data +public class Index { + private int id; + private String name; + private String language; + private String[] tags; + private String sha; + private String description; + private String path; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/AddEntity.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/AddEntity.java new file mode 100644 index 0000000..f636128 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/AddEntity.java @@ -0,0 +1,14 @@ +package work.slhaf.snippet.entity.file; + +import lombok.Data; + +@Data +public class AddEntity { + private String name; + private String language; + private String content; + + public boolean checkEmpty(){ + return name.isEmpty() || language.isEmpty() || content.isEmpty(); + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/EditEntity.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/EditEntity.java new file mode 100644 index 0000000..a9ff58d --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/EditEntity.java @@ -0,0 +1,20 @@ +package work.slhaf.snippet.entity.file; + +import lombok.Data; + +@Data +public class EditEntity { + private String id; + private String path; + + private String[] tags; + private String description; + + private String content; + + public boolean checkEmpty() { + return id.isEmpty() || + path.isEmpty() || + content.isEmpty(); + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/ListEntity.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/ListEntity.java new file mode 100644 index 0000000..11bf969 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/ListEntity.java @@ -0,0 +1,16 @@ +package work.slhaf.snippet.entity.file; + +import lombok.Data; + +@Data +public class ListEntity implements Comparable { + private String id; + private String path; + private String name; + private int score; + + @Override + public int compareTo(ListEntity listEntity) { + return Integer.compare(listEntity.getScore(), this.score); + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/MetaDataEntity.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/MetaDataEntity.java new file mode 100644 index 0000000..dfbaf61 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/MetaDataEntity.java @@ -0,0 +1,9 @@ +package work.slhaf.snippet.entity.file; + +import lombok.Data; + +@Data +public class MetaDataEntity { + private String description; + private String[] tags; +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/RebuildEntity.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/RebuildEntity.java new file mode 100644 index 0000000..d11eb6d --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/entity/file/RebuildEntity.java @@ -0,0 +1,15 @@ +package work.slhaf.snippet.entity.file; + +import lombok.Data; + +@Data +public class RebuildEntity { + private String name; + private String path; + private String sha; + + private String language; + private String[] tags; + private String description; + +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/gateway/ClientSocket.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/gateway/ClientSocket.java new file mode 100644 index 0000000..5a7c014 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/gateway/ClientSocket.java @@ -0,0 +1,54 @@ +package work.slhaf.snippet.gateway; + +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.entity.SocketInputData; +import work.slhaf.snippet.entity.SocketOutputData; +import work.slhaf.snippet.service.ActionHandler; + +import java.io.*; +import java.net.Socket; + +@Slf4j +public record ClientSocket(Socket socket, ActionHandler handler) implements Runnable { + + @Override + public void run() { + System.out.println("建立连接: " + socket.getRemoteSocketAddress()); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) { + + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + + SocketInputData inputData = JSONUtil.toBean(sb.toString(), SocketInputData.class); + SocketOutputData outputData = handler.handle(inputData); + + writer.write(JSONUtil.toJsonStr(outputData)); + writer.newLine(); + writer.flush(); + + } catch (Exception e) { + SocketOutputData outputData = new SocketOutputData(Constant.Status.FAILED, e.getLocalizedMessage()); + try { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); + writer.write(JSONUtil.toJsonStr(outputData)); + writer.newLine(); + writer.flush(); + } catch (IOException ex) { + log.error(ex.getLocalizedMessage()); + } + } finally { + try { + socket.close(); + } catch (IOException e) { + log.error(e.getLocalizedMessage()); + } + } + } + +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/gateway/CodeSnippetSocketServer.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/gateway/CodeSnippetSocketServer.java new file mode 100644 index 0000000..9cbe4c1 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/gateway/CodeSnippetSocketServer.java @@ -0,0 +1,34 @@ +package work.slhaf.snippet.gateway; + +import lombok.extern.slf4j.Slf4j; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.service.ActionHandler; + +import java.io.IOException; +import java.net.ServerSocket; +import java.sql.SQLException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +@Slf4j +public class CodeSnippetSocketServer { + + public void launch() throws IOException, SQLException { + int port = Integer.parseInt(System.getenv(Constant.Property.PORT)); + ActionHandler handler = new ActionHandler(); + try (ServerSocket socket = new ServerSocket(port); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + socket.setReuseAddress(true); + while (true) { + Future future = executor.submit(new ClientSocket(socket.accept(), handler)); + try{ + future.get(); + }catch (Exception e){ + log.error(e.getLocalizedMessage()); + } + } + } + } + +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/ActionHandler.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/ActionHandler.java new file mode 100644 index 0000000..bdb45b5 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/ActionHandler.java @@ -0,0 +1,120 @@ +package work.slhaf.snippet.service; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.entity.Snippet; +import work.slhaf.snippet.entity.SocketInputData; +import work.slhaf.snippet.entity.SocketOutputData; +import work.slhaf.snippet.entity.db.Index; +import work.slhaf.snippet.entity.file.AddEntity; +import work.slhaf.snippet.entity.file.EditEntity; +import work.slhaf.snippet.entity.file.ListEntity; +import work.slhaf.snippet.entity.file.MetaDataEntity; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.List; + +@Slf4j +public class ActionHandler { + + private final SnippetManager snippetManager = new SnippetManager(); + private final IndexManager indexManager = IndexManager.getInstance(); + private final MetaDataExtractor metaDataExtractor = new MetaDataExtractor(); + + public ActionHandler() throws SQLException { + } + + public SocketOutputData handle(SocketInputData inputData) { + log.info("收到请求: {}", inputData.getAction()); + String data = inputData.getData(); + try { + return switch (inputData.getAction()) { + case Constant.Action.ADD -> handleAdd(JSONUtil.toBean(data, AddEntity.class)); + case Constant.Action.EDIT -> handleEdit(JSONUtil.toBean(data, EditEntity.class)); + case Constant.Action.DELETE -> handleDelete(data); + case Constant.Action.LIST -> handleList(data); + }; + } catch (Exception e) { + log.error("处理请求失败: {}", e.getLocalizedMessage()); + return new SocketOutputData(Constant.Status.FAILED, e.getLocalizedMessage()); + } + + } + + private SocketOutputData handleList(String key) throws SQLException { + List result = indexManager.list(key); + return new SocketOutputData(Constant.Status.SUCCESS, JSONUtil.toJsonStr(result)); + } + + private SocketOutputData handleDelete(String path) throws SQLException, IOException { + if (path.isEmpty()) { + return new SocketOutputData(Constant.Status.FAILED, "Path不能为空!"); + } + indexManager.delete(path); + snippetManager.delete(path); + return new SocketOutputData(Constant.Status.SUCCESS, "删除成功: " + path); + } + + private SocketOutputData handleEdit(EditEntity entity) throws IOException { + if (entity.checkEmpty()) { + return new SocketOutputData(Constant.Status.FAILED, "Id、Path、代码内容均不能为空!"); + } + try { + snippetManager.update(entity, SnippetManager.UpdateAction.EDIT); + Index index = new Index(); + BeanUtil.copyProperties(entity, index); + String sha = snippetManager.sha(entity.getPath()); + index.setSha(sha); + indexManager.update(index); + snippetManager.update(entity, SnippetManager.UpdateAction.CONFIRM); + return new SocketOutputData(Constant.Status.SUCCESS, "文件编辑成功: " + entity.getPath()); + } catch (Exception e) { + snippetManager.update(entity, SnippetManager.UpdateAction.FALLBACK); + log.error("文件编辑失败, 已回滚: {}", e.getLocalizedMessage()); + return new SocketOutputData(Constant.Status.FAILED, e.getLocalizedMessage()); + } + } + + private SocketOutputData handleAdd(AddEntity entity) throws IOException { + if (entity.checkEmpty()) { + return new SocketOutputData(Constant.Status.FAILED, "Language、Name、代码片段内容均不能为空!"); + } + Path path = Path.of(System.getenv(Constant.Property.DIR), entity.getLanguage().toLowerCase(), entity.getName() + ".md"); + try { + Snippet snippet = fixSnippet(entity); + snippetManager.add(snippet, path); + Index index = fixIndexEntity(snippet, path); + index.setName(entity.getName()); + indexManager.add(index); + return new SocketOutputData(Constant.Status.SUCCESS, "代码片段已添加, 路径: " + path); + } catch (Exception e) { + Files.deleteIfExists(path); + log.error("文件添加失败: {}", e.getLocalizedMessage()); + return new SocketOutputData(Constant.Status.FAILED, e.getLocalizedMessage()); + } + } + + private Index fixIndexEntity(Snippet snippet, Path path) { + Index index = new Index(); + BeanUtil.copyProperties(snippet, index); + index.setPath(path.toString()); + index.setSha(snippetManager.sha(index.getPath())); + return index; + } + + private Snippet fixSnippet(AddEntity entity) { + MetaDataEntity metaData = metaDataExtractor.extract(entity); + Snippet snippet = new Snippet(); + snippet.setLanguage(entity.getLanguage().toLowerCase()); + snippet.setContent(entity.getContent()); + snippet.setDescription(metaData.getDescription()); + snippet.setTags(metaData.getTags()); + + return snippet; + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/IndexManager.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/IndexManager.java new file mode 100644 index 0000000..d9bded1 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/IndexManager.java @@ -0,0 +1,178 @@ +package work.slhaf.snippet.service; + +import lombok.extern.slf4j.Slf4j; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.entity.db.Index; +import work.slhaf.snippet.entity.file.ListEntity; + +import java.sql.*; +import java.util.*; + +@Slf4j +public class IndexManager { + + private static volatile IndexManager instance; + + private final Connection connection; + + public static IndexManager getInstance() throws SQLException { + if (instance == null) { + synchronized (IndexManager.class) { + if (instance == null) { + instance = new IndexManager(); + } + } + } + return instance; + } + + private IndexManager() throws SQLException { + connection = DriverManager.getConnection("jdbc:sqlite:" + System.getenv(Constant.Property.CONF) + "/index.db"); + checkTable(connection); + } + + private void checkTable(Connection connection) throws SQLException { + Statement statement = connection.createStatement(); + statement.execute(""" + create table if not exists code_snippet_index( + id integer primary key, + name varchar(255) not null, + language varchar(60) not null, + tags varchar(300) not null, + sha varchar(100) not null, + description varchar(300) not null, + path varchar(300) not null + ) + """); + statement.close(); + } + + public void rebuildIndex(Set indexSet) throws SQLException { + log.info("重建索引数据库"); + resetIndex(); + for (Index index : indexSet) { + add(index); + } + } + + public void add(Index index) throws SQLException { + PreparedStatement statement = connection.prepareStatement(""" + insert into code_snippet_index(name,language, tags, sha, description, path) + values (?,?,?,?,?,?) + """); + statement.setString(1, index.getName()); + statement.setString(2, index.getLanguage()); + statement.setString(3, Arrays.toString(index.getTags())); + statement.setString(4, index.getSha()); + statement.setString(5, index.getDescription()); + statement.setString(6, index.getPath()); + statement.execute(); + statement.close(); + } + + public void update(Index index) throws SQLException { + PreparedStatement statement = connection.prepareStatement(""" + update code_snippet_index + set tags = ?, description = ?, sha = ? + where id = ? + """); + statement.setString(1, Arrays.toString(index.getTags())); + statement.setString(2, index.getDescription()); + statement.setString(3, index.getSha()); + statement.executeUpdate(); + statement.close(); + } + + public void delete(String path) throws SQLException { + PreparedStatement statement = connection.prepareStatement("delete from code_snippet_index where path = ?"); + statement.setString(1, path); + statement.executeUpdate(); + } + + public List list(String input) throws SQLException { + String[] keywords = input.trim().toLowerCase().split("\\s+"); + + // 构建 SQL 占位符字符串 + StringBuilder sb = new StringBuilder("SELECT * FROM code_snippet_index WHERE "); + List params = new ArrayList<>(); + for (int k = 0; k < keywords.length; k++) { + if (k > 0) sb.append(" OR "); + sb.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR language LIKE ?)"); + for (int i = 0; i < 4; i++) params.add("%" + keywords[k] + "%"); + } + + PreparedStatement statement = connection.prepareStatement(sb.toString()); + for (int i = 0; i < params.size(); i++) { + statement.setString(i + 1, params.get(i)); + } + + ResultSet rs = statement.executeQuery(); + List list = new ArrayList<>(); + + while (rs.next()) { + ListEntity entity = new ListEntity(); + entity.setId(rs.getString("id")); + entity.setPath(rs.getString("path")); + entity.setName(rs.getString("name")); + entity.setScore(calculateScore(rs, keywords)); // 多关键字打分 + list.add(entity); + } + + // 按分数降序排序 + list.sort(Comparator.comparingInt(ListEntity::getScore).reversed()); + return list; + } + + // 多关键字加权打分 + private int calculateScore(ResultSet rs, String[] keywords) throws SQLException { + int score = 0; + String name = rs.getString("name").toLowerCase(); + String description = rs.getString("description").toLowerCase(); + String language = rs.getString("language").toLowerCase(); + + String tagsRaw = rs.getString("tags"); + List tagList = Arrays.stream(tagsRaw.substring(1, tagsRaw.length() - 1) + .split(",")) + .map(String::toLowerCase) + .toList(); + + for (String key : keywords) { + if (name.contains(key)) score += 5; + if (tagList.stream().anyMatch(t -> t.contains(key))) score += 4; + if (description.contains(key)) score += 3; + if (language.contains(key)) score += 2; + } + return score; + } + + + public boolean isEmpty() throws SQLException { + Statement statement = connection.createStatement(); + //判断表是否为空 + ResultSet rs = statement.executeQuery("select count(*) from code_snippet_index"); + rs.next(); + int count = rs.getInt(1); + statement.close(); + return count == 0; + } + + public void resetIndex() throws SQLException { + log.info("重置索引"); + //删除表中全部索引条目 + Statement statement = connection.createStatement(); + statement.execute("delete from code_snippet_index"); + statement.close(); + } + + public HashMap getIndexStatus() throws SQLException { + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery(""" + select sha,path from code_snippet_index + """); + HashMap map = new HashMap<>(); + while (rs.next()) { + map.put(rs.getString("path"), rs.getString("sha")); + } + return map; + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/MetaDataExtractor.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/MetaDataExtractor.java new file mode 100644 index 0000000..d41d980 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/MetaDataExtractor.java @@ -0,0 +1,55 @@ +package work.slhaf.snippet.service; + +import cn.hutool.json.JSONUtil; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.common.chat.ChatClient; +import work.slhaf.snippet.common.chat.constant.ChatConstant; +import work.slhaf.snippet.common.chat.pojo.ChatResponse; +import work.slhaf.snippet.common.chat.pojo.Message; +import work.slhaf.snippet.entity.file.AddEntity; +import work.slhaf.snippet.entity.file.MetaDataEntity; + +import java.util.ArrayList; +import java.util.List; + +import static work.slhaf.snippet.common.SnippetUtil.extractJson; + +public class MetaDataExtractor { + private final ChatClient chatClient = new ChatClient( + System.getenv(Constant.Property.BASE_URL), + System.getenv(Constant.Property.API_KEY), + System.getenv(Constant.Property.MODEL) + ); + private final List messages = List.of( + new Message(ChatConstant.Character.SYSTEM, """ + 你需要对接下来发送的代码相关内容进行提取,提取出‘描述’、‘标签’等内容。 + + 输入格式示例: + ```json + { + "name": "ByteBuddy用法示例", //代码片段名称 + "language": "Java", //代码片段所属语言 + "content": "Class clazz = module.getClazz();\\nClass proxyClass = new ByteBuddy()\\n .subclass(clazz)\\n .method(ElementMatchers.isOverriddenFrom(overrideSource))\\n .intercept(MethodDelegation.to(new ModuleProxyInterceptor(record.post, record.pre)))\\n .make()\\n .load(ModuleProxyFactory.class.getClassLoader())\\n .getLoaded();" //代码片段内容 + } + ``` + + 输出格式示例 + ```json + { + "tags": [ + "ByteBuddy", + "动态代理" + ], + "description": "通过ByteBuddy创建动态代理类的示例" + } + ``` + """) + ); + + public MetaDataEntity extract(AddEntity entity) { + List temp = new ArrayList<>(messages); + temp.add(new Message(ChatConstant.Character.USER, JSONUtil.toJsonStr(entity))); + ChatResponse response = chatClient.runChat(temp); + return JSONUtil.toBean(extractJson(response.getMessage()), MetaDataEntity.class); + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/SnippetManager.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/SnippetManager.java new file mode 100644 index 0000000..3816ada --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/SnippetManager.java @@ -0,0 +1,144 @@ +package work.slhaf.snippet.service; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.crypto.digest.DigestUtil; +import lombok.extern.slf4j.Slf4j; +import work.slhaf.snippet.common.Constant; +import work.slhaf.snippet.entity.Snippet; +import work.slhaf.snippet.entity.file.EditEntity; +import work.slhaf.snippet.entity.file.RebuildEntity; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +@Slf4j +public class SnippetManager { + + private final SnippetReader snippetReader = new SnippetReader(); + + public HashMap getFileStatus() { + File file = new File(System.getenv(Constant.Property.DIR)); + HashMap map = new HashMap<>(); + listFileStatus(file, map); + return map; + } + + private void listFileStatus(File file, HashMap map) { + File[] files = file.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + if (f.isDirectory()) { + listFileStatus(f, map); + } else { + String sha = DigestUtil.sha256Hex(f); + map.put(f.getAbsolutePath(), sha); + } + } + } + + public void add(Snippet snippet, Path path) throws IOException { + String markdown = snippet.toMarkdown(); + File file = path.toFile(); + if (file.exists()) { + throw new RuntimeException("文件已存在: " + file.getAbsolutePath()); + } + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), markdown, StandardCharsets.UTF_8); + } + + public void update(EditEntity entity, UpdateAction action) throws IOException { + log.info("编辑操作: {}, {}", entity.getPath(), action); + switch (action) { + case EDIT -> edit(entity); + case CONFIRM -> confirm(entity); + case FALLBACK -> fallback(entity); + } + + } + + private void fallback(EditEntity entity) throws IOException { + String path = entity.getPath(); + String tempPath = getTempPath(path); + Files.move(Paths.get(tempPath), Paths.get(path), StandardCopyOption.REPLACE_EXISTING); + } + + private void confirm(EditEntity entity) throws IOException { + String tempPath = getTempPath(entity.getPath()); + Files.deleteIfExists(Paths.get(tempPath)); + } + + private void edit(EditEntity entity) throws IOException { + String path = entity.getPath(); + String tempPath = getTempPath(path); + Path p = Paths.get(path); + + Snippet snippet = new Snippet(); + BeanUtil.copyProperties(entity, snippet); + + Files.move(p, Paths.get(tempPath), StandardCopyOption.REPLACE_EXISTING); + Files.writeString(p, snippet.toMarkdown(), StandardCharsets.UTF_8); + } + + private String getTempPath(String path) { + return path + ".tmp"; + } + + public void delete(String path) throws IOException { + Files.deleteIfExists(Paths.get(path)); + } + + public String sha(String filePath) { + File file = new File(filePath); + if (!file.exists()) { + throw new RuntimeException("文件不存在: " + filePath); + } + return DigestUtil.sha256Hex(file); + } + + public List listAll() throws IOException { + log.info("获取数据目录[{}]文件信息", System.getenv(Constant.Property.DIR)); + List list = new ArrayList<>(); + File file = new File(System.getenv(Constant.Property.DIR)); + listAll(file, list); + return list; + } + + private void listAll(File file, List list) throws IOException { + File[] files = file.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + if (f.isDirectory()) { + listAll(f, list); + } else { + FileReader reader = new FileReader(f, StandardCharsets.UTF_8); + String s = reader.readAllAsString(); + Snippet snippet = snippetReader.visit(s); + RebuildEntity rebuildEntity = new RebuildEntity(); + BeanUtil.copyProperties(snippet, rebuildEntity); + rebuildEntity.setPath(f.getAbsolutePath()); + rebuildEntity.setSha(DigestUtil.sha256Hex(f)); + rebuildEntity.setName(FileNameUtil.mainName(f)); + list.add(rebuildEntity); + reader.close(); + } + } + } + + public enum UpdateAction { + EDIT, CONFIRM, FALLBACK + } +} diff --git a/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/SnippetReader.java b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/SnippetReader.java new file mode 100644 index 0000000..84c99c2 --- /dev/null +++ b/CodeSnippetDaemon/src/main/java/work/slhaf/snippet/service/SnippetReader.java @@ -0,0 +1,134 @@ +package work.slhaf.snippet.service; + +import org.commonmark.node.*; +import org.commonmark.parser.Parser; +import work.slhaf.snippet.entity.Snippet; + +public class SnippetReader { + + public Snippet visit(String text){ + Snippet snippet = new Snippet(); + Node document = Parser.builder().build().parse(text); + document.accept(new SnippetVisitor(snippet)); + return snippet; + } + + private static class SnippetVisitor extends AbstractVisitor { + + private final Snippet snippet; + private String currentSection = ""; + private String lastMetadataKey = ""; + + public SnippetVisitor(Snippet snippet) { + this.snippet = snippet; + } + + private String extractText(Node node) { + StringBuilder text = new StringBuilder(); + Node child = node.getFirstChild(); + while (child != null) { + if (child instanceof Text) { + text.append(((Text) child).getLiteral()); + } else if (child instanceof Paragraph) { + text.append(extractText(child)); + } + child = child.getNext(); + } + return text.toString(); + } + + @Override + public void visit(FencedCodeBlock fencedCodeBlock) { + // Extract the code content from the fenced code block + String codeContent = fencedCodeBlock.getLiteral(); + if (codeContent != null) { + snippet.setContent(codeContent); + } + + // Extract the language from the info string + String info = fencedCodeBlock.getInfo(); + if (info != null && !info.isEmpty()) { + snippet.setLanguage(info); + } + + super.visit(fencedCodeBlock); + } + + @Override + public void visit(Heading heading) { + // Extract text content from the heading to identify the section + StringBuilder headingText = new StringBuilder(); + Node child = heading.getFirstChild(); + while (child != null) { + if (child instanceof Text) { + headingText.append(((Text) child).getLiteral()); + } + child = child.getNext(); + } + + // Set the current section based on the heading text + currentSection = headingText.toString(); + + super.visit(heading); + } + + private void addTag(String tag) { + String[] existingTags = snippet.getTags(); + if (existingTags == null) { + snippet.setTags(new String[]{tag}); + } else { + String[] newTags = new String[existingTags.length + 1]; + System.arraycopy(existingTags, 0, newTags, 0, existingTags.length); + newTags[existingTags.length] = tag; + snippet.setTags(newTags); + } + } + + @Override + public void visit(BulletList bulletList) { + super.visit(bulletList); + } + + @Override + public void visit(ListItem listItem) { + // Extract text content from the list item + String itemText = extractText(listItem); + + // Remove leading/trailing whitespace + itemText = itemText.trim(); + + // Process metadata based on the current section and item text + if ("MetaData".equals(currentSection)) { + if (itemText.equals("Language")) { + lastMetadataKey = "Language"; + } else if (itemText.equals("Tags")) { + lastMetadataKey = "Tags"; + } else if (itemText.equals("Description")) { + lastMetadataKey = "description"; + } else { + // This is a value for the previous metadata key + if ("Language".equals(lastMetadataKey)) { + snippet.setLanguage(itemText); + } else if ("description".equals(lastMetadataKey)) { + snippet.setDescription(itemText); + } else if ("Tags".equals(lastMetadataKey)) { + // Collect tags + addTag(itemText); + } + } + } + + super.visit(listItem); + } + + @Override + public void visit(Paragraph paragraph) { + super.visit(paragraph); + } + + @Override + public void visit(Text text) { + super.visit(text); + } + } +} diff --git a/CodeSnippetDaemon/src/test/java/MarkdownTest.java b/CodeSnippetDaemon/src/test/java/MarkdownTest.java new file mode 100644 index 0000000..8eee038 --- /dev/null +++ b/CodeSnippetDaemon/src/test/java/MarkdownTest.java @@ -0,0 +1,11 @@ +import work.slhaf.snippet.entity.Snippet; +import work.slhaf.snippet.service.SnippetReader; + +void main() throws IOException { + // Read the test file + FileReader reader = new FileReader("../test/test.md", StandardCharsets.UTF_8); + String s = reader.readAllAsString(); + SnippetReader snippetReader = new SnippetReader(); + Snippet visit = snippetReader.visit(s); + System.out.println(visit); +} \ No newline at end of file diff --git a/CodeSnippetDaemon/src/test/java/SnippetReaderTest.java b/CodeSnippetDaemon/src/test/java/SnippetReaderTest.java new file mode 100644 index 0000000..fa4fbaf --- /dev/null +++ b/CodeSnippetDaemon/src/test/java/SnippetReaderTest.java @@ -0,0 +1,29 @@ +import work.slhaf.snippet.entity.Snippet; +import work.slhaf.snippet.service.SnippetReader; + + void main() throws IOException { + // Read the test file + FileReader reader = new FileReader("../test/test.md", StandardCharsets.UTF_8); + SnippetReader snippetReader = new SnippetReader(); + StringBuilder content = new StringBuilder(); + int ch; + while ((ch = reader.read()) != -1) { + content.append((char) ch); + } + reader.close(); + + // Parse the content using SnippetReader + Snippet snippet = snippetReader.visit(content.toString()); + + // Print the extracted information + System.out.println("Language: " + snippet.getLanguage()); + System.out.println("Description: " + snippet.getDescription()); + System.out.println("Tags: "); + if (snippet.getTags() != null) { + for (String tag : snippet.getTags()) { + System.out.println(" - " + tag); + } + } + System.out.println("Content: "); + System.out.println(snippet.getContent()); +} \ No newline at end of file diff --git a/CodeSnippetRofi/common/constant.py b/CodeSnippetRofi/common/constant.py new file mode 100644 index 0000000..ba86237 --- /dev/null +++ b/CodeSnippetRofi/common/constant.py @@ -0,0 +1,40 @@ +import os + +code_snippet_dir = os.getenv("CODE_SNIPPET_DIR") +code_snippet_port = os.getenv("CODE_SNIPPET_PORT") +code_snippet_rofi = os.getenv("CODE_SNIPPET_ROFI") + +action_list = "LIST" +action_add = "ADD" +action_edit = "EDIT" +action_delete = "DELETE" + +template_add = """ +## Snippet + +```[Language] + +``` + +## MetaData + +- Name + - +- Language + - +""" +template_edit = """ +## Snippet + +```[Language] + +``` + +## MetaData + +- Tags + - + - +- Description + - +""" \ No newline at end of file diff --git a/CodeSnippetRofi/common/rofi.py b/CodeSnippetRofi/common/rofi.py new file mode 100644 index 0000000..3ef9137 --- /dev/null +++ b/CodeSnippetRofi/common/rofi.py @@ -0,0 +1,823 @@ +# +# python-rofi +# +# The MIT License +# +# Copyright (c) 2016, 2017 Blair Bonnett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +import atexit +from datetime import datetime +from decimal import Decimal, InvalidOperation +import signal +import subprocess +import time + +# Python < 3.2 doesn't provide a context manager interface for Popen. +# Let's make our own wrapper if needed. +if hasattr(subprocess.Popen, '__exit__'): + Popen = subprocess.Popen +else: + class ContextManagedPopen(subprocess.Popen): + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + if self.stdout: + self.stdout.close() + if self.stderr: + self.stderr.close() + if self.stdin: + self.stdin.close() + self.wait() + + + Popen = ContextManagedPopen + + +class Rofi(object): + """Class to facilitate making simple GUIs with Rofi. + + Rofi is a popup window system with minimal dependencies (xlib and pango). + It was designed as a window switcher. Its basic operation is to display a + list of options and let the user pick one. + + This class provides a set of methods to make simple GUIs with Rofi. It does + this by using the subprocess module to call Rofi externally. Many of the + methods are blocking. + + Some strings can contain Pango markup for additional formatting (those that + can are noted as such in the docstrings). Any text in these strings *must* + be escaped before calling Rofi. The class method Rofi.escape() performs + this escaping for you. Make sure you call this on the text prior to adding + Pango markup, otherwise the markup will be escaped and displayed to the + user. See https://developer.gnome.org/pango/stable/PangoMarkupFormat.html + for available markup. + + """ + + def __init__(self, lines=None, fixed_lines=None, width=None, + fullscreen=None, location=None, + exit_hotkeys=('Alt+F4', 'Control+q'), rofi_cmd=None): + """ + Parameters + ---------- + exit_hotkeys: tuple of strings + Hotkeys to use to exit the application. These will be automatically + set and handled in any method which takes hotkey arguments. If one + of these hotkeys is pressed, a SystemExit will be raised to perform + the exit. + + The following parameters set default values for various layout options, + and can be overwritten in any display method. A value of None means + use the system default, which may be set by a configuration file or + fall back to the compile-time default. See the Rofi documentation for + full details on what the values mean. + + lines: positive integer + The maximum number of lines to show before scrolling. + fixed_lines: positive integer + Keep a fixed number of lines visible. + width: real + If positive but not more than 100, this is the percentage of the + screen's width the window takes up. If greater than 100, it is the + width in pixels. If negative, it estimates the width required for + the corresponding number of characters, i.e., -30 would set the + width so ~30 characters per row would show. + fullscreen: boolean + If True, use the full height and width of the screen. + location: integer + The position of the window on the screen. + + """ + # The Popen class returned for any non-blocking windows. + self._process = None + + # Save parameters. + self.lines = lines + self.fixed_lines = fixed_lines + self.width = width + self.fullscreen = fullscreen + self.location = location + self.exit_hotkeys = exit_hotkeys + + if rofi_cmd is None: + self.rofi = "rofi" + else: + self.rofi = rofi_cmd + # Don't want a window left on the screen if we exit unexpectedly + # (e.g., an unhandled exception). + atexit.register(self.close) + + @classmethod + def escape(self, string): + """Escape a string for Pango markup. + + Parameters + ---------- + string: + A piece of text to escape. + + Returns + ------- + The text, safe for use in with Pango markup. + + """ + # Escape ampersands first, then other entities. Since argument is a + # dictionary, we can't guarantee order of translations and so doing it + # in one go would risk the ampersands in other translations being + # escaped again. + return string.translate( + {38: '&'} + ).translate({ + 34: '"', + 39: ''', + 60: '<', + 62: '>' + }) + + def close(self): + """Close any open window. + + Note that this only works with non-blocking methods. + + """ + if self._process: + # Be nice first. + self._process.send_signal(signal.SIGINT) + + # If it doesn't close itself promptly, be brutal. + # Python 3.2+ added the timeout option to wait() and the + # corresponding TimeoutExpired exception. If they exist, use them. + if hasattr(subprocess, 'TimeoutExpired'): + try: + self._process.wait(timeout=1) + except subprocess.TimeoutExpired: + self._process.send_signal(signal.SIGKILL) + + # Otherwise, roll our own polling loop. + else: + # Give it 1s, checking every 10ms. + count = 0 + while count < 100: + if self._process.poll() is not None: + break + time.sleep(0.01) + + # Still hasn't quit. + if self._process.poll() is None: + self._process.send_signal(signal.SIGKILL) + + # Clean up. + self._process = None + + def _run_blocking(self, args, input=None): + """Internal API: run a blocking command with subprocess. + + This closes any open non-blocking dialog before running the command. + + Parameters + ---------- + args: Popen constructor arguments + Command to run. + input: string + Value to feed to the stdin of the process. + + Returns + ------- + (returncode, stdout) + The exit code (integer) and stdout value (string) from the process. + + """ + # Close any existing dialog. + if self._process: + self.close() + + # Make sure we grab stdout as text (not bytes). + kwargs = {} + kwargs['stdout'] = subprocess.PIPE + kwargs['universal_newlines'] = True + + # Use the run() method if available (Python 3.5+). + if hasattr(subprocess, 'run'): + result = subprocess.run(args, input=input, **kwargs) + return result.returncode, result.stdout + + # Have to do our own. If we need to feed stdin, we must open a pipe. + if input is not None: + kwargs['stdin'] = subprocess.PIPE + + # Start the process. + with Popen(args, **kwargs) as proc: + # Talk to it (no timeout). This will wait until termination. + stdout, stderr = proc.communicate(input) + + # Find out the return code. + returncode = proc.poll() + + # Done. + return returncode, stdout + + def _run_nonblocking(self, args, input=None): + """Internal API: run a non-blocking command with subprocess. + + This closes any open non-blocking dialog before running the command. + + Parameters + ---------- + args: Popen constructor arguments + Command to run. + input: string + Value to feed to the stdin of the process. + + """ + # Close any existing dialog. + if self._process: + self.close() + + # Start the new one. + self._process = subprocess.Popen(args, stdout=subprocess.PIPE) + + def _common_args(self, allow_fullscreen=True, **kwargs): + args = [] + + # Number of lines. + lines = kwargs.get('lines', self.lines) + if lines: + args.extend(['-lines', str(lines)]) + fixed_lines = kwargs.get('fixed_lines', self.fixed_lines) + if fixed_lines: + args.extend(['-fixed-num-lines', str(fixed_lines)]) + + # Width. + width = kwargs.get('width', self.width) + if width is not None: + args.extend(['-width', str(width)]) + + # Fullscreen mode? + fullscreen = kwargs.get('fullscreen', self.fullscreen) + if allow_fullscreen and fullscreen: + args.append('-fullscreen') + + # Location on screen. + location = kwargs.get('location', self.location) + if location is not None: + args.extend(['-location', str(location)]) + + # Done. + return args + + def error(self, message, **kwargs): + """Show an error window. + + This method blocks until the user presses a key. + + Fullscreen mode is not supported for error windows, and if specified + will be ignored. + + Parameters + ---------- + message: string + Error message to show. + + """ + # Generate arguments list. + args = [self.rofi, '-e', message] + args.extend(self._common_args(allow_fullscreen=False, **kwargs)) + + # Close any existing window and show the error. + self._run_blocking(args) + + def status(self, message, **kwargs): + """Show a status message. + + This method is non-blocking, and intended to give a status update to + the user while something is happening in the background. + + To close the window, either call the close() method or use any of the + display methods to replace it with a different window. + + Fullscreen mode is not supported for status messages and if specified + will be ignored. + + Parameters + ---------- + message: string + Progress message to show. + + """ + # Generate arguments list. + args = [self.rofi, '-e', message] + args.extend(self._common_args(allow_fullscreen=False, **kwargs)) + + # Update the status. + self._run_nonblocking(args) + + def select(self, prompt, options, message="", select=None, **kwargs): + """Show a list of options and return user selection. + + This method blocks until the user makes their choice. + + Parameters + ---------- + prompt: string + The prompt telling the user what they are selecting. + options: list of strings + The options they can choose from. Any newline characters are + replaced with spaces. + message: string, optional + Message to show between the prompt and the options. This can + contain Pango markup, and any text content should be escaped. + select: integer, optional + Set which option is initially selected. + keyN: tuple (string, string); optional + Custom key bindings where N is one or greater. The first entry in + the tuple should be a string defining the key, e.g., "Alt+x" or + "Delete". Note that letter keys should be lowercase ie.e., Alt+a + not Alt+A. + + The second entry should be a short string stating the action the + key will take. This is displayed to the user at the top of the + dialog. If None or an empty string, it is not displayed (but the + binding is still set). + + By default, key1 through key9 are set to ("Alt+1", None) through + ("Alt+9", None) respectively. + + Returns + ------- + tuple (index, key) + The index of the option the user selected, or -1 if they cancelled + the dialog. + Key indicates which key was pressed, with 0 being 'OK' (generally + Enter), -1 being 'Cancel' (generally escape), and N being custom + key N. + + """ + # Replace newlines and turn the options into a single string. + optionstr = '\n'.join(option.replace('\n', ' ') for option in options) + + # Set up arguments. + args = [self.rofi, '-dmenu', '-p', prompt, '-format', 'i'] + if select is not None: + args.extend(['-selected-row', str(select)]) + + # Key bindings to display. + display_bindings = [] + + # Configure the key bindings. + user_keys = set() + for k, v in kwargs.items(): + # See if the keyword name matches the needed format. + if not k.startswith('key'): + continue + try: + keynum = int(k[3:]) + except ValueError: + continue + + # Add it to the set. + key, action = v + user_keys.add(keynum) + args.extend(['-kb-custom-{0:s}'.format(k[3:]), key]) + if action: + display_bindings.append("{0:s}: {1:s}".format(key, action)) + + # And the global exit bindings. + exit_keys = set() + next_key = 10 + for key in self.exit_hotkeys: + while next_key in user_keys: + next_key += 1 + exit_keys.add(next_key) + args.extend(['-kb-custom-{0:d}'.format(next_key), key]) + next_key += 1 + + # Add any displayed key bindings to the message. + message = message or "" + if display_bindings: + message += "\n" + " ".join(display_bindings) + message = message.strip() + + # If we have a message, add it to the arguments. + if message: + args.extend(['-mesg', message]) + + # Add in common arguments. + args.extend(self._common_args(**kwargs)) + + # Run the dialog. + returncode, stdout = self._run_blocking(args, input=optionstr) + + # Figure out which option was selected. + stdout = stdout.strip() + index = int(stdout) if stdout else -1 + + # And map the return code to a key. + if returncode == 0: + key = 0 + elif returncode == 1: + key = -1 + elif returncode > 9: + key = returncode - 9 + if key in exit_keys: + raise SystemExit() + else: + self.exit_with_error("Unexpected rofi returncode {0:d}.".format(results.returncode)) + + # And return. + return index, key + + def generic_entry(self, prompt, validator=None, message=None, **kwargs): + """A generic entry box. + + Parameters + ---------- + prompt: string + Text prompt for the entry. + validator: function, optional + A function to validate and convert the value entered by the user. + It should take one parameter, the string that the user entered, and + return a tuple (value, error). The value should be the users entry + converted to the appropriate Python type, or None if the entry was + invalid. The error message should be a string telling the user what + was wrong, or None if the entry was valid. The prompt will be + re-displayed to the user (along with the error message) until they + enter a valid value. If no validator is given, the text that the + user entered is returned as-is. + message: string + Optional message to display under the entry. + + Returns + ------- + The value returned by the validator, or None if the dialog was + cancelled. + + Examples + -------- + Enforce a minimum entry length: + >>> r = Rofi() + >>> validator = lambda s: (s, None) if len(s) > 6 else (None, "Too short") + >>> r.generic_entry('Enter a 7-character or longer string: ', validator) + + """ + error = "" + + # Keep going until we get something valid. + while True: + args = [self.rofi, '-dmenu', '-p', prompt, '-format', 's'] + + # Add any error to the given message. + msg = message or "" + if error: + msg = '{0:s}\n{1:s}'.format(error, msg) + msg = msg.rstrip('\n') + + # If there is actually a message to show. + if msg: + args.extend(['-mesg', msg]) + + # Add in common arguments. + args.extend(self._common_args(**kwargs)) + + # Run it. + returncode, stdout = self._run_blocking(args, input="") + + # Was the dialog cancelled? + if returncode == 1: + return None + + # Get rid of the trailing newline and check its validity. + text = stdout.rstrip('\n') + if validator: + value, error = validator(text) + if not error: + return value + else: + return text + + def text_entry(self, prompt, message=None, allow_blank=False, strip=True, **kwargs): + """Prompt the user to enter a piece of text. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + allow_blank: Boolean + Whether to allow blank entries. + strip: Boolean + Whether to strip leading and trailing whitespace from the entered + value. + + Returns + ------- + string, or None if the dialog was cancelled. + + """ + + def text_validator(text): + if strip: + text = text.strip() + if not allow_blank: + if not text: + return None, "A value is required." + + return text, None + + return self.generic_entry(prompt, text_validator, message, **kwargs) + + def integer_entry(self, prompt, message=None, min=None, max=None, **kwargs): + """Prompt the user to enter an integer. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + min, max: integer, optional + Minimum and maximum values to allow. If None, no limit is imposed. + + Returns + ------- + integer, or None if the dialog is cancelled. + + """ + # Sanity check. + if (min is not None) and (max is not None) and not (max > min): + raise ValueError("Maximum limit has to be more than the minimum limit.") + + def integer_validator(text): + error = None + + # Attempt to convert to integer. + try: + value = int(text) + except ValueError: + return None, "Please enter an integer value." + + # Check its within limits. + if (min is not None) and (value < min): + return None, "The minimum allowable value is {0:d}.".format(min) + if (max is not None) and (value > max): + return None, "The maximum allowable value is {0:d}.".format(max) + + return value, None + + return self.generic_entry(prompt, integer_validator, message, **kwargs) + + def float_entry(self, prompt, message=None, min=None, max=None, **kwargs): + """Prompt the user to enter a floating point number. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + min, max: float, optional + Minimum and maximum values to allow. If None, no limit is imposed. + + Returns + ------- + float, or None if the dialog is cancelled. + + """ + # Sanity check. + if (min is not None) and (max is not None) and not (max > min): + raise ValueError("Maximum limit has to be more than the minimum limit.") + + def float_validator(text): + error = None + + # Attempt to convert to float. + try: + value = float(text) + except ValueError: + return None, "Please enter a floating point value." + + # Check its within limits. + if (min is not None) and (value < min): + return None, "The minimum allowable value is {0}.".format(min) + if (max is not None) and (value > max): + return None, "The maximum allowable value is {0}.".format(max) + + return value, None + + return self.generic_entry(prompt, float_validator, message, **kwargs) + + def decimal_entry(self, prompt, message=None, min=None, max=None, **kwargs): + """Prompt the user to enter a decimal number. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + min, max: Decimal, optional + Minimum and maximum values to allow. If None, no limit is imposed. + + Returns + ------- + Decimal, or None if the dialog is cancelled. + + """ + # Sanity check. + if (min is not None) and (max is not None) and not (max > min): + raise ValueError("Maximum limit has to be more than the minimum limit.") + + def decimal_validator(text): + error = None + + # Attempt to convert to decimal. + try: + value = Decimal(text) + except InvalidOperation: + return None, "Please enter a decimal value." + + # Check its within limits. + if (min is not None) and (value < min): + return None, "The minimum allowable value is {0}.".format(min) + if (max is not None) and (value > max): + return None, "The maximum allowable value is {0}.".format(max) + + return value, None + + return self.generic_entry(prompt, decimal_validator, message, **kwargs) + + def date_entry(self, prompt, message=None, formats=['%x', '%d/%m/%Y'], show_example=False, **kwargs): + """Prompt the user to enter a date. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + formats: list of strings, optional + The formats that the user can enter dates in. These should be + format strings as accepted by the datetime.datetime.strptime() + function from the standard library. They are tried in order, and + the first that returns a date object without error is selected. + Note that the '%x' in the default list is the current locale's date + representation. + show_example: Boolean + If True, today's date in the first format given is appended to the + message. + + Returns + ------- + datetime.date, or None if the dialog is cancelled. + + """ + + def date_validator(text): + # Try them in order. + for format in formats: + try: + dt = datetime.strptime(text, format) + except ValueError: + continue + else: + # This one worked; good enough for us. + return (dt.date(), None) + + # None of the formats worked. + return (None, 'Please enter a valid date.') + + # Add an example to the message? + if show_example: + message = message or "" + message += "Today's date in the correct format: " + datetime.now().strftime(formats[0]) + + return self.generic_entry(prompt, date_validator, message, **kwargs) + + def time_entry(self, prompt, message=None, formats=['%X', '%H:%M', '%I:%M', '%H.%M', '%I.%M'], show_example=False, + **kwargs): + """Prompt the user to enter a time. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + formats: list of strings, optional + The formats that the user can enter times in. These should be + format strings as accepted by the datetime.datetime.strptime() + function from the standard library. They are tried in order, and + the first that returns a time object without error is selected. + Note that the '%X' in the default list is the current locale's time + representation. + show_example: Boolean + If True, the current time in the first format given is appended to + the message. + + Returns + ------- + datetime.time, or None if the dialog is cancelled. + + """ + + def time_validator(text): + # Try them in order. + for format in formats: + try: + dt = datetime.strptime(text, format) + except ValueError: + continue + else: + # This one worked; good enough for us. + return (dt.time(), None) + + # None of the formats worked. + return (None, 'Please enter a valid time.') + + # Add an example to the message? + if show_example: + message = message or "" + message += "Current time in the correct format: " + datetime.now().strftime(formats[0]) + + return self.generic_entry(prompt, time_validator, message, **kwargs) + + def datetime_entry(self, prompt, message=None, formats=['%x %X'], show_example=False, **kwargs): + """Prompt the user to enter a date and time. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + formats: list of strings, optional + The formats that the user can enter the date and time in. These + should be format strings as accepted by the + datetime.datetime.strptime() function from the standard library. + They are tried in order, and the first that returns a datetime + object without error is selected. Note that the '%x %X' in the + default list is the current locale's date and time representation. + show_example: Boolean + If True, the current date and time in the first format given is appended to + the message. + + Returns + ------- + datetime.datetime, or None if the dialog is cancelled. + + """ + + def datetime_validator(text): + # Try them in order. + for format in formats: + try: + dt = datetime.strptime(text, format) + except ValueError: + continue + else: + # This one worked; good enough for us. + return (dt, None) + + # None of the formats worked. + return (None, 'Please enter a valid date and time.') + + # Add an example to the message? + if show_example: + message = message or "" + message += "Current date and time in the correct format: " + datetime.now().strftime(formats[0]) + + return self.generic_entry(prompt, datetime_validator, message, **kwargs) + + def exit_with_error(self, error, **kwargs): + """Report an error and exit. + + This raises a SystemExit exception to ask the interpreter to quit. + + Parameters + ---------- + error: string + The error to report before quitting. + + """ + self.error(error, **kwargs) + raise SystemExit(error) diff --git a/CodeSnippetRofi/entity/response.py b/CodeSnippetRofi/entity/response.py new file mode 100644 index 0000000..6431bcc --- /dev/null +++ b/CodeSnippetRofi/entity/response.py @@ -0,0 +1,19 @@ +import json + + +class SearchResponse: + def __init__(self, response: dict): + self.status = response["status"] + self.data = [SearchResponse.SearchData(dict(r)) for r in json.loads(response["data"])] + + class SearchData: + def __init__(self, data: dict): + self.id = data["id"] + self.name = data["name"] + self.path = data["path"] + self.score = data["score"] + +class NormalResponse: + def __init__(self, response: dict): + self.status = response["status"] + self.data = response["data"] diff --git a/CodeSnippetRofi/entity/result.py b/CodeSnippetRofi/entity/result.py new file mode 100644 index 0000000..74eaf25 --- /dev/null +++ b/CodeSnippetRofi/entity/result.py @@ -0,0 +1,4 @@ +class ActionResult: + def __init__(self, ok: bool, message: str): + self.ok = ok + self.message = message \ No newline at end of file diff --git a/CodeSnippetRofi/helper/api_helper.py b/CodeSnippetRofi/helper/api_helper.py new file mode 100644 index 0000000..b92270e --- /dev/null +++ b/CodeSnippetRofi/helper/api_helper.py @@ -0,0 +1,58 @@ +import json +import subprocess + +from common.constant import code_snippet_port, action_list, action_edit, action_delete, action_add +from entity.response import SearchResponse, NormalResponse + + +def _run_api(action: str, data: str) -> dict: + action = action.upper() + if action not in ["LIST", "ADD", "EDIT", "DELETE"]: + raise ValueError("Invalid action") + + message = { + "action": f"{action}", + "data": f"{data}" + } + + # 使用 Popen 创建管道 + echo_process = subprocess.Popen( + ["echo", json.dumps(message)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + nc_process = subprocess.Popen( + ["nc", "-q", "0", "127.0.0.1", code_snippet_port], + stdin=echo_process.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # 关闭 echo 的输出,避免资源泄漏 + echo_process.stdout.close() + + try: + # 获取 nc 的输出 + nc_stdout, nc_stderr = nc_process.communicate() + return dict(json.loads(nc_stdout)) + except Exception: + raise ConnectionError(f"后端交互失败!检查守护进程是否运行") + + +def run_search(key: str) -> SearchResponse: + return SearchResponse(_run_api(action_list, key)) + + +def run_edit(data: str) -> NormalResponse: + return NormalResponse(_run_api(action_edit, data)) + + +def run_delete(path: str) -> NormalResponse: + return NormalResponse(_run_api(action_delete, path)) + + +def run_add(data: str) -> NormalResponse: + return NormalResponse(_run_api(action_add, data)) diff --git a/CodeSnippetRofi/helper/file_helper.py b/CodeSnippetRofi/helper/file_helper.py new file mode 100644 index 0000000..0044c46 --- /dev/null +++ b/CodeSnippetRofi/helper/file_helper.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +from common.constant import template_add, template_edit +from entity.result import ActionResult +from helper.api_helper import run_edit, run_add + + +def _parse_add_text(text: str) -> str: + # 提取第一个代码块(包含可选的 ```[Lang] 或 ```Lang) + fence_re = re.search(r"```(?:\[(?P[^\]]*)\]|(?P[A-Za-z0-9_+\-]+))?\s*\r?\n(?P[\s\S]*?)```", text) + fence_lang = '' + content = '' + if fence_re: + fence_lang = (fence_re.group('fence_lang') or fence_re.group('fence_lang2') or '') or '' + content = (fence_re.group('code') or '').strip() + # 把模板占位符当做空值处理 + if fence_lang.strip().lower() == 'language' or fence_lang.strip() == '': + fence_lang = '' + else: + fence_lang = fence_lang.strip() + + # 定位 MetaData 区块(从"## MetaData"到文档末尾或下一个"## ") + meta_match = re.search(r"##\s*MetaData\s*(?P[\s\S]*)", text, flags=re.I) + meta = meta_match.group('meta') if meta_match else '' + meta_lines = meta.splitlines() + + def extract_list_value(field_name: str) -> str: + field_name_re = re.compile(rf"^\s*-\s*{re.escape(field_name)}\s*$", flags=re.I) + list_item_re = re.compile(r"^\s*-\s*(.*)$") + for i, line in enumerate(meta_lines): + if field_name_re.match(line): + # 找下一条非空行作为子项 + j = i + 1 + while j < len(meta_lines) and meta_lines[j].strip() == "": + j += 1 + if j < len(meta_lines): + m = list_item_re.match(meta_lines[j]) + if m: + val = m.group(1).strip() + # 把模板占位符当做空值处理 + if val.lower() == field_name.lower() or val.lower() == 'language' and field_name.lower() == 'language' and val == '': + return '' + return val + return "" + return "" + + name = extract_list_value("Name") + language_meta = extract_list_value("Language") + + # 优先使用 MetaData 下方的 Language,若无再使用代码块 fence 的语言 + language = language_meta or fence_lang or "" + + entity = { + "name": name or "", + "language": language or "", + "content": content or "", + } + return json.dumps(entity, ensure_ascii=False) + +# 包装成读取文件的版本(保留原函数签名) +def _parse_add(file_path: str) -> str: + text = Path(file_path).read_text(encoding='utf-8') + return _parse_add_text(text) + + +def _prefill_edit(file_path: str, source_path: str, _id: str) -> None: + # 将原始文件内容与元信息写入模板占位 + # 读取原文件 + src = Path(source_path).read_text(encoding='utf-8') + + # 提取代码块(仅代码,不带多余模板)与语言 + code_block = re.search(r"```(?:\[([^\]]*)\]|([A-Za-z0-9_+\-]+))?\s*\r?\n([\s\S]*?)```", src) + language = '' + code_only = '' + if code_block: + language = (code_block.group(1) or code_block.group(2) or '').strip() + code_only = (code_block.group(3) or '').strip() + # 若未检测到代码块则回退为全文 + if not code_only: + code_only = src.strip() + + # 从源文档的 MetaData 段提取 tags 与 description + tags: list[str] = [] + desc = '' + # 定位 MetaData 段 + meta_match = re.search(r"##\s*MetaData([\s\S]*)", src) + meta = meta_match.group(1) if meta_match else '' + if meta: + # Tags 段 + tags_section = re.search(r"-\s*Tags\s*([\s\S]*?)(?:\n-\s*Description|\Z)", meta) + if tags_section: + for line in tags_section.group(1).splitlines(): + m = re.match(r"^\s*-\s*(.+)$", line) + if m: + val = m.group(1).strip() + if val: + tags.append(val) + # Description 段 + desc_section = re.search(r"-\s*Description\s*\n\s*-\s*(.+)", meta) + if desc_section: + desc = desc_section.group(1).strip() + + # 若语言未能从代码块检测,则用路径上级名兜底 + if not language: + language = Path(source_path).parent.name + + # 重建模板(覆盖写入) + rebuilt_lines = [] + rebuilt_lines.append("## Snippet\n\n") + if language: + rebuilt_lines.append(f"```{language}\n") + else: + rebuilt_lines.append("```\n") + rebuilt_lines.append(code_only) + rebuilt_lines.append("\n```\n\n") + rebuilt_lines.append("## MetaData\n\n") + rebuilt_lines.append("- Tags\n") + if tags: + for t in tags: + rebuilt_lines.append(f" - {t}\n") + else: + rebuilt_lines.append(" - \n - \n") + rebuilt_lines.append("- Description\n") + rebuilt_lines.append(f" - {desc}\n") + + Path(file_path).write_text(''.join(rebuilt_lines), encoding='utf-8') + + +def _parse_edit(file_path: str, _id: str, source_path: str) -> str: + text = Path(file_path).read_text(encoding='utf-8') + # 解析语言(不强制需要) + # 解析代码块内容 + code_match = re.search(r"```(?:\[[^\]]*\]|[A-Za-z0-9_+\-]*)\s*\r?\n([\s\S]*?)```", text) + content = (code_match.group(1) if code_match else '').strip() + # tags + tag_lines = re.findall(r"^-\s+(.*)$", text, flags=re.M) + # 在 '- Tags' 之后的两级缩进项 + tags_section = False + tags = [] + for line in text.splitlines(): + if re.match(r"^-\s*Tags\s*$", line): + tags_section = True + continue + if tags_section: + m = re.match(r"^\s*-\s*(.*)$", line) + if m: + val = m.group(1).strip() + if val: + tags.append(val) + else: + # 离开 tags 段 + tags_section = False + # 到 Description 再退出 + if re.match(r"^-\s*Description\s*$", line): + tags_section = False + + desc_match = re.search(r"-\s*Description\s*\n\s*-\s*(.*)", text) + description = (desc_match.group(1) if desc_match else '').strip() + + entity = { + "id": _id, + "path": source_path, + "tags": tags, + "description": description, + "content": content, + } + return json.dumps(entity, ensure_ascii=False) + + +def _create_tmp(content: str) -> str: + # 创建临时文件,并写入template_path对应的文件中的内容,最终返回临时文件的路径 + # 临时文件路径固定为/tmp/<随机字符串>.md + with tempfile.NamedTemporaryFile(prefix='', suffix='.md', dir='/tmp/', delete=False) as tmp_file: + temp_path = tmp_file.name + + # 将模板文件内容复制到临时文件 + Path(temp_path).write_text(content, encoding='utf-8') + + return temp_path + + +def _open_with_nvim(file_path: str) -> None: + # 获取终端环境变量,默认为xterm + term = os.environ.get('TERMINAL', '') + if not term: + term = 'xterm' + + # 获取终端名称 + name = os.path.basename(term) + + # 根据不同终端类型构建命令 + cmd = [term] + + # 根据终端类型添加特定参数 + if name == 'alacritty': + cmd.extend(['--class', 'floating-nvim', '-e', 'nvim', file_path]) + elif name == 'kitty': + cmd.extend(['--class', 'floating-nvim', 'nvim', file_path]) + elif name == 'foot': + cmd.extend(['-a', 'floating-nvim', 'nvim', file_path]) + elif name == 'wezterm': + cmd.extend(['start', '--class', 'floating-nvim', '--', 'nvim', file_path]) + elif name.startswith('gnome-terminal'): + cmd.extend(['--class=floating-nvim', '--', 'nvim', file_path]) + elif name == 'konsole': + cmd.extend(['--class', 'floating-nvim', '-e', 'nvim', file_path]) + elif name == 'urxvt': + cmd.extend(['-name', 'floating-nvim', '-e', 'nvim', file_path]) + elif name == 'xterm': + cmd.extend(['-class', 'floating-nvim', '-e', 'nvim', file_path]) + else: + cmd.extend(['-e', 'nvim', file_path]) + + # 阻塞执行命令,直到进程结束 + subprocess.run(cmd) + + +def _copy(tmp_path: str) -> None: + # 读取临时文件内容并提取代码块 + text = Path(tmp_path).read_text(encoding='utf-8') + m = re.search(r"```(?:\[[^\]]*\]|[A-Za-z0-9_+\-]*)\s*\r?\n([\s\S]*?)```", text) + if not m: + code = "" + else: + code = m.group(1).strip() + + # 检查是否有内容 + if not code: + Path(tmp_path).unlink(missing_ok=True) + return + + # 检查是否有 wl-copy 命令 + if not shutil.which('wl-copy'): + print("Error: 未安装 wl-copy") + Path(tmp_path).unlink(missing_ok=True) + return + + # 复制到剪贴板 + process = subprocess.Popen(['wl-copy'], stdin=subprocess.PIPE, text=True) + process.communicate(input=code) + + +def _calculate_file_sha256(file_path): + """ + 计算文件的SHA256哈希值 + + Args: + file_path (str): 文件路径 + + Returns: + str: 文件的SHA256哈希值(十六进制字符串) + """ + # 创建SHA256哈希对象 + sha256_hash = hashlib.sha256() + + # 以二进制模式打开文件 + with open(file_path, 'rb') as file: + # 分块读取文件,避免内存问题 + for byte_block in iter(lambda: file.read(4096), b""): + sha256_hash.update(byte_block) + + # 返回十六进制格式的哈希值 + return sha256_hash.hexdigest() + +def edit_and_copy(path: str, id: str) -> ActionResult: + tmp_path = _create_tmp(template_edit) + try: + _prefill_edit(tmp_path, path, id) + _open_with_nvim(tmp_path) + _copy(tmp_path) + # 清理临时文件 + return ActionResult(True, "") + except Exception as e: + print(f"Error: {e}") + return ActionResult(False, str(e)) + finally: + Path(tmp_path).unlink(missing_ok=True) + +def edit(path: str, id: str) -> ActionResult: + tmp_path = _create_tmp(template_edit) + primary_hash = _calculate_file_sha256(path) + try: + _prefill_edit(tmp_path, path, id) + _open_with_nvim(tmp_path) + new_hash = _calculate_file_sha256(path) + if primary_hash == new_hash: + return ActionResult(True, "文件未修改") + edit_json = _parse_edit(tmp_path, id, path) + response = run_edit(edit_json) + if response.status == "SUCCESS": + return ActionResult(True, response.data) + else: + return ActionResult(False, response.data) + except Exception as e: + print(f"Error: {e}") + return ActionResult(False, str(e)) + finally: + Path(tmp_path).unlink(missing_ok=True) + + +def add() -> ActionResult: + tmp_path = _create_tmp(template_add) + primary_hash = _calculate_file_sha256(tmp_path) + try: + _open_with_nvim(tmp_path) + new_hash = _calculate_file_sha256(tmp_path) + if primary_hash == new_hash: + return ActionResult(True, "内容为空") + add_json = _parse_add(tmp_path) + response = run_add(add_json) + if response.status == "SUCCESS": + return ActionResult(True, response.data) + else: + return ActionResult(False, response.data) + except Exception as e: + print(f"Error: {e}") + return ActionResult(False, str(e)) + finally: + Path(tmp_path).unlink(missing_ok=True) + + +if __name__ == '__main__': + tmp_path = _create_tmp(template_add) + _open_with_nvim(tmp_path) + add_json = _parse_add(tmp_path) + print(add_json) diff --git a/CodeSnippetRofi/launcher.py b/CodeSnippetRofi/launcher.py new file mode 100755 index 0000000..a4d314a --- /dev/null +++ b/CodeSnippetRofi/launcher.py @@ -0,0 +1,11 @@ +import os + +from common.constant import code_snippet_rofi +from common import rofi +from menu.MainMenu import MainMenu + +if code_snippet_rofi is None: + r = rofi.Rofi() +else: + r = rofi.Rofi(code_snippet_rofi) +MainMenu(r).run() diff --git a/CodeSnippetRofi/menu/AddMenu.py b/CodeSnippetRofi/menu/AddMenu.py new file mode 100644 index 0000000..4d7bcdb --- /dev/null +++ b/CodeSnippetRofi/menu/AddMenu.py @@ -0,0 +1,13 @@ +import rofi + +from helper.file_helper import add + + +class AddMenu: + def __init__(self,r:rofi.Rofi): + self._r = r + + def run(self): + result = add() + if not result.ok: + self._r.error(result.message) diff --git a/CodeSnippetRofi/menu/DeleteMenu.py b/CodeSnippetRofi/menu/DeleteMenu.py new file mode 100644 index 0000000..3b4e585 --- /dev/null +++ b/CodeSnippetRofi/menu/DeleteMenu.py @@ -0,0 +1,44 @@ +import rofi + +from helper.api_helper import run_search, run_delete + + +class DeleteMenu: + def __init__(self,r: rofi.Rofi): + self._r = r + + def run(self): + try: + result = run_search("") + except Exception as e: + print(f"Error: {e}") + self._r.error(str(e)) + return + if result.status != "SUCCESS": + self._r.error(result.data) + return + while True: + if len(result.data) == 0: + self._r.select("删除", ["未找到Snippet记录"]) + break + options = [f"{d.name} - {d.path}" for d in result.data] + index, key = self._r.select("删除", options) + if key == -1: + break + ConfirmMenu(self._r,result.data[index].path).run() + + +class ConfirmMenu: + def __init__(self,r:rofi.Rofi, path: str): + self._r = r + self.path = path + + def run(self): + options = ["是", "否"] + index, key = self._r.select("确定删除?", options) + if key == -1: + return + if index == 0: + res = run_delete(self.path) + if res.status != "SUCCESS": + self._r.error(res.data) diff --git a/CodeSnippetRofi/menu/EditMenu.py b/CodeSnippetRofi/menu/EditMenu.py new file mode 100644 index 0000000..c1ae001 --- /dev/null +++ b/CodeSnippetRofi/menu/EditMenu.py @@ -0,0 +1,32 @@ +import rofi + +from helper.api_helper import run_search +from helper.file_helper import edit + + +class EditMenu: + def __init__(self,r: rofi.Rofi): + self._r = r + + def run(self): + try: + result = run_search("") + except Exception as e: + print(f"Error: {e}") + self._r.error(str(e)) + return + if result.status != "SUCCESS": + self._r.error(result.data) + return + while True: + if len(result.data) == 0: + self._r.select("编辑", ["未找到Snippet记录"]) + break + options = [f"{d.name} - {d.path}" for d in result.data] + index, key = self._r.select("编辑", options) + if key == -1: + break + data = result.data[index] + res = edit(data.path, data.id) + if not res.ok: + self._r.error(res.message) diff --git a/CodeSnippetRofi/menu/MainMenu.py b/CodeSnippetRofi/menu/MainMenu.py new file mode 100644 index 0000000..9a6b1fd --- /dev/null +++ b/CodeSnippetRofi/menu/MainMenu.py @@ -0,0 +1,29 @@ +import rofi + +from menu.AddMenu import AddMenu +from menu.DeleteMenu import DeleteMenu +from menu.EditMenu import EditMenu +from menu.SearchMenu import SearchMenu + + +class MainMenu: + def __init__(self,r: rofi.Rofi): + self._r = r + + def run(self): + while True: + options = ["搜索", "编辑", "添加", "删除"] + index, key = self._r.select("Code Snippet", options) + print(f"index: {index}") + print(f"key: {key}") + if key == -1: + break + match index: + case 0: + SearchMenu(self._r).run() + case 1: + EditMenu(self._r).run() + case 2: + AddMenu(self._r).run() + case 3: + DeleteMenu(self._r).run() diff --git a/CodeSnippetRofi/menu/SearchMenu.py b/CodeSnippetRofi/menu/SearchMenu.py new file mode 100644 index 0000000..ad5d954 --- /dev/null +++ b/CodeSnippetRofi/menu/SearchMenu.py @@ -0,0 +1,71 @@ +import rofi + +from entity.response import SearchResponse +from helper.api_helper import run_search, run_delete +from helper.file_helper import edit, edit_and_copy + + +class SearchMenu: + def __init__(self, r: rofi.Rofi): + self._r = r + + def run(self): + while True: + key = self._r.text_entry("搜索", allow_blank=True) + if key is None: + break + print(key) + try: + res = run_search(key) + except Exception as e: + print(f"Error: {e}") + self._r.error(str(e)) + return + if res.status != "SUCCESS": + self._r.error(res.data) + return + SearchResultMenu(self._r, res.data).run() + + +class SearchResultMenu: + def __init__(self, r: rofi.Rofi, data: list[SearchResponse.SearchData]): + self._r = r + self._data = data + + def run(self): + while True: + if len(self._data) == 0: + self._r.select("搜索结果", ["未找到Snippet记录"]) + break + else: + # 将data按照索引和path换成dict,前者为键 + options = [f"{d.name} - {d.path}" for d in self._data] + index, key = self._r.select("搜索结果", options) + if key == -1: + break + SearchResultActionMenu(self._r, self._data[index]).run() + + +class SearchResultActionMenu: + def __init__(self, r: rofi.Rofi, data: SearchResponse.SearchData): + self._r = r + self.data = data + + def run(self): + while True: + index, key = self._r.select("操作", ["编辑", "编辑并复制", "删除"]) + if key == -1: + break + match index: + case 0: + result = edit(self.data.path, self.data.id) + if not result.ok: + self._r.error(result.message) + case 1: + result = edit_and_copy(self.data.path, self.data.id) + if not result.ok: + self._r.error(result.message) + exit() + case 2: + run_delete(self.data.path) + break diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..835e534 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Blair Bonnett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..16efde9 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# CodeSnippetDaemon + +使用 Rofi 前端 + Java 守护进程管理代码片段,支持 Markdown 存储、跨平台同步和快速索引。 +## 项目概述 +- 使用`Rofi`前端+Java守护进程的代码片段管理工具。代码片段以Markdown形式存储在指定目录中,Java守护进程负责维护索引,Rofi前端负责与守护进程进行交互,并调用其他工具完成交互操作。 +- 该项目的设计目的在于,简化‘在线预览前端’的编写,可以通过其他工具查看实际存储的代码片段内容,同时又能够做到跨平台同步。 +- 因此,选择使用`Markdown`格式存储代码片段,使用SQLite维护目录索引,前端可以使用该项目列出的`Rofi`启动,也可以根据下文列出的请求格式自行编写前端。 +- 此外,由于守护进程体量确实不大,故未采用http服务器的形式,而是选择使用普通的TCP交互(即守护进程使用SocketServer,前端使用netcat)。 + +## 依赖(Rofi前端) +- rofi +- nc +- nvim + - 推荐装有 Markdown 相关插件 +- python3 +- python-rofi + +## 工作流程 + +### 守护进程 +- 实际代码片段内容存储在对应 Markdown 文件中 +- 守护进程通过 SQLite 数据库维护目录索引,提供异常处理机制维护索引一致性 +- 使用 SocketServer 创建 TCP 服务器,可由前端连接 +- 添加片段时使用 AI 提取提供的代码片段的部分信息(标签、描述等),省却部分手动操作,也可以在添加完毕后手动进行编辑 + +### 前端 +- 默认使用 Rofi 作为启动器,已涵盖后端所需的所有操作类型 +- 借助 Python 完成复杂操作 +- 涉及到代码片段的编辑行为时,将通过调用 nvim 来编辑临时文件,编辑完毕后将发送请求至守护进程 + +## 使用方法 +1. 设置环境变量 + - CODE_SNIPPET_CONF + - 配置文件目录 + - CODE_SNIPPET_DIR + - 文件存储目录 + - CODE_SNIPPET_PORT + - TCP服务器端口 + - CODE_SNIPPET_API_KEY + - AI服务商提供的的 api_key + - CODE_SNIPPET_BASE_URL + - AI服务商提供的 base_url + - CODE_SNIPPET_MODEL + - 所用模型名称 + - CODE_SNIPPET_ROFI (可选,若未指定则使用默认 rofi) + - 指定的 rofi 脚本,可以添加参数,比如: `rofi -theme ~/.config/rofi/launchers/type-4/style-1.rasi` + +2. 启动 Java 守护进程 +3. 启动 rofi 脚本 + + +## 说明 +### rofi 菜单说明 +- 搜索 + - 编辑 + - 编辑并复制 + - 删除 +- 添加 +- 编辑 +- 删除 + +### 接口说明 +详见: [接口说明](doc/接口说明.md) + +### 模板说明 +对文件做出修改、添加文件时,看到的文档内容、可填写的内容都以模板文档为基础。 + +详见: [模板说明](doc/模板说明.md) + +## 许可 +本项目使用了修改版的 python-rofi(原项目 [python-rofi](https://github.com/bcbnz/python-rofi),MIT License)。 + +对其源码进行了少量修改,以支持自定义 rofi 命令和样式。 + +本项目整体遵循 MIT 协议,详见 LICENSE 文件。 \ No newline at end of file diff --git a/doc/resource/add.png b/doc/resource/add.png new file mode 100644 index 0000000..bc4b6d3 Binary files /dev/null and b/doc/resource/add.png differ diff --git a/doc/resource/empty-add.png b/doc/resource/empty-add.png new file mode 100644 index 0000000..ca5a869 Binary files /dev/null and b/doc/resource/empty-add.png differ diff --git a/doc/接口说明.md b/doc/接口说明.md new file mode 100644 index 0000000..082706c --- /dev/null +++ b/doc/接口说明.md @@ -0,0 +1,81 @@ +## 接口说明 +### 添加片段 +#### 请求示例 +```json +{ + "action": "ADD", + "data": { + "name": "ExtractJson", //片段名称 + "language": "Java", //代码片段语言 + "content": "public static String extractJson(String jsonStr) {\n jsonStr = jsonStr.replace(\"“\", \"\\\"\").replace(\"”\", \"\\\"\");\n int start = jsonStr.indexOf(\"{\");\n int end = jsonStr.lastIndexOf(\"}\");\n if (start != -1 && end != -1 && start < end) {\n return jsonStr.substring(start, end + 1);\n }\n return jsonStr;\n}" + } +} +``` + +#### 响应示例 +```json +{ + "data" : "代码片段已添加, 路径: /home/slhaf/Documents/code-snippet/java/ExtractJson.md", + "status" : "SUCCESS" +} +``` + +### 编辑片段 +> 该部分字段中,除`id`, `path`以外,都可进行编辑 + +#### 请求示例 +```json +{ + "action": "EDIT", + "data": { + "id": "1", //代码片段id + "path": "$CODE_SNIPPET_DIR/java/ExtractJson.md", //代码片段的实际存储路径 + "tags": "[JSON处理, 字符串操作, 文本提取]" //代码片段的标签 + "description": "从字符串中提取JSON内容的工具方法,支持处理中文引号并定位JSON对象的起始和结束位置", //代码片段描述 + "content": "public static String extractJson(String jsonStr) {\n jsonStr = jsonStr.replace(\"“\", \"\\\"\").replace(\"”\", \"\\\"\");\n int start = jsonStr.indexOf(\"{\");\n int end = jsonStr.lastIndexOf(\"}\");\n if (start != -1 && end != -1 && start < end) {\n return jsonStr.substring(start, end + 1);\n }\n return jsonStr;\n}" + } +} +``` + +#### 响应示例 +```json +{ + "data" : "文件编辑成功: /home/slhaf/Documents/code-snippet/java/ExtractJson.md", + "status" : "SUCCESS" +} +``` + +### 删除片段 +#### 请求示例 +```json +{ + "action": "DELETE", + "data": "$CODE_SNIPPET_DIR/java/ExtractJson.md" //待删除的文件 +} +``` + +#### 响应示例 +```json +{ + "data" : "删除成功: /home/slhaf/Documents/code-snippet/java/ExtractJson.md", + "status" : "SUCCESS" +} +``` +### 搜索片段 +> 搜索片段时,输入内容可由空格分隔开表明为多个匹配内容,可匹配`name`、`tags`、`description`、`language`多个字段,权重依次递减,分别为‘5、4、3、2、1’,匹配到多个字段时分值累加,最终返回的列表将依此进行由高到低的排序 + +#### 请求示例 +```json +{ + "action": "LIST", + "data": "json extract " +} +``` + +#### 响应示例 +```json +{ + "data" : "[{\"id\":\"1\",\"name\":\"ExtractJson\",\"path\":\"/home/slhaf/Documents/code-snippet/java/ExtractJson.md\",\"score\":14}]", + "status" : "SUCCESS" +} +``` \ No newline at end of file diff --git a/doc/模板说明.md b/doc/模板说明.md new file mode 100644 index 0000000..2dce026 --- /dev/null +++ b/doc/模板说明.md @@ -0,0 +1,34 @@ +## 模板说明 +### 添加片段 +```markdown + ## Snippet + + ```[Language] + <代码片段内容> + ``` + + ## MetaData + + - Name + - <代码片段名称> + - Language + - <代码片段所用语言> +``` + +### 编辑片段 +```markdown + ## Snippet + + ```[Language] + <代码片段内容> + ``` + + ## MetaData + + - Tags + - <代码片段标签> + - <可以为多个> + - <初次添加本片段时无需手动填写,由配置的LLM自动生成> + - Description + - <代码片段描述内容,默认也由LLM自动生成> +``` \ No newline at end of file diff --git a/test/data-test.json b/test/data-test.json new file mode 100644 index 0000000..730e069 --- /dev/null +++ b/test/data-test.json @@ -0,0 +1,8 @@ +{ + "action": "ADD", + "data": { + "name": "ExtractJSON", + "language": "Java", + "content": "public static String extractJson(String jsonStr) {\n jsonStr = jsonStr.replace(\"“\", \"\\\"\").replace(\"”\", \"\\\"\");\n int start = jsonStr.indexOf(\"{\");\n int end = jsonStr.lastIndexOf(\"}\");\n if (start != -1 && end != -1 && start < end) {\n return jsonStr.substring(start, end + 1);\n }\n return jsonStr;\n}" + } +} \ No newline at end of file diff --git a/test/test.json b/test/test.json new file mode 100644 index 0000000..c966901 --- /dev/null +++ b/test/test.json @@ -0,0 +1,7 @@ +{ + "tags": [ + "ByteBuddy", + "动态代理" + ], + "description": "通过ByteBuddy创建动态代理类的示例" +} \ No newline at end of file diff --git a/test/test.md b/test/test.md new file mode 100644 index 0000000..f0534ed --- /dev/null +++ b/test/test.md @@ -0,0 +1,50 @@ +## Snippet +```Java +class test { + while(true) + + { + //输入层级 + if (esc) { + break; + } + if (enter) { + //按下enter + while (true) { + //展示结果层 + if (未找到) { + //无结果 + if (esc || enter) { + break; + } + } else { + //有结果 + if (enter) { + //选中结果按下enter + while (true) { + //展示操作层 + if (esc) { + break; + } + if (enter) { + //执行操作 + doSomething(); + return; + } + } + } + } + } + } + } +} +``` + +## MetaData +- Language + - Java +- Tags + - hello world + - test +- Description + - test-description \ No newline at end of file