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.Paths;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Comparator;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.function.Predicate;
 | 
			
		||||
import java.util.stream.BaseStream;
 | 
			
		||||
import java.util.zip.ZipEntry;
 | 
			
		||||
import java.util.zip.ZipInputStream;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +30,7 @@ import org.springframework.core.io.Resource;
 | 
			
		|||
import org.springframework.http.MediaType;
 | 
			
		||||
import org.springframework.http.codec.multipart.FilePart;
 | 
			
		||||
import org.springframework.http.codec.multipart.Part;
 | 
			
		||||
import org.springframework.lang.NonNull;
 | 
			
		||||
import org.springframework.lang.Nullable;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
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.Theme;
 | 
			
		||||
import run.halo.app.extension.ConfigMap;
 | 
			
		||||
import run.halo.app.extension.ListResult;
 | 
			
		||||
import run.halo.app.extension.ReactiveExtensionClient;
 | 
			
		||||
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.properties.HaloProperties;
 | 
			
		||||
import run.halo.app.infra.utils.DataBufferUtils;
 | 
			
		||||
| 
						 | 
				
			
			@ -106,9 +112,65 @@ public class ThemeEndpoint implements CustomEndpoint {
 | 
			
		|||
                    .response(responseBuilder()
 | 
			
		||||
                        .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();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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) {
 | 
			
		||||
        String name = request.pathVariable("name");
 | 
			
		||||
        return client.fetch(Theme.class, name)
 | 
			
		||||
| 
						 | 
				
			
			@ -238,12 +300,29 @@ public class ThemeEndpoint implements CustomEndpoint {
 | 
			
		|||
 | 
			
		||||
    static class ThemeUtils {
 | 
			
		||||
        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_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) {
 | 
			
		||||
            return loadUnstructured(themePath, THEME_SETTING);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -329,7 +408,7 @@ public class ThemeEndpoint implements CustomEndpoint {
 | 
			
		|||
 | 
			
		||||
        @Nullable
 | 
			
		||||
        private static Path resolveThemeManifest(Path tempDirectory) {
 | 
			
		||||
            for (String themeManifest : themeManifests) {
 | 
			
		||||
            for (String themeManifest : THEME_MANIFESTS) {
 | 
			
		||||
                Path path = tempDirectory.resolve(themeManifest);
 | 
			
		||||
                if (Files.exists(path)) {
 | 
			
		||||
                    return path;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ package run.halo.app.extension;
 | 
			
		|||
import com.fasterxml.jackson.annotation.JsonIgnore;
 | 
			
		||||
import com.fasterxml.jackson.annotation.JsonProperty;
 | 
			
		||||
import io.swagger.v3.oas.annotations.media.Schema;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.Iterator;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
| 
						 | 
				
			
			@ -128,4 +129,21 @@ public class ListResult<T> implements Streamable<T> {
 | 
			
		|||
    public static <T> ListResult<T> emptyResult() {
 | 
			
		||||
        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) {
 | 
			
		||||
        Assert.notNull(theme, "The theme must not be null.");
 | 
			
		||||
        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" ]
 | 
			
		||||
    verbs: [ "*" ]
 | 
			
		||||
  - nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ]
 | 
			
		||||
    verbs: [ "post" ]
 | 
			
		||||
    verbs: [ "create" ]
 | 
			
		||||
---
 | 
			
		||||
apiVersion: v1alpha1
 | 
			
		||||
kind: "Role"
 | 
			
		||||
| 
						 | 
				
			
			@ -36,5 +36,6 @@ rules:
 | 
			
		|||
    resources: [ "themes" ]
 | 
			
		||||
    verbs: [ "get", "list" ]
 | 
			
		||||
  - apiGroups: [ "api.console.halo.run" ]
 | 
			
		||||
    resources: [ "singlepages" ]
 | 
			
		||||
    resources: [ "themes" ]
 | 
			
		||||
    verbs: [ "get", "list" ]
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue