Support managing posts in the user center (#4866)

* Support managing posts in user center

Signed-off-by: John Niang <johnniang@foxmail.com>

* Adapt post management in user center

Signed-off-by: Ryan Wang <i@ryanc.cc>

---------

Signed-off-by: John Niang <johnniang@foxmail.com>
Signed-off-by: Ryan Wang <i@ryanc.cc>
Co-authored-by: Ryan Wang <i@ryanc.cc>
pull/4942/head^2
John Niang 2023-11-30 11:55:29 +08:00 committed by GitHub
parent f659a3279e
commit b2b096c544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 5247 additions and 159 deletions

View File

@ -16,7 +16,7 @@ import reactor.core.publisher.Mono;
* @param content is binary data of the attachment file.
* @param mediaType is media type of the attachment file.
*/
record SimpleFilePart(
public record SimpleFilePart(
String filename,
Flux<DataBuffer> content,
MediaType mediaType

View File

@ -9,6 +9,7 @@ import java.util.Set;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@ -27,6 +28,8 @@ import run.halo.app.extension.Ref;
public class Snapshot extends AbstractExtension {
public static final String KIND = "Snapshot";
public static final String KEEP_RAW_ANNO = "content.halo.run/keep-raw";
public static final String PATCHED_CONTENT_ANNO = "content.halo.run/patched-content";
public static final String PATCHED_RAW_ANNO = "content.halo.run/patched-raw";
@Schema(requiredMode = REQUIRED)
private SnapShotSpec spec;
@ -67,4 +70,18 @@ public class Snapshot extends AbstractExtension {
contributors.add(name);
}
/**
* Check if the given snapshot is a base snapshot.
*
* @param snapshot must not be null.
* @return true if the given snapshot is a base snapshot; false otherwise.
*/
public static boolean isBaseSnapshot(@NonNull Snapshot snapshot) {
var annotations = snapshot.getMetadata().getAnnotations();
if (annotations == null) {
return false;
}
return Boolean.parseBoolean(annotations.get(Snapshot.KEEP_RAW_ANNO));
}
}

View File

@ -2,8 +2,10 @@ package run.halo.app.core.extension.service;
import java.net.URI;
import java.time.Duration;
import java.util.function.Consumer;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
@ -18,6 +20,25 @@ import run.halo.app.core.extension.attachment.Attachment;
*/
public interface AttachmentService {
/**
* Uploads the given attachment to specific storage using handlers in plugins.
* <p>
* If no handler can be found to upload the given attachment, ServerError exception will be
* thrown.
*
* @param policyName is attachment policy name.
* @param groupName is group name the attachment belongs.
* @param filePart contains filename, content and media type.
* @param beforeCreating is an attachment modifier before creating.
* @return attachment.
*/
Mono<Attachment> upload(
@NonNull String username,
@NonNull String policyName,
@Nullable String groupName,
@NonNull FilePart filePart,
@Nullable Consumer<Attachment> beforeCreating);
/**
* Uploads the given attachment to specific storage using handlers in plugins. Please note
* that we will make sure the request is authenticated, or an unauthorized exception throws.

View File

@ -5,6 +5,7 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Objects;
import lombok.Data;
import org.springframework.lang.NonNull;
@Data
@Schema(description = "Extension reference object. The name is mandatory")
@ -59,4 +60,18 @@ public class Ref {
return Objects.equals(ref.getGroup(), gvk.group())
&& Objects.equals(ref.getKind(), gvk.kind());
}
/**
* Check if the extension is equal to the ref.
*
* @param ref must not be null.
* @param extension must not be null.
* @return true if they are equal; false otherwise.
*/
public static boolean equals(@NonNull Ref ref, @NonNull ExtensionOperator extension) {
var gvk = extension.groupVersionKind();
var name = extension.getMetadata().getName();
return groupKindEquals(ref, gvk) && Objects.equals(ref.getName(), name);
}
}

View File

@ -80,6 +80,9 @@ public class SystemSetting {
Integer tagPageSize;
Boolean review;
String slugGenerationStrategy;
String attachmentPolicyName;
String attachmentGroupName;
}
@Data

View File

@ -75,6 +75,15 @@ public class SwaggerConfig {
.build();
}
@Bean
GroupedOpenApi userCenterApi() {
return GroupedOpenApi.builder()
.group("uc.api")
.displayName("User center APIs.")
.pathsToMatch("/apis/uc.api.*/**")
.build();
}
@Bean
GroupedOpenApi allApi() {
return GroupedOpenApi.builder()

View File

@ -51,9 +51,7 @@ public abstract class AbstractContentService {
protected void checkBaseSnapshot(Snapshot snapshot) {
Assert.notNull(snapshot, "The snapshot must not be null.");
String keepRawAnno =
MetadataUtil.nullSafeAnnotations(snapshot).get(Snapshot.KEEP_RAW_ANNO);
if (!StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) {
if (!Snapshot.isBaseSnapshot(snapshot)) {
throw new IllegalArgumentException(
String.format("The snapshot [%s] is not a base snapshot.",
snapshot.getMetadata().getName()));
@ -68,7 +66,7 @@ public abstract class AbstractContentService {
snapshot.getSpec().setParentSnapshotName(parentSnapshotName);
final String baseSnapshotNameToUse =
StringUtils.defaultString(baseSnapshotName, snapshot.getMetadata().getName());
StringUtils.defaultIfBlank(baseSnapshotName, snapshot.getMetadata().getName());
return client.fetch(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)
.defaultIfEmpty(snapshot)
@ -119,7 +117,8 @@ public abstract class AbstractContentService {
.map(baseSnapshot -> ContentWrapper.patchSnapshot(headSnapshot, baseSnapshot));
}
protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot,
protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse,
Snapshot baseSnapshot,
ContentRequest contentRequest) {
Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
Assert.notNull(contentRequest, "The contentRequest must not be null.");
@ -130,7 +129,7 @@ public abstract class AbstractContentService {
snapshotToUse.getSpec().setLastModifyTime(Instant.now());
// it is the v1 snapshot, set the content directly
if (org.thymeleaf.util.StringUtils.equals(baseSnapshotName,
if (StringUtils.equals(baseSnapshotName,
snapshotToUse.getMetadata().getName())) {
snapshotToUse.getSpec().setRawPatch(contentRequest.raw());
snapshotToUse.getSpec().setContentPatch(contentRequest.content());

View File

@ -0,0 +1,4 @@
package run.halo.app.content;
public record Content(String raw, String content, String rawType) {
}

View File

@ -3,17 +3,20 @@ package run.halo.app.content;
import static java.util.Comparator.comparing;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import run.halo.app.core.extension.content.Post;
@ -31,9 +34,22 @@ public class PostQuery extends IListRequest.QueryListRequest {
private final ServerWebExchange exchange;
private final String username;
public PostQuery(ServerRequest request) {
this(request, null);
}
public PostQuery(ServerRequest request, @Nullable String username) {
super(request.queryParams());
this.exchange = request.exchange();
this.username = username;
}
@Schema(hidden = true)
@JsonIgnore
public String getUsername() {
return username;
}
@Nullable
@ -131,14 +147,27 @@ public class PostQuery extends IListRequest.QueryListRequest {
* @return a predicate
*/
public Predicate<Post> toPredicate() {
Predicate<Post> paramPredicate = post ->
contains(getCategories(), post.getSpec().getCategories())
&& contains(getTags(), post.getSpec().getTags())
&& contains(getContributors(), post.getStatusOrDefault().getContributors());
Predicate<Post> predicate = labelAndFieldSelectorToPredicate(getLabelSelector(),
getFieldSelector());
if (!CollectionUtils.isEmpty(getCategories())) {
predicate =
predicate.and(post -> contains(getCategories(), post.getSpec().getCategories()));
}
if (!CollectionUtils.isEmpty(getTags())) {
predicate = predicate.and(post -> contains(getTags(), post.getSpec().getTags()));
}
if (!CollectionUtils.isEmpty(getContributors())) {
Predicate<Post> hasStatus = post -> post.getStatus() != null;
var containsContributors = hasStatus.and(
post -> contains(getContributors(), post.getStatus().getContributors())
);
predicate = predicate.and(containsContributors);
}
String keyword = getKeyword();
if (keyword != null) {
paramPredicate = paramPredicate.and(post -> {
predicate = predicate.and(post -> {
String excerpt = post.getStatusOrDefault().getExcerpt();
return StringUtils.containsIgnoreCase(excerpt, keyword)
|| StringUtils.containsIgnoreCase(post.getSpec().getSlug(), keyword)
@ -148,7 +177,7 @@ public class PostQuery extends IListRequest.QueryListRequest {
Post.PostPhase publishPhase = getPublishPhase();
if (publishPhase != null) {
paramPredicate = paramPredicate.and(post -> {
predicate = predicate.and(post -> {
if (Post.PostPhase.PENDING_APPROVAL.equals(publishPhase)) {
return !post.isPublished()
&& Post.PostPhase.PENDING_APPROVAL.name()
@ -165,13 +194,15 @@ public class PostQuery extends IListRequest.QueryListRequest {
Post.VisibleEnum visible = getVisible();
if (visible != null) {
paramPredicate =
paramPredicate.and(post -> visible.equals(post.getSpec().getVisible()));
predicate =
predicate.and(post -> visible.equals(post.getSpec().getVisible()));
}
Predicate<Post> predicate = labelAndFieldSelectorToPredicate(getLabelSelector(),
getFieldSelector());
return predicate.and(paramPredicate);
if (StringUtils.isNotBlank(username)) {
Predicate<Post> isOwner = post -> Objects.equals(username, post.getSpec().getOwner());
predicate = predicate.and(isOwner);
}
return predicate;
}
boolean contains(Collection<String> left, List<String> right) {

View File

@ -3,22 +3,23 @@ package run.halo.app.content;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.lang.NonNull;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.Ref;
/**
* Post and content data for creating and updating post.
*
* @author guqing
* @since 2.0.0
*/
public record PostRequest(@Schema(requiredMode = REQUIRED) Post post,
@Schema(requiredMode = REQUIRED) Content content) {
public record PostRequest(@Schema(requiredMode = REQUIRED) @NonNull Post post,
Content content) {
public ContentRequest contentRequest() {
Ref subjectRef = Ref.of(post);
return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw,
content.content, content.rawType);
return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw(),
content.content(), content.rawType());
}
public record Content(String raw, String content, String rawType) {
}
}

View File

@ -1,5 +1,6 @@
package run.halo.app.content;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
@ -18,9 +19,28 @@ public interface PostService {
Mono<Post> updatePost(PostRequest postRequest);
Mono<Post> updateBy(@NonNull Post post);
Mono<ContentWrapper> getHeadContent(String postName);
Mono<ContentWrapper> getHeadContent(Post post);
Mono<ContentWrapper> getReleaseContent(String postName);
Mono<ContentWrapper> getReleaseContent(Post post);
Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName);
Mono<Post> publish(Post post);
Mono<Post> unpublish(Post post);
/**
* Get post by username.
*
* @param postName is post name.
* @param username is username.
* @return full post data or empty.
*/
Mono<Post> getByUsername(String postName, String username);
}

View File

@ -17,10 +17,8 @@ public record SinglePageRequest(@Schema(requiredMode = REQUIRED) SinglePage page
public ContentRequest contentRequest() {
Ref subjectRef = Ref.of(page);
return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw,
content.content, content.rawType);
return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw(),
content.content(), content.rawType());
}
public record Content(String raw, String content, String rawType) {
}
}

View File

@ -0,0 +1,22 @@
package run.halo.app.content;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Snapshot;
public interface SnapshotService {
Mono<Snapshot> getBy(String snapshotName);
Mono<Snapshot> getPatchedBy(String snapshotName, String baseSnapshotName);
Mono<Snapshot> patchAndCreate(@NonNull Snapshot snapshot,
@Nullable Snapshot baseSnapshot,
@NonNull Content content);
Mono<Snapshot> patchAndUpdate(@NonNull Snapshot snapshot,
@NonNull Snapshot baseSnapshot,
@NonNull Content content);
}

View File

@ -8,6 +8,7 @@ import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
@ -185,6 +186,9 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
)
.flatMap(client::create)
.flatMap(post -> {
if (postRequest.content() == null) {
return Mono.just(post);
}
var contentRequest =
new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(),
postRequest.content().raw(), postRequest.content().content(),
@ -248,21 +252,59 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
.filter(throwable -> throwable instanceof OptimisticLockingFailureException));
}
@Override
public Mono<Post> updateBy(@NonNull Post post) {
return client.update(post);
}
@Override
public Mono<ContentWrapper> getHeadContent(String postName) {
return client.get(Post.class, postName)
.flatMap(post -> {
String headSnapshot = post.getSpec().getHeadSnapshot();
.flatMap(this::getHeadContent);
}
@Override
public Mono<ContentWrapper> getHeadContent(Post post) {
var headSnapshot = post.getSpec().getHeadSnapshot();
return getContent(headSnapshot, post.getSpec().getBaseSnapshot());
});
}
@Override
public Mono<ContentWrapper> getReleaseContent(String postName) {
return client.get(Post.class, postName)
.flatMap(post -> {
String releaseSnapshot = post.getSpec().getReleaseSnapshot();
.flatMap(this::getReleaseContent);
}
@Override
public Mono<ContentWrapper> getReleaseContent(Post post) {
var releaseSnapshot = post.getSpec().getReleaseSnapshot();
return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot());
});
}
@Override
public Mono<Post> publish(Post post) {
return Mono.just(post)
.doOnNext(p -> {
var spec = post.getSpec();
spec.setPublish(true);
if (spec.getHeadSnapshot() == null) {
spec.setHeadSnapshot(spec.getBaseSnapshot());
}
spec.setReleaseSnapshot(spec.getHeadSnapshot());
}).flatMap(client::update);
}
@Override
public Mono<Post> unpublish(Post post) {
return Mono.just(post)
.doOnNext(p -> p.getSpec().setPublish(false))
.flatMap(client::update);
}
@Override
public Mono<Post> getByUsername(String postName, String username) {
return client.get(Post.class, postName)
.filter(post -> post.getSpec() != null)
.filter(post -> Objects.equals(username, post.getSpec().getOwner()));
}
}

View File

@ -0,0 +1,125 @@
package run.halo.app.content.impl;
import java.time.Clock;
import java.util.HashMap;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import run.halo.app.content.Content;
import run.halo.app.content.PatchUtils;
import run.halo.app.content.SnapshotService;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ReactiveExtensionClient;
@Service
public class SnapshotServiceImpl implements SnapshotService {
private final ReactiveExtensionClient client;
private Clock clock;
public SnapshotServiceImpl(ReactiveExtensionClient client) {
this.client = client;
this.clock = Clock.systemDefaultZone();
}
@Override
public Mono<Snapshot> getBy(String snapshotName) {
return client.get(Snapshot.class, snapshotName);
}
@Override
public Mono<Snapshot> getPatchedBy(String snapshotName, String baseSnapshotName) {
if (StringUtils.isBlank(snapshotName) || StringUtils.isBlank(baseSnapshotName)) {
return Mono.empty();
}
return client.fetch(Snapshot.class, baseSnapshotName)
.filter(Snapshot::isBaseSnapshot)
.switchIfEmpty(Mono.error(() -> new IllegalArgumentException(
"The snapshot " + baseSnapshotName + " is not a base snapshot.")))
.flatMap(baseSnapshot ->
Mono.defer(() -> {
if (Objects.equals(snapshotName, baseSnapshotName)) {
return Mono.just(baseSnapshot);
}
return client.fetch(Snapshot.class, snapshotName);
}).doOnNext(snapshot -> {
var baseRaw = baseSnapshot.getSpec().getRawPatch();
var baseContent = baseSnapshot.getSpec().getContentPatch();
var rawPatch = snapshot.getSpec().getRawPatch();
var contentPatch = snapshot.getSpec().getContentPatch();
var annotations = snapshot.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
snapshot.getMetadata().setAnnotations(annotations);
}
String patchedContent = baseContent;
String patchedRaw = baseRaw;
if (!Objects.equals(snapshot, baseSnapshot)) {
patchedContent = PatchUtils.applyPatch(baseContent, contentPatch);
patchedRaw = PatchUtils.applyPatch(baseRaw, rawPatch);
}
annotations.put(Snapshot.PATCHED_CONTENT_ANNO, patchedContent);
annotations.put(Snapshot.PATCHED_RAW_ANNO, patchedRaw);
})
);
}
@Override
public Mono<Snapshot> patchAndCreate(@NonNull Snapshot snapshot,
@Nullable Snapshot baseSnapshot,
@NonNull Content content) {
return Mono.just(snapshot)
.doOnNext(s -> this.patch(s, baseSnapshot, content))
.flatMap(client::create);
}
@Override
public Mono<Snapshot> patchAndUpdate(@NonNull Snapshot snapshot,
@NonNull Snapshot baseSnapshot,
@NonNull Content content) {
return Mono.just(snapshot)
.doOnNext(s -> this.patch(s, baseSnapshot, content))
.flatMap(client::update);
}
private void patch(@NonNull Snapshot snapshot,
@Nullable Snapshot baseSnapshot,
@NonNull Content content) {
var annotations = snapshot.getMetadata().getAnnotations();
if (annotations != null) {
annotations.remove(Snapshot.PATCHED_CONTENT_ANNO);
annotations.remove(Snapshot.PATCHED_RAW_ANNO);
}
var spec = snapshot.getSpec();
if (spec == null) {
spec = new Snapshot.SnapShotSpec();
}
spec.setRawType(content.rawType());
if (baseSnapshot == null || Objects.equals(snapshot, baseSnapshot)) {
// indicate the snapshot is a base snapshot
// update raw and content directly
spec.setRawPatch(content.raw());
spec.setContentPatch(content.content());
} else {
// apply the patch and set the raw and content
var baseSpec = baseSnapshot.getSpec();
var baseContent = baseSpec.getContentPatch();
var baseRaw = baseSpec.getRawPatch();
var rawPatch = PatchUtils.diffToJsonPatch(baseRaw, content.raw());
var contentPatch = PatchUtils.diffToJsonPatch(baseContent, content.content());
spec.setRawPatch(rawPatch);
spec.setContentPatch(contentPatch);
}
spec.setLastModifyTime(clock.instant());
}
}

View File

@ -23,6 +23,7 @@ import org.springframework.web.server.ServerErrorException;
import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.Content;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.ListedPost;
import run.halo.app.content.PostQuery;
@ -132,7 +133,7 @@ public class PostEndpoint implements CustomEndpoint {
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(PostRequest.Content.class))
.implementation(Content.class))
))
.response(responseBuilder()
.implementation(Post.class))
@ -191,7 +192,7 @@ public class PostEndpoint implements CustomEndpoint {
Mono<ServerResponse> updateContent(ServerRequest request) {
String postName = request.pathVariable("name");
return request.bodyToMono(PostRequest.Content.class)
return request.bodyToMono(Content.class)
.flatMap(content -> client.fetch(Post.class, postName)
.flatMap(post -> {
PostRequest postRequest = new PostRequest(post, content);

View File

@ -21,6 +21,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.thymeleaf.util.StringUtils;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.Content;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.ListedSinglePage;
import run.halo.app.content.SinglePageQuery;
@ -130,7 +131,7 @@ public class SinglePageEndpoint implements CustomEndpoint {
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(SinglePageRequest.Content.class))
.implementation(Content.class))
))
.response(responseBuilder()
.implementation(Post.class))
@ -169,7 +170,7 @@ public class SinglePageEndpoint implements CustomEndpoint {
Mono<ServerResponse> updateContent(ServerRequest request) {
String pageName = request.pathVariable("name");
return request.bodyToMono(SinglePageRequest.Content.class)
return request.bodyToMono(Content.class)
.flatMap(content -> client.fetch(SinglePage.class, pageName)
.flatMap(page -> {
SinglePageRequest pageRequest = new SinglePageRequest(page, content);

View File

@ -66,11 +66,9 @@ public class DefaultRoleService implements RoleService {
// search all permissions
return extensionClient.list(Role.class,
shouldFilterHidden(true),
compareCreationTimestamp(true))
.filter(DefaultRoleService::isRoleTemplate);
compareCreationTimestamp(true));
}
return listDependencies(names, shouldFilterHidden(true))
.filter(DefaultRoleService::isRoleTemplate);
return listDependencies(names, shouldFilterHidden(true));
}
@Override

View File

@ -2,10 +2,12 @@ package run.halo.app.core.extension.service.impl;
import java.net.URI;
import java.time.Duration;
import java.util.function.Consumer;
import java.util.function.Function;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
@ -22,6 +24,7 @@ import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
import run.halo.app.core.extension.attachment.endpoint.DeleteOption;
import run.halo.app.core.extension.attachment.endpoint.SimpleFilePart;
import run.halo.app.core.extension.attachment.endpoint.UploadOption;
import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.extension.ConfigMap;
@ -42,12 +45,13 @@ public class DefaultAttachmentService implements AttachmentService {
}
@Override
public Mono<Attachment> upload(@NonNull String policyName,
public Mono<Attachment> upload(
@NonNull String username,
@NonNull String policyName,
@Nullable String groupName,
@NonNull String filename,
@NonNull Flux<DataBuffer> content,
@Nullable MediaType mediaType) {
return authenticationConsumer(authentication -> client.get(Policy.class, policyName)
@NonNull FilePart filePart,
@Nullable Consumer<Attachment> beforeCreating) {
return client.get(Policy.class, policyName)
.flatMap(policy -> {
var configMapName = policy.getSpec().getConfigMapName();
if (!StringUtils.hasText(configMapName)) {
@ -55,11 +59,7 @@ public class DefaultAttachmentService implements AttachmentService {
"ConfigMap name not found in Policy " + policyName));
}
return client.get(ConfigMap.class, configMapName)
.map(configMap -> UploadOption.from(filename,
content,
mediaType,
policy,
configMap));
.map(configMap -> new UploadOption(filePart, policy, configMap));
})
.flatMap(uploadContext -> {
var handlers = extensionComponentsFinder.getExtensions(AttachmentHandler.class);
@ -75,13 +75,29 @@ public class DefaultAttachmentService implements AttachmentService {
spec = new Attachment.AttachmentSpec();
attachment.setSpec(spec);
}
spec.setOwnerName(authentication.getName());
spec.setOwnerName(username);
if (StringUtils.hasText(groupName)) {
spec.setGroupName(groupName);
}
spec.setPolicyName(policyName);
})
.flatMap(client::create));
.doOnNext(attachment -> {
if (beforeCreating != null) {
beforeCreating.accept(attachment);
}
})
.flatMap(client::create);
}
@Override
public Mono<Attachment> upload(@NonNull String policyName,
@Nullable String groupName,
@NonNull String filename,
@NonNull Flux<DataBuffer> content,
@Nullable MediaType mediaType) {
var file = new SimpleFilePart(filename, content, mediaType);
return authenticationConsumer(
authentication -> upload(authentication.getName(), policyName, groupName, file, null));
}
@Override

View File

@ -0,0 +1,208 @@
package run.halo.app.endpoint.uc.content;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.HashMap;
import java.util.function.Consumer;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.exception.NotFoundException;
@Component
public class UcPostAttachmentEndpoint implements CustomEndpoint {
public static final String POST_NAME_LABEL = "content.halo.run/post-name";
public static final String SINGLE_PAGE_NAME_LABEL = "content.halo.run/single-page-name";
private final AttachmentService attachmentService;
private final PostService postService;
private final SystemConfigurableEnvironmentFetcher systemSettingFetcher;
public UcPostAttachmentEndpoint(AttachmentService attachmentService, PostService postService,
SystemConfigurableEnvironmentFetcher systemSettingFetcher) {
this.attachmentService = attachmentService;
this.postService = postService;
this.systemSettingFetcher = systemSettingFetcher;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = groupVersion() + "/Attachment";
return route()
.POST("/attachments",
this::createAttachmentForPost,
builder -> builder.operationId("CreateAttachmentForPost").tag(tag)
.description("Create attachment for the given post.")
.parameter(parameterBuilder()
.name("waitForPermalink")
.description("Wait for permalink.")
.in(ParameterIn.QUERY)
.required(false)
.implementation(boolean.class))
.requestBody(requestBodyBuilder()
.content(contentBuilder()
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
.schema(schemaBuilder().implementation(PostAttachmentRequest.class)))
)
.response(responseBuilder().implementation(Attachment.class))
)
.build();
}
private Mono<ServerResponse> createAttachmentForPost(ServerRequest request) {
var postAttachmentRequestMono = request.body(BodyExtractors.toMultipartData())
.map(PostAttachmentRequest::from)
.cache();
var postSettingMono = systemSettingFetcher.fetchPost()
.<SystemSetting.Post>handle((postSetting, sink) -> {
var attachmentPolicyName = postSetting.getAttachmentPolicyName();
if (StringUtils.isBlank(attachmentPolicyName)) {
sink.error(new ServerWebInputException(
"Please configure storage policy for post attachment first."));
return;
}
sink.next(postSetting);
});
// get settings
var createdAttachment = postSettingMono.flatMap(postSetting -> postAttachmentRequestMono
.flatMap(postAttachmentRequest -> getCurrentUser().flatMap(
username -> attachmentService.upload(username,
postSetting.getAttachmentPolicyName(),
postSetting.getAttachmentGroupName(),
postAttachmentRequest.file(),
linkWith(postAttachmentRequest)))));
var waitForPermalink = request.queryParam("waitForPermalink")
.map(Boolean::valueOf)
.orElse(false);
if (waitForPermalink) {
createdAttachment = createdAttachment.flatMap(attachment ->
attachmentService.getPermalink(attachment)
.doOnNext(permalink -> {
var status = attachment.getStatus();
if (status == null) {
status = new Attachment.AttachmentStatus();
attachment.setStatus(status);
}
status.setPermalink(permalink.toString());
})
.thenReturn(attachment));
}
return ServerResponse.ok().body(createdAttachment, Attachment.class);
}
private Consumer<Attachment> linkWith(PostAttachmentRequest request) {
return attachment -> {
var labels = attachment.getMetadata().getLabels();
if (labels == null) {
labels = new HashMap<>();
attachment.getMetadata().setLabels(labels);
}
if (StringUtils.isNotBlank(request.postName())) {
labels.put(POST_NAME_LABEL, request.postName());
}
if (StringUtils.isNotBlank(request.singlePageName())) {
labels.put(SINGLE_PAGE_NAME_LABEL, request.singlePageName());
}
};
}
private Mono<Void> checkPostOwnership(Mono<PostAttachmentRequest> postAttachmentRequest) {
// check the post
var postNotFoundError = Mono.<Post>error(
() -> new NotFoundException("The post was not found or deleted.")
);
return postAttachmentRequest.map(PostAttachmentRequest::postName)
.flatMap(postName -> getCurrentUser()
.flatMap(username -> postService.getByUsername(postName, username)
.switchIfEmpty(postNotFoundError)))
.then();
}
private Mono<String> getCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName);
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1");
}
@Schema(types = "object")
public record PostAttachmentRequest(
@Schema(requiredMode = REQUIRED, description = "Attachment data.")
FilePart file,
@Schema(requiredMode = NOT_REQUIRED, description = "Post name.")
String postName,
@Schema(requiredMode = NOT_REQUIRED, description = "Single page name.")
String singlePageName
) {
/**
* Convert multipart data into PostAttachmentRequest.
*
* @param multipartData is multipart data from request.
* @return post attachment request data.
*/
public static PostAttachmentRequest from(MultiValueMap<String, Part> multipartData) {
var part = multipartData.getFirst("postName");
String postName = null;
if (part instanceof FormFieldPart formFieldPart) {
postName = formFieldPart.value();
}
part = multipartData.getFirst("singlePageName");
String singlePageName = null;
if (part instanceof FormFieldPart formFieldPart) {
singlePageName = formFieldPart.value();
}
part = multipartData.getFirst("file");
if (!(part instanceof FilePart file)) {
throw new ServerWebInputException("Invalid type of parameter 'file'.");
}
return new PostAttachmentRequest(file, postName, singlePageName);
}
}
}

View File

@ -0,0 +1,333 @@
package run.halo.app.endpoint.uc.content;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.content.Content;
import run.halo.app.content.ListedPost;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.content.SnapshotService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Ref;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.JsonUtils;
@Component
public class UcPostEndpoint implements CustomEndpoint {
private static final String CONTENT_JSON_ANNO = "content.halo.run/content-json";
private final PostService postService;
private final SnapshotService snapshotService;
public UcPostEndpoint(PostService postService, SnapshotService snapshotService) {
this.postService = postService;
this.snapshotService = snapshotService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = groupVersion() + "/Post";
var namePathParam = parameterBuilder().name("name")
.description("Post name")
.in(ParameterIn.PATH)
.required(true);
return route().nest(
path("/posts"),
() -> route()
.GET(this::listMyPost, builder -> {
builder.operationId("ListMyPosts")
.description("List posts owned by the current user.")
.tag(tag)
.response(responseBuilder().implementation(
ListResult.generateGenericClass(ListedPost.class)));
buildParametersFromType(builder, PostQuery.class);
}
)
.POST(this::createMyPost, builder -> builder.operationId("CreateMyPost")
.tag(tag)
.description("""
Create my post. If you want to create a post with content, please set
annotation: "content.halo.run/content-json" into annotations and refer
to Content for corresponding data type.
""")
.requestBody(requestBodyBuilder().implementation(Post.class))
.response(responseBuilder().implementation(Post.class))
)
.GET("/{name}", this::getMyPost, builder -> builder.operationId("GetMyPost")
.tag(tag)
.parameter(namePathParam)
.description("Get post that belongs to the current user.")
.response(responseBuilder().implementation(Post.class))
)
.PUT("/{name}", this::updateMyPost, builder ->
builder.operationId("UpdateMyPost")
.tag(tag)
.parameter(namePathParam)
.description("Update my post.")
.requestBody(requestBodyBuilder().implementation(Post.class))
.response(responseBuilder().implementation(Post.class))
)
.GET("/{name}/draft", this::getMyPostDraft, builder -> builder.tag(tag)
.operationId("GetMyPostDraft")
.description("Get my post draft.")
.parameter(namePathParam)
.parameter(parameterBuilder()
.name("patched")
.in(ParameterIn.QUERY)
.required(false)
.implementation(Boolean.class)
.description("Should include patched content and raw or not.")
)
.response(responseBuilder().implementation(Snapshot.class))
)
.PUT("/{name}/draft", this::updateMyPostDraft, builder -> builder.tag(tag)
.operationId("UpdateMyPostDraft")
.description("""
Update draft of my post. Please make sure set annotation:
"content.halo.run/content-json" into annotations and refer to
Content for corresponding data type.
""")
.parameter(namePathParam)
.requestBody(requestBodyBuilder().implementation(Snapshot.class))
.response(responseBuilder().implementation(Snapshot.class)))
.PUT("/{name}/publish", this::publishMyPost, builder -> builder.tag(tag)
.operationId("PublishMyPost")
.description("Publish my post.")
.parameter(namePathParam)
.response(responseBuilder().implementation(Post.class)))
.PUT("/{name}/unpublish", this::unpublishMyPost, builder -> builder.tag(tag)
.operationId("UnpublishMyPost")
.description("Unpublish my post.")
.parameter(namePathParam)
.response(responseBuilder().implementation(Post.class)))
.build(),
builder -> {
})
.build();
}
private Mono<ServerResponse> getMyPostDraft(ServerRequest request) {
var name = request.pathVariable("name");
var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false);
var draft = getMyPost(name)
.flatMap(post -> {
var headSnapshotName = post.getSpec().getHeadSnapshot();
var baseSnapshotName = post.getSpec().getBaseSnapshot();
if (StringUtils.isBlank(headSnapshotName)) {
headSnapshotName = baseSnapshotName;
}
if (patched) {
return snapshotService.getPatchedBy(headSnapshotName, baseSnapshotName);
}
return snapshotService.getBy(headSnapshotName);
});
return ServerResponse.ok().body(draft, Snapshot.class);
}
private Mono<ServerResponse> unpublishMyPost(ServerRequest request) {
var name = request.pathVariable("name");
var postMono = getCurrentUser()
.flatMap(username -> postService.getByUsername(name, username));
var unpublishedPost = postMono.flatMap(postService::unpublish);
return ServerResponse.ok().body(unpublishedPost, Post.class);
}
private Mono<ServerResponse> publishMyPost(ServerRequest request) {
var name = request.pathVariable("name");
var postMono = getCurrentUser()
.flatMap(username -> postService.getByUsername(name, username));
var publishedPost = postMono.flatMap(postService::publish);
return ServerResponse.ok().body(publishedPost, Post.class);
}
private Mono<ServerResponse> updateMyPostDraft(ServerRequest request) {
var name = request.pathVariable("name");
var postMono = getMyPost(name).cache();
var snapshotMono = request.bodyToMono(Snapshot.class).cache();
var contentMono = snapshotMono
.map(Snapshot::getMetadata)
.filter(metadata -> {
var annotations = metadata.getAnnotations();
return annotations != null && annotations.containsKey(CONTENT_JSON_ANNO);
})
.map(metadata -> {
var contentJson = metadata.getAnnotations().remove(CONTENT_JSON_ANNO);
return JsonUtils.jsonToObject(contentJson, Content.class);
})
.cache();
// check the snapshot belongs to the post.
var checkSnapshot = postMono.flatMap(post -> snapshotMono.filter(
snapshot -> Ref.equals(snapshot.getSpec().getSubjectRef(), post)
).switchIfEmpty(Mono.error(() ->
new ServerWebInputException("The snapshot does not belong to the given post."))
).filter(snapshot -> {
var snapshotName = snapshot.getMetadata().getName();
var headSnapshotName = post.getSpec().getHeadSnapshot();
return Objects.equals(snapshotName, headSnapshotName);
}).switchIfEmpty(Mono.error(() ->
new ServerWebInputException("The snapshot was not the head snapshot of the post.")))
).then();
var setContributor = getCurrentUser().flatMap(username ->
snapshotMono.doOnNext(snapshot -> Snapshot.addContributor(snapshot, username)));
var getBaseSnapshot = postMono.map(post -> post.getSpec().getBaseSnapshot())
.flatMap(snapshotService::getBy);
var updatedSnapshot = getBaseSnapshot.flatMap(
baseSnapshot -> contentMono.flatMap(content -> postMono.flatMap(post -> {
var postName = post.getMetadata().getName();
var headSnapshotName = post.getSpec().getHeadSnapshot();
var releaseSnapshotName = post.getSpec().getReleaseSnapshot();
if (!Objects.equals(headSnapshotName, releaseSnapshotName)) {
// patch and update
return snapshotMono.flatMap(
s -> snapshotService.patchAndUpdate(s, baseSnapshot, content));
}
// patch and create
return getCurrentUser().map(
username -> {
var metadata = new Metadata();
metadata.setGenerateName(postName + "-snapshot-");
var spec = new Snapshot.SnapShotSpec();
spec.setParentSnapshotName(headSnapshotName);
spec.setOwner(username);
spec.setSubjectRef(Ref.of(post));
var snapshot = new Snapshot();
snapshot.setMetadata(metadata);
snapshot.setSpec(spec);
Snapshot.addContributor(snapshot, username);
return snapshot;
})
.flatMap(s -> snapshotService.patchAndCreate(s, baseSnapshot, content))
.flatMap(createdSnapshot -> {
post.getSpec().setHeadSnapshot(createdSnapshot.getMetadata().getName());
return postService.updateBy(post).thenReturn(createdSnapshot);
});
})));
return ServerResponse.ok()
.body(checkSnapshot.and(setContributor).then(updatedSnapshot), Snapshot.class);
}
private Mono<ServerResponse> updateMyPost(ServerRequest request) {
var name = request.pathVariable("name");
var postBody = request.bodyToMono(Post.class)
.doOnNext(post -> {
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
// we don't support updating content while updating post.
annotations.remove(CONTENT_JSON_ANNO);
}
})
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required.")));
var updatedPost = getMyPost(name).flatMap(oldPost ->
postBody.doOnNext(post -> {
var oldSpec = oldPost.getSpec();
// restrict fields of post.spec.
var spec = post.getSpec();
spec.setOwner(oldSpec.getOwner());
spec.setPublish(oldSpec.getPublish());
spec.setPublishTime(oldSpec.getPublishTime());
spec.setHeadSnapshot(oldSpec.getHeadSnapshot());
spec.setBaseSnapshot(oldSpec.getBaseSnapshot());
spec.setReleaseSnapshot(oldSpec.getReleaseSnapshot());
spec.setDeleted(oldSpec.getDeleted());
post.getMetadata().setName(oldPost.getMetadata().getName());
}))
.flatMap(postService::updateBy);
return ServerResponse.ok().body(updatedPost, Post.class);
}
private Mono<ServerResponse> createMyPost(ServerRequest request) {
var postFromRequest = request.bodyToMono(Post.class)
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required.")));
var createdPost = getCurrentUser()
.flatMap(username -> postFromRequest
.doOnNext(post -> {
if (post.getSpec() == null) {
post.setSpec(new Post.PostSpec());
}
post.getSpec().setOwner(username);
}))
.map(post -> new PostRequest(post, getContent(post)))
.flatMap(postService::draftPost);
return ServerResponse.ok().body(createdPost, Post.class);
}
private Content getContent(Post post) {
Content content = null;
var annotations = post.getMetadata().getAnnotations();
if (annotations != null && annotations.containsKey(CONTENT_JSON_ANNO)) {
var contentJson = annotations.remove(CONTENT_JSON_ANNO);
content = JsonUtils.jsonToObject(contentJson, Content.class);
}
return content;
}
private Mono<ServerResponse> listMyPost(ServerRequest request) {
var posts = getCurrentUser()
.map(username -> new PostQuery(request, username))
.flatMap(postService::listPost);
return ServerResponse.ok().body(posts, ListedPost.class);
}
private Mono<ServerResponse> getMyPost(ServerRequest request) {
var postName = request.pathVariable("name");
var post = getMyPost(postName);
return ServerResponse.ok().body(post, Post.class);
}
private Mono<Post> getMyPost(String postName) {
return getCurrentUser()
.flatMap(username -> postService.getByUsername(postName, username)
.switchIfEmpty(
Mono.error(() -> new NotFoundException("The post was not found or deleted"))
)
);
}
private Mono<String> getCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName);
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1");
}
}

View File

@ -0,0 +1,124 @@
package run.halo.app.endpoint.uc.content;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.content.PostService;
import run.halo.app.content.SnapshotService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.Ref;
import run.halo.app.infra.exception.NotFoundException;
@Component
public class UcSnapshotEndpoint implements CustomEndpoint {
private final PostService postService;
private final SnapshotService snapshotService;
public UcSnapshotEndpoint(PostService postService, SnapshotService snapshotService) {
this.postService = postService;
this.snapshotService = snapshotService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = groupVersion() + "/Snapshot";
return route().nest(path("/snapshots"),
() -> route()
.GET("/{name}",
this::getSnapshot,
builder -> builder.operationId("GetSnapshotForPost")
.description("Get snapshot for one post.")
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.description("Snapshot name.")
)
.parameter(parameterBuilder()
.name("postName")
.in(ParameterIn.QUERY)
.required(true)
.description("Post name.")
)
.parameter(parameterBuilder()
.name("patched")
.in(ParameterIn.QUERY)
.required(false)
.implementation(Boolean.class)
.description("Should include patched content and raw or not.")
)
.response(responseBuilder().implementation(Snapshot.class))
.tag(tag))
.build(),
builder -> {
})
.build();
}
private Mono<ServerResponse> getSnapshot(ServerRequest request) {
var snapshotName = request.pathVariable("name");
var postName = request.queryParam("postName")
.orElseThrow(() -> new ServerWebInputException("Query parameter postName is required"));
var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false);
var postNotFoundError = Mono.<Post>error(
() -> new NotFoundException("The post was not found or deleted.")
);
var snapshotNotFoundError = Mono.<Snapshot>error(
() -> new NotFoundException("The snapshot was not found or deleted.")
);
var postMono = getCurrentUser().flatMap(username ->
postService.getByUsername(postName, username).switchIfEmpty(postNotFoundError)
);
// check the post belongs to the current user.
var snapshotMono = postMono.flatMap(post -> Mono.defer(
() -> {
if (patched) {
var baseSnapshotName = post.getSpec().getBaseSnapshot();
return snapshotService.getPatchedBy(snapshotName, baseSnapshotName);
}
return snapshotService.getBy(snapshotName);
})
.filter(snapshot -> {
var subjectRef = snapshot.getSpec().getSubjectRef();
return Ref.equals(subjectRef, post);
})
.switchIfEmpty(snapshotNotFoundError)
);
return ServerResponse.ok().body(snapshotMono, Snapshot.class);
}
private Mono<String> getCurrentUser() {
return ReactiveSecurityContextHolder
.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName);
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1");
}
}

View File

@ -7,6 +7,8 @@ metadata:
halo.run/hidden: "true"
annotations:
rbac.authorization.halo.run/dependencies: "[ \"role-template-view-categories\" ]"
rbac.authorization.halo.run/ui-permissions: |
[ "system:categories:manage", "uc:categories:manage" ]
rules:
- apiGroups: [ "content.halo.run" ]
resources: [ "categories" ]
@ -19,6 +21,9 @@ metadata:
labels:
halo.run/role-template: "true"
halo.run/hidden: "true"
annotations:
rbac.authorization.halo.run/ui-permissions: |
[ "system:categories:view", "uc:categories:view" ]
rules:
- apiGroups: [ "content.halo.run" ]
resources: [ "categories" ]

View File

@ -0,0 +1,94 @@
apiVersion: v1alpha1
kind: "Role"
metadata:
name: post-editor
annotations:
rbac.authorization.halo.run/module: "Posts Management"
rbac.authorization.halo.run/display-name: "Post Editor"
rbac.authorization.halo.run/dependencies: |
["role-template-manage-posts", "post-author"]
rules: [ ]
---
apiVersion: v1alpha1
kind: "Role"
metadata:
name: post-author
annotations:
rbac.authorization.halo.run/module: "Posts Management"
rbac.authorization.halo.run/display-name: "Post Author"
rbac.authorization.halo.run/dependencies: |
[ "post-contributor", "post-publisher" ]
rules: [ ]
---
apiVersion: v1alpha1
kind: "Role"
metadata:
name: post-contributor
annotations:
rbac.authorization.halo.run/module: "Posts Management"
rbac.authorization.halo.run/display-name: "Post Contributor"
rbac.authorization.halo.run/dependencies: |
[ "role-template-view-categories", "role-template-view-tags" ]
rbac.authorization.halo.run/ui-permissions: |
[ "uc:posts:manage" ]
rules:
- apiGroups: [ "uc.api.content.halo.run" ]
resources: [ "posts" ]
verbs: [ "get", "list", "create", "update", "delete" ]
- apiGroups: [ "uc.api.content.halo.run" ]
resources: [ "posts/draft" ]
verbs: [ "update", "get" ]
---
apiVersion: v1alpha1
kind: Role
metadata:
name: post-publisher
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Posts Management"
rbac.authorization.halo.run/display-name: "Post Publisher"
rbac.authorization.halo.run/ui-permissions: |
[ "uc:posts:publish" ]
rules:
- apiGroups: [ "uc.api.content.halo.run" ]
resources: [ "posts/publish", "posts/unpublish" ]
verbs: [ "update" ]
---
apiVersion: v1alpha1
kind: Role
metadata:
name: post-attachment-manager
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Posts Management"
rbac.authorization.halo.run/display-name: "Post Attachment Manager"
rbac.authorization.halo.run/dependencies: |
[ "role-template-post-attachment-viewer" ]
rbac.authorization.halo.run/ui-permissions: |
[ "uc:attachments:manage" ]
rules:
- apiGroups: [ "uc.api.content.halo.run" ]
resources: [ "attachments" ]
verbs: [ "create", "update", "delete" ]
---
apiVersion: v1alpha1
kind: Role
metadata:
name: post-attachment-viewer
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Posts Management"
rbac.authorization.halo.run/display-name: "Post Attachment Viewer"
rbac.authorization.halo.run/ui-permissions: |
[ "uc:attachments:view" ]
rules:
- apiGroups: [ "uc.api.content.halo.run" ]
resources: [ "attachments" ]
verbs: [ "get", "list" ]

View File

@ -32,7 +32,8 @@ data:
"archivePageSize": 10,
"categoryPageSize": 10,
"tagPageSize": 10,
"slugGenerationStrategy": "generateByTitle"
"slugGenerationStrategy": "generateByTitle",
"attachmentPolicyName": "default-policy"
}
comment: |
{

View File

@ -68,6 +68,14 @@ spec:
value: 'shortUUID'
- label: 'UUID'
value: 'UUID'
- $formkit: attachmentPolicySelect
name: attachmentPolicyName
label: "附件存储策略"
value: "default-policy"
- $formkit: attachmentGroupSelect
name: attachmentGroupName
label: "附件存储组"
value: ""
- group: seo
label: SEO 设置
formSchema:

View File

@ -23,6 +23,26 @@ import run.halo.app.core.extension.content.Post;
@ExtendWith(MockitoExtension.class)
class PostQueryTest {
@Test
void userScopedQueryTest() {
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
MockServerRequest request = MockServerRequest.builder()
.queryParams(multiValueMap)
.exchange(mock(ServerWebExchange.class))
.build();
PostQuery postQuery = new PostQuery(request, "faker");
var spec = new Post.PostSpec();
var post = new Post();
post.setSpec(spec);
spec.setOwner("another-faker");
assertThat(postQuery.toPredicate().test(post)).isFalse();
spec.setOwner("faker");
assertThat(postQuery.toPredicate().test(post)).isTrue();
}
@Test
void toPredicate() {
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();

View File

@ -18,6 +18,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.content.Content;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.content.TestPost;
@ -176,6 +177,6 @@ class PostEndpointTest {
}
PostRequest postRequest(Post post) {
return new PostRequest(post, new PostRequest.Content("B", "<p>B</p>", "MARKDOWN"));
return new PostRequest(post, new Content("B", "<p>B</p>", "MARKDOWN"));
}
}

View File

@ -28,7 +28,7 @@ import cloneDeep from "lodash.clonedeep";
import { useRouter } from "vue-router";
import { randomUUID } from "@/utils/id";
import { useContentCache } from "@console/composables/use-content-cache";
import { useEditorExtensionPoints } from "@console/composables/use-editor-extension-points";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
@ -372,6 +372,20 @@ const handlePreview = async () => {
};
useSaveKeybinding(handleSave);
// Upload image
async function handleUploadImage(file: File) {
if (!isUpdateMode.value) {
await handleSave();
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost({
file,
singlePageName: formState.value.page.metadata.name,
waitForPermalink: true,
});
return data;
}
</script>
<template>
@ -450,6 +464,7 @@ useSaveKeybinding(handleSave);
v-if="currentEditorProvider"
v-model:raw="formState.content.raw"
v-model:content="formState.content.content"
:upload-image="handleUploadImage"
class="h-full"
@update="handleSetContentCache"
/>

View File

@ -28,7 +28,7 @@ import { useRouteQuery } from "@vueuse/router";
import { useRouter } from "vue-router";
import { randomUUID } from "@/utils/id";
import { useContentCache } from "@console/composables/use-content-cache";
import { useEditorExtensionPoints } from "@console/composables/use-editor-extension-points";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
@ -66,8 +66,17 @@ const handleChangeEditorProvider = async (provider: EditorProvider) => {
}
};
// fixme: PostRequest type may be wrong
interface PostRequestWithContent extends PostRequest {
content: {
raw: string;
content: string;
rawType: string;
};
}
// Post form
const initialFormState: PostRequest = {
const initialFormState: PostRequestWithContent = {
post: {
spec: {
title: "",
@ -103,7 +112,7 @@ const initialFormState: PostRequest = {
},
};
const formState = ref<PostRequest>(cloneDeep(initialFormState));
const formState = ref<PostRequestWithContent>(cloneDeep(initialFormState));
const settingModal = ref(false);
const saving = ref(false);
const publishing = ref(false);
@ -388,6 +397,20 @@ const handlePreview = async () => {
};
useSaveKeybinding(handleSave);
// Upload image
async function handleUploadImage(file: File) {
if (!isUpdateMode.value) {
await handleSave();
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost({
file,
postName: formState.value.post.metadata.name,
waitForPermalink: true,
});
return data;
}
</script>
<template>
@ -466,6 +489,7 @@ useSaveKeybinding(handleSave);
v-if="currentEditorProvider"
v-model:raw="formState.content.raw"
v-model:content="formState.content.content"
:upload-image="handleUploadImage"
class="h-full"
@update="handleSetContentCache"
/>

View File

@ -39,7 +39,6 @@ api/content-halo-run-v1alpha1-reply-api.ts
api/content-halo-run-v1alpha1-single-page-api.ts
api/content-halo-run-v1alpha1-snapshot-api.ts
api/content-halo-run-v1alpha1-tag-api.ts
api/doc-halo-run-v1alpha1-doc-tree-api.ts
api/login-api.ts
api/metrics-halo-run-v1alpha1-counter-api.ts
api/migration-halo-run-v1alpha1-backup-api.ts
@ -60,6 +59,9 @@ api/storage-halo-run-v1alpha1-group-api.ts
api/storage-halo-run-v1alpha1-policy-api.ts
api/storage-halo-run-v1alpha1-policy-template-api.ts
api/theme-halo-run-v1alpha1-theme-api.ts
api/uc-api-content-halo-run-v1alpha1-attachment-api.ts
api/uc-api-content-halo-run-v1alpha1-post-api.ts
api/uc-api-content-halo-run-v1alpha1-snapshot-api.ts
api/v1alpha1-annotation-setting-api.ts
api/v1alpha1-cache-api.ts
api/v1alpha1-config-map-api.ts
@ -124,9 +126,6 @@ models/create-user-request.ts
models/custom-templates.ts
models/dashboard-stats.ts
models/detailed-user.ts
models/doc-tree-list.ts
models/doc-tree-status.ts
models/doc-tree.ts
models/email-verify-request.ts
models/excerpt.ts
models/extension-definition-list.ts
@ -262,7 +261,6 @@ models/site-stats-vo.ts
models/snap-shot-spec.ts
models/snapshot-list.ts
models/snapshot.ts
models/spec.ts
models/stats-vo.ts
models/stats.ts
models/subject.ts

View File

@ -70,6 +70,9 @@ export * from "./api/storage-halo-run-v1alpha1-group-api";
export * from "./api/storage-halo-run-v1alpha1-policy-api";
export * from "./api/storage-halo-run-v1alpha1-policy-template-api";
export * from "./api/theme-halo-run-v1alpha1-theme-api";
export * from "./api/uc-api-content-halo-run-v1alpha1-attachment-api";
export * from "./api/uc-api-content-halo-run-v1alpha1-post-api";
export * from "./api/uc-api-content-halo-run-v1alpha1-snapshot-api";
export * from "./api/v1alpha1-annotation-setting-api";
export * from "./api/v1alpha1-cache-api";
export * from "./api/v1alpha1-config-map-api";

View File

@ -0,0 +1,273 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
// @ts-ignore
import { Attachment } from "../models";
/**
* UcApiContentHaloRunV1alpha1AttachmentApi - axios parameter creator
* @export
*/
export const UcApiContentHaloRunV1alpha1AttachmentApiAxiosParamCreator =
function (configuration?: Configuration) {
return {
/**
* Create attachment for the given post.
* @param {File} file
* @param {boolean} [waitForPermalink] Wait for permalink.
* @param {string} [postName] Post name.
* @param {string} [singlePageName] Single page name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAttachmentForPost: async (
file: File,
waitForPermalink?: boolean,
postName?: string,
singlePageName?: string,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'file' is not null or undefined
assertParamExists("createAttachmentForPost", "file", file);
const localVarPath = `/apis/uc.api.content.halo.run/v1alpha1/attachments`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
const localVarFormParams = new ((configuration &&
configuration.formDataCtor) ||
FormData)();
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (waitForPermalink !== undefined) {
localVarQueryParameter["waitForPermalink"] = waitForPermalink;
}
if (file !== undefined) {
localVarFormParams.append("file", file as any);
}
if (postName !== undefined) {
localVarFormParams.append("postName", postName as any);
}
if (singlePageName !== undefined) {
localVarFormParams.append("singlePageName", singlePageName as any);
}
localVarHeaderParameter["Content-Type"] = "multipart/form-data";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* UcApiContentHaloRunV1alpha1AttachmentApi - functional programming interface
* @export
*/
export const UcApiContentHaloRunV1alpha1AttachmentApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
UcApiContentHaloRunV1alpha1AttachmentApiAxiosParamCreator(configuration);
return {
/**
* Create attachment for the given post.
* @param {File} file
* @param {boolean} [waitForPermalink] Wait for permalink.
* @param {string} [postName] Post name.
* @param {string} [singlePageName] Single page name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createAttachmentForPost(
file: File,
waitForPermalink?: boolean,
postName?: string,
singlePageName?: string,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Attachment>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.createAttachmentForPost(
file,
waitForPermalink,
postName,
singlePageName,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* UcApiContentHaloRunV1alpha1AttachmentApi - factory interface
* @export
*/
export const UcApiContentHaloRunV1alpha1AttachmentApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = UcApiContentHaloRunV1alpha1AttachmentApiFp(configuration);
return {
/**
* Create attachment for the given post.
* @param {UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAttachmentForPost(
requestParameters: UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPostRequest,
options?: AxiosRequestConfig
): AxiosPromise<Attachment> {
return localVarFp
.createAttachmentForPost(
requestParameters.file,
requestParameters.waitForPermalink,
requestParameters.postName,
requestParameters.singlePageName,
options
)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for createAttachmentForPost operation in UcApiContentHaloRunV1alpha1AttachmentApi.
* @export
* @interface UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPostRequest
*/
export interface UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPostRequest {
/**
*
* @type {File}
* @memberof UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPost
*/
readonly file: File;
/**
* Wait for permalink.
* @type {boolean}
* @memberof UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPost
*/
readonly waitForPermalink?: boolean;
/**
* Post name.
* @type {string}
* @memberof UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPost
*/
readonly postName?: string;
/**
* Single page name.
* @type {string}
* @memberof UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPost
*/
readonly singlePageName?: string;
}
/**
* UcApiContentHaloRunV1alpha1AttachmentApi - object-oriented interface
* @export
* @class UcApiContentHaloRunV1alpha1AttachmentApi
* @extends {BaseAPI}
*/
export class UcApiContentHaloRunV1alpha1AttachmentApi extends BaseAPI {
/**
* Create attachment for the given post.
* @param {UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UcApiContentHaloRunV1alpha1AttachmentApi
*/
public createAttachmentForPost(
requestParameters: UcApiContentHaloRunV1alpha1AttachmentApiCreateAttachmentForPostRequest,
options?: AxiosRequestConfig
) {
return UcApiContentHaloRunV1alpha1AttachmentApiFp(this.configuration)
.createAttachmentForPost(
requestParameters.file,
requestParameters.waitForPermalink,
requestParameters.postName,
requestParameters.singlePageName,
options
)
.then((request) => request(this.axios, this.basePath));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,251 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
// @ts-ignore
import { Snapshot } from "../models";
/**
* UcApiContentHaloRunV1alpha1SnapshotApi - axios parameter creator
* @export
*/
export const UcApiContentHaloRunV1alpha1SnapshotApiAxiosParamCreator =
function (configuration?: Configuration) {
return {
/**
* Get snapshot for one post.
* @param {string} name Snapshot name.
* @param {string} postName Post name.
* @param {boolean} [patched] Should include patched content and raw or not.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSnapshotForPost: async (
name: string,
postName: string,
patched?: boolean,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("getSnapshotForPost", "name", name);
// verify required parameter 'postName' is not null or undefined
assertParamExists("getSnapshotForPost", "postName", postName);
const localVarPath =
`/apis/uc.api.content.halo.run/v1alpha1/snapshots/{name}`.replace(
`{${"name"}}`,
encodeURIComponent(String(name))
);
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "GET",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (postName !== undefined) {
localVarQueryParameter["postName"] = postName;
}
if (patched !== undefined) {
localVarQueryParameter["patched"] = patched;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* UcApiContentHaloRunV1alpha1SnapshotApi - functional programming interface
* @export
*/
export const UcApiContentHaloRunV1alpha1SnapshotApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
UcApiContentHaloRunV1alpha1SnapshotApiAxiosParamCreator(configuration);
return {
/**
* Get snapshot for one post.
* @param {string} name Snapshot name.
* @param {string} postName Post name.
* @param {boolean} [patched] Should include patched content and raw or not.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSnapshotForPost(
name: string,
postName: string,
patched?: boolean,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Snapshot>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.getSnapshotForPost(
name,
postName,
patched,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* UcApiContentHaloRunV1alpha1SnapshotApi - factory interface
* @export
*/
export const UcApiContentHaloRunV1alpha1SnapshotApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = UcApiContentHaloRunV1alpha1SnapshotApiFp(configuration);
return {
/**
* Get snapshot for one post.
* @param {UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSnapshotForPost(
requestParameters: UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPostRequest,
options?: AxiosRequestConfig
): AxiosPromise<Snapshot> {
return localVarFp
.getSnapshotForPost(
requestParameters.name,
requestParameters.postName,
requestParameters.patched,
options
)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for getSnapshotForPost operation in UcApiContentHaloRunV1alpha1SnapshotApi.
* @export
* @interface UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPostRequest
*/
export interface UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPostRequest {
/**
* Snapshot name.
* @type {string}
* @memberof UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPost
*/
readonly name: string;
/**
* Post name.
* @type {string}
* @memberof UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPost
*/
readonly postName: string;
/**
* Should include patched content and raw or not.
* @type {boolean}
* @memberof UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPost
*/
readonly patched?: boolean;
}
/**
* UcApiContentHaloRunV1alpha1SnapshotApi - object-oriented interface
* @export
* @class UcApiContentHaloRunV1alpha1SnapshotApi
* @extends {BaseAPI}
*/
export class UcApiContentHaloRunV1alpha1SnapshotApi extends BaseAPI {
/**
* Get snapshot for one post.
* @param {UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UcApiContentHaloRunV1alpha1SnapshotApi
*/
public getSnapshotForPost(
requestParameters: UcApiContentHaloRunV1alpha1SnapshotApiGetSnapshotForPostRequest,
options?: AxiosRequestConfig
) {
return UcApiContentHaloRunV1alpha1SnapshotApiFp(this.configuration)
.getSnapshotForPost(
requestParameters.name,
requestParameters.postName,
requestParameters.patched,
options
)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -0,0 +1,655 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
// @ts-ignore
import { ListedPostList } from "../models";
// @ts-ignore
import { Post } from "../models";
/**
* UcContentHaloRunV1alpha1PostApi - axios parameter creator
* @export
*/
export const UcContentHaloRunV1alpha1PostApiAxiosParamCreator = function (
configuration?: Configuration
) {
return {
/**
* Create my post. If you want to create a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {Post} [post]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createMyPost: async (
post?: Post,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/uc.content.halo.run/v1alpha1/posts`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
post,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* List posts owned by the current user.
* @param {Array<string>} [category]
* @param {Array<string>} [contributor]
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Posts filtered by keyword.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'} [publishPhase]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @param {Array<string>} [tag]
* @param {'PUBLIC' | 'INTERNAL' | 'PRIVATE'} [visible]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listMyPosts: async (
category?: Array<string>,
contributor?: Array<string>,
fieldSelector?: Array<string>,
keyword?: string,
labelSelector?: Array<string>,
page?: number,
publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED",
size?: number,
sort?: Array<string>,
tag?: Array<string>,
visible?: "PUBLIC" | "INTERNAL" | "PRIVATE",
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/uc.content.halo.run/v1alpha1/posts`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "GET",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (category) {
localVarQueryParameter["category"] = Array.from(category);
}
if (contributor) {
localVarQueryParameter["contributor"] = Array.from(contributor);
}
if (fieldSelector) {
localVarQueryParameter["fieldSelector"] = fieldSelector;
}
if (keyword !== undefined) {
localVarQueryParameter["keyword"] = keyword;
}
if (labelSelector) {
localVarQueryParameter["labelSelector"] = labelSelector;
}
if (page !== undefined) {
localVarQueryParameter["page"] = page;
}
if (publishPhase !== undefined) {
localVarQueryParameter["publishPhase"] = publishPhase;
}
if (size !== undefined) {
localVarQueryParameter["size"] = size;
}
if (sort) {
localVarQueryParameter["sort"] = Array.from(sort);
}
if (tag) {
localVarQueryParameter["tag"] = Array.from(tag);
}
if (visible !== undefined) {
localVarQueryParameter["visible"] = visible;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Update my post. If you want to update a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {string} name Post name
* @param {Post} [post]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateMyPost: async (
name: string,
post?: Post,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("updateMyPost", "name", name);
const localVarPath =
`/apis/uc.content.halo.run/v1alpha1/posts/{name}`.replace(
`{${"name"}}`,
encodeURIComponent(String(name))
);
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "PUT",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
post,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* UcContentHaloRunV1alpha1PostApi - functional programming interface
* @export
*/
export const UcContentHaloRunV1alpha1PostApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
UcContentHaloRunV1alpha1PostApiAxiosParamCreator(configuration);
return {
/**
* Create my post. If you want to create a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {Post} [post]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createMyPost(
post?: Post,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createMyPost(
post,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* List posts owned by the current user.
* @param {Array<string>} [category]
* @param {Array<string>} [contributor]
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Posts filtered by keyword.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'} [publishPhase]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @param {Array<string>} [tag]
* @param {'PUBLIC' | 'INTERNAL' | 'PRIVATE'} [visible]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listMyPosts(
category?: Array<string>,
contributor?: Array<string>,
fieldSelector?: Array<string>,
keyword?: string,
labelSelector?: Array<string>,
page?: number,
publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED",
size?: number,
sort?: Array<string>,
tag?: Array<string>,
visible?: "PUBLIC" | "INTERNAL" | "PRIVATE",
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listMyPosts(
category,
contributor,
fieldSelector,
keyword,
labelSelector,
page,
publishPhase,
size,
sort,
tag,
visible,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Update my post. If you want to update a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {string} name Post name
* @param {Post} [post]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateMyPost(
name: string,
post?: Post,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateMyPost(
name,
post,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* UcContentHaloRunV1alpha1PostApi - factory interface
* @export
*/
export const UcContentHaloRunV1alpha1PostApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = UcContentHaloRunV1alpha1PostApiFp(configuration);
return {
/**
* Create my post. If you want to create a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {UcContentHaloRunV1alpha1PostApiCreateMyPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createMyPost(
requestParameters: UcContentHaloRunV1alpha1PostApiCreateMyPostRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<Post> {
return localVarFp
.createMyPost(requestParameters.post, options)
.then((request) => request(axios, basePath));
},
/**
* List posts owned by the current user.
* @param {UcContentHaloRunV1alpha1PostApiListMyPostsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listMyPosts(
requestParameters: UcContentHaloRunV1alpha1PostApiListMyPostsRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<ListedPostList> {
return localVarFp
.listMyPosts(
requestParameters.category,
requestParameters.contributor,
requestParameters.fieldSelector,
requestParameters.keyword,
requestParameters.labelSelector,
requestParameters.page,
requestParameters.publishPhase,
requestParameters.size,
requestParameters.sort,
requestParameters.tag,
requestParameters.visible,
options
)
.then((request) => request(axios, basePath));
},
/**
* Update my post. If you want to update a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {UcContentHaloRunV1alpha1PostApiUpdateMyPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateMyPost(
requestParameters: UcContentHaloRunV1alpha1PostApiUpdateMyPostRequest,
options?: AxiosRequestConfig
): AxiosPromise<Post> {
return localVarFp
.updateMyPost(requestParameters.name, requestParameters.post, options)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for createMyPost operation in UcContentHaloRunV1alpha1PostApi.
* @export
* @interface UcContentHaloRunV1alpha1PostApiCreateMyPostRequest
*/
export interface UcContentHaloRunV1alpha1PostApiCreateMyPostRequest {
/**
*
* @type {Post}
* @memberof UcContentHaloRunV1alpha1PostApiCreateMyPost
*/
readonly post?: Post;
}
/**
* Request parameters for listMyPosts operation in UcContentHaloRunV1alpha1PostApi.
* @export
* @interface UcContentHaloRunV1alpha1PostApiListMyPostsRequest
*/
export interface UcContentHaloRunV1alpha1PostApiListMyPostsRequest {
/**
*
* @type {Array<string>}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly category?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly contributor?: Array<string>;
/**
* Field selector for filtering.
* @type {Array<string>}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly fieldSelector?: Array<string>;
/**
* Posts filtered by keyword.
* @type {string}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly keyword?: string;
/**
* Label selector for filtering.
* @type {Array<string>}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly labelSelector?: Array<string>;
/**
* The page number. Zero indicates no page.
* @type {number}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly page?: number;
/**
*
* @type {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED";
/**
* Size of one page. Zero indicates no limit.
* @type {number}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly size?: number;
/**
* Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @type {Array<string>}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly sort?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly tag?: Array<string>;
/**
*
* @type {'PUBLIC' | 'INTERNAL' | 'PRIVATE'}
* @memberof UcContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly visible?: "PUBLIC" | "INTERNAL" | "PRIVATE";
}
/**
* Request parameters for updateMyPost operation in UcContentHaloRunV1alpha1PostApi.
* @export
* @interface UcContentHaloRunV1alpha1PostApiUpdateMyPostRequest
*/
export interface UcContentHaloRunV1alpha1PostApiUpdateMyPostRequest {
/**
* Post name
* @type {string}
* @memberof UcContentHaloRunV1alpha1PostApiUpdateMyPost
*/
readonly name: string;
/**
*
* @type {Post}
* @memberof UcContentHaloRunV1alpha1PostApiUpdateMyPost
*/
readonly post?: Post;
}
/**
* UcContentHaloRunV1alpha1PostApi - object-oriented interface
* @export
* @class UcContentHaloRunV1alpha1PostApi
* @extends {BaseAPI}
*/
export class UcContentHaloRunV1alpha1PostApi extends BaseAPI {
/**
* Create my post. If you want to create a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {UcContentHaloRunV1alpha1PostApiCreateMyPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UcContentHaloRunV1alpha1PostApi
*/
public createMyPost(
requestParameters: UcContentHaloRunV1alpha1PostApiCreateMyPostRequest = {},
options?: AxiosRequestConfig
) {
return UcContentHaloRunV1alpha1PostApiFp(this.configuration)
.createMyPost(requestParameters.post, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* List posts owned by the current user.
* @param {UcContentHaloRunV1alpha1PostApiListMyPostsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UcContentHaloRunV1alpha1PostApi
*/
public listMyPosts(
requestParameters: UcContentHaloRunV1alpha1PostApiListMyPostsRequest = {},
options?: AxiosRequestConfig
) {
return UcContentHaloRunV1alpha1PostApiFp(this.configuration)
.listMyPosts(
requestParameters.category,
requestParameters.contributor,
requestParameters.fieldSelector,
requestParameters.keyword,
requestParameters.labelSelector,
requestParameters.page,
requestParameters.publishPhase,
requestParameters.size,
requestParameters.sort,
requestParameters.tag,
requestParameters.visible,
options
)
.then((request) => request(this.axios, this.basePath));
}
/**
* Update my post. If you want to update a post with content, please set annotation: \"content.halo.run/content-json\" into annotations and refer to PostRequestContent for corresponding data type.
* @param {UcContentHaloRunV1alpha1PostApiUpdateMyPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UcContentHaloRunV1alpha1PostApi
*/
public updateMyPost(
requestParameters: UcContentHaloRunV1alpha1PostApiUpdateMyPostRequest,
options?: AxiosRequestConfig
) {
return UcContentHaloRunV1alpha1PostApiFp(this.configuration)
.updateMyPost(requestParameters.name, requestParameters.post, options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -181,7 +181,6 @@ export * from "./site-stats-vo";
export * from "./snap-shot-spec";
export * from "./snapshot";
export * from "./snapshot-list";
export * from "./spec";
export * from "./stats";
export * from "./stats-vo";
export * from "./subject";

View File

@ -0,0 +1,39 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface PostRequestContent
*/
export interface PostRequestContent {
/**
*
* @type {string}
* @memberof PostRequestContent
*/
content?: string;
/**
*
* @type {string}
* @memberof PostRequestContent
*/
raw?: string;
/**
*
* @type {string}
* @memberof PostRequestContent
*/
rawType?: string;
}

View File

@ -30,7 +30,7 @@ export interface PostRequest {
* @type {Content}
* @memberof PostRequest
*/
content: Content;
content?: Content;
/**
*
* @type {Post}

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useEditorExtensionPoints } from "@console/composables/use-editor-extension-points";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import {
VAvatar,

View File

@ -51,7 +51,6 @@ import {
IconFolder,
IconLink,
IconUserFollow,
Toast,
VTabItem,
VTabs,
} from "@halo-dev/components";
@ -75,11 +74,9 @@ import {
} from "vue";
import { formatDatetime } from "@/utils/date";
import { useAttachmentSelect } from "@console/modules/contents/attachments/composables/use-attachment";
import { apiClient } from "@/utils/api-client";
import * as fastq from "fastq";
import type { queueAsPromised } from "fastq";
import type { Attachment } from "@halo-dev/api-client";
import { useFetchAttachmentPolicy } from "@console/modules/contents/attachments/composables/use-attachment-policy";
import { useI18n } from "vue-i18n";
import { i18n } from "@/locales";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
@ -88,17 +85,21 @@ import type { PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn } from "@vueuse/core";
import { onBeforeUnmount } from "vue";
import { generateAnchor } from "@/utils/anchor";
import { usePermission } from "@/utils/permission";
const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
const props = withDefaults(
defineProps<{
raw?: string;
content: string;
uploadImage?: (file: File) => Promise<Attachment>;
}>(),
{
raw: "",
content: "",
uploadImage: undefined,
}
);
@ -236,6 +237,11 @@ onMounted(() => {
}),
Extension.create({
addOptions() {
// If user has no permission to view attachments, return
if (!currentUserHasPermission(["system:attachments:view"])) {
return this;
}
return {
getToolboxItems({ editor }: { editor: Editor }) {
return [
@ -362,8 +368,6 @@ onBeforeUnmount(() => {
});
// image drag and paste upload
const { policies } = useFetchAttachmentPolicy();
type Task = {
file: File;
process: (permalink: string) => void;
@ -372,61 +376,17 @@ type Task = {
const uploadQueue: queueAsPromised<Task> = fastq.promise(asyncWorker, 1);
async function asyncWorker(arg: Task): Promise<void> {
if (!policies.value?.length) {
Toast.warning(
t(
"core.components.default_editor.upload_attachment.toast.no_available_policy"
)
);
if (!props.uploadImage) {
return;
}
const { data: attachmentData } = await apiClient.attachment.uploadAttachment({
file: arg.file,
policyName: policies.value[0].metadata.name,
});
const attachmentData = await props.uploadImage(arg.file);
const permalink = await handleFetchPermalink(attachmentData, 3);
if (permalink) {
arg.process(permalink);
if (attachmentData.status?.permalink) {
arg.process(attachmentData.status.permalink);
}
}
const handleFetchPermalink = async (
attachment: Attachment,
maxRetry: number
): Promise<string | undefined> => {
if (maxRetry === 0) {
Toast.error(
t(
"core.components.default_editor.upload_attachment.toast.failed_fetch_permalink",
{ display_name: attachment.spec.displayName }
)
);
return undefined;
}
const { data } =
await apiClient.extension.storage.attachment.getstorageHaloRunV1alpha1Attachment(
{
name: attachment.metadata.name,
}
);
if (data.status?.permalink) {
return data.status.permalink;
}
return await new Promise((resolve) => {
const timer = setTimeout(() => {
const permalink = handleFetchPermalink(attachment, maxRetry - 1);
clearTimeout(timer);
resolve(permalink);
}, 300);
});
};
const handleGenerateTableOfContent = () => {
if (!editor.value) {
return;

View File

@ -18,6 +18,9 @@ export enum rbacAnnotations {
export enum contentAnnotations {
PREFERRED_EDITOR = "content.halo.run/preferred-editor",
PATCHED_CONTENT = "content.halo.run/patched-content",
PATCHED_RAW = "content.halo.run/patched-raw",
CONTENT_JSON = "content.halo.run/content-json",
}
// pat

View File

@ -14,6 +14,7 @@ import SearchResultListItem from "./components/SearchResultListItem.vue";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
import { slugify } from "transliteration";
import HasPermission from "@/components/permission/HasPermission.vue";
const { currentUserHasPermission } = usePermission();
@ -289,18 +290,24 @@ const handleDelete = () => {
<div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']">
<ul class="p-1">
<li
<HasPermission
v-if="text.trim() && !searchResults?.length"
v-permission="['system:posts:manage']"
:permissions="['system:posts:manage']"
>
<li
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
@click="handleCreateCategory"
>
<span class="text-xs text-gray-700 group-hover:text-gray-900">
{{
$t("core.formkit.category_select.creation_label", { text: text })
$t("core.formkit.category_select.creation_label", {
text: text,
})
}}
</span>
</li>
</HasPermission>
<template v-if="text">
<SearchResultListItem
v-for="category in searchResults"

View File

@ -14,6 +14,7 @@ import Fuse from "fuse.js";
import { usePermission } from "@/utils/permission";
import { slugify } from "transliteration";
import { usePostTag } from "@console/modules/contents/posts/tags/composables/use-post-tag";
import HasPermission from "@/components/permission/HasPermission.vue";
const { currentUserHasPermission } = usePermission();
@ -279,9 +280,11 @@ const handleDelete = () => {
<div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']">
<ul class="p-1">
<li
<HasPermission
v-if="text.trim() && !searchResults?.length"
v-permission="['system:posts:manage']"
:permissions="['system:posts:manage']"
>
<li
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
@click="handleCreateTag"
>
@ -289,6 +292,7 @@ const handleDelete = () => {
{{ $t("core.formkit.tag_select.creation_label", { text: text }) }}
</span>
</li>
</HasPermission>
<li
v-for="tag in searchResults"
:id="tag.metadata.name"

View File

@ -1205,10 +1205,6 @@ core:
placeholder: "Enter / to select input type."
toolbox:
attachment: Attachment
upload_attachment:
toast:
no_available_policy: There is currently no available storage policy
failed_fetch_permalink: "Failed to get the permalink of the attachment: {display_name}"
global_search:
placeholder: Enter keywords to search
no_results: No search results

View File

@ -1133,10 +1133,6 @@ core:
placeholder: "Ingresa / para seleccionar el tipo de entrada."
toolbox:
attachment: Adjunto
upload_attachment:
toast:
no_available_policy: Actualmente no hay una política de almacenamiento disponible
failed_fetch_permalink: "Error al obtener el enlace permanente del adjunto: {display_name}"
global_search:
placeholder: Ingresa palabras clave para buscar
no_results: Sin resultados de búsqueda

View File

@ -1205,10 +1205,6 @@ core:
placeholder: "输入 / 以选择输入类型"
toolbox:
attachment: 选择附件
upload_attachment:
toast:
no_available_policy: 目前没有可用的存储策略
failed_fetch_permalink: 获取附件永久链接失败:{display_name}
global_search:
placeholder: 输入关键词以搜索
no_results: 没有搜索结果
@ -1294,7 +1290,7 @@ core:
delete_success: 删除成功
save_success: 保存成功
publish_success: 发布成功
cancel_publish_success: 发布成功
cancel_publish_success: 取消发布成功
recovery_success: 恢复成功
uninstall_success: 卸载成功
active_success: 启用成功

View File

@ -1205,10 +1205,6 @@ core:
placeholder: "輸入 / 以選擇輸入類型"
toolbox:
attachment: 選擇附件
upload_attachment:
toast:
no_available_policy: 目前沒有可用的存儲策略
failed_fetch_permalink: 獲取附件永久連結失敗:{display_name}
global_search:
placeholder: 輸入關鍵字以搜尋
no_results: 沒有搜尋結果
@ -1294,7 +1290,7 @@ core:
delete_success: 刪除成功
save_success: 保存成功
publish_success: 發布成功
cancel_publish_success: 發布成功
cancel_publish_success: 取消發布成功
recovery_success: 恢復成功
uninstall_success: 卸載成功
active_success: 啟用成功

View File

@ -45,6 +45,9 @@ import {
NotificationHaloRunV1alpha1NotifierDescriptorApi,
ApiSecurityHaloRunV1alpha1PersonalAccessTokenApi,
SecurityHaloRunV1alpha1PersonalAccessTokenApi,
UcApiContentHaloRunV1alpha1AttachmentApi,
UcApiContentHaloRunV1alpha1PostApi,
UcApiContentHaloRunV1alpha1SnapshotApi,
} from "@halo-dev/api-client";
import type { AxiosError, AxiosInstance } from "axios";
import axios from "axios";
@ -244,6 +247,19 @@ function setupApiClient(axios: AxiosInstance) {
baseURL,
axios
),
uc: {
post: new UcApiContentHaloRunV1alpha1PostApi(undefined, baseURL, axios),
attachment: new UcApiContentHaloRunV1alpha1AttachmentApi(
undefined,
baseURL,
axios
),
snapshot: new UcApiContentHaloRunV1alpha1SnapshotApi(
undefined,
baseURL,
axios
),
},
};
}

View File

@ -0,0 +1,439 @@
<script lang="ts" setup>
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import {
Dialog,
IconBookRead,
IconSave,
IconSendPlaneFill,
IconSettings,
Toast,
VButton,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { ref } from "vue";
import { useLocalStorage } from "@vueuse/core";
import type { Post, Content, Snapshot } from "@halo-dev/api-client";
import { randomUUID } from "@/utils/id";
import { contentAnnotations } from "@/constants/annotations";
import { useRouteQuery } from "@vueuse/router";
import { onMounted } from "vue";
import { apiClient } from "@/utils/api-client";
import { nextTick } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useMutation } from "@tanstack/vue-query";
import { computed } from "vue";
import { useSaveKeybinding } from "@console/composables/use-save-keybinding";
import PostCreationModal from "./components/PostCreationModal.vue";
import PostSettingEditModal from "./components/PostSettingEditModal.vue";
import HasPermission from "@/components/permission/HasPermission.vue";
import { provide } from "vue";
import type { ComputedRef } from "vue";
const router = useRouter();
const { t } = useI18n();
const formState = ref<Post>({
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
annotations: {},
name: randomUUID(),
},
spec: {
allowComment: true,
baseSnapshot: "",
categories: [],
cover: "",
deleted: false,
excerpt: {
autoGenerate: true,
raw: "",
},
headSnapshot: "",
htmlMetas: [],
owner: "",
pinned: false,
priority: 0,
publish: false,
publishTime: "",
releaseSnapshot: "",
slug: "",
tags: [],
template: "",
title: "",
visible: "PUBLIC",
},
});
const content = ref<Content>({
content: "",
raw: "",
rawType: "",
});
const snapshot = ref<Snapshot>();
// provide some data to editor
provide<ComputedRef<string | undefined>>(
"owner",
computed(() => formState.value.spec.owner)
);
provide<ComputedRef<string | undefined>>(
"publishTime",
computed(() => formState.value.spec.publishTime)
);
provide<ComputedRef<string | undefined>>(
"permalink",
computed(() => formState.value.status?.permalink)
);
// Editor providers
const { editorProviders } = useEditorExtensionPoints();
const currentEditorProvider = ref<EditorProvider>();
const storedEditorProviderName = useLocalStorage("editor-provider-name", "");
const handleChangeEditorProvider = async (provider: EditorProvider) => {
currentEditorProvider.value = provider;
const { name, rawType } = provider;
storedEditorProviderName.value = name;
content.value.rawType = rawType;
formState.value.metadata.annotations = {
...formState.value.metadata.annotations,
[contentAnnotations.PREFERRED_EDITOR]: name,
};
};
// Fetch post data when the route contains the name parameter
const name = useRouteQuery<string | undefined>("name");
onMounted(async () => {
if (name.value) {
const { data: post } = await apiClient.uc.post.getMyPost({
name: name.value,
});
formState.value = post;
await handleFetchContent();
return;
}
// New post, set default editor
const provider =
editorProviders.value.find(
(provider) => provider.name === storedEditorProviderName.value
) || editorProviders.value[0];
if (provider) {
currentEditorProvider.value = provider;
content.value.rawType = provider.rawType;
formState.value.metadata.annotations = {
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
}
});
/**
* Fetch content from the head snapshot.
*/
async function handleFetchContent() {
const { headSnapshot } = formState.value.spec || {};
if (!headSnapshot || !name.value) {
return;
}
const { data } = await apiClient.uc.post.getMyPostDraft({
name: name.value,
patched: true,
});
const {
[contentAnnotations.PATCHED_CONTENT]: patchedContent,
[contentAnnotations.PATCHED_RAW]: patchedRaw,
} = data.metadata.annotations || {};
const { rawType } = data.spec || {};
content.value = {
content: patchedContent,
raw: patchedRaw,
rawType,
};
snapshot.value = data;
if (currentEditorProvider.value) {
return;
}
await handleSetEditorProviderFromRemote();
}
async function handleSetEditorProviderFromRemote() {
const { [contentAnnotations.PREFERRED_EDITOR]: preferredEditorName } =
formState.value.metadata.annotations || {};
const preferredEditor = editorProviders.value.find(
(provider) => provider.name === preferredEditorName
);
const provider =
preferredEditor ||
editorProviders.value.find(
(provider) => provider.rawType === content.value.rawType
);
if (provider) {
currentEditorProvider.value = provider;
formState.value.metadata.annotations = {
...formState.value.metadata.annotations,
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
} else {
Dialog.warning({
title: t("core.common.dialog.titles.warning"),
description: t("core.common.dialog.descriptions.editor_not_found", {
raw_type: content.value.rawType,
}),
confirmText: t("core.common.buttons.confirm"),
showCancel: false,
onConfirm: () => {
router.back();
},
});
}
await nextTick();
}
// Create post
const postCreationModal = ref(false);
function handleSaveClick() {
if (isUpdateMode.value) {
handleSave({ mute: false });
} else {
postCreationModal.value = true;
}
}
function onCreatePostSuccess(data: Post) {
formState.value = data;
// Update route query params
name.value = data.metadata.name;
handleFetchContent();
}
// Save post
const isUpdateMode = computed(
() => !!formState.value.metadata.creationTimestamp
);
const { mutateAsync: handleSave, isLoading: isSaving } = useMutation({
mutationKey: ["save-post"],
variables: {
mute: false,
},
mutationFn: async () => {
// Snapshot always exists in update mode
if (!snapshot.value) {
return;
}
const { annotations } = snapshot.value.metadata || {};
snapshot.value.metadata.annotations = {
...annotations,
[contentAnnotations.CONTENT_JSON]: JSON.stringify(content.value),
};
if (!isUpdateMode.value || !name.value) {
return;
}
const { data } = await apiClient.uc.post.updateMyPostDraft({
name: name.value,
snapshot: snapshot.value,
});
snapshot.value = data;
return data;
},
onSuccess(_, variables) {
if (!variables.mute) Toast.success(t("core.common.toast.save_success"));
handleFetchContent();
},
onError() {
Toast.error(t("core.common.toast.save_failed_and_retry"));
},
});
useSaveKeybinding(handleSaveClick);
// Publish post
const postPublishModal = ref(false);
function handlePublishClick() {
if (isUpdateMode.value) {
handlePublish();
} else {
postPublishModal.value = true;
}
}
function onPublishPostSuccess() {
router.push({ name: "Posts" });
}
const { mutateAsync: handlePublish, isLoading: isPublishing } = useMutation({
mutationKey: ["publish-post"],
mutationFn: async () => {
await handleSave({ mute: true });
return await apiClient.uc.post.publishMyPost({
name: formState.value.metadata.name,
});
},
onSuccess() {
Toast.success(t("core.common.toast.publish_success"), {
duration: 2000,
});
router.push({ name: "Posts" });
},
onError() {
Toast.error(t("core.common.toast.publish_failed_and_retry"));
},
});
// Post setting
const postSettingEditModal = ref(false);
function handleOpenPostSettingEditModal() {
handleSave({ mute: true });
postSettingEditModal.value = true;
}
function onUpdatePostSuccess(data: Post) {
formState.value = data;
handleFetchContent();
}
// Upload image
async function handleUploadImage(file: File) {
if (!isUpdateMode.value) {
formState.value.metadata.annotations = {
...formState.value.metadata.annotations,
[contentAnnotations.CONTENT_JSON]: JSON.stringify(content.value),
};
await apiClient.uc.post.createMyPost({
post: formState.value,
});
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost({
file,
postName: formState.value.metadata.name,
waitForPermalink: true,
});
return data;
}
</script>
<template>
<VPageHeader :title="$t('core.post.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<EditorProviderSelector
v-if="editorProviders.length > 1"
:provider="currentEditorProvider"
:allow-forced-select="!isUpdateMode"
@select="handleChangeEditorProvider"
/>
<VButton
size="sm"
type="default"
:loading="isSaving && !isPublishing"
@click="handleSaveClick"
>
<template #icon>
<IconSave class="h-full w-full" />
</template>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="handleOpenPostSettingEditModal"
>
<template #icon>
<IconSettings class="h-full w-full" />
</template>
{{ $t("core.common.buttons.setting") }}
</VButton>
<HasPermission :permissions="['uc:posts:publish']">
<VButton
:loading="isPublishing"
type="secondary"
@click="handlePublishClick"
>
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
{{ $t("core.common.buttons.publish") }}
</VButton>
</HasPermission>
</VSpace>
</template>
</VPageHeader>
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
<component
:is="currentEditorProvider.component"
v-if="currentEditorProvider"
v-model:raw="content.raw"
v-model:content="content.content"
:upload-image="handleUploadImage"
class="h-full"
/>
</div>
<PostCreationModal
v-if="postCreationModal"
title="创建文章"
:content="content"
@close="postCreationModal = false"
@success="onCreatePostSuccess"
/>
<PostCreationModal
v-if="postPublishModal"
title="发布文章"
:content="content"
publish
@close="postPublishModal = false"
@success="onPublishPostSuccess"
/>
<PostSettingEditModal
v-if="postSettingEditModal"
:post="formState"
@close="postSettingEditModal = false"
@success="onUpdatePostSuccess"
/>
</template>

View File

@ -0,0 +1,208 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import { useQuery } from "@tanstack/vue-query";
import {
IconAddCircle,
IconBookRead,
IconRefreshLine,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import PostListItem from "./components/PostListItem.vue";
import { useRouteQuery } from "@vueuse/router";
import { computed } from "vue";
import { watch } from "vue";
import { postLabels } from "@/constants/labels";
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const keyword = useRouteQuery<string>("keyword", "");
const selectedPublishPhase = useRouteQuery<
"DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED" | undefined
>("status");
function handleClearFilters() {
selectedPublishPhase.value = undefined;
}
const hasFilters = computed(() => {
return selectedPublishPhase.value !== undefined;
});
watch(
() => [selectedPublishPhase.value, keyword.value],
() => {
page.value = 1;
}
);
const {
data: posts,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["my-posts", page, size, keyword, selectedPublishPhase],
queryFn: async () => {
const labelSelector: string[] = ["content.halo.run/deleted=false"];
const { data } = await apiClient.uc.post.listMyPosts({
labelSelector,
page: page.value,
size: size.value,
keyword: keyword.value,
publishPhase: selectedPublishPhase.value,
});
return data;
},
onSuccess(data) {
page.value = data.page;
size.value = data.size;
},
refetchInterval: (data) => {
const abnormalPosts = data?.items.filter((post) => {
const { spec, metadata, status } = post.post;
return (
spec.deleted ||
metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
return abnormalPosts?.length ? 1000 : false;
},
});
</script>
<template>
<VPageHeader title="我的文章">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VButton :route="{ name: 'PostEditor' }" type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row sm:items-center"
>
<SearchInput v-model="keyword" />
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedPublishPhase"
:label="$t('core.common.filters.labels.status')"
:items="[
{
label: $t('core.common.filters.item_labels.all'),
value: undefined,
},
{
label: '已发布',
value: 'PUBLISHED',
},
{
label: '待审核',
value: 'PENDING_APPROVAL',
},
{
label: '未发布',
value: 'DRAFT',
},
{
label: '发布失败',
value: 'FAILED',
},
]"
/>
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!posts?.items.length" appear name="fade">
<VEmpty
:message="$t('core.post.empty.message')"
:title="$t('core.post.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
type="primary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="post in posts.items" :key="post.post.metadata.name">
<PostListItem :post="post" />
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', {
total: posts?.total || 0,
})
"
:total="posts?.total || 0"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,149 @@
<script lang="ts" setup>
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import { nextTick } from "vue";
import { onMounted } from "vue";
import { ref } from "vue";
import PostSettingForm from "./PostSettingForm.vue";
import type { Content, Post } from "@halo-dev/api-client";
import type { PostFormState } from "../types";
import { useMutation } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { randomUUID } from "@/utils/id";
import { contentAnnotations } from "@/constants/annotations";
import { apiClient } from "@/utils/api-client";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
title: string;
content: Content;
publish?: boolean;
}>(),
{
publish: false,
}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "success", post: Post): void;
}>();
// fixme: refactor VModal component
const shouldRender = ref(false);
const visible = ref(false);
onMounted(() => {
shouldRender.value = true;
nextTick(() => {
visible.value = true;
});
});
function onClose() {
visible.value = false;
setTimeout(() => {
shouldRender.value = false;
emit("close");
}, 200);
}
const { mutate, isLoading } = useMutation({
mutationKey: ["create-post"],
mutationFn: async ({ data }: { data: PostFormState }) => {
const post: Post = {
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
annotations: {
[contentAnnotations.CONTENT_JSON]: JSON.stringify(props.content),
},
name: randomUUID(),
},
spec: {
allowComment: data.allowComment,
categories: data.categories,
cover: data.cover,
deleted: false,
excerpt: {
autoGenerate: data.excerptAutoGenerate,
raw: data.excerptRaw,
},
htmlMetas: [],
pinned: data.pinned,
priority: 0,
publish: false,
publishTime: data.publishTime,
slug: data.slug,
tags: data.tags,
title: data.title,
visible: data.visible,
},
};
const { data: createdPost } = await apiClient.uc.post.createMyPost({
post,
});
if (props.publish) {
await apiClient.uc.post.publishMyPost({
name: post.metadata.name,
});
}
return createdPost;
},
onSuccess(data) {
if (props.publish) {
Toast.success(t("core.common.toast.publish_success"));
} else {
Toast.success(t("core.common.toast.save_success"));
}
emit("success", data);
emit("close");
},
onError() {
if (props.publish) {
Toast.error(t("core.common.toast.publish_failed_and_retry"));
} else {
Toast.error(t("core.common.toast.save_failed_and_retry"));
}
},
});
function onSubmit(data: PostFormState) {
mutate({ data });
}
</script>
<template>
<VModal
v-if="shouldRender"
v-model:visible="visible"
:title="title"
:width="700"
centered
@close="onClose"
>
<PostSettingForm @submit="onSubmit" />
<template #footer>
<VSpace>
<VButton
:loading="isLoading"
type="secondary"
@click="$formkit.submit('post-setting-form')"
>
{{
props.publish
? $t("core.common.buttons.publish")
: $t("core.common.buttons.save")
}}
</VButton>
<VButton type="default" @click="onClose()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,234 @@
<script lang="ts" setup>
import {
Dialog,
IconExternalLinkLine,
IconEye,
IconEyeOff,
Toast,
VAvatar,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import type { ListedPost } from "@halo-dev/api-client";
import { computed } from "vue";
import { postLabels } from "@/constants/labels";
import PostTag from "@console/modules/contents/posts/tags/components/PostTag.vue";
import { formatDatetime } from "@/utils/date";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import { useI18n } from "vue-i18n";
import HasPermission from "@/components/permission/HasPermission.vue";
import { apiClient } from "@/utils/api-client";
import { useQueryClient } from "@tanstack/vue-query";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const externalUrl = computed(() => {
const { status, metadata } = props.post.post;
if (metadata.labels?.[postLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/posts/${metadata.name}`;
});
const publishStatus = computed(() => {
const { labels } = props.post.post.metadata;
return labels?.[postLabels.PUBLISHED] === "true"
? t("core.post.filters.status.items.published")
: t("core.post.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
async function handlePublish() {
await apiClient.uc.post.publishMyPost({
name: props.post.post.metadata.name,
});
Toast.success(t("core.common.toast.publish_success"));
queryClient.invalidateQueries({ queryKey: ["my-posts"] });
}
function handleUnpublish() {
Dialog.warning({
title: "取消发布",
description: "确定要取消发布吗?",
async onConfirm() {
await apiClient.uc.post.unpublishMyPost({
name: props.post.post.metadata.name,
});
Toast.success(t("core.common.toast.cancel_publish_success"));
queryClient.invalidateQueries({ queryKey: ["my-posts"] });
},
});
}
</script>
<template>
<VEntity>
<template #start>
<VEntityField
:title="post.post.spec.title"
:route="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
width="27rem"
>
<template #extra>
<VSpace class="mt-1 sm:mt-0">
<RouterLink
v-if="post.post.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
class="flex items-center"
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
>
<VStatusDot state="success" animate />
</RouterLink>
<a
target="_blank"
:href="externalUrl"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
<template #description>
<div class="flex flex-col gap-1.5">
<VSpace class="flex-wrap !gap-y-1">
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
{{ $t("core.post.list.fields.categories") }}
<a
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
:href="category.status?.permalink"
:title="category.status?.permalink"
target="_blank"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</a>
</p>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
<span v-if="post.post.spec.pinned" class="text-xs text-gray-500">
{{ $t("core.post.list.fields.pinned") }}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<VAvatar
v-for="{ name, avatar, displayName } in post.contributors"
:key="name"
v-tooltip="displayName"
size="xs"
:src="avatar"
:alt="displayName"
circle
></VAvatar>
</template>
</VEntityField>
<VEntityField :description="publishStatus">
<template v-if="isPublishing" #description>
<VStatusDot :text="$t('core.common.tooltips.publishing')" animate />
</template>
</VEntityField>
<VEntityField>
<template #description>
<IconEye
v-if="post.post.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.post.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<IconEyeOff
v-if="post.post.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.post.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<StatusDotField
v-if="props.post.post.spec.deleted"
:tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
<VEntityField
v-if="post.post.spec.publishTime"
:description="formatDatetime(post.post.spec.publishTime)"
></VEntityField>
</template>
<template #dropdownItems>
<VDropdownItem
@click="
$router.push({
name: 'PostEditor',
query: { name: post.post.metadata.name },
})
"
>
编辑
</VDropdownItem>
<HasPermission :permissions="['uc:posts:publish']">
<VDropdownItem
v-if="post.post.metadata.labels?.[postLabels.PUBLISHED] === 'false'"
@click="handlePublish"
>
发布
</VDropdownItem>
<VDropdownItem v-else type="danger" @click="handleUnpublish">
取消发布
</VDropdownItem>
</HasPermission>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,131 @@
<script lang="ts" setup>
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import PostSettingForm from "./PostSettingForm.vue";
import type { PostFormState } from "../types";
import type { Post } from "@halo-dev/api-client";
import { onMounted } from "vue";
import { nextTick, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation } from "@tanstack/vue-query";
import { apiClient } from "@/utils/api-client";
import { toDatetimeLocal } from "@/utils/date";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
post: Post;
}>(),
{}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "success", post: Post): void;
}>();
// fixme: refactor VModal component
const shouldRender = ref(false);
const visible = ref(false);
onMounted(() => {
shouldRender.value = true;
nextTick(() => {
visible.value = true;
});
});
function onClose() {
visible.value = false;
setTimeout(() => {
shouldRender.value = false;
emit("close");
}, 200);
}
const { mutate, isLoading } = useMutation({
mutationKey: ["edit-post"],
mutationFn: async ({ data }: { data: PostFormState }) => {
const postToUpdate: Post = {
...props.post,
spec: {
...props.post.spec,
allowComment: data.allowComment,
categories: data.categories,
cover: data.cover,
excerpt: {
autoGenerate: data.excerptAutoGenerate,
raw: data.excerptRaw,
},
pinned: data.pinned,
publishTime: data.publishTime,
slug: data.slug,
tags: data.tags,
title: data.title,
visible: data.visible,
},
};
const { data: updatedPost } = await apiClient.uc.post.updateMyPost({
name: props.post.metadata.name,
post: postToUpdate,
});
return updatedPost;
},
onSuccess(data) {
Toast.success(t("core.common.toast.save_success"));
emit("success", data);
emit("close");
},
onError() {
Toast.error(t("core.common.toast.save_failed_and_retry"));
},
});
function onSubmit(data: PostFormState) {
mutate({ data });
}
</script>
<template>
<VModal
v-if="shouldRender"
v-model:visible="visible"
title="文章设置"
:width="700"
centered
@close="onClose"
>
<PostSettingForm
:form-state="{
title: props.post.spec.title,
slug: props.post.spec.slug,
cover: props.post.spec.cover,
categories: props.post.spec.categories,
tags: props.post.spec.tags,
allowComment: props.post.spec.allowComment,
visible: props.post.spec.visible,
pinned: props.post.spec.pinned,
publishTime: props.post.spec.publishTime
? toDatetimeLocal(props.post.spec.publishTime)
: undefined,
excerptAutoGenerate: props.post.spec.excerpt.autoGenerate,
excerptRaw: props.post.spec.excerpt.raw,
}"
update-mode
@submit="onSubmit"
/>
<template #footer>
<VSpace>
<VButton
:loading="isLoading"
type="secondary"
@click="$formkit.submit('post-setting-form')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton type="default" @click="onClose()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,204 @@
<script lang="ts" setup>
import { IconRefreshLine } from "@halo-dev/components";
import type { PostFormState } from "../types";
import { toISOString } from "@/utils/date";
import { computed } from "vue";
import useSlugify from "@console/composables/use-slugify";
import { FormType } from "@/types/slug";
import { ref } from "vue";
import HasPermission from "@/components/permission/HasPermission.vue";
const props = withDefaults(
defineProps<{
formState?: PostFormState;
updateMode?: boolean;
}>(),
{
formState: undefined,
updateMode: false,
}
);
const internalFormState = ref<PostFormState>(
props.formState || {
title: "",
slug: "",
categories: [],
tags: [],
excerptAutoGenerate: true,
excerptRaw: "",
allowComment: true,
pinned: false,
visible: "PUBLIC",
publishTime: undefined,
cover: undefined,
}
);
const emit = defineEmits<{
(event: "submit", data: PostFormState): void;
}>();
function onSubmit(data: PostFormState) {
emit("submit", {
...data,
publishTime: data.publishTime ? toISOString(data.publishTime) : undefined,
});
}
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => internalFormState.value?.title || ""),
computed({
get() {
return internalFormState.value?.slug || "";
},
set(value) {
internalFormState.value.slug = value;
},
}),
computed(() => !props.updateMode),
FormType.POST
);
</script>
<template>
<FormKit
id="post-setting-form"
v-model="internalFormState"
type="form"
name="post-setting-form"
:config="{ validationVisibility: 'submit' }"
@submit="onSubmit"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post.settings.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
:label="$t('core.post.settings.fields.title.label')"
type="text"
name="title"
validation="required|length:0,100"
></FormKit>
<FormKit
:label="$t('core.post.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
:help="$t('core.post.settings.fields.slug.help')"
>
<template #suffix>
<div
v-tooltip="$t('core.post.settings.fields.slug.refresh_message')"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug(true, FormType.POST)"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
:label="$t('core.post.settings.fields.categories.label')"
name="categories"
type="categorySelect"
:multiple="true"
/>
<FormKit
:label="$t('core.post.settings.fields.tags.label')"
name="tags"
type="tagSelect"
:multiple="true"
/>
<FormKit
:value="true"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="excerptAutoGenerate"
:label="$t('core.post.settings.fields.auto_generate_excerpt.label')"
type="radio"
>
</FormKit>
<FormKit
v-if="!internalFormState.excerptAutoGenerate"
:label="$t('core.post.settings.fields.raw_excerpt.label')"
name="excerptRaw"
type="textarea"
:rows="5"
validation="length:0,1024"
></FormKit>
</div>
</div>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post.settings.groups.advanced") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
name="allowComment"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
:label="$t('core.post.settings.fields.allow_comment.label')"
type="radio"
></FormKit>
<FormKit
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
:label="$t('core.post.settings.fields.pinned.label')"
name="pinned"
type="radio"
></FormKit>
<FormKit
:options="[
{ label: $t('core.common.select.public'), value: 'PUBLIC' },
{
label: $t('core.common.select.private'),
value: 'PRIVATE',
},
]"
:label="$t('core.post.settings.fields.visible.label')"
name="visible"
type="select"
></FormKit>
<FormKit
name="publishTime"
:label="$t('core.post.settings.fields.publish_time.label')"
type="datetime-local"
></FormKit>
<HasPermission :permissions="['system:attachments:view']">
<FormKit
name="cover"
:label="$t('core.post.settings.fields.cover.label')"
type="attachment"
:accepts="['image/*']"
validation="length:0,1024"
></FormKit>
</HasPermission>
</div>
</div>
</div>
</FormKit>
</template>

View File

@ -0,0 +1,44 @@
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@uc/layouts/BasicLayout.vue";
import { IconBookRead } from "@halo-dev/components";
import PostList from "./PostList.vue";
import { markRaw } from "vue";
import PostEditor from "./PostEditor.vue";
export default definePlugin({
ucRoutes: [
{
path: "/posts",
component: BasicLayout,
children: [
{
path: "",
name: "Posts",
component: PostList,
meta: {
title: "core.post.title",
searchable: true,
permissions: ["uc:posts:manage"],
menu: {
name: "core.sidebar.menu.items.posts",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
mobile: true,
},
},
},
{
path: "editor",
name: "PostEditor",
component: PostEditor,
meta: {
title: "文章编辑",
searchable: true,
permissions: ["uc:posts:manage"],
},
},
],
},
],
});

View File

@ -0,0 +1,15 @@
import type { PostSpecVisibleEnum } from "packages/api-client/dist";
export interface PostFormState {
title: string;
slug: string;
categories?: string[];
tags?: string[];
excerptAutoGenerate: boolean;
excerptRaw?: string;
allowComment: boolean;
pinned: boolean;
visible: PostSpecVisibleEnum;
publishTime?: string;
cover?: string;
}

View File

@ -19,6 +19,7 @@ export default definePlugin({
searchable: true,
menu: {
name: "消息",
group: "dashboard",
icon: markRaw(IconNotificationBadgeLine),
priority: 1,
mobile: true,

View File

@ -21,6 +21,7 @@ export default definePlugin({
searchable: true,
menu: {
name: "我的",
group: "dashboard",
icon: markRaw(IconAccountCircleLine),
priority: 0,
mobile: true,