hoàn thành block editor của Pages

This commit is contained in:
2026-06-25 16:38:03 +07:00
parent 5c644545b8
commit 6fe098bc5b
32 changed files with 2036 additions and 8 deletions
@@ -0,0 +1,29 @@
package com.sisvietnamvn.web.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.file.Path;
import java.nio.file.Paths;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
exposeDirectory("uploads", registry);
}
private void exposeDirectory(String dirName, ResourceHandlerRegistry registry) {
Path uploadDir = Paths.get(dirName);
String uploadPath = uploadDir.toFile().getAbsolutePath();
if (dirName.startsWith("../")) {
dirName = dirName.replace("../", "");
}
registry.addResourceHandler("/" + dirName + "/**")
.addResourceLocations("file:/" + uploadPath + "/");
}
}
@@ -46,8 +46,9 @@ public class SecurityConfiguration {
authz
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/**", "/css/**", "/images/**", "/js/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**")
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/api/manage/snippets/**", "/page/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/manage/**", "/api/manage/media/upload").permitAll()
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/authenticate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/authenticate").permitAll()
@@ -13,6 +13,7 @@ import org.springframework.util.CollectionUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import com.sisvietnamvn.web.hook.HookManager;
import tech.jhipster.config.JHipsterConstants;
import tech.jhipster.config.JHipsterProperties;
import tech.jhipster.config.h2.H2ConfigurationHelper;
@@ -26,12 +27,13 @@ public class WebConfigurer implements ServletContextInitializer {
private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class);
private final Environment env;
private final JHipsterProperties jHipsterProperties;
private final HookManager hookManager;
public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) {
public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties, HookManager hookManager) {
this.env = env;
this.jHipsterProperties = jHipsterProperties;
this.hookManager = hookManager;
}
@Override
@@ -44,6 +46,7 @@ public class WebConfigurer implements ServletContextInitializer {
initH2Console(servletContext);
}
LOG.info("Web application fully configured");
hookManager.doAction("init");
}
@Bean
@@ -0,0 +1,79 @@
package com.sisvietnamvn.web.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.security.SecurityUtils;
import com.sisvietnamvn.web.service.PageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Controller for rendering public-facing pages dynamically.
*/
@Controller
public class PageController {
private static final Logger LOG = LoggerFactory.getLogger(PageController.class);
private final PageService pageService;
private final ObjectMapper objectMapper;
public PageController(PageService pageService, ObjectMapper objectMapper) {
this.pageService = pageService;
this.objectMapper = objectMapper;
}
/**
* GET /page/{slug} : Render a page dynamically by slug.
*/
@GetMapping("/page/{slug}")
public String getPage(@PathVariable("slug") String slug, Model model) {
LOG.debug("REST request to get public Page : {}", slug);
Optional<Page> pageOpt = pageService.findBySlug(slug);
if (pageOpt.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Page not found");
}
Page page = pageOpt.get();
// Security Check: If Draft, only Admins can view
if ("DRAFT".equals(page.getStatus()) && !SecurityUtils.hasCurrentUserThisAuthority(AuthoritiesConstants.ADMIN)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Page not found");
}
// Parse Editor.js content JSON to extract blocks
List<Map<String, Object>> blocks = Collections.emptyList();
if (page.getContent() != null && !page.getContent().trim().isEmpty()) {
try {
// Editor.js JSON has a "blocks" array at the root level
Map<String, Object> editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for page slug: {}", slug, e);
}
}
model.addAttribute("page", page);
model.addAttribute("blocks", blocks);
return "page";
}
}
@@ -1,16 +1,19 @@
package com.sisvietnamvn.web.controller;
package com.sisvietnamvn.web.controller.manage;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Controller for the admin dashboard index page.
* Serves the main "/manage" landing page.
*/
@Controller
@RequestMapping("/manage")
public class ManageController {
public class ManageDashboardController {
@GetMapping({"", "/"})
public String index() {
return "manage/index";
}
}
@@ -0,0 +1,135 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.PageService;
import jakarta.validation.Valid;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing CMS Pages in the admin panel.
* Provides CRUD operations for static pages.
*/
@Controller
@RequestMapping("/manage/pages")
public class ManagePageController {
private static final Logger LOG = LoggerFactory.getLogger(ManagePageController.class);
private final PageService pageService;
private final HookManager hookManager;
public ManagePageController(PageService pageService, HookManager hookManager) {
this.pageService = pageService;
this.hookManager = hookManager;
}
/**
* Triggered before any handler method in this controller.
*/
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/pages — List all pages.
*/
@GetMapping
public String listPages(Model model) {
LOG.debug("Request to list all pages");
model.addAttribute("pages", pageService.findAll());
return "manage/pages/list";
}
/**
* GET /manage/pages/new — Show the "Add New Page" form.
*/
@GetMapping("/new")
public String showCreateForm(Model model) {
LOG.debug("Request to show create page form");
Page page = new Page();
page.setStatus(PageStatus.DRAFT);
page.setDisplayOrder(0);
model.addAttribute("page", page);
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("isNew", true);
return "manage/pages/form";
}
/**
* POST /manage/pages — Save a new page.
*/
@PostMapping
public String createPage(@Valid @ModelAttribute("page") Page page,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Page : {}", page);
if (bindingResult.hasErrors()) {
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("isNew", true);
return "manage/pages/form";
}
pageService.save(page);
redirectAttributes.addFlashAttribute("successMessage", "Page created successfully!");
return "redirect:/manage/pages";
}
/**
* GET /manage/pages/{id}/edit — Show the "Edit Page" form.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Page : {}", id);
Optional<Page> pageOptional = pageService.findById(id);
if (pageOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Page not found.");
return "redirect:/manage/pages";
}
model.addAttribute("page", pageOptional.get());
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("isNew", false);
return "manage/pages/form";
}
/**
* POST /manage/pages/{id} — Update an existing page.
*/
@PostMapping("/{id}")
public String updatePage(@PathVariable Long id,
@Valid @ModelAttribute("page") Page page,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Page : {}", id);
if (bindingResult.hasErrors()) {
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("isNew", false);
return "manage/pages/form";
}
page.setId(id);
pageService.save(page);
redirectAttributes.addFlashAttribute("successMessage", "Page updated successfully!");
return "redirect:/manage/pages";
}
/**
* POST /manage/pages/{id}/delete — Delete a page.
*/
@PostMapping("/{id}/delete")
public String deletePage(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Page : {}", id);
pageService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Page deleted successfully!");
return "redirect:/manage/pages";
}
}
@@ -0,0 +1,79 @@
package com.sisvietnamvn.web.controller.manage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/manage/media")
public class MediaController {
private final Logger log = LoggerFactory.getLogger(MediaController.class);
// Save files in an 'uploads' directory at the project root
private final String UPLOAD_DIR = "uploads/";
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) {
log.debug("REST request to upload file: {}", file.getOriginalFilename());
Map<String, Object> response = new HashMap<>();
if (file == null || file.isEmpty()) {
response.put("success", 0);
return ResponseEntity.badRequest().body(response);
}
try {
// Ensure the upload directory exists
Path uploadPath = Paths.get(UPLOAD_DIR);
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// Generate a unique file name to avoid conflicts
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
String extension = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
extension = originalFilename.substring(dotIndex);
}
String newFilename = UUID.randomUUID().toString() + extension;
// Save the file
Path filePath = uploadPath.resolve(newFilename);
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
// Construct the Editor.js expected response
Map<String, Object> fileData = new HashMap<>();
fileData.put("url", "/uploads/" + newFilename);
fileData.put("name", originalFilename);
fileData.put("size", file.getSize());
if (extension.startsWith(".")) {
fileData.put("extension", extension.substring(1));
}
response.put("success", 1);
response.put("file", fileData);
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to store uploaded file", e);
response.put("success", 0);
return ResponseEntity.internalServerError().body(response);
}
}
}
@@ -0,0 +1,56 @@
package com.sisvietnamvn.web.controller.manage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* REST controller for fetching predefined HTML snippets to be previewed in Editor.js.
*/
@RestController
@RequestMapping("/api/manage/snippets")
public class SnippetController {
private static final Logger LOG = LoggerFactory.getLogger(SnippetController.class);
private final ResourceLoader resourceLoader;
public SnippetController(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
/**
* GET /api/manage/snippets/{id} : get the raw HTML of a snippet
*/
@GetMapping(value = "/{id}", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> getSnippet(@PathVariable("id") String id) {
LOG.debug("REST request to get Snippet : {}", id);
// Prevent path traversal attacks
if (id == null || id.contains("..") || id.contains("/")) {
return ResponseEntity.badRequest().body("Invalid Snippet ID");
}
String path = "classpath:templates/snippets/" + id + ".html";
Resource resource = resourceLoader.getResource(path);
if (!resource.exists() || !resource.isReadable()) {
LOG.warn("Snippet not found at path: {}", path);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("<div style=\"padding: 20px; border: 1px dashed red; color: red;\">Snippet ID <strong>" + id + "</strong> not found.</div>");
}
try {
String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return ResponseEntity.ok().body(content);
} catch (IOException e) {
LOG.error("Error reading snippet file: {}", path, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error reading snippet file");
}
}
}
@@ -0,0 +1,136 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.Objects;
/**
* A CMS Page entity representing a static page (e.g. About Us, Contact, Working Hours).
* Maps to the "sis_page" database table.
*/
@Entity
@Table(name = "sis_page")
public class Page extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(max = 255)
@Column(name = "title", length = 255, nullable = false)
private String title;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
@Lob
@Column(name = "content")
private String content;
@Size(max = 500)
@Column(name = "meta_description", length = 500)
private String metaDescription;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
private PageStatus status = PageStatus.DRAFT;
@Column(name = "display_order")
private Integer displayOrder = 0;
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getMetaDescription() {
return metaDescription;
}
public void setMetaDescription(String metaDescription) {
this.metaDescription = metaDescription;
}
public PageStatus getStatus() {
return status;
}
public void setStatus(PageStatus status) {
this.status = status;
}
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Page page)) return false;
return id != null && id.equals(page.id);
}
@Override
public int hashCode() {
// Use a constant value to ensure consistency across entity state transitions.
// See: https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/
return getClass().hashCode();
}
@Override
public String toString() {
return "Page{" +
"id=" + getId() +
", title='" + getTitle() + "'" +
", slug='" + getSlug() + "'" +
", status='" + getStatus() + "'" +
", displayOrder=" + getDisplayOrder() +
"}";
}
}
@@ -0,0 +1,10 @@
package com.sisvietnamvn.web.domain;
/**
* Enumeration representing the publication status of a CMS page.
*/
public enum PageStatus {
DRAFT,
PUBLISHED,
ARCHIVED
}
@@ -0,0 +1,111 @@
package com.sisvietnamvn.web.hook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A central manager for WordPress-style Actions and Filters.
*/
@Service
public class HookManager {
private static final Logger LOG = LoggerFactory.getLogger(HookManager.class);
private final Map<String, List<ActionRegistration>> actions = new HashMap<>();
private final Map<String, List<FilterRegistration>> filters = new HashMap<>();
// --- Actions ---
public void addAction(String hookName, ActionCallback callback, int priority) {
actions.computeIfAbsent(hookName, k -> new ArrayList<>())
.add(new ActionRegistration(callback, priority));
actions.get(hookName).sort(Comparator.comparingInt(a -> a.priority));
LOG.debug("Added action to hook '{}' with priority {}", hookName, priority);
}
public void addAction(String hookName, ActionCallback callback) {
addAction(hookName, callback, 10);
}
public void doAction(String hookName, Object... args) {
if (actions.containsKey(hookName)) {
LOG.debug("Executing action hook '{}'", hookName);
for (ActionRegistration reg : actions.get(hookName)) {
try {
reg.callback.execute(args);
} catch (Exception e) {
LOG.error("Error executing action hook '{}'", hookName, e);
}
}
}
}
// --- Filters ---
public <T> void addFilter(String hookName, FilterCallback<T> callback, int priority) {
filters.computeIfAbsent(hookName, k -> new ArrayList<>())
.add(new FilterRegistration(callback, priority));
filters.get(hookName).sort(Comparator.comparingInt(a -> a.priority));
LOG.debug("Added filter to hook '{}' with priority {}", hookName, priority);
}
public <T> void addFilter(String hookName, FilterCallback<T> callback) {
addFilter(hookName, callback, 10);
}
@SuppressWarnings("unchecked")
public <T> T applyFilters(String hookName, T value, Object... args) {
if (filters.containsKey(hookName)) {
LOG.debug("Applying filter hook '{}'", hookName);
for (FilterRegistration reg : filters.get(hookName)) {
try {
value = (T) ((FilterCallback<Object>) reg.callback).filter(value, args);
} catch (Exception e) {
LOG.error("Error applying filter hook '{}'", hookName, e);
}
}
}
return value;
}
// --- Interfaces ---
@FunctionalInterface
public interface ActionCallback {
void execute(Object... args);
}
@FunctionalInterface
public interface FilterCallback<T> {
T filter(T value, Object... args);
}
// --- Internal Registration Records ---
private static class ActionRegistration {
final ActionCallback callback;
final int priority;
ActionRegistration(ActionCallback callback, int priority) {
this.callback = callback;
this.priority = priority;
}
}
private static class FilterRegistration {
final FilterCallback<?> callback;
final int priority;
FilterRegistration(FilterCallback<?> callback, int priority) {
this.callback = callback;
this.priority = priority;
}
}
}
@@ -0,0 +1,63 @@
package com.sisvietnamvn.web.hook;
import com.sisvietnamvn.web.domain.Page;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* A sample plugin to demonstrate the WordPress-style Hook system in action.
*/
@Component
public class SamplePluginHooks {
private static final Logger LOG = LoggerFactory.getLogger(SamplePluginHooks.class);
private final HookManager hookManager;
public SamplePluginHooks(HookManager hookManager) {
this.hookManager = hookManager;
}
@PostConstruct
public void registerHooks() {
LOG.info("Registering SamplePlugin hooks...");
// Example Action: Log when a page is saved
hookManager.addAction("save_post", args -> {
if (args.length > 0 && args[0] instanceof Page page) {
LOG.info("ACTION TRIGGERED (save_post): Page '{}' (ID: {}) was just saved!", page.getTitle(), page.getId());
}
});
// Example Action: Log on admin init
hookManager.addAction("admin_init", args -> {
LOG.debug("ACTION TRIGGERED (admin_init): Admin panel accessed.");
});
// Example Filter: Automatically modify page slug if it contains 'test'
hookManager.addFilter("page_slug", (value, args) -> {
if (value instanceof String slug) {
if (slug.contains("test")) {
String newSlug = slug + "-modified-by-plugin";
LOG.info("FILTER TRIGGERED (page_slug): Changed slug from '{}' to '{}'", slug, newSlug);
return newSlug;
}
}
return value;
}, 10);
// Example Filter: Pre-save filter to modify page content
hookManager.addFilter("pre_save_post", (value, args) -> {
if (value instanceof Page page) {
if (page.getContent() != null && !page.getContent().contains("Auto-signature")) {
LOG.info("FILTER TRIGGERED (pre_save_post): Appending signature to page '{}'", page.getTitle());
// In a real scenario, this would modify the JSON content of Editor.js,
// but for demonstration we just log it.
}
}
return value;
}, 20);
}
}
@@ -0,0 +1,40 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageStatus;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the {@link Page} entity.
*/
@Repository
public interface PageRepository extends JpaRepository<Page, Long> {
/**
* Find all pages ordered by display_order ascending (for admin list view).
*/
List<Page> findAllByOrderByDisplayOrderAsc();
/**
* Find a page by its URL-friendly slug (for public page rendering).
*/
Optional<Page> findBySlug(String slug);
/**
* Find all pages with a given publication status.
*/
List<Page> findByStatusOrderByDisplayOrderAsc(PageStatus status);
/**
* Check if a slug already exists (for uniqueness validation).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different page (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
}
@@ -0,0 +1,152 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.repository.PageRepository;
import java.text.Normalizer;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing CMS pages.
*/
@Service
@Transactional
public class PageService {
private static final Logger LOG = LoggerFactory.getLogger(PageService.class);
private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]");
private static final Pattern WHITESPACE = Pattern.compile("[\\s]");
private final PageRepository pageRepository;
private final HookManager hookManager;
public PageService(PageRepository pageRepository, HookManager hookManager) {
this.pageRepository = pageRepository;
this.hookManager = hookManager;
}
/**
* Get all pages sorted by display order.
*/
@Transactional(readOnly = true)
public List<Page> findAll() {
LOG.debug("Request to get all Pages");
return pageRepository.findAllByOrderByDisplayOrderAsc();
}
/**
* Get a single page by ID.
*/
@Transactional(readOnly = true)
public Optional<Page> findById(Long id) {
LOG.debug("Request to get Page : {}", id);
return pageRepository.findById(id);
}
/**
* Get a single page by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Page> findBySlug(String slug) {
LOG.debug("Request to get Page by slug : {}", slug);
return pageRepository.findBySlug(slug);
}
/**
* Find all pages with a specific status.
*/
@Transactional(readOnly = true)
public List<Page> findByStatus(PageStatus status) {
LOG.debug("Request to get Pages by status : {}", status);
return pageRepository.findByStatusOrderByDisplayOrderAsc(status);
}
/**
* Save a page (create or update).
* Auto-generates a URL slug from the title if the slug is empty.
*/
public Page save(Page page) {
LOG.debug("Request to save Page : {}", page);
// Hook: pre_save_post filter
page = hookManager.applyFilters("pre_save_post", page);
if (page.getSlug() == null || page.getSlug().isBlank()) {
page.setSlug(generateSlug(page.getTitle()));
}
// Ensure slug uniqueness by appending a suffix if needed
String baseSlug = page.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, page.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
// Hook: page_slug filter
candidateSlug = hookManager.applyFilters("page_slug", candidateSlug);
page.setSlug(candidateSlug);
Page savedPage = pageRepository.save(page);
// Hook: save_post action
hookManager.doAction("save_post", savedPage);
return savedPage;
}
/**
* Delete a page by ID.
*/
public void delete(Long id) {
LOG.debug("Request to delete Page : {}", id);
pageRepository.deleteById(id);
// Hook: deleted_post action
hookManager.doAction("deleted_post", id);
}
/**
* Check if a slug is already taken by another page.
*/
private boolean isSlugTaken(String slug, Long currentPageId) {
if (currentPageId == null) {
return pageRepository.existsBySlug(slug);
}
return pageRepository.existsBySlugAndIdNot(slug, currentPageId);
}
/**
* Generate a URL-friendly slug from a title string.
* Handles Vietnamese diacritics by normalizing Unicode characters.
* Example: "Giới thiệu Bệnh viện" -> "gioi-thieu-benh-vien"
*/
public static String generateSlug(String input) {
if (input == null || input.isBlank()) {
return "";
}
// Normalize Unicode (NFD) to separate base characters from diacritical marks
String noWhitespace = WHITESPACE.matcher(input.trim()).replaceAll("-");
String normalized = Normalizer.normalize(noWhitespace, Normalizer.Form.NFD);
// Remove diacritical marks (combining characters)
String noDiacritics = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
// Handle Vietnamese special characters that NFD doesn't fully decompose
noDiacritics = noDiacritics.replace('đ', 'd').replace('Đ', 'D');
// Remove non-latin characters
String slug = NON_LATIN.matcher(noDiacritics).replaceAll("");
// Collapse multiple hyphens
slug = slug.replaceAll("-{2,}", "-");
// Remove leading/trailing hyphens
slug = slug.replaceAll("^-|-$", "");
return slug.toLowerCase(Locale.ENGLISH);
}
}