diff --git a/build.gradle b/build.gradle index 4c0a5c554..7f97e6d1c 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ bootJar { ext { ohMyEmailVersion = '0.0.4' - hutoolVersion = '4.6.3' + hutoolVersion = '5.0.3' upyunSdkVersion = '4.0.1' qiniuSdkVersion = '7.2.18' aliyunSdkVersion = '3.4.2' diff --git a/src/main/java/run/halo/app/config/HaloConfiguration.java b/src/main/java/run/halo/app/config/HaloConfiguration.java index 9b5be4d5b..49f4bff22 100644 --- a/src/main/java/run/halo/app/config/HaloConfiguration.java +++ b/src/main/java/run/halo/app/config/HaloConfiguration.java @@ -151,6 +151,7 @@ public class HaloConfiguration { "/api/admin/refresh/*", "/api/admin/installations", "/api/admin/recoveries/migrations/*", + "/api/admin/migrations/*", "/api/admin/is_installed", "/api/admin/password/code", "/api/admin/password/reset" diff --git a/src/main/java/run/halo/app/controller/admin/api/InstallController.java b/src/main/java/run/halo/app/controller/admin/api/InstallController.java index 2ad1661ba..b2aea0d35 100644 --- a/src/main/java/run/halo/app/controller/admin/api/InstallController.java +++ b/src/main/java/run/halo/app/controller/admin/api/InstallController.java @@ -179,7 +179,7 @@ public class InstallController { @Nullable private Category createDefaultCategoryIfAbsent() { long categoryCount = categoryService.count(); - if (categoryCount == 0) { + if (categoryCount > 0) { return null; } diff --git a/src/main/java/run/halo/app/controller/admin/api/MigrateController.java b/src/main/java/run/halo/app/controller/admin/api/MigrateController.java new file mode 100644 index 000000000..e8df6d20a --- /dev/null +++ b/src/main/java/run/halo/app/controller/admin/api/MigrateController.java @@ -0,0 +1,45 @@ +package run.halo.app.controller.admin.api; + +import io.swagger.annotations.ApiOperation; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.model.enums.MigrateType; +import run.halo.app.service.MigrateService; + +/** + * Migrate controller + * + * @author ryanwang + * @date 2019-10-29 + */ +@RestController +@RequestMapping("/api/admin/migrations") +public class MigrateController { + + private final MigrateService migrateService; + + public MigrateController(MigrateService migrateService) { + this.migrateService = migrateService; + } + + @PostMapping("halo_v0_4_4") + @ApiOperation("Migrate from Halo 0.4.4") + public void migrateHaloOldVersion(@RequestPart("file") MultipartFile file) { + migrateService.migrate(file, MigrateType.OLD_VERSION); + } + + @PostMapping("wordpress") + @ApiOperation("Migrate from WordPress") + public void migrateWordPress(@RequestPart("file") MultipartFile file) { + migrateService.migrate(file, MigrateType.WORDPRESS); + } + + @PostMapping("cnblogs") + @ApiOperation("Migrate from cnblogs") + public void migrateCnBlogs(@RequestPart("file") MultipartFile file) { + migrateService.migrate(file, MigrateType.CNBLOGS); + } +} diff --git a/src/main/java/run/halo/app/controller/admin/api/RecoveryController.java b/src/main/java/run/halo/app/controller/admin/api/RecoveryController.java index ebb831477..76407ee15 100644 --- a/src/main/java/run/halo/app/controller/admin/api/RecoveryController.java +++ b/src/main/java/run/halo/app/controller/admin/api/RecoveryController.java @@ -19,6 +19,7 @@ import run.halo.app.service.RecoveryService; * @author johnniang * @date 19-4-26 */ +@Deprecated @RestController @RequestMapping("/api/admin/recoveries") public class RecoveryController { diff --git a/src/main/java/run/halo/app/handler/migrate/CnBlogsMigrateHandler.java b/src/main/java/run/halo/app/handler/migrate/CnBlogsMigrateHandler.java new file mode 100644 index 000000000..bdd512d6d --- /dev/null +++ b/src/main/java/run/halo/app/handler/migrate/CnBlogsMigrateHandler.java @@ -0,0 +1,23 @@ +package run.halo.app.handler.migrate; + +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.model.enums.MigrateType; + +/** + * Cnblogs(https://cnblogs.com) migrate handler. + * + * @author ryanwang + * @date 2019-10-30 + */ +public class CnBlogsMigrateHandler implements MigrateHandler { + + @Override + public void migrate(MultipartFile file) { + // TODO + } + + @Override + public boolean supportType(MigrateType type) { + return MigrateType.CNBLOGS.equals(type); + } +} diff --git a/src/main/java/run/halo/app/handler/migrate/MigrateHandler.java b/src/main/java/run/halo/app/handler/migrate/MigrateHandler.java new file mode 100644 index 000000000..3d8f59a9a --- /dev/null +++ b/src/main/java/run/halo/app/handler/migrate/MigrateHandler.java @@ -0,0 +1,30 @@ +package run.halo.app.handler.migrate; + +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.model.enums.MigrateType; + +/** + * Migrate handler interface. + * + * @author ryanwang + * @date 2019-10-28 + */ +public interface MigrateHandler { + + /** + * Migrate + * + * @param file multipart file must not be null + */ + void migrate(@NonNull MultipartFile file); + + /** + * Checks if the given type is supported. + * + * @param type migrate type + * @return true if supported; false or else + */ + boolean supportType(@Nullable MigrateType type); +} diff --git a/src/main/java/run/halo/app/handler/migrate/MigrateHandlers.java b/src/main/java/run/halo/app/handler/migrate/MigrateHandlers.java new file mode 100644 index 000000000..e7ac60f11 --- /dev/null +++ b/src/main/java/run/halo/app/handler/migrate/MigrateHandlers.java @@ -0,0 +1,65 @@ +package run.halo.app.handler.migrate; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.exception.FileOperationException; +import run.halo.app.model.enums.MigrateType; + +import java.util.Collection; +import java.util.LinkedList; + +/** + * Migrate handler manager. + * + * @author ryanwang + * @date 2019-10-28 + */ +@Slf4j +@Component +public class MigrateHandlers { + + /** + * Migrate handler container. + */ + private final Collection migrateHandlers = new LinkedList<>(); + + public MigrateHandlers(ApplicationContext applicationContext) { + // Add all migrate handler + addFileHandlers(applicationContext.getBeansOfType(MigrateHandler.class).values()); + } + + @NonNull + public void upload(@NonNull MultipartFile file, @NonNull MigrateType migrateType) { + Assert.notNull(file, "Multipart file must not be null"); + Assert.notNull(migrateType, "Migrate type must not be null"); + + for (MigrateHandler migrateHandler : migrateHandlers) { + if (migrateHandler.supportType(migrateType)) { + migrateHandler.migrate(file); + return; + } + } + + throw new FileOperationException("No available migrate handler to migrate the file").setErrorData(migrateType); + } + + /** + * Adds migrate handlers. + * + * @param migrateHandlers migrate handler collection + * @return current migrate handlers + */ + @NonNull + private MigrateHandlers addFileHandlers(@Nullable Collection migrateHandlers) { + if (!CollectionUtils.isEmpty(migrateHandlers)) { + this.migrateHandlers.addAll(migrateHandlers); + } + return this; + } +} diff --git a/src/main/java/run/halo/app/handler/migrate/OldVersionMigrateHandler.java b/src/main/java/run/halo/app/handler/migrate/OldVersionMigrateHandler.java new file mode 100644 index 000000000..b5c9f2843 --- /dev/null +++ b/src/main/java/run/halo/app/handler/migrate/OldVersionMigrateHandler.java @@ -0,0 +1,693 @@ +package run.halo.app.handler.migrate; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.FileCopyUtils; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.exception.ServiceException; +import run.halo.app.model.entity.*; +import run.halo.app.model.enums.AttachmentType; +import run.halo.app.model.enums.CommentStatus; +import run.halo.app.model.enums.MigrateType; +import run.halo.app.model.enums.PostStatus; +import run.halo.app.service.*; +import run.halo.app.utils.BeanUtils; +import run.halo.app.utils.JsonUtils; +import run.halo.app.utils.ServiceUtils; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Old version(0.4.4) migrate handler + * + * @author ryanwang + * @author johnniang + * @date 2019-10-28 + */ +@Slf4j +@Component +@SuppressWarnings("unchecked") +public class OldVersionMigrateHandler implements MigrateHandler { + + private final AttachmentService attachmentService; + + private final PostService postService; + + private final LinkService linkService; + + private final MenuService menuService; + + private final CategoryService categoryService; + + private final TagService tagService; + + private final PostCommentService postCommentService; + + private final SheetCommentService sheetCommentService; + + private final SheetService sheetService; + + private final PhotoService photoService; + + private final PostCategoryService postCategoryService; + + private final PostTagService postTagService; + + public OldVersionMigrateHandler(AttachmentService attachmentService, + PostService postService, + LinkService linkService, + MenuService menuService, + CategoryService categoryService, + TagService tagService, + PostCommentService postCommentService, + SheetCommentService sheetCommentService, + SheetService sheetService, + PhotoService photoService, + PostCategoryService postCategoryService, + PostTagService postTagService) { + this.attachmentService = attachmentService; + this.postService = postService; + this.linkService = linkService; + this.menuService = menuService; + this.categoryService = categoryService; + this.tagService = tagService; + this.postCommentService = postCommentService; + this.sheetCommentService = sheetCommentService; + this.sheetService = sheetService; + this.photoService = photoService; + this.postCategoryService = postCategoryService; + this.postTagService = postTagService; + } + + @Override + public void migrate(MultipartFile file) { + // Get migration content + try { + String migrationContent = FileCopyUtils.copyToString(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8)); + + Object migrationObject = JsonUtils.jsonToObject(migrationContent, Object.class); + + if (migrationObject instanceof Map) { + Map migrationMap = (Map) migrationObject; + + // Handle attachments + List attachments = handleAttachments(migrationMap.get("attachments")); + + log.debug("Migrated attachments: [{}]", attachments); + + // Handle links + List links = handleLinks(migrationMap.get("links")); + + log.debug("Migrated links: [{}]", links); + + // Handle galleries + List photos = handleGalleries(migrationMap.get("galleries")); + + log.debug("Migrated photos: [{}]", photos); + + // Handle menus + List menus = handleMenus(migrationMap.get("menus")); + + log.debug("Migrated menus: [{}]", menus); + + // Handle posts + List posts = handleBasePosts(migrationMap.get("posts")); + + log.debug("Migrated posts: [{}]", posts); + } + } catch (IOException e) { + throw new ServiceException("备份文件 " + file.getOriginalFilename() + " 读取失败", e); + } + } + + + @NonNull + private List handleBasePosts(@Nullable Object postsObject) { + if (!(postsObject instanceof List)) { + return Collections.emptyList(); + } + + List postObjectList = (List) postsObject; + + List result = new LinkedList<>(); + + postObjectList.forEach(postObject -> { + if (!(postObject instanceof Map)) { + return; + } + + Map postMap = (Map) postObject; + + BasePost post = new BasePost(); + post.setTitle(postMap.getOrDefault("postTitle", "").toString()); + post.setUrl(postMap.getOrDefault("postUrl", "").toString()); + post.setOriginalContent(postMap.getOrDefault("postContentMd", "").toString()); + post.setFormatContent(postMap.getOrDefault("postContent", "").toString()); + post.setSummary(postMap.getOrDefault("postSummary", "").toString()); + post.setThumbnail(postMap.getOrDefault("postThumbnail", "").toString()); + post.setVisits(getLongOrDefault(postMap.getOrDefault("postViews", "").toString(), 0L)); + post.setDisallowComment(false); + post.setTemplate(postMap.getOrDefault("customTpl", "").toString()); + + // Set disallow comment + Integer allowComment = getIntegerOrDefault(postMap.getOrDefault("allowComment", "1").toString(), 1); + if (allowComment != 1) { + post.setDisallowComment(true); + } + + // Set create time + Long createTime = getLongOrDefault(postMap.getOrDefault("postDate", "").toString(), 0L); + if (createTime != 0L) { + post.setCreateTime(new Date(createTime)); + } + + // Set update time + Long updateTime = getLongOrDefault(postMap.getOrDefault("postUpdate", "").toString(), 0L); + if (updateTime != 0L) { + post.setUpdateTime(new Date(updateTime)); + } + + // Set status (default draft) + Integer postStatus = getIntegerOrDefault(postMap.getOrDefault("postStatus", "").toString(), 1); + if (postStatus == 0) { + post.setStatus(PostStatus.PUBLISHED); + } else if (postStatus == 1) { + post.setStatus(PostStatus.DRAFT); + } else { + post.setStatus(PostStatus.RECYCLE); + } + + String postType = postMap.getOrDefault("postType", "post").toString(); + + try { + if ("post".equalsIgnoreCase(postType)) { + // Handle post + result.add(handlePost(post, postMap)); + } else { + // Handle page + result.add(handleSheet(post, postMap)); + } + } catch (Exception e) { + log.warn("Failed to migrate a post or sheet", e); + // Ignore this exception + } + }); + + return result; + } + + @NonNull + private Post handlePost(@NonNull BasePost basePost, @NonNull Map postMap) { + Post post = BeanUtils.transformFrom(basePost, Post.class); + + // Create it + Post createdPost = postService.createOrUpdateBy(post); + + Object commentsObject = postMap.get("comments"); + Object categoriesObject = postMap.get("categories"); + Object tagsObject = postMap.get("tags"); + // Handle comments + List baseComments = handleComment(commentsObject, createdPost.getId()); + + // Handle categories + List categories = handleCategories(categoriesObject, createdPost.getId()); + + log.debug("Migrated categories of post [{}]: [{}]", categories, createdPost.getId()); + + // Handle tags + List tags = handleTags(tagsObject, createdPost.getId()); + + log.debug("Migrated tags of post [{}]: [{}]", tags, createdPost.getId()); + + List postComments = baseComments.stream() + .map(baseComment -> BeanUtils.transformFrom(baseComment, PostComment.class)) + .collect(Collectors.toList()); + + try { + // Build virtual comment + PostComment virtualPostComment = new PostComment(); + virtualPostComment.setId(0L); + // Create comments + createPostCommentRecursively(virtualPostComment, postComments); + } catch (Exception e) { + log.warn("Failed to create post comments for post with id " + createdPost.getId(), e); + // Ignore this exception + } + + return createdPost; + } + + @NonNull + private Sheet handleSheet(@NonNull BasePost basePost, @NonNull Map postMap) { + Sheet sheet = BeanUtils.transformFrom(basePost, Sheet.class); + + // Create it + Sheet createdSheet = sheetService.createOrUpdateBy(sheet); + + Object commentsObject = postMap.get("comments"); + // Handle comments + List baseComments = handleComment(commentsObject, createdSheet.getId()); + + List sheetComments = baseComments.stream() + .map(baseComment -> BeanUtils.transformFrom(baseComment, SheetComment.class)) + .collect(Collectors.toList()); + + // Create comments + try { + // Build virtual comment + SheetComment virtualSheetComment = new SheetComment(); + virtualSheetComment.setId(0L); + // Create comments + createSheetCommentRecursively(virtualSheetComment, sheetComments); + } catch (Exception e) { + log.warn("Failed to create sheet comments for sheet with id " + createdSheet.getId(), e); + // Ignore this exception + } + + return createdSheet; + } + + + private void createPostCommentRecursively(@NonNull final PostComment parentComment, List postComments) { + Long oldParentId = parentComment.getId(); + + // Create parent + if (!ServiceUtils.isEmptyId(parentComment.getId())) { + PostComment createdComment = postCommentService.create(parentComment); + log.debug("Created post comment: [{}]", createdComment); + parentComment.setId(createdComment.getId()); + } + + if (CollectionUtils.isEmpty(postComments)) { + return; + } + // Get all children + List children = postComments.stream() + .filter(postComment -> Objects.equals(oldParentId, postComment.getParentId())) + .collect(Collectors.toList()); + + + // Set parent id again + children.forEach(postComment -> postComment.setParentId(parentComment.getId())); + + // Remove children + postComments.removeAll(children); + + // Create children recursively + children.forEach(childComment -> createPostCommentRecursively(childComment, postComments)); + } + + private void createSheetCommentRecursively(@NonNull final SheetComment parentComment, List sheetComments) { + Long oldParentId = parentComment.getId(); + // Create parent + if (!ServiceUtils.isEmptyId(parentComment.getId())) { + SheetComment createComment = sheetCommentService.create(parentComment); + parentComment.setId(createComment.getId()); + } + + if (CollectionUtils.isEmpty(sheetComments)) { + return; + } + // Get all children + List children = sheetComments.stream() + .filter(sheetComment -> Objects.equals(oldParentId, sheetComment.getParentId())) + .collect(Collectors.toList()); + + // Set parent id again + children.forEach(postComment -> postComment.setParentId(parentComment.getId())); + + // Remove children + sheetComments.removeAll(children); + + // Create children recursively + children.forEach(childComment -> createSheetCommentRecursively(childComment, sheetComments)); + } + + private List handleComment(@Nullable Object commentsObject, @NonNull Integer postId) { + Assert.notNull(postId, "Post id must not be null"); + + if (!(commentsObject instanceof List)) { + return Collections.emptyList(); + } + + List commentObjectList = (List) commentsObject; + + List result = new LinkedList<>(); + + commentObjectList.forEach(commentObject -> { + if (!(commentObject instanceof Map)) { + return; + } + + Map commentMap = (Map) commentObject; + + BaseComment baseComment = new BaseComment(); + baseComment.setId(getLongOrDefault(commentMap.getOrDefault("commentId", "").toString(), null)); + baseComment.setAuthor(commentMap.getOrDefault("commentAuthor", "").toString()); + baseComment.setEmail(commentMap.getOrDefault("commentAuthorEmail", "").toString()); + baseComment.setIpAddress(commentMap.getOrDefault("commentAuthorIp", "").toString()); + baseComment.setAuthorUrl(commentMap.getOrDefault("commentAuthorUrl", "").toString()); + baseComment.setGravatarMd5(commentMap.getOrDefault("commentAuthorAvatarMd5", "").toString()); + baseComment.setContent(commentMap.getOrDefault("commentContent", "").toString()); + baseComment.setUserAgent(commentMap.getOrDefault("commentAgent", "").toString()); + baseComment.setIsAdmin(getBooleanOrDefault(commentMap.getOrDefault("isAdmin", "").toString(), false)); + baseComment.setPostId(postId); + baseComment.setParentId(getLongOrDefault(commentMap.getOrDefault("commentParent", "").toString(), 0L)); + + // Set create date + Long createTimestamp = getLongOrDefault(commentMap.getOrDefault("createDate", "").toString(), System.currentTimeMillis()); + baseComment.setCreateTime(new Date(createTimestamp)); + + Integer commentStatus = getIntegerOrDefault(commentMap.getOrDefault("commentStatus", "").toString(), 1); + if (commentStatus == 0) { + baseComment.setStatus(CommentStatus.PUBLISHED); + } else if (commentStatus == 1) { + baseComment.setStatus(CommentStatus.AUDITING); + } else { + baseComment.setStatus(CommentStatus.RECYCLE); + } + + result.add(baseComment); + }); + + return result; + } + + @NonNull + private List handleCategories(@Nullable Object categoriesObject, @NonNull Integer postId) { + Assert.notNull(postId, "Post id must not be null"); + + if (!(categoriesObject instanceof List)) { + return Collections.emptyList(); + } + + List categoryObjectList = (List) categoriesObject; + + List result = new LinkedList<>(); + + categoryObjectList.forEach(categoryObject -> { + if (!(categoryObject instanceof Map)) { + return; + } + + Map categoryMap = (Map) categoryObject; + + String slugName = categoryMap.getOrDefault("cateUrl", "").toString(); + + Category category = categoryService.getBySlugName(slugName); + + if (null == category) { + category = new Category(); + category.setName(categoryMap.getOrDefault("cateName", "").toString()); + category.setSlugName(slugName); + category.setDescription(categoryMap.getOrDefault("cateDesc", "").toString()); + category = categoryService.create(category); + } + + PostCategory postCategory = new PostCategory(); + postCategory.setCategoryId(category.getId()); + postCategory.setPostId(postId); + postCategoryService.create(postCategory); + + try { + result.add(category); + } catch (Exception e) { + log.warn("Failed to migrate a category", e); + } + }); + + return result; + } + + @NonNull + private List handleTags(@Nullable Object tagsObject, @NonNull Integer postId) { + Assert.notNull(postId, "Post id must not be null"); + + if (!(tagsObject instanceof List)) { + return Collections.emptyList(); + } + + List tagObjectList = (List) tagsObject; + + List result = new LinkedList<>(); + + tagObjectList.forEach(tagObject -> { + if (!(tagObject instanceof Map)) { + return; + } + + Map tagMap = (Map) tagObject; + + String slugName = tagMap.getOrDefault("tagUrl", "").toString(); + + Tag tag = tagService.getBySlugName(slugName); + + if (null == tag) { + tag = new Tag(); + tag.setName(tagMap.getOrDefault("tagName", "").toString()); + tag.setSlugName(slugName); + tag = tagService.create(tag); + } + + PostTag postTag = new PostTag(); + postTag.setTagId(tag.getId()); + postTag.setPostId(postId); + postTagService.create(postTag); + + try { + result.add(tag); + } catch (Exception e) { + log.warn("Failed to migrate a tag", e); + } + }); + + return result; + } + + @NonNull + private List handleMenus(@Nullable Object menusObject) { + if (!(menusObject instanceof List)) { + return Collections.emptyList(); + } + + List menuObjectList = (List) menusObject; + + List result = new LinkedList<>(); + + menuObjectList.forEach(menuObject -> { + if (!(menuObject instanceof Map)) { + return; + } + + Map menuMap = (Map) menuObject; + + Menu menu = new Menu(); + + menu.setName(menuMap.getOrDefault("menuName", "").toString()); + menu.setUrl(menuMap.getOrDefault("menuUrl", "").toString()); + // Set priority + String sortString = menuMap.getOrDefault("menuSort", "0").toString(); + menu.setPriority(getIntegerOrDefault(sortString, 0)); + menu.setTarget(menuMap.getOrDefault("menuTarget", "_self").toString()); + menu.setIcon(menuMap.getOrDefault("menuIcon", "").toString()); + + try { + // Create menu + result.add(menuService.create(menu)); + } catch (Exception e) { + log.warn("Failed to migrate a menu", e); + } + }); + + return result; + } + + @NonNull + private List handleGalleries(@Nullable Object galleriesObject) { + if (!(galleriesObject instanceof List)) { + return Collections.emptyList(); + } + + List galleryObjectList = (List) galleriesObject; + + List result = new LinkedList<>(); + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + + galleryObjectList.forEach(galleryObject -> { + if (!(galleriesObject instanceof Map)) { + return; + } + + Map galleryMap = (Map) galleryObject; + + Photo photo = new Photo(); + photo.setName(galleryMap.getOrDefault("galleryName", "").toString()); + photo.setDescription(galleryMap.getOrDefault("galleryDesc", "").toString()); + photo.setLocation(galleryMap.getOrDefault("galleryLocation", "").toString()); + photo.setThumbnail(galleryMap.getOrDefault("galleryThumbnailUrl", "").toString()); + photo.setUrl(galleryMap.getOrDefault("galleryUrl", "").toString()); + + Object galleryDate = galleryMap.get("galleryDate"); + + try { + if (galleryDate != null) { + photo.setTakeTime(dateFormat.parse(galleryDate.toString())); + } + + // Create it + result.add(photoService.create(photo)); + } catch (Exception e) { + log.warn("Failed to create a photo", e); + // Ignore this exception + } + + }); + + return result; + } + + @NonNull + private List handleLinks(@Nullable Object linksObject) { + if (!(linksObject instanceof List)) { + return Collections.emptyList(); + } + + List linkObjectList = (List) linksObject; + + List result = new LinkedList<>(); + + linkObjectList.forEach(linkObject -> { + if (!(linkObject instanceof Map)) { + return; + } + + Map linkMap = (Map) linkObject; + + Link link = new Link(); + + link.setName(linkMap.getOrDefault("linkName", "").toString()); + link.setUrl(linkMap.getOrDefault("linkUrl", "").toString()); + link.setLogo(linkMap.getOrDefault("linkPic", "").toString()); + link.setDescription(linkMap.getOrDefault("linkDesc", "").toString()); + try { + result.add(linkService.create(link)); + } catch (Exception e) { + log.warn("Failed to migrate a link", e); + } + }); + + return result; + } + + @NonNull + private List handleAttachments(@Nullable Object attachmentsObject) { + if (!(attachmentsObject instanceof List)) { + return Collections.emptyList(); + } + + List attachmentObjectList = (List) attachmentsObject; + + List result = new LinkedList<>(); + + attachmentObjectList.forEach(attachmentObject -> { + if (!(attachmentObject instanceof Map)) { + return; + } + + Map attachmentMap = (Map) attachmentObject; + // Convert to attachment param + Attachment attachment = new Attachment(); + + attachment.setName(attachmentMap.getOrDefault("attachName", "").toString()); + attachment.setPath(StringUtils.removeStart(attachmentMap.getOrDefault("attachPath", "").toString(), "/")); + attachment.setThumbPath(attachmentMap.getOrDefault("attachSmallPath", "").toString()); + attachment.setMediaType(attachmentMap.getOrDefault("attachType", "").toString()); + attachment.setSuffix(StringUtils.removeStart(attachmentMap.getOrDefault("attachSuffix", "").toString(), ".")); + attachment.setSize(0L); + + if (StringUtils.startsWith(attachment.getPath(), "/upload")) { + // Set this key + attachment.setFileKey(attachment.getPath()); + } + + // Set location + String attachLocation = attachmentMap.getOrDefault("attachLocation", "").toString(); + if (StringUtils.equalsIgnoreCase(attachLocation, "qiniu")) { + attachment.setType(AttachmentType.QNYUN); + } else if (StringUtils.equalsIgnoreCase(attachLocation, "upyun")) { + attachment.setType(AttachmentType.UPYUN); + } else { + attachment.setType(AttachmentType.LOCAL); + } + + try { + // Save to db + Attachment createdAttachment = attachmentService.create(attachment); + + result.add(createdAttachment); + + } catch (Exception e) { + // Ignore this exception + log.warn("Failed to migrate an attachment " + attachment.getPath(), e); + } + }); + + return result; + } + + @NonNull + private Integer getIntegerOrDefault(@Nullable String numberString, @Nullable Integer defaultNumber) { + try { + return Integer.valueOf(numberString); + } catch (Exception e) { + // Ignore this exception + return defaultNumber; + } + } + + @NonNull + private Long getLongOrDefault(@Nullable String numberString, @Nullable Long defaultNumber) { + try { + return Long.valueOf(numberString); + } catch (Exception e) { + // Ignore this exception + return defaultNumber; + } + } + + private Boolean getBooleanOrDefault(@Nullable String boolString, @Nullable Boolean defaultValue) { + if (StringUtils.equalsIgnoreCase(boolString, "0")) { + return false; + } + + if (StringUtils.equalsIgnoreCase(boolString, "1")) { + return true; + } + + if (StringUtils.equalsIgnoreCase(boolString, "true")) { + return true; + } + + if (StringUtils.equalsIgnoreCase(boolString, "false")) { + return false; + } + + return defaultValue; + } + + @Override + public boolean supportType(MigrateType type) { + return MigrateType.OLD_VERSION.equals(type); + } +} diff --git a/src/main/java/run/halo/app/handler/migrate/WordPressMigrateHandler.java b/src/main/java/run/halo/app/handler/migrate/WordPressMigrateHandler.java new file mode 100644 index 000000000..2f38bdb6d --- /dev/null +++ b/src/main/java/run/halo/app/handler/migrate/WordPressMigrateHandler.java @@ -0,0 +1,229 @@ +package run.halo.app.handler.migrate; + +import lombok.extern.slf4j.Slf4j; +import org.dom4j.Element; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.util.FileCopyUtils; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.exception.ServiceException; +import run.halo.app.model.entity.BasePost; +import run.halo.app.model.entity.Category; +import run.halo.app.model.entity.Post; +import run.halo.app.model.entity.Tag; +import run.halo.app.model.enums.MigrateType; +import run.halo.app.service.*; +import run.halo.app.utils.MarkdownUtils; +import run.halo.app.utils.WordPressMigrateUtils; + +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * WordPress migrate handler + * + * @author ryanwang + * @date 2019-10-28 + */ +@Slf4j +@Component +@SuppressWarnings("unchecked") +public class WordPressMigrateHandler implements MigrateHandler { + + private final AttachmentService attachmentService; + + private final PostService postService; + + private final LinkService linkService; + + private final MenuService menuService; + + private final CategoryService categoryService; + + private final TagService tagService; + + private final PostCommentService postCommentService; + + private final SheetCommentService sheetCommentService; + + private final SheetService sheetService; + + private final PhotoService photoService; + + private final PostCategoryService postCategoryService; + + private final PostTagService postTagService; + + public WordPressMigrateHandler(AttachmentService attachmentService, + PostService postService, + LinkService linkService, + MenuService menuService, + CategoryService categoryService, + TagService tagService, + PostCommentService postCommentService, + SheetCommentService sheetCommentService, + SheetService sheetService, + PhotoService photoService, + PostCategoryService postCategoryService, + PostTagService postTagService) { + this.attachmentService = attachmentService; + this.postService = postService; + this.linkService = linkService; + this.menuService = menuService; + this.categoryService = categoryService; + this.tagService = tagService; + this.postCommentService = postCommentService; + this.sheetCommentService = sheetCommentService; + this.sheetService = sheetService; + this.photoService = photoService; + this.postCategoryService = postCategoryService; + this.postTagService = postTagService; + } + + @Override + public void migrate(MultipartFile file) { + try { + String migrationContent = FileCopyUtils.copyToString(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8)); + Element rootElement = WordPressMigrateUtils.getRootElement(new FileInputStream(migrationContent)); + Map resultSetMapping = WordPressMigrateUtils.getResultSetMapping(rootElement); + + // Handle categories + List categories = handleCategories(resultSetMapping.get("wp:category")); + + // Handle tags + List tags = handleTags(resultSetMapping.get("wp:tag")); + + // Handle posts + List posts = handlePosts(resultSetMapping.get("item")); + + log.debug("Migrated posts: [{}]", posts); + } catch (Exception e) { + throw new ServiceException("WordPress 导出文件 " + file.getOriginalFilename() + " 读取失败", e); + } + } + + private List handleCategories(@Nullable Object categoriesObject) { + + if (!(categoriesObject instanceof List)) { + return Collections.emptyList(); + } + + List categoryObjectList = (List) categoriesObject; + + List result = new LinkedList<>(); + + categoryObjectList.forEach(categoryObject -> { + + if (!(categoryObject instanceof Map)) { + return; + } + + Map categoryMap = (Map) categoryObject; + + String slugName = categoryMap.getOrDefault("wp:category_nicename", "").toString(); + + Category category = categoryService.getBySlugName(slugName); + + if (null == category) { + category = new Category(); + category.setName(categoryMap.getOrDefault("wp:cat_name", "").toString()); + category.setSlugName(slugName); + category = categoryService.create(category); + } + + try { + result.add(category); + } catch (Exception e) { + log.warn("Failed to migrate a category", e); + } + }); + + return result; + } + + private List handleTags(@Nullable Object tagsObject) { + + if (!(tagsObject instanceof List)) { + return Collections.emptyList(); + } + + List tagObjectList = (List) tagsObject; + + List result = new LinkedList<>(); + + tagObjectList.forEach(tagObject -> { + if (!(tagObject instanceof Map)) { + return; + } + + Map tagMap = (Map) tagObject; + + String slugName = tagMap.getOrDefault("wp:tag_slug", "").toString(); + + Tag tag = tagService.getBySlugName(slugName); + + if (null == tag) { + tag = new Tag(); + tag.setName(tagMap.getOrDefault("wp:tag_name", "").toString()); + tag.setSlugName(slugName); + tag = tagService.create(tag); + } + + try { + result.add(tag); + } catch (Exception e) { + log.warn("Failed to migrate a tag", e); + } + }); + + return result; + } + + @NonNull + private List handlePosts(@Nullable Object postsObject) { + if (!(postsObject instanceof List)) { + return Collections.emptyList(); + } + + List postObjectList = (List) postsObject; + + List result = new LinkedList<>(); + + postObjectList.forEach(postObject -> { + if (!(postObject instanceof Map)) { + return; + } + + Map postMap = (Map) postObject; + + BasePost post = new BasePost(); + post.setTitle(postMap.getOrDefault("title", "").toString()); + post.setUrl(postMap.getOrDefault("wp:post_name", "").toString()); + post.setOriginalContent(MarkdownUtils.renderMarkdown(postMap.getOrDefault("content:encoded", "").toString())); + post.setFormatContent(postMap.getOrDefault("content:encoded", "").toString()); + post.setSummary(postMap.getOrDefault("excerpt:encoded", "").toString()); + + String url = postMap.getOrDefault("wp:post_name", "").toString(); + + Post temp = postService.getByUrl(url); + + if (temp != null) { + post.setUrl(post.getUrl() + "_1"); + } + + + }); + return null; + } + + @Override + public boolean supportType(MigrateType type) { + return MigrateType.WORDPRESS.equals(type); + } +} diff --git a/src/main/java/run/halo/app/model/enums/MigrateType.java b/src/main/java/run/halo/app/model/enums/MigrateType.java new file mode 100644 index 000000000..975e55c51 --- /dev/null +++ b/src/main/java/run/halo/app/model/enums/MigrateType.java @@ -0,0 +1,41 @@ +package run.halo.app.model.enums; + +/** + * Migrate type. + * + * @author ryanwang + * @date : 2019-03-12 + */ +public enum MigrateType implements ValueEnum { + + /** + * Halo version 0.4.4 + */ + OLD_VERSION(0), + + /** + * WordPress + */ + WORDPRESS(1), + + /** + * cnblogs.com + */ + CNBLOGS(2); + + private Integer value; + + MigrateType(Integer value) { + this.value = value; + } + + /** + * Get enum value. + * + * @return enum value + */ + @Override + public Integer getValue() { + return value; + } +} diff --git a/src/main/java/run/halo/app/model/params/PostParam.java b/src/main/java/run/halo/app/model/params/PostParam.java index 43f3d08b5..54fc3f068 100644 --- a/src/main/java/run/halo/app/model/params/PostParam.java +++ b/src/main/java/run/halo/app/model/params/PostParam.java @@ -11,6 +11,7 @@ import run.halo.app.model.enums.PostStatus; import javax.validation.constraints.Min; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Size; +import java.net.URLEncoder; import java.util.Date; import java.util.Set; @@ -62,7 +63,7 @@ public class PostParam implements InputConverter { @Override public Post convertTo() { if (StringUtils.isBlank(url)) { - url = title.replace(".",""); + url = URLEncoder.encode(title.replace(".","")); } if (null == thumbnail) { thumbnail = ""; @@ -74,7 +75,7 @@ public class PostParam implements InputConverter { @Override public void update(Post post) { if (StringUtils.isBlank(url)) { - url = title.replace(".",""); + url = URLEncoder.encode(title.replace(".","")); } if (null == thumbnail) { thumbnail = ""; diff --git a/src/main/java/run/halo/app/service/MigrateService.java b/src/main/java/run/halo/app/service/MigrateService.java new file mode 100644 index 000000000..34288582f --- /dev/null +++ b/src/main/java/run/halo/app/service/MigrateService.java @@ -0,0 +1,22 @@ +package run.halo.app.service; + +import org.springframework.lang.NonNull; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.model.enums.MigrateType; + +/** + * Migrate service interface. + * + * @author ryanwang + * @date 2019-10-29 + */ +public interface MigrateService { + + /** + * Migrate. + * + * @param file multipart file must not be null + * @param migrateType migrate type + */ + void migrate(@NonNull MultipartFile file,@NonNull MigrateType migrateType); +} diff --git a/src/main/java/run/halo/app/service/RecoveryService.java b/src/main/java/run/halo/app/service/RecoveryService.java index 0a495a376..660c7c7ac 100644 --- a/src/main/java/run/halo/app/service/RecoveryService.java +++ b/src/main/java/run/halo/app/service/RecoveryService.java @@ -9,6 +9,7 @@ import org.springframework.web.multipart.MultipartFile; * @author johnniang * @date 2019-04-26 */ +@Deprecated public interface RecoveryService { /** diff --git a/src/main/java/run/halo/app/service/impl/MigrateServiceImpl.java b/src/main/java/run/halo/app/service/impl/MigrateServiceImpl.java new file mode 100644 index 000000000..152945eb4 --- /dev/null +++ b/src/main/java/run/halo/app/service/impl/MigrateServiceImpl.java @@ -0,0 +1,32 @@ +package run.halo.app.service.impl; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.handler.migrate.MigrateHandlers; +import run.halo.app.model.enums.MigrateType; +import run.halo.app.service.MigrateService; + +/** + * Migrate service implementation. + * + * @author ryanwang + * @date 2019-10-29 + */ +@Service +public class MigrateServiceImpl implements MigrateService { + + private final MigrateHandlers migrateHandlers; + + public MigrateServiceImpl(MigrateHandlers migrateHandlers) { + this.migrateHandlers = migrateHandlers; + } + + @Override + public void migrate(MultipartFile file, MigrateType migrateType) { + Assert.notNull(file, "Multipart file must not be null"); + Assert.notNull(migrateType, "Migrate type must not be null"); + + migrateHandlers.upload(file, migrateType); + } +} diff --git a/src/main/java/run/halo/app/utils/WordPressMigrateUtils.java b/src/main/java/run/halo/app/utils/WordPressMigrateUtils.java new file mode 100644 index 000000000..c44abe517 --- /dev/null +++ b/src/main/java/run/halo/app/utils/WordPressMigrateUtils.java @@ -0,0 +1,144 @@ +package run.halo.app.utils; + +import org.dom4j.Document; +import org.dom4j.Element; +import org.dom4j.io.SAXReader; + +import java.io.File; +import java.io.FileInputStream; +import java.util.*; + +/** + * 解析 WordPress 导出的 XML 数据为 Map 结果集 + * + * @author guqing + * @date 2019-10-29 14:49 + */ +public class WordPressMigrateUtils { + + /** + * 存储在此集合中的节点名称都会被解析为一个List存储 + */ + private static final List ARRAY_PROPERTY = Arrays.asList("channel", "item", "wp:category", "wp:tag", "wp:term", "wp:postmeta", "wp:comment"); + + /** + * 根据xml文件对象获取xml的根节点rootElement对象 + * + * @param file xml的文件对象 + * @return 返回根节点元素 + */ + public static Element getRootElement(File file) { + try { + SAXReader saxReader = new SAXReader(); + FileInputStream fileInputStream = new FileInputStream(file); + Document document = saxReader.read(fileInputStream); + + return document.getRootElement(); + } catch (Exception e) { + throw new RuntimeException("can not get root element"); + } + } + + public static Element getRootElement(FileInputStream fileInputStream) { + try { + SAXReader saxReader = new SAXReader(); + Document document = saxReader.read(fileInputStream); + return document.getRootElement(); + } catch (Exception e) { + throw new RuntimeException("can not get root element"); + } + } + + /** + * 获取 xml的 映射结果集对象 + * + * @return 返回 xml 解析得到的 Map 映射结果集 + */ + public static Map getResultSetMapping(File file) { + Element rootElement = getRootElement(file); + return getResultSetMapping(rootElement); + } + + /** + * 根据根节点获取子节点元素集合递归遍历得到 Map 结果集 + * + * @param root xml 的根节点元素对象 + * @return 返回解析 xml 得到的Map结果集 + */ + public static Map getResultSetMapping(Element root) { + Map result = new HashMap(); + + try { + // 获取根元素的所有子元素 + List children = root.elements(); + + // 递归遍历将 xml 节点数据解析为 Map 结果集 + result = transfer2Map(children, null); + } catch (Exception e) { + throw new RuntimeException("can not transfer xml file to map." + e.getMessage()); + } + + return result; + } + + /** + * 递归解析 xml,实现 N 层解析 + * + * @param elements 所有子节点元素集,随着递归遍历而改变 + * @param list 存储中间遍历结果的容器 + * @return 返回递归完成后的Map结果集映射 + */ + private static Map transfer2Map(List elements, List> list) { + Map map = new HashMap(); + + for (Element element : elements) { + // getName 获取到的节点名称不带名称空间例如 获取到 name 为 content + String nameWithoutPrefix = element.getName(); + + // 需要使用的真是 name 默认等于不带名称空间的,如果名称存在空间则 name 的形式为: 名称空间:名称 + String name = nameWithoutPrefix; + String prefix = element.getNamespace().getPrefix(); + if (isNotBlack(prefix)) { + name = prefix + ":" + nameWithoutPrefix; + } + + // 判断节点是否在定义的数组中,如果存在将其创建成 List 集合,否则作为文本 + if (ARRAY_PROPERTY.contains(name)) { + //继续递归循环 + List> sublist = new ArrayList>(); + + Map subMap = transfer2Map(element.elements(), sublist); + + // 根据 key 获取是否已经存在 + Object object = map.get(name); + // 如果存在,合并 + if (object != null) { + List> olist = (List>) object; + olist.add(subMap); + map.put(name, olist); + } else { + // 否则直接存入 map + map.put(name, sublist); + } + } else { + map.put(name, element.getTextTrim()); + } + } + + // 存入 list 中 + if (list != null) { + list.add(map); + } + return map; + } + + /** + * 判断字符串是否为空,如果是空串返回 {@code true},否则 {@code false} + * + * @param str 需要进行空串判断的字符串 + * @return 如果是空串返回 true, 否则返回 false + */ + private static boolean isNotBlack(String str) { + return str != null && str.length() > 0 && str.trim().length() > 0; + } +} diff --git a/src/main/java/run/halo/app/utils/XmlTransferMapUtils.java b/src/main/java/run/halo/app/utils/XmlTransferMapUtils.java deleted file mode 100644 index e298d4841..000000000 --- a/src/main/java/run/halo/app/utils/XmlTransferMapUtils.java +++ /dev/null @@ -1,144 +0,0 @@ -package run.halo.app.utils; - -import org.dom4j.Document; -import org.dom4j.Element; -import org.dom4j.io.SAXReader; - -import java.io.*; -import java.util.*; - -/** - * 解析wordpress导出的xml文章数据为Map结果集 - * @author guqing - * @date 2019-10-29 14:49 - */ -public class XmlTransferMapUtils { - - /** - * 存储在此集合中的节点名称都会被解析为一个List存储 - */ - private static final List ARRAY_PROPERTY = Arrays.asList("channel", "item", "category", "postmeta", "comment"); - - /** - * 需要解析成map集合的xml文件对象 - */ - private File file; - - public XmlTransferMapUtils(File file) { - this.file = file; - } - - /** - * 根据xml文件对象获取xml的根节点rootElement对象 - * @param file xml的文件对象 - * @return 返回根节点元素 - */ - private Element getRootElement(File file) { - try { - SAXReader saxReader = new SAXReader(); - FileInputStream fileInputStream = new FileInputStream(file); - Document document = saxReader.read(fileInputStream); - - return document.getRootElement(); - } catch (Exception e) { - throw new RuntimeException("can not get root element"); - } - } - - /** - * 获取xml的映射结果集对象 - * @return 返回xml解析得到的Map映射结果集 - */ - public Map getResultSetMapping() { - Element rootElement = getRootElement(file); - return getResultSetMapping(rootElement); - } - - - /** - * 根据根节点获取子节点元素集合递归遍历得到Map结果集 - * @param root xml的根节点元素对象 - * @return 返回解析xml得到的Map结果集 - */ - private Map getResultSetMapping(Element root) { - Map result = new HashMap(); - - try { - // 获取根元素的所有子元素 - List children = root.elements(); - - //递归遍历将xml节点数据解析为Map结果集 - result = transfer2Map(children,null); - } catch (Exception e) { - throw new RuntimeException("can not transfer xml file to map." + e.getMessage()); - } - - return result; - } - - /** - * 递归解析xml,实现N层解析 - * @param elements 所有子节点元素集,随着递归遍历而改变 - * @param list 存储中间遍历结果的容器 - * @return 返回递归完成后的Map结果集映射 - */ - private Map transfer2Map(List elements,List> list){ - Map map = new HashMap(); - - for(Element element : elements){ - // getName获取到的节点名称不带名称空间例如获取到name为content - String nameWithoutPrefix = element.getName(); - - // 需要使用的真是name默认等于不带名称空间的,如果名称存在空间则name的形式为: 名称空间:名称 - String name = nameWithoutPrefix; - String preifx = element.getNamespace().getPrefix(); - if(isNotBlack(preifx)) { - name = preifx + ":" +nameWithoutPrefix; - } - - //判断节点是否在定义的数组中,如果存在将其创建成List集合,否则作为文本 - if(ARRAY_PROPERTY.contains(name)) { - //继续递归循环 - List> sublist = new ArrayList>(); - - Map subMap = this.transfer2Map(element.elements(), sublist); - - //根据key获取是否已经存在 - Object object = map.get(name); - //如果存在,合并 - if(object !=null ){ - List> olist = (List>)object; - olist.add(subMap); - map.put(name, olist); - }else{ - //否则直接存入map - map.put(name, sublist); - } - }else { - //单个值存入map - map.put(name, element.getTextTrim()); - } - } - - //存入list中 - if(list != null) { - list.add(map); - } - - //返回结果集合 - return map; - } - - /** - * 判断字符串是否为空,如果是空串返回 {@code true},否则 {@code false} - * @param str 需要进行空串判断的字符串 - * @return 如果是空串返回true,否则返回false - */ - private boolean isNotBlack(String str) { - if(str != null && str.length() > 0 && str.trim().length() > 0) { - return true; - } - - return false; - } -}