代码片段管理工具:rofi前端+Java守护进程

This commit is contained in:
2025-10-05 00:30:37 +08:00
commit a6b2905ad2
49 changed files with 3058 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/CodeSnippetRofi/.idea/
/CodeSnippetRofi/.venv/
/CodeSnippetDaemon/.idea/
/CodeSnippetDaemon/.mvn/
/CodeSnippetDaemon/target/
/CodeSnippetDaemon/dependency-reduced-pom.xml
/CodeSnippetDaemon/pom.xml
/.idea/

View File

@@ -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<String, String> sha2PathDB = indexManager.getIndexStatus();
HashMap<String, String> sha2PathMD = snippetManager.getFileStatus();
if (!sha2PathMD.equals(sha2PathDB)) {
log.info("数据库与文件目录不一致,重建索引数据库");
List<RebuildEntity> snippets = snippetManager.listAll();
Set<Index> 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;
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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<Message> 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;
}
}

View File

@@ -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";
}
}

View File

@@ -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<Message> 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ChoicesBean> 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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,16 @@
package work.slhaf.snippet.entity.file;
import lombok.Data;
@Data
public class ListEntity implements Comparable<ListEntity> {
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);
}
}

View File

@@ -0,0 +1,9 @@
package work.slhaf.snippet.entity.file;
import lombok.Data;
@Data
public class MetaDataEntity {
private String description;
private String[] tags;
}

View File

@@ -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;
}

View File

@@ -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());
}
}
}
}

View File

@@ -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());
}
}
}
}
}

View File

@@ -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<ListEntity> 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;
}
}

View File

@@ -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<Index> 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<ListEntity> list(String input) throws SQLException {
String[] keywords = input.trim().toLowerCase().split("\\s+");
// 构建 SQL 占位符字符串
StringBuilder sb = new StringBuilder("SELECT * FROM code_snippet_index WHERE ");
List<String> 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<ListEntity> 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<String> 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<String, String> getIndexStatus() throws SQLException {
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("""
select sha,path from code_snippet_index
""");
HashMap<String, String> map = new HashMap<>();
while (rs.next()) {
map.put(rs.getString("path"), rs.getString("sha"));
}
return map;
}
}

View File

@@ -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<Message> messages = List.of(
new Message(ChatConstant.Character.SYSTEM, """
你需要对接下来发送的代码相关内容进行提取,提取出‘描述’、‘标签’等内容。
输入格式示例:
```json
{
"name": "ByteBuddy用法示例", //代码片段名称
"language": "Java", //代码片段所属语言
"content": "Class<? extends Module> clazz = module.getClazz();\\nClass<? extends Module> 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<Message> 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);
}
}

View File

@@ -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<String, String> getFileStatus() {
File file = new File(System.getenv(Constant.Property.DIR));
HashMap<String, String> map = new HashMap<>();
listFileStatus(file, map);
return map;
}
private void listFileStatus(File file, HashMap<String, String> 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<RebuildEntity> listAll() throws IOException {
log.info("获取数据目录[{}]文件信息", System.getenv(Constant.Property.DIR));
List<RebuildEntity> list = new ArrayList<>();
File file = new File(System.getenv(Constant.Property.DIR));
listAll(file, list);
return list;
}
private void listAll(File file, List<RebuildEntity> 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
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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());
}

View File

@@ -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
-
"""

View File

@@ -0,0 +1,823 @@
#
# python-rofi
#
# The MIT License
#
# Copyright (c) 2016, 2017 Blair Bonnett <blair.bonnett@gmail.com>
#
# 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: '&amp;'}
).translate({
34: '&quot;',
39: '&apos;',
60: '&lt;',
62: '&gt;'
})
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("<b>{0:s}</b>: {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 = '<span color="#FF0000" font_weight="bold">{0:s}</span>\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)

View File

@@ -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"]

View File

@@ -0,0 +1,4 @@
class ActionResult:
def __init__(self, ok: bool, message: str):
self.ok = ok
self.message = message

View File

@@ -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))

View File

@@ -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<fence_lang>[^\]]*)\]|(?P<fence_lang2>[A-Za-z0-9_+\-]+))?\s*\r?\n(?P<code>[\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<meta>[\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)

11
CodeSnippetRofi/launcher.py Executable file
View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

21
LICENSE Normal file
View File

@@ -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.

75
README.md Normal file
View File

@@ -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 文件。

BIN
doc/resource/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
doc/resource/empty-add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

81
doc/接口说明.md Normal file
View File

@@ -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"
}
```

34
doc/模板说明.md Normal file
View File

@@ -0,0 +1,34 @@
## 模板说明
### 添加片段
```markdown
## Snippet
```[Language]
<代码片段内容>
```
## MetaData
- Name
- <代码片段名称>
- Language
- <代码片段所用语言>
```
### 编辑片段
```markdown
## Snippet
```[Language]
<代码片段内容>
```
## MetaData
- Tags
- <代码片段标签>
- <可以为多个>
- <初次添加本片段时无需手动填写由配置的LLM自动生成>
- Description
- <代码片段描述内容默认也由LLM自动生成>
```

8
test/data-test.json Normal file
View File

@@ -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}"
}
}

7
test/test.json Normal file
View File

@@ -0,0 +1,7 @@
{
"tags": [
"ByteBuddy",
"动态代理"
],
"description": "通过ByteBuddy创建动态代理类的示例"
}

50
test/test.md Normal file
View File

@@ -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