mirror of https://github.com/halo-dev/halo
feat: add an API to list uninstalled themes (#2586)
#### What type of PR is this? /kind feature /milestone 2.0 /area core /kind api-change #### What this PR does / why we need it: 新增 API 用于查询未安装的主题 #### Which issue(s) this PR fixes: Fixes #2554 #### Special notes for your reviewer: how to test it? 1. 安装几个主题 2. 直接解压几个主题到 work dir 的 themes 目录 3. 使用以下 endpoint 查询未安装的主题,期望获得所有未安装主题的 themes.yaml 信息 ``` /apis/api.console.halo.run/v1alpha1/themes?uninstalled=true ``` /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 支持扫描主题目录下未安装的主题 ```pull/2592/head
parent
3d79484591
commit
58e98f0fc8
|
@ -14,8 +14,10 @@ import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.BaseStream;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
@ -28,6 +30,7 @@ import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.http.codec.multipart.Part;
|
import org.springframework.http.codec.multipart.Part;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
@ -46,8 +49,11 @@ import reactor.core.scheduler.Schedulers;
|
||||||
import run.halo.app.core.extension.Setting;
|
import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.ListResult;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Unstructured;
|
import run.halo.app.extension.Unstructured;
|
||||||
|
import run.halo.app.extension.router.IListRequest;
|
||||||
|
import run.halo.app.extension.router.QueryParamBuildUtil;
|
||||||
import run.halo.app.infra.exception.ThemeInstallationException;
|
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
import run.halo.app.infra.utils.DataBufferUtils;
|
import run.halo.app.infra.utils.DataBufferUtils;
|
||||||
|
@ -106,9 +112,65 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
.response(responseBuilder()
|
.response(responseBuilder()
|
||||||
.implementation(Theme.class))
|
.implementation(Theme.class))
|
||||||
)
|
)
|
||||||
|
.GET("themes", this::listThemes,
|
||||||
|
builder -> {
|
||||||
|
builder.operationId("ListThemes")
|
||||||
|
.description("List themes.")
|
||||||
|
.tag(tag)
|
||||||
|
.response(responseBuilder()
|
||||||
|
.implementation(ListResult.generateGenericClass(Theme.class)));
|
||||||
|
QueryParamBuildUtil.buildParametersFromType(builder, ThemeQuery.class);
|
||||||
|
}
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ThemeQuery extends IListRequest.QueryListRequest {
|
||||||
|
|
||||||
|
public ThemeQuery(MultiValueMap<String, String> queryParams) {
|
||||||
|
super(queryParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Boolean getUninstalled() {
|
||||||
|
return Boolean.parseBoolean(queryParams.getFirst("uninstalled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<ServerResponse> listThemes(ServerRequest request) {
|
||||||
|
MultiValueMap<String, String> queryParams = request.queryParams();
|
||||||
|
ThemeQuery query = new ThemeQuery(queryParams);
|
||||||
|
return Mono.defer(() -> {
|
||||||
|
if (query.getUninstalled()) {
|
||||||
|
return listUninstalled(query);
|
||||||
|
}
|
||||||
|
return client.list(Theme.class, null, null, query.getPage(), query.getSize());
|
||||||
|
}).flatMap(extensions -> ServerResponse.ok().bodyValue(extensions));
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<ListResult<Theme>> listUninstalled(ThemeQuery query) {
|
||||||
|
Path path = themePathPolicy.themesDir();
|
||||||
|
return ThemeUtils.listAllThemesFromThemeDir(path)
|
||||||
|
.collectList()
|
||||||
|
.flatMap(this::filterUnInstalledThemes)
|
||||||
|
.map(themes -> {
|
||||||
|
Integer page = query.getPage();
|
||||||
|
Integer size = query.getSize();
|
||||||
|
List<Theme> subList = ListResult.subList(themes, page, size);
|
||||||
|
return new ListResult<>(page, size, themes.size(), subList);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<List<Theme>> filterUnInstalledThemes(@NonNull List<Theme> allThemes) {
|
||||||
|
return client.list(Theme.class, null, null)
|
||||||
|
.map(theme -> theme.getMetadata().getName())
|
||||||
|
.collectList()
|
||||||
|
.map(installed -> allThemes.stream()
|
||||||
|
.filter(theme -> !installed.contains(theme.getMetadata().getName()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Mono<ServerResponse> reloadSetting(ServerRequest request) {
|
Mono<ServerResponse> reloadSetting(ServerRequest request) {
|
||||||
String name = request.pathVariable("name");
|
String name = request.pathVariable("name");
|
||||||
return client.fetch(Theme.class, name)
|
return client.fetch(Theme.class, name)
|
||||||
|
@ -238,12 +300,29 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
static class ThemeUtils {
|
static class ThemeUtils {
|
||||||
private static final String THEME_TMP_PREFIX = "halo-theme-";
|
private static final String THEME_TMP_PREFIX = "halo-theme-";
|
||||||
private static final String[] themeManifests = {"theme.yaml", "theme.yml"};
|
public static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"};
|
||||||
|
|
||||||
private static final String[] THEME_CONFIG = {"config.yaml", "config.yml"};
|
private static final String[] THEME_CONFIG = {"config.yaml", "config.yml"};
|
||||||
|
|
||||||
private static final String[] THEME_SETTING = {"settings.yaml", "settings.yml"};
|
private static final String[] THEME_SETTING = {"settings.yaml", "settings.yml"};
|
||||||
|
|
||||||
|
static Flux<Theme> listAllThemesFromThemeDir(Path themesDir) {
|
||||||
|
return walkThemesFromPath(themesDir)
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.map(themePath -> loadUnstructured(themePath, THEME_MANIFESTS))
|
||||||
|
.map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured,
|
||||||
|
Theme.class))
|
||||||
|
.sort(Comparator.comparing(theme -> theme.getMetadata().getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Flux<Path> walkThemesFromPath(Path path) {
|
||||||
|
return Flux.using(() -> Files.walk(path, 2),
|
||||||
|
Flux::fromStream,
|
||||||
|
BaseStream::close
|
||||||
|
)
|
||||||
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
static List<Unstructured> loadThemeSetting(Path themePath) {
|
static List<Unstructured> loadThemeSetting(Path themePath) {
|
||||||
return loadUnstructured(themePath, THEME_SETTING);
|
return loadUnstructured(themePath, THEME_SETTING);
|
||||||
}
|
}
|
||||||
|
@ -329,7 +408,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private static Path resolveThemeManifest(Path tempDirectory) {
|
private static Path resolveThemeManifest(Path tempDirectory) {
|
||||||
for (String themeManifest : themeManifests) {
|
for (String themeManifest : THEME_MANIFESTS) {
|
||||||
Path path = tempDirectory.resolve(themeManifest);
|
Path path = tempDirectory.resolve(themeManifest);
|
||||||
if (Files.exists(path)) {
|
if (Files.exists(path)) {
|
||||||
return path;
|
return path;
|
||||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.extension;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -128,4 +129,21 @@ public class ListResult<T> implements Streamable<T> {
|
||||||
public static <T> ListResult<T> emptyResult() {
|
public static <T> ListResult<T> emptyResult() {
|
||||||
return new ListResult<>(List.of());
|
return new ListResult<>(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually paginate the List collection.
|
||||||
|
*/
|
||||||
|
public static <T> List<T> subList(List<T> list, int page, int size) {
|
||||||
|
if (page < 1) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
List<T> listSort = new ArrayList<>();
|
||||||
|
int total = list.size();
|
||||||
|
int pageStart = page == 1 ? 0 : (page - 1) * size;
|
||||||
|
int pageEnd = Math.min(total, page * size);
|
||||||
|
if (total > pageStart) {
|
||||||
|
listSort = list.subList(pageStart, pageEnd);
|
||||||
|
}
|
||||||
|
return listSort;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,10 @@ public class ThemePathPolicy {
|
||||||
public Path generate(Theme theme) {
|
public Path generate(Theme theme) {
|
||||||
Assert.notNull(theme, "The theme must not be null.");
|
Assert.notNull(theme, "The theme must not be null.");
|
||||||
String name = theme.getMetadata().getName();
|
String name = theme.getMetadata().getName();
|
||||||
return workDir.resolve(THEME_WORK_DIR).resolve(name);
|
return themesDir().resolve(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path themesDir() {
|
||||||
|
return workDir.resolve(ThemePathPolicy.THEME_WORK_DIR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ rules:
|
||||||
resources: [ "themes", "themes/reload-setting" ]
|
resources: [ "themes", "themes/reload-setting" ]
|
||||||
verbs: [ "*" ]
|
verbs: [ "*" ]
|
||||||
- nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ]
|
- nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ]
|
||||||
verbs: [ "post" ]
|
verbs: [ "create" ]
|
||||||
---
|
---
|
||||||
apiVersion: v1alpha1
|
apiVersion: v1alpha1
|
||||||
kind: "Role"
|
kind: "Role"
|
||||||
|
@ -36,5 +36,6 @@ rules:
|
||||||
resources: [ "themes" ]
|
resources: [ "themes" ]
|
||||||
verbs: [ "get", "list" ]
|
verbs: [ "get", "list" ]
|
||||||
- apiGroups: [ "api.console.halo.run" ]
|
- apiGroups: [ "api.console.halo.run" ]
|
||||||
resources: [ "singlepages" ]
|
resources: [ "themes" ]
|
||||||
verbs: [ "get", "list" ]
|
verbs: [ "get", "list" ]
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue