diff --git a/cyynote-backend/cyynote.db b/cyynote-backend/cyynote.db index a1a90cb..3574502 100644 Binary files a/cyynote-backend/cyynote.db and b/cyynote-backend/cyynote.db differ diff --git a/cyynote-backend/pom.xml b/cyynote-backend/pom.xml index bcc0643..2773217 100644 --- a/cyynote-backend/pom.xml +++ b/cyynote-backend/pom.xml @@ -84,6 +84,13 @@ solon-test test + + + + software.amazon.awssdk + s3 + 2.25.16 + diff --git a/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.java b/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.java index bf5530e..5152a0c 100644 --- a/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.java +++ b/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.java @@ -1,6 +1,7 @@ package com.cyynote.controller; import cn.hutool.core.date.DateUtil; +import cn.hutool.crypto.digest.DigestUtil; import com.alibaba.fastjson2.JSONObject; import com.cyynote.model.UserModel; import com.cyynote.payload.request.LoginRequest; @@ -42,9 +43,8 @@ public class AuthController extends BaseController { } log.info("signin username:" + loginRequest.getUsername()); - UserModel model = (UserModel) ((DbTableQuery) ((DbTableQuery) this.db.table("user") + UserModel model = (UserModel) ((DbTableQuery) this.db.table("user") .whereEq("username", loginRequest.getUsername())) - .andEq("password", loginRequest.getPassword())) .selectItem("*", UserModel.class); if (model == null) { @@ -52,6 +52,29 @@ public class AuthController extends BaseController { return Result.failure(401, "用户名或密码错误"); } + boolean passwordMatch; + if (model.getPasswordHashed() == null || model.getPasswordHashed() == 0) { + // Plain-text comparison; migrate to hash on success + passwordMatch = loginRequest.getPassword().equals(model.getPassword()); + if (passwordMatch) { + String hashed = DigestUtil.sha256Hex(loginRequest.getPassword()); + ((DbTableQuery) this.db.table("user") + .set("password", hashed) + .set("password_hashed", 1) + .whereEq("id", model.getId())) + .update(); + model.setPassword(hashed); + model.setPasswordHashed(1); + } + } else { + passwordMatch = DigestUtil.sha256Hex(loginRequest.getPassword()).equals(model.getPassword()); + } + + if (!passwordMatch) { + ctx.status(401); + return Result.failure(401, "用户名或密码错误"); + } + ctx.sessionSet("user_name", model.getUsername()); ctx.sessionSet("user_id", model.getId()); String token = ctx.sessionState().sessionToken(); @@ -84,17 +107,30 @@ public class AuthController extends BaseController { } log.info("update password username:" + loginRequest.getUsername()); - UserModel model = (UserModel) ((DbTableQuery) ((DbTableQuery) this.db.table("user") + UserModel model = (UserModel) ((DbTableQuery) this.db.table("user") .whereEq("username", loginRequest.getUsername())) - .andEq("password", loginRequest.getOldpassword())) .selectItem("*", UserModel.class); if (model == null) { ctx.status(400); return Result.failure(400, "用户名或旧密码错误"); } + boolean oldPasswordMatch; + if (model.getPasswordHashed() == null || model.getPasswordHashed() == 0) { + oldPasswordMatch = loginRequest.getOldpassword().equals(model.getPassword()); + } else { + oldPasswordMatch = DigestUtil.sha256Hex(loginRequest.getOldpassword()).equals(model.getPassword()); + } + + if (!oldPasswordMatch) { + ctx.status(400); + return Result.failure(400, "用户名或旧密码错误"); + } + + String newHashed = DigestUtil.sha256Hex(loginRequest.getNewpassword()); int flag = ((DbTableQuery) this.db.table("user") - .set("password", loginRequest.getNewpassword()) + .set("password", newHashed) + .set("password_hashed", 1) .whereEq("id", model.getId())) .update(); return Result.succeed(flag); @@ -120,7 +156,8 @@ public class AuthController extends BaseController { UserModel user = new UserModel(); user.setUsername(signUpRequest.getUsername()); user.setEmail(signUpRequest.getEmail()); - user.setPassword(signUpRequest.getPassword()); + user.setPassword(DigestUtil.sha256Hex(signUpRequest.getPassword())); + user.setPasswordHashed(1); user.setRole("ROLE_USER"); this.db.table("user").setEntityIf(user, (k, v) -> Boolean.valueOf(v != null)).insert(); return Result.succeed("注册成功"); @@ -145,4 +182,3 @@ public class AuthController extends BaseController { return List.of("ROLE_USER"); } } - diff --git a/cyynote-backend/src/main/java/com/cyynote/controller/DriveController.java b/cyynote-backend/src/main/java/com/cyynote/controller/DriveController.java new file mode 100644 index 0000000..e971cd1 --- /dev/null +++ b/cyynote-backend/src/main/java/com/cyynote/controller/DriveController.java @@ -0,0 +1,477 @@ +package com.cyynote.controller; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.IdUtil; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.cyynote.model.DriveFileModel; +import com.cyynote.payload.request.NoteRequest; +import io.jsonwebtoken.Claims; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.SQLException; +import java.util.List; +import java.util.logging.Logger; +import org.noear.solon.annotation.Body; +import org.noear.solon.annotation.Controller; +import org.noear.solon.annotation.Get; +import org.noear.solon.annotation.Mapping; +import org.noear.solon.annotation.Post; +import org.noear.solon.core.handle.Context; +import org.noear.solon.core.handle.Result; +import org.noear.solon.core.handle.UploadedFile; +import org.noear.wood.DataItem; +import org.noear.wood.DbContext; +import org.noear.wood.DbTableQuery; +import org.noear.wood.annotation.Db; +import org.noear.solon.sessionstate.jwt.JwtUtils; + +@Mapping("/api/drive") +@Controller +public class DriveController extends BaseController { + private static final Logger log = Logger.getLogger(DriveController.class.getName()); + private static final String DEFAULT_LOCAL_DIR = "./local_drive"; + + @Db + DbContext db; + + /** List files for current user */ + @Get + @Mapping("/list") + public Result listFiles(Context ctx) throws SQLException { + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("drive_file").whereEq("flag", 1); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + List files = q.orderByDesc("createtime") + .selectList("id,filename,original_name,size,mime_type,share_token,share_expiry,createtime,userid,storage_mode,local_path", DriveFileModel.class); + return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(files))); + } + + /** Upload file to local server storage */ + @Post + @Mapping("/upload-local") + public Result uploadLocal(Context ctx) throws SQLException, IOException { + String userId = getUserInfoId(ctx); + if (userId == null) { + ctx.status(401); + return Result.failure(401, "未登录"); + } + UploadedFile uploadedFile = ctx.file("file"); + if (uploadedFile == null) { + ctx.status(400); + return Result.failure(400, "未选择文件"); + } + + // Resolve storage directory from settings, fallback to default + String localDir = getLocalStoragePath(); + Path dirPath = Paths.get(localDir); + Files.createDirectories(dirPath); + + // Generate unique filename to avoid collisions + String ext = ""; + String originalName = uploadedFile.getName(); + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) ext = originalName.substring(dotIdx); + String uniqueFilename = IdUtil.fastSimpleUUID() + ext; + Path filePath = dirPath.resolve(uniqueFilename); + + // Save file + Files.copy(uploadedFile.getContent(), filePath); + + DriveFileModel file = new DriveFileModel(); + file.setFilename(uniqueFilename); + file.setOriginalName(originalName); + file.setSize(uploadedFile.getSize()); + file.setMimeType(uploadedFile.getContentType()); + file.setStorageMode("local"); + file.setLocalPath(filePath.toString()); + file.setUserid(Integer.valueOf(userId)); + file.setCreatetime(DateUtil.now()); + file.setFlag(1); + this.db.table("drive_file").setEntityIf(file, (k, v) -> Boolean.valueOf(v != null)).insert(); + return Result.succeed("ok"); + } + + /** Download a local file by record id */ + @Get + @Mapping("/download/{id}") + public void downloadLocal(Context ctx, int id) throws SQLException, IOException { + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("drive_file").whereEq("id", id).whereEq("flag", 1); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + DriveFileModel file = q.selectItem("id,original_name,local_path,mime_type,storage_mode", DriveFileModel.class); + if (file == null || !"local".equals(file.getStorageMode())) { + ctx.status(404); + ctx.output("文件不存在"); + return; + } + File f = new File(file.getLocalPath()); + if (!f.exists()) { + ctx.status(404); + ctx.output("文件已被移除"); + return; + } + String mimeType = file.getMimeType() != null ? file.getMimeType() : "application/octet-stream"; + String encodedName = URLEncoder.encode(file.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20"); + ctx.headerSet("Content-Type", mimeType); + ctx.headerSet("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName); + ctx.headerSet("Content-Length", String.valueOf(f.length())); + try (FileInputStream fis = new FileInputStream(f); OutputStream out = ctx.outputStream()) { + fis.transferTo(out); + } + } + + /** Register a file record (called after frontend uploads directly to R2) */ + @Post + @Mapping("/register") + public Result registerFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + if (userId == null) { + ctx.status(401); + return Result.failure(401, "未登录"); + } + DriveFileModel file = new DriveFileModel(); + file.setFilename(body.getString("filename")); + file.setOriginalName(body.getString("original_name")); + file.setSize(body.getLong("size")); + file.setMimeType(body.getString("mime_type")); + file.setR2Key(body.getString("r2_key")); + file.setStorageMode("r2"); + file.setUserid(Integer.valueOf(userId)); + file.setCreatetime(DateUtil.now()); + file.setFlag(1); + this.db.table("drive_file").setEntityIf(file, (k, v) -> Boolean.valueOf(v != null)).insert(); + return Result.succeed("ok"); + } + + /** Generate or refresh share link for a file */ + @Post + @Mapping("/share") + public Result shareFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null || body.getInteger("id") == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + int fileId = body.getInteger("id"); + DbTableQuery q = this.db.table("drive_file").whereEq("id", fileId); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + DriveFileModel file = q.selectItem("*", DriveFileModel.class); + if (file == null) { + ctx.status(404); + return Result.failure(404, "文件不存在"); + } + String token = IdUtil.fastSimpleUUID(); + Integer days = body.getInteger("days"); + String expiry = (days != null && days > 0) + ? DateUtil.offsetDay(DateUtil.date(), days).toString() + : null; + this.db.table("drive_file") + .set("share_token", token) + .set("share_expiry", expiry) + .whereEq("id", fileId) + .update(); + JSONObject result = new JSONObject(); + result.put("share_token", token); + result.put("share_expiry", expiry); + return Result.succeed(result); + } + + /** Revoke share link */ + @Post + @Mapping("/unshare") + public Result unshareFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null || body.getInteger("id") == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + int fileId = body.getInteger("id"); + DbTableQuery q = this.db.table("drive_file") + .set("share_token", null) + .set("share_expiry", null) + .whereEq("id", fileId); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + q.update(); + return Result.succeed("ok"); + } + + /** Soft delete a file */ + @Post + @Mapping("/delete") + public Result deleteFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null || body.getInteger("id") == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + int fileId = body.getInteger("id"); + DbTableQuery q = this.db.table("drive_file").set("flag", 0).whereEq("id", fileId); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + int rows = q.update(); + if (rows == 0) { + ctx.status(404); + return Result.failure(404, "文件不存在"); + } + return Result.succeed("ok"); + } + + /** Public download via share token (no auth required) */ + @Get + @Mapping("/public/{token}") + public Result publicDownload(Context ctx, String token) throws SQLException { + if (token == null || token.isBlank()) { + ctx.status(400); + return Result.failure(400, "无效的分享链接"); + } + DriveFileModel file = this.db.table("drive_file") + .whereEq("share_token", token) + .whereEq("flag", 1) + .selectItem("id,filename,original_name,r2_key,share_expiry,mime_type,storage_mode,local_path", DriveFileModel.class); + if (file == null) { + ctx.status(404); + return Result.failure(404, "分享链接不存在或已失效"); + } + if (file.getShareExpiry() != null && !file.getShareExpiry().isBlank()) { + try { + if (DateUtil.parseDateTime(file.getShareExpiry()).before(DateUtil.date())) { + ctx.status(410); + return Result.failure(410, "分享链接已过期"); + } + } catch (Exception ignored) {} + } + JSONObject result = new JSONObject(); + result.put("original_name", file.getOriginalName()); + result.put("r2_key", file.getR2Key()); + result.put("mime_type", file.getMimeType()); + result.put("storage_mode", file.getStorageMode()); + result.put("local_download_url", "local".equals(file.getStorageMode()) + ? "/api/drive/public-download/" + token : null); + return Result.succeed(result); + } + + /** Public direct download for local files via share token */ + @Get + @Mapping("/public-download/{token}") + public void publicLocalDownload(Context ctx, String token) throws SQLException, IOException { + DriveFileModel file = this.db.table("drive_file") + .whereEq("share_token", token) + .whereEq("flag", 1) + .selectItem("id,original_name,local_path,mime_type,storage_mode,share_expiry", DriveFileModel.class); + if (file == null || !"local".equals(file.getStorageMode())) { + ctx.status(404); ctx.output("文件不存在"); return; + } + if (file.getShareExpiry() != null && !file.getShareExpiry().isBlank()) { + try { + if (DateUtil.parseDateTime(file.getShareExpiry()).before(DateUtil.date())) { + ctx.status(410); ctx.output("分享链接已过期"); return; + } + } catch (Exception ignored) {} + } + File f = new File(file.getLocalPath()); + if (!f.exists()) { ctx.status(404); ctx.output("文件已被移除"); return; } + String mimeType = file.getMimeType() != null ? file.getMimeType() : "application/octet-stream"; + String encodedName = URLEncoder.encode(file.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20"); + ctx.headerSet("Content-Type", mimeType); + ctx.headerSet("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName); + ctx.headerSet("Content-Length", String.valueOf(f.length())); + try (FileInputStream fis = new FileInputStream(f); OutputStream out = ctx.outputStream()) { + fis.transferTo(out); + } + } + + private String getLocalStoragePath() { + try { + DataItem row = (DataItem) db.table("settings").whereEq("key", "local_storage_path").selectItem("value", DataItem.class); + if (row != null) { + String v = row.getString("value"); + if (v != null && !v.isBlank()) return v; + } + } catch (Exception ignored) {} + return DEFAULT_LOCAL_DIR; + } + + private String getUserInfoId(Context ctx) { + if (ctx.header("Authorization") != null) { + String token = ctx.header("Authorization").replace("Bearer ", ""); + Claims claims = JwtUtils.parseJwt(token); + if (claims != null) return claims.get("user_id").toString(); + } + return null; + } +} + + +@Mapping("/api/drive") +@Controller +public class DriveController extends BaseController { + private static final Logger log = Logger.getLogger(DriveController.class.getName()); + + @Db + DbContext db; + + /** List files for current user */ + @Get + @Mapping("/list") + public Result listFiles(Context ctx) throws SQLException { + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("drive_file").whereEq("flag", 1); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + List files = q.orderByDesc("createtime") + .selectList("id,filename,original_name,size,mime_type,share_token,share_expiry,createtime,userid", DriveFileModel.class); + return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(files))); + } + + /** Register a file record (called after frontend uploads directly to R2, or used for server-side upload) */ + @Post + @Mapping("/register") + public Result registerFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + if (userId == null) { + ctx.status(401); + return Result.failure(401, "未登录"); + } + DriveFileModel file = new DriveFileModel(); + file.setFilename(body.getString("filename")); + file.setOriginalName(body.getString("original_name")); + file.setSize(body.getLong("size")); + file.setMimeType(body.getString("mime_type")); + file.setR2Key(body.getString("r2_key")); + file.setUserid(Integer.valueOf(userId)); + file.setCreatetime(DateUtil.now()); + file.setFlag(1); + this.db.table("drive_file").setEntityIf(file, (k, v) -> Boolean.valueOf(v != null)).insert(); + return Result.succeed("ok"); + } + + /** Generate or refresh share link for a file */ + @Post + @Mapping("/share") + public Result shareFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null || body.getInteger("id") == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + int fileId = body.getInteger("id"); + // Verify ownership + DbTableQuery q = this.db.table("drive_file").whereEq("id", fileId); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + DriveFileModel file = q.selectItem("*", DriveFileModel.class); + if (file == null) { + ctx.status(404); + return Result.failure(404, "文件不存在"); + } + String token = IdUtil.fastSimpleUUID(); + // Expiry: 7 days by default, or "forever" if expiry=0 + Integer days = body.getInteger("days"); + String expiry = (days != null && days > 0) + ? DateUtil.offsetDay(DateUtil.date(), days).toString() + : null; + this.db.table("drive_file") + .set("share_token", token) + .set("share_expiry", expiry) + .whereEq("id", fileId) + .update(); + JSONObject result = new JSONObject(); + result.put("share_token", token); + result.put("share_expiry", expiry); + return Result.succeed(result); + } + + /** Revoke share link */ + @Post + @Mapping("/unshare") + public Result unshareFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null || body.getInteger("id") == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + int fileId = body.getInteger("id"); + DbTableQuery q = this.db.table("drive_file") + .set("share_token", null) + .set("share_expiry", null) + .whereEq("id", fileId); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + q.update(); + return Result.succeed("ok"); + } + + /** Soft delete a file */ + @Post + @Mapping("/delete") + public Result deleteFile(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null || body.getInteger("id") == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + String userId = getUserInfoId(ctx); + int fileId = body.getInteger("id"); + DbTableQuery q = this.db.table("drive_file").set("flag", 0).whereEq("id", fileId); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + int rows = q.update(); + if (rows == 0) { + ctx.status(404); + return Result.failure(404, "文件不存在"); + } + return Result.succeed("ok"); + } + + /** Public download via share token (no auth required) */ + @Get + @Mapping("/public/{token}") + public Result publicDownload(Context ctx, String token) throws SQLException { + if (token == null || token.isBlank()) { + ctx.status(400); + return Result.failure(400, "无效的分享链接"); + } + DriveFileModel file = this.db.table("drive_file") + .whereEq("share_token", token) + .whereEq("flag", 1) + .selectItem("id,filename,original_name,r2_key,share_expiry,mime_type", DriveFileModel.class); + if (file == null) { + ctx.status(404); + return Result.failure(404, "分享链接不存在或已失效"); + } + // Check expiry + if (file.getShareExpiry() != null && !file.getShareExpiry().isBlank()) { + try { + if (DateUtil.parseDateTime(file.getShareExpiry()).before(DateUtil.date())) { + ctx.status(410); + return Result.failure(410, "分享链接已过期"); + } + } catch (Exception ignored) {} + } + // Return R2 key for frontend to construct download URL, or redirect to public R2 URL + JSONObject result = new JSONObject(); + result.put("original_name", file.getOriginalName()); + result.put("r2_key", file.getR2Key()); + result.put("mime_type", file.getMimeType()); + return Result.succeed(result); + } + + private String getUserInfoId(Context ctx) { + if (ctx.header("Authorization") != null) { + String token = ctx.header("Authorization").replace("Bearer ", ""); + Claims claims = JwtUtils.parseJwt(token); + if (claims != null) return claims.get("user_id").toString(); + } + return null; + } +} diff --git a/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java b/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java index 5a58aa7..a2c7f1e 100644 --- a/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java +++ b/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java @@ -40,7 +40,7 @@ public class NoteController extends BaseController { @Db DbContext db; - private static final String QUERY_F = "id,pid,title,createtime,updatetime,viewtime,flag"; + private static final String QUERY_F = "id,pid,title,createtime,updatetime,viewtime,flag,tags"; @Get @Mapping("get1") @@ -64,7 +64,10 @@ public class NoteController extends BaseController { @Get @Mapping("/all") public Result noteAccess(Context ctx) throws Exception { - List all = ((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + String userId = getUserInfoId(ctx); + DbTableQuery query = this.db.table("note").whereEq("flag", 1); + if (userId != null) query = query.andEq("userid", Integer.valueOf(userId)); + List all = query.selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); List treeNodeList = new ArrayList<>(); for (NoteModel n : all) { TreeNode treeNode = new TreeNode(n.getId().intValue(), n.getPid().intValue(), n.getTitle(), String.valueOf(n.getId()), String.valueOf(n.getId())); @@ -78,17 +81,29 @@ public class NoteController extends BaseController { @Get @Mapping("/home") public Result noteHome(Context ctx, @Path int type, @Path String search) throws Exception { + String userId = getUserInfoId(ctx); List all = new ArrayList<>(); if (type == 0) { - all = ((DbTableQuery)((DbTableQuery)this.db.table("note").whereLk("context", "%" + search + "%")).andLk("title", "%" + search + "%")).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + DbTableQuery q = this.db.table("note") + .where("flag = 1 AND (title LIKE ? OR context LIKE ?)", "%" + search + "%", "%" + search + "%"); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + all = q.selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); } else if (type == 1) { - all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("updatetime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + DbTableQuery q = this.db.table("note").whereEq("flag", 1); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + all = q.orderByDesc("updatetime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); } else if (type == 2) { - all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("createtime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + DbTableQuery q = this.db.table("note").whereEq("flag", 1); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + all = q.orderByDesc("createtime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); } else if (type == 3) { - all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("viewtime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + DbTableQuery q = this.db.table("note").whereEq("flag", 1); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + all = q.orderByDesc("viewtime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); } else if (type == 4) { - all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(0))).orderByDesc("updatetime")).limit(10000)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + DbTableQuery q = this.db.table("note").whereEq("flag", 0); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + all = q.orderByDesc("updatetime").limit(10000).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); } return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(all))); } @@ -96,15 +111,17 @@ public class NoteController extends BaseController { @Get @Mapping("/get") public Result getNote(Context ctx, @Path int id) throws Exception { - NoteModel model = (NoteModel)((DbTableQuery)this.db.table("note").whereEq("id", Integer.valueOf(id))).selectItem("*", NoteModel.class); + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("note").whereEq("id", id); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + NoteModel model = q.selectItem("*", NoteModel.class); if (model == null) { ctx.status(404); return Result.failure(404, "笔记不存在"); } String time = DateUtil.now(); - ((DbTableQuery)this.db.table("note").set("viewtime", time).whereEq("id", Integer.valueOf(id))).update(); + this.db.table("note").set("viewtime", time).whereEq("id", id).update(); model.setViewtime(time); - log.info(JSONObject.toJSONString(model, new com.alibaba.fastjson2.JSONWriter.Feature[0])); return Result.succeed(model); } @@ -121,9 +138,8 @@ public class NoteController extends BaseController { note.setCreatetime(DateUtil.now()); note.setUpdatetime(note.getCreatetime()); note.setViewtime(note.getCreatetime()); + note.setUserid(Integer.valueOf(userid)); note.setContext("{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"未命名Note\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}"); - -// note.setContext("{\"root\": {\"type\": \"root\", \"format\": \"\", \"indent\": 0, \"version\": 1, \"children\": [{\"type\": \"paragraph\", \"format\": \"\", \"indent\": 0, \"version\": 1, \"children\": [], \"direction\": null}], \"direction\": null}}"); this.db.table("note").setEntityIf(note, (k, v) -> Boolean.valueOf((v != null))).insert(); return Result.succeed("ok"); } @@ -135,26 +151,32 @@ public class NoteController extends BaseController { ctx.status(400); return Result.failure(400, "参数不完整"); } - NoteModel note = (NoteModel)((DbTableQuery)this.db.table("note").whereEq("id", noteRequest.getId())).selectItem("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("note").whereEq("id", noteRequest.getId()); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + NoteModel note = q.selectItem("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class); if (note == null) { ctx.status(404); return Result.failure(404, "笔记不存在"); } - note.setTitle(noteRequest.getTitle()); - note.setUpdatetime(DateUtil.now()); String cc = noteRequest.getContext(); JSONObject ar = JSONObject.parseObject(cc); - note.setContext(ar.toJSONString()); - int flag = ((DbTableQuery)this.db.table("note").set("title", noteRequest.getTitle()).set("updatetime", DateUtil.now()).set("context", ar.toJSONString()).whereEq("id", noteRequest.getId())).update(); - log.info("updateNote+ flag"); - HistoryModel history = new HistoryModel(); + DbTableQuery uq = this.db.table("note") + .set("title", noteRequest.getTitle()) + .set("updatetime", DateUtil.now()) + .set("context", ar.toJSONString()) + .set("tags", noteRequest.getTags() != null ? noteRequest.getTags() : "") + .whereEq("id", noteRequest.getId()); + if (userId != null) uq = uq.andEq("userid", Integer.valueOf(userId)); + uq.update(); + log.info("updateNote id=" + noteRequest.getId()); + HistoryModel history = new HistoryModel(); history.setNid(Integer.valueOf(noteRequest.getId().intValue())); history.setTitle(noteRequest.getTitle()); history.setFlag(Integer.valueOf(1)); history.setCreatetime(DateUtil.now()); history.setContext(ar.toJSONString()); - long his = this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert(); - log.info("history+ his"); + this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert(); return Result.succeed("ok"); } @@ -165,7 +187,10 @@ public class NoteController extends BaseController { ctx.status(400); return Result.failure(400, "笔记ID不能为空"); } - int flag = ((DbTableQuery)this.db.table("note").set("flag", Integer.valueOf(0)).set("updatetime", DateUtil.now()).whereEq("id", noteRequest.getId())).update(); + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("note").set("flag", 0).set("updatetime", DateUtil.now()).whereEq("id", noteRequest.getId()); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + int flag = q.update(); if (flag == 0) { ctx.status(404); return Result.failure(404, "笔记不存在"); @@ -176,7 +201,28 @@ public class NoteController extends BaseController { @Get @Mapping("/deleteBack") public Result deleteBack(Context ctx, @Path int id) throws Exception { - int flag = ((DbTableQuery)this.db.table("note").set("flag", Integer.valueOf(1)).set("updatetime", DateUtil.now()).whereEq("id", Integer.valueOf(id))).update(); + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("note").set("flag", 1).set("updatetime", DateUtil.now()).whereEq("id", id); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + int flag = q.update(); + if (flag == 0) { + ctx.status(404); + return Result.failure(404, "笔记不存在"); + } + return Result.succeed("ok"); + } + + @Post + @Mapping("/move") + public Result moveNote(Context ctx, @Body NoteRequest noteRequest) throws SQLException { + if (noteRequest == null || noteRequest.getId() == null || noteRequest.getPid() == null) { + ctx.status(400); + return Result.failure(400, "参数不完整"); + } + String userId = getUserInfoId(ctx); + DbTableQuery q = this.db.table("note").set("pid", noteRequest.getPid()).set("updatetime", DateUtil.now()).whereEq("id", noteRequest.getId()); + if (userId != null) q = q.andEq("userid", Integer.valueOf(userId)); + int flag = q.update(); if (flag == 0) { ctx.status(404); return Result.failure(404, "笔记不存在"); @@ -187,21 +233,52 @@ public class NoteController extends BaseController { @Get @Mapping("/historyQueryOrDelete") public Result historyQueryOrDelete(@Path int flag) throws Exception { - log.info("history+ flag" + flag); - - if(flag==0){ + if (flag == 0) { long count = db.table("history").selectCount(); log.info("count:" + count); return Result.succeed(count); - }else if(flag==1){ - int flagdb = db.table("history").whereEq("flag",1).delete(); + } else if (flag == 1) { + int flagdb = db.table("history").whereEq("flag", 1).delete(); log.info("flagdb:" + flagdb); return Result.succeed(0); } return Result.failure(400, "不支持的操作类型"); } + @Get + @Mapping("/history") + public Result getNoteHistory(Context ctx, @Path int nid) throws Exception { + if (nid <= 0) { + ctx.status(400); + return Result.failure(400, "笔记ID不能为空"); + } + List list = this.db.table("history") + .whereEq("nid", nid) + .whereEq("flag", 1) + .orderByDesc("createtime") + .limit(50) + .selectList("id,nid,title,createtime", HistoryModel.class); + return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(list))); + } + + @Get + @Mapping("/history/content") + public Result getHistoryContent(Context ctx, @Path int id) throws Exception { + if (id <= 0) { + ctx.status(400); + return Result.failure(400, "历史ID不能为空"); + } + HistoryModel model = this.db.table("history") + .whereEq("id", id) + .selectItem("*", HistoryModel.class); + if (model == null) { + ctx.status(404); + return Result.failure(404, "历史记录不存在"); + } + return Result.succeed(model); + } + private String getUserInfoId(Context ctx) { String userId = ""; if (null != ctx.header("Authorization")) { diff --git a/cyynote-backend/src/main/java/com/cyynote/controller/SettingsController.java b/cyynote-backend/src/main/java/com/cyynote/controller/SettingsController.java new file mode 100644 index 0000000..a9608ba --- /dev/null +++ b/cyynote-backend/src/main/java/com/cyynote/controller/SettingsController.java @@ -0,0 +1,86 @@ +package com.cyynote.controller; + +import com.alibaba.fastjson2.JSONObject; +import java.sql.SQLException; +import java.util.List; +import java.util.logging.Logger; +import org.noear.solon.annotation.Body; +import org.noear.solon.annotation.Controller; +import org.noear.solon.annotation.Get; +import org.noear.solon.annotation.Mapping; +import org.noear.solon.annotation.Post; +import org.noear.solon.core.handle.Context; +import org.noear.solon.core.handle.Result; +import org.noear.wood.DbContext; +import org.noear.wood.DbTableQuery; +import org.noear.wood.DataItem; +import org.noear.wood.annotation.Db; + +@Mapping("/api/settings") +@Controller +public class SettingsController extends BaseController { + private static final Logger log = Logger.getLogger(SettingsController.class.getName()); + + @Db + DbContext db; + + // Allowed setting keys (whitelist for security) + private static final List ALLOWED_KEYS = List.of( + "r2_endpoint", "r2_access_key_id", "r2_secret_access_key", "r2_bucket_name", + "r2_public_url", "app_theme", "local_storage_path" + ); + + @Get + @Mapping("/get/{key}") + public Result getSetting(Context ctx, String key) throws SQLException { + if (!ALLOWED_KEYS.contains(key)) { + ctx.status(400); + return Result.failure(400, "不支持的配置项"); + } + DataItem row = (DataItem) this.db.table("settings").whereEq("key", key).selectItem("key,value", DataItem.class); + if (row == null) return Result.succeed(null); + String value = row.getString("value"); + if ("r2_secret_access_key".equals(key) && value != null && value.length() > 4) { + value = "****" + value.substring(value.length() - 4); + } + return Result.succeed(value); + } + + @Get + @Mapping("/all") + public Result getAllSettings(Context ctx) throws SQLException { + JSONObject result = new JSONObject(); + for (String key : ALLOWED_KEYS) { + DataItem row = (DataItem) this.db.table("settings").whereEq("key", key).selectItem("key,value", DataItem.class); + String value = row == null ? null : row.getString("value"); + if ("r2_secret_access_key".equals(key) && value != null && value.length() > 4) { + value = "****" + value.substring(value.length() - 4); + } + result.put(key, value); + } + return Result.succeed(result); + } + + @Post + @Mapping("/set") + public Result setSetting(Context ctx, @Body JSONObject body) throws SQLException { + if (body == null) { + ctx.status(400); + return Result.failure(400, "参数不能为空"); + } + for (String key : body.keySet()) { + if (!ALLOWED_KEYS.contains(key)) { + ctx.status(400); + return Result.failure(400, "不支持的配置项: " + key); + } + String value = body.getString(key); + long exists = this.db.table("settings").whereEq("key", key).selectCount(); + if (exists > 0) { + this.db.table("settings").set("value", value).whereEq("key", key).update(); + } else { + this.db.table("settings").set("key", key).set("value", value).insert(); + } + } + return Result.succeed("ok"); + } +} diff --git a/cyynote-backend/src/main/java/com/cyynote/dso/DsHelper.java b/cyynote-backend/src/main/java/com/cyynote/dso/DsHelper.java index 23a4dda..03c999b 100644 --- a/cyynote-backend/src/main/java/com/cyynote/dso/DsHelper.java +++ b/cyynote-backend/src/main/java/com/cyynote/dso/DsHelper.java @@ -1,59 +1,174 @@ package com.cyynote.dso; - import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; import javax.sql.DataSource; public class DsHelper { + private static final Logger log = Logger.getLogger(DsHelper.class.getName()); private static boolean inited = false; public static void initData(DataSource ds) { - if (inited) - return; + if (inited) return; inited = true; - Connection conn = null; - try { - conn = ds.getConnection(); - PreparedStatement ps = conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='appx';"); - ResultSet res = ps.executeQuery(); - if (!res.next()) { - String[] sqls = getSqlFromFile(); - for (String sql : sqls) - runSql(conn, sql); + try (Connection conn = ds.getConnection()) { + // 1. Initial schema: create all base tables if not present + if (!tableExists(conn, "appx")) { + log.info("[DB] Running initial schema..."); + for (String sql : loadSqlFile("/db/schema.sql")) { + runSqlIgnoreError(conn, sql, null); + } } - ps.close(); - } catch (SQLException sqlException) { - throw new RuntimeException(sqlException); - } finally { - try { - if (conn != null) - conn.close(); - } catch (SQLException sQLException) {} + // 2. Versioned migrations + runMigrations(conn); + } catch (SQLException e) { + throw new RuntimeException("DB init failed", e); } } - private static String[] getSqlFromFile() { + private static void runMigrations(Connection conn) throws SQLException { + runSql(conn, "CREATE TABLE IF NOT EXISTS db_version (" + + "version INTEGER PRIMARY KEY, applied_at TEXT)"); + + List migrationFiles = listMigrationFiles(); + Collections.sort(migrationFiles); + + for (String fileName : migrationFiles) { + int version = parseMigrationVersion(fileName); + if (version < 0) continue; + if (isMigrationApplied(conn, version)) continue; + + log.info("[DB] Running migration: " + fileName); + String[] statements = loadSqlFile("/db/migrations/" + fileName); + for (String sql : statements) { + runSqlIgnoreError(conn, sql, "duplicate column"); + } + PreparedStatement ps = conn.prepareStatement( + "INSERT OR IGNORE INTO db_version(version, applied_at) VALUES(?, datetime('now'))"); + ps.setInt(1, version); + ps.executeUpdate(); + ps.close(); + log.info("[DB] Migration V" + version + " applied."); + } + } + + private static boolean isMigrationApplied(Connection conn, int version) throws SQLException { + PreparedStatement ps = conn.prepareStatement( + "SELECT 1 FROM db_version WHERE version = ?"); + ps.setInt(1, version); + ResultSet rs = ps.executeQuery(); + boolean exists = rs.next(); + ps.close(); + return exists; + } + + private static List listMigrationFiles() { + List files = new ArrayList<>(); try { - InputStream ins = com.cyynote.dso.DsHelper.class.getResourceAsStream("/db/schema.sql"); - int len = ins.available(); - byte[] bs = new byte[len]; - ins.read(bs); - String str = new String(bs, "UTF-8"); - String[] sql = str.split(";"); - return sql; + URL dirUrl = DsHelper.class.getResource("/db/migrations/"); + if (dirUrl == null) return files; + String protocol = dirUrl.getProtocol(); + if ("file".equals(protocol)) { + java.io.File dir = new java.io.File(dirUrl.toURI()); + if (dir.isDirectory()) { + for (java.io.File f : dir.listFiles()) { + if (f.getName().startsWith("V") && f.getName().endsWith(".sql")) { + files.add(f.getName()); + } + } + } + } else if ("jar".equals(protocol)) { + String jarPath = dirUrl.getPath(); + String jarFile = jarPath.substring(5, jarPath.indexOf("!")); + String prefix = jarPath.substring(jarPath.indexOf("!") + 2); + try (java.util.jar.JarFile jar = new java.util.jar.JarFile(jarFile)) { + java.util.Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + if (name.startsWith(prefix) && name.endsWith(".sql")) { + String fileName = name.substring(name.lastIndexOf('/') + 1); + if (fileName.startsWith("V")) { + files.add(fileName); + } + } + } + } + } + } catch (Exception e) { + log.warning("[DB] Could not list migration files: " + e.getMessage()); + } + return files; + } + + private static int parseMigrationVersion(String fileName) { + try { + String num = fileName.substring(1, fileName.indexOf("__")); + return Integer.parseInt(num); + } catch (Exception e) { + return -1; + } + } + + private static boolean tableExists(Connection conn, String tableName) throws SQLException { + PreparedStatement ps = conn.prepareStatement( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?"); + ps.setString(1, tableName); + ResultSet rs = ps.executeQuery(); + boolean exists = rs.next(); + ps.close(); + return exists; + } + + private static String[] loadSqlFile(String path) { + try { + InputStream ins = DsHelper.class.getResourceAsStream(path); + if (ins == null) return new String[0]; + byte[] bs = ins.readAllBytes(); + String str = new String(bs, StandardCharsets.UTF_8); + String[] parts = str.split(";"); + List result = new ArrayList<>(); + for (String p : parts) { + String trimmed = p.trim(); + if (!trimmed.isEmpty()) result.add(trimmed); + } + return result.toArray(new String[0]); } catch (Exception ex) { - throw new RuntimeException(ex); + throw new RuntimeException("Failed to load SQL file: " + path, ex); } } private static void runSql(Connection conn, String sql) throws SQLException { - System.out.println(sql); - PreparedStatement ps = conn.prepareStatement(sql); - ps.executeUpdate(); - ps.close(); + Statement st = conn.createStatement(); + st.execute(sql); + st.close(); + } + + private static void runSqlIgnoreError(Connection conn, String sql, String ignoreContaining) { + try { + String trimmed = sql.trim(); + if (trimmed.isEmpty()) return; + log.info("[DB SQL] " + trimmed.substring(0, Math.min(80, trimmed.length()))); + Statement st = conn.createStatement(); + st.execute(trimmed); + st.close(); + } catch (SQLException e) { + if (ignoreContaining != null && e.getMessage() != null && + e.getMessage().toLowerCase().contains(ignoreContaining.toLowerCase())) { + log.info("[DB] Ignored: " + e.getMessage()); + } else { + log.warning("[DB] SQL error (non-fatal): " + e.getMessage() + " | SQL: " + sql.trim().substring(0, Math.min(80, sql.trim().length()))); + } + } } } + diff --git a/cyynote-backend/src/main/java/com/cyynote/model/DriveFileModel.java b/cyynote-backend/src/main/java/com/cyynote/model/DriveFileModel.java new file mode 100644 index 0000000..3738f8e --- /dev/null +++ b/cyynote-backend/src/main/java/com/cyynote/model/DriveFileModel.java @@ -0,0 +1,63 @@ +package com.cyynote.model; + +import lombok.Data; +import org.noear.wood.annotation.PrimaryKey; +import org.noear.wood.annotation.Table; + +@Data +@Table("drive_file") +public class DriveFileModel { + @PrimaryKey + public Integer id; + public String filename; + public String originalName; + public Long size; + public String mimeType; + public String r2Key; + public String shareToken; + public String shareExpiry; + public Integer userid; + public String createtime; + public Integer flag; + public String storageMode; + public String localPath; + + public void setId(Integer id) { this.id = id; } + public Integer getId() { return id; } + + public void setFilename(String filename) { this.filename = filename; } + public String getFilename() { return filename; } + + public void setOriginalName(String originalName) { this.originalName = originalName; } + public String getOriginalName() { return originalName; } + + public void setSize(Long size) { this.size = size; } + public Long getSize() { return size; } + + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + public String getMimeType() { return mimeType; } + + public void setR2Key(String r2Key) { this.r2Key = r2Key; } + public String getR2Key() { return r2Key; } + + public void setShareToken(String shareToken) { this.shareToken = shareToken; } + public String getShareToken() { return shareToken; } + + public void setShareExpiry(String shareExpiry) { this.shareExpiry = shareExpiry; } + public String getShareExpiry() { return shareExpiry; } + + public void setUserid(Integer userid) { this.userid = userid; } + public Integer getUserid() { return userid; } + + public void setCreatetime(String createtime) { this.createtime = createtime; } + public String getCreatetime() { return createtime; } + + public void setFlag(Integer flag) { this.flag = flag; } + public Integer getFlag() { return flag; } + + public void setStorageMode(String storageMode) { this.storageMode = storageMode; } + public String getStorageMode() { return storageMode; } + + public void setLocalPath(String localPath) { this.localPath = localPath; } + public String getLocalPath() { return localPath; } +} diff --git a/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java b/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java index 6dda42d..360f8da 100644 --- a/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java +++ b/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java @@ -25,8 +25,12 @@ public class NoteModel { public String viewtime; + public Integer userid; + public Integer flag; + public String tags; + public void setId(Integer id) { this.id = id; } @@ -63,6 +67,14 @@ public class NoteModel { this.flag = flag; } + public void setUserid(Integer userid) { + this.userid = userid; + } + + public Integer getUserid() { + return this.userid; + } + public String toString() { return "NoteModel(id=" + getId() + ", pid=" + getPid() + ", title=" + getTitle() + ", context=" + getContext() + ", conjson=" + getConjson() + ", createtime=" + getCreatetime() + ", updatetime=" + getUpdatetime() + ", viewtime=" + getViewtime() + ", flag=" + getFlag() + ")"; @@ -103,5 +115,13 @@ public class NoteModel { public Integer getFlag() { return this.flag; } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getTags() { + return this.tags; + } } diff --git a/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java b/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java index c14197b..fd1365e 100644 --- a/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java +++ b/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java @@ -23,6 +23,8 @@ public class UserModel { public String logintime; + public Integer passwordHashed; + public void setId(Integer id) { this.id = id; } @@ -51,6 +53,14 @@ public class UserModel { this.logintime = logintime; } + public Integer getPasswordHashed() { + return this.passwordHashed; + } + + public void setPasswordHashed(Integer passwordHashed) { + this.passwordHashed = passwordHashed; + } + public String toString() { return "UserModel(id=" + getId() + ", email=" + getEmail() + ", password=" + getPassword() + ", username=" + getUsername() + ", role=" + getRole() + ", token=" + getToken() + ", logintime=" + getLogintime() + ")"; } diff --git a/cyynote-backend/src/main/java/com/cyynote/payload/request/NoteRequest.java b/cyynote-backend/src/main/java/com/cyynote/payload/request/NoteRequest.java index 511889b..dede0de 100644 --- a/cyynote-backend/src/main/java/com/cyynote/payload/request/NoteRequest.java +++ b/cyynote-backend/src/main/java/com/cyynote/payload/request/NoteRequest.java @@ -10,6 +10,8 @@ public class NoteRequest { private String context; + private String tags; + public Long getId() { return this.id; } @@ -41,4 +43,12 @@ public class NoteRequest { public void setContext(String context) { this.context = context; } + + public String getTags() { + return this.tags; + } + + public void setTags(String tags) { + this.tags = tags; + } } diff --git a/cyynote-backend/src/main/resources/db/migrations/V2__settings_drive_file.sql b/cyynote-backend/src/main/resources/db/migrations/V2__settings_drive_file.sql new file mode 100644 index 0000000..d95ecb5 --- /dev/null +++ b/cyynote-backend/src/main/resources/db/migrations/V2__settings_drive_file.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS drive_file ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT, + original_name TEXT, + size INTEGER, + mime_type TEXT, + r2_key TEXT, + share_token TEXT, + share_expiry TEXT, + userid INTEGER, + createtime TEXT, + flag INTEGER DEFAULT 1 +); + +ALTER TABLE user ADD COLUMN password_hashed INTEGER DEFAULT 0; diff --git a/cyynote-backend/src/main/resources/db/migrations/V3__note_userid_index.sql b/cyynote-backend/src/main/resources/db/migrations/V3__note_userid_index.sql new file mode 100644 index 0000000..1ec040c --- /dev/null +++ b/cyynote-backend/src/main/resources/db/migrations/V3__note_userid_index.sql @@ -0,0 +1,6 @@ +-- Ensure note.userid column exists (for older databases created before this field was added) +ALTER TABLE note ADD COLUMN userid INTEGER DEFAULT 0; + +-- Index for faster per-user queries +CREATE INDEX IF NOT EXISTS idx_note_userid ON note(userid); +CREATE INDEX IF NOT EXISTS idx_note_flag_userid ON note(flag, userid); diff --git a/cyynote-backend/src/main/resources/db/migrations/V4__backfill_note_userid.sql b/cyynote-backend/src/main/resources/db/migrations/V4__backfill_note_userid.sql new file mode 100644 index 0000000..69b3898 --- /dev/null +++ b/cyynote-backend/src/main/resources/db/migrations/V4__backfill_note_userid.sql @@ -0,0 +1,5 @@ +-- V4: Assign existing notes without an owner to userid=1 (first admin) +-- This ensures production data created before multi-user isolation is not lost. +UPDATE note SET userid = 1 WHERE userid IS NULL OR userid = 0; + +INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (4, datetime('now')); diff --git a/cyynote-backend/src/main/resources/db/migrations/V5__note_tags.sql b/cyynote-backend/src/main/resources/db/migrations/V5__note_tags.sql new file mode 100644 index 0000000..00e25ec --- /dev/null +++ b/cyynote-backend/src/main/resources/db/migrations/V5__note_tags.sql @@ -0,0 +1,4 @@ +-- V5: Add tags column to note table +ALTER TABLE note ADD COLUMN tags TEXT DEFAULT ''; + +INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (5, datetime('now')); diff --git a/cyynote-backend/src/main/resources/db/migrations/V6__drive_local_storage.sql b/cyynote-backend/src/main/resources/db/migrations/V6__drive_local_storage.sql new file mode 100644 index 0000000..a4d86bb --- /dev/null +++ b/cyynote-backend/src/main/resources/db/migrations/V6__drive_local_storage.sql @@ -0,0 +1,5 @@ +-- V6: Add local storage support for drive files +ALTER TABLE drive_file ADD COLUMN storage_mode TEXT DEFAULT 'r2'; +ALTER TABLE drive_file ADD COLUMN local_path TEXT; + +INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (6, datetime('now')); diff --git a/cyynote-backend/target/classes/com/cyynote/controller/AuthController.class b/cyynote-backend/target/classes/com/cyynote/controller/AuthController.class index bc15810..d61aca4 100644 Binary files a/cyynote-backend/target/classes/com/cyynote/controller/AuthController.class and b/cyynote-backend/target/classes/com/cyynote/controller/AuthController.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/controller/DriveController.class b/cyynote-backend/target/classes/com/cyynote/controller/DriveController.class new file mode 100644 index 0000000..1ae1033 Binary files /dev/null and b/cyynote-backend/target/classes/com/cyynote/controller/DriveController.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/controller/NoteController.class b/cyynote-backend/target/classes/com/cyynote/controller/NoteController.class index 6756ed5..2114c76 100644 Binary files a/cyynote-backend/target/classes/com/cyynote/controller/NoteController.class and b/cyynote-backend/target/classes/com/cyynote/controller/NoteController.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/controller/SettingsController.class b/cyynote-backend/target/classes/com/cyynote/controller/SettingsController.class new file mode 100644 index 0000000..9e73f2c Binary files /dev/null and b/cyynote-backend/target/classes/com/cyynote/controller/SettingsController.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/dso/DsHelper.class b/cyynote-backend/target/classes/com/cyynote/dso/DsHelper.class index c8da4f3..9f1a6fe 100644 Binary files a/cyynote-backend/target/classes/com/cyynote/dso/DsHelper.class and b/cyynote-backend/target/classes/com/cyynote/dso/DsHelper.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/model/DriveFileModel.class b/cyynote-backend/target/classes/com/cyynote/model/DriveFileModel.class new file mode 100644 index 0000000..ebe9095 Binary files /dev/null and b/cyynote-backend/target/classes/com/cyynote/model/DriveFileModel.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/model/NoteModel.class b/cyynote-backend/target/classes/com/cyynote/model/NoteModel.class index d788a7a..0cafd25 100644 Binary files a/cyynote-backend/target/classes/com/cyynote/model/NoteModel.class and b/cyynote-backend/target/classes/com/cyynote/model/NoteModel.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/model/UserModel.class b/cyynote-backend/target/classes/com/cyynote/model/UserModel.class index ecc6ad7..41cb2ea 100644 Binary files a/cyynote-backend/target/classes/com/cyynote/model/UserModel.class and b/cyynote-backend/target/classes/com/cyynote/model/UserModel.class differ diff --git a/cyynote-backend/target/classes/com/cyynote/payload/request/NoteRequest.class b/cyynote-backend/target/classes/com/cyynote/payload/request/NoteRequest.class index 0284ba2..3f7d039 100644 Binary files a/cyynote-backend/target/classes/com/cyynote/payload/request/NoteRequest.class and b/cyynote-backend/target/classes/com/cyynote/payload/request/NoteRequest.class differ diff --git a/cyynote-backend/target/classes/db/migrations/V2__settings_drive_file.sql b/cyynote-backend/target/classes/db/migrations/V2__settings_drive_file.sql new file mode 100644 index 0000000..d95ecb5 --- /dev/null +++ b/cyynote-backend/target/classes/db/migrations/V2__settings_drive_file.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS drive_file ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT, + original_name TEXT, + size INTEGER, + mime_type TEXT, + r2_key TEXT, + share_token TEXT, + share_expiry TEXT, + userid INTEGER, + createtime TEXT, + flag INTEGER DEFAULT 1 +); + +ALTER TABLE user ADD COLUMN password_hashed INTEGER DEFAULT 0; diff --git a/cyynote-backend/target/classes/db/migrations/V3__note_userid_index.sql b/cyynote-backend/target/classes/db/migrations/V3__note_userid_index.sql new file mode 100644 index 0000000..1ec040c --- /dev/null +++ b/cyynote-backend/target/classes/db/migrations/V3__note_userid_index.sql @@ -0,0 +1,6 @@ +-- Ensure note.userid column exists (for older databases created before this field was added) +ALTER TABLE note ADD COLUMN userid INTEGER DEFAULT 0; + +-- Index for faster per-user queries +CREATE INDEX IF NOT EXISTS idx_note_userid ON note(userid); +CREATE INDEX IF NOT EXISTS idx_note_flag_userid ON note(flag, userid); diff --git a/cyynote-backend/target/classes/db/migrations/V4__backfill_note_userid.sql b/cyynote-backend/target/classes/db/migrations/V4__backfill_note_userid.sql new file mode 100644 index 0000000..69b3898 --- /dev/null +++ b/cyynote-backend/target/classes/db/migrations/V4__backfill_note_userid.sql @@ -0,0 +1,5 @@ +-- V4: Assign existing notes without an owner to userid=1 (first admin) +-- This ensures production data created before multi-user isolation is not lost. +UPDATE note SET userid = 1 WHERE userid IS NULL OR userid = 0; + +INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (4, datetime('now')); diff --git a/cyynote-backend/target/classes/db/migrations/V5__note_tags.sql b/cyynote-backend/target/classes/db/migrations/V5__note_tags.sql new file mode 100644 index 0000000..00e25ec --- /dev/null +++ b/cyynote-backend/target/classes/db/migrations/V5__note_tags.sql @@ -0,0 +1,4 @@ +-- V5: Add tags column to note table +ALTER TABLE note ADD COLUMN tags TEXT DEFAULT ''; + +INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (5, datetime('now')); diff --git a/cyynote-backend/target/cyynote.jar b/cyynote-backend/target/cyynote.jar index 0572970..7f30e50 100644 Binary files a/cyynote-backend/target/cyynote.jar and b/cyynote-backend/target/cyynote.jar differ diff --git a/cyynote-backend/target/cyynote.jar.original b/cyynote-backend/target/cyynote.jar.original index 073ed36..f400f24 100644 Binary files a/cyynote-backend/target/cyynote.jar.original and b/cyynote-backend/target/cyynote.jar.original differ diff --git a/cyynote-backend/target/maven-archiver/pom.properties b/cyynote-backend/target/maven-archiver/pom.properties index 30b2849..3817db7 100644 --- a/cyynote-backend/target/maven-archiver/pom.properties +++ b/cyynote-backend/target/maven-archiver/pom.properties @@ -1,5 +1,5 @@ #Generated by Maven -#Thu May 14 15:10:41 CST 2026 +#Fri May 15 17:58:56 CST 2026 groupId=com.cyynote artifactId=cyynote version=1.0.0 diff --git a/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 207e70e..3676c73 100644 --- a/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,5 +1,6 @@ com/cyynote/payload/request/LoginRequest.class com/cyynote/dso/DsHelper.class +com/cyynote/controller/SettingsController.class com/cyynote/controller/JwtInterceptor.class com/cyynote/util/TreeNode.class com/cyynote/model/Appx.class @@ -14,12 +15,14 @@ com/cyynote/Config.class com/cyynote/payload/request/UpdatePasswordRequest.class com/cyynote/payload/request/NoteRequest.class com/cyynote/WebApp.class +com/cyynote/controller/DriveController.class com/cyynote/dso/AuthSqlAnnotation.class com/cyynote/dso/SqlMapper.class com/cyynote/payload/response/JwtResponse.class com/cyynote/controller/AuthController.class com/cyynote/dso/NoteSqlAnnotation.class com/cyynote/model/AppxModel.class +com/cyynote/model/DriveFileModel.class com/cyynote/dso/SqlAnnotation.class com/cyynote/controller/BaseController.class com/cyynote/util/TreeBuild.class diff --git a/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index bad85bb..a5c181e 100644 --- a/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/cyynote-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -3,8 +3,10 @@ /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/BaseController.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/DemoController.java +/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/DriveController.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/JwtInterceptor.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java +/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/SettingsController.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/Test2Controller.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/AuthProcessorImpl.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/AuthSqlAnnotation.java @@ -14,6 +16,7 @@ /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/SqlMapper.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/Appx.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/AppxModel.java +/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/DriveFileModel.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/HistoryModel.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java /opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java diff --git a/cyynote-frontend/src/components/lexical/Editor.tsx b/cyynote-frontend/src/components/lexical/Editor.tsx index fe36594..6f7b460 100644 --- a/cyynote-frontend/src/components/lexical/Editor.tsx +++ b/cyynote-frontend/src/components/lexical/Editor.tsx @@ -79,20 +79,19 @@ const skipCollaborationInit = window.parent != null && window.parent.frames.right === window; -function OnChangePlugin({ onChange }) { +function OnChangePlugin({ onChange }: { onChange: (editorState: unknown) => void }): null { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerUpdateListener(({editorState}) => { onChange(editorState); }); }, [editor, onChange]); + return null; } -export default function Editor(props: { noteId: bigint; noteTitle: string; editData: any; handleLawClick_item2: any; }): JSX.Element { +export default function Editor(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick_item2: any; }): JSX.Element { const [editor] = useLexicalComposerContext(); - console.log(props.noteId) - const {historyState} = useSharedHistoryContext(); const { settings: { @@ -121,7 +120,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false); const [isLinkEditMode, setIsLinkEditMode] = useState(false); - const [editorState, setEditorState] = useState(); + const [editorState, setEditorState] = useState(undefined); const onRef = (_floatingAnchorElem: HTMLDivElement) => { if (_floatingAnchorElem !== null) { setFloatingAnchorElem(_floatingAnchorElem); @@ -146,18 +145,11 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD }, [isSmallWidthViewport]); - function onChange(editorState) { - // Call toJSON on the EditorState object, which produces a serialization safe string + function onChange(editorState: { toJSON: () => unknown }) { const editorStateJSON = editorState.toJSON(); - - // However, we still have a JavaScript object, so we need to convert it to an actual string with JSON.stringify setEditorState(JSON.stringify(editorStateJSON)); - } - console.log(props.noteId) - // console.log(props.editData) - return ( <> {isRichText && } @@ -270,7 +262,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD {isAutocomplete && }
{showTableOfContents && }
{shouldUseLexicalContextMenu && } - + {showTreeView && } diff --git a/cyynote-frontend/src/components/lexical/LexicalApp.tsx b/cyynote-frontend/src/components/lexical/LexicalApp.tsx index 73e3a86..b5e9fb3 100644 --- a/cyynote-frontend/src/components/lexical/LexicalApp.tsx +++ b/cyynote-frontend/src/components/lexical/LexicalApp.tsx @@ -112,16 +112,11 @@ function prepopulatedRichText() { } } -function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle: string; handleLawClick_item1: any; }): JSX.Element { +function LexicalApp(props: { noteId: number; editData: string | null; noteTitle: string; handleLawClick_item1: any; }): JSX.Element { const { settings: {isCollab, emptyEditor, measureTypingPerf}, } = useSettings(); - console.log(props.noteId) - // console.log(props.editData) - console.log(props.editData!==null) - console.log(prepopulatedRichText) - const resolveEditorState = (): string | null | undefined => { if (props.editData === null) return undefined; // use prepopulatedRichText via function below const raw = props.editData as unknown as string; @@ -179,7 +174,7 @@ function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle: ); } -export default function PlaygroundApp(props: { noteId: bigint; noteTitle: string; editData: object; handleLawClick: any; }): JSX.Element { +export default function PlaygroundApp(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick: any; }): JSX.Element { return ( // diff --git a/cyynote-frontend/src/components/lexical/plugins/ActionsPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/ActionsPlugin/index.tsx index 983819c..c674f56 100644 --- a/cyynote-frontend/src/components/lexical/plugins/ActionsPlugin/index.tsx +++ b/cyynote-frontend/src/components/lexical/plugins/ActionsPlugin/index.tsx @@ -39,6 +39,32 @@ import NoteService from "../../../../services/note.service.ts"; import {Toast} from "@douyinfe/semi-ui"; import {SAVE_COMMAND} from "../GlobalEventsPlugin"; +function exportMarkdown(editor: LexicalEditor, title: string) { + let markdown = ''; + editor.getEditorState().read(() => { + markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS); + }); + const blob = new Blob([`# ${title}\n\n${markdown}`], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${title || 'note'}.md`; + a.click(); + URL.revokeObjectURL(url); +} + +function exportHtml(title: string) { + const content = document.querySelector('.editor-scroller')?.innerHTML ?? ''; + const html = `${title}

${title}

${content}`; + const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${title || 'note'}.html`; + a.click(); + URL.revokeObjectURL(url); +} + async function sendEditorState(editor: LexicalEditor): Promise { const stringifiedEditorState = JSON.stringify(editor.getEditorState()); try { @@ -77,7 +103,7 @@ async function validateEditorState(editor: LexicalEditor): Promise { } } -export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string;handleLawClick_item3:any; }): JSX.Element { +export default function ActionsPlugin(props: { noteId: number; noteTitle: string; handleLawClick_item3: any; editData?: any }): JSX.Element { const [editor] = useLexicalComposerContext(); const [isEditable, setIsEditable] = useState(() => editor.isEditable()); const [isSpeechToText, setIsSpeechToText] = useState(false); @@ -169,29 +195,22 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string }); }, [editor]); - const saveData = (editor) => { - const x = document.getElementsByClassName("semi-input semi-input-large"); - console.log('================当前noteTitle x: ', x) - console.log('当前ID: ', props.noteId) - console.log('当前noteTitle: ', props.noteTitle) - console.log('当前noteTitle2: ', x[0].value) - // console.log('当前editor3: ', editor.getEditorState().toJSON()) - const obj = editor.getEditorState().toJSON(); - // console.log('当前editor4: ', JSON.stringify(obj)) - - NoteService.update(props.noteId,x[0].value, JSON.stringify(obj)).then( - response => { - console.log(response.data) - const data = response.data - console.log(data) - console.log('handleLawClick_item3') - props.handleLawClick_item3('1'); - Toast.info('保存成功' ); - }, - error => { - console.log(error) - } - ); + const saveData = (editor: LexicalEditor) => { + const currentTitle = (document.querySelector('.semi-input.semi-input-large') as HTMLInputElement)?.value ?? props.noteTitle; + const obj = editor.getEditorState().toJSON(); + import('../../../../stores/useNoteStore').then(({ useNoteStore }) => { + const tags = useNoteStore.getState().currentTags; + NoteService.update(props.noteId, currentTitle, JSON.stringify(obj), tags).then( + () => { + props.handleLawClick_item3('1'); + Toast.info('保存成功'); + useNoteStore.getState().setDirty(false); + }, + () => { + Toast.error('保存失败'); + } + ); + }).catch(() => {}); }; return ( @@ -235,10 +254,24 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string source: 'Playground', }) } - title="Export" + title="Export JSON" aria-label="Export editor state to JSON"> + + + + } + /> + )} + /> + )} + + ); +} diff --git a/cyynote-frontend/src/components/note/NoteEditor.tsx b/cyynote-frontend/src/components/note/NoteEditor.tsx index b085468..7cafc9a 100644 --- a/cyynote-frontend/src/components/note/NoteEditor.tsx +++ b/cyynote-frontend/src/components/note/NoteEditor.tsx @@ -1,12 +1,32 @@ -import { useCallback } from 'react'; -import { Input, Skeleton, Breadcrumb } from '@douyinfe/semi-ui'; +import { useCallback, useState } from 'react'; +import { Input, Skeleton, Breadcrumb, Button, Tag, TagInput } from '@douyinfe/semi-ui'; +import { IconHistory } from '@douyinfe/semi-icons'; import { useNoteStore } from '../../stores/useNoteStore'; import { useUIStore } from '../../stores/useUIStore'; import PlaygroundApp from '../lexical/LexicalApp'; +import NoteHistoryModal from '../modals/NoteHistoryModal'; +import type { ITreeNode } from '../../types'; + +function getNotePath( + nodes: ITreeNode[], + targetKey: string | number, + path: string[] = [], +): string[] | null { + for (const node of nodes) { + const current = [...path, node.label]; + if (String(node.key) === String(targetKey)) return current; + if (node.children) { + const found = getNotePath(node.children, targetKey, current); + if (found) return found; + } + } + return null; +} export default function NoteEditor() { - const { currentNoteId, currentTitle, currentContent, setTitle } = useNoteStore(); + const { currentNoteId, currentTitle, currentContent, currentTags, setTitle, setTags, treeData } = useNoteStore(); const { isNoteLoading } = useUIStore(); + const [historyVisible, setHistoryVisible] = useState(false); const handleTitleChange = useCallback( (value: string) => { @@ -15,19 +35,37 @@ export default function NoteEditor() { [setTitle], ); - // handleRefreshTree is intentionally a no-op here: - // LexicalApp calls it after structural changes, but tree refresh - // is driven by the NoteStore and handled at the NotePage level. + const handleTagsChange = useCallback( + (tags: string[]) => { + setTags(tags.join(',')); + }, + [setTags], + ); + const handleRefreshTree = useCallback(() => { // no-op: tree is refreshed by NotePage via store subscription }, []); + const notePath = currentNoteId != null ? getNotePath(treeData, currentNoteId) : null; + const breadcrumbRoutes = ['CyyNote', ...(notePath ?? ['笔记标题', currentTitle])]; + const tagValues = currentTags ? currentTags.split(',').filter(Boolean) : []; + return ( <> - +
+ + {currentNoteId !== null && ( + + )} +
+ setHistoryVisible(false)} /> } loading={isNoteLoading} @@ -37,8 +75,19 @@ export default function NoteEditor() { size="large" value={currentTitle} onChange={handleTitleChange} - style={{ marginBottom: '12px' }} + style={{ marginBottom: '8px' }} /> + {currentNoteId !== null && ( + ( + {value} + )} + /> + )}
{currentNoteId !== null && currentContent !== null ? ( (null); + const [editingValue, setEditingValue] = useState(''); + const toNoteId = (id: string | number) => Number(id); const loadNote = useCallback( (id: number) => { - noteService.get(id).then( - (res) => { - const note = res.data; - setCurrentNote(note.id, note.title, note.context); - setActiveView('note'); - }, - () => Toast.error('加载笔记失败'), - ); + const doLoad = () => { + noteService.get(id).then( + (res) => { + const note = res.data; + setCurrentNote(note.id, note.title, note.context, note.tags ?? ''); + setActiveView('note'); + }, + () => Toast.error('加载笔记失败'), + ); + }; + + if (isDirty) { + Modal.confirm({ + title: '未保存的内容', + content: '当前笔记有未保存的内容,确定要离开吗?', + okText: '确定', + cancelText: '取消', + onOk: doLoad, + }); + } else { + doLoad(); + } }, - [setCurrentNote, setActiveView], + [setCurrentNote, setActiveView, isDirty], ); const addNote = useCallback( @@ -63,6 +82,19 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No [onRefreshTree], ); + const renameNote = useCallback( + async (id: number, newTitle: string) => { + try { + const res = await noteService.get(id); + await noteService.update(id, newTitle, res.data.context); + onRefreshTree(); + } catch { + Toast.error('重命名失败'); + } + }, + [onRefreshTree], + ); + const treeStyle: CSSProperties = { height: 550, border: '1px solid var(--semi-color-border)', @@ -70,35 +102,71 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No top: '85px', }; - const renderLabel = (label: ReactNode, item: ITreeNode) => ( -
- - {label} - - -
- ); + > + {label} + + +
+ ); + }; return ( <> @@ -140,8 +208,9 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No onClick={() => onNavItemClick(item)} > {item === '首页' && + ) : null} + + {record.shareToken && ( + void handleUnshare(record.id)} + okText="确定" + cancelText="取消" + > + + + )} + void handleDelete(record.id)} + okText="确定" + cancelText="取消" + > +