mirror of https://github.com/halo-dev/halo
Merge branch 'main' into feature-user-center
commit
d356b65671
|
@ -1,15 +1,20 @@
|
|||
plugins {
|
||||
id 'java-library'
|
||||
id 'halo.publish'
|
||||
id "io.freefair.lombok" version "8.0.0-rc2"
|
||||
id "io.freefair.lombok" version "8.4"
|
||||
}
|
||||
|
||||
group = 'run.halo.app'
|
||||
description = 'API of halo project, connecting by other projects.'
|
||||
|
||||
compileJava.options.encoding = "UTF-8"
|
||||
compileTestJava.options.encoding = "UTF-8"
|
||||
javadoc.options.encoding = "UTF-8"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
plugins {
|
||||
id 'org.springframework.boot' version '3.1.5'
|
||||
id 'org.springframework.boot' version '3.2.0-RC2'
|
||||
id 'io.spring.dependency-management' version '1.1.0'
|
||||
id "com.gorylenko.gradle-git-properties" version "2.3.2"
|
||||
id "checkstyle"
|
||||
id 'java'
|
||||
id 'jacoco'
|
||||
id "de.undercouch.download" version "5.3.1"
|
||||
id "io.freefair.lombok" version "8.0.0-rc2"
|
||||
id "io.freefair.lombok" version "8.4"
|
||||
}
|
||||
|
||||
group = "run.halo.app"
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
compileJava.options.encoding = "UTF-8"
|
||||
compileTestJava.options.encoding = "UTF-8"
|
||||
|
||||
checkstyle {
|
||||
toolVersion = "9.3"
|
||||
|
@ -22,6 +24,7 @@ repositories {
|
|||
mavenCentral()
|
||||
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' }
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ public interface ReplyService {
|
|||
reply -> reply.getMetadata().getCreationTimestamp();
|
||||
// ascending order by creation time
|
||||
// asc nulls high will be placed at the end
|
||||
return Comparator.comparing(creationTime, Comparators.nullsHigh())
|
||||
return Comparator.comparing(creationTime, Comparators.nullsLow())
|
||||
.thenComparing(metadataCreationTime)
|
||||
.thenComparing(reply -> reply.getMetadata().getName());
|
||||
}
|
||||
|
|
|
@ -273,6 +273,7 @@ public class AttachmentEndpoint implements CustomEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
@Schema(types = "object")
|
||||
public interface IUploadRequest {
|
||||
|
||||
@Schema(requiredMode = REQUIRED, description = "Attachment file")
|
||||
|
|
|
@ -673,7 +673,7 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
.flatMap(listResult -> ServerResponse.ok().bodyValue(listResult));
|
||||
}
|
||||
|
||||
@Schema(name = "PluginInstallRequest")
|
||||
@Schema(name = "PluginInstallRequest", types = "object")
|
||||
public static class InstallRequest {
|
||||
|
||||
private final MultiValueMap<String, Part> multipartData;
|
||||
|
|
|
@ -8,7 +8,7 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuil
|
|||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
|
@ -42,9 +42,10 @@ import run.halo.app.extension.router.QueryParamBuildUtil;
|
|||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class PostEndpoint implements CustomEndpoint {
|
||||
|
||||
private int maxAttemptsWaitForPublish = 10;
|
||||
private final PostService postService;
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
|
@ -243,7 +244,7 @@ public class PostEndpoint implements CustomEndpoint {
|
|||
})
|
||||
.switchIfEmpty(Mono.error(
|
||||
() -> new RetryException("Retry to check post publish status"))))
|
||||
.retryWhen(Retry.fixedDelay(10, Duration.ofMillis(200))
|
||||
.retryWhen(Retry.backoff(maxAttemptsWaitForPublish, Duration.ofMillis(100))
|
||||
.filter(t -> t instanceof RetryException));
|
||||
}
|
||||
|
||||
|
@ -278,4 +279,11 @@ public class PostEndpoint implements CustomEndpoint {
|
|||
return postService.listPost(postQuery)
|
||||
.flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenient for testing, to avoid waiting too long for post published when testing.
|
||||
*/
|
||||
public void setMaxAttemptsWaitForPublish(int maxAttempts) {
|
||||
this.maxAttemptsWaitForPublish = maxAttempts;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -244,6 +244,7 @@ public class UserEndpoint implements CustomEndpoint {
|
|||
.flatMap(user -> ServerResponse.ok().bodyValue(user));
|
||||
}
|
||||
|
||||
@Schema(types = "object")
|
||||
public interface IAvatarUploadRequest {
|
||||
@Schema(requiredMode = REQUIRED, description = "Avatar file")
|
||||
FilePart getFile();
|
||||
|
|
|
@ -8,7 +8,10 @@ import static run.halo.app.plugin.PluginConst.PLUGIN_PATH;
|
|||
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
@ -40,6 +43,7 @@ import org.springframework.lang.Nullable;
|
|||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.core.extension.ReverseProxy;
|
||||
|
@ -649,11 +653,19 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
if (StringUtils.isBlank(pathString)) {
|
||||
return null;
|
||||
}
|
||||
String processedPathString = pathString;
|
||||
if (processedPathString.startsWith("file:")) {
|
||||
processedPathString = processedPathString.substring(7);
|
||||
try {
|
||||
var pathURL = new URL(pathString);
|
||||
if (!ResourceUtils.isFileURL(pathURL)) {
|
||||
throw new IllegalArgumentException("The path cannot be resolved to absolute file"
|
||||
+ " path because it does not reside in the file system: "
|
||||
+ pathString);
|
||||
}
|
||||
var pathURI = ResourceUtils.toURI(pathURL);
|
||||
return Paths.get(pathURI);
|
||||
} catch (MalformedURLException | URISyntaxException ignored) {
|
||||
// the given path string is not a valid URL.
|
||||
}
|
||||
return Paths.get(processedPathString);
|
||||
return Paths.get(pathString);
|
||||
}
|
||||
|
||||
URI toUri(String pathString) {
|
||||
|
|
|
@ -466,7 +466,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
.bodyValue(theme));
|
||||
}
|
||||
|
||||
@Schema(name = "ThemeInstallRequest")
|
||||
@Schema(name = "ThemeInstallRequest", types = "object")
|
||||
public static class InstallRequest {
|
||||
|
||||
@Schema(hidden = true)
|
||||
|
|
|
@ -9,6 +9,11 @@ public class ThemeProperties {
|
|||
@Valid
|
||||
private final Initializer initializer = new Initializer();
|
||||
|
||||
/**
|
||||
* Indicates whether the generator meta needs to be disabled.
|
||||
*/
|
||||
private boolean generatorMetaDisabled;
|
||||
|
||||
@Data
|
||||
public static class Initializer {
|
||||
|
||||
|
|
|
@ -140,6 +140,7 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
.switchIfEmpty(backupFileContent);
|
||||
}
|
||||
|
||||
@Schema(types = "object")
|
||||
public static class RestoreRequest {
|
||||
private final MultiValueMap<String, Part> multipart;
|
||||
|
||||
|
|
|
@ -8,7 +8,10 @@ import java.io.IOException;
|
|||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
@ -21,8 +24,10 @@ import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
|
|||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
import run.halo.app.theme.dialect.GeneratorMetaProcessor;
|
||||
import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
|
||||
import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
|
||||
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
|
@ -86,4 +91,12 @@ public class ThemeConfiguration {
|
|||
ServerSecurityContextRepository securityContextRepository) {
|
||||
return new HaloSpringSecurityDialect(securityContextRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "halo.theme.generator-meta-disabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
TemplateHeadProcessor generatorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
|
||||
return new GeneratorMetaProcessor(buildProperties);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
package run.halo.app.theme.dialect;
|
||||
|
||||
import static org.thymeleaf.model.AttributeValueQuotes.DOUBLE;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.thymeleaf.context.ITemplateContext;
|
||||
import org.thymeleaf.model.IModel;
|
||||
import org.thymeleaf.processor.element.IElementModelStructureHandler;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Processor for generating generator meta.
|
||||
* Set the order to 0 for removing the meta in later TemplateHeadProcessor.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
@Order(0)
|
||||
public class GeneratorMetaProcessor implements TemplateHeadProcessor {
|
||||
|
||||
private final String generatorValue;
|
||||
|
||||
public GeneratorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
|
||||
this.generatorValue = "Halo " + buildProperties.stream().findFirst()
|
||||
.map(BuildProperties::getVersion)
|
||||
.orElse("Unknown");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||
IElementModelStructureHandler structureHandler) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
var modelFactory = context.getModelFactory();
|
||||
var generatorMeta = modelFactory.createStandaloneElementTag("meta",
|
||||
Map.of("name", "generator", "content", generatorValue),
|
||||
DOUBLE, false, true);
|
||||
model.add(generatorMeta);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
package run.halo.app.theme.finders.impl;
|
||||
|
||||
|
||||
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
@ -9,7 +12,6 @@ import java.util.function.Function;
|
|||
import java.util.function.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
|
@ -254,35 +256,41 @@ public class CommentPublicQueryServiceImpl implements CommentPublicQueryService
|
|||
public int compare(Comment c1, Comment c2) {
|
||||
boolean c1Top = BooleanUtils.isTrue(c1.getSpec().getTop());
|
||||
boolean c2Top = BooleanUtils.isTrue(c2.getSpec().getTop());
|
||||
if (c1Top == c2Top) {
|
||||
int c1Priority = ObjectUtils.defaultIfNull(c1.getSpec().getPriority(), 0);
|
||||
int c2Priority = ObjectUtils.defaultIfNull(c2.getSpec().getPriority(), 0);
|
||||
if (c1Top) {
|
||||
// 都置顶
|
||||
return Integer.compare(c1Priority, c2Priority);
|
||||
}
|
||||
|
||||
// 两个评论不置顶根据 creationTime 降序排列
|
||||
return Comparator.comparing(
|
||||
(Comment comment) -> comment.getSpec().getCreationTime(),
|
||||
Comparators.nullsLow())
|
||||
.thenComparing((Comment comment) -> comment.getMetadata().getName())
|
||||
.compare(c2, c1);
|
||||
} else if (c1Top) {
|
||||
// 只有 c1 置顶,c1 排前面
|
||||
// c1 top = true && c2 top = false
|
||||
if (c1Top && !c2Top) {
|
||||
return -1;
|
||||
} else {
|
||||
// 只有c2置顶, c2排在前面
|
||||
}
|
||||
|
||||
// c1 top = false && c2 top = true
|
||||
if (!c1Top && c2Top) {
|
||||
return 1;
|
||||
}
|
||||
// c1 top = c2 top = true || c1 top = c2 top = false
|
||||
var priorityComparator = Comparator.<Comment, Integer>comparing(
|
||||
comment -> defaultIfNull(comment.getSpec().getPriority(), 0));
|
||||
|
||||
var creationTimeComparator = Comparator.<Comment, Instant>comparing(
|
||||
comment -> comment.getSpec().getCreationTime(),
|
||||
Comparators.nullsLow(Comparator.<Instant>reverseOrder()));
|
||||
|
||||
var nameComparator = Comparator.<Comment, String>comparing(
|
||||
comment -> comment.getMetadata().getName());
|
||||
|
||||
if (c1Top) {
|
||||
return priorityComparator.thenComparing(creationTimeComparator)
|
||||
.thenComparing(nameComparator)
|
||||
.compare(c1, c2);
|
||||
}
|
||||
return creationTimeComparator.thenComparing(nameComparator).compare(c1, c2);
|
||||
}
|
||||
}
|
||||
|
||||
int pageNullSafe(Integer page) {
|
||||
return ObjectUtils.defaultIfNull(page, 1);
|
||||
return defaultIfNull(page, 1);
|
||||
}
|
||||
|
||||
int sizeNullSafe(Integer size) {
|
||||
return ObjectUtils.defaultIfNull(size, DEFAULT_SIZE);
|
||||
return defaultIfNull(size, DEFAULT_SIZE);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.config;
|
||||
|
||||
import java.util.List;
|
||||
import org.hamcrest.core.StringStartsWith;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
@ -30,7 +31,8 @@ class WebFluxConfigTest {
|
|||
.forEach(uri -> webClient.get().uri(uri)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(String.class).isEqualTo("console index\n"));
|
||||
.expectBody(String.class).value(StringStartsWith.startsWith("console index"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -38,7 +40,7 @@ class WebFluxConfigTest {
|
|||
webClient.get().uri("/console/assets/fake.txt")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(String.class).isEqualTo("fake.\n");
|
||||
.expectBody(String.class).value(StringStartsWith.startsWith("fake."));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -50,6 +50,7 @@ class PostEndpointTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
postEndpoint.setMaxAttemptsWaitForPublish(3);
|
||||
webTestClient = WebTestClient
|
||||
.bindToRouterFunction(postEndpoint.endpoint())
|
||||
.build();
|
||||
|
@ -170,7 +171,7 @@ class PostEndpointTest {
|
|||
.is5xxServerError();
|
||||
|
||||
// Verify WebClient retry behavior
|
||||
verify(client, times(12)).get(eq(Post.class), eq("post-1"));
|
||||
verify(client, times(5)).get(eq(Post.class), eq("post-1"));
|
||||
verify(client).update(any(Post.class));
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,9 @@ package run.halo.app.core.extension.reconciler;
|
|||
import static java.util.Objects.requireNonNull;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
@ -369,12 +371,15 @@ class PluginReconcilerTest {
|
|||
|
||||
@Test
|
||||
void resolvePluginPathAnnotation() {
|
||||
when(haloPluginManager.getPluginsRoot()).thenReturn(Paths.get("/tmp/plugins"));
|
||||
String path = pluginReconciler.resolvePluginPathForAnno("/tmp/plugins/sitemap-1.0.jar");
|
||||
var pluginRoot = Paths.get("tmp", "plugins");
|
||||
when(haloPluginManager.getPluginsRoot()).thenReturn(pluginRoot);
|
||||
var path = pluginReconciler.resolvePluginPathForAnno(
|
||||
pluginRoot.resolve("sitemap-1.0.jar").toString());
|
||||
assertThat(path).isEqualTo("sitemap-1.0.jar");
|
||||
|
||||
path = pluginReconciler.resolvePluginPathForAnno("/abc/plugins/sitemap-1.0.jar");
|
||||
assertThat(path).isEqualTo("/abc/plugins/sitemap-1.0.jar");
|
||||
var givenPath = Paths.get("abc", "plugins", "sitemap-1.0.jar");
|
||||
path = pluginReconciler.resolvePluginPathForAnno(givenPath.toString());
|
||||
assertThat(path).isEqualTo(givenPath.toString());
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@ -457,17 +462,18 @@ class PluginReconcilerTest {
|
|||
assertThat(pluginReconciler.toPath("")).isNull();
|
||||
assertThat(pluginReconciler.toPath(" ")).isNull();
|
||||
|
||||
Path path = pluginReconciler.toPath("file:///path/to/file.txt");
|
||||
assertThat(path).isNotNull();
|
||||
assertThat(path.toString()).isEqualTo("/path/to/file.txt");
|
||||
final var filePath = Paths.get("path", "to", "file.txt").toAbsolutePath();
|
||||
|
||||
assertThat(pluginReconciler.toPath("C:\\Users\\faker\\halo\\plugins").toString())
|
||||
.isEqualTo("C:\\Users\\faker\\halo\\plugins");
|
||||
assertThat(pluginReconciler.toPath("C:/Users/faker/halo/plugins").toString())
|
||||
.isEqualTo("C:/Users/faker/halo/plugins");
|
||||
Path windowsPath = Paths.get("C:/Users/username/Documents/file.txt");
|
||||
assertThat(pluginReconciler.toPath("file://C:/Users/username/Documents/file.txt"))
|
||||
.isEqualTo(windowsPath);
|
||||
// test for file:///
|
||||
assertEquals(filePath, pluginReconciler.toPath(filePath.toUri().toString()));
|
||||
// test for absolute path /home/xyz or C:\Windows
|
||||
assertEquals(filePath, pluginReconciler.toPath(filePath.toString()));
|
||||
|
||||
var exception = assertThrows(IllegalArgumentException.class, () -> {
|
||||
var fileUri = filePath.toUri();
|
||||
pluginReconciler.toPath(fileUri.toString().replaceFirst("file", "http"));
|
||||
});
|
||||
assertTrue(exception.getMessage().contains("not reside in the file system"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -483,8 +489,9 @@ class PluginReconcilerTest {
|
|||
});
|
||||
|
||||
// Test with non-empty pathString
|
||||
URI uri = pluginReconciler.toUri("/path/to/file");
|
||||
Assertions.assertEquals("file:///path/to/file", uri.toString());
|
||||
var filePath = Paths.get("path", "to", "file");
|
||||
URI uri = pluginReconciler.toUri(filePath.toString());
|
||||
assertEquals(filePath.toUri(), uri);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -207,8 +207,9 @@ class PluginServiceImplTest {
|
|||
String pluginName = "test-plugin";
|
||||
PluginWrapper pluginWrapper = mock(PluginWrapper.class);
|
||||
when(pluginManager.getPlugin(pluginName)).thenReturn(pluginWrapper);
|
||||
var pluginPath = Paths.get("tmp", "plugins", "fake-plugin.jar");
|
||||
when(pluginWrapper.getPluginPath())
|
||||
.thenReturn(Paths.get("/tmp/plugins/fake-plugin.jar"));
|
||||
.thenReturn(pluginPath);
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setMetadata(new Metadata());
|
||||
plugin.getMetadata().setName(pluginName);
|
||||
|
@ -224,7 +225,7 @@ class PluginServiceImplTest {
|
|||
verify(client, times(1)).update(
|
||||
argThat(p -> {
|
||||
String reloadPath = p.getMetadata().getAnnotations().get(PluginConst.RELOAD_ANNO);
|
||||
assertThat(reloadPath).isEqualTo("/tmp/plugins/fake-plugin.jar");
|
||||
assertThat(reloadPath).isEqualTo(pluginPath.toString());
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
|
|
@ -64,8 +64,9 @@ class FileUtilsTest {
|
|||
unzip(zis, unzipTarget);
|
||||
}
|
||||
|
||||
var content = Files.readString(unzipTarget.resolve("examplefile"));
|
||||
assertEquals("Here is an example file.\n", content);
|
||||
var lines = Files.readAllLines(unzipTarget.resolve("examplefile"));
|
||||
assertEquals(1, lines.size());
|
||||
assertEquals("Here is an example file.", lines.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -79,9 +80,9 @@ class FileUtilsTest {
|
|||
try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) {
|
||||
unzip(zis, unzipTarget);
|
||||
}
|
||||
|
||||
var content = Files.readString(unzipTarget.resolve("examplefile"));
|
||||
assertEquals("Here is an example file.\n", content);
|
||||
var lines = Files.readAllLines(unzipTarget.resolve("examplefile"));
|
||||
assertEquals(1, lines.size());
|
||||
assertEquals("Here is an example file.", lines.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -23,7 +23,7 @@ class ThemeConfigurationTest {
|
|||
@InjectMocks
|
||||
private ThemeConfiguration themeConfiguration;
|
||||
|
||||
private final Path themeRoot = Paths.get("/tmp/.halo/themes");
|
||||
private final Path themeRoot = Paths.get("tmp", ".halo", "themes");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
|
@ -33,25 +33,28 @@ class ThemeConfigurationTest {
|
|||
@Test
|
||||
void themeAssets() {
|
||||
Path path = themeConfiguration.getThemeAssetsPath("fake-theme", "hello.jpg");
|
||||
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/hello.jpg"));
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "hello.jpg")));
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "./hello.jpg");
|
||||
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/./hello.jpg"));
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", ".", "hello.jpg")));
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg");
|
||||
}).isInstanceOf(AccessDeniedException.class)
|
||||
.hasMessage(
|
||||
"403 FORBIDDEN \"Directory traversal detected: /tmp/"
|
||||
+ ".halo/themes/fake-theme/templates/assets/../../hello.jpg\"");
|
||||
assertThatThrownBy(() ->
|
||||
themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg"))
|
||||
.isInstanceOf(AccessDeniedException.class)
|
||||
.hasMessageContaining("Directory traversal detected");
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "%2e%2e/f.jpg");
|
||||
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/%2e%2e/f.jpg"));
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "%2e%2e", "f.jpg")));
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "f/./../p.jpg");
|
||||
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/f/./../p.jpg"));
|
||||
assertThat(path).isEqualTo(themeRoot.resolve(
|
||||
Paths.get("fake-theme", "templates", "assets", "f", ".", "..", "p.jpg")));
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "f../p.jpg");
|
||||
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/f../p.jpg"));
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "f..", "p.jpg")));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
package run.halo.app.theme.dialect;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
import run.halo.app.theme.ThemeContext;
|
||||
import run.halo.app.theme.ThemeResolver;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureWebTestClient
|
||||
class GeneratorMetaProcessorTest {
|
||||
|
||||
@Autowired
|
||||
WebTestClient webClient;
|
||||
|
||||
@MockBean
|
||||
InitializationStateGetter initializationStateGetter;
|
||||
|
||||
@MockBean
|
||||
ThemeResolver themeResolver;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws FileNotFoundException, URISyntaxException {
|
||||
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
|
||||
var themeContext = ThemeContext.builder()
|
||||
.name("default")
|
||||
.path(Path.of(ResourceUtils.getURL("classpath:themes/default").toURI()))
|
||||
.active(true)
|
||||
.build();
|
||||
when(themeResolver.getTheme(any(ServerWebExchange.class)))
|
||||
.thenReturn(Mono.just(themeContext));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestIndexPage() {
|
||||
webClient.get().uri("/")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.consumeWith(System.out::println)
|
||||
.xpath("/html/head/meta[@name=\"generator\"][starts-with(@content, \"Halo \")]")
|
||||
.exists();
|
||||
}
|
||||
|
||||
}
|
|
@ -164,7 +164,7 @@ class CommentPublicQueryServiceImplTest {
|
|||
.map(Comment::getMetadata)
|
||||
.map(MetadataOperator::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
assertThat(result).isEqualTo("1, 2, 3, 4, 5, 6, 9, 14, 10, 8, 7, 13, 12, 11");
|
||||
assertThat(result).isEqualTo("1, 2, 4, 3, 5, 6, 10, 14, 9, 8, 7, 11, 12, 13");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -68,21 +68,9 @@ public class ThemeMessageResolverIntegrationTest {
|
|||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>zh</div>
|
||||
<div>欢迎来到首页</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
.expectBody()
|
||||
.xpath("/html/body/div[1]").isEqualTo("zh")
|
||||
.xpath("/html/body/div[2]").isEqualTo("欢迎来到首页");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -92,21 +80,9 @@ public class ThemeMessageResolverIntegrationTest {
|
|||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>en</div>
|
||||
<div>Welcome to the index</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
.expectBody()
|
||||
.xpath("/html/body/div[1]").isEqualTo("en")
|
||||
.xpath("/html/body/div[2]").isEqualTo("Welcome to the index");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -116,21 +92,9 @@ public class ThemeMessageResolverIntegrationTest {
|
|||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>foo</div>
|
||||
<div>欢迎来到首页</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
.expectBody()
|
||||
.xpath("/html/body/div[1]").isEqualTo("foo")
|
||||
.xpath("/html/body/div[2]").isEqualTo("欢迎来到首页");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -140,21 +104,11 @@ public class ThemeMessageResolverIntegrationTest {
|
|||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>zh</div>
|
||||
<div>欢迎来到首页</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
.expectBody()
|
||||
.xpath("/html/head/title").isEqualTo("Title")
|
||||
.xpath("/html/body/div[1]").isEqualTo("zh")
|
||||
.xpath("/html/body/div[2]").isEqualTo("欢迎来到首页")
|
||||
;
|
||||
|
||||
// For other theme
|
||||
when(themeResolver.getTheme(any(ServerWebExchange.class)))
|
||||
|
@ -162,35 +116,16 @@ public class ThemeMessageResolverIntegrationTest {
|
|||
webTestClient.get()
|
||||
.uri("/index?language=zh")
|
||||
.exchange()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Other theme title</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Other 首页</p>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
.expectBody()
|
||||
.xpath("/html/head/title").isEqualTo("Other theme title")
|
||||
.xpath("/html/body/p").isEqualTo("Other 首页");
|
||||
|
||||
webTestClient.get()
|
||||
.uri("/index?language=en")
|
||||
.exchange()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Other theme title</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>other index</p>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
.expectBody()
|
||||
.xpath("/html/head/title").isEqualTo("Other theme title")
|
||||
.xpath("/html/body/p").isEqualTo("other index");
|
||||
}
|
||||
|
||||
ThemeContext createDefaultContext() throws URISyntaxException {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Other theme title</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { usePluginModuleStore } from "@/stores/plugin";
|
||||
import type { EditorProvider, PluginModule } from "@halo-dev/console-shared";
|
||||
import { onMounted, ref, type Ref, defineAsyncComponent } from "vue";
|
||||
import { onMounted, ref, type Ref, defineAsyncComponent, markRaw } from "vue";
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
import Logo from "@/assets/logo.png";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
@ -18,11 +18,13 @@ export function useEditorExtensionPoints(): useEditorExtensionPointsReturn {
|
|||
{
|
||||
name: "default",
|
||||
displayName: t("core.plugin.extension_points.editor.providers.default"),
|
||||
component: defineAsyncComponent({
|
||||
loader: () => import("@/components/editor/DefaultEditor.vue"),
|
||||
loadingComponent: VLoading,
|
||||
delay: 200,
|
||||
}),
|
||||
component: markRaw(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/editor/DefaultEditor.vue"),
|
||||
loadingComponent: VLoading,
|
||||
delay: 200,
|
||||
})
|
||||
),
|
||||
rawType: "HTML",
|
||||
logo: Logo,
|
||||
},
|
||||
|
|
|
@ -21,7 +21,7 @@ export async function setupPluginModules(app: App) {
|
|||
const { load } = useScriptTag(
|
||||
`${
|
||||
import.meta.env.VITE_API_URL
|
||||
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js`
|
||||
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?t=${Date.now()}`
|
||||
);
|
||||
|
||||
await load();
|
||||
|
@ -45,7 +45,7 @@ export async function setupPluginModules(app: App) {
|
|||
await loadStyle(
|
||||
`${
|
||||
import.meta.env.VITE_API_URL
|
||||
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css`
|
||||
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css?t=${Date.now()}`
|
||||
);
|
||||
} catch (e) {
|
||||
const message = i18n.global.t("core.plugin.loader.toast.style_load_failed");
|
||||
|
|
|
@ -86,6 +86,7 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
|
|||
import { usePluginModuleStore } from "@/stores/plugin";
|
||||
import type { PluginModule } from "@halo-dev/console-shared";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { onBeforeUnmount } from "vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
@ -355,6 +356,10 @@ onMounted(() => {
|
|||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
// image drag and paste upload
|
||||
const { policies } = useFetchAttachmentPolicy();
|
||||
|
||||
|
@ -491,203 +496,205 @@ const currentLocale = i18n.global.locale.value as
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AttachmentSelectorModal
|
||||
v-model:visible="attachmentSelectorModal"
|
||||
@select="onAttachmentSelect"
|
||||
/>
|
||||
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
|
||||
<template #extra>
|
||||
<OverlayScrollbarsComponent
|
||||
element="div"
|
||||
:options="{ scrollbars: { autoHide: 'scroll' } }"
|
||||
class="h-full border-l bg-white"
|
||||
defer
|
||||
>
|
||||
<VTabs v-model:active-id="extraActiveId" type="outline">
|
||||
<VTabItem
|
||||
id="toc"
|
||||
:label="$t('core.components.default_editor.tabs.toc.title')"
|
||||
>
|
||||
<div class="p-1 pt-0">
|
||||
<ul v-if="headingNodes?.length" class="space-y-1">
|
||||
<li
|
||||
v-for="(node, index) in headingNodes"
|
||||
:key="index"
|
||||
:class="[
|
||||
{ 'bg-gray-100': node.id === selectedHeadingNode?.id },
|
||||
]"
|
||||
class="group cursor-pointer truncate rounded-base px-1.5 py-1 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
@click="handleSelectHeadingNode(node)"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
paddingLeft: `${(node.level - 1) * 0.8}rem`,
|
||||
}"
|
||||
class="flex items-center gap-2"
|
||||
<div>
|
||||
<AttachmentSelectorModal
|
||||
v-model:visible="attachmentSelectorModal"
|
||||
@select="onAttachmentSelect"
|
||||
/>
|
||||
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
|
||||
<template #extra>
|
||||
<OverlayScrollbarsComponent
|
||||
element="div"
|
||||
:options="{ scrollbars: { autoHide: 'scroll' } }"
|
||||
class="h-full border-l bg-white"
|
||||
defer
|
||||
>
|
||||
<VTabs v-model:active-id="extraActiveId" type="outline">
|
||||
<VTabItem
|
||||
id="toc"
|
||||
:label="$t('core.components.default_editor.tabs.toc.title')"
|
||||
>
|
||||
<div class="p-1 pt-0">
|
||||
<ul v-if="headingNodes?.length" class="space-y-1">
|
||||
<li
|
||||
v-for="(node, index) in headingNodes"
|
||||
:key="index"
|
||||
:class="[
|
||||
{ 'bg-gray-100': node.id === selectedHeadingNode?.id },
|
||||
]"
|
||||
class="group cursor-pointer truncate rounded-base px-1.5 py-1 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
@click="handleSelectHeadingNode(node)"
|
||||
>
|
||||
<component
|
||||
:is="headingIcons[node.level]"
|
||||
class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white"
|
||||
:class="[
|
||||
{ '!bg-white': node.id === selectedHeadingNode?.id },
|
||||
]"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ node.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="flex flex-col items-center py-10">
|
||||
<span class="text-sm text-gray-600">
|
||||
{{ $t("core.components.default_editor.tabs.toc.empty") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VTabItem>
|
||||
<VTabItem
|
||||
id="information"
|
||||
:label="$t('core.components.default_editor.tabs.detail.title')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1 pt-0">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
:style="{
|
||||
paddingLeft: `${(node.level - 1) * 0.8}rem`,
|
||||
}"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.character_count"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconCharacterRecognition
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
<component
|
||||
:is="headingIcons[node.level]"
|
||||
class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white"
|
||||
:class="[
|
||||
{ '!bg-white': node.id === selectedHeadingNode?.id },
|
||||
]"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ node.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{ editor.storage.characterCount.characters() }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.word_count"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconCharacterRecognition
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{ editor.storage.characterCount.words() }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="flex flex-col items-center py-10">
|
||||
<span class="text-sm text-gray-600">
|
||||
{{ $t("core.components.default_editor.tabs.toc.empty") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VTabItem>
|
||||
<VTabItem
|
||||
id="information"
|
||||
:label="$t('core.components.default_editor.tabs.detail.title')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1 pt-0">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.character_count"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconCharacterRecognition
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{ editor.storage.characterCount.characters() }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.word_count"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconCharacterRecognition
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{ editor.storage.characterCount.words() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="publishTime" class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
<div v-if="publishTime" class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.publish_time"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconCalendar
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{
|
||||
formatDatetime(publishTime) ||
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.publish_time"
|
||||
"core.components.default_editor.tabs.detail.fields.draft"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconCalendar
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="owner" class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.owner"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconUserFollow
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{ owner }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{
|
||||
formatDatetime(publishTime) ||
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.draft"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="permalink" class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.permalink"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconLink
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
:href="permalink"
|
||||
:title="permalink"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{{ permalink }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="owner" class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.owner"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconUserFollow
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base font-medium text-gray-900">
|
||||
{{ owner }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="permalink" class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-sm text-gray-500 group-hover:text-gray-900"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"core.components.default_editor.tabs.detail.fields.permalink"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="rounded bg-gray-200 p-0.5">
|
||||
<IconLink
|
||||
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
:href="permalink"
|
||||
:title="permalink"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{{ permalink }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VTabItem>
|
||||
</VTabs>
|
||||
</OverlayScrollbarsComponent>
|
||||
</template>
|
||||
</RichTextEditor>
|
||||
</VTabItem>
|
||||
</VTabs>
|
||||
</OverlayScrollbarsComponent>
|
||||
</template>
|
||||
</RichTextEditor>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -17,10 +17,12 @@ import { randomUUID } from "@/utils/id";
|
|||
const themeStore = useThemeStore();
|
||||
|
||||
function keyValidationRule(node: FormKitNode) {
|
||||
return (
|
||||
!annotations.value?.[node.value as string] &&
|
||||
!customAnnotationsDuplicateKey.value
|
||||
);
|
||||
const validAnnotations = [
|
||||
...Object.keys(annotations.value),
|
||||
...customAnnotationsState.value.map((item) => item.key),
|
||||
];
|
||||
const count = validAnnotations.filter((item) => item === node.value);
|
||||
return count.length < 2;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
|
@ -73,12 +75,6 @@ const annotations = ref<{
|
|||
}>({});
|
||||
const customAnnotationsState = ref<{ key: string; value: string }[]>([]);
|
||||
|
||||
const customAnnotationsDuplicateKey = computed(() => {
|
||||
const keys = customAnnotationsState.value.map((item) => item.key);
|
||||
const uniqueKeys = new Set(keys);
|
||||
return keys.length !== uniqueKeys.size;
|
||||
});
|
||||
|
||||
const customAnnotations = computed(() => {
|
||||
return customAnnotationsState.value.reduce((acc, cur) => {
|
||||
acc[cur.key] = cur.value;
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import { VModal, IconLink } from "@halo-dev/components";
|
||||
import {
|
||||
VModal,
|
||||
IconLink,
|
||||
VTabbar,
|
||||
IconComputer,
|
||||
IconTablet,
|
||||
IconPhone,
|
||||
} from "@halo-dev/components";
|
||||
import { computed, markRaw, ref } from "vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
@ -25,6 +33,32 @@ const onVisibleChange = (visible: boolean) => {
|
|||
emit("close");
|
||||
}
|
||||
};
|
||||
|
||||
const mockDevices = [
|
||||
{
|
||||
id: "desktop",
|
||||
icon: markRaw(IconComputer),
|
||||
},
|
||||
{
|
||||
id: "tablet",
|
||||
icon: markRaw(IconTablet),
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
icon: markRaw(IconPhone),
|
||||
},
|
||||
];
|
||||
const deviceActiveId = ref(mockDevices[0].id);
|
||||
|
||||
const iframeClasses = computed(() => {
|
||||
if (deviceActiveId.value === "desktop") {
|
||||
return "w-full h-full";
|
||||
}
|
||||
if (deviceActiveId.value === "tablet") {
|
||||
return "w-2/3 h-2/3 ring-2 rounded ring-gray-300";
|
||||
}
|
||||
return "w-96 h-[50rem] ring-2 rounded ring-gray-300";
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<VModal
|
||||
|
@ -35,6 +69,14 @@ const onVisibleChange = (visible: boolean) => {
|
|||
:layer-closable="true"
|
||||
@update:visible="onVisibleChange"
|
||||
>
|
||||
<template #center>
|
||||
<!-- TODO: Reactor VTabbar component to support icon prop -->
|
||||
<VTabbar
|
||||
v-model:active-id="deviceActiveId"
|
||||
:items="mockDevices as any"
|
||||
type="outline"
|
||||
></VTabbar>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions"></slot>
|
||||
<span>
|
||||
|
@ -46,7 +88,8 @@ const onVisibleChange = (visible: boolean) => {
|
|||
<div class="flex h-full items-center justify-center">
|
||||
<iframe
|
||||
v-if="visible"
|
||||
class="h-full w-full border-none transition-all duration-300"
|
||||
class="border-none transition-all duration-500"
|
||||
:class="iframeClasses"
|
||||
:src="url"
|
||||
></iframe>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ export function loadStyle(href: string) {
|
|||
return new Promise(function (resolve, reject) {
|
||||
let shouldAppend = false;
|
||||
let el: HTMLLinkElement | null = document.querySelector(
|
||||
'script[src="' + href + '"]'
|
||||
'link[href="' + href + '"]'
|
||||
);
|
||||
if (!el) {
|
||||
el = document.createElement("link");
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
version=2.11.0-SNAPSHOT
|
||||
snakeyaml.version=2.2
|
||||
|
|
Binary file not shown.
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
|
@ -144,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
|
@ -152,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import org.springframework.boot.gradle.plugin.SpringBootPlugin
|
||||
|
||||
plugins {
|
||||
id 'org.springframework.boot' version '3.1.5' apply false
|
||||
id 'org.springframework.boot' version '3.2.0-RC2' apply false
|
||||
id 'java-platform'
|
||||
id 'halo.publish'
|
||||
id 'signing'
|
||||
|
@ -18,7 +18,7 @@ ext {
|
|||
guava = "32.0.1-jre"
|
||||
jsoup = '1.15.3'
|
||||
jsonPatch = "1.13"
|
||||
springDocOpenAPI = "2.2.0"
|
||||
springDocOpenAPI = "2.2.1-SNAPSHOT"
|
||||
lucene = "9.7.0"
|
||||
resilience4jVersion = "2.0.2"
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue