+ * The request URI contains the pagination arguments. For example, the request URI is /console/categories/1/10/20, means
+ * the current page is 1, the page size is 10, and the window size is 20.
+ *
+ *
+ * The request URI contains the pagination arguments. For example, the
+ * request URI is /console/comments/1/10/20, means the current page is 1, the
+ * page size is 10, and the window size is 20.
+ *
+ * comments = commentQueryService.getComments(articleId);
+
+ ret.put(Comment.COMMENTS, comments);
+ ret.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/ConsoleAdminAuthAdvice.java b/src/main/java/org/b3log/solo/processor/console/ConsoleAdminAuthAdvice.java
new file mode 100644
index 00000000..48ec2fa5
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/ConsoleAdminAuthAdvice.java
@@ -0,0 +1,50 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Singleton;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.advice.ProcessAdvice;
+import org.b3log.latke.servlet.advice.RequestProcessAdviceException;
+import org.b3log.solo.util.Solos;
+import org.json.JSONObject;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * The common auth check before advice for admin console.
+ *
+ * @author Liang Ding
+ * @version 1.0.1.3, Oct 5, 2018
+ * @since 2.9.5
+ */
+@Singleton
+public class ConsoleAdminAuthAdvice extends ProcessAdvice {
+
+ @Override
+ public void doAdvice(final RequestContext context) throws RequestProcessAdviceException {
+ if (!Solos.isAdminLoggedIn(context)) {
+ final JSONObject exception401 = new JSONObject();
+ exception401.put(Keys.MSG, "Unauthorized to request [" + context.requestURI() + "], please signin using admin account");
+ exception401.put(Keys.STATUS_CODE, HttpServletResponse.SC_UNAUTHORIZED);
+
+ throw new RequestProcessAdviceException(exception401);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/ConsoleAuthAdvice.java b/src/main/java/org/b3log/solo/processor/console/ConsoleAuthAdvice.java
new file mode 100644
index 00000000..6cb441f8
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/ConsoleAuthAdvice.java
@@ -0,0 +1,62 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Singleton;
+import org.b3log.latke.model.Role;
+import org.b3log.latke.model.User;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.advice.ProcessAdvice;
+import org.b3log.latke.servlet.advice.RequestProcessAdviceException;
+import org.b3log.solo.util.Solos;
+import org.json.JSONObject;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * The common auth check before advice for admin console.
+ *
+ * @author Liang Ding
+ * @version 1.0.1.3, Feb 7, 2019
+ * @since 2.9.5
+ */
+@Singleton
+public class ConsoleAuthAdvice extends ProcessAdvice {
+
+ @Override
+ public void doAdvice(final RequestContext context) throws RequestProcessAdviceException {
+ final JSONObject currentUser = Solos.getCurrentUser(context.getRequest(), context.getResponse());
+ if (null == currentUser) {
+ final JSONObject exception401 = new JSONObject();
+ exception401.put(Keys.MSG, "Unauthorized to request [" + context.requestURI() + "], please signin");
+ exception401.put(Keys.STATUS_CODE, HttpServletResponse.SC_UNAUTHORIZED);
+
+ throw new RequestProcessAdviceException(exception401);
+ }
+
+ final String userRole = currentUser.optString(User.USER_ROLE);
+ if (Role.VISITOR_ROLE.equals(userRole)) {
+ final JSONObject exception403 = new JSONObject();
+ exception403.put(Keys.MSG, "Forbidden to request [" + context.requestURI() + "]");
+ exception403.put(Keys.STATUS_CODE, HttpServletResponse.SC_FORBIDDEN);
+
+ throw new RequestProcessAdviceException(exception403);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/ConsoleRenderer.java b/src/main/java/org/b3log/solo/processor/console/ConsoleRenderer.java
new file mode 100644
index 00000000..7585fbf9
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/ConsoleRenderer.java
@@ -0,0 +1,63 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import freemarker.template.Template;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.renderer.AbstractFreeMarkerRenderer;
+import org.b3log.solo.util.Skins;
+
+/**
+ * FreeMarker HTTP response renderer for administrator console.
+ *
+ * @author Liang Ding
+ * @version 1.0.1.6, Feb 7, 2019
+ * @since 0.4.1
+ */
+public final class ConsoleRenderer extends AbstractFreeMarkerRenderer {
+
+ /**
+ * HTTP servlet request context.
+ */
+ private final RequestContext context;
+
+ /**
+ * Constructs a skin renderer with the specified request context and template name.
+ *
+ * @param context the specified request context
+ * @param templateName the specified template name
+ */
+ public ConsoleRenderer(final RequestContext context, final String templateName) {
+ this.context = context;
+ this.context.setRenderer(this);
+ setTemplateName("admin/" + templateName);
+ }
+
+ @Override
+ protected Template getTemplate() {
+ return Skins.getTemplate(getTemplateName());
+ }
+
+ @Override
+ protected void beforeRender(final RequestContext context) {
+ }
+
+ @Override
+ protected void afterRender(final RequestContext context) {
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/LinkConsole.java b/src/main/java/org/b3log/solo/processor/console/LinkConsole.java
new file mode 100644
index 00000000..60c3e90b
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/LinkConsole.java
@@ -0,0 +1,348 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.model.Common;
+import org.b3log.solo.model.Link;
+import org.b3log.solo.service.LinkMgmtService;
+import org.b3log.solo.service.LinkQueryService;
+import org.b3log.solo.util.Solos;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Link console request processing.
+ *
+ * @author Liang Ding
+ * @version 1.0.1.4, Dec 11, 2018
+ * @since 0.4.0
+ */
+@RequestProcessor
+@Before(ConsoleAdminAuthAdvice.class)
+public class LinkConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(LinkConsole.class);
+
+ /**
+ * Link query service.
+ */
+ @Inject
+ private LinkQueryService linkQueryService;
+
+ /**
+ * Link management service.
+ */
+ @Inject
+ private LinkMgmtService linkMgmtService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Removes a link by the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void removeLink(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+
+ try {
+ final String linkId = context.pathVar("id");
+ linkMgmtService.removeLink(linkId);
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeSuccLabel"));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ jsonObject.put(Keys.STATUS_CODE, false);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeFailLabel"));
+ }
+ }
+
+ /**
+ * Updates a link by the specified request.
+ *
+ * Request json:
+ *
+ * {
+ * "link": {
+ * "oId": "",
+ * "linkTitle": "",
+ * "linkAddress": "",
+ * "linkDescription": ""
+ * }
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void updateLink(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSON = context.requestJSON();
+ linkMgmtService.updateLink(requestJSON);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ renderer.setJSONObject(ret);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Changes a link order by the specified link id and direction.
+ *
+ * Request json:
+ *
+ * {
+ * "oId": "",
+ * "direction": "" // "up"/"down"
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void changeOrder(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSON = context.requestJSON();
+ final String linkId = requestJSON.getString(Keys.OBJECT_ID);
+ final String direction = requestJSON.getString(Common.DIRECTION);
+
+ linkMgmtService.changeOrder(linkId, direction);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ renderer.setJSONObject(ret);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Adds a link with the specified request.
+ *
+ *
+ * {
+ * "link": {
+ * "linkTitle": "",
+ * "linkAddress": "",
+ * "linkDescription": ""
+ * }
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "oId": "", // Generated link id
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void addLink(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSON = context.requestJSON();
+ final String linkId = linkMgmtService.addLink(requestJSON);
+
+ ret.put(Keys.OBJECT_ID, linkId);
+ ret.put(Keys.MSG, langPropsService.get("addSuccLabel"));
+ ret.put(Keys.STATUS_CODE, true);
+ renderer.setJSONObject(ret);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("addFailLabel"));
+ }
+ }
+
+ /**
+ * Gets links by the specified request.
+ *
+ * The request URI contains the pagination arguments. For example, the
+ * request URI is /console/links/1/10/20, means the current page is 1, the
+ * page size is 10, and the window size is 20.
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "pagination": {
+ * "paginationPageCount": 100,
+ * "paginationPageNums": [1, 2, 3, 4, 5]
+ * },
+ * "links": [{
+ * "oId": "",
+ * "linkTitle": "",
+ * "linkAddress": "",
+ * "linkDescription": ""
+ * }, ....]
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getLinks(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final String requestURI = context.requestURI();
+ final String path = requestURI.substring((Latkes.getContextPath() + "/console/links/").length());
+ final JSONObject requestJSONObject = Solos.buildPaginationRequest(path);
+ final JSONObject result = linkQueryService.getLinks(requestJSONObject);
+ result.put(Keys.STATUS_CODE, true);
+ renderer.setJSONObject(result);
+
+ final JSONArray links = result.optJSONArray(Link.LINKS);
+ for (int i = 0; i < links.length(); i++) {
+ final JSONObject link = links.optJSONObject(i);
+ String title = link.optString(Link.LINK_TITLE);
+ title = StringEscapeUtils.escapeXml(title);
+ link.put(Link.LINK_TITLE, title);
+ }
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * Gets the file with the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "link": {
+ * "oId": "",
+ * "linkTitle": "",
+ * "linkAddress": "",
+ * "linkDescription": ""
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getLink(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final String linkId = context.pathVar("id");
+ final JSONObject result = linkQueryService.getLink(linkId);
+ if (null == result) {
+ renderer.setJSONObject(new JSONObject().put(Keys.STATUS_CODE, false));
+
+ return;
+ }
+
+ renderer.setJSONObject(result);
+ result.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/OtherConsole.java b/src/main/java/org/b3log/solo/processor/console/OtherConsole.java
new file mode 100644
index 00000000..ab4a9ea4
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/OtherConsole.java
@@ -0,0 +1,129 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.service.ArchiveDateMgmtService;
+import org.b3log.solo.service.TagMgmtService;
+import org.json.JSONObject;
+
+/**
+ * Other console request processing.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.0, Mar 20, 2019
+ * @since 3.4.0
+ */
+@RequestProcessor
+public class OtherConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(OtherConsole.class);
+
+ /**
+ * Tag management service.
+ */
+ @Inject
+ private TagMgmtService tagMgmtService;
+
+ /**
+ * ArchiveDate maangement service.
+ */
+ @Inject
+ private ArchiveDateMgmtService archiveDateMgmtService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Removes all unused archives.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void removeUnusedArchives(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+
+ try {
+ archiveDateMgmtService.removeUnusedArchiveDates();
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeSuccLabel"));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Removes unused archives failed", e);
+
+ jsonObject.put(Keys.MSG, langPropsService.get("removeFailLabel"));
+ }
+ }
+
+ /**
+ * Removes all unused tags.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void removeUnusedTags(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+
+ try {
+ tagMgmtService.removeUnusedTags();
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeSuccLabel"));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Removes unused tags failed", e);
+
+ jsonObject.put(Keys.MSG, langPropsService.get("removeFailLabel"));
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/PageConsole.java b/src/main/java/org/b3log/solo/processor/console/PageConsole.java
new file mode 100644
index 00000000..33984f3b
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/PageConsole.java
@@ -0,0 +1,361 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.model.Common;
+import org.b3log.solo.model.Page;
+import org.b3log.solo.service.PageMgmtService;
+import org.b3log.solo.service.PageQueryService;
+import org.b3log.solo.service.UserQueryService;
+import org.b3log.solo.util.Solos;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Plugin console request processing.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.12, Apr 22, 2019
+ * @since 0.4.0
+ */
+@RequestProcessor
+@Before(ConsoleAdminAuthAdvice.class)
+public class PageConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(PageConsole.class);
+
+ /**
+ * User query service.
+ */
+ @Inject
+ private UserQueryService userQueryService;
+
+ /**
+ * Page query service.
+ */
+ @Inject
+ private PageQueryService pageQueryService;
+
+ /**
+ * Page management service.
+ */
+ @Inject
+ private PageMgmtService pageMgmtService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Updates a page by the specified request.
+ *
+ * Request json:
+ *
+ * {
+ * "page": {
+ * "oId": "",
+ * "pageTitle": "",
+ * "pageOrder": int,
+ * "pagePermalink": "",
+ * "pageOpenTarget": "",
+ * "pageIcon": ""
+ * }
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void updatePage(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSON = context.requestJSON();
+ pageMgmtService.updatePage(requestJSON);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ renderer.setJSONObject(ret);
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Removes a page by the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void removePage(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+
+ try {
+ final String pageId = context.pathVar("id");
+ pageMgmtService.removePage(pageId);
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeSuccLabel"));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ jsonObject.put(Keys.STATUS_CODE, false);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeFailLabel"));
+
+ }
+ }
+
+ /**
+ * Adds a page with the specified request.
+ *
+ * Request json:
+ *
+ * {
+ * "page": {
+ * "pageTitle": "",
+ * "pagePermalink": "" // optional
+ * "pageOpenTarget": "",
+ * "pageIcon": ""
+ * }
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "oId": "", // Generated page id
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void addPage(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSON = context.requestJSON();
+ final String pageId = pageMgmtService.addPage(requestJSON);
+
+ ret.put(Keys.OBJECT_ID, pageId);
+ ret.put(Keys.MSG, langPropsService.get("addSuccLabel"));
+ ret.put(Keys.STATUS_CODE, true);
+ renderer.setJSONObject(ret);
+ } catch (final ServiceException e) { // May be permalink check exception
+ LOGGER.log(Level.WARN, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Changes a page order by the specified page id and direction.
+ *
+ * Request json:
+ *
+ * {
+ * "oId": "",
+ * "direction": "" // "up"/"down"
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void changeOrder(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSONObject = context.requestJSON();
+ final String linkId = requestJSONObject.getString(Keys.OBJECT_ID);
+ final String direction = requestJSONObject.getString(Common.DIRECTION);
+
+ pageMgmtService.changeOrder(linkId, direction);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+
+ renderer.setJSONObject(ret);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Gets a page by the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean
+ * "page": {
+ * "oId": "",
+ * "pageTitle": "",
+ * "pageOrder": int,
+ * "pagePermalink": "",
+ * "pageIcon": ""
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getPage(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final String pageId = context.pathVar("id");
+ final JSONObject result = pageQueryService.getPage(pageId);
+ if (null == result) {
+ renderer.setJSONObject(new JSONObject().put(Keys.STATUS_CODE, false));
+
+ return;
+ }
+
+ renderer.setJSONObject(result);
+ result.put(Keys.STATUS_CODE, true);
+ result.put(Keys.MSG, langPropsService.get("getSuccLabel"));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * Gets pages by the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "pagination": {
+ * "paginationPageCount": 100,
+ * "paginationPageNums": [1, 2, 3, 4, 5]
+ * },
+ * "pages": [{
+ * "oId": "",
+ * "pageTitle": "",
+ * "pageOrder": int,
+ * "pagePermalink": "",
+ * .{@link PageMgmtService...}
+ * }, ....]
+ * "sc": "GET_PAGES_SUCC"
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getPages(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final String requestURI = context.requestURI();
+ final String path = requestURI.substring((Latkes.getContextPath() + "/console/pages/").length());
+ final JSONObject requestJSONObject = Solos.buildPaginationRequest(path);
+ final JSONObject result = pageQueryService.getPages(requestJSONObject);
+ final JSONArray pages = result.optJSONArray(Page.PAGES);
+
+ for (int i = 0; i < pages.length(); i++) {
+ final JSONObject page = pages.getJSONObject(i);
+ String title = page.optString(Page.PAGE_TITLE);
+ title = StringEscapeUtils.escapeXml(title);
+ page.put(Page.PAGE_TITLE, title);
+ }
+
+ result.put(Keys.STATUS_CODE, true);
+ renderer.setJSONObject(result);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/PluginConsole.java b/src/main/java/org/b3log/solo/processor/console/PluginConsole.java
new file mode 100644
index 00000000..891e1aba
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/PluginConsole.java
@@ -0,0 +1,191 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.Plugin;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.service.PluginMgmtService;
+import org.b3log.solo.service.PluginQueryService;
+import org.b3log.solo.util.Solos;
+import org.json.JSONObject;
+
+import java.util.Map;
+
+/**
+ * Plugin console request processing.
+ *
+ * @author Liang Ding
+ * @author Love Yao
+ * @version 1.1.0.5, Dec 11, 2018
+ * @since 0.4.0
+ */
+@RequestProcessor
+@Before(ConsoleAdminAuthAdvice.class)
+public class PluginConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(PluginConsole.class);
+
+ /**
+ * Plugin query service.
+ */
+ @Inject
+ private PluginQueryService pluginQueryService;
+
+ /**
+ * Plugin management service.
+ */
+ @Inject
+ private PluginMgmtService pluginMgmtService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Sets a plugin's status with the specified plugin id, status.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void setPluginStatus(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ final JSONObject requestJSONObject = context.requestJSON();
+ final String pluginId = requestJSONObject.getString(Keys.OBJECT_ID);
+ final String status = requestJSONObject.getString(Plugin.PLUGIN_STATUS);
+ final JSONObject result = pluginMgmtService.setPluginStatus(pluginId, status);
+
+ renderer.setJSONObject(result);
+ }
+
+ /**
+ * Gets plugins by the specified request.
+ *
+ * The request URI contains the pagination arguments. For example, the
+ * request URI is /console/plugins/1/10/20, means the current page is 1, the
+ * page size is 10, and the window size is 20.
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "pagination": {
+ * "paginationPageCount": 100,
+ * "paginationPageNums": [1, 2, 3, 4, 5]
+ * },
+ * "plugins": [{
+ * "name": "",
+ * "version": "",
+ * "author": "",
+ * "status": "", // Enumeration name of {@link org.b3log.latke.plugin.PluginStatus}
+ * }, ....]
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ * @throws Exception exception
+ */
+ public void getPlugins(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final String requestURI = context.requestURI();
+ final String path = requestURI.substring((Latkes.getContextPath() + "/console/plugins/").length());
+ final JSONObject requestJSONObject = Solos.buildPaginationRequest(path);
+ final JSONObject result = pluginQueryService.getPlugins(requestJSONObject);
+
+ renderer.setJSONObject(result);
+ result.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * get the info of the specified pluginoId,just fot the plugin-setting.
+ *
+ * @param context the specified request context
+ */
+ public void toSetting(final RequestContext context) {
+ final ConsoleRenderer renderer = new ConsoleRenderer(context, "admin-plugin-setting.ftl");
+ final Map dataModel = renderer.getDataModel();
+
+ try {
+ final JSONObject requestJSONObject = context.requestJSON();
+ final String pluginId = requestJSONObject.getString(Keys.OBJECT_ID);
+ final String setting = pluginQueryService.getPluginSetting(pluginId);
+ Keys.fillRuntime(dataModel);
+ dataModel.put(Plugin.PLUGIN_SETTING, setting);
+ dataModel.put(Keys.OBJECT_ID, pluginId);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ final JsonRenderer JsonRenderer = new JsonRenderer();
+ JsonRenderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * update the setting of the plugin.
+ *
+ * @param context the specified request context
+ */
+ public void updateSetting(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ final JSONObject requestJSONObject = context.requestJSON();
+ final String pluginoId = requestJSONObject.optString(Keys.OBJECT_ID);
+ final String settings = requestJSONObject.optString(Plugin.PLUGIN_SETTING);
+ final JSONObject ret = pluginMgmtService.updatePluginSetting(pluginoId, settings);
+
+ renderer.setJSONObject(ret);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/PreferenceConsole.java b/src/main/java/org/b3log/solo/processor/console/PreferenceConsole.java
new file mode 100644
index 00000000..5def703d
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/PreferenceConsole.java
@@ -0,0 +1,388 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.model.Option;
+import org.b3log.solo.model.Sign;
+import org.b3log.solo.service.OptionMgmtService;
+import org.b3log.solo.service.OptionQueryService;
+import org.b3log.solo.service.PreferenceMgmtService;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Preference console request processing.
+ *
+ * @author Liang Ding
+ * @author hzchendou
+ * @version 1.2.0.25, Jun 13, 2019
+ * @since 0.4.0
+ */
+@RequestProcessor
+@Before(ConsoleAdminAuthAdvice.class)
+public class PreferenceConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(PreferenceConsole.class);
+
+ /**
+ * Preference management service.
+ */
+ @Inject
+ private PreferenceMgmtService preferenceMgmtService;
+
+ /**
+ * Option management service.
+ */
+ @Inject
+ private OptionMgmtService optionMgmtService;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Gets signs.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "signs": [{
+ * "oId": "",
+ * "signHTML": ""
+ * }, ...]
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getSigns(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final JSONObject preference = optionQueryService.getPreference();
+ final JSONArray signs = new JSONArray();
+ final JSONArray allSigns = // includes the empty sign(id=0)
+ new JSONArray(preference.getString(Option.ID_C_SIGNS));
+
+ for (int i = 1; i < allSigns.length(); i++) { // excludes the empty sign
+ signs.put(allSigns.getJSONObject(i));
+ }
+
+ final JSONObject ret = new JSONObject();
+ renderer.setJSONObject(ret);
+ ret.put(Sign.SIGNS, signs);
+ ret.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * Gets preference.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "preference": {
+ * "mostViewArticleDisplayCount": int,
+ * "recentCommentDisplayCount": int,
+ * "mostUsedTagDisplayCount": int,
+ * "articleListDisplayCount": int,
+ * "articleListPaginationWindowSize": int,
+ * "mostCommentArticleDisplayCount": int,
+ * "externalRelevantArticlesDisplayCount": int,
+ * "relevantArticlesDisplayCount": int,
+ * "randomArticlesDisplayCount": int,
+ * "blogTitle": "",
+ * "blogSubtitle": "",
+ * "localeString": "",
+ * "timeZoneId": "",
+ * "skinDirName": "",
+ * "skins": "[{
+ * "skinDirName": ""
+ * }, ....]",
+ * "noticeBoard": "",
+ * "footerContent": "",
+ * "htmlHead": "",
+ * "metaKeywords": "",
+ * "metaDescription": "",
+ * "enableArticleUpdateHint": boolean,
+ * "signs": "[{
+ * "oId": "",
+ * "signHTML": ""
+ * }, ...]",
+ * "allowVisitDraftViaPermalink": boolean,
+ * "version": "",
+ * "articleListStyle": "", // Optional values: "titleOnly"/"titleAndContent"/"titleAndAbstract"
+ * "commentable": boolean,
+ * "feedOutputMode: "" // Optional values: "abstract"/"full"
+ * "feedOutputCnt": int,
+ * "faviconURL": "",
+ * "syncGitHub": boolean,
+ * "pullGitHub": boolean,
+ * "customVars" "", // 支持配置自定义参数 https://github.com/b3log/solo/issues/12535
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getPreference(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final JSONObject preference = optionQueryService.getPreference();
+ if (null == preference) {
+ renderer.setJSONObject(new JSONObject().put(Keys.STATUS_CODE, false));
+
+ return;
+ }
+
+ String footerContent = "";
+ final JSONObject opt = optionQueryService.getOptionById(Option.ID_C_FOOTER_CONTENT);
+ if (null != opt) {
+ footerContent = opt.optString(Option.OPTION_VALUE);
+ }
+ preference.put(Option.ID_C_FOOTER_CONTENT, footerContent);
+
+ final JSONObject ret = new JSONObject();
+ renderer.setJSONObject(ret);
+ ret.put(Option.CATEGORY_C_PREFERENCE, preference);
+ ret.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * Updates the preference by the specified request.
+ *
+ * Request json:
+ *
+ * {
+ * "preference": {
+ * "mostViewArticleDisplayCount": int,
+ * "recentCommentDisplayCount": int,
+ * "mostUsedTagDisplayCount": int,
+ * "articleListDisplayCount": int,
+ * "articleListPaginationWindowSize": int,
+ * "mostCommentArticleDisplayCount": int,
+ * "externalRelevantArticlesDisplayCount": int,
+ * "relevantArticlesDisplayCount": int,
+ * "randomArticlesDisplayCount": int,
+ * "blogTitle": "",
+ * "blogSubtitle": "",
+ * "localeString": "",
+ * "timeZoneId": "",
+ * "noticeBoard": "",
+ * "footerContent": "",
+ * "htmlHead": "",
+ * "metaKeywords": "",
+ * "metaDescription": "",
+ * "enableArticleUpdateHint": boolean,
+ * "signs": [{
+ * "oId": "",
+ * "signHTML": ""
+ * }, ...],
+ * "allowVisitDraftViaPermalink": boolean,
+ * "articleListStyle": "",
+ * "commentable": boolean,
+ * "feedOutputMode: "",
+ * "feedOutputCnt": int,
+ * "faviconURL": "",
+ * "syncGitHub": boolean,
+ * "pullGitHub": boolean,
+ * "customVars" "", // 支持配置自定义参数 https://github.com/b3log/solo/issues/12535
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void updatePreference(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final JSONObject requestJSONObject = context.requestJSON();
+ final JSONObject preference = requestJSONObject.getJSONObject(Option.CATEGORY_C_PREFERENCE);
+ final JSONObject ret = new JSONObject();
+ renderer.setJSONObject(ret);
+ if (isInvalid(preference, ret)) {
+ return;
+ }
+
+ preferenceMgmtService.updatePreference(preference);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Checks whether the specified preference is invalid and sets the specified response object.
+ *
+ * @param preference the specified preference
+ * @param responseObject the specified response object
+ * @return {@code true} if the specified preference is invalid, returns {@code false} otherwise
+ */
+ private boolean isInvalid(final JSONObject preference, final JSONObject responseObject) {
+ responseObject.put(Keys.STATUS_CODE, false);
+
+ final StringBuilder errMsgBuilder = new StringBuilder('[' + langPropsService.get("paramSettingsLabel"));
+ errMsgBuilder.append(" - ");
+
+ String input = preference.optString(Option.ID_C_EXTERNAL_RELEVANT_ARTICLES_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("externalRelevantArticlesDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_RELEVANT_ARTICLES_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("relevantArticlesDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_RANDOM_ARTICLES_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("randomArticlesDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_MOST_COMMENT_ARTICLE_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("indexMostCommentArticleDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_MOST_VIEW_ARTICLE_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("indexMostViewArticleDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_RECENT_COMMENT_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("indexRecentCommentDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_MOST_USED_TAG_DISPLAY_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("indexTagDisplayCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_ARTICLE_LIST_DISPLAY_COUNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("pageSizeLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_ARTICLE_LIST_PAGINATION_WINDOW_SIZE);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("windowSizeLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ input = preference.optString(Option.ID_C_FEED_OUTPUT_CNT);
+ if (!isNonNegativeInteger(input)) {
+ errMsgBuilder.append(langPropsService.get("feedOutputCntLabel")).append("] ")
+ .append(langPropsService.get("nonNegativeIntegerOnlyLabel"));
+ responseObject.put(Keys.MSG, errMsgBuilder.toString());
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks whether the specified input is a non-negative integer.
+ *
+ * @param input the specified input
+ * @return {@code true} if it is, returns {@code false} otherwise
+ */
+ private boolean isNonNegativeInteger(final String input) {
+ try {
+ return 0 <= Integer.valueOf(input);
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/RepairConsole.java b/src/main/java/org/b3log/solo/processor/console/RepairConsole.java
new file mode 100644
index 00000000..29cfdd5f
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/RepairConsole.java
@@ -0,0 +1,116 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.TextHtmlRenderer;
+import org.b3log.solo.model.Option;
+import org.b3log.solo.repository.ArticleRepository;
+import org.b3log.solo.repository.TagArticleRepository;
+import org.b3log.solo.repository.TagRepository;
+import org.b3log.solo.service.OptionQueryService;
+import org.b3log.solo.service.PreferenceMgmtService;
+import org.b3log.solo.service.StatisticMgmtService;
+import org.b3log.solo.service.StatisticQueryService;
+import org.json.JSONObject;
+
+/**
+ * Provides patches on some special issues.
+ *
+ * @author Liang Ding
+ * @version 1.2.0.21, Mar 3, 2019
+ * @since 0.3.1
+ */
+@RequestProcessor
+@Before(ConsoleAuthAdvice.class)
+public class RepairConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(RepairConsole.class);
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Preference management service.
+ */
+ @Inject
+ private PreferenceMgmtService preferenceMgmtService;
+
+ /**
+ * Tag repository.
+ */
+ @Inject
+ private TagRepository tagRepository;
+
+ /**
+ * Tag-Article repository.
+ */
+ @Inject
+ private TagArticleRepository tagArticleRepository;
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Statistic query service.
+ */
+ @Inject
+ private StatisticQueryService statisticQueryService;
+
+ /**
+ * Statistic management service.
+ */
+ @Inject
+ private StatisticMgmtService statisticMgmtService;
+
+ /**
+ * Restores the signs of preference to default.
+ *
+ * @param context the specified context
+ */
+ public void restoreSigns(final RequestContext context) {
+ final TextHtmlRenderer renderer = new TextHtmlRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final JSONObject preference = optionQueryService.getPreference();
+ preference.put(Option.ID_C_SIGNS, Option.DefaultPreference.DEFAULT_SIGNS);
+ preferenceMgmtService.updatePreference(preference);
+
+ renderer.setContent("Restore signs succeeded.");
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ renderer.setContent("Restores signs failed, error msg [" + e.getMessage() + "]");
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/SkinConsole.java b/src/main/java/org/b3log/solo/processor/console/SkinConsole.java
new file mode 100644
index 00000000..4f997b10
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/SkinConsole.java
@@ -0,0 +1,200 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.model.Common;
+import org.b3log.solo.model.Option;
+import org.b3log.solo.service.OptionQueryService;
+import org.b3log.solo.service.SkinMgmtService;
+import org.b3log.solo.util.Skins;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Set;
+
+/**
+ * Skin console request processing.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.0, Mar 29, 2019
+ * @since 3.5.0
+ */
+@RequestProcessor
+@Before(ConsoleAdminAuthAdvice.class)
+public class SkinConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(SkinConsole.class);
+
+ /**
+ * Skin management service.
+ */
+ @Inject
+ private SkinMgmtService skinMgmtService;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Gets skin.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "skin": {
+ * "skinDirName": "",
+ * "mobileSkinDirName": "",
+ * "skins": "[{
+ * "skinDirName": ""
+ * }, ....]"
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void getSkin(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final JSONObject skin = optionQueryService.getSkin();
+ if (null == skin) {
+ renderer.setJSONObject(new JSONObject().put(Keys.STATUS_CODE, false));
+
+ return;
+ }
+
+ final Set skinDirNames = Skins.getSkinDirNames();
+ final JSONArray skinArray = new JSONArray();
+ for (final String dirName : skinDirNames) {
+ final JSONObject s = new JSONObject();
+ final String name = Latkes.getSkinName(dirName);
+ if (null == name) {
+ LOGGER.log(Level.WARN, "The directory [{0}] does not contain any skin, ignored it", dirName);
+
+ continue;
+ }
+
+ s.put(Option.ID_C_SKIN_DIR_NAME, dirName);
+ skinArray.put(s);
+ }
+ skin.put("skins", skinArray.toString());
+
+ final JSONObject ret = new JSONObject();
+ renderer.setJSONObject(ret);
+ ret.put(Option.CATEGORY_C_SKIN, skin);
+ ret.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * Updates the skin by the specified request.
+ *
+ * Request json:
+ *
+ * {
+ * "skin": {
+ * "skinDirName": "",
+ * "mobileSkinDirName": "",
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ public void updateSkin(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final JSONObject requestJSONObject = context.requestJSON();
+ final JSONObject skin = requestJSONObject.getJSONObject(Option.CATEGORY_C_SKIN);
+ final JSONObject ret = new JSONObject();
+ renderer.setJSONObject(ret);
+
+ skinMgmtService.updateSkin(skin);
+
+ final HttpServletResponse response = context.getResponse();
+ final Cookie skinDirNameCookie = new Cookie(Common.COOKIE_NAME_SKIN, skin.getString(Option.ID_C_SKIN_DIR_NAME));
+ skinDirNameCookie.setMaxAge(60 * 60); // 1 hour
+ skinDirNameCookie.setPath("/");
+ response.addCookie(skinDirNameCookie);
+ final Cookie mobileSkinDirNameCookie = new Cookie(Common.COOKIE_NAME_MOBILE_SKIN, skin.getString(Option.ID_C_MOBILE_SKIN_DIR_NAME));
+ mobileSkinDirNameCookie.setMaxAge(60 * 60); // 1 hour
+ mobileSkinDirNameCookie.setPath("/");
+ response.addCookie(mobileSkinDirNameCookie);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Checks whether the specified input is a non-negative integer.
+ *
+ * @param input the specified input
+ * @return {@code true} if it is, returns {@code false} otherwise
+ */
+ private boolean isNonNegativeInteger(final String input) {
+ try {
+ return 0 <= Integer.valueOf(input);
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/TagConsole.java b/src/main/java/org/b3log/solo/processor/console/TagConsole.java
new file mode 100644
index 00000000..e7c2eafa
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/TagConsole.java
@@ -0,0 +1,139 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.model.Common;
+import org.b3log.solo.model.Tag;
+import org.b3log.solo.service.TagMgmtService;
+import org.b3log.solo.service.TagQueryService;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tag console request processing.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.3, Dec 11, 2018
+ * @since 0.4.0
+ */
+@RequestProcessor
+public class TagConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(TagConsole.class);
+
+ /**
+ * Tag query service.
+ */
+ @Inject
+ private TagQueryService tagQueryService;
+
+ /**
+ * Gets all tags.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "tags": [
+ * {"tagTitle": "", ....},
+ * ....
+ * ]
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAuthAdvice.class)
+ public void getTags(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+
+ try {
+ jsonObject.put(Tag.TAGS, tagQueryService.getTags());
+ jsonObject.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets tags failed", e);
+
+ jsonObject.put(Keys.STATUS_CODE, false);
+ }
+ }
+
+ /**
+ * Gets all unused tags.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "unusedTags": [
+ * {"tagTitle": "", ....},
+ * ....
+ * ]
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void getUnusedTags(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+
+ final List unusedTags = new ArrayList<>();
+
+ try {
+ jsonObject.put(Common.UNUSED_TAGS, unusedTags);
+
+ final List tags = tagQueryService.getTags();
+ for (int i = 0; i < tags.size(); i++) {
+ final JSONObject tag = tags.get(i);
+ final String tagId = tag.optString(Keys.OBJECT_ID);
+ final int articleCount = tagQueryService.getArticleCount(tagId);
+ if (1 > articleCount) {
+ unusedTags.add(tag);
+ }
+ }
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets unused tags failed", e);
+
+ jsonObject.put(Keys.STATUS_CODE, false);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/UserConsole.java b/src/main/java/org/b3log/solo/processor/console/UserConsole.java
new file mode 100644
index 00000000..bd759064
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/UserConsole.java
@@ -0,0 +1,283 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.processor.console;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.User;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.servlet.annotation.Before;
+import org.b3log.latke.servlet.annotation.RequestProcessor;
+import org.b3log.latke.servlet.renderer.JsonRenderer;
+import org.b3log.solo.service.UserMgmtService;
+import org.b3log.solo.service.UserQueryService;
+import org.b3log.solo.util.Solos;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * User console request processing.
+ *
+ * @author Liang Ding
+ * @author DASHU
+ * @version 1.2.1.7, Mar 29, 2019
+ * @since 0.4.0
+ */
+@RequestProcessor
+public class UserConsole {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(UserConsole.class);
+
+ /**
+ * User query service.
+ */
+ @Inject
+ private UserQueryService userQueryService;
+
+ /**
+ * User management service.
+ */
+ @Inject
+ private UserMgmtService userMgmtService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Updates a user by the specified request.
+ *
+ *
+ * Request json:
+ *
+ * {
+ * "oId": "",
+ * "userName": "",
+ * "userRole": "",
+ * "userURL": "",
+ * "userAvatar": "",
+ * "userB3Key": ""
+ * }
+ *
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void updateUser(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject requestJSONObject = context.requestJSON();
+ userMgmtService.updateUser(requestJSONObject);
+
+ ret.put(Keys.STATUS_CODE, true);
+ ret.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ renderer.setJSONObject(ret);
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateFailLabel"));
+ }
+ }
+
+ /**
+ * Removes a user by the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void removeUser(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+ try {
+ final String userId = context.pathVar("id");
+ userMgmtService.removeUser(userId);
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeSuccLabel"));
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ jsonObject.put(Keys.STATUS_CODE, false);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeFailLabel"));
+ }
+ }
+
+ /**
+ * Gets users by the specified request json object.
+ *
+ * The request URI contains the pagination arguments. For example, the request URI is /console/users/1/10/20, means
+ * the current page is 1, the page size is 10, and the window size is 20.
+ *
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "pagination": {
+ * "paginationPageCount": 100,
+ * "paginationPageNums": [1, 2, 3, 4, 5]
+ * },
+ * "users": [{
+ * "oId": "",
+ * "userName": "",
+ * "roleName": "",
+ * ....
+ * }, ....]
+ * "sc": true
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void getUsers(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+
+ try {
+ final String requestURI = context.requestURI();
+ final String path = requestURI.substring((Latkes.getContextPath() + "/console/users/").length());
+ final JSONObject requestJSONObject = Solos.buildPaginationRequest(path);
+ final JSONObject result = userQueryService.getUsers(requestJSONObject);
+ result.put(Keys.STATUS_CODE, true);
+ renderer.setJSONObject(result);
+
+ final JSONArray users = result.optJSONArray(User.USERS);
+ for (int i = 0; i < users.length(); i++) {
+ final JSONObject user = users.optJSONObject(i);
+ String userName = user.optString(User.USER_NAME);
+ userName = StringEscapeUtils.escapeXml(userName);
+ user.put(User.USER_NAME, userName);
+ }
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+ }
+ }
+
+ /**
+ * Gets a user by the specified request.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "user": {
+ * "oId": "",
+ * "userName": "",
+ * "userAvatar": ""
+ * }
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void getUser(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final String userId = context.pathVar("id");
+
+ final JSONObject result = userQueryService.getUser(userId);
+ if (null == result) {
+ final JSONObject jsonObject = new JSONObject().put(Keys.STATUS_CODE, false);
+ renderer.setJSONObject(jsonObject);
+ jsonObject.put(Keys.MSG, langPropsService.get("getFailLabel"));
+
+ return;
+ }
+
+ renderer.setJSONObject(result);
+ result.put(Keys.STATUS_CODE, true);
+ }
+
+ /**
+ * Change a user role.
+ *
+ * Renders the response with a json object, for example,
+ *
+ * {
+ * "sc": boolean,
+ * "msg": ""
+ * }
+ *
+ *
+ *
+ * @param context the specified request context
+ */
+ @Before(ConsoleAdminAuthAdvice.class)
+ public void changeUserRole(final RequestContext context) {
+ final JsonRenderer renderer = new JsonRenderer();
+ context.setRenderer(renderer);
+ final JSONObject jsonObject = new JSONObject();
+ renderer.setJSONObject(jsonObject);
+ try {
+ final String userId = context.pathVar("id");
+ userMgmtService.changeRole(userId);
+
+ jsonObject.put(Keys.STATUS_CODE, true);
+ jsonObject.put(Keys.MSG, langPropsService.get("updateSuccLabel"));
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+
+ jsonObject.put(Keys.STATUS_CODE, false);
+ jsonObject.put(Keys.MSG, langPropsService.get("removeFailLabel"));
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/processor/console/package-info.java b/src/main/java/org/b3log/solo/processor/console/package-info.java
new file mode 100644
index 00000000..32585efb
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/console/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Console requests (Articles, Comments, Preference, etc, management) processing.
+ */
+package org.b3log.solo.processor.console;
diff --git a/src/main/java/org/b3log/solo/processor/package-info.java b/src/main/java/org/b3log/solo/processor/package-info.java
new file mode 100644
index 00000000..22b259e6
--- /dev/null
+++ b/src/main/java/org/b3log/solo/processor/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * HTTP request processing.
+ */
+package org.b3log.solo.processor;
diff --git a/src/main/java/org/b3log/solo/repository/ArchiveDateArticleRepository.java b/src/main/java/org/b3log/solo/repository/ArchiveDateArticleRepository.java
new file mode 100644
index 00000000..bd258056
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/ArchiveDateArticleRepository.java
@@ -0,0 +1,135 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.BeanManager;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.ArchiveDate;
+import org.b3log.solo.model.Article;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.List;
+
+/**
+ * Archive date-Article repository.
+ *
+ * @author Liang Ding
+ * @version 1.1.0.0, Sep 11, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class ArchiveDateArticleRepository extends AbstractRepository {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArchiveDateArticleRepository.class);
+
+ /**
+ * Public constructor.
+ */
+ public ArchiveDateArticleRepository() {
+ super((ArchiveDate.ARCHIVE_DATE + "_" + Article.ARTICLE).toLowerCase());
+ }
+
+ /**
+ * Gets published article count of an archive date specified by the given archive data id.
+ *
+ * @param archiveDateId the given archive date id
+ * @return published article count, returns {@code -1} if occurred an exception
+ */
+ public int getPublishedArticleCount(final String archiveDateId) {
+ try {
+ final BeanManager beanManager = BeanManager.getInstance();
+ final ArticleRepository articleRepository = beanManager.getReference(ArticleRepository.class);
+ final ArchiveDateArticleRepository archiveDateArticleRepository = beanManager.getReference(ArchiveDateArticleRepository.class);
+
+ final StringBuilder queryCount = new StringBuilder("SELECT count(DISTINCT(article.oId)) as C FROM ");
+ final StringBuilder queryStr = new StringBuilder(articleRepository.getName() + " AS article,").
+ append(archiveDateArticleRepository.getName() + " AS archive_article").
+ append(" WHERE article.oId=archive_article.article_oId ").
+ append(" AND article.articleStatus=").append(Article.ARTICLE_STATUS_C_PUBLISHED).
+ append(" AND ").append("archive_article.archiveDate_oId=").append(archiveDateId);
+ final List articlesCountResult = select(queryCount.append(queryStr.toString()).toString());
+ return articlesCountResult == null ? 0 : articlesCountResult.get(0).optInt("C");
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets archivedate [" + archiveDateId + "]'s published article count failed", e);
+
+ return -1;
+ }
+ }
+
+ /**
+ * Gets archive date-article relations by the specified archive date id.
+ *
+ * @param archiveDateId the specified archive date id
+ * @param currentPageNum the specified current page number, MUST greater then {@code 0}
+ * @param pageSize the specified page size(count of a page contains objects), MUST greater then {@code 0}
+ * @return for example
+ *
+ * {
+ * "pagination": {
+ * "paginationPageCount": 88250
+ * },
+ * "rslts": [{
+ * "oId": "",
+ * "archiveDate_oId": "",
+ * "article_oId": ""
+ * }, ....]
+ * }
+ *
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByArchiveDateId(final String archiveDateId, final int currentPageNum, final int pageSize) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(ArchiveDate.ARCHIVE_DATE + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, archiveDateId)).
+ addSort(Article.ARTICLE + "_" + Keys.OBJECT_ID, SortDirection.DESCENDING).
+ setPage(currentPageNum, pageSize).setPageCount(1);
+
+ return get(query);
+ }
+
+ /**
+ * Gets an archive date-article relations by the specified article id.
+ *
+ * @param articleId the specified article id
+ * @return for example
+ *
+ * {
+ * "archiveDate_oId": "",
+ * "article_oId": articleId
+ * }, returns {@code null} if not found
+ *
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByArticleId(final String articleId) throws RepositoryException {
+ final Query query = new Query().
+ setFilter(new PropertyFilter(Article.ARTICLE + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, articleId));
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/ArchiveDateRepository.java b/src/main/java/org/b3log/solo/repository/ArchiveDateRepository.java
new file mode 100644
index 00000000..af770d4a
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/ArchiveDateRepository.java
@@ -0,0 +1,122 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.apache.commons.lang.time.DateUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.ArchiveDate;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.text.ParseException;
+import java.util.List;
+
+/**
+ * Archive date repository.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.5, Sep 11, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class ArchiveDateRepository extends AbstractRepository {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArchiveDateRepository.class);
+
+ /**
+ * Archive date-Article repository.
+ */
+ @Inject
+ private ArchiveDateArticleRepository archiveDateArticleRepository;
+
+ /**
+ * Public constructor.
+ */
+ public ArchiveDateRepository() {
+ super(ArchiveDate.ARCHIVE_DATE.toLowerCase());
+ }
+
+ /**
+ * Gets an archive date by the specified archive date string.
+ *
+ * @param archiveDate the specified archive date stirng (yyyy/MM)
+ * @return an archive date, {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByArchiveDate(final String archiveDate) throws RepositoryException {
+ long time;
+ try {
+ time = DateUtils.parseDate(archiveDate, new String[]{"yyyy/MM"}).getTime();
+ } catch (final ParseException e) {
+ return null;
+ }
+
+ LOGGER.log(Level.TRACE, "Archive date [{0}] parsed to time [{1}]", archiveDate, time);
+
+ Query query = new Query().setFilter(new PropertyFilter(ArchiveDate.ARCHIVE_TIME, FilterOperator.EQUAL, time)).setPageCount(1);
+ JSONObject result = get(query);
+ JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ // Try to fix wired timezone issue: https://github.com/b3log/solo/issues/12435
+ try {
+ time = DateUtils.parseDate(archiveDate, new String[]{"yyyy/MM"}).getTime();
+ time += 60 * 1000 * 60 * 8;
+ } catch (final ParseException e) {
+ return null;
+ }
+
+ LOGGER.log(Level.TRACE, "Fix archive date [{0}] parsed to time [{1}]", archiveDate, time);
+
+ query = new Query().setFilter(new PropertyFilter(ArchiveDate.ARCHIVE_TIME, FilterOperator.EQUAL, time)).setPageCount(1);
+ result = get(query);
+ array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Get archive dates.
+ *
+ * @return a list of archive date, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getArchiveDates() throws RepositoryException {
+ final Query query = new Query().addSort(ArchiveDate.ARCHIVE_TIME, SortDirection.DESCENDING).setPageCount(1);
+ // TODO: Performance issue
+ final List ret = getList(query);
+ for (final JSONObject archiveDate : ret) {
+ final String archiveDateId = archiveDate.optString(Keys.OBJECT_ID);
+ final int publishedArticleCount = archiveDateArticleRepository.getPublishedArticleCount(archiveDateId);
+ archiveDate.put(ArchiveDate.ARCHIVE_DATE_T_PUBLISHED_ARTICLE_COUNT, publishedArticleCount);
+ }
+
+ return ret;
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/ArticleRepository.java b/src/main/java/org/b3log/solo/repository/ArticleRepository.java
new file mode 100644
index 00000000..547c0301
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/ArticleRepository.java
@@ -0,0 +1,365 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.cache.ArticleCache;
+import org.b3log.solo.model.Article;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Article repository.
+ *
+ * @author Liang Ding
+ * @version 1.1.1.13, Jun 6, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class ArticleRepository extends AbstractRepository {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArticleRepository.class);
+
+ /**
+ * Random range.
+ */
+ private static final double RANDOM_RANGE = 0.1D;
+
+ /**
+ * Article cache.
+ */
+ @Inject
+ private ArticleCache articleCache;
+
+ /**
+ * Public constructor.
+ */
+ public ArticleRepository() {
+ super(Article.ARTICLE);
+ }
+
+ @Override
+ public void remove(final String id) throws RepositoryException {
+ super.remove(id);
+
+ articleCache.removeArticle(id);
+ }
+
+ @Override
+ public JSONObject get(final String id) throws RepositoryException {
+ JSONObject ret = articleCache.getArticle(id);
+ if (null != ret) {
+ return ret;
+ }
+
+ ret = super.get(id);
+ if (null == ret) {
+ return null;
+ }
+
+ articleCache.putArticle(ret);
+
+ return ret;
+ }
+
+ @Override
+ public void update(final String id, final JSONObject article, final String... propertyNames) throws RepositoryException {
+ super.update(id, article, propertyNames);
+
+ article.put(Keys.OBJECT_ID, id);
+ articleCache.putArticle(article);
+ }
+
+ @Override
+ public List getRandomly(final int fetchSize) throws RepositoryException {
+ final List ret = new ArrayList<>();
+
+ if (0 == count()) {
+ return ret;
+ }
+
+ final double mid = Math.random() + RANDOM_RANGE;
+
+ LOGGER.log(Level.TRACE, "Random mid[{0}]", mid);
+
+ Query query = new Query().setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Article.ARTICLE_RANDOM_DOUBLE, FilterOperator.GREATER_THAN_OR_EQUAL, mid),
+ new PropertyFilter(Article.ARTICLE_RANDOM_DOUBLE, FilterOperator.LESS_THAN_OR_EQUAL, mid),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ setPage(1, fetchSize).setPageCount(1);
+
+ final List list1 = getList(query);
+ ret.addAll(list1);
+
+ final int reminingSize = fetchSize - list1.size();
+ if (0 != reminingSize) { // Query for remains
+ query = new Query();
+ query.setFilter(
+ CompositeFilterOperator.and(
+ new PropertyFilter(Article.ARTICLE_RANDOM_DOUBLE, FilterOperator.GREATER_THAN_OR_EQUAL, 0D),
+ new PropertyFilter(Article.ARTICLE_RANDOM_DOUBLE, FilterOperator.LESS_THAN_OR_EQUAL, mid),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ setPage(1, reminingSize).setPageCount(1);
+
+ final List list2 = getList(query);
+
+ ret.addAll(list2);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Gets published articles by the specified author id, current page number and page size.
+ *
+ * @param authorId the specified author id
+ * @param currentPageNum the specified current page number, MUST greater then {@code 0}
+ * @param pageSize the specified page size(count of a page contains objects), MUST greater then {@code 0}
+ * @return for example
+ *
+ * {
+ * "pagination": {
+ * "paginationPageCount": 88250
+ * },
+ * "rslts": [{
+ * // article keys....
+ * }, ....]
+ * }
+ *
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByAuthorId(final String authorId, final int currentPageNum, final int pageSize) throws RepositoryException {
+ final Query query = new Query().
+ setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Article.ARTICLE_AUTHOR_ID, FilterOperator.EQUAL, authorId),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING).addSort(Article.ARTICLE_PUT_TOP, SortDirection.DESCENDING).
+ setPage(currentPageNum, pageSize);
+
+ return get(query);
+ }
+
+ /**
+ * Gets an article by the specified permalink.
+ *
+ * @param permalink the specified permalink
+ * @return an article, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByPermalink(final String permalink) throws RepositoryException {
+ JSONObject ret = articleCache.getArticleByPermalink(permalink);
+ if (null != ret) {
+ return ret;
+ }
+
+ final Query query = new Query().
+ setFilter(new PropertyFilter(Article.ARTICLE_PERMALINK, FilterOperator.EQUAL, permalink)).
+ setPageCount(1);
+
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ ret = array.optJSONObject(0);
+ articleCache.putArticle(ret);
+
+ return ret;
+ }
+
+ /**
+ * Gets post articles recently with the specified fetch size.
+ *
+ * @param fetchSize the specified fetch size
+ * @return a list of articles recently, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getRecentArticles(final int fetchSize) throws RepositoryException {
+ final Query query = new Query().
+ setFilter(new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED)).
+ addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING).
+ setPage(1, fetchSize).setPageCount(1);
+
+ return getList(query);
+ }
+
+ /**
+ * Gets most commented and published articles with the specified number.
+ *
+ * @param num the specified number
+ * @return a list of most comment articles, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getMostCommentArticles(final int num) throws RepositoryException {
+ final Query query = new Query().
+ addSort(Article.ARTICLE_COMMENT_COUNT, SortDirection.DESCENDING).
+ addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING).
+ setFilter(new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED)).
+ setPage(1, num).setPageCount(1);
+
+ return getList(query);
+ }
+
+ /**
+ * Gets most view count and published articles with the specified number.
+ *
+ * @param num the specified number
+ * @return a list of most view count articles, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getMostViewCountArticles(final int num) throws RepositoryException {
+ final Query query = new Query().
+ addSort(Article.ARTICLE_VIEW_COUNT, SortDirection.DESCENDING).
+ addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING).
+ setFilter(new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED)).
+ setPage(1, num).setPageCount(1);
+
+ return getList(query);
+ }
+
+ /**
+ * Gets the previous article(by create date) by the specified article id.
+ *
+ * @param articleId the specified article id
+ * @return the previous article,
+ *
+ * {
+ * "articleTitle": "",
+ * "articlePermalink": "",
+ * "articleAbstract: ""
+ * }
+ *
+ * returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getPreviousArticle(final String articleId) throws RepositoryException {
+ final JSONObject currentArticle = get(articleId);
+ if (null == currentArticle) {
+ return null;
+ }
+
+ final long currentArticleCreated = currentArticle.optLong(Article.ARTICLE_CREATED);
+
+ final Query query = new Query().
+ setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Article.ARTICLE_CREATED, FilterOperator.LESS_THAN, currentArticleCreated),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ addSort(Article.ARTICLE_CREATED, SortDirection.DESCENDING).
+ setPage(1, 1).setPageCount(1).
+ select(Article.ARTICLE_TITLE, Article.ARTICLE_PERMALINK, Article.ARTICLE_ABSTRACT);
+
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ final JSONObject ret = new JSONObject();
+ final JSONObject article = array.optJSONObject(0);
+
+ try {
+ ret.put(Article.ARTICLE_TITLE, article.getString(Article.ARTICLE_TITLE));
+ ret.put(Article.ARTICLE_PERMALINK, article.getString(Article.ARTICLE_PERMALINK));
+ ret.put(Article.ARTICLE_ABSTRACT, article.getString((Article.ARTICLE_ABSTRACT)));
+ } catch (final JSONException e) {
+ throw new RepositoryException(e);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Gets the next article(by create date, oId) by the specified article id.
+ *
+ * @param articleId the specified article id
+ * @return the next article,
+ *
+ * {
+ * "articleTitle": "",
+ * "articlePermalink": "",
+ * "articleAbstract: ""
+ * }
+ *
+ * returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getNextArticle(final String articleId) throws RepositoryException {
+ final JSONObject currentArticle = get(articleId);
+ if (null == currentArticle) {
+ return null;
+ }
+
+ final long currentArticleCreated = currentArticle.optLong(Article.ARTICLE_CREATED);
+
+ final Query query = new Query().
+ setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Article.ARTICLE_CREATED, FilterOperator.GREATER_THAN, currentArticleCreated),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ addSort(Article.ARTICLE_CREATED, SortDirection.ASCENDING).
+ setPage(1, 1).setPageCount(1).
+ select(Article.ARTICLE_TITLE, Article.ARTICLE_PERMALINK, Article.ARTICLE_ABSTRACT);
+
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ final JSONObject ret = new JSONObject();
+ final JSONObject article = array.optJSONObject(0);
+
+ try {
+ ret.put(Article.ARTICLE_TITLE, article.getString(Article.ARTICLE_TITLE));
+ ret.put(Article.ARTICLE_PERMALINK, article.getString(Article.ARTICLE_PERMALINK));
+ ret.put(Article.ARTICLE_ABSTRACT, article.getString((Article.ARTICLE_ABSTRACT)));
+ } catch (final JSONException e) {
+ throw new RepositoryException(e);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Determines an article specified by the given article id is published.
+ *
+ * @param articleId the given article id
+ * @return {@code true} if it is published, {@code false} otherwise
+ * @throws RepositoryException repository exception
+ */
+ public boolean isPublished(final String articleId) throws RepositoryException {
+ final JSONObject article = get(articleId);
+ if (null == article) {
+ return false;
+ }
+
+ return Article.ARTICLE_STATUS_C_PUBLISHED == article.optInt(Article.ARTICLE_STATUS);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/CategoryRepository.java b/src/main/java/org/b3log/solo/repository/CategoryRepository.java
new file mode 100644
index 00000000..fceeb9c2
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/CategoryRepository.java
@@ -0,0 +1,201 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.BeanManager;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.Article;
+import org.b3log.solo.model.Category;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.List;
+
+/**
+ * Category repository.
+ *
+ * @author Liang Ding
+ * @version 1.2.0.3, Sep 11, 2019
+ * @since 2.0.0
+ */
+@Repository
+public class CategoryRepository extends AbstractRepository {
+
+ /**
+ * Public constructor.
+ */
+ public CategoryRepository() {
+ super(Category.CATEGORY);
+ }
+
+ /**
+ * Gets a category by the specified category title.
+ *
+ * @param categoryTitle the specified category title
+ * @return a category, {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByTitle(final String categoryTitle) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY_TITLE, FilterOperator.EQUAL, categoryTitle)).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets a category by the specified category URI.
+ *
+ * @param categoryURI the specified category URI
+ * @return a category, {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByURI(final String categoryURI) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY_URI, FilterOperator.EQUAL, categoryURI)).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets the maximum order.
+ *
+ * @return order number, returns {@code -1} if not found
+ * @throws RepositoryException repository exception
+ */
+ public int getMaxOrder() throws RepositoryException {
+ final Query query = new Query().addSort(Category.CATEGORY_ORDER, SortDirection.DESCENDING);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return -1;
+ }
+
+ return array.optJSONObject(0).optInt(Category.CATEGORY_ORDER);
+ }
+
+ /**
+ * Gets the upper category of the category specified by the given id.
+ *
+ * @param id the given id
+ * @return upper category, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getUpper(final String id) throws RepositoryException {
+ final JSONObject category = get(id);
+ if (null == category) {
+ return null;
+ }
+
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY_ORDER, FilterOperator.LESS_THAN, category.optInt(Category.CATEGORY_ORDER))).
+ addSort(Category.CATEGORY_ORDER, SortDirection.DESCENDING).setPage(1, 1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets the under category of the category specified by the given id.
+ *
+ * @param id the given id
+ * @return under category, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getUnder(final String id) throws RepositoryException {
+ final JSONObject category = get(id);
+ if (null == category) {
+ return null;
+ }
+
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY_ORDER, FilterOperator.GREATER_THAN, category.optInt(Category.CATEGORY_ORDER))).
+ addSort(Category.CATEGORY_ORDER, SortDirection.ASCENDING).setPage(1, 1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets a category by the specified order.
+ *
+ * @param order the specified order
+ * @return category, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByOrder(final int order) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY_ORDER, FilterOperator.EQUAL, order));
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets most used categories (contains the most tags) with the specified number.
+ *
+ * @param num the specified number
+ * @return a list of most used categories, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getMostUsedCategories(final int num) throws RepositoryException {
+ final Query query = new Query().addSort(Category.CATEGORY_ORDER, SortDirection.ASCENDING).
+ setPage(1, num).setPageCount(1);
+ final List ret = getList(query);
+
+ final BeanManager beanManager = BeanManager.getInstance();
+ final ArticleRepository articleRepository = beanManager.getReference(ArticleRepository.class);
+ final TagArticleRepository tagArticleRepository = beanManager.getReference(TagArticleRepository.class);
+ final CategoryTagRepository categoryTagRepository = beanManager.getReference(CategoryTagRepository.class);
+
+ for (final JSONObject category : ret) {
+ final String categoryId = category.optString(Keys.OBJECT_ID);
+ final StringBuilder queryCount = new StringBuilder("SELECT count(DISTINCT(b3_solo_article.oId)) as C FROM ");
+ final StringBuilder queryStr = new StringBuilder(articleRepository.getName() + " AS b3_solo_article,").
+ append(tagArticleRepository.getName() + " AS b3_solo_tag_article").
+ append(" WHERE b3_solo_article.oId=b3_solo_tag_article.article_oId ").
+ append(" AND b3_solo_article.articleStatus=").append(Article.ARTICLE_STATUS_C_PUBLISHED).
+ append(" AND ").append("b3_solo_tag_article.tag_oId").append(" IN (").
+ append("SELECT tag_oId FROM ").append(categoryTagRepository.getName() + " AS b3_solo_category_tag WHERE b3_solo_category_tag.category_oId = ").
+ append(categoryId).append(")");
+ final List articlesCountResult = select(queryCount.append(queryStr.toString()).toString());
+ final int articleCount = articlesCountResult == null ? 0 : articlesCountResult.get(0).optInt("C");
+ category.put(Category.CATEGORY_T_PUBLISHED_ARTICLE_COUNT, articleCount);
+ }
+
+ return ret;
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/CategoryTagRepository.java b/src/main/java/org/b3log/solo/repository/CategoryTagRepository.java
new file mode 100644
index 00000000..5e759dae
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/CategoryTagRepository.java
@@ -0,0 +1,128 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.Category;
+import org.b3log.solo.model.Tag;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Category-Tag relation repository.
+ *
+ * @author Liang Ding
+ * @version 1.1.0.3, Jan 15, 2019
+ * @since 2.0.0
+ */
+@Repository
+public class CategoryTagRepository extends AbstractRepository {
+
+ /**
+ * Public constructor.
+ */
+ public CategoryTagRepository() {
+ super(Category.CATEGORY + "_" + Tag.TAG);
+ }
+
+ /**
+ * Gets category-tag relations by the specified category id.
+ *
+ * @param categoryId the specified category id
+ * @param currentPageNum the specified current page number, MUST greater then {@code 0}
+ * @param pageSize the specified page size(count of a page contains objects), MUST greater then {@code 0}
+ * @return for example
+ * {
+ * "pagination": {
+ * "paginationPageCount": 88250
+ * },
+ * "rslts": [{
+ * "oId": "",
+ * "category_oId": categoryId,
+ * "tag_oId": ""
+ * }, ....]
+ * }
+ *
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByCategoryId(final String categoryId, final int currentPageNum, final int pageSize) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, categoryId)).
+ setPage(currentPageNum, pageSize).setPageCount(1);
+
+ return get(query);
+ }
+
+ /**
+ * Gets category-tag relations by the specified tag id.
+ *
+ * @param tagId the specified tag id
+ * @param currentPageNum the specified current page number, MUST greater then {@code 0}
+ * @param pageSize the specified page size(count of a page contains objects), MUST greater then {@code 0}
+ * @return for example
+ * {
+ * "pagination": {
+ * "paginationPageCount": 88250
+ * },
+ * "rslts": [{
+ * "oId": "",
+ * "category_oId": "",
+ * "tag_oId": tagId
+ * }, ....]
+ * }
+ *
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByTagId(final String tagId, final int currentPageNum, final int pageSize) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tagId)).
+ setPage(currentPageNum, pageSize).setPageCount(1);
+
+ return get(query);
+ }
+
+ /**
+ * Removes category-tag relations by the specified category id.
+ *
+ * @param categoryId the specified category id
+ * @throws RepositoryException repository exception
+ */
+ public void removeByCategoryId(final String categoryId) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Category.CATEGORY + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, categoryId));
+ final JSONArray relations = get(query).optJSONArray(Keys.RESULTS);
+ for (int i = 0; i < relations.length(); i++) {
+ final JSONObject rel = relations.optJSONObject(i);
+ remove(rel.optString(Keys.OBJECT_ID));
+ }
+ }
+
+ /**
+ * Removes category-tag relations by the specified tag id.
+ *
+ * @param tagId the specified tag id
+ * @throws RepositoryException repository exception
+ */
+ public void removeByTagId(final String tagId) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tagId));
+ final JSONArray relations = get(query).optJSONArray(Keys.RESULTS);
+ for (int i = 0; i < relations.length(); i++) {
+ final JSONObject rel = relations.optJSONObject(i);
+ remove(rel.optString(Keys.OBJECT_ID));
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/CommentRepository.java b/src/main/java/org/b3log/solo/repository/CommentRepository.java
new file mode 100644
index 00000000..61097de7
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/CommentRepository.java
@@ -0,0 +1,175 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.cache.CommentCache;
+import org.b3log.solo.model.Comment;
+import org.json.JSONObject;
+
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Comment repository.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.6, Jan 15, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class CommentRepository extends AbstractRepository {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(CommentRepository.class);
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Comment cache.
+ */
+ @Inject
+ private CommentCache commentCache;
+
+ /**
+ * Public constructor.
+ */
+ public CommentRepository() {
+ super(Comment.COMMENT);
+ }
+
+ @Override
+ public void remove(final String id) throws RepositoryException {
+ super.remove(id);
+
+ commentCache.removeComment(id);
+ }
+
+ @Override
+ public JSONObject get(final String id) throws RepositoryException {
+ JSONObject ret = commentCache.getComment(id);
+ if (null != ret) {
+ return ret;
+ }
+
+ ret = super.get(id);
+ if (null == ret) {
+ return null;
+ }
+
+ commentCache.putComment(ret);
+
+ return ret;
+ }
+
+ @Override
+ public void update(final String id, final JSONObject comment, final String... propertyNames) throws RepositoryException {
+ super.update(id, comment, propertyNames);
+
+ comment.put(Keys.OBJECT_ID, id);
+ commentCache.putComment(comment);
+ }
+
+ /**
+ * Gets post comments recently with the specified fetch.
+ *
+ * @param fetchSize the specified fetch size
+ * @return a list of comments recently, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getRecentComments(final int fetchSize) throws RepositoryException {
+ final Query query = new Query().
+ addSort(Keys.OBJECT_ID, SortDirection.DESCENDING).
+ setPage(1, fetchSize).setPageCount(1);
+ final List ret = getList(query);
+ // Removes unpublished article related comments
+ removeForUnpublishedArticles(ret);
+
+ return ret;
+ }
+
+ /**
+ * Gets comments with the specified on id, current page number and
+ * page size.
+ *
+ * @param onId the specified on id
+ * @param currentPageNum the specified current page number
+ * @param pageSize the specified page size
+ * @return a list of comments, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getComments(final String onId, final int currentPageNum, final int pageSize) throws RepositoryException {
+ final Query query = new Query().
+ addSort(Keys.OBJECT_ID, SortDirection.DESCENDING).
+ setFilter(new PropertyFilter(Comment.COMMENT_ON_ID, FilterOperator.EQUAL, onId)).
+ setPage(currentPageNum, pageSize).setPageCount(1);
+
+ return getList(query);
+ }
+
+ /**
+ * Removes comments with the specified on id.
+ *
+ * @param onId the specified on id
+ * @return removed count
+ * @throws RepositoryException repository exception
+ */
+ public int removeComments(final String onId) throws RepositoryException {
+ final List comments = getComments(onId, 1, Integer.MAX_VALUE);
+ for (final JSONObject comment : comments) {
+ final String commentId = comment.optString(Keys.OBJECT_ID);
+ remove(commentId);
+ }
+
+ LOGGER.log(Level.DEBUG, "Removed comments[onId={0}, removedCnt={1}]", onId, comments.size());
+
+ return comments.size();
+ }
+
+ /**
+ * Removes comments of unpublished articles for the specified comments.
+ *
+ * @param comments the specified comments
+ * @throws RepositoryException repository exception
+ */
+ private void removeForUnpublishedArticles(final List comments) throws RepositoryException {
+ LOGGER.debug("Removing unpublished articles' comments....");
+ final Iterator iterator = comments.iterator();
+
+ while (iterator.hasNext()) {
+ final JSONObject comment = iterator.next();
+ final String articleId = comment.optString(Comment.COMMENT_ON_ID);
+ if (!articleRepository.isPublished(articleId)) {
+ iterator.remove();
+ }
+ }
+
+ LOGGER.debug("Removed unpublished articles' comments....");
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/LinkRepository.java b/src/main/java/org/b3log/solo/repository/LinkRepository.java
new file mode 100644
index 00000000..f1fa6489
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/LinkRepository.java
@@ -0,0 +1,144 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.Link;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Link repository.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.5, Jan 15, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class LinkRepository extends AbstractRepository {
+
+ /**
+ * Public constructor.
+ */
+ public LinkRepository() {
+ super(Link.LINK);
+ }
+
+ /**
+ * Gets a link by the specified address.
+ *
+ * @param address the specified address
+ * @return link, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByAddress(final String address) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Link.LINK_ADDRESS, FilterOperator.EQUAL, address)).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets the maximum order.
+ *
+ * @return order number, returns {@code -1} if not found
+ * @throws RepositoryException repository exception
+ */
+ public int getMaxOrder() throws RepositoryException {
+ final Query query = new Query().addSort(Link.LINK_ORDER, SortDirection.DESCENDING);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return -1;
+ }
+
+ return array.optJSONObject(0).optInt(Link.LINK_ORDER);
+ }
+
+ /**
+ * Gets the upper link of the link specified by the given id.
+ *
+ * @param id the given id
+ * @return upper link, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getUpper(final String id) throws RepositoryException {
+ final JSONObject link = get(id);
+ if (null == link) {
+ return null;
+ }
+
+ final Query query = new Query().setFilter(new PropertyFilter(Link.LINK_ORDER, FilterOperator.LESS_THAN, link.optInt(Link.LINK_ORDER))).
+ addSort(Link.LINK_ORDER, SortDirection.DESCENDING).setPage(1, 1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets the under link of the link specified by the given id.
+ *
+ * @param id the given id
+ * @return under link, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getUnder(final String id) throws RepositoryException {
+ final JSONObject link = get(id);
+ if (null == link) {
+ return null;
+ }
+
+ final Query query = new Query().setFilter(new PropertyFilter(Link.LINK_ORDER, FilterOperator.GREATER_THAN, link.optInt(Link.LINK_ORDER))).
+ addSort(Link.LINK_ORDER, SortDirection.ASCENDING).setPage(1, 1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets a link by the specified order.
+ *
+ * @param order the specified order
+ * @return link, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByOrder(final int order) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Link.LINK_ORDER, FilterOperator.EQUAL, order));
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/OptionRepository.java b/src/main/java/org/b3log/solo/repository/OptionRepository.java
new file mode 100644
index 00000000..e18fa206
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/OptionRepository.java
@@ -0,0 +1,129 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.cache.OptionCache;
+import org.b3log.solo.model.Option;
+import org.json.JSONObject;
+
+import java.util.List;
+
+/**
+ * Option repository.
+ *
+ * @author Liang Ding
+ * @version 1.1.0.2, Jun 6, 2019
+ * @since 0.6.0
+ */
+@Repository
+public class OptionRepository extends AbstractRepository {
+
+ /**
+ * Option cache.
+ */
+ @Inject
+ private OptionCache optionCache;
+
+ /**
+ * Public constructor.
+ */
+ public OptionRepository() {
+ super(Option.OPTION);
+ }
+
+ @Override
+ public void remove(final String id) throws RepositoryException {
+ final JSONObject option = get(id);
+ if (null == option) {
+ return;
+ }
+
+ super.remove(id);
+ optionCache.removeOption(id);
+
+ final String category = option.optString(Option.OPTION_CATEGORY);
+ optionCache.removeCategory(category);
+ }
+
+ @Override
+ public JSONObject get(final String id) throws RepositoryException {
+ JSONObject ret = optionCache.getOption(id);
+ if (null != ret) {
+ return ret;
+ }
+
+ ret = super.get(id);
+ if (null == ret) {
+ return null;
+ }
+
+ optionCache.putOption(ret);
+
+ return ret;
+ }
+
+ @Override
+ public void update(final String id, final JSONObject option, final String... propertyNames) throws RepositoryException {
+ super.update(id, option, propertyNames);
+
+ option.put(Keys.OBJECT_ID, id);
+ optionCache.putOption(option);
+ }
+
+ /**
+ * Gets options with the specified category.
+ *
+ * All options with the specified category will be merged into one json object as the return value.
+ *
+ *
+ * @param category the specified category
+ * @return all options with the specified category, for example,
+ *
+ * {
+ * "${optionId}": "${optionValue}",
+ * ....
+ * }
+ *
, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getOptions(final String category) throws RepositoryException {
+ final JSONObject cached = optionCache.getCategory(category);
+ if (null != cached) {
+ return cached;
+ }
+
+ final JSONObject ret = new JSONObject();
+ try {
+ final List options = getList(new Query().setFilter(new PropertyFilter(Option.OPTION_CATEGORY, FilterOperator.EQUAL, category)));
+ if (0 == options.size()) {
+ return null;
+ }
+
+ options.stream().forEach(option -> ret.put(option.optString(Keys.OBJECT_ID), option.opt(Option.OPTION_VALUE)));
+ optionCache.putCategory(category, ret);
+
+ return ret;
+ } catch (final Exception e) {
+ throw new RepositoryException(e);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/PageRepository.java b/src/main/java/org/b3log/solo/repository/PageRepository.java
new file mode 100644
index 00000000..af03dda2
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/PageRepository.java
@@ -0,0 +1,198 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.cache.PageCache;
+import org.b3log.solo.model.Page;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.List;
+
+/**
+ * Page repository.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.9, Jun 6, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class PageRepository extends AbstractRepository {
+
+ /**
+ * Page cache.
+ */
+ @Inject
+ private PageCache pageCache;
+
+ /**
+ * Public constructor.
+ */
+ public PageRepository() {
+ super(Page.PAGE);
+ }
+
+ @Override
+ public void remove(final String id) throws RepositoryException {
+ super.remove(id);
+
+ pageCache.removePage(id);
+ }
+
+ @Override
+ public JSONObject get(final String id) throws RepositoryException {
+ JSONObject ret = pageCache.getPage(id);
+ if (null != ret) {
+ return ret;
+ }
+
+ ret = super.get(id);
+ if (null == ret) {
+ return null;
+ }
+
+ pageCache.putPage(ret);
+
+ return ret;
+ }
+
+ @Override
+ public void update(final String id, final JSONObject page, final String... propertyNames) throws RepositoryException {
+ super.update(id, page, propertyNames);
+
+ page.put(Keys.OBJECT_ID, id);
+ pageCache.putPage(page);
+ }
+
+ /**
+ * Gets a page by the specified permalink.
+ *
+ * @param permalink the specified permalink
+ * @return page, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByPermalink(final String permalink) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Page.PAGE_PERMALINK, FilterOperator.EQUAL, permalink)).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets the maximum order.
+ *
+ * @return order number, returns {@code -1} if not found
+ * @throws RepositoryException repository exception
+ */
+ public int getMaxOrder() throws RepositoryException {
+ final Query query = new Query().addSort(Page.PAGE_ORDER, SortDirection.DESCENDING).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return -1;
+ }
+
+ return array.optJSONObject(0).optInt(Page.PAGE_ORDER);
+ }
+
+ /**
+ * Gets the upper page of the page specified by the given id.
+ *
+ * @param id the given id
+ * @return upper page, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getUpper(final String id) throws RepositoryException {
+ final JSONObject page = get(id);
+ if (null == page) {
+ return null;
+ }
+
+ final Query query = new Query().setFilter(new PropertyFilter(Page.PAGE_ORDER, FilterOperator.LESS_THAN, page.optInt(Page.PAGE_ORDER))).
+ addSort(Page.PAGE_ORDER, SortDirection.DESCENDING).setPage(1, 1).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets the under page of the page specified by the given id.
+ *
+ * @param id the given id
+ * @return under page, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getUnder(final String id) throws RepositoryException {
+ final JSONObject page = get(id);
+ if (null == page) {
+ return null;
+ }
+
+ final Query query = new Query().setFilter(new PropertyFilter(Page.PAGE_ORDER, FilterOperator.GREATER_THAN, page.optInt(Page.PAGE_ORDER))).
+ addSort(Page.PAGE_ORDER, SortDirection.ASCENDING).setPage(1, 1).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (1 != array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets a page by the specified order.
+ *
+ * @param order the specified order
+ * @return page, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByOrder(final int order) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Page.PAGE_ORDER, FilterOperator.EQUAL, order)).setPageCount(1);
+ final JSONObject result = get(query);
+ final JSONArray array = result.optJSONArray(Keys.RESULTS);
+ if (0 == array.length()) {
+ return null;
+ }
+
+ return array.optJSONObject(0);
+ }
+
+ /**
+ * Gets pages.
+ *
+ * @return a list of pages, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getPages() throws RepositoryException {
+ final Query query = new Query().addSort(Page.PAGE_ORDER, SortDirection.ASCENDING).setPageCount(1);
+
+ return getList(query);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/PluginRepository.java b/src/main/java/org/b3log/solo/repository/PluginRepository.java
new file mode 100644
index 00000000..7e1d56d8
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/PluginRepository.java
@@ -0,0 +1,40 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.model.Plugin;
+import org.b3log.latke.repository.AbstractRepository;
+import org.b3log.latke.repository.annotation.Repository;
+
+/**
+ * Plugin repository.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.1, Sep 30, 2018
+ * @since 0.3.1
+ */
+@Repository
+public class PluginRepository extends AbstractRepository {
+
+ /**
+ * Public constructor.
+ */
+ public PluginRepository() {
+ super(Plugin.PLUGIN);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/TagArticleRepository.java b/src/main/java/org/b3log/solo/repository/TagArticleRepository.java
new file mode 100644
index 00000000..c0e6c563
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/TagArticleRepository.java
@@ -0,0 +1,185 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.apache.commons.lang.StringUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.BeanManager;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.Article;
+import org.b3log.solo.model.Tag;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tag-Article repository.
+ *
+ * @author Liang Ding
+ * @version 1.1.1.0, Aug 20, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class TagArticleRepository extends AbstractRepository {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(TagArticleRepository.class);
+
+ /**
+ * Public constructor.
+ */
+ public TagArticleRepository() {
+ super(Tag.TAG + "_" + Article.ARTICLE);
+ }
+
+ /**
+ * Gets most used tags with the specified number.
+ *
+ * @param num the specified number
+ * @return a list of most used tags, returns an empty list if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getMostUsedTags(final int num) throws RepositoryException {
+ final List records = select("SELECT\n" +
+ "\t`tag_oId`,\n" +
+ "\tcount(*) AS cnt\n" +
+ "FROM `" + getName() + "`\n" +
+ "GROUP BY\n" +
+ "\t`tag_oId`\n" +
+ "ORDER BY\n" +
+ "\tcnt DESC\n" +
+ "LIMIT ?", num);
+ final List ret = new ArrayList<>();
+ final TagRepository tagRepository = BeanManager.getInstance().getReference(TagRepository.class);
+ for (final JSONObject record : records) {
+ final String tagId = record.optString(Tag.TAG + "_" + Keys.OBJECT_ID);
+ final JSONObject tag = tagRepository.get(tagId);
+ if (null != tag) {
+ final int articleCount = getPublishedArticleCount(tagId);
+ tag.put(Tag.TAG_T_PUBLISHED_REFERENCE_COUNT, articleCount);
+ }
+ ret.add(tag);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Gets article count of a tag specified by the given tag id.
+ *
+ * @param tagId the given tag id
+ * @return article count, returns {@code -1} if occurred an exception
+ */
+ public int getArticleCount(final String tagId) {
+ final Query query = new Query().setFilter(new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tagId));
+ try {
+ return (int) count(query);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets tag [" + tagId + "]'s article count failed", e);
+
+ return -1;
+ }
+ }
+
+ /**
+ * Gets published article count of a tag specified by the given tag id.
+ *
+ * @param tagId the given tag id
+ * @return published article count, returns {@code -1} if occurred an exception
+ */
+ public int getPublishedArticleCount(final String tagId) {
+ try {
+ final String tableNamePrefix = StringUtils.isNotBlank(Latkes.getLocalProperty("jdbc.tablePrefix"))
+ ? Latkes.getLocalProperty("jdbc.tablePrefix") + "_"
+ : "";
+ final List result = select("SELECT\n" +
+ "\tcount(*) AS `C`\n" +
+ "FROM\n" +
+ "\t" + tableNamePrefix + "tag_article AS t,\n" +
+ "\t" + tableNamePrefix + "article AS a\n" +
+ "WHERE\n" +
+ "\tt.article_oId = a.oId\n" +
+ "AND a.articleStatus = ?\n" +
+ "AND t.tag_oId = ?", Article.ARTICLE_STATUS_C_PUBLISHED, tagId);
+ return result.get(0).optInt("C");
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets tag [" + tagId + "]'s published article count failed", e);
+
+ return -1;
+ }
+ }
+
+ /**
+ * Gets tag-article relations by the specified article id.
+ *
+ * @param articleId the specified article id
+ * @return for example
+ *
+ * [{
+ * "oId": "",
+ * "tag_oId": "",
+ * "article_oId": articleId
+ * }, ....], returns an empty list if not found
+ *
+ * @throws RepositoryException repository exception
+ */
+ public List getByArticleId(final String articleId) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Article.ARTICLE + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, articleId)).
+ setPageCount(1);
+
+ return getList(query);
+ }
+
+ /**
+ * Gets tag-article relations by the specified tag id.
+ *
+ * @param tagId the specified tag id
+ * @param currentPageNum the specified current page number, MUST greater
+ * then {@code 0}
+ * @param pageSize the specified page size(count of a page contains objects),
+ * MUST greater then {@code 0}
+ * @return for example
+ *
+ * {
+ * "pagination": {
+ * "paginationPageCount": 88250
+ * },
+ * "rslts": [{
+ * "oId": "",
+ * "tag_oId": tagId,
+ * "article_oId": ""
+ * }, ....]
+ * }
+ *
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByTagId(final String tagId, final int currentPageNum, final int pageSize) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tagId)).
+ addSort(Article.ARTICLE + "_" + Keys.OBJECT_ID, SortDirection.DESCENDING).
+ setPage(currentPageNum, pageSize);
+
+ return get(query);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/TagRepository.java b/src/main/java/org/b3log/solo/repository/TagRepository.java
new file mode 100644
index 00000000..8854d8f6
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/TagRepository.java
@@ -0,0 +1,96 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.model.Tag;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tag repository.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.5, Jun 20, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class TagRepository extends AbstractRepository {
+
+ /**
+ * Public constructor.
+ */
+ public TagRepository() {
+ super(Tag.TAG);
+ }
+
+ /**
+ * Tag-Article relation repository.
+ */
+ @Inject
+ private TagArticleRepository tagArticleRepository;
+
+ /**
+ * Gets tags of an article specified by the article id.
+ *
+ * @param articleId the specified article id
+ * @return a list of tags of the specified article, returns an empty list
+ * if not found
+ * @throws RepositoryException repository exception
+ */
+ public List getByArticleId(final String articleId) throws RepositoryException {
+ final List ret = new ArrayList<>();
+
+ final List tagArticleRelations = tagArticleRepository.getByArticleId(articleId);
+ for (final JSONObject tagArticleRelation : tagArticleRelations) {
+ final String tagId = tagArticleRelation.optString(Tag.TAG + "_" + Keys.OBJECT_ID);
+ final JSONObject tag = get(tagId);
+
+ ret.add(tag);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Gets a tag by the specified tag title.
+ *
+ * @param tagTitle the specified tag title
+ * @return a tag, {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByTitle(final String tagTitle) throws RepositoryException {
+ final Query query = new Query().setFilter(new PropertyFilter(Tag.TAG_TITLE, FilterOperator.EQUAL, tagTitle)).setPageCount(1);
+
+ final JSONObject ret = getFirst(query);
+ if (null == ret) {
+ return null;
+ }
+
+ final String tagId = ret.optString(Keys.OBJECT_ID);
+ final int articleCount = tagArticleRepository.getPublishedArticleCount(tagId);
+ ret.put(Tag.TAG_T_PUBLISHED_REFERENCE_COUNT, articleCount);
+
+ return ret;
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/UserRepository.java b/src/main/java/org/b3log/solo/repository/UserRepository.java
new file mode 100644
index 00000000..8836ea97
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/UserRepository.java
@@ -0,0 +1,120 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.repository;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.model.Role;
+import org.b3log.latke.model.User;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Repository;
+import org.b3log.solo.cache.UserCache;
+import org.json.JSONObject;
+
+/**
+ * User repository.
+ *
+ * @author Liang Ding
+ * @version 1.1.0.3, Jun 6, 2019
+ * @since 0.3.1
+ */
+@Repository
+public class UserRepository extends AbstractRepository {
+
+ /**
+ * User cache.
+ */
+ @Inject
+ private UserCache userCache;
+
+ /**
+ * Public constructor.
+ */
+ public UserRepository() {
+ super(User.USER);
+ }
+
+ @Override
+ public void remove(final String id) throws RepositoryException {
+ super.remove(id);
+
+ userCache.removeUser(id);
+ }
+
+ @Override
+ public JSONObject get(final String id) throws RepositoryException {
+ JSONObject ret = userCache.getUser(id);
+ if (null != ret) {
+ return ret;
+ }
+
+ ret = super.get(id);
+ if (null == ret) {
+ return null;
+ }
+
+ userCache.putUser(ret);
+
+ return ret;
+ }
+
+ @Override
+ public void update(final String id, final JSONObject user, final String... propertyNames) throws RepositoryException {
+ super.update(id, user, propertyNames);
+
+ user.put(Keys.OBJECT_ID, id);
+ userCache.putUser(user);
+
+ if (Role.ADMIN_ROLE.equals(user.optString(User.USER_ROLE))) {
+ userCache.putAdmin(user);
+ }
+ }
+
+ /**
+ * Gets a user by the specified username.
+ *
+ * @param userName the specified username
+ * @return user, returns {@code null} if not found
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getByUserName(final String userName) throws RepositoryException {
+ return getFirst(new Query().setFilter(new PropertyFilter(User.USER_NAME, FilterOperator.EQUAL, userName)));
+ }
+
+ /**
+ * Gets the administrator user.
+ *
+ * @return administrator user, returns {@code null} if not found or error
+ * @throws RepositoryException repository exception
+ */
+ public JSONObject getAdmin() throws RepositoryException {
+ JSONObject ret = userCache.getAdmin();
+ if (null != ret) {
+ return ret;
+ }
+
+ ret = getFirst(new Query().setFilter(new PropertyFilter(User.USER_ROLE, FilterOperator.EQUAL, Role.ADMIN_ROLE)));
+ if (null == ret) {
+ return null;
+ }
+
+ userCache.putAdmin(ret);
+
+ return ret;
+ }
+}
diff --git a/src/main/java/org/b3log/solo/repository/package-info.java b/src/main/java/org/b3log/solo/repository/package-info.java
new file mode 100644
index 00000000..4284b755
--- /dev/null
+++ b/src/main/java/org/b3log/solo/repository/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Data access.
+ */
+package org.b3log.solo.repository;
diff --git a/src/main/java/org/b3log/solo/service/ArchiveDateMgmtService.java b/src/main/java/org/b3log/solo/service/ArchiveDateMgmtService.java
new file mode 100644
index 00000000..0b6caa05
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/ArchiveDateMgmtService.java
@@ -0,0 +1,86 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.RepositoryException;
+import org.b3log.latke.repository.Transaction;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.solo.model.ArchiveDate;
+import org.b3log.solo.repository.ArchiveDateArticleRepository;
+import org.b3log.solo.repository.ArchiveDateRepository;
+import org.json.JSONObject;
+
+import java.util.List;
+
+/**
+ * Archive date query service.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.0, Mar 20, 2019
+ * @since 3.4.0
+ */
+@Service
+public class ArchiveDateMgmtService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArchiveDateMgmtService.class);
+
+ /**
+ * Archive date repository.
+ */
+ @Inject
+ private ArchiveDateRepository archiveDateRepository;
+
+ /**
+ * Archive date-Article repository.
+ */
+ @Inject
+ private ArchiveDateArticleRepository archiveDateArticleRepository;
+
+
+ /**
+ * Removes all unused archive dates.
+ *
+ * @return a list of archive dates, returns an empty list if not found
+ */
+ public void removeUnusedArchiveDates() {
+ final Transaction transaction = archiveDateRepository.beginTransaction();
+ try {
+ final List archiveDates = archiveDateRepository.getArchiveDates();
+ for (final JSONObject archiveDate : archiveDates) {
+ if (1 > archiveDate.optInt(ArchiveDate.ARCHIVE_DATE_T_PUBLISHED_ARTICLE_COUNT)) {
+ final String archiveDateId = archiveDate.optString(Keys.OBJECT_ID);
+ archiveDateRepository.remove(archiveDateId);
+ }
+ }
+ transaction.commit();
+ } catch (final RepositoryException e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Gets archive dates failed", e);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/ArchiveDateQueryService.java b/src/main/java/org/b3log/solo/service/ArchiveDateQueryService.java
new file mode 100644
index 00000000..b9f1a5df
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/ArchiveDateQueryService.java
@@ -0,0 +1,121 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.RepositoryException;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.solo.model.ArchiveDate;
+import org.b3log.solo.repository.ArchiveDateArticleRepository;
+import org.b3log.solo.repository.ArchiveDateRepository;
+import org.json.JSONObject;
+
+import java.util.List;
+
+/**
+ * Archive date query service.
+ *
+ * @author Liang Ding
+ * @version 1.1.0.1, Sep 11, 2019
+ * @since 0.4.0
+ */
+@Service
+public class ArchiveDateQueryService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArchiveDateQueryService.class);
+
+ /**
+ * Archive date repository.
+ */
+ @Inject
+ private ArchiveDateRepository archiveDateRepository;
+
+ /**
+ * Archive date-Article repository.
+ */
+ @Inject
+ private ArchiveDateArticleRepository archiveDateArticleRepository;
+
+ /**
+ * Gets published article count of an archive date specified by the given archive date id.
+ *
+ * @param archiveDateId the given archive date id
+ * @return published article count, returns {@code -1} if occurred an exception
+ */
+ public int getArchiveDatePublishedArticleCount(final String archiveDateId) {
+ return archiveDateArticleRepository.getPublishedArticleCount(archiveDateId);
+ }
+
+ /**
+ * Gets all archive dates.
+ *
+ * @return a list of archive dates, returns an empty list if not found
+ * @throws ServiceException service exception
+ */
+ public List getArchiveDates() throws ServiceException {
+ try {
+ return archiveDateRepository.getArchiveDates();
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets archive dates failed", e);
+ throw new ServiceException("Gets archive dates failed");
+ }
+ }
+
+ /**
+ * Gets an archive date by the specified archive date string.
+ *
+ * @param archiveDateString the specified archive date string (yyyy/MM)
+ * @return for example,
+ *
+ * {
+ * "archiveDate": {
+ * "oId": "",
+ * "archiveTime": "",
+ * "archiveDatePublishedArticleCount": int
+ * }
+ * }
+ *
, returns {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getByArchiveDateString(final String archiveDateString) throws ServiceException {
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final JSONObject archiveDate = archiveDateRepository.getByArchiveDate(archiveDateString);
+ if (null == archiveDate) {
+ return null;
+ }
+
+ final int articleCount = archiveDateArticleRepository.getPublishedArticleCount(archiveDate.optString(Keys.OBJECT_ID));
+ archiveDate.put(ArchiveDate.ARCHIVE_DATE_T_PUBLISHED_ARTICLE_COUNT, articleCount);
+ ret.put(ArchiveDate.ARCHIVE_DATE, archiveDate);
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets archive date[string=" + archiveDateString + "] failed", e);
+ throw new ServiceException("Gets archive date[string=" + archiveDateString + "] failed");
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/ArticleMgmtService.java b/src/main/java/org/b3log/solo/service/ArticleMgmtService.java
new file mode 100644
index 00000000..3cf71354
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/ArticleMgmtService.java
@@ -0,0 +1,1048 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang.time.DateFormatUtils;
+import org.apache.commons.lang.time.DateUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.event.Event;
+import org.b3log.latke.event.EventManager;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.RepositoryException;
+import org.b3log.latke.repository.Transaction;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.util.Ids;
+import org.b3log.solo.event.B3ArticleSender;
+import org.b3log.solo.event.EventTypes;
+import org.b3log.solo.model.*;
+import org.b3log.solo.repository.*;
+import org.b3log.solo.util.GitHubs;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.b3log.solo.model.Article.*;
+
+/**
+ * Article management service.
+ *
+ * @author Liang Ding
+ * @version 1.3.2.1, Sep 11, 2019
+ * @since 0.3.5
+ */
+@Service
+public class ArticleMgmtService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArticleMgmtService.class);
+
+ /**
+ * Article query service.
+ */
+ @Inject
+ private ArticleQueryService articleQueryService;
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Page repository.
+ */
+ @Inject
+ private PageRepository pageRepository;
+
+ /**
+ * User repository.
+ */
+ @Inject
+ private UserRepository userRepository;
+
+ /**
+ * Tag repository.
+ */
+ @Inject
+ private TagRepository tagRepository;
+
+ /**
+ * Archive date repository.
+ */
+ @Inject
+ private ArchiveDateRepository archiveDateRepository;
+
+ /**
+ * Archive date-Article repository.
+ */
+ @Inject
+ private ArchiveDateArticleRepository archiveDateArticleRepository;
+
+ /**
+ * Tag-Article repository.
+ */
+ @Inject
+ private TagArticleRepository tagArticleRepository;
+
+ /**
+ * Comment repository.
+ */
+ @Inject
+ private CommentRepository commentRepository;
+
+ /**
+ * Category-tag repository.
+ */
+ @Inject
+ private CategoryTagRepository categoryTagRepository;
+
+ /**
+ * Permalink query service.
+ */
+ @Inject
+ private PermalinkQueryService permalinkQueryService;
+
+ /**
+ * Event manager.
+ */
+ @Inject
+ private EventManager eventManager;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Statistic management service.
+ */
+ @Inject
+ private StatisticMgmtService statisticMgmtService;
+
+ /**
+ * Statistic query service.
+ */
+ @Inject
+ private StatisticQueryService statisticQueryService;
+
+ /**
+ * Init service.
+ */
+ @Inject
+ private InitService initService;
+
+ /**
+ * Tag management service.
+ */
+ @Inject
+ private TagMgmtService tagMgmtService;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Option management service.
+ */
+ @Inject
+ private OptionMgmtService optionMgmtService;
+
+ /**
+ * Refreshes GitHub repos. 同步拉取 GitHub 仓库 https://github.com/b3log/solo/issues/12514
+ */
+ public void refreshGitHub() {
+ if (!initService.isInited()) {
+ return;
+ }
+
+ final JSONObject preference = optionQueryService.getPreference();
+ if (null == preference) {
+ return;
+ }
+
+ if (!preference.optBoolean(Option.ID_C_PULL_GITHUB)) {
+ return;
+ }
+
+ JSONObject admin;
+ try {
+ admin = userRepository.getAdmin();
+ } catch (final Exception e) {
+ return;
+ }
+
+ if (null == admin) {
+ return;
+ }
+
+ final String githubId = admin.optString(UserExt.USER_GITHUB_ID);
+ final JSONArray gitHubRepos = GitHubs.getGitHubRepos(githubId);
+ if (null == gitHubRepos || gitHubRepos.isEmpty()) {
+ return;
+ }
+
+ JSONObject githubReposOpt = optionQueryService.getOptionById(Option.ID_C_GITHUB_REPOS);
+ if (null == githubReposOpt) {
+ githubReposOpt = new JSONObject();
+ githubReposOpt.put(Keys.OBJECT_ID, Option.ID_C_GITHUB_REPOS);
+ githubReposOpt.put(Option.OPTION_CATEGORY, Option.CATEGORY_C_GITHUB);
+ }
+ githubReposOpt.put(Option.OPTION_VALUE, gitHubRepos.toString());
+
+ try {
+ optionMgmtService.addOrUpdateOption(githubReposOpt);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Updates github repos option failed", e);
+
+ return;
+ }
+
+ final StringBuilder contentBuilder = new StringBuilder();
+ contentBuilder.append("\n");
+ contentBuilder.append("\n\n");
+ for (int i = 0; i < gitHubRepos.length(); i++) {
+ final JSONObject repo = gitHubRepos.optJSONObject(i);
+ final String url = repo.optString("githubrepoHTMLURL");
+ final String desc = repo.optString("githubrepoDescription");
+ final String name = repo.optString("githubrepoName");
+ final String stars = repo.optString("githubrepoStargazersCount");
+ final String watchers = repo.optString("githubrepoWatchersCount");
+ final String forks = repo.optString("githubrepoForksCount");
+ final String lang = repo.optString("githubrepoLanguage");
+ final String hp = repo.optString("githubrepoHomepage");
+
+ String stat = "[🤩`{watchers}`]({url}/watchers \"关注数\") [⭐️`{stars}`]({url}/stargazers \"收藏数\") [🖖`{forks}`]({url}/network/members \"分叉数\")";
+ stat = stat.replace("{watchers}", watchers).replace("{stars}", stars).replace("{url}", url).replace("{forks}", forks);
+ if (StringUtils.isNotBlank(hp)) {
+ stat += " [\uD83C\uDFE0`{hp}`]({hp} \"项目主页\")";
+ stat = stat.replace("{hp}", hp);
+ }
+ stat += "";
+ contentBuilder.append("### " + (i + 1) + ". [" + name + "](" + url + ") " + lang + " " + stat + "\n\n" + desc + "\n\n");
+ if (i < gitHubRepos.length() - 1) {
+ contentBuilder.append("\n\n---\n\n");
+ }
+ }
+ final String content = contentBuilder.toString();
+
+ try {
+ final String permalink = "/my-github-repos";
+ JSONObject article = articleRepository.getByPermalink(permalink);
+ if (null == article) {
+ article = new JSONObject();
+ article.put(Article.ARTICLE_AUTHOR_ID, admin.optString(Keys.OBJECT_ID));
+ article.put(Article.ARTICLE_TITLE, "我在 GitHub 上的开源项目");
+ article.put(Article.ARTICLE_ABSTRACT, Article.getAbstractText(content));
+ article.put(Article.ARTICLE_COMMENT_COUNT, 0);
+ article.put(Article.ARTICLE_TAGS_REF, "开源,GitHub");
+ article.put(Article.ARTICLE_PERMALINK, permalink);
+ article.put(Article.ARTICLE_COMMENTABLE, true);
+ article.put(Article.ARTICLE_CONTENT, content);
+ article.put(Article.ARTICLE_VIEW_PWD, "");
+ article.put(Article.ARTICLE_STATUS, Article.ARTICLE_STATUS_C_PUBLISHED);
+ article.put(Common.POST_TO_COMMUNITY, false);
+
+ final JSONObject addArticleReq = new JSONObject();
+ addArticleReq.put(Article.ARTICLE, article);
+ addArticle(addArticleReq);
+ } else {
+ article.put(Article.ARTICLE_CONTENT, content);
+
+ final String articleId = article.optString(Keys.OBJECT_ID);
+ final Transaction transaction = articleRepository.beginTransaction();
+ articleRepository.update(articleId, article);
+ transaction.commit();
+ }
+
+ final Transaction transaction = pageRepository.beginTransaction();
+ JSONObject page = pageRepository.getByPermalink(permalink);
+ if (null == page) {
+ page = new JSONObject();
+ final int maxOrder = pageRepository.getMaxOrder();
+ page.put(Page.PAGE_ORDER, maxOrder + 1);
+ page.put(Page.PAGE_TITLE, "我的开源");
+ page.put(Page.PAGE_OPEN_TARGET, "_self");
+ page.put(Page.PAGE_PERMALINK, permalink);
+ page.put(Page.PAGE_ICON, "/images/github-icon.png");
+ pageRepository.add(page);
+ } else {
+ page.put(Page.PAGE_OPEN_TARGET, "_self");
+ page.put(Page.PAGE_ICON, "/images/github-icon.png");
+ pageRepository.update(page.optString(Keys.OBJECT_ID), page);
+ }
+ transaction.commit();
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Updates github repos page failed", e);
+ }
+ }
+
+ /**
+ * Pushes an article specified by the given article id to community.
+ *
+ * @param articleId the given article id
+ */
+ public void pushArticleToCommunity(final String articleId) {
+ try {
+ final JSONObject article = articleRepository.get(articleId);
+ if (null == article) {
+ return;
+ }
+
+ article.put(Common.POST_TO_COMMUNITY, true);
+
+ final JSONObject data = new JSONObject().put(ARTICLE, article);
+ B3ArticleSender.pushArticleToRhy(data);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Pushes an article [id=" + articleId + "] to community failed", e);
+ }
+ }
+
+ /**
+ * Article comment count +1 for an article specified by the given article id.
+ *
+ * @param articleId the given article id
+ * @throws JSONException json exception
+ * @throws RepositoryException repository exception
+ */
+ public void incArticleCommentCount(final String articleId) throws JSONException, RepositoryException {
+ final JSONObject article = articleRepository.get(articleId);
+ final JSONObject newArticle = new JSONObject(article, JSONObject.getNames(article));
+ final int commentCnt = article.getInt(Article.ARTICLE_COMMENT_COUNT);
+ newArticle.put(Article.ARTICLE_COMMENT_COUNT, commentCnt + 1);
+ articleRepository.update(articleId, newArticle, ARTICLE_COMMENT_COUNT);
+ }
+
+ /**
+ * Cancels publish an article by the specified article id.
+ *
+ * @param articleId the specified article id
+ * @throws ServiceException service exception
+ */
+ public void cancelPublishArticle(final String articleId) throws ServiceException {
+ final Transaction transaction = articleRepository.beginTransaction();
+
+ try {
+ final JSONObject article = articleRepository.get(articleId);
+ article.put(ARTICLE_STATUS, ARTICLE_STATUS_C_DRAFT);
+ articleRepository.update(articleId, article, ARTICLE_STATUS);
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Cancels publish article failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Puts an article specified by the given article id to top or cancel top.
+ *
+ * @param articleId the given article id
+ * @param top the specified flag, {@code true} to top, {@code false} to
+ * cancel top
+ * @throws ServiceException service exception
+ */
+ public void topArticle(final String articleId, final boolean top) throws ServiceException {
+ final Transaction transaction = articleRepository.beginTransaction();
+
+ try {
+ final JSONObject topArticle = articleRepository.get(articleId);
+ topArticle.put(ARTICLE_PUT_TOP, top);
+ articleRepository.update(articleId, topArticle, ARTICLE_PUT_TOP);
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Can't put the article[oId{0}] to top", articleId);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Updates an article by the specified request json object.
+ *
+ * @param requestJSONObject the specified request json object, for example,
+ * {
+ * "article": {
+ * "oId": "",
+ * "articleTitle": "",
+ * "articleAbstract": "",
+ * "articleContent": "",
+ * "articleTags": "tag1,tag2,tag3", // optional, default set "待分类"
+ * "articlePermalink": "", // optional
+ * "articleStatus": int, // 0: published, 1: draft
+ * "articleSignId": "", // optional
+ * "articleCommentable": boolean,
+ * "articleViewPwd": ""
+ * }
+ * }
+ * @throws ServiceException service exception
+ */
+ public void updateArticle(final JSONObject requestJSONObject) throws ServiceException {
+ final Transaction transaction = articleRepository.beginTransaction();
+
+ try {
+ final JSONObject article = requestJSONObject.getJSONObject(ARTICLE);
+ String tagsString = article.optString(Article.ARTICLE_TAGS_REF);
+ tagsString = Tag.formatTags(tagsString, 4);
+ if (StringUtils.isBlank(tagsString)) {
+ tagsString = "待分类";
+ }
+ article.put(Article.ARTICLE_TAGS_REF, tagsString);
+
+ final String articleId = article.getString(Keys.OBJECT_ID);
+ // Set permalink
+ final JSONObject oldArticle = articleRepository.get(articleId);
+ final String permalink = getPermalinkForUpdateArticle(oldArticle, article, oldArticle.optLong(ARTICLE_CREATED));
+ article.put(ARTICLE_PERMALINK, permalink);
+
+ processTagsForArticleUpdate(oldArticle, article);
+
+ archiveDate(article);
+
+ if (!oldArticle.getString(Article.ARTICLE_PERMALINK).equals(permalink)) { // The permalink has been updated
+ // Updates related comments' links
+ processCommentsForArticleUpdate(article);
+ }
+
+ // Fill auto properties
+ fillAutoProperties(oldArticle, article);
+ // Set date
+ article.put(ARTICLE_UPDATED, oldArticle.getLong(ARTICLE_UPDATED));
+ final long now = System.currentTimeMillis();
+
+ // The article to update has no sign
+ if (!article.has(Article.ARTICLE_SIGN_ID)) {
+ article.put(Article.ARTICLE_SIGN_ID, "0");
+ }
+
+ article.put(ARTICLE_UPDATED, now);
+
+ final String articleImg1URL = getArticleImg1URL(article);
+ article.put(ARTICLE_IMG1_URL, articleImg1URL);
+
+ final String articleAbstractText = Article.getAbstractText(article);
+ article.put(ARTICLE_ABSTRACT_TEXT, articleAbstractText);
+
+ final boolean postToCommunity = article.optBoolean(Common.POST_TO_COMMUNITY);
+ article.remove(Common.POST_TO_COMMUNITY);
+ articleRepository.update(articleId, article);
+ article.put(Common.POST_TO_COMMUNITY, postToCommunity);
+
+ final boolean publishNewArticle = Article.ARTICLE_STATUS_C_DRAFT == oldArticle.optInt(ARTICLE_STATUS) && Article.ARTICLE_STATUS_C_PUBLISHED == article.optInt(ARTICLE_STATUS);
+ final JSONObject eventData = new JSONObject();
+ eventData.put(ARTICLE, article);
+ if (publishNewArticle) {
+ eventManager.fireEventAsynchronously(new Event<>(EventTypes.ADD_ARTICLE, eventData));
+ } else {
+ eventManager.fireEventAsynchronously(new Event<>(EventTypes.UPDATE_ARTICLE, eventData));
+ }
+
+ transaction.commit();
+ } catch (final ServiceException e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Updates an article failed", e);
+
+ throw e;
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Updates an article failed", e);
+
+ throw new ServiceException(e.getMessage());
+ }
+ }
+
+ /**
+ * Adds an article from the specified request json object.
+ *
+ * @param requestJSONObject the specified request json object, for example,
+ * {
+ * "article": {
+ * "articleAuthorId": "",
+ * "articleTitle": "",
+ * "articleAbstract": "",
+ * "articleContent": "",
+ * "articleTags": "tag1,tag2,tag3",
+ * "articleStatus": int, // 0: published, 1: draft
+ * "articlePermalink": "", // optional
+ * "postToCommunity": boolean, // optional
+ * "articleSignId": "" // optional, default is "0",
+ * "articleCommentable": boolean,
+ * "articleCommentCount": int, // optional, default is 0
+ * "articleViewPwd": "",
+ * "oId": "" // optional, generate it if not exists this key
+ * }
+ * }
+ * @return generated article id
+ * @throws ServiceException service exception
+ */
+ public String addArticle(final JSONObject requestJSONObject) throws ServiceException {
+ final Transaction transaction = articleRepository.beginTransaction();
+
+ try {
+ final JSONObject article = requestJSONObject.getJSONObject(Article.ARTICLE);
+ String ret = article.optString(Keys.OBJECT_ID);
+ if (StringUtils.isBlank(ret)) {
+ ret = Ids.genTimeMillisId();
+ article.put(Keys.OBJECT_ID, ret);
+ }
+
+ String tagsString = article.optString(Article.ARTICLE_TAGS_REF);
+ tagsString = Tag.formatTags(tagsString, 4);
+ if (StringUtils.isBlank(tagsString)) {
+ tagsString = "待分类";
+ }
+ article.put(Article.ARTICLE_TAGS_REF, tagsString);
+ final String[] tagTitles = tagsString.split(",");
+ final JSONArray tags = tag(tagTitles, article);
+
+ article.put(Article.ARTICLE_COMMENT_COUNT, article.optInt(Article.ARTICLE_COMMENT_COUNT));
+ article.put(Article.ARTICLE_VIEW_COUNT, 0);
+ if (!article.has(Article.ARTICLE_CREATED)) {
+ article.put(Article.ARTICLE_CREATED, System.currentTimeMillis());
+ }
+ article.put(Article.ARTICLE_UPDATED, article.optLong(Article.ARTICLE_CREATED));
+ article.put(Article.ARTICLE_PUT_TOP, false);
+
+ addTagArticleRelation(tags, article);
+
+ archiveDate(article);
+
+ final String permalink = getPermalinkForAddArticle(article);
+ article.put(Article.ARTICLE_PERMALINK, permalink);
+
+ final String signId = article.optString(Article.ARTICLE_SIGN_ID, "1");
+ article.put(Article.ARTICLE_SIGN_ID, signId);
+
+ article.put(Article.ARTICLE_RANDOM_DOUBLE, Math.random());
+
+ final String articleImg1URL = getArticleImg1URL(article);
+ article.put(ARTICLE_IMG1_URL, articleImg1URL);
+
+ final String articleAbstractText = Article.getAbstractText(article);
+ article.put(ARTICLE_ABSTRACT_TEXT, articleAbstractText);
+
+ final boolean postToCommunity = article.optBoolean(Common.POST_TO_COMMUNITY);
+ article.remove(Common.POST_TO_COMMUNITY);
+ articleRepository.add(article);
+ transaction.commit();
+
+ article.put(Common.POST_TO_COMMUNITY, postToCommunity);
+ if (Article.ARTICLE_STATUS_C_PUBLISHED == article.optInt(ARTICLE_STATUS)) {
+ final JSONObject eventData = new JSONObject();
+ eventData.put(Article.ARTICLE, article);
+ eventManager.fireEventAsynchronously(new Event<>(EventTypes.ADD_ARTICLE, eventData));
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ throw new ServiceException(e.getMessage());
+ }
+ }
+
+ /**
+ * Removes the article specified by the given id.
+ *
+ * @param articleId the given id
+ * @throws ServiceException service exception
+ */
+ public void removeArticle(final String articleId) throws ServiceException {
+ final Transaction transaction = articleRepository.beginTransaction();
+ try {
+ unArchiveDate(articleId);
+ removeTagArticleRelations(articleId);
+ articleRepository.remove(articleId);
+ commentRepository.removeComments(articleId);
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Removes an article[id=" + articleId + "] failed", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Updates the random values of articles fetched with the specified update
+ * count.
+ *
+ * @param updateCnt the specified update count
+ * @throws ServiceException service exception
+ */
+ public void updateArticlesRandomValue(final int updateCnt) throws ServiceException {
+ final Transaction transaction = articleRepository.beginTransaction();
+
+ try {
+ final List randomArticles = articleRepository.getRandomly(updateCnt);
+
+ for (final JSONObject article : randomArticles) {
+ article.put(Article.ARTICLE_RANDOM_DOUBLE, Math.random());
+
+ articleRepository.update(article.getString(Keys.OBJECT_ID), article, ARTICLE_RANDOM_DOUBLE);
+ }
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.WARN, "Updates article random value failed");
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Increments the view count of the article specified by the given article id.
+ *
+ * @param articleId the given article id
+ * @throws ServiceException service exception
+ */
+ public void incViewCount(final String articleId) throws ServiceException {
+ JSONObject article;
+
+ try {
+ article = articleRepository.get(articleId);
+
+ if (null == article) {
+ return;
+ }
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets article [id=" + articleId + "] failed", e);
+
+ return;
+ }
+
+ final Transaction transaction = articleRepository.beginTransaction();
+
+ try {
+ article.put(Article.ARTICLE_VIEW_COUNT, article.getInt(Article.ARTICLE_VIEW_COUNT) + 1);
+ articleRepository.update(articleId, article, ARTICLE_VIEW_COUNT);
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.WARN, "Updates article view count failed");
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Un-archive an article specified by the given specified article id.
+ *
+ * @param articleId the given article id
+ * @throws ServiceException service exception
+ */
+ private void unArchiveDate(final String articleId) throws ServiceException {
+ try {
+ final JSONObject archiveDateArticleRelation = archiveDateArticleRepository.getByArticleId(articleId);
+ final String archiveDateId = archiveDateArticleRelation.getString(ArchiveDate.ARCHIVE_DATE + "_" + Keys.OBJECT_ID);
+ final int publishedArticleCount = archiveDateArticleRepository.getPublishedArticleCount(archiveDateId);
+ if (1 > publishedArticleCount) {
+ archiveDateRepository.remove(archiveDateId);
+ }
+
+ archiveDateArticleRepository.remove(archiveDateArticleRelation.getString(Keys.OBJECT_ID));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Unarchive date for article[id=" + articleId + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Processes comments for article update.
+ *
+ * @param article the specified article to update
+ * @throws Exception exception
+ */
+ private void processCommentsForArticleUpdate(final JSONObject article) throws Exception {
+ final String articleId = article.getString(Keys.OBJECT_ID);
+
+ final List comments = commentRepository.getComments(articleId, 1, Integer.MAX_VALUE);
+ for (final JSONObject comment : comments) {
+ final String commentId = comment.getString(Keys.OBJECT_ID);
+ final String sharpURL = Comment.getCommentSharpURLForArticle(article, commentId);
+ comment.put(Comment.COMMENT_SHARP_URL, sharpURL);
+ if (StringUtils.isBlank(comment.optString(Comment.COMMENT_ORIGINAL_COMMENT_ID))) {
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, "");
+ }
+ if (StringUtils.isBlank(comment.optString(Comment.COMMENT_ORIGINAL_COMMENT_NAME))) {
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, "");
+ }
+
+ commentRepository.update(commentId, comment);
+ }
+ }
+
+ /**
+ * Processes tags for article update.
+ *
+ *
+ * - Un-tags old article, decrements tag reference count
+ * - Removes old article-tag relations
+ * - Saves new article-tag relations with tag reference count
+ *
+ *
+ * @param oldArticle the specified old article
+ * @param newArticle the specified new article
+ * @throws Exception exception
+ */
+ private void processTagsForArticleUpdate(final JSONObject oldArticle, final JSONObject newArticle) throws Exception {
+ final String oldArticleId = oldArticle.getString(Keys.OBJECT_ID);
+ final List oldTags = tagRepository.getByArticleId(oldArticleId);
+ final String tagsString = newArticle.getString(Article.ARTICLE_TAGS_REF);
+ String[] tagStrings = tagsString.split(",");
+ final List newTags = new ArrayList<>();
+
+ for (int i = 0; i < tagStrings.length; i++) {
+ final String tagTitle = tagStrings[i].trim();
+ JSONObject newTag = tagRepository.getByTitle(tagTitle);
+
+ if (null == newTag) {
+ newTag = new JSONObject();
+ newTag.put(Tag.TAG_TITLE, tagTitle);
+ }
+ newTags.add(newTag);
+ }
+
+ final List tagsDropped = new ArrayList<>();
+ final List tagsNeedToAdd = new ArrayList<>();
+ final List tagsUnchanged = new ArrayList<>();
+
+ for (final JSONObject newTag : newTags) {
+ final String newTagTitle = newTag.getString(Tag.TAG_TITLE);
+
+ if (!tagExists(newTagTitle, oldTags)) {
+ LOGGER.log(Level.DEBUG, "Tag need to add[title={0}]", newTagTitle);
+ tagsNeedToAdd.add(newTag);
+ } else {
+ tagsUnchanged.add(newTag);
+ }
+ }
+ for (final JSONObject oldTag : oldTags) {
+ final String oldTagTitle = oldTag.getString(Tag.TAG_TITLE);
+
+ if (!tagExists(oldTagTitle, newTags)) {
+ LOGGER.log(Level.DEBUG, "Tag dropped[title={0}]", oldTag);
+ tagsDropped.add(oldTag);
+ } else {
+ tagsUnchanged.remove(oldTag);
+ }
+ }
+
+ LOGGER.log(Level.DEBUG, "Tags unchanged [{0}]", tagsUnchanged);
+
+ final String[] tagIdsDropped = new String[tagsDropped.size()];
+ for (int i = 0; i < tagIdsDropped.length; i++) {
+ final JSONObject tag = tagsDropped.get(i);
+ final String id = tag.getString(Keys.OBJECT_ID);
+ tagIdsDropped[i] = id;
+ }
+
+ removeTagArticleRelations(oldArticleId, 0 == tagIdsDropped.length ? new String[]{"l0y0l"} : tagIdsDropped);
+
+ tagStrings = new String[tagsNeedToAdd.size()];
+ for (int i = 0; i < tagStrings.length; i++) {
+ final JSONObject tag = tagsNeedToAdd.get(i);
+ final String tagTitle = tag.getString(Tag.TAG_TITLE);
+ tagStrings[i] = tagTitle;
+ }
+ final JSONArray tags = tag(tagStrings, newArticle);
+
+ addTagArticleRelation(tags, newArticle);
+ }
+
+ /**
+ * Removes tag-article relations by the specified article id and tag ids of the relations to be removed.
+ *
+ * Removes all relations if not specified the tag ids.
+ *
+ *
+ * @param articleId the specified article id
+ * @param tagIds the specified tag ids of the relations to be removed
+ * @throws JSONException json exception
+ * @throws RepositoryException repository exception
+ */
+ private void removeTagArticleRelations(final String articleId, final String... tagIds) throws JSONException, RepositoryException {
+ final List tagIdList = Arrays.asList(tagIds);
+ final List tagArticleRelations = tagArticleRepository.getByArticleId(articleId);
+
+ for (int i = 0; i < tagArticleRelations.size(); i++) {
+ final JSONObject tagArticleRelation = tagArticleRelations.get(i);
+ String relationId;
+ if (tagIdList.isEmpty()) { // Removes all if un-specified
+ relationId = tagArticleRelation.getString(Keys.OBJECT_ID);
+ tagArticleRepository.remove(relationId);
+ } else {
+ if (tagIdList.contains(tagArticleRelation.getString(Tag.TAG + "_" + Keys.OBJECT_ID))) {
+ relationId = tagArticleRelation.getString(Keys.OBJECT_ID);
+ tagArticleRepository.remove(relationId);
+ }
+ }
+
+ final String tagId = tagArticleRelation.optString(Tag.TAG + "_" + Keys.OBJECT_ID);
+ final int articleCount = tagArticleRepository.getArticleCount(tagId);
+ if (1 > articleCount) {
+ categoryTagRepository.removeByTagId(tagId);
+ tagRepository.remove(tagId);
+ }
+ }
+ }
+
+ /**
+ * Adds relation of the specified tags and article.
+ *
+ * @param tags the specified tags
+ * @param article the specified article
+ * @throws RepositoryException repository exception
+ */
+ private void addTagArticleRelation(final JSONArray tags, final JSONObject article) throws RepositoryException {
+ for (int i = 0; i < tags.length(); i++) {
+ final JSONObject tag = tags.optJSONObject(i);
+ final JSONObject tagArticleRelation = new JSONObject();
+
+ tagArticleRelation.put(Tag.TAG + "_" + Keys.OBJECT_ID, tag.optString(Keys.OBJECT_ID));
+ tagArticleRelation.put(Article.ARTICLE + "_" + Keys.OBJECT_ID, article.optString(Keys.OBJECT_ID));
+
+ tagArticleRepository.add(tagArticleRelation);
+ }
+ }
+
+ /**
+ * Tags the specified article with the specified tag titles.
+ *
+ * @param tagTitles the specified tag titles
+ * @param article the specified article
+ * @return an array of tags
+ * @throws RepositoryException repository exception
+ */
+ private JSONArray tag(final String[] tagTitles, final JSONObject article) throws RepositoryException {
+ final JSONArray ret = new JSONArray();
+
+ for (int i = 0; i < tagTitles.length; i++) {
+ final String tagTitle = tagTitles[i].trim();
+ JSONObject tag = tagRepository.getByTitle(tagTitle);
+ String tagId;
+
+ if (null == tag) {
+ LOGGER.log(Level.TRACE, "Found a new tag[title={0}] in article[title={1}]",
+ tagTitle, article.optString(Article.ARTICLE_TITLE));
+ tag = new JSONObject();
+ tag.put(Tag.TAG_TITLE, tagTitle);
+ tagId = tagRepository.add(tag);
+ tag.put(Keys.OBJECT_ID, tagId);
+ } else {
+ tagId = tag.optString(Keys.OBJECT_ID);
+ LOGGER.log(Level.TRACE, "Found a existing tag[title={0}, id={1}] in article[title={2}]",
+ tag.optString(Tag.TAG_TITLE), tag.optString(Keys.OBJECT_ID), article.optString(Article.ARTICLE_TITLE));
+ final JSONObject tagTmp = new JSONObject();
+ tagTmp.put(Keys.OBJECT_ID, tagId);
+ tagTmp.put(Tag.TAG_TITLE, tagTitle);
+ tagRepository.update(tagId, tagTmp);
+ }
+
+ ret.put(tag);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Archive the create date with the specified article.
+ *
+ * @param article the specified article, for example,
+ * {
+ * "oId": "",
+ * ....
+ * }
+ * @throws RepositoryException repository exception
+ */
+ private void archiveDate(final JSONObject article) throws RepositoryException {
+ if (Article.ARTICLE_STATUS_C_PUBLISHED != article.optInt(ARTICLE_STATUS)) {
+ return;
+ }
+
+ final long created = article.optLong(Keys.OBJECT_ID);
+ final String createDateString = DateFormatUtils.format(created, "yyyy/MM");
+ JSONObject archiveDate = archiveDateRepository.getByArchiveDate(createDateString);
+ if (null == archiveDate) {
+ archiveDate = new JSONObject();
+ try {
+ archiveDate.put(ArchiveDate.ARCHIVE_TIME, DateUtils.parseDate(createDateString, new String[]{"yyyy/MM"}).getTime());
+ archiveDateRepository.add(archiveDate);
+ } catch (final ParseException e) {
+ LOGGER.log(Level.ERROR, e.getMessage(), e);
+ throw new RepositoryException(e);
+ }
+ }
+
+ final JSONObject archiveDateArticleRelation = new JSONObject();
+ archiveDateArticleRelation.put(ArchiveDate.ARCHIVE_DATE + "_" + Keys.OBJECT_ID, archiveDate.optString(Keys.OBJECT_ID));
+ archiveDateArticleRelation.put(Article.ARTICLE + "_" + Keys.OBJECT_ID, article.optString(Keys.OBJECT_ID));
+ archiveDateArticleRepository.add(archiveDateArticleRelation);
+ }
+
+ /**
+ * Fills 'auto' properties for the specified article and old article.
+ *
+ * Some properties of an article are not been changed while article
+ * updating, these properties are called 'auto' properties.
+ *
+ *
+ * The property(named {@value org.b3log.solo.model.Article#ARTICLE_RANDOM_DOUBLE}) of the specified
+ * article will be regenerated.
+ *
+ *
+ * @param oldArticle the specified old article
+ * @param article the specified article
+ * @throws JSONException json exception
+ */
+ private void fillAutoProperties(final JSONObject oldArticle, final JSONObject article) throws JSONException {
+ final long created = oldArticle.getLong(ARTICLE_CREATED);
+ article.put(ARTICLE_CREATED, created);
+ article.put(ARTICLE_COMMENT_COUNT, oldArticle.getInt(ARTICLE_COMMENT_COUNT));
+ article.put(ARTICLE_VIEW_COUNT, oldArticle.getInt(ARTICLE_VIEW_COUNT));
+ article.put(ARTICLE_PUT_TOP, oldArticle.getBoolean(ARTICLE_PUT_TOP));
+ article.put(ARTICLE_AUTHOR_ID, oldArticle.getString(ARTICLE_AUTHOR_ID));
+ article.put(ARTICLE_RANDOM_DOUBLE, Math.random());
+ }
+
+ /**
+ * Gets article permalink for adding article with the specified article.
+ *
+ * @param article the specified article
+ * @return permalink
+ * @throws ServiceException if invalid permalink occurs
+ */
+ private String getPermalinkForAddArticle(final JSONObject article) throws ServiceException {
+ final long date = article.optLong(Article.ARTICLE_CREATED);
+ String ret = article.optString(Article.ARTICLE_PERMALINK);
+ if (StringUtils.isBlank(ret)) {
+ ret = "/articles/" + DateFormatUtils.format(date, "yyyy/MM/dd") + "/" + article.optString(Keys.OBJECT_ID) + ".html";
+ }
+
+ if (!ret.startsWith("/")) {
+ ret = "/" + ret;
+ }
+
+ if (PermalinkQueryService.invalidArticlePermalinkFormat(ret)) {
+ throw new ServiceException(langPropsService.get("invalidPermalinkFormatLabel"));
+ }
+
+ if (permalinkQueryService.exist(ret)) {
+ throw new ServiceException(langPropsService.get("duplicatedPermalinkLabel"));
+ }
+
+ return ret.replaceAll(" ", "-");
+ }
+
+ /**
+ * Gets article permalink for updating article with the specified old article, article, created at.
+ *
+ * @param oldArticle the specified old article
+ * @param article the specified article
+ * @param created the specified created
+ * @return permalink
+ * @throws ServiceException if invalid permalink occurs
+ * @throws JSONException json exception
+ */
+ private String getPermalinkForUpdateArticle(final JSONObject oldArticle, final JSONObject article, final long created)
+ throws ServiceException, JSONException {
+ final String articleId = article.getString(Keys.OBJECT_ID);
+ String ret = article.optString(ARTICLE_PERMALINK).trim();
+ final String oldPermalink = oldArticle.getString(ARTICLE_PERMALINK);
+
+ if (!oldPermalink.equals(ret)) {
+ if (StringUtils.isBlank(ret)) {
+ ret = "/articles/" + DateFormatUtils.format(created, "yyyy/MM/dd") + "/" + articleId + ".html";
+ }
+
+ if (!ret.startsWith("/")) {
+ ret = "/" + ret;
+ }
+
+ if (PermalinkQueryService.invalidArticlePermalinkFormat(ret)) {
+ throw new ServiceException(langPropsService.get("invalidPermalinkFormatLabel"));
+ }
+
+ if (!oldPermalink.equals(ret) && permalinkQueryService.exist(ret)) {
+ throw new ServiceException(langPropsService.get("duplicatedPermalinkLabel"));
+ }
+ }
+
+ return ret.replaceAll(" ", "-");
+ }
+
+ /**
+ * Determines whether the specified tag title exists in the specified tags.
+ *
+ * @param tagTitle the specified tag title
+ * @param tags the specified tags
+ * @return {@code true} if it exists, {@code false} otherwise
+ * @throws JSONException json exception
+ */
+ private static boolean tagExists(final String tagTitle, final List tags) throws JSONException {
+ for (final JSONObject tag : tags) {
+ if (tag.getString(Tag.TAG_TITLE).equals(tagTitle)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/ArticleQueryService.java b/src/main/java/org/b3log/solo/service/ArticleQueryService.java
new file mode 100644
index 00000000..d78320db
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/ArticleQueryService.java
@@ -0,0 +1,994 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.apache.commons.lang.StringUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.Pagination;
+import org.b3log.latke.model.Role;
+import org.b3log.latke.model.User;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.util.CollectionUtils;
+import org.b3log.latke.util.Paginator;
+import org.b3log.latke.util.Stopwatchs;
+import org.b3log.solo.model.*;
+import org.b3log.solo.repository.*;
+import org.b3log.solo.util.Markdowns;
+import org.b3log.solo.util.Solos;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.*;
+
+/**
+ * Article query service.
+ *
+ * @author Liang Ding
+ * @author ArmstrongCN
+ * @author Zephyr
+ * @author Liyuan Li
+ * @version 1.3.3.0, Sep 11, 2019
+ * @since 0.3.5
+ */
+@Service
+public class ArticleQueryService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ArticleQueryService.class);
+
+ /**
+ * User repository.
+ */
+ @Inject
+ private UserRepository userRepository;
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Category-Tag repository.
+ */
+ @Inject
+ private CategoryTagRepository categoryTagRepository;
+
+ /**
+ * User service.
+ */
+ @Inject
+ private UserQueryService userQueryService;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Tag repository.
+ */
+ @Inject
+ private TagRepository tagRepository;
+
+ /**
+ * Tag-Article repository.
+ */
+ @Inject
+ private TagArticleRepository tagArticleRepository;
+
+ /**
+ * Archive date-Article repository.
+ */
+ @Inject
+ private ArchiveDateArticleRepository archiveDateArticleRepository;
+
+ /**
+ * Statistic query service.
+ */
+ @Inject
+ private StatisticQueryService statisticQueryService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Searches articles with the specified keyword.
+ *
+ * @param keyword the specified keyword
+ * @param currentPageNum the specified current page number
+ * @param pageSize the specified page size
+ * @return result
+ */
+ public JSONObject searchKeyword(final String keyword, final int currentPageNum, final int pageSize) {
+ final JSONObject ret = new JSONObject();
+ ret.put(Article.ARTICLES, (Object) Collections.emptyList());
+
+ final JSONObject pagination = new JSONObject();
+ ret.put(Pagination.PAGINATION, pagination);
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, 0);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, (Object) Collections.emptyList());
+
+ try {
+ final Query query = new Query().setFilter(
+ CompositeFilterOperator.and(new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED),
+ CompositeFilterOperator.or(
+ new PropertyFilter(Article.ARTICLE_TITLE, FilterOperator.LIKE, "%" + keyword + "%"),
+ new PropertyFilter(Article.ARTICLE_CONTENT, FilterOperator.LIKE, "%" + keyword + "%")))).
+ addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING).setPage(currentPageNum, pageSize);
+
+ final JSONObject result = articleRepository.get(query);
+
+ final int pageCount = result.optJSONObject(Pagination.PAGINATION).optInt(Pagination.PAGINATION_PAGE_COUNT);
+ final JSONObject preference = optionQueryService.getPreference();
+ final int windowSize = preference.optInt(Option.ID_C_ARTICLE_LIST_PAGINATION_WINDOW_SIZE);
+ final List pageNums = Paginator.paginate(currentPageNum, pageSize, pageCount, windowSize);
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, pageCount);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, (Object) pageNums);
+
+ final List articles = CollectionUtils.jsonArrayToList(result.optJSONArray(Keys.RESULTS));
+ ret.put(Article.ARTICLES, (Object) articles);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Searches articles error", e);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Gets category articles.
+ *
+ * @param categoryId the specified category id
+ * @param currentPageNum the specified current page number
+ * @param pageSize the specified page size
+ * @return result
+ * @throws ServiceException service exception
+ */
+ public JSONObject getCategoryArticles(final String categoryId, final int currentPageNum, final int pageSize) throws ServiceException {
+ final JSONObject ret = new JSONObject();
+ ret.put(Keys.RESULTS, (Object) Collections.emptyList());
+
+ final JSONObject pagination = new JSONObject();
+ ret.put(Pagination.PAGINATION, pagination);
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, 0);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, (Object) Collections.emptyList());
+
+ try {
+ final JSONArray categoryTags = categoryTagRepository.getByCategoryId(categoryId, 1, Integer.MAX_VALUE).optJSONArray(Keys.RESULTS);
+ if (categoryTags.length() <= 0) {
+ return ret;
+ }
+
+ final List tagIds = new ArrayList<>();
+ for (int i = 0; i < categoryTags.length(); i++) {
+ tagIds.add(categoryTags.optJSONObject(i).optString(Tag.TAG + "_" + Keys.OBJECT_ID));
+ }
+
+ final StringBuilder queryCount = new StringBuilder("SELECT count(DISTINCT(b3_solo_article.oId)) as C FROM ");
+ final StringBuilder queryList = new StringBuilder("SELECT DISTINCT(b3_solo_article.oId) ").append(" FROM ");
+ final StringBuilder queryStr = new StringBuilder(articleRepository.getName() + " AS b3_solo_article,").
+ append(tagArticleRepository.getName() + " AS b3_solo_tag_article").
+ append(" WHERE b3_solo_article.oId=b3_solo_tag_article.article_oId ").
+ append(" AND b3_solo_article.").append(Article.ARTICLE_STATUS).append("=?").
+ append(" AND ").append("b3_solo_tag_article.tag_oId").append(" IN (");
+ for (int i = 0; i < tagIds.size(); i++) {
+ queryStr.append(" ").append(tagIds.get(i));
+ if (i < (tagIds.size() - 1)) {
+ queryStr.append(",");
+ }
+ }
+ queryStr.append(") ORDER BY ").append("b3_solo_tag_article.oId DESC");
+
+ final List tagArticlesCountResult = articleRepository.
+ select(queryCount.append(queryStr.toString()).toString(), Article.ARTICLE_STATUS_C_PUBLISHED);
+ queryStr.append(" LIMIT ").append((currentPageNum - 1) * pageSize).append(",").append(pageSize);
+ final List tagArticles = articleRepository.
+ select(queryList.append(queryStr.toString()).toString(), Article.ARTICLE_STATUS_C_PUBLISHED);
+ if (tagArticles.size() <= 0) {
+ return ret;
+ }
+
+ final int tagArticlesCount = tagArticlesCountResult == null ? 0 : tagArticlesCountResult.get(0).optInt("C");
+ final int pageCount = (int) Math.ceil(tagArticlesCount / (double) pageSize);
+ final JSONObject preference = optionQueryService.getPreference();
+ final int windowSize = preference.optInt(Option.ID_C_ARTICLE_LIST_PAGINATION_WINDOW_SIZE);
+ final List pageNums = Paginator.paginate(currentPageNum, pageSize, pageCount, windowSize);
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, pageCount);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, (Object) pageNums);
+ pagination.put(Pagination.PAGINATION_RECORD_COUNT, tagArticlesCount);
+
+ final Set articleIds = new HashSet<>();
+ for (int i = 0; i < tagArticles.size(); i++) {
+ articleIds.add(tagArticles.get(i).optString(Keys.OBJECT_ID));
+ }
+ final Query query = new Query().setFilter(new PropertyFilter(Keys.OBJECT_ID, FilterOperator.IN, articleIds)).
+ setPageCount(1).addSort(Keys.OBJECT_ID, SortDirection.DESCENDING);
+ final List articles = new ArrayList<>();
+ final JSONArray articleArray = articleRepository.get(query).optJSONArray(Keys.RESULTS);
+ for (int i = 0; i < articleArray.length(); i++) {
+ final JSONObject article = articleArray.optJSONObject(i);
+ article.put(Article.ARTICLE_CREATE_TIME, article.optLong(Article.ARTICLE_CREATED));
+ article.put(Article.ARTICLE_T_CREATE_DATE, new Date(article.optLong(Article.ARTICLE_CREATED)));
+ article.put(Article.ARTICLE_T_UPDATE_DATE, new Date(article.optLong(Article.ARTICLE_UPDATED)));
+ articles.add(article);
+ }
+ ret.put(Keys.RESULTS, (Object) articles);
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets category articles error", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Can the specified user access an article specified by the given article id?
+ *
+ * @param articleId the given article id
+ * @param user the specified user
+ * @return {@code true} if the current user can access the article, {@code false} otherwise
+ * @throws ServiceException service exception
+ */
+ public boolean canAccessArticle(final String articleId, final JSONObject user) throws ServiceException {
+ if (StringUtils.isBlank(articleId)) {
+ return false;
+ }
+
+ if (null == user) {
+ return false;
+ }
+
+ if (Role.ADMIN_ROLE.equals(user.optString(User.USER_ROLE))) {
+ return true;
+ }
+
+ try {
+ final JSONObject article = articleRepository.get(articleId);
+ final String currentUserId = user.getString(Keys.OBJECT_ID);
+
+ return article.getString(Article.ARTICLE_AUTHOR_ID).equals(currentUserId);
+ } catch (final Exception e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets time of the recent updated article.
+ *
+ * @return time of the recent updated article, returns {@code 0} if not found
+ */
+ public long getRecentArticleTime() {
+ try {
+ final List recentArticles = articleRepository.getRecentArticles(1);
+ if (recentArticles.isEmpty()) {
+ return 0;
+ }
+
+ final JSONObject recentArticle = recentArticles.get(0);
+
+ return recentArticle.getLong(Article.ARTICLE_UPDATED);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets recent article time failed", e);
+
+ return 0;
+ }
+ }
+
+ /**
+ * Gets the specified article's author.
+ *
+ * The specified article has a property {@value Article#ARTICLE_AUTHOR_ID}, this method will use this property to
+ * get a user from users.
+ *
+ *
+ * If can't find the specified article's author (i.e. the author has been removed by administrator), returns
+ * administrator.
+ *
+ *
+ * @param article the specified article
+ * @return user, {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getAuthor(final JSONObject article) throws ServiceException {
+ try {
+ final String userId = article.getString(Article.ARTICLE_AUTHOR_ID);
+ JSONObject ret = userRepository.get(userId);
+ if (null == ret) {
+ LOGGER.log(Level.WARN, "Gets author of article failed, assumes the administrator is the author of this article [id={0}]",
+ article.getString(Keys.OBJECT_ID));
+ // This author may be deleted by admin, use admin as the author of this article
+ ret = userRepository.getAdmin();
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets author of article [id={0}] failed", article.optString(Keys.OBJECT_ID));
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets the sign of an article specified by the sign id.
+ *
+ * @param signId the specified article id
+ * @param preference the specified preference
+ * @return article sign, returns the default sign (which oId is "1") if not found
+ * @throws JSONException json exception
+ */
+ public JSONObject getSign(final String signId, final JSONObject preference) throws JSONException {
+ final JSONArray signs = new JSONArray(preference.getString(Option.ID_C_SIGNS));
+ JSONObject defaultSign = null;
+ for (int i = 0; i < signs.length(); i++) {
+ final JSONObject ret = signs.getJSONObject(i);
+
+ if (signId.equals(ret.optString(Keys.OBJECT_ID))) {
+ return ret;
+ }
+
+ if ("1".equals(ret.optString(Keys.OBJECT_ID))) {
+ defaultSign = ret;
+ }
+ }
+
+ LOGGER.log(Level.WARN, "Can not find the sign [id={0}], returns a default sign [id=1]", signId);
+
+ return defaultSign;
+ }
+
+ /**
+ * Determines the specified article has updated.
+ *
+ * @param article the specified article
+ * @return {@code true} if it has updated, {@code false} otherwise
+ * @throws JSONException json exception
+ */
+ public boolean hasUpdated(final JSONObject article) throws JSONException {
+ final long updateDate = article.getLong(Article.ARTICLE_UPDATED);
+ final long createDate = article.getLong(Article.ARTICLE_CREATED);
+
+ return createDate != updateDate;
+ }
+
+ /**
+ * Gets the recent articles with the specified fetch size.
+ *
+ * @param fetchSize the specified fetch size
+ * @return a list of json object, its size less or equal to the specified fetch size
+ */
+ public List getRecentArticles(final int fetchSize) {
+ try {
+ return articleRepository.getRecentArticles(fetchSize);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets recent articles failed", e);
+
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Gets an article by the specified article id.
+ *
+ * Note: The article content and abstract is raw (no editor type processing).
+ *
+ *
+ * @param articleId the specified article id
+ * @return for example,
+ * {
+ * "article": {
+ * "oId": "",
+ * "articleTitle": "",
+ * "articleAbstract": "",
+ * "articleContent": "",
+ * "articlePermalink": "",
+ * "articleCreateDate": java.util.Date,
+ * "articleTags": [{
+ * "oId": "",
+ * "tagTitle": ""
+ * }, ....],
+ * "articleSignId": "",
+ * "articleViewPwd": "",
+ * "signs": [{
+ * "oId": "",
+ * "signHTML": ""
+ * }, ....]
+ * }
+ * }
+ *
, returns {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getArticle(final String articleId) throws ServiceException {
+ try {
+ final JSONObject ret = new JSONObject();
+ final JSONObject article = articleRepository.get(articleId);
+ if (null == article) {
+ return null;
+ }
+
+ ret.put(Article.ARTICLE, article);
+
+ // Tags
+ final JSONArray tags = new JSONArray();
+ final List tagArticleRelations = tagArticleRepository.getByArticleId(articleId);
+ for (final JSONObject tagArticleRelation : tagArticleRelations) {
+ final String tagId = tagArticleRelation.getString(Tag.TAG + "_" + Keys.OBJECT_ID);
+ final JSONObject tag = tagRepository.get(tagId);
+
+ tags.put(tag);
+ }
+ article.put(Article.ARTICLE_TAGS_REF, tags);
+
+ // Signs
+ final JSONObject preference = optionQueryService.getPreference();
+ article.put(Sign.SIGNS, new JSONArray(preference.getString(Option.ID_C_SIGNS)));
+ // Remove unused properties
+ article.remove(Article.ARTICLE_AUTHOR_ID);
+ article.remove(Article.ARTICLE_COMMENT_COUNT);
+ article.remove(Article.ARTICLE_PUT_TOP);
+ article.remove(Article.ARTICLE_UPDATED);
+ article.remove(Article.ARTICLE_VIEW_COUNT);
+ article.remove(Article.ARTICLE_RANDOM_DOUBLE);
+
+ LOGGER.log(Level.DEBUG, "Got an article [id={0}]", articleId);
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets an article failed", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets articles(by crate date descending) by the specified request json object.
+ *
+ * Specified the "excludes" for results properties exclusion.
+ *
+ *
+ * @param requestJSONObject the specified request json object, for example,
+ * "paginationCurrentPageNum": 1,
+ * "paginationPageSize": 20,
+ * "paginationWindowSize": 10,
+ * "articleStatus": int,
+ * "keyword": "", // Optional search keyword
+ * "excludes": ["", ....], // Optional
+ * "enableArticleUpdateHint": bool // Optional
+ * see {@link Pagination} for more details
+ * @return for example,
+ * {
+ * "pagination": {
+ * "paginationPageCount": 100,
+ * "paginationPageNums": [1, 2, 3, 4, 5]
+ * },
+ * "articles": [{
+ * "oId": "",
+ * "articleTitle": "",
+ * "articleCommentCount": int,
+ * "articleCreateTime"; long,
+ * "articleViewCount": int,
+ * "articleTags": "tag1, tag2, ....",
+ * "articlePutTop": boolean,
+ * "articleSignId": "",
+ * "articleViewPwd": "",
+ * .... // Specified by the "excludes"
+ * }, ....]
+ * }
+ *
, order by article update date and sticky(put top).
+ * @see Pagination
+ */
+ public JSONObject getArticles(final JSONObject requestJSONObject) {
+ final JSONObject ret = new JSONObject();
+
+ try {
+ final int currentPageNum = requestJSONObject.getInt(Pagination.PAGINATION_CURRENT_PAGE_NUM);
+ final int pageSize = requestJSONObject.getInt(Pagination.PAGINATION_PAGE_SIZE);
+ final int windowSize = requestJSONObject.getInt(Pagination.PAGINATION_WINDOW_SIZE);
+ final int articleStatus = requestJSONObject.optInt(Article.ARTICLE_STATUS, Article.ARTICLE_STATUS_C_PUBLISHED);
+
+ final Query query = new Query().setPage(currentPageNum, pageSize).
+ addSort(Article.ARTICLE_PUT_TOP, SortDirection.DESCENDING);
+ if (requestJSONObject.optBoolean(Option.ID_C_ENABLE_ARTICLE_UPDATE_HINT)) {
+ query.addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING);
+ } else {
+ query.addSort(Article.ARTICLE_CREATED, SortDirection.DESCENDING);
+ }
+ final String keyword = requestJSONObject.optString(Common.KEYWORD);
+ if (StringUtils.isBlank(keyword)) {
+ query.setFilter(new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, articleStatus));
+ } else {
+ query.setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, articleStatus),
+ CompositeFilterOperator.or(
+ new PropertyFilter(Article.ARTICLE_TITLE, FilterOperator.LIKE, "%" + keyword + "%"),
+ new PropertyFilter(Article.ARTICLE_TAGS_REF, FilterOperator.LIKE, "%" + keyword + "%")
+ )
+ ));
+ }
+
+ final JSONObject result = articleRepository.get(query);
+ final int pageCount = result.optJSONObject(Pagination.PAGINATION).optInt(Pagination.PAGINATION_PAGE_COUNT);
+ final JSONObject pagination = new JSONObject();
+ ret.put(Pagination.PAGINATION, pagination);
+ final List pageNums = Paginator.paginate(currentPageNum, pageSize, pageCount, windowSize);
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, pageCount);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, pageNums);
+
+ final JSONArray articles = result.getJSONArray(Keys.RESULTS);
+ JSONArray excludes = requestJSONObject.optJSONArray(Keys.EXCLUDES);
+ excludes = null == excludes ? new JSONArray() : excludes;
+
+ for (int i = 0; i < articles.length(); i++) {
+ final JSONObject article = articles.getJSONObject(i);
+ final JSONObject author = getAuthor(article);
+ final String authorName = author.getString(User.USER_NAME);
+ article.put(Common.AUTHOR_NAME, authorName);
+ article.put(Article.ARTICLE_CREATE_TIME, article.getLong(Article.ARTICLE_CREATED));
+ article.put(Article.ARTICLE_UPDATE_TIME, article.getLong(Article.ARTICLE_UPDATED));
+
+ // Remove unused properties
+ for (int j = 0; j < excludes.length(); j++) {
+ article.remove(excludes.optString(j));
+ }
+ }
+
+ ret.put(Article.ARTICLES, articles);
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets articles failed", e);
+
+ return null;
+ }
+ }
+
+ /**
+ * Gets a list of published articles with the specified tag id, current page number and page size.
+ *
+ * @param tagId the specified tag id
+ * @param currentPageNum the specified current page number
+ * @param pageSize the specified page size
+ * @return result, returns {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getArticlesByTag(final String tagId, final int currentPageNum, final int pageSize) throws ServiceException {
+ try {
+ JSONObject result = tagArticleRepository.getByTagId(tagId, currentPageNum, pageSize);
+ if (null == result) {
+ return null;
+ }
+ final JSONArray tagArticleRelations = result.getJSONArray(Keys.RESULTS);
+ if (0 == tagArticleRelations.length()) {
+ return null;
+ }
+ final JSONObject pagination = result.optJSONObject(Pagination.PAGINATION);
+
+ final Set articleIds = new HashSet<>();
+ for (int i = 0; i < tagArticleRelations.length(); i++) {
+ final JSONObject tagArticleRelation = tagArticleRelations.getJSONObject(i);
+ final String articleId = tagArticleRelation.getString(Article.ARTICLE + "_" + Keys.OBJECT_ID);
+ articleIds.add(articleId);
+ }
+
+ final List retArticles = new ArrayList<>();
+
+ final Query query = new Query().setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Keys.OBJECT_ID, FilterOperator.IN, articleIds),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ setPageCount(1).
+ addSort(Keys.OBJECT_ID, SortDirection.DESCENDING);
+ final List articles = articleRepository.getList(query);
+ for (final JSONObject article : articles) {
+ article.put(Article.ARTICLE_CREATE_TIME, article.getLong(Article.ARTICLE_CREATED));
+ article.put(Article.ARTICLE_T_CREATE_DATE, new Date(article.getLong(Article.ARTICLE_CREATED)));
+ article.put(Article.ARTICLE_T_UPDATE_DATE, new Date(article.optLong(Article.ARTICLE_UPDATED)));
+ retArticles.add(article);
+ }
+ final JSONObject ret = new JSONObject();
+ ret.put(Pagination.PAGINATION, pagination);
+ ret.put(Keys.RESULTS, (Object) retArticles);
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets articles by tag [id=" + tagId + "] failed", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets a list of published articles with the specified archive date id, current page number and page size.
+ *
+ * @param archiveDateId the specified archive date id
+ * @param currentPageNum the specified current page number
+ * @param pageSize the specified page size
+ * @return a list of articles, returns an empty list if not found
+ * @throws ServiceException service exception
+ */
+ public List getArticlesByArchiveDate(final String archiveDateId, final int currentPageNum, final int pageSize) throws ServiceException {
+ try {
+ JSONObject result = archiveDateArticleRepository.getByArchiveDateId(archiveDateId, currentPageNum, pageSize);
+ final JSONArray relations = result.getJSONArray(Keys.RESULTS);
+ if (0 == relations.length()) {
+ return Collections.emptyList();
+ }
+
+ final Set articleIds = new HashSet<>();
+ for (int i = 0; i < relations.length(); i++) {
+ final JSONObject relation = relations.getJSONObject(i);
+ final String articleId = relation.getString(Article.ARTICLE + "_" + Keys.OBJECT_ID);
+
+ articleIds.add(articleId);
+ }
+
+ final List ret = new ArrayList<>();
+ final Query query = new Query().setFilter(CompositeFilterOperator.and(
+ new PropertyFilter(Keys.OBJECT_ID, FilterOperator.IN, articleIds),
+ new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED))).
+ setPageCount(1).
+ addSort(Keys.OBJECT_ID, SortDirection.DESCENDING);
+ final List articles = articleRepository.getList(query);
+ for (final JSONObject article : articles) {
+ article.put(Article.ARTICLE_CREATE_TIME, article.getLong(Article.ARTICLE_CREATED));
+ article.put(Article.ARTICLE_T_CREATE_DATE, new Date(article.getLong(Article.ARTICLE_CREATED)));
+ article.put(Article.ARTICLE_T_UPDATE_DATE, new Date(article.optLong(Article.ARTICLE_UPDATED)));
+ ret.add(article);
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets articles by archive date[id=" + archiveDateId + "] failed", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets a list of articles randomly with the specified fetch size.
+ *
+ * Note: The article content and abstract is raw (no editor type processing).
+ *
+ *
+ * @param fetchSize the specified fetch size
+ * @return a list of json objects, its size less or equal to the specified fetch size
+ * @throws ServiceException service exception
+ */
+ public List getArticlesRandomly(final int fetchSize) throws ServiceException {
+ try {
+ final List ret = articleRepository.getRandomly(fetchSize);
+ removeUnusedProperties(ret);
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets articles randomly failed[fetchSize=" + fetchSize + "]", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets the relevant published articles of the specified article.
+ *
+ * Note: The article content and abstract is raw (no editor type processing).
+ *
+ *
+ * @param article the specified article
+ * @param preference the specified preference
+ * @return a list of articles, returns an empty list if not found
+ */
+ public List getRelevantArticles(final JSONObject article, final JSONObject preference) {
+ try {
+ final int displayCnt = preference.getInt(Option.ID_C_RELEVANT_ARTICLES_DISPLAY_CNT);
+ final String[] tagTitles = article.getString(Article.ARTICLE_TAGS_REF).split(",");
+ final int maxTagCnt = displayCnt > tagTitles.length ? tagTitles.length : displayCnt;
+ final String articleId = article.getString(Keys.OBJECT_ID);
+
+ final List articles = new ArrayList<>();
+
+ for (int i = 0; i < maxTagCnt; i++) { // XXX: should average by tag?
+ final String tagTitle = tagTitles[i];
+ final JSONObject tag = tagRepository.getByTitle(tagTitle);
+ final String tagId = tag.getString(Keys.OBJECT_ID);
+ final JSONObject result = tagArticleRepository.getByTagId(tagId, 1, displayCnt);
+ final JSONArray tagArticleRelations = result.getJSONArray(Keys.RESULTS);
+
+ final int relationSize = displayCnt < tagArticleRelations.length() ? displayCnt : tagArticleRelations.length();
+
+ for (int j = 0; j < relationSize; j++) {
+ final JSONObject tagArticleRelation = tagArticleRelations.getJSONObject(j);
+ final String relatedArticleId = tagArticleRelation.getString(Article.ARTICLE + "_" + Keys.OBJECT_ID);
+
+ if (articleId.equals(relatedArticleId)) {
+ continue;
+ }
+
+ final JSONObject relevant = articleRepository.get(relatedArticleId);
+
+ if (Article.ARTICLE_STATUS_C_PUBLISHED != relevant.optInt(Article.ARTICLE_STATUS)) {
+ continue;
+ }
+
+ boolean existed = false;
+
+ for (final JSONObject relevantArticle : articles) {
+ if (relevantArticle.getString(Keys.OBJECT_ID).equals(relevant.getString(Keys.OBJECT_ID))) {
+ existed = true;
+ }
+ }
+
+ if (!existed) {
+ articles.add(relevant);
+ }
+ }
+ }
+
+ removeUnusedProperties(articles);
+
+ if (displayCnt > articles.size()) {
+ return articles;
+ }
+
+ final List randomIntegers = CollectionUtils.getRandomIntegers(0, articles.size() - 1, displayCnt);
+ final List ret = new ArrayList<>();
+ for (final int index : randomIntegers) {
+ ret.add(articles.get(index));
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets relevant articles failed", e);
+
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Gets the next article(by create date) by the specified article id.
+ *
+ * Note: The article content and abstract is raw (no editor type processing).
+ *
+ *
+ * @param articleId the specified article id
+ * @return the previous article,
+ * {
+ * "articleTitle": "",
+ * "articlePermalink": "",
+ * "articleAbstract": ""
+ * }
+ *
returns {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getNextArticle(final String articleId) throws ServiceException {
+ try {
+ return articleRepository.getNextArticle(articleId);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets the next article failed[articleId=" + articleId + "]", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets the previous article(by create date) by the specified article id.
+ *
+ * Note: The article content and abstract is raw (no editor type processing).
+ *
+ *
+ * @param articleId the specified article id
+ * @return the previous article,
+ * {
+ * "articleTitle": "",
+ * "articlePermalink": "",
+ * "articleAbstract": ""
+ * }
+ *
returns {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getPreviousArticle(final String articleId) throws ServiceException {
+ try {
+ return articleRepository.getPreviousArticle(articleId);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets the previous article failed[articleId=" + articleId + "]", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets an article by the specified article id.
+ *
+ * Note: The article content and abstract is raw (no editor type processing).
+ *
+ *
+ * @param articleId the specified article id
+ * @return an article, returns {@code null} if not found
+ */
+ public JSONObject getArticleById(final String articleId) {
+ try {
+ return articleRepository.get(articleId);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets an article [id=" + articleId + "] failed", e);
+
+ return null;
+ }
+ }
+
+ /**
+ * Gets published articles by the specified author id, current page number and page size.
+ *
+ * @param authorId the specified author id
+ * @param currentPageNum the specified current page number
+ * @param pageSize the specified page size
+ * @return result
+ * @throws ServiceException service exception
+ */
+ public JSONObject getArticlesByAuthorId(final String authorId, final int currentPageNum, final int pageSize) throws ServiceException {
+ try {
+ final JSONObject ret = articleRepository.getByAuthorId(authorId, currentPageNum, pageSize);
+ final JSONArray articles = ret.getJSONArray(Keys.RESULTS);
+ for (int i = 0; i < articles.length(); i++) {
+ final JSONObject article = articles.getJSONObject(i);
+ article.put(Article.ARTICLE_CREATE_TIME, article.getLong(Article.ARTICLE_CREATED));
+ article.put(Article.ARTICLE_T_CREATE_DATE, new Date(article.optLong(Article.ARTICLE_CREATED)));
+ article.put(Article.ARTICLE_T_UPDATE_DATE, new Date(article.optLong(Article.ARTICLE_UPDATED)));
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets articles by author id failed [authorId=" + authorId + ", currentPageNum=" + currentPageNum + ", pageSize=" + pageSize + "]", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets article contents with the specified article id.
+ *
+ * Invoking this method dose not effect on article view count.
+ *
+ *
+ * @param context the specified HTTP servlet request context
+ * @param articleId the specified article id
+ * @return article contents, returns {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public String getArticleContent(final RequestContext context, final String articleId) throws ServiceException {
+ if (StringUtils.isBlank(articleId)) {
+ return null;
+ }
+
+ try {
+ final JSONObject article = articleRepository.get(articleId);
+ if (null == article) {
+ return null;
+ }
+
+ if (null != context && Solos.needViewPwd(context, article)) {
+ final String content = langPropsService.get("articleContentPwd");
+ article.put(Article.ARTICLE_CONTENT, content);
+ } else {
+ // Markdown to HTML for content and abstract
+ Stopwatchs.start("Get Article Content [Markdown]");
+ String content = article.optString(Article.ARTICLE_CONTENT);
+ content = Markdowns.toHTML(content);
+ article.put(Article.ARTICLE_CONTENT, content);
+ Stopwatchs.end();
+ }
+
+ return article.getString(Article.ARTICLE_CONTENT);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets article content failed[articleId=" + articleId + "]", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Converts the content and abstract for each of the specified articles to HTML if that is saved by Markdown editor.
+ *
+ * @param articles the specified articles
+ */
+ public void markdowns(final List articles) {
+ for (final JSONObject article : articles) {
+ markdown(article);
+ }
+ }
+
+ /**
+ * Converts the content and abstract for the specified article to HTML if it is saved by Markdown editor.
+ *
+ * @param article the specified article
+ */
+ public void markdown(final JSONObject article) {
+ Stopwatchs.start("Markdown Article [id=" + article.optString(Keys.OBJECT_ID) + "]");
+
+ String content = article.optString(Article.ARTICLE_CONTENT);
+ content = Markdowns.toHTML(content);
+ article.put(Article.ARTICLE_CONTENT, content);
+
+ String abstractContent = article.optString(Article.ARTICLE_ABSTRACT);
+ if (StringUtils.isNotBlank(abstractContent)) {
+ Stopwatchs.start("Abstract");
+ abstractContent = Markdowns.toHTML(abstractContent);
+ article.put(Article.ARTICLE_ABSTRACT, abstractContent);
+ Stopwatchs.end();
+ }
+
+ Stopwatchs.end();
+ }
+
+ /**
+ * Removes unused properties of each article in the specified articles.
+ *
+ * Remains the following properties:
+ *
+ * - {@link Article#ARTICLE_TITLE article title}
+ * - {@link Article#ARTICLE_PERMALINK article permalink}
+ *
+ *
+ *
+ * The batch version of method {@link #removeUnusedProperties(org.json.JSONObject)}.
+ *
+ *
+ * @param articles the specified articles
+ * @see #removeUnusedProperties(org.json.JSONObject)
+ */
+ private void removeUnusedProperties(final List articles) {
+ for (final JSONObject article : articles) {
+ removeUnusedProperties(article);
+ }
+ }
+
+ /**
+ * Removes unused properties of the specified article.
+ *
+ * Remains the following properties:
+ *
+ * - {@link Article#ARTICLE_TITLE article title}
+ * - {@link Article#ARTICLE_PERMALINK article permalink}
+ *
+ *
+ *
+ * @param article the specified article
+ * @see #removeUnusedProperties(java.util.List)
+ */
+ private void removeUnusedProperties(final JSONObject article) {
+ article.remove(Keys.OBJECT_ID);
+ article.remove(Article.ARTICLE_AUTHOR_ID);
+ article.remove(Article.ARTICLE_ABSTRACT);
+ article.remove(Article.ARTICLE_COMMENT_COUNT);
+ article.remove(Article.ARTICLE_CONTENT);
+ article.remove(Article.ARTICLE_CREATED);
+ article.remove(Article.ARTICLE_TAGS_REF);
+ article.remove(Article.ARTICLE_UPDATED);
+ article.remove(Article.ARTICLE_VIEW_COUNT);
+ article.remove(Article.ARTICLE_RANDOM_DOUBLE);
+ article.remove(Article.ARTICLE_PUT_TOP);
+ article.remove(Article.ARTICLE_VIEW_PWD);
+ article.remove(Article.ARTICLE_SIGN_ID);
+ article.remove(Article.ARTICLE_COMMENTABLE);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/CategoryMgmtService.java b/src/main/java/org/b3log/solo/service/CategoryMgmtService.java
new file mode 100644
index 00000000..ed07a2cd
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/CategoryMgmtService.java
@@ -0,0 +1,261 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.repository.annotation.Transactional;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.solo.model.Category;
+import org.b3log.solo.model.Tag;
+import org.b3log.solo.repository.CategoryRepository;
+import org.b3log.solo.repository.CategoryTagRepository;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Category management service.
+ *
+ * @author Liang Ding
+ * @version 1.2.0.0, Apr 1, 2017
+ * @since 2.0.0
+ */
+@Service
+public class CategoryMgmtService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(CategoryMgmtService.class);
+
+ /**
+ * Category repository.
+ */
+ @Inject
+ private CategoryRepository categoryRepository;
+
+ /**
+ * Category tag repository.
+ */
+ @Inject
+ private CategoryTagRepository categoryTagRepository;
+
+ /**
+ * Changes the order of a category specified by the given category id with the specified direction.
+ *
+ * @param categoryId the given category id
+ * @param direction the specified direction, "up"/"down"
+ * @throws ServiceException service exception
+ */
+ public void changeOrder(final String categoryId, final String direction)
+ throws ServiceException {
+ final Transaction transaction = categoryRepository.beginTransaction();
+
+ try {
+ final JSONObject srcCategory = categoryRepository.get(categoryId);
+ final int srcCategoryOrder = srcCategory.getInt(Category.CATEGORY_ORDER);
+
+ JSONObject targetCategory;
+
+ if ("up".equals(direction)) {
+ targetCategory = categoryRepository.getUpper(categoryId);
+ } else { // Down
+ targetCategory = categoryRepository.getUnder(categoryId);
+ }
+
+ if (null == targetCategory) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.WARN, "Cant not find the target category of source category [order={0}]", srcCategoryOrder);
+
+ return;
+ }
+
+ // Swaps
+ srcCategory.put(Category.CATEGORY_ORDER, targetCategory.getInt(Category.CATEGORY_ORDER));
+ targetCategory.put(Category.CATEGORY_ORDER, srcCategoryOrder);
+
+ categoryRepository.update(srcCategory.getString(Keys.OBJECT_ID), srcCategory);
+ categoryRepository.update(targetCategory.getString(Keys.OBJECT_ID), targetCategory);
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Changes category's order failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Removes a category-tag relation.
+ *
+ * @param categoryId the specified category id
+ * @param tagId the specified tag id
+ * @throws ServiceException service exception
+ */
+ @Transactional
+ public void removeCategoryTag(final String categoryId, final String tagId) throws ServiceException {
+ try {
+ final JSONObject category = categoryRepository.get(categoryId);
+ category.put(Category.CATEGORY_TAG_CNT, category.optInt(Category.CATEGORY_TAG_CNT) - 1);
+
+ categoryRepository.update(categoryId, category);
+
+ final Query query = new Query().setFilter(
+ CompositeFilterOperator.and(
+ new PropertyFilter(Category.CATEGORY + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, categoryId),
+ new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tagId)));
+
+ final JSONArray relations = categoryTagRepository.get(query).optJSONArray(Keys.RESULTS);
+ if (relations.length() < 1) {
+ return;
+ }
+
+ final JSONObject relation = relations.optJSONObject(0);
+ categoryTagRepository.remove(relation.optString(Keys.OBJECT_ID));
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Adds a category-tag relation failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Adds a category-tag relation.
+ *
+ * @param categoryTag the specified category-tag relation
+ * @throws ServiceException service exception
+ */
+ @Transactional
+ public void addCategoryTag(final JSONObject categoryTag) throws ServiceException {
+ try {
+ categoryTagRepository.add(categoryTag);
+
+ final String categoryId = categoryTag.optString(Category.CATEGORY + "_" + Keys.OBJECT_ID);
+ final JSONObject category = categoryRepository.get(categoryId);
+ final int tagCount =
+ categoryTagRepository.getByCategoryId(categoryId, 1, Integer.MAX_VALUE).
+ optJSONArray(Keys.RESULTS).length();
+ category.put(Category.CATEGORY_TAG_CNT, tagCount);
+
+ categoryRepository.update(categoryId, category);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Adds a category-tag relation failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Adds a category relation.
+ *
+ * @param category the specified category relation
+ * @return category id
+ * @throws ServiceException service exception
+ */
+ @Transactional
+ public String addCategory(final JSONObject category) throws ServiceException {
+ try {
+ final JSONObject record = new JSONObject();
+ record.put(Category.CATEGORY_TAG_CNT, 0);
+ record.put(Category.CATEGORY_URI, category.optString(Category.CATEGORY_URI));
+ record.put(Category.CATEGORY_TITLE, category.optString(Category.CATEGORY_TITLE));
+ record.put(Category.CATEGORY_DESCRIPTION, category.optString(Category.CATEGORY_DESCRIPTION));
+
+ final int maxOrder = categoryRepository.getMaxOrder();
+ final int order = maxOrder + 1;
+ record.put(Category.CATEGORY_ORDER, order);
+ category.put(Category.CATEGORY_ORDER, order);
+
+ final String ret = categoryRepository.add(record);
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Adds a category failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Updates the specified category by the given category id.
+ *
+ * @param categoryId the given category id
+ * @param category the specified category
+ * @throws ServiceException service exception
+ */
+ @Transactional
+ public void updateCategory(final String categoryId, final JSONObject category) throws ServiceException {
+ try {
+ final JSONObject oldCategory = categoryRepository.get(categoryId);
+ category.put(Category.CATEGORY_ORDER, oldCategory.optInt(Category.CATEGORY_ORDER));
+ category.put(Category.CATEGORY_TAG_CNT, oldCategory.optInt(Category.CATEGORY_TAG_CNT));
+
+ categoryRepository.update(categoryId, category);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Updates a category [id=" + categoryId + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Removes the specified category by the given category id.
+ *
+ * @param categoryId the given category id
+ * @throws ServiceException service exception
+ */
+ @Transactional
+ public void removeCategory(final String categoryId) throws ServiceException {
+ try {
+ categoryTagRepository.removeByCategoryId(categoryId);
+ categoryRepository.remove(categoryId);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Remove a category [id=" + categoryId + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Removes category-tag relations by the given category id.
+ *
+ * @param categoryId the given category id
+ * @throws ServiceException service exception
+ */
+ @Transactional
+ public void removeCategoryTags(final String categoryId) throws ServiceException {
+ try {
+ categoryTagRepository.removeByCategoryId(categoryId);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Remove category-tag [categoryId=" + categoryId + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/CategoryQueryService.java b/src/main/java/org/b3log/solo/service/CategoryQueryService.java
new file mode 100644
index 00000000..5a1471c1
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/CategoryQueryService.java
@@ -0,0 +1,296 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.Pagination;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.util.CollectionUtils;
+import org.b3log.latke.util.Paginator;
+import org.b3log.solo.model.Category;
+import org.b3log.solo.model.Tag;
+import org.b3log.solo.repository.CategoryRepository;
+import org.b3log.solo.repository.CategoryTagRepository;
+import org.b3log.solo.repository.TagRepository;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Category query service.
+ *
+ * @author Liang Ding
+ * @author lzh984294471
+ * @version 1.0.1.4, Sep 1, 2019
+ * @since 2.0.0
+ */
+@Service
+public class CategoryQueryService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(CategoryQueryService.class);
+
+ /**
+ * Category repository.
+ */
+ @Inject
+ private CategoryRepository categoryRepository;
+
+ /**
+ * Tag repository.
+ */
+ @Inject
+ private TagRepository tagRepository;
+
+ /**
+ * Category tag repository.
+ */
+ @Inject
+ private CategoryTagRepository categoryTagRepository;
+
+ /**
+ * Gets most tag category.
+ *
+ * @param fetchSize the specified fetch size
+ * @return categories, returns an empty list if not found
+ */
+ public List getMostTagCategory(final int fetchSize) {
+ final Query query = new Query().addSort(Category.CATEGORY_ORDER, SortDirection.ASCENDING).
+ addSort(Category.CATEGORY_TAG_CNT, SortDirection.DESCENDING).
+ addSort(Keys.OBJECT_ID, SortDirection.DESCENDING).
+ setPageSize(fetchSize).setPageCount(1);
+ try {
+ final List ret = categoryRepository.getList(query);
+ for (final JSONObject category : ret) {
+ final List tags = getTags(category.optString(Keys.OBJECT_ID));
+
+ category.put(Category.CATEGORY_T_TAGS, (Object) tags);
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets most tag category error", e);
+
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Gets a category's tags.
+ *
+ * @param categoryId the specified category id
+ * @return tags, returns an empty list if not found
+ */
+ public List getTags(final String categoryId) {
+ final List ret = new ArrayList<>();
+
+ final Query query = new Query().
+ setFilter(new PropertyFilter(Category.CATEGORY + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, categoryId));
+ try {
+ final List relations = categoryTagRepository.getList(query);
+ for (final JSONObject relation : relations) {
+ final String tagId = relation.optString(Tag.TAG + "_" + Keys.OBJECT_ID);
+ final JSONObject tag = tagRepository.get(tagId);
+ if (null == tag) { // 修复修改分类时空指针错误 https://github.com/b3log/solo/pull/12876
+ continue;
+ }
+ ret.add(tag);
+ }
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets category [id=" + categoryId + "] tags error", e);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Gets a category by the specified category URI.
+ *
+ * @param categoryURI the specified category URI
+ * @return category, returns {@code null} if not null
+ * @throws ServiceException service exception
+ */
+ public JSONObject getByURI(final String categoryURI) throws ServiceException {
+ try {
+ final JSONObject ret = categoryRepository.getByURI(categoryURI);
+ if (null == ret) {
+ return null;
+ }
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets category [URI=" + categoryURI + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets a category by the specified category title.
+ *
+ * @param categoryTitle the specified category title
+ * @return category, returns {@code null} if not null
+ * @throws ServiceException service exception
+ */
+ public JSONObject getByTitle(final String categoryTitle) throws ServiceException {
+ try {
+ final JSONObject ret = categoryRepository.getByTitle(categoryTitle);
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets category [title=" + categoryTitle + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets categories by the specified request json object.
+ *
+ * @param requestJSONObject the specified request json object, for example,
+ * "categoryTitle": "", // optional
+ * "paginationCurrentPageNum": 1,
+ * "paginationPageSize": 20,
+ * "paginationWindowSize": 10
+ * see {@link Pagination} for more details
+ * @return for example,
+ *
+ * {
+ * "pagination": {
+ * "paginationPageCount": 100,
+ * "paginationPageNums": [1, 2, 3, 4, 5]
+ * },
+ * "categories": [{
+ * "oId": "",
+ * "categoryTitle": "",
+ * "categoryDescription": "",
+ * ....
+ * }, ....]
+ * }
+ *
+ * @throws ServiceException service exception
+ * @see Pagination
+ */
+ public JSONObject getCategoris(final JSONObject requestJSONObject) throws ServiceException {
+ final JSONObject ret = new JSONObject();
+
+ final int currentPageNum = requestJSONObject.optInt(Pagination.PAGINATION_CURRENT_PAGE_NUM);
+ final int pageSize = requestJSONObject.optInt(Pagination.PAGINATION_PAGE_SIZE);
+ final int windowSize = requestJSONObject.optInt(Pagination.PAGINATION_WINDOW_SIZE);
+ final Query query = new Query().setPage(currentPageNum, pageSize).
+ addSort(Category.CATEGORY_ORDER, SortDirection.ASCENDING).
+ addSort(Category.CATEGORY_TAG_CNT, SortDirection.DESCENDING).
+ addSort(Keys.OBJECT_ID, SortDirection.DESCENDING);
+
+ if (requestJSONObject.has(Category.CATEGORY_TITLE)) {
+ query.setFilter(new PropertyFilter(Category.CATEGORY_TITLE, FilterOperator.EQUAL,
+ requestJSONObject.optString(Category.CATEGORY_TITLE)));
+ }
+
+ JSONObject result;
+ try {
+ result = categoryRepository.get(query);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets categories failed", e);
+
+ throw new ServiceException(e);
+ }
+
+ final int pageCount = result.optJSONObject(Pagination.PAGINATION).optInt(Pagination.PAGINATION_PAGE_COUNT);
+
+ final JSONObject pagination = new JSONObject();
+ ret.put(Pagination.PAGINATION, pagination);
+ final List pageNums = Paginator.paginate(currentPageNum, pageSize, pageCount, windowSize);
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, pageCount);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, pageNums);
+
+ final JSONArray data = result.optJSONArray(Keys.RESULTS);
+ final List categories = CollectionUtils.jsonArrayToList(data);
+
+ ret.put(Category.CATEGORIES, categories);
+
+ return ret;
+ }
+
+ /**
+ * Gets a category by the specified id.
+ *
+ * @param categoryId the specified id
+ * @return a category, return {@code null} if not found
+ * @throws ServiceException service exception
+ */
+ public JSONObject getCategory(final String categoryId) throws ServiceException {
+ try {
+ final JSONObject ret = categoryRepository.get(categoryId);
+ if (null == ret) {
+ return null;
+ }
+
+ final List tags = getTags(categoryId);
+ ret.put(Category.CATEGORY_T_TAGS, (Object) tags);
+
+ return ret;
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Gets a category [categoryId=" + categoryId + "] failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Whether a tag specified by the given tag title in a category specified by the given category id.
+ *
+ * @param tagTitle the given tag title
+ * @param categoryId the given category id
+ * @return {@code true} if the tag in the category, returns {@code false} otherwise
+ */
+ public boolean containTag(final String tagTitle, final String categoryId) {
+ try {
+ final JSONObject category = categoryRepository.get(categoryId);
+ if (null == category) {
+ return true;
+ }
+
+ final JSONObject tag = tagRepository.getByTitle(tagTitle);
+ if (null == tag) {
+ return true;
+ }
+
+ final Query query = new Query().setFilter(
+ CompositeFilterOperator.and(
+ new PropertyFilter(Category.CATEGORY + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, categoryId),
+ new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tag.optString(Keys.OBJECT_ID))));
+
+ return categoryTagRepository.count(query) > 0;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Check category tag [tagTitle=" + tagTitle + ", categoryId=" + categoryId + "] failed", e);
+
+ return true;
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/CommentMgmtService.java b/src/main/java/org/b3log/solo/service/CommentMgmtService.java
new file mode 100644
index 00000000..a987acb1
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/CommentMgmtService.java
@@ -0,0 +1,390 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang.time.DateFormatUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.event.Event;
+import org.b3log.latke.event.EventManager;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.repository.RepositoryException;
+import org.b3log.latke.repository.Transaction;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.util.Ids;
+import org.b3log.latke.util.Strings;
+import org.b3log.solo.event.EventTypes;
+import org.b3log.solo.model.*;
+import org.b3log.solo.repository.ArticleRepository;
+import org.b3log.solo.repository.CommentRepository;
+import org.b3log.solo.repository.UserRepository;
+import org.b3log.solo.util.Markdowns;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Date;
+
+/**
+ * Comment management service.
+ *
+ * @author Liang Ding
+ * @version 1.4.0.3, Jun 6, 2019
+ * @since 0.3.5
+ */
+@Service
+public class CommentMgmtService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(CommentMgmtService.class);
+
+ /**
+ * Minimum length of comment name.
+ */
+ private static final int MIN_COMMENT_NAME_LENGTH = 2;
+
+ /**
+ * Maximum length of comment name.
+ */
+ private static final int MAX_COMMENT_NAME_LENGTH = 20;
+
+ /**
+ * Minimum length of comment content.
+ */
+ private static final int MIN_COMMENT_CONTENT_LENGTH = 2;
+
+ /**
+ * Maximum length of comment content.
+ */
+ private static final int MAX_COMMENT_CONTENT_LENGTH = 500;
+
+ /**
+ * Event manager.
+ */
+ @Inject
+ private static EventManager eventManager;
+
+ /**
+ * Article management service.
+ */
+ @Inject
+ private ArticleMgmtService articleMgmtService;
+
+ /**
+ * Comment repository.
+ */
+ @Inject
+ private CommentRepository commentRepository;
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * User repository.
+ */
+ @Inject
+ private UserRepository userRepository;
+
+ /**
+ * Statistic management service.
+ */
+ @Inject
+ private StatisticMgmtService statisticMgmtService;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * Checks the specified comment adding request.
+ *
+ * XSS process (name) in this method.
+ *
+ *
+ * @param requestJSONObject the specified comment adding request, for example,
+ * {
+ * "type": "", // "article"
+ * "oId": "",
+ * "commentName": "",
+ * "commentURL": "",
+ * "commentContent": "",
+ * }
+ * @return check result, for example,
+ * {
+ * "sc": boolean,
+ * "msg": "" // Exists if "sc" equals to false
+ * }
+ *
+ */
+ public JSONObject checkAddCommentRequest(final JSONObject requestJSONObject) {
+ final JSONObject ret = new JSONObject();
+
+ try {
+ ret.put(Keys.STATUS_CODE, false);
+ final JSONObject preference = optionQueryService.getPreference();
+
+ if (null == preference || !preference.optBoolean(Option.ID_C_COMMENTABLE)) {
+ ret.put(Keys.MSG, langPropsService.get("notAllowCommentLabel"));
+
+ return ret;
+ }
+
+ final String id = requestJSONObject.optString(Keys.OBJECT_ID);
+ final String type = requestJSONObject.optString(Common.TYPE);
+
+ if (Article.ARTICLE.equals(type)) {
+ final JSONObject article = articleRepository.get(id);
+
+ if (null == article || !article.optBoolean(Article.ARTICLE_COMMENTABLE)) {
+ ret.put(Keys.MSG, langPropsService.get("notAllowCommentLabel"));
+
+ return ret;
+ }
+ } else {
+ ret.put(Keys.MSG, langPropsService.get("notAllowCommentLabel"));
+
+ return ret;
+ }
+
+ String commentName = requestJSONObject.getString(Comment.COMMENT_NAME);
+ if (MAX_COMMENT_NAME_LENGTH < commentName.length() || MIN_COMMENT_NAME_LENGTH > commentName.length()) {
+ LOGGER.log(Level.WARN, "Comment name is too long [{0}]", commentName);
+ ret.put(Keys.MSG, langPropsService.get("nameTooLongLabel"));
+
+ return ret;
+ }
+
+ final JSONObject commenter = userRepository.getByUserName(commentName);
+ if (null == commenter) {
+ LOGGER.log(Level.WARN, "Not found user [" + commentName + "]");
+ ret.put(Keys.MSG, langPropsService.get("queryUserFailedLabel"));
+
+ return ret;
+ }
+
+ final String commentURL = requestJSONObject.optString(Comment.COMMENT_URL);
+
+ if (!Strings.isURL(commentURL) || StringUtils.contains(commentURL, "<")) {
+ requestJSONObject.put(Comment.COMMENT_URL, "");
+ }
+
+ String commentContent = requestJSONObject.optString(Comment.COMMENT_CONTENT);
+
+ if (MAX_COMMENT_CONTENT_LENGTH < commentContent.length() || MIN_COMMENT_CONTENT_LENGTH > commentContent.length()) {
+ LOGGER.log(Level.WARN, "Comment conent length is invalid[{0}]", commentContent.length());
+ ret.put(Keys.MSG, langPropsService.get("commentContentCannotEmptyLabel"));
+
+ return ret;
+ }
+
+ ret.put(Keys.STATUS_CODE, true);
+ requestJSONObject.put(Comment.COMMENT_CONTENT, commentContent);
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.WARN, "Checks add comment request[" + requestJSONObject.toString() + "] failed", e);
+
+ ret.put(Keys.STATUS_CODE, false);
+ ret.put(Keys.MSG, langPropsService.get("addFailLabel"));
+
+ return ret;
+ }
+ }
+
+ /**
+ * Adds an article comment with the specified request json object.
+ *
+ * @param requestJSONObject the specified request json object, for example,
+ * {
+ * "oId": "", // article id
+ * "commentName": "",
+ * "commentURL": "", // optional
+ * "commentContent": "",
+ * "commentOriginalCommentId": "" // optional
+ * }
+ * @return add result, for example,
+ * {
+ * "oId": "", // generated comment id
+ * "commentDate": "", // format: yyyy-MM-dd HH:mm:ss
+ * "commentOriginalCommentName": "" // optional, corresponding to argument "commentOriginalCommentId"
+ * "commentThumbnailURL": "",
+ * "commentSharpURL": "",
+ * "commentContent": "",
+ * "commentName": "",
+ * "commentURL": "", // optional
+ * "isReply": boolean,
+ * "article": {},
+ * "commentOriginalCommentId": "", // optional
+ * "commentable": boolean,
+ * "permalink": "" // article.articlePermalink
+ * }
+ *
+ * @throws ServiceException service exception
+ */
+ public JSONObject addArticleComment(final JSONObject requestJSONObject) throws ServiceException {
+ final JSONObject ret = new JSONObject();
+ ret.put(Common.IS_REPLY, false);
+
+ final Transaction transaction = commentRepository.beginTransaction();
+
+ try {
+ final String articleId = requestJSONObject.getString(Keys.OBJECT_ID);
+ final JSONObject article = articleRepository.get(articleId);
+ ret.put(Article.ARTICLE, article);
+ final String commentName = requestJSONObject.getString(Comment.COMMENT_NAME);
+ final String commentURL = requestJSONObject.optString(Comment.COMMENT_URL);
+ final String commentContent = requestJSONObject.getString(Comment.COMMENT_CONTENT);
+ final String originalCommentId = requestJSONObject.optString(Comment.COMMENT_ORIGINAL_COMMENT_ID);
+ ret.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, originalCommentId);
+ final JSONObject comment = new JSONObject();
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, "");
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, "");
+ comment.put(Comment.COMMENT_NAME, commentName);
+ comment.put(Comment.COMMENT_URL, commentURL);
+ comment.put(Comment.COMMENT_CONTENT, commentContent);
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, requestJSONObject.optString(Comment.COMMENT_ORIGINAL_COMMENT_ID));
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, requestJSONObject.optString(Comment.COMMENT_ORIGINAL_COMMENT_NAME));
+ final JSONObject preference = optionQueryService.getPreference();
+ final Date date = new Date();
+ comment.put(Comment.COMMENT_CREATED, date.getTime());
+ ret.put(Comment.COMMENT_T_DATE, DateFormatUtils.format(date, "yyyy-MM-dd HH:mm:ss"));
+ ret.put("commentDate2", date);
+ ret.put(Common.COMMENTABLE, preference.getBoolean(Option.ID_C_COMMENTABLE) && article.getBoolean(Article.ARTICLE_COMMENTABLE));
+ ret.put(Common.PERMALINK, article.getString(Article.ARTICLE_PERMALINK));
+ ret.put(Comment.COMMENT_NAME, commentName);
+ String cmtContent = Markdowns.toHTML(commentContent);
+ cmtContent = Markdowns.clean(cmtContent);
+ ret.put(Comment.COMMENT_CONTENT, cmtContent);
+ ret.put(Comment.COMMENT_URL, commentURL);
+
+ JSONObject originalComment;
+ if (StringUtils.isNotBlank(originalCommentId)) {
+ originalComment = commentRepository.get(originalCommentId);
+ if (null != originalComment) {
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, originalCommentId);
+ final String originalCommentName = originalComment.getString(Comment.COMMENT_NAME);
+
+ comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, originalCommentName);
+ ret.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, originalCommentName);
+
+ ret.put(Common.IS_REPLY, true);
+ } else {
+ LOGGER.log(Level.WARN, "Not found orginal comment[id={0}] of reply[name={1}, content={2}]",
+ originalCommentId, commentName, commentContent);
+ }
+ }
+ setCommentThumbnailURL(comment);
+ ret.put(Comment.COMMENT_THUMBNAIL_URL, comment.getString(Comment.COMMENT_THUMBNAIL_URL));
+ comment.put(Comment.COMMENT_ON_ID, articleId);
+ final String commentId = Ids.genTimeMillisId();
+ comment.put(Keys.OBJECT_ID, commentId);
+ ret.put(Keys.OBJECT_ID, commentId);
+ final String commentSharpURL = Comment.getCommentSharpURLForArticle(article, commentId);
+ comment.put(Comment.COMMENT_SHARP_URL, commentSharpURL);
+ ret.put(Comment.COMMENT_SHARP_URL, commentSharpURL);
+
+ commentRepository.add(comment);
+ articleMgmtService.incArticleCommentCount(articleId);
+
+ final JSONObject eventData = new JSONObject();
+ eventData.put(Comment.COMMENT, comment);
+ eventData.put(Article.ARTICLE, article);
+ eventManager.fireEventAsynchronously(new Event<>(EventTypes.ADD_COMMENT_TO_ARTICLE, eventData));
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ throw new ServiceException(e);
+ }
+
+ return ret;
+ }
+
+ /**
+ * Removes a comment of an article with the specified comment id.
+ *
+ * @param commentId the given comment id
+ * @throws ServiceException service exception
+ */
+ public void removeArticleComment(final String commentId) throws ServiceException {
+ final Transaction transaction = commentRepository.beginTransaction();
+
+ try {
+ final JSONObject comment = commentRepository.get(commentId);
+ final String articleId = comment.getString(Comment.COMMENT_ON_ID);
+ commentRepository.remove(commentId);
+ decArticleCommentCount(articleId);
+
+ transaction.commit();
+ } catch (final Exception e) {
+ if (transaction.isActive()) {
+ transaction.rollback();
+ }
+
+ LOGGER.log(Level.ERROR, "Removes a comment of an article failed", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Article comment count -1 for an article specified by the given article id.
+ *
+ * @param articleId the given article id
+ * @throws JSONException json exception
+ * @throws RepositoryException repository exception
+ */
+ private void decArticleCommentCount(final String articleId) throws JSONException, RepositoryException {
+ final JSONObject article = articleRepository.get(articleId);
+ final JSONObject newArticle = new JSONObject(article, JSONObject.getNames(article));
+ final int commentCnt = article.getInt(Article.ARTICLE_COMMENT_COUNT);
+ newArticle.put(Article.ARTICLE_COMMENT_COUNT, commentCnt - 1);
+ articleRepository.update(articleId, newArticle, Article.ARTICLE_COMMENT_COUNT);
+ }
+
+ /**
+ * Sets commenter thumbnail URL for the specified comment.
+ *
+ * @param comment the specified comment
+ * @throws Exception exception
+ */
+ public void setCommentThumbnailURL(final JSONObject comment) throws Exception {
+ final String commenterName = comment.optString(Comment.COMMENT_NAME);
+ final JSONObject commenter = userRepository.getByUserName(commenterName);
+ final String avatarURL = commenter.optString(UserExt.USER_AVATAR);
+ comment.put(Comment.COMMENT_THUMBNAIL_URL, avatarURL);
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/CommentQueryService.java b/src/main/java/org/b3log/solo/service/CommentQueryService.java
new file mode 100644
index 00000000..766efc53
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/CommentQueryService.java
@@ -0,0 +1,265 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.apache.commons.lang.StringUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.Pagination;
+import org.b3log.latke.model.Role;
+import org.b3log.latke.model.User;
+import org.b3log.latke.repository.Query;
+import org.b3log.latke.repository.SortDirection;
+import org.b3log.latke.repository.Transaction;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.util.Paginator;
+import org.b3log.solo.model.Article;
+import org.b3log.solo.model.Comment;
+import org.b3log.solo.model.Common;
+import org.b3log.solo.repository.ArticleRepository;
+import org.b3log.solo.repository.CommentRepository;
+import org.b3log.solo.repository.PageRepository;
+import org.b3log.solo.util.Emotions;
+import org.b3log.solo.util.Markdowns;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.jsoup.Jsoup;
+import org.jsoup.safety.Whitelist;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Comment query service.
+ *
+ * @author Liang Ding
+ * @version 1.3.2.7, Apr 24, 2019
+ * @since 0.3.5
+ */
+@Service
+public class CommentQueryService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(CommentQueryService.class);
+
+ /**
+ * User service.
+ */
+ @Inject
+ private UserQueryService userQueryService;
+
+ /**
+ * Comment repository.
+ */
+ @Inject
+ private CommentRepository commentRepository;
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Page repository.
+ */
+ @Inject
+ private PageRepository pageRepository;
+
+ /**
+ * Can the specified user access a comment specified by the given comment id?
+ *
+ * @param commentId the given comment id
+ * @param user the specified user
+ * @return {@code true} if the current user can access the comment, {@code false} otherwise
+ * @throws Exception exception
+ */
+ public boolean canAccessComment(final String commentId, final JSONObject user) throws Exception {
+ if (StringUtils.isBlank(commentId)) {
+ return false;
+ }
+
+ if (null == user) {
+ return false;
+ }
+
+ if (Role.ADMIN_ROLE.equals(user.optString(User.USER_ROLE))) {
+ return true;
+ }
+
+ final JSONObject comment = commentRepository.get(commentId);
+ if (null == comment) {
+ return false;
+ }
+
+ final String onId = comment.optString(Comment.COMMENT_ON_ID);
+ final JSONObject article = articleRepository.get(onId);
+ if (null == article) {
+ return false;
+ }
+
+ final String currentUserId = user.getString(Keys.OBJECT_ID);
+
+ return article.getString(Article.ARTICLE_AUTHOR_ID).equals(currentUserId);
+ }
+
+ /**
+ * Gets comments with the specified request json object, request and response.
+ *
+ * @param requestJSONObject the specified request json object, for example,
+ * "paginationCurrentPageNum": 1,
+ * "paginationPageSize": 20,
+ * "paginationWindowSize": 10
+ * @return for example,
+ *
+ * {
+ * "comments": [{
+ * "oId": "",
+ * "commentTitle": "",
+ * "commentName": "",
+ * "thumbnailUrl": "",
+ * "commentURL": "",
+ * "commentContent": "",
+ * "commentTime": long,
+ * "commentSharpURL": ""
+ * }, ....]
+ * "sc": "GET_COMMENTS_SUCC"
+ * }
+ *
+ * @throws ServiceException service exception
+ * @see Pagination
+ */
+ public JSONObject getComments(final JSONObject requestJSONObject) throws ServiceException {
+ try {
+ final JSONObject ret = new JSONObject();
+
+ final int currentPageNum = requestJSONObject.getInt(Pagination.PAGINATION_CURRENT_PAGE_NUM);
+ final int pageSize = requestJSONObject.getInt(Pagination.PAGINATION_PAGE_SIZE);
+ final int windowSize = requestJSONObject.getInt(Pagination.PAGINATION_WINDOW_SIZE);
+
+ final Query query = new Query().setPage(currentPageNum, pageSize).
+ addSort(Comment.COMMENT_CREATED, SortDirection.DESCENDING);
+ final JSONObject result = commentRepository.get(query);
+ final JSONArray comments = result.getJSONArray(Keys.RESULTS);
+
+ // Sets comment title and content escaping
+ for (int i = 0; i < comments.length(); i++) {
+ final JSONObject comment = comments.getJSONObject(i);
+ String title;
+
+ final String onId = comment.getString(Comment.COMMENT_ON_ID);
+ final JSONObject article = articleRepository.get(onId);
+ if (null == article) {
+ // 某种情况下导致的数据不一致:文章已经被删除了,但是评论还在
+ // 为了保持数据一致性,需要删除该条评论 https://hacpai.com/article/1556060195022
+ final Transaction transaction = commentRepository.beginTransaction();
+ final String commentId = comment.optString(Keys.OBJECT_ID);
+ commentRepository.remove(commentId);
+ transaction.commit();
+
+ continue;
+ }
+
+ title = article.getString(Article.ARTICLE_TITLE);
+ comment.put(Common.TYPE, Common.ARTICLE_COMMENT_TYPE);
+ comment.put(Common.COMMENT_TITLE, title);
+
+ String commentContent = comment.optString(Comment.COMMENT_CONTENT);
+ commentContent = Markdowns.toHTML(commentContent);
+ commentContent = Markdowns.clean(commentContent);
+ comment.put(Comment.COMMENT_CONTENT, commentContent);
+
+ String commentName = comment.optString(Comment.COMMENT_NAME);
+ commentName = Jsoup.clean(commentName, Whitelist.none());
+ comment.put(Comment.COMMENT_NAME, commentName);
+
+ comment.put(Comment.COMMENT_TIME, comment.optLong(Comment.COMMENT_CREATED));
+ comment.remove(Comment.COMMENT_CREATED);
+ }
+
+ final int pageCount = result.getJSONObject(Pagination.PAGINATION).getInt(Pagination.PAGINATION_PAGE_COUNT);
+ final JSONObject pagination = new JSONObject();
+ final List pageNums = Paginator.paginate(currentPageNum, pageSize, pageCount, windowSize);
+
+ pagination.put(Pagination.PAGINATION_PAGE_COUNT, pageCount);
+ pagination.put(Pagination.PAGINATION_PAGE_NUMS, pageNums);
+
+ ret.put(Comment.COMMENTS, comments);
+ ret.put(Pagination.PAGINATION, pagination);
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets comments failed", e);
+
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Gets comments of an article or page specified by the on id.
+ *
+ * @param onId the specified on id
+ * @return a list of comments, returns an empty list if not found
+ * @throws ServiceException service exception
+ */
+ public List getComments(final String onId) throws ServiceException {
+ try {
+ final List ret = new ArrayList<>();
+ final List comments = commentRepository.getComments(onId, 1, Integer.MAX_VALUE);
+ for (final JSONObject comment : comments) {
+ comment.put(Comment.COMMENT_TIME, comment.optLong(Comment.COMMENT_CREATED));
+ comment.put(Comment.COMMENT_T_DATE, new Date(comment.optLong(Comment.COMMENT_CREATED)));
+ comment.put("commentDate2", new Date(comment.optLong(Comment.COMMENT_CREATED))); // 1.9.0 向后兼容
+ comment.put(Comment.COMMENT_NAME, comment.getString(Comment.COMMENT_NAME));
+ String url = comment.getString(Comment.COMMENT_URL);
+ if (StringUtils.contains(url, "<")) { // legacy issue https://github.com/b3log/solo/issues/12091
+ url = "";
+ }
+ comment.put(Comment.COMMENT_URL, url);
+ comment.put(Common.IS_REPLY, false); // Assumes this comment is not a reply
+
+ if (StringUtils.isNotBlank(comment.optString(Comment.COMMENT_ORIGINAL_COMMENT_ID))) {
+ // This comment is a reply
+ comment.put(Common.IS_REPLY, true);
+ }
+
+ String commentContent = comment.optString(Comment.COMMENT_CONTENT);
+ commentContent = Markdowns.toHTML(commentContent);
+ commentContent = Markdowns.clean(commentContent);
+ comment.put(Comment.COMMENT_CONTENT, commentContent);
+
+ String commentName = comment.optString(Comment.COMMENT_NAME);
+ commentName = Jsoup.clean(commentName, Whitelist.none());
+ comment.put(Comment.COMMENT_NAME, commentName);
+
+ ret.add(comment);
+ }
+
+ return ret;
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets comments failed", e);
+ throw new ServiceException(e);
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/CronMgmtService.java b/src/main/java/org/b3log/solo/service/CronMgmtService.java
new file mode 100644
index 00000000..e4347b08
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/CronMgmtService.java
@@ -0,0 +1,128 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.util.Stopwatchs;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Cron management service.
+ *
+ * @author Liang Ding
+ * @version 1.0.0.4, Apr 18, 2019
+ * @since 2.9.7
+ */
+@Service
+public class CronMgmtService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(CronMgmtService.class);
+
+ /**
+ * Cron thread pool.
+ */
+ private static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE = Executors.newScheduledThreadPool(1);
+
+ /**
+ * User query service.
+ */
+ @Inject
+ private UserQueryService userQueryService;
+
+ /**
+ * Article management service.
+ */
+ @Inject
+ private ArticleMgmtService articleMgmtService;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Export service.
+ */
+ @Inject
+ private ExportService exportService;
+
+ /**
+ * User management service.
+ */
+ @Inject
+ private UserMgmtService userMgmtService;
+
+ /**
+ * Start all cron tasks.
+ */
+ public void start() {
+ long delay = 10000;
+
+ SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> {
+ try {
+ StatisticMgmtService.removeExpiredOnlineVisitor();
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Executes cron failed", e);
+ } finally {
+ Stopwatchs.release();
+ }
+ }, delay, 1000 * 60 * 10, TimeUnit.MILLISECONDS);
+ delay += 2000;
+
+ SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> {
+ try {
+ articleMgmtService.refreshGitHub();
+ userMgmtService.refreshUSite();
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Executes cron failed", e);
+ } finally {
+ Stopwatchs.release();
+ }
+ }, delay, 1000 * 60 * 60 * 24, TimeUnit.MILLISECONDS);
+ delay += 2000;
+
+ SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> {
+ try {
+ exportService.exportGitHubRepo();
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Executes cron failed", e);
+ } finally {
+ Stopwatchs.release();
+ }
+ }, delay + 1000 * 60 * 10, 1000 * 60 * 60 * 24, TimeUnit.MILLISECONDS);
+ delay += 2000;
+
+ }
+
+ /**
+ * Stop all cron tasks.
+ */
+ public void stop() {
+ SCHEDULED_EXECUTOR_SERVICE.shutdown();
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/DataModelService.java b/src/main/java/org/b3log/solo/service/DataModelService.java
new file mode 100644
index 00000000..1077930f
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/DataModelService.java
@@ -0,0 +1,1104 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import freemarker.template.Template;
+import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang.time.DateFormatUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.event.Event;
+import org.b3log.latke.event.EventManager;
+import org.b3log.latke.ioc.BeanManager;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.Pagination;
+import org.b3log.latke.model.Plugin;
+import org.b3log.latke.model.Role;
+import org.b3log.latke.model.User;
+import org.b3log.latke.plugin.ViewLoadEventData;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.service.LangPropsService;
+import org.b3log.latke.service.ServiceException;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.servlet.RequestContext;
+import org.b3log.latke.util.*;
+import org.b3log.solo.SoloServletListener;
+import org.b3log.solo.model.*;
+import org.b3log.solo.repository.*;
+import org.b3log.solo.util.Markdowns;
+import org.b3log.solo.util.Skins;
+import org.b3log.solo.util.Solos;
+import org.json.JSONObject;
+import org.jsoup.Jsoup;
+import org.jsoup.safety.Whitelist;
+
+import java.io.StringWriter;
+import java.util.*;
+
+import static org.b3log.solo.model.Article.ARTICLE_CONTENT;
+
+/**
+ * Data model service.
+ *
+ * @author Liang Ding
+ * @author Liyuan Li
+ * @version 1.7.0.11, Sep 17, 2019
+ * @since 0.3.1
+ */
+@Service
+public class DataModelService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(DataModelService.class);
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Comment repository.
+ */
+ @Inject
+ private CommentRepository commentRepository;
+
+ /**
+ * Archive date repository.
+ */
+ @Inject
+ private ArchiveDateRepository archiveDateRepository;
+
+ /**
+ * Category repository.
+ */
+ @Inject
+ private CategoryRepository categoryRepository;
+
+ /**
+ * Tag-Article repository.
+ */
+ @Inject
+ private TagArticleRepository tagArticleRepository;
+
+ /**
+ * Tag repository.
+ */
+ @Inject
+ private TagRepository tagRepository;
+
+ /**
+ * Category-Tag repository.
+ */
+ @Inject
+ private CategoryTagRepository categoryTagRepository;
+
+ /**
+ * Link repository.
+ */
+ @Inject
+ private LinkRepository linkRepository;
+
+ /**
+ * Page repository.
+ */
+ @Inject
+ private PageRepository pageRepository;
+
+ /**
+ * Statistic query service.
+ */
+ @Inject
+ private StatisticQueryService statisticQueryService;
+
+ /**
+ * User repository.
+ */
+ @Inject
+ private UserRepository userRepository;
+
+ /**
+ * Option query service..
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Article query service.
+ */
+ @Inject
+ private ArticleQueryService articleQueryService;
+
+ /**
+ * Tag query service.
+ */
+ @Inject
+ private TagQueryService tagQueryService;
+
+ /**
+ * User query service.
+ */
+ @Inject
+ private UserQueryService userQueryService;
+
+ /**
+ * Event manager.
+ */
+ @Inject
+ private EventManager eventManager;
+
+ /**
+ * Language service.
+ */
+ @Inject
+ private LangPropsService langPropsService;
+
+ /**
+ * User management service.
+ */
+ @Inject
+ private UserMgmtService userMgmtService;
+
+ /**
+ * Fills articles in index.ftl.
+ *
+ * @param context the specified HTTP servlet request context
+ * @param dataModel data model
+ * @param currentPageNum current page number
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillIndexArticles(final RequestContext context, final Map dataModel, final int currentPageNum, final JSONObject preference)
+ throws ServiceException {
+ Stopwatchs.start("Fill Index Articles");
+
+ try {
+ final int pageSize = preference.getInt(Option.ID_C_ARTICLE_LIST_DISPLAY_COUNT);
+ final int windowSize = preference.getInt(Option.ID_C_ARTICLE_LIST_PAGINATION_WINDOW_SIZE);
+
+ final Query query = new Query().setPage(currentPageNum, pageSize).
+ setFilter(new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED));
+
+ final Template template = Skins.getSkinTemplate(context, "index.ftl");
+ boolean isArticles1 = false;
+ if (null == template) {
+ LOGGER.debug("The skin dose not contain [index.ftl] template");
+ } else // See https://github.com/b3log/solo/issues/179 for more details
+ if (Templates.hasExpression(template, "<#list articles1 as article>")) {
+ isArticles1 = true;
+ query.addSort(Article.ARTICLE_CREATED, SortDirection.DESCENDING);
+ LOGGER.trace("Query ${articles1} in index.ftl");
+ } else { // <#list articles as article>
+ query.addSort(Article.ARTICLE_PUT_TOP, SortDirection.DESCENDING);
+ if (preference.getBoolean(Option.ID_C_ENABLE_ARTICLE_UPDATE_HINT)) {
+ query.addSort(Article.ARTICLE_UPDATED, SortDirection.DESCENDING);
+ } else {
+ query.addSort(Article.ARTICLE_CREATED, SortDirection.DESCENDING);
+ }
+ }
+
+ final JSONObject articlesResult = articleRepository.get(query);
+ final List articles = CollectionUtils.jsonArrayToList(articlesResult.optJSONArray(Keys.RESULTS));
+ final int pageCount = articlesResult.optJSONObject(Pagination.PAGINATION).optInt(Pagination.PAGINATION_PAGE_COUNT);
+ setArticlesExProperties(context, articles, preference);
+
+ final List pageNums = Paginator.paginate(currentPageNum, pageSize, pageCount, windowSize);
+ if (0 != pageNums.size()) {
+ dataModel.put(Pagination.PAGINATION_FIRST_PAGE_NUM, pageNums.get(0));
+ dataModel.put(Pagination.PAGINATION_LAST_PAGE_NUM, pageNums.get(pageNums.size() - 1));
+ }
+ dataModel.put(Pagination.PAGINATION_PAGE_COUNT, pageCount);
+ dataModel.put(Pagination.PAGINATION_PAGE_NUMS, pageNums);
+
+ if (!isArticles1) {
+ dataModel.put(Article.ARTICLES, articles);
+ } else {
+ dataModel.put(Article.ARTICLES + "1", articles);
+ }
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills index articles failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills links.
+ *
+ * @param dataModel data model
+ * @throws ServiceException service exception
+ */
+ public void fillLinks(final Map dataModel) throws ServiceException {
+ Stopwatchs.start("Fill Links");
+ try {
+ final Map sorts = new HashMap<>();
+
+ sorts.put(Link.LINK_ORDER, SortDirection.ASCENDING);
+ final Query query = new Query().addSort(Link.LINK_ORDER, SortDirection.ASCENDING).setPageCount(1);
+ final List links = linkRepository.getList(query);
+
+ dataModel.put(Link.LINKS, links);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Fills links failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ Stopwatchs.end();
+ }
+
+ /**
+ * Fills tags.
+ *
+ * @param dataModel data model
+ * @throws ServiceException service exception
+ */
+ public void fillTags(final Map dataModel) throws ServiceException {
+ Stopwatchs.start("Fill Tags");
+ try {
+ final List tags = tagQueryService.getTags();
+ dataModel.put(Tag.TAGS, tags);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills tags failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+
+ Stopwatchs.end();
+ }
+
+ /**
+ * Fills categories.
+ *
+ * @param dataModel data model
+ * @throws ServiceException service exception
+ */
+ public void fillCategories(final Map dataModel) throws ServiceException {
+ Stopwatchs.start("Fill Categories");
+
+ try {
+ LOGGER.debug("Filling categories....");
+ final List categories = categoryRepository.getMostUsedCategories(Integer.MAX_VALUE);
+ dataModel.put(Category.CATEGORIES, categories);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Fills categories failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills most used categories.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillMostUsedCategories(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Most Used Categories");
+
+ try {
+ LOGGER.debug("Filling most used categories....");
+ final int mostUsedCategoryDisplayCnt = Integer.MAX_VALUE; // XXX: preference instead
+ final List categories = categoryRepository.getMostUsedCategories(mostUsedCategoryDisplayCnt);
+ dataModel.put(Common.MOST_USED_CATEGORIES, categories);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Fills most used categories failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills most used tags.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillMostUsedTags(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Most Used Tags");
+
+ try {
+ LOGGER.debug("Filling most used tags....");
+ final int mostUsedTagDisplayCnt = preference.getInt(Option.ID_C_MOST_USED_TAG_DISPLAY_CNT);
+ final List tags = tagArticleRepository.getMostUsedTags(mostUsedTagDisplayCnt);
+ dataModel.put(Common.MOST_USED_TAGS, tags);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills most used tags failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills archive dates.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillArchiveDates(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Archive Dates");
+
+ try {
+ LOGGER.debug("Filling archive dates....");
+ final List archiveDates = archiveDateRepository.getArchiveDates();
+ final List archiveDates2 = new ArrayList<>();
+ dataModel.put(ArchiveDate.ARCHIVE_DATES, archiveDates2);
+ if (archiveDates.isEmpty()) {
+ return;
+ }
+
+ archiveDates2.add(archiveDates.get(0));
+
+ if (1 < archiveDates.size()) { // XXX: Workaround, remove the duplicated archive dates
+ for (int i = 1; i < archiveDates.size(); i++) {
+ final JSONObject archiveDate = archiveDates.get(i);
+
+ final long time = archiveDate.getLong(ArchiveDate.ARCHIVE_TIME);
+ final String dateString = DateFormatUtils.format(time, "yyyy/MM");
+
+ final JSONObject last = archiveDates2.get(archiveDates2.size() - 1);
+ final String lastDateString = DateFormatUtils.format(last.getLong(ArchiveDate.ARCHIVE_TIME), "yyyy/MM");
+
+ if (!dateString.equals(lastDateString)) {
+ archiveDates2.add(archiveDate);
+ } else {
+ LOGGER.log(Level.DEBUG, "Found a duplicated archive date [{0}]", dateString);
+ }
+ }
+ }
+
+ final String localeString = preference.getString(Option.ID_C_LOCALE_STRING);
+ final String language = Locales.getLanguage(localeString);
+
+ for (final JSONObject archiveDate : archiveDates2) {
+ final long time = archiveDate.getLong(ArchiveDate.ARCHIVE_TIME);
+ final String dateString = DateFormatUtils.format(time, "yyyy/MM");
+ final String[] dateStrings = dateString.split("/");
+ final String year = dateStrings[0];
+ final String month = dateStrings[1];
+
+ archiveDate.put(ArchiveDate.ARCHIVE_DATE_YEAR, year);
+
+ archiveDate.put(ArchiveDate.ARCHIVE_DATE_MONTH, month);
+ if ("en".equals(language)) {
+ final String monthName = Dates.EN_MONTHS.get(month);
+
+ archiveDate.put(Common.MONTH_NAME, monthName);
+ }
+ }
+
+ dataModel.put(ArchiveDate.ARCHIVE_DATES, archiveDates2);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills archive dates failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills most view count articles.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillMostViewCountArticles(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Most View Articles");
+ try {
+ LOGGER.debug("Filling the most view count articles....");
+ final int mostCommentArticleDisplayCnt = preference.getInt(Option.ID_C_MOST_VIEW_ARTICLE_DISPLAY_CNT);
+ final List mostViewCountArticles = articleRepository.getMostViewCountArticles(mostCommentArticleDisplayCnt);
+
+ dataModel.put(Common.MOST_VIEW_COUNT_ARTICLES, mostViewCountArticles);
+
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills most view count articles failed", e);
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills most comments articles.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillMostCommentArticles(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Most CMMTs Articles");
+
+ try {
+ LOGGER.debug("Filling most comment articles....");
+ final int mostCommentArticleDisplayCnt = preference.getInt(Option.ID_C_MOST_COMMENT_ARTICLE_DISPLAY_CNT);
+ final List mostCommentArticles = articleRepository.getMostCommentArticles(mostCommentArticleDisplayCnt);
+
+ dataModel.put(Common.MOST_COMMENT_ARTICLES, mostCommentArticles);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills most comment articles failed", e);
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills post articles recently.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillRecentArticles(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Recent Articles");
+
+ try {
+ final int recentArticleDisplayCnt = preference.getInt(Option.ID_C_RECENT_ARTICLE_DISPLAY_CNT);
+ final List recentArticles = articleRepository.getRecentArticles(recentArticleDisplayCnt);
+ dataModel.put(Common.RECENT_ARTICLES, recentArticles);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills recent articles failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills post comments recently.
+ *
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillRecentComments(final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill Recent Comments");
+ try {
+ LOGGER.debug("Filling recent comments....");
+ final int recentCommentDisplayCnt = preference.getInt(Option.ID_C_RECENT_COMMENT_DISPLAY_CNT);
+ final List recentComments = commentRepository.getRecentComments(recentCommentDisplayCnt);
+ for (final JSONObject comment : recentComments) {
+ String commentContent = comment.optString(Comment.COMMENT_CONTENT);
+ commentContent = Markdowns.toHTML(commentContent);
+ commentContent = Jsoup.clean(commentContent, Whitelist.relaxed());
+ comment.put(Comment.COMMENT_CONTENT, commentContent);
+ comment.put(Comment.COMMENT_NAME, comment.getString(Comment.COMMENT_NAME));
+ comment.put(Comment.COMMENT_URL, comment.getString(Comment.COMMENT_URL));
+ comment.put(Common.IS_REPLY, false);
+ comment.put(Comment.COMMENT_T_DATE, new Date(comment.optLong(Comment.COMMENT_CREATED)));
+ comment.put("commentDate2", new Date(comment.optLong(Comment.COMMENT_CREATED)));
+ }
+
+ dataModel.put(Common.RECENT_COMMENTS, recentComments);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills recent comments failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills favicon URL. 可配置 favicon 图标路径 https://github.com/b3log/solo/issues/12706
+ *
+ * @param dataModel the specified data model
+ * @param preference the specified preference
+ */
+ public void fillFaviconURL(final Map dataModel, final JSONObject preference) {
+ if (null == preference) {
+ dataModel.put(Common.FAVICON_URL, Option.DefaultPreference.DEFAULT_FAVICON_URL);
+ } else {
+ dataModel.put(Common.FAVICON_URL, preference.optString(Option.ID_C_FAVICON_URL));
+ }
+ }
+
+ /**
+ * Fills usite. 展示站点连接 https://github.com/b3log/solo/issues/12719
+ *
+ * @param dataModel the specified data model
+ */
+ public void fillUsite(final Map dataModel) {
+ try {
+ final JSONObject usiteOpt = optionQueryService.getOptionById(Option.ID_C_USITE);
+ if (null == usiteOpt) {
+ return;
+ }
+
+ dataModel.put(Option.ID_C_USITE, new JSONObject(usiteOpt.optString(Option.OPTION_VALUE)));
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills usite failed", e);
+ }
+ }
+
+ /**
+ * Fills common parts (header, side and footer).
+ *
+ * @param context the specified HTTP servlet request context
+ * @param dataModel the specified data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillCommon(final RequestContext context, final Map dataModel, final JSONObject preference) throws ServiceException {
+ fillSide(context, dataModel, preference);
+ fillBlogHeader(context, dataModel, preference);
+ fillBlogFooter(context, dataModel, preference);
+
+ // 支持配置自定义模板变量 https://github.com/b3log/solo/issues/12535
+ final Map customVars = new HashMap<>();
+ final String customVarsStr = preference.optString(Option.ID_C_CUSTOM_VARS);
+ final String[] customVarsArray = customVarsStr.split("\\|");
+ for (int i = 0; i < customVarsArray.length; i++) {
+ final String customVarPair = customVarsArray[i];
+ if (StringUtils.isNotBlank(customVarsStr)) {
+ final String customVarKey = customVarPair.split("=")[0];
+ final String customVarVal = customVarPair.split("=")[1];
+ if (StringUtils.isNotBlank(customVarKey) && StringUtils.isNotBlank(customVarVal)) {
+ customVars.put(customVarKey, customVarVal);
+ }
+ }
+ }
+ dataModel.put("customVars", customVars);
+
+ dataModel.put(Common.LUTE_AVAILABLE, Markdowns.LUTE_AVAILABLE);
+ String hljsTheme = preference.optString(Option.ID_C_HLJS_THEME);
+ if (StringUtils.isBlank(hljsTheme)) {
+ hljsTheme = Option.DefaultPreference.DEFAULT_HLJS_THEME;
+ }
+ dataModel.put(Option.ID_C_HLJS_THEME, hljsTheme);
+ }
+
+ /**
+ * Fills footer.ftl.
+ *
+ * @param context the specified HTTP servlet request context
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ private void fillBlogFooter(final RequestContext context, final Map dataModel, final JSONObject preference)
+ throws ServiceException {
+ Stopwatchs.start("Fill Footer");
+ try {
+ LOGGER.debug("Filling footer....");
+ final String blogTitle = preference.getString(Option.ID_C_BLOG_TITLE);
+ dataModel.put(Option.ID_C_BLOG_TITLE, blogTitle);
+ dataModel.put("blogHost", Latkes.getServePath());
+ dataModel.put(Common.VERSION, SoloServletListener.VERSION);
+ dataModel.put(Common.STATIC_RESOURCE_VERSION, Latkes.getStaticResourceVersion());
+ dataModel.put(Common.YEAR, String.valueOf(Calendar.getInstance().get(Calendar.YEAR)));
+ String footerContent = "";
+ final JSONObject opt = optionQueryService.getOptionById(Option.ID_C_FOOTER_CONTENT);
+ if (null != opt) {
+ footerContent = opt.optString(Option.OPTION_VALUE);
+ }
+ dataModel.put(Option.ID_C_FOOTER_CONTENT, footerContent);
+ dataModel.put(Keys.Server.STATIC_SERVER, Latkes.getStaticServer());
+ dataModel.put(Keys.Server.SERVER, Latkes.getServer());
+ dataModel.put(Common.IS_INDEX, "/".equals(context.requestURI()));
+ dataModel.put(User.USER_NAME, "");
+ final JSONObject currentUser = Solos.getCurrentUser(context.getRequest(), context.getResponse());
+ if (null != currentUser) {
+ final String userAvatar = currentUser.optString(UserExt.USER_AVATAR);
+ dataModel.put(Common.GRAVATAR, userAvatar);
+ dataModel.put(User.USER_NAME, currentUser.optString(User.USER_NAME));
+ }
+
+ // Activates plugins
+ final ViewLoadEventData data = new ViewLoadEventData();
+ data.setViewName("footer.ftl");
+ data.setDataModel(dataModel);
+ eventManager.fireEventSynchronously(new Event<>(Keys.FREEMARKER_ACTION, data));
+ if (StringUtils.isBlank((String) dataModel.get(Plugin.PLUGINS))) {
+ // There is no plugin for this template, fill ${plugins} with blank.
+ dataModel.put(Plugin.PLUGINS, "");
+ }
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills blog footer failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills header.ftl.
+ *
+ * @param context the specified HTTP servlet request context
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ private void fillBlogHeader(final RequestContext context, final Map dataModel, final JSONObject preference)
+ throws ServiceException {
+ Stopwatchs.start("Fill Header");
+ try {
+ LOGGER.debug("Filling header....");
+ final String topBarHTML = getTopBarHTML(context);
+ dataModel.put(Common.LOGIN_URL, userQueryService.getLoginURL(Common.ADMIN_INDEX_URI));
+ dataModel.put(Common.LOGOUT_URL, userQueryService.getLogoutURL());
+ dataModel.put(Common.ONLINE_VISITOR_CNT, StatisticQueryService.getOnlineVisitorCount());
+ dataModel.put(Common.TOP_BAR, topBarHTML);
+ dataModel.put(Option.ID_C_ARTICLE_LIST_DISPLAY_COUNT, preference.getInt(Option.ID_C_ARTICLE_LIST_DISPLAY_COUNT));
+ dataModel.put(Option.ID_C_ARTICLE_LIST_PAGINATION_WINDOW_SIZE, preference.getInt(Option.ID_C_ARTICLE_LIST_PAGINATION_WINDOW_SIZE));
+ dataModel.put(Option.ID_C_LOCALE_STRING, preference.getString(Option.ID_C_LOCALE_STRING));
+ dataModel.put(Option.ID_C_BLOG_TITLE, preference.getString(Option.ID_C_BLOG_TITLE));
+ dataModel.put(Option.ID_C_BLOG_SUBTITLE, preference.getString(Option.ID_C_BLOG_SUBTITLE));
+ dataModel.put(Option.ID_C_HTML_HEAD, preference.getString(Option.ID_C_HTML_HEAD));
+ String metaKeywords = preference.getString(Option.ID_C_META_KEYWORDS);
+ if (StringUtils.isBlank(metaKeywords)) {
+ metaKeywords = "";
+ }
+ dataModel.put(Option.ID_C_META_KEYWORDS, metaKeywords);
+ String metaDescription = preference.getString(Option.ID_C_META_DESCRIPTION);
+ if (StringUtils.isBlank(metaDescription)) {
+ metaDescription = "";
+ }
+ dataModel.put(Option.ID_C_META_DESCRIPTION, metaDescription);
+ dataModel.put(Common.YEAR, String.valueOf(Calendar.getInstance().get(Calendar.YEAR)));
+ dataModel.put(Common.IS_LOGGED_IN, null != Solos.getCurrentUser(context.getRequest(), context.getResponse()));
+ dataModel.put(Common.FAVICON_API, Solos.FAVICON_API);
+ final String noticeBoard = preference.getString(Option.ID_C_NOTICE_BOARD);
+ dataModel.put(Option.ID_C_NOTICE_BOARD, noticeBoard);
+ // 皮肤不显示访客用户 https://github.com/b3log/solo/issues/12752
+ final Query query = new Query().setPageCount(1).setFilter(new PropertyFilter(User.USER_ROLE, FilterOperator.NOT_EQUAL, Role.VISITOR_ROLE));
+ final List userList = userRepository.getList(query);
+ dataModel.put(User.USERS, userList);
+ final JSONObject admin = userRepository.getAdmin();
+ dataModel.put(Common.ADMIN_USER, admin);
+ final String skinDirName = (String) context.attr(Keys.TEMAPLTE_DIR_NAME);
+ dataModel.put(Option.ID_C_SKIN_DIR_NAME, skinDirName);
+ Keys.fillRuntime(dataModel);
+ fillMinified(dataModel);
+ fillPageNavigations(dataModel);
+ fillStatistic(dataModel);
+ fillMostUsedTags(dataModel, preference);
+ fillArchiveDates(dataModel, preference);
+ fillMostUsedCategories(dataModel, preference);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills blog header failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills minified directory and file postfix for static JavaScript, CSS.
+ *
+ * @param dataModel the specified data model
+ */
+ public void fillMinified(final Map dataModel) {
+ switch (Latkes.getRuntimeMode()) {
+ case DEVELOPMENT:
+ dataModel.put(Common.MINI_POSTFIX, "");
+ break;
+
+ case PRODUCTION:
+ dataModel.put(Common.MINI_POSTFIX, Common.MINI_POSTFIX_VALUE);
+ break;
+
+ default:
+ throw new AssertionError();
+ }
+ }
+
+ /**
+ * Fills side.ftl.
+ *
+ * @param context the specified HTTP servlet request context
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ private void fillSide(final RequestContext context, final Map dataModel, final JSONObject preference)
+ throws ServiceException {
+ Stopwatchs.start("Fill Side");
+ try {
+ LOGGER.debug("Filling side....");
+
+ Template template = Skins.getSkinTemplate(context, "side.ftl");
+ if (null == template) {
+ LOGGER.debug("The skin dose not contain [side.ftl] template");
+
+ template = Skins.getSkinTemplate(context, "index.ftl");
+ if (null == template) {
+ LOGGER.debug("The skin dose not contain [index.ftl] template");
+ return;
+ }
+ }
+
+ if (Templates.hasExpression(template, "<#list recentArticles as article>")) {
+ fillRecentArticles(dataModel, preference);
+ }
+
+ if (Templates.hasExpression(template, "<#list links as link>")) {
+ fillLinks(dataModel);
+ }
+
+ if (Templates.hasExpression(template, "<#list recentComments as comment>")) {
+ fillRecentComments(dataModel, preference);
+ }
+
+ if (Templates.hasExpression(template, "<#list mostCommentArticles as article>")) {
+ fillMostCommentArticles(dataModel, preference);
+ }
+
+ if (Templates.hasExpression(template, "<#list mostViewCountArticles as article>")) {
+ fillMostViewCountArticles(dataModel, preference);
+ }
+ } catch (final ServiceException e) {
+ LOGGER.log(Level.ERROR, "Fills side failed", e);
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills the specified template.
+ *
+ * @param context the specified HTTP servlet request context
+ * @param template the specified template
+ * @param dataModel data model
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void fillUserTemplate(final RequestContext context, final Template template,
+ final Map dataModel, final JSONObject preference) throws ServiceException {
+ Stopwatchs.start("Fill User Template[name=" + template.getName() + "]");
+ try {
+ LOGGER.log(Level.DEBUG, "Filling user template[name{0}]", template.getName());
+
+ if (Templates.hasExpression(template, "<#list links as link>")) {
+ fillLinks(dataModel);
+ }
+
+ if (Templates.hasExpression(template, "<#list tags as tag>")) {
+ fillTags(dataModel);
+ }
+
+ if (Templates.hasExpression(template, "<#list categories as category>")) {
+ fillCategories(dataModel);
+ }
+
+ if (Templates.hasExpression(template, "<#list recentComments as comment>")) {
+ fillRecentComments(dataModel, preference);
+ }
+
+ if (Templates.hasExpression(template, "<#list mostCommentArticles as article>")) {
+ fillMostCommentArticles(dataModel, preference);
+ }
+
+ if (Templates.hasExpression(template, "<#list mostViewCountArticles as article>")) {
+ fillMostViewCountArticles(dataModel, preference);
+ }
+
+ if (Templates.hasExpression(template, "<#include \"side.ftl\"/>")) {
+ fillSide(context, dataModel, preference);
+ }
+
+ final String noticeBoard = preference.getString(Option.ID_C_NOTICE_BOARD);
+
+ dataModel.put(Option.ID_C_NOTICE_BOARD, noticeBoard);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Fills user template failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills page navigations.
+ *
+ * @param dataModel data model
+ * @throws ServiceException service exception
+ */
+ private void fillPageNavigations(final Map dataModel) throws ServiceException {
+ Stopwatchs.start("Fill Navigations");
+ try {
+ LOGGER.debug("Filling page navigations....");
+ final List pages = pageRepository.getPages();
+ dataModel.put(Common.PAGE_NAVIGATIONS, pages);
+ } catch (final RepositoryException e) {
+ LOGGER.log(Level.ERROR, "Fills page navigations failed", e);
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Fills statistic.
+ *
+ * @param dataModel the specified data model
+ */
+ private void fillStatistic(final Map dataModel) {
+ Stopwatchs.start("Fill Statistic");
+ try {
+ LOGGER.debug("Filling statistic....");
+ final JSONObject statistic = statisticQueryService.getStatistic();
+ dataModel.put(Option.CATEGORY_C_STATISTIC, statistic);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+
+ /**
+ * Sets some extra properties into the specified article with the specified preference, performs content and abstract editor processing.
+ *
+ * Article ext properties:
+ *
+ * {
+ * ....,
+ * "authorName": "",
+ * "authorId": "",
+ * "authorThumbnailURL": "",
+ * "hasUpdated": boolean
+ * }
+ *
+ *
+ *
+ * @param context the specified HTTP servlet request context
+ * @param article the specified article
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ * @see #setArticlesExProperties(RequestContext, List, JSONObject)
+ */
+ private void setArticleExProperties(final RequestContext context, final JSONObject article, final JSONObject preference) throws ServiceException {
+ try {
+ final JSONObject author = articleQueryService.getAuthor(article);
+ final String authorName = author.getString(User.USER_NAME);
+ article.put(Common.AUTHOR_NAME, authorName);
+ final String authorId = author.getString(Keys.OBJECT_ID);
+ article.put(Common.AUTHOR_ID, authorId);
+ article.put(Article.ARTICLE_T_CREATE_DATE, new Date(article.optLong(Article.ARTICLE_CREATED)));
+ article.put(Article.ARTICLE_T_UPDATE_DATE, new Date(article.optLong(Article.ARTICLE_UPDATED)));
+
+ final String userAvatar = author.optString(UserExt.USER_AVATAR);
+ article.put(Common.AUTHOR_THUMBNAIL_URL, userAvatar);
+
+ if (preference.getBoolean(Option.ID_C_ENABLE_ARTICLE_UPDATE_HINT)) {
+ article.put(Common.HAS_UPDATED, articleQueryService.hasUpdated(article));
+ } else {
+ article.put(Common.HAS_UPDATED, false);
+ }
+
+ if (Solos.needViewPwd(context, article)) {
+ final String content = langPropsService.get("articleContentPwd");
+ article.put(ARTICLE_CONTENT, content);
+ }
+
+ processArticleAbstract(preference, article);
+
+ articleQueryService.markdown(article);
+
+ fillCategory(article);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Sets article extra properties failed", e);
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Fills category for the specified article.
+ *
+ * @param article the specified article
+ */
+ public void fillCategory(final JSONObject article) {
+ final String tagsStr = article.optString(Article.ARTICLE_TAGS_REF);
+ final String[] tags = tagsStr.split(",");
+ JSONObject category = null;
+ for (final String tagTitle : tags) {
+ final JSONObject c = getCategoryOfTag(tagTitle);
+ if (null != c) {
+ category = c;
+ break;
+ }
+ }
+ article.put(Category.CATEGORY, category);
+ }
+
+ /**
+ * Gets a category for a tag specified by the given tag title.
+ *
+ * @param tagTitle the given tag title
+ * @return category, returns {@code null} if not found
+ */
+ private JSONObject getCategoryOfTag(final String tagTitle) {
+ try {
+ final JSONObject tag = tagRepository.getByTitle(tagTitle);
+ if (null == tag) {
+ return null;
+ }
+
+ final String tagId = tag.optString(Keys.OBJECT_ID);
+ final Query query = new Query().setFilter(new PropertyFilter(Tag.TAG + "_" + Keys.OBJECT_ID, FilterOperator.EQUAL, tagId)).
+ setPage(1, 1).setPageCount(1);
+ final JSONObject tagCategory = categoryTagRepository.getFirst(query);
+ if (null == tagCategory) {
+ return null;
+ }
+
+ final String categoryId = tagCategory.optString(Category.CATEGORY + "_" + Keys.OBJECT_ID);
+
+ return categoryRepository.get(categoryId);
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gets category of tag [" + tagTitle + "] failed", e);
+
+ return null;
+ }
+ }
+
+ /**
+ * Sets some extra properties into the specified article with the specified preference.
+ *
+ * The batch version of method {@linkplain #setArticleExProperties(RequestContext, JSONObject, JSONObject)}.
+ *
+ *
+ * Article ext properties:
+ *
+ * {
+ * ....,
+ * "authorName": "",
+ * "authorId": "",
+ * "hasUpdated": boolean
+ * }
+ *
+ *
+ *
+ * @param context the specified HTTP servlet request context
+ * @param articles the specified articles
+ * @param preference the specified preference
+ * @throws ServiceException service exception
+ */
+ public void setArticlesExProperties(final RequestContext context, final List articles, final JSONObject preference)
+ throws ServiceException {
+ for (final JSONObject article : articles) {
+ setArticleExProperties(context, article, preference);
+ }
+ }
+
+ /**
+ * Processes the abstract of the specified article with the specified preference.
+ *
+ * - If the abstract is {@code null}, sets it with ""
+ * - If user configured preference "titleOnly", sets the abstract with ""
+ * - If user configured preference "titleAndContent", sets the abstract with the content of the article
+ *
+ *
+ * @param preference the specified preference
+ * @param article the specified article
+ */
+ private void processArticleAbstract(final JSONObject preference, final JSONObject article) {
+ final String articleAbstract = article.optString(Article.ARTICLE_ABSTRACT);
+ if (StringUtils.isBlank(articleAbstract)) {
+ article.put(Article.ARTICLE_ABSTRACT, "");
+ }
+ final String articleAbstractText = article.optString(Article.ARTICLE_ABSTRACT_TEXT);
+ if (StringUtils.isBlank(articleAbstractText)) {
+ // 发布文章时会自动提取摘要文本,其中如果文章加密且没有写摘要,则自动提取文本会返回空字符串 Article#getAbstractText()
+ // 所以当且仅当文章加密且没有摘要的情况下 articleAbstractText 会为空
+ final LangPropsService langPropsService = BeanManager.getInstance().getReference(LangPropsService.class);
+ article.put(Article.ARTICLE_ABSTRACT_TEXT, langPropsService.get("articleContentPwd"));
+ }
+
+ final String articleListStyle = preference.optString(Option.ID_C_ARTICLE_LIST_STYLE);
+ if ("titleOnly".equals(articleListStyle)) {
+ article.put(Article.ARTICLE_ABSTRACT, "");
+ } else if ("titleAndContent".equals(articleListStyle)) {
+ article.put(Article.ARTICLE_ABSTRACT, article.optString(Article.ARTICLE_CONTENT));
+ }
+ }
+
+ /**
+ * Generates top bar HTML.
+ *
+ * @param context the specified request context
+ * @return top bar HTML
+ * @throws ServiceException service exception
+ */
+ public String getTopBarHTML(final RequestContext context) throws ServiceException {
+ Stopwatchs.start("Gens Top Bar HTML");
+
+ try {
+ final Template topBarTemplate = Skins.getTemplate("common-template/top-bar.ftl");
+ final StringWriter stringWriter = new StringWriter();
+ final Map topBarModel = new HashMap<>();
+ final JSONObject currentUser = Solos.getCurrentUser(context.getRequest(), context.getResponse());
+
+ Keys.fillServer(topBarModel);
+ topBarModel.put(Common.IS_LOGGED_IN, false);
+ topBarModel.put(Common.IS_MOBILE_REQUEST, Solos.isMobile(context.getRequest()));
+ topBarModel.put("mobileLabel", langPropsService.get("mobileLabel"));
+ topBarModel.put("onlineVisitor1Label", langPropsService.get("onlineVisitor1Label"));
+ topBarModel.put(Common.ONLINE_VISITOR_CNT, StatisticQueryService.getOnlineVisitorCount());
+ if (null == currentUser) {
+ topBarModel.put(Common.LOGIN_URL, userQueryService.getLoginURL(Common.ADMIN_INDEX_URI));
+ topBarModel.put("startToUseLabel", langPropsService.get("startToUseLabel"));
+ topBarTemplate.process(topBarModel, stringWriter);
+
+ return stringWriter.toString();
+ }
+
+ topBarModel.put(Common.IS_LOGGED_IN, true);
+ topBarModel.put(Common.LOGOUT_URL, userQueryService.getLogoutURL());
+ topBarModel.put(Common.IS_ADMIN, Role.ADMIN_ROLE.equals(currentUser.getString(User.USER_ROLE)));
+ topBarModel.put(Common.IS_VISITOR, Role.VISITOR_ROLE.equals(currentUser.getString(User.USER_ROLE)));
+ topBarModel.put("adminLabel", langPropsService.get("adminLabel"));
+ topBarModel.put("logoutLabel", langPropsService.get("logoutLabel"));
+ final String userName = currentUser.getString(User.USER_NAME);
+ topBarModel.put(User.USER_NAME, userName);
+ topBarTemplate.process(topBarModel, stringWriter);
+
+ return stringWriter.toString();
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Gens top bar HTML failed", e);
+
+ throw new ServiceException(e);
+ } finally {
+ Stopwatchs.end();
+ }
+ }
+}
diff --git a/src/main/java/org/b3log/solo/service/ExportService.java b/src/main/java/org/b3log/solo/service/ExportService.java
new file mode 100644
index 00000000..639037e5
--- /dev/null
+++ b/src/main/java/org/b3log/solo/service/ExportService.java
@@ -0,0 +1,415 @@
+/*
+ * Solo - A small and beautiful blogging system written in Java.
+ * Copyright (c) 2010-present, b3log.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.b3log.solo.service;
+
+import jodd.http.HttpRequest;
+import jodd.http.HttpResponse;
+import jodd.io.ZipUtil;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang.time.DateFormatUtils;
+import org.b3log.latke.Keys;
+import org.b3log.latke.Latkes;
+import org.b3log.latke.ioc.Inject;
+import org.b3log.latke.logging.Level;
+import org.b3log.latke.logging.Logger;
+import org.b3log.latke.model.Plugin;
+import org.b3log.latke.model.User;
+import org.b3log.latke.repository.*;
+import org.b3log.latke.service.annotation.Service;
+import org.b3log.latke.util.Strings;
+import org.b3log.solo.SoloServletListener;
+import org.b3log.solo.model.*;
+import org.b3log.solo.repository.*;
+import org.b3log.solo.util.Solos;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Export service.
+ *
+ * @author Liang Ding
+ * @version 1.1.1.1, Sep 18, 2019
+ * @since 2.5.0
+ */
+@Service
+public class ExportService {
+
+ /**
+ * Logger.
+ */
+ private static final Logger LOGGER = Logger.getLogger(ExportService.class);
+
+ /**
+ * Archive date repository.
+ */
+ @Inject
+ private ArchiveDateRepository archiveDateRepository;
+
+ /**
+ * Archive date-Article repository.
+ */
+ @Inject
+ private ArchiveDateArticleRepository archiveDateArticleRepository;
+
+ /**
+ * Article repository.
+ */
+ @Inject
+ private ArticleRepository articleRepository;
+
+ /**
+ * Category repository.
+ */
+ @Inject
+ private CategoryRepository categoryRepository;
+
+ /**
+ * Category-Tag relation repository.
+ */
+ @Inject
+ private CategoryTagRepository categoryTagRepository;
+
+ /**
+ * Comment repository.
+ */
+ @Inject
+ private CommentRepository commentRepository;
+
+ /**
+ * Link repository.
+ */
+ @Inject
+ private LinkRepository linkRepository;
+
+ /**
+ * Option repository.
+ */
+ @Inject
+ private OptionRepository optionRepository;
+
+ /**
+ * Page repository.
+ */
+ @Inject
+ private PageRepository pageRepository;
+
+ /**
+ * Plugin repository.
+ */
+ @Inject
+ private PluginRepository pluginRepository;
+
+ /**
+ * Tag repository.
+ */
+ @Inject
+ private TagRepository tagRepository;
+
+ /**
+ * Tag-Article repository.
+ */
+ @Inject
+ private TagArticleRepository tagArticleRepository;
+
+ /**
+ * User repository.
+ */
+ @Inject
+ private UserRepository userRepository;
+
+ /**
+ * Option query service.
+ */
+ @Inject
+ private OptionQueryService optionQueryService;
+
+ /**
+ * Exports public articles to admin's GitHub repos. 博文定时同步 GitHub 仓库 https://hacpai.com/article/1557238327458
+ */
+ public void exportGitHubRepo() {
+ LOGGER.log(Level.INFO, "Github repo syncing....");
+ try {
+ final JSONObject preference = optionQueryService.getPreference();
+ if (null == preference) {
+ return;
+ }
+
+ if (!preference.optBoolean(Option.ID_C_SYNC_GITHUB)) {
+ return;
+ }
+
+ if (Latkes.getServePath().contains("localhost") || Strings.isIPv4(Latkes.getServerHost())) {
+ return;
+ }
+
+ if (Latkes.RuntimeMode.PRODUCTION != Latkes.getRuntimeMode()) {
+ return;
+ }
+
+ final JSONObject mds = exportHexoMDs();
+ final List posts = (List) mds.opt("posts");
+
+ final String tmpDir = System.getProperty("java.io.tmpdir");
+ final String date = DateFormatUtils.format(new Date(), "yyyyMMddHHmmss");
+ String localFilePath = tmpDir + File.separator + "solo-hexo-" + date;
+ final File localFile = new File(localFilePath);
+
+ final File postDir = new File(localFilePath + File.separator + "posts");
+ exportHexoMd(posts, postDir.getPath());
+
+ final File zipFile = ZipUtil.zip(localFile);
+ byte[] zipData;
+ try (final FileInputStream inputStream = new FileInputStream(zipFile)) {
+ zipData = IOUtils.toByteArray(inputStream);
+ }
+
+ FileUtils.deleteQuietly(localFile);
+ FileUtils.deleteQuietly(zipFile);
+
+ final JSONObject user = userRepository.getAdmin();
+ final String userName = user.optString(User.USER_NAME);
+ final String userB3Key = user.optString(UserExt.USER_B3_KEY);
+ final String clientTitle = preference.optString(Option.ID_C_BLOG_TITLE);
+ final String clientSubtitle = preference.optString(Option.ID_C_BLOG_SUBTITLE);
+
+ final Set articleIds = new HashSet<>();
+ final Filter published = new PropertyFilter(Article.ARTICLE_STATUS, FilterOperator.EQUAL, Article.ARTICLE_STATUS_C_PUBLISHED);
+
+ final StringBuilder bodyBuilder = new StringBuilder("### 最新\n");
+ final List recentArticles = articleRepository.getList(new Query().setFilter(published).select(Keys.OBJECT_ID, Article.ARTICLE_TITLE, Article.ARTICLE_PERMALINK).addSort(Article.ARTICLE_CREATED, SortDirection.DESCENDING).setPage(1, 20));
+ for (final JSONObject article : recentArticles) {
+ final String title = article.optString(Article.ARTICLE_TITLE);
+ final String link = Latkes.getServePath() + article.optString(Article.ARTICLE_PERMALINK);
+ bodyBuilder.append("\n* [").append(title).append("](").append(link).append(")");
+ articleIds.add(article.optString(Keys.OBJECT_ID));
+ }
+ bodyBuilder.append("\n\n");
+
+ final StringBuilder mostViewBuilder = new StringBuilder();
+ final List mostViewArticles = articleRepository.getList(new Query().setFilter(published).select(Keys.OBJECT_ID, Article.ARTICLE_TITLE, Article.ARTICLE_PERMALINK).addSort(Article.ARTICLE_VIEW_COUNT, SortDirection.DESCENDING).setPage(1, 40));
+ int count = 0;
+ for (final JSONObject article : mostViewArticles) {
+ final String articleId = article.optString(Keys.OBJECT_ID);
+ if (!articleIds.contains(articleId)) {
+ final String title = article.optString(Article.ARTICLE_TITLE);
+ final String link = Latkes.getServePath() + article.optString(Article.ARTICLE_PERMALINK);
+ mostViewBuilder.append("\n* [").append(title).append("](").append(link).append(")");
+ articleIds.add(articleId);
+ count++;
+ }
+ if (20 <= count) {
+ break;
+ }
+ }
+ if (0 < mostViewBuilder.length()) {
+ bodyBuilder.append("### 热门\n").append(mostViewBuilder).append("\n\n");
+ }
+
+ final StringBuilder mostCmtBuilder = new StringBuilder();
+ final List mostCmtArticles = articleRepository.getList(new Query().setFilter(published).select(Keys.OBJECT_ID, Article.ARTICLE_TITLE, Article.ARTICLE_PERMALINK).addSort(Article.ARTICLE_COMMENT_COUNT, SortDirection.DESCENDING).setPage(1, 60));
+ count = 0;
+ for (final JSONObject article : mostCmtArticles) {
+ final String articleId = article.optString(Keys.OBJECT_ID);
+ if (!articleIds.contains(articleId)) {
+ final String title = article.optString(Article.ARTICLE_TITLE);
+ final String link = Latkes.getServePath() + article.optString(Article.ARTICLE_PERMALINK);
+ mostCmtBuilder.append("\n* [").append(title).append("](").append(link).append(")");
+ articleIds.add(articleId);
+ count++;
+ }
+ if (20 <= count) {
+ break;
+ }
+ }
+ if (0 < mostCmtBuilder.length()) {
+ bodyBuilder.append("### 热议\n").append(mostCmtBuilder);
+ }
+
+ final HttpResponse response = HttpRequest.post("https://hacpai.com/github/repos").
+ connectionTimeout(7000).timeout(60000).trustAllCerts(true).header("User-Agent", Solos.USER_AGENT).
+ form("userName", userName,
+ "userB3Key", userB3Key,
+ "clientName", "Solo",
+ "clientVersion", SoloServletListener.VERSION,
+ "clientHost", Latkes.getServePath(),
+ "clientFavicon", preference.optString(Option.ID_C_FAVICON_URL),
+ "clientTitle", clientTitle,
+ "clientSubtitle", clientSubtitle,
+ "clientBody", bodyBuilder.toString(),
+ "file", zipData).send();
+ response.close();
+ response.charset("UTF-8");
+ LOGGER.info("Github repo sync completed: " + response.bodyText());
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Exports articles to github repo failed", e);
+ } finally {
+ LOGGER.log(Level.INFO, "Github repo synced");
+ }
+ }
+
+ /**
+ * Exports the specified articles to the specified dir path.
+ *
+ * @param articles the specified articles
+ * @param dirPath the specified dir path
+ */
+ public void exportHexoMd(final List articles, final String dirPath) {
+ articles.forEach(article -> {
+ final String filename = Solos.sanitizeFilename(article.optString("title")) + ".md";
+ final String text = article.optString("front") + "---" + Strings.LINE_SEPARATOR + article.optString("content");
+
+ try {
+ final String date = DateFormatUtils.format(article.optLong("created"), "yyyyMM");
+ final String dir = dirPath + File.separator + date + File.separator;
+ new File(dir).mkdirs();
+ FileUtils.writeStringToFile(new File(dir + filename), text, "UTF-8");
+ } catch (final Exception e) {
+ LOGGER.log(Level.ERROR, "Write markdown file failed", e);
+ }
+ });
+ }
+
+ /**
+ * Exports as Hexo markdown format.
+ *
+ * @return posts, password posts and drafts,
+ * {
+ * "posts": [
+ * {
+ * "front": "", // yaml front matter,
+ * "title": "",
+ * "content": "",
+ * "created": long
+ * }, ....
+ * ],
+ * "passwords": [], // format is same as post
+ * "drafts": [] // format is same as post
+ * }
+ *
+ */
+ public JSONObject exportHexoMDs() {
+ final JSONObject ret = new JSONObject();
+ final List posts = new ArrayList<>();
+ ret.put("posts", (Object) posts);
+ final List passwords = new ArrayList<>();
+ ret.put("passwords", (Object) passwords);
+ final List