feat: add api for markdown export (#1199)

* add API for markdown-export

* add front-matter support

* optimize fileName for markdown-export

* fornt-matter与正文中间增加换行符
pull/1220/head
Raremaa 2020-12-27 23:58:52 +08:00 committed by GitHub
parent 276aea4bdd
commit 8ba115ffb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 322 additions and 14 deletions

View File

@ -58,6 +58,11 @@ public class HaloProperties {
*/
private String backupDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup" + FILE_SEPARATOR;
/**
* Halo backup markdown directory.(Not recommended to modify this config);
*/
private String backupMarkdownDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup-markdown" + FILE_SEPARATOR;
/**
* Halo data export directory.
*/

View File

@ -12,6 +12,7 @@ import run.halo.app.annotation.DisableOnCondition;
import run.halo.app.config.properties.HaloProperties;
import run.halo.app.model.dto.BackupDTO;
import run.halo.app.model.dto.post.BasePostDetailDTO;
import run.halo.app.model.params.PostMarkdownParam;
import run.halo.app.service.BackupService;
import javax.servlet.http.HttpServletRequest;
@ -23,6 +24,7 @@ import java.util.List;
*
* @author johnniang
* @author ryanwang
* @author Raremaa
* @date 2019-04-26
*/
@RestController
@ -34,8 +36,7 @@ public class BackupController {
private final HaloProperties haloProperties;
public BackupController(BackupService backupService,
HaloProperties haloProperties) {
public BackupController(BackupService backupService, HaloProperties haloProperties) {
this.backupService = backupService;
this.haloProperties = haloProperties;
}
@ -84,7 +85,7 @@ public class BackupController {
backupService.deleteWorkDirBackup(filename);
}
@PostMapping("markdown")
@PostMapping("markdown/import")
@ApiOperation("Imports markdown")
public BasePostDetailDTO backupMarkdowns(@RequestPart("file") MultipartFile file) throws IOException {
return backupService.importMarkdown(file);
@ -132,4 +133,50 @@ public class BackupController {
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + exportDataResource.getFilename() + "\"")
.body(exportDataResource);
}
@PostMapping("markdown/export")
@ApiOperation("Exports markdowns")
@DisableOnCondition
public BackupDTO exportMarkdowns(@RequestBody PostMarkdownParam postMarkdownParam) throws IOException {
return backupService.exportMarkdowns(postMarkdownParam);
}
@GetMapping("markdown/export")
@ApiOperation("Gets all markdown backups")
public List<BackupDTO> listMarkdowns() {
return backupService.listMarkdowns();
}
@DeleteMapping("markdown/export")
@ApiOperation("Deletes a markdown backup")
@DisableOnCondition
public void deleteMarkdown(@RequestParam("filename") String filename) {
backupService.deleteMarkdown(filename);
}
@GetMapping("markdown/export/{fileName:.+}")
@ApiOperation("Downloads a work markdown backup file")
@DisableOnCondition
public ResponseEntity<Resource> downloadMarkdown(@PathVariable("fileName") String fileName, HttpServletRequest request) {
log.info("Try to download markdown backup file: [{}]", fileName);
// Load file as resource
Resource backupResource = backupService.loadFileAsResource(haloProperties.getBackupMarkdownDir(), fileName);
String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
// Try to determine file's content type
try {
contentType = request.getServletContext().getMimeType(backupResource.getFile().getAbsolutePath());
} catch (IOException e) {
log.warn("Could not determine file type", e);
// Ignore this error
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + backupResource.getFilename() + "\"")
.body(backupResource);
}
}

View File

@ -0,0 +1,17 @@
package run.halo.app.model.params;
import lombok.Data;
/**
* @author Raremaa
* @date 2020/12/25 11:22
*/
@Data
public class PostMarkdownParam {
/**
* true if need frontMatter
* default false
*/
private Boolean needFrontMatter;
}

View File

@ -36,6 +36,11 @@ public class HaloConst {
*/
public final static String HALO_BACKUP_PREFIX = "halo-backup-";
/**
* Halo backup markdown prefix.
*/
public final static String HALO_BACKUP_MARKDOWN_PREFIX = "halo-backup-markdown-";
/**
* Halo data export prefix.
*/

View File

@ -0,0 +1,23 @@
package run.halo.app.model.vo;
import lombok.Data;
import lombok.ToString;
/**
* Markdown export VO
*
* @author Raremaa
* @date 2020/12/25 9:14
*/
@Data
@ToString
public class PostMarkdownVO {
private String title;
private String slug;
private String originalContent;
private String frontMatter;
}

View File

@ -5,6 +5,7 @@ import org.springframework.lang.NonNull;
import org.springframework.web.multipart.MultipartFile;
import run.halo.app.model.dto.BackupDTO;
import run.halo.app.model.dto.post.BasePostDetailDTO;
import run.halo.app.model.params.PostMarkdownParam;
import java.io.IOException;
import java.util.List;
@ -91,4 +92,29 @@ public interface BackupService {
* @throws IOException throws IOException
*/
void importData(MultipartFile file) throws IOException;
/**
* Export Markdown content
*
* @param postMarkdownParam param
* @return backup dto.
* @throws IOException throws IOException
*/
@NonNull
BackupDTO exportMarkdowns(PostMarkdownParam postMarkdownParam) throws IOException;
/**
* list Markdown backups
*
* @return backup list
*/
@NonNull
List<BackupDTO> listMarkdowns();
/**
* delete a markdown backup
*
* @param fileName
*/
void deleteMarkdown(@NonNull String fileName);
}

View File

@ -8,10 +8,7 @@ import run.halo.app.model.entity.Post;
import run.halo.app.model.entity.PostMeta;
import run.halo.app.model.enums.PostStatus;
import run.halo.app.model.params.PostQuery;
import run.halo.app.model.vo.ArchiveMonthVO;
import run.halo.app.model.vo.ArchiveYearVO;
import run.halo.app.model.vo.PostDetailVO;
import run.halo.app.model.vo.PostListVO;
import run.halo.app.model.vo.*;
import run.halo.app.service.base.BasePostService;
import javax.validation.constraints.NotNull;
@ -112,8 +109,8 @@ public interface PostService extends BasePostService<Post> {
/**
* Gets post by post year and slug.
*
* @param year post create year.
* @param slug post slug.
* @param year post create year.
* @param slug post slug.
* @return post info
*/
@NonNull
@ -276,4 +273,12 @@ public interface PostService extends BasePostService<Post> {
@NotNull
Sort getPostDefaultSort();
/**
* Lists PostMarkdown vo
*
* @return a list of PostMarkdown vo
*/
@NonNull
List<PostMarkdownVO> listPostMarkdowns();
}

View File

@ -23,10 +23,13 @@ import run.halo.app.event.options.OptionUpdatedEvent;
import run.halo.app.event.theme.ThemeUpdatedEvent;
import run.halo.app.exception.NotFoundException;
import run.halo.app.exception.ServiceException;
import run.halo.app.handler.file.FileHandler;
import run.halo.app.model.dto.BackupDTO;
import run.halo.app.model.dto.post.BasePostDetailDTO;
import run.halo.app.model.entity.*;
import run.halo.app.model.params.PostMarkdownParam;
import run.halo.app.model.support.HaloConst;
import run.halo.app.model.vo.PostMarkdownVO;
import run.halo.app.security.service.OneTimeTokenService;
import run.halo.app.service.*;
import run.halo.app.utils.DateTimeUtils;
@ -45,12 +48,14 @@ import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipOutputStream;
/**
* Backup service implementation.
*
* @author johnniang
* @author ryanwang
* @author Raremaa
* @date 2019-04-26
*/
@Service
@ -59,10 +64,14 @@ public class BackupServiceImpl implements BackupService {
private static final String BACKUP_RESOURCE_BASE_URI = "/api/admin/backups/work-dir";
private static final String DATA_EXPORT_MARKDOWN_BASE_URI = "/api/admin/backups/markdown/export";
private static final String DATA_EXPORT_BASE_URI = "/api/admin/backups/data";
private static final String LINE_SEPARATOR = System.getProperty("line.separator");
private final static String UPLOAD_SUB_DIR = "upload/";
private static final Type MAP_TYPE = new TypeToken<Map<String, ?>>() {
}.getType();
@ -429,6 +438,121 @@ public class BackupServiceImpl implements BackupService {
eventPublisher.publishEvent(new ThemeUpdatedEvent(this));
}
@Override
public BackupDTO exportMarkdowns(PostMarkdownParam postMarkdownParam) throws IOException {
// Query all Post data
List<PostMarkdownVO> postMarkdownList = postService.listPostMarkdowns();
Assert.notEmpty(postMarkdownList, "当前无文章可以导出");
// Write files to the temporary directory
String markdownFileTempPathName = haloProperties.getBackupMarkdownDir() + IdUtil.simpleUUID().hashCode();
for (int i = 0; i < postMarkdownList.size(); i++) {
PostMarkdownVO postMarkdownVO = postMarkdownList.get(i);
StringBuilder content = new StringBuilder();
Boolean needFrontMatter = Optional.ofNullable(postMarkdownParam.getNeedFrontMatter()).orElse(false);
if (needFrontMatter) {
// Add front-matter
content.append(postMarkdownVO.getFrontMatter()).append("\n");
}
content.append(postMarkdownVO.getOriginalContent());
try {
String markdownFileName = postMarkdownVO.getTitle() + "-" + postMarkdownVO.getSlug() + ".md";
Path markdownFilePath = Paths.get(markdownFileTempPathName, markdownFileName);
if (!Files.exists(markdownFilePath.getParent())) {
Files.createDirectories(markdownFilePath.getParent());
}
Path markdownDataPath = Files.createFile(markdownFilePath);
FileWriter fileWriter = new FileWriter(markdownDataPath.toFile(), CharsetUtil.UTF_8);
fileWriter.write(content.toString());
} catch (IOException e) {
throw new ServiceException("导出数据失败", e);
}
}
ZipOutputStream markdownZipOut = null;
// Zip file
try {
// Create zip path
String markdownZipFileName = HaloConst.HALO_BACKUP_MARKDOWN_PREFIX +
DateTimeUtils.format(LocalDateTime.now(), DateTimeUtils.HORIZONTAL_LINE_DATETIME_FORMATTER) +
IdUtil.simpleUUID().hashCode() + ".zip";
// Create zip file
Path markdownZipFilePath = Paths.get(haloProperties.getBackupMarkdownDir(), markdownZipFileName);
if (!Files.exists(markdownZipFilePath.getParent())) {
Files.createDirectories(markdownZipFilePath.getParent());
}
Path markdownZipPath = Files.createFile(markdownZipFilePath);
markdownZipOut = new ZipOutputStream(Files.newOutputStream(markdownZipPath));
// Zip temporary directory
Path markdownFileTempPath = Paths.get(markdownFileTempPathName);
run.halo.app.utils.FileUtils.zip(markdownFileTempPath, markdownZipOut);
// Zip upload sub-directory
String uploadPathName = FileHandler.normalizeDirectory(haloProperties.getWorkDir()) + UPLOAD_SUB_DIR;
Path uploadPath = Paths.get(uploadPathName);
if (Files.exists(uploadPath)) {
run.halo.app.utils.FileUtils.zip(uploadPath, markdownZipOut);
}
// Remove files in the temporary directory
run.halo.app.utils.FileUtils.deleteFolder(markdownFileTempPath);
// Build backup dto
return buildBackupDto(DATA_EXPORT_MARKDOWN_BASE_URI, markdownZipPath);
} catch (IOException e) {
throw new ServiceException("Failed to export markdowns", e);
} finally {
if (markdownZipOut != null) {
markdownZipOut.close();
}
}
}
@Override
public List<BackupDTO> listMarkdowns() {
// Ensure the parent folder exist
Path backupParentPath = Paths.get(haloProperties.getBackupMarkdownDir());
if (Files.notExists(backupParentPath)) {
return Collections.emptyList();
}
// Build backup dto
try (Stream<Path> subPathStream = Files.list(backupParentPath)) {
return subPathStream
.filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_MARKDOWN_PREFIX))
.map(backupPath -> buildBackupDto(DATA_EXPORT_MARKDOWN_BASE_URI, backupPath))
.sorted(Comparator.comparingLong(BackupDTO::getUpdateTime).reversed())
.collect(Collectors.toList());
} catch (IOException e) {
throw new ServiceException("Failed to fetch backups", e);
}
}
@Override
public void deleteMarkdown(String fileName) {
Assert.hasText(fileName, "File name must not be blank");
Path backupRootPath = Paths.get(haloProperties.getBackupMarkdownDir());
// Get backup path
Path backupPath = backupRootPath.resolve(fileName);
// Check directory traversal
run.halo.app.utils.FileUtils.checkDirectoryTraversal(backupRootPath, backupPath);
try {
// Delete backup file
Files.delete(backupPath);
} catch (NoSuchFileException e) {
throw new NotFoundException("The file " + fileName + " was not found", e);
} catch (IOException e) {
throw new ServiceException("Failed to delete backup", e);
}
}
/**
* Builds backup dto.
*

View File

@ -27,10 +27,7 @@ import run.halo.app.model.enums.PostStatus;
import run.halo.app.model.params.PostParam;
import run.halo.app.model.params.PostQuery;
import run.halo.app.model.properties.PostProperties;
import run.halo.app.model.vo.ArchiveMonthVO;
import run.halo.app.model.vo.ArchiveYearVO;
import run.halo.app.model.vo.PostDetailVO;
import run.halo.app.model.vo.PostListVO;
import run.halo.app.model.vo.*;
import run.halo.app.repository.PostRepository;
import run.halo.app.repository.base.BasePostRepository;
import run.halo.app.service.*;
@ -57,6 +54,7 @@ import static run.halo.app.model.support.HaloConst.URL_SEPARATOR;
* @author guqing
* @author evanwang
* @author coor.top
* @author Raremaa
* @date 2019-03-14
*/
@Slf4j
@ -819,6 +817,64 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
return Sort.by(DESC, "topPriority").and(Sort.by(DESC, indexSort).and(Sort.by(DESC, "id")));
}
@Override
public List<PostMarkdownVO> listPostMarkdowns() {
List<Post> allPostList = listAll();
List<PostMarkdownVO> result = new ArrayList(allPostList.size());
for (int i = 0; i < allPostList.size(); i++) {
Post post = allPostList.get(i);
result.add(convertToPostMarkdownVo(post));
}
return result;
}
private PostMarkdownVO convertToPostMarkdownVo(Post post) {
PostMarkdownVO postMarkdownVO = new PostMarkdownVO();
StringBuilder frontMatter = new StringBuilder("---\n");
frontMatter.append("title: ").append(post.getTitle()).append("\n");
frontMatter.append("date: ").append(post.getCreateTime()).append("\n");
frontMatter.append("updated: ").append(post.getUpdateTime()).append("\n");
//set fullPath
frontMatter.append("url: ").append(buildFullPath(post)).append("\n");
//set category
List<Category> categories = postCategoryService.listCategoriesBy(post.getId());
StringBuilder categoryContent = new StringBuilder();
for (int i = 0; i < categories.size(); i++) {
Category category = categories.get(i);
String categoryName = category.getName();
if (i == 0) {
categoryContent.append(categoryName);
} else {
categoryContent.append(" | ").append(categoryName);
}
}
frontMatter.append("categories: ").append(categoryContent.toString()).append("\n");
//set tags
List<Tag> tags = postTagService.listTagsBy(post.getId());
StringBuilder tagContent = new StringBuilder();
for (int i = 0; i < tags.size(); i++) {
Tag tag = tags.get(i);
String tagName = tag.getName();
if (i == 0) {
tagContent.append(tagName);
} else {
tagContent.append(" | ").append(tagName);
}
}
frontMatter.append("tags: ").append(tagContent.toString()).append("\n");
frontMatter.append("---\n");
postMarkdownVO.setFrontMatter(frontMatter.toString());
postMarkdownVO.setOriginalContent(post.getOriginalContent());
postMarkdownVO.setTitle(post.getTitle());
postMarkdownVO.setSlug(post.getSlug());
return postMarkdownVO;
}
private String buildFullPath(Post post) {
PostPermalinkType permalinkType = optionService.getPostPermalinkType();

View File

@ -36,7 +36,7 @@ class PostServiceImplTest {
@Test
void getContent() {
String exportMarkdown = postService.exportMarkdown(18);
String exportMarkdown = postService.exportMarkdown(1);
log.debug(exportMarkdown);
}