Skip to content

Commit

Permalink
feat: add scheduled post publishing feature (#5940)
Browse files Browse the repository at this point in the history
#### What type of PR is this?
/kind feature
/area core
/milestone 2.16.x

#### What this PR does / why we need it:
新增文章定时发布功能

#### Which issue(s) this PR fixes:
Fixes #4602

#### Does this PR introduce a user-facing change?
```release-note
新增文章定时发布功能
```
  • Loading branch information
guqing committed May 24, 2024
1 parent de85156 commit c1e8bdb
Show file tree
Hide file tree
Showing 19 changed files with 250 additions and 72 deletions.
7 changes: 7 additions & 0 deletions api-docs/openapi/v3_0/aggregated.json
Original file line number Diff line number Diff line change
Expand Up @@ -3109,6 +3109,13 @@
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "async",
"schema": {
"type": "boolean"
}
}
],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public class Post extends AbstractExtension {

public static final String STATS_ANNO = "content.halo.run/stats";

/**
* <p>The key of the label that indicates that the post is scheduled to be published.</p>
* <p>Can be used to query posts that are scheduled to be published.</p>
*/
public static final String SCHEDULING_PUBLISH_LABEL = "content.halo.run/scheduling-publish";

public static final String DELETED_LABEL = "content.halo.run/deleted";
public static final String PUBLISHED_LABEL = "content.halo.run/published";
public static final String OWNER_LABEL = "content.halo.run/owner";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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 run.halo.app.extension.MetadataUtil.nullSafeLabels;

import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import java.util.Objects;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.dao.OptimisticLockingFailureException;
Expand Down Expand Up @@ -199,6 +202,11 @@ public RouterFunction<ServerResponse> endpoint() {
.description("Head snapshot name of content.")
.in(ParameterIn.QUERY)
.required(false))
.parameter(parameterBuilder()
.name("async")
.in(ParameterIn.QUERY)
.implementation(Boolean.class)
.required(false))
.response(responseBuilder()
.implementation(Post.class))
)
Expand Down Expand Up @@ -319,6 +327,7 @@ Mono<ServerResponse> publishPost(ServerRequest request) {
boolean asyncPublish = request.queryParam("async")
.map(Boolean::parseBoolean)
.orElse(false);

return Mono.defer(() -> client.get(Post.class, name)
.doOnNext(post -> {
var spec = post.getSpec();
Expand All @@ -327,7 +336,6 @@ Mono<ServerResponse> publishPost(ServerRequest request) {
if (spec.getHeadSnapshot() == null) {
spec.setHeadSnapshot(spec.getBaseSnapshot());
}
// TODO Provide release snapshot query param to control
spec.setReleaseSnapshot(spec.getHeadSnapshot());
})
.flatMap(client::update)
Expand All @@ -342,12 +350,17 @@ Mono<ServerResponse> publishPost(ServerRequest request) {
}

private Mono<Post> awaitPostPublished(String postName) {
Predicate<Post> schedulePublish = post -> {
var labels = nullSafeLabels(post);
return BooleanUtils.TRUE.equals(labels.get(Post.SCHEDULING_PUBLISH_LABEL));
};
return Mono.defer(() -> client.get(Post.class, postName)
.filter(post -> {
var releasedSnapshot = MetadataUtil.nullSafeAnnotations(post)
.get(Post.LAST_RELEASED_SNAPSHOT_ANNO);
var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot();
return Objects.equals(releasedSnapshot, expectReleaseSnapshot);
return Objects.equals(releasedSnapshot, expectReleaseSnapshot)
|| schedulePublish.test(post);
})
.switchIfEmpty(Mono.error(
() -> new RetryException("Retry to check post publish status"))))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package run.halo.app.core.extension.reconciler;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.BooleanUtils.TRUE;
import static org.apache.commons.lang3.BooleanUtils.isFalse;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
import static run.halo.app.extension.MetadataUtil.nullSafeLabels;
import static run.halo.app.extension.index.query.QueryFactory.equal;

import com.google.common.hash.Hashing;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
Expand Down Expand Up @@ -46,6 +51,7 @@
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.RequeueException;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.Condition;
Expand Down Expand Up @@ -96,26 +102,13 @@ public Result reconcile(Request request) {
events.forEach(eventPublisher::publishEvent);
return;
}

addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));

subscribeNewCommentNotification(post);
populateLabels(post);

var labels = post.getMetadata().getLabels();
if (labels == null) {
labels = new HashMap<>();
post.getMetadata().setLabels(labels);
}

var annotations = post.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
post.getMetadata().setAnnotations(annotations);
}
schedulePublishIfNecessary(post);

if (!annotations.containsKey(Post.PUBLISHED_LABEL)) {
labels.put(Post.PUBLISHED_LABEL, BooleanUtils.FALSE);
}
subscribeNewCommentNotification(post);

var status = post.getStatus();
if (status == null) {
Expand All @@ -131,44 +124,20 @@ public Result reconcile(Request request) {
var configSha256sum = Hashing.sha256().hashString(post.getSpec().toString(), UTF_8)
.toString();

var annotations = nullSafeAnnotations(post);
var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO);
if (!Objects.equals(oldConfigChecksum, configSha256sum)) {
// if the checksum doesn't match
events.add(new PostUpdatedEvent(this, post.getMetadata().getName()));
annotations.put(Constant.CHECKSUM_CONFIG_ANNO, configSha256sum);
}

var expectDelete = defaultIfNull(post.getSpec().getDeleted(), false);
var expectPublish = defaultIfNull(post.getSpec().getPublish(), false);

if (expectDelete || !expectPublish) {
if (shouldUnPublish(post)) {
unPublishPost(post, events);
} else {
publishPost(post, events);
}

labels.put(Post.DELETED_LABEL, expectDelete.toString());

var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC);
var oldVisible = VisibleEnum.from(labels.get(Post.VISIBLE_LABEL));
if (!Objects.equals(oldVisible, expectVisible)) {
eventPublisher.publishEvent(
new PostVisibleChangedEvent(request.name(), oldVisible, expectVisible));
}
labels.put(Post.VISIBLE_LABEL, expectVisible.toString());

var ownerName = post.getSpec().getOwner();
if (StringUtils.isNotBlank(ownerName)) {
labels.put(Post.OWNER_LABEL, ownerName);
}

var publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}

var permalinkPattern = postPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern);

Expand All @@ -195,7 +164,6 @@ public Result reconcile(Request request) {
status.setExcerpt(excerpt.getRaw());
}


var ref = Ref.of(post);
// handle contributors
var headSnapshot = post.getSpec().getHeadSnapshot();
Expand Down Expand Up @@ -227,19 +195,75 @@ public Result reconcile(Request request) {
return Result.doNotRetry();
}

private void populateLabels(Post post) {
var labels = nullSafeLabels(post);
labels.put(Post.DELETED_LABEL, String.valueOf(isTrue(post.getSpec().getDeleted())));

var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC);
var oldVisible = VisibleEnum.from(labels.get(Post.VISIBLE_LABEL));
if (!Objects.equals(oldVisible, expectVisible)) {
var postName = post.getMetadata().getName();
eventPublisher.publishEvent(
new PostVisibleChangedEvent(postName, oldVisible, expectVisible));
}
labels.put(Post.VISIBLE_LABEL, expectVisible.toString());

var ownerName = post.getSpec().getOwner();
if (StringUtils.isNotBlank(ownerName)) {
labels.put(Post.OWNER_LABEL, ownerName);
}

var publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}

if (!labels.containsKey(Post.PUBLISHED_LABEL)) {
labels.put(Post.PUBLISHED_LABEL, BooleanUtils.FALSE);
}
}

private static boolean shouldUnPublish(Post post) {
return isTrue(post.getSpec().getDeleted()) || isFalse(post.getSpec().getPublish());
}

@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Post())
.onAddMatcher(DefaultExtensionMatcher.builder(client, Post.GVK)
.fieldSelector(FieldSelector.of(
equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, BooleanUtils.TRUE))
equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, TRUE))
)
.build()
)
.build();
}

void schedulePublishIfNecessary(Post post) {
var labels = nullSafeLabels(post);
// ensure the label is removed
labels.remove(Post.SCHEDULING_PUBLISH_LABEL);

final var now = Instant.now();
var publishTime = post.getSpec().getPublishTime();
if (post.isPublished() || publishTime == null) {
return;
}

// expect to publish in the future
if (isTrue(post.getSpec().getPublish()) && publishTime.isAfter(now)) {
labels.put(Post.SCHEDULING_PUBLISH_LABEL, TRUE);
// update post changes before requeue
client.update(post);

throw new RequeueException(Result.requeue(Duration.between(now, publishTime)),
"Requeue for scheduled publish.");
}
}

void subscribeNewCommentNotification(Post post) {
var subscriber = new Subscription.Subscriber();
subscriber.setName(post.getSpec().getOwner());
Expand Down
16 changes: 10 additions & 6 deletions ui/console-src/modules/contents/posts/PostList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,7 @@ const {
}
if (selectedPublishStatus.value !== undefined) {
labelSelector.push(
`${postLabels.PUBLISHED}=${selectedPublishStatus.value}`
);
labelSelector.push(selectedPublishStatus.value);
}
const { data } = await apiClient.post.listPosts({
Expand All @@ -158,7 +156,9 @@ const {
const { spec, metadata, status } = post.post;
return (
spec.deleted ||
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
Expand Down Expand Up @@ -357,11 +357,15 @@ watch(selectedPostNames, (newValue) => {
},
{
label: t('core.post.filters.status.items.published'),
value: 'true',
value: `${postLabels.PUBLISHED}=true`,
},
{
label: t('core.post.filters.status.items.draft'),
value: 'false',
value: `${postLabels.PUBLISHED}=false`,
},
{
label: t('core.post.filters.status.items.scheduling'),
value: `${postLabels.SCHEDULING_PUBLISH}=true`,
},
]"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import {
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
} from "@halo-dev/components";
import { formatDatetime } from "@/utils/date";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { usePermission } from "@/utils/permission";
Expand All @@ -26,6 +24,7 @@ import ContributorsField from "./entity-fields/ContributorsField.vue";
import PublishStatusField from "./entity-fields/PublishStatusField.vue";
import VisibleField from "./entity-fields/VisibleField.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import PublishTimeField from "./entity-fields/PublishTimeField.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
Expand Down Expand Up @@ -160,9 +159,9 @@ const { startFields, endFields } = useEntityFieldItemExtensionPoint<ListedPost>(
{
priority: 50,
position: "end",
component: markRaw(VEntityField),
component: markRaw(PublishTimeField),
props: {
description: formatDatetime(props.post.post.spec.publishTime),
post: props.post,
},
},
])
Expand Down

0 comments on commit c1e8bdb

Please sign in to comment.