mraible commented on code in PR #245:
URL: https://github.com/apache/roller/pull/245#discussion_r4559393877
##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,144 @@
package org.apache.roller.weblogger.ui.struts2.editor;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
+
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.roller.weblogger.WebloggerException;
import org.apache.roller.weblogger.business.WebloggerFactory;
import org.apache.roller.weblogger.business.WeblogEntryManager;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
import org.apache.roller.weblogger.pojos.WeblogCategory;
import org.apache.roller.weblogger.pojos.WeblogPermission;
import org.apache.roller.weblogger.ui.struts2.util.UIAction;
-import org.apache.struts2.convention.annotation.AllowedMethods;
+import org.apache.roller.weblogger.util.cache.CacheManager;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
/**
- * Manage weblog categories.
+ * Manage weblog categories: list them with their entry counts, and change
+ * their order. Adding, editing and removing are done by CategoryEdit and
+ * CategoryRemove.
*/
-// TODO: make this work @AllowedMethods({"execute","move"})
public class Categories extends UIAction {
- private static Log log = LogFactory.getLog(Categories.class);
+ private static final Log log = LogFactory.getLog(Categories.class);
+
+ // all categories from the action weblog, in display order
+ private List<WeblogCategory> allCategories = Collections.emptyList();
- // all categories from the action weblog
- private List<WeblogCategory> allCategories;
+ // number of entries in each category, by category id
+ private Map<String, Long> entryCounts = Collections.emptyMap();
- public Categories() {
- this.actionName = "categories";
- this.desiredMenu = "editor";
- this.pageTitle = "categoriesForm.rootTitle";
- }
+ // category ids in their new order, posted by reorder
+ private List<String> categoryIds = new ArrayList<>();
- @Override
- public List<String> requiredWeblogPermissionActions() {
- return Collections.singletonList(WeblogPermission.POST);
- }
+ public Categories() {
+ this.actionName = "categories";
+ this.desiredMenu = "editor";
+ this.pageTitle = "categoriesForm.rootTitle";
+ }
- @Override
- public String execute() {
- try {
+ @Override
+ public List<String> requiredWeblogPermissionActions() {
+ return Collections.singletonList(WeblogPermission.POST);
+ }
+
+ @Override
+ public String execute() {
+ try {
WeblogEntryManager wmgr =
WebloggerFactory.getWeblogger().getWeblogEntryManager();
- allCategories =
wmgr.getWeblogCategories(getActionWeblog());
- } catch (WebloggerException ex) {
- log.error("Error building categories list", ex);
- addError("Error building categories list");
- }
-
- return LIST;
- }
-
- public String move() {
- return execute();
- }
-
- public List<WeblogCategory> getAllCategories() {
- return allCategories;
- }
-
- public void setAllCategories(List<WeblogCategory> allCategories) {
- this.allCategories = allCategories;
- }
+ allCategories = wmgr.getWeblogCategories(getActionWeblog());
+ entryCounts = wmgr.getWeblogCategoryEntryCounts(getActionWeblog());
+ } catch (WebloggerException ex) {
+ log.error("Error building categories list", ex);
+ addError("generic.error.check.logs");
+ }
+ return LIST;
+ }
+
+ /**
+ * Save a new display order. The posted ids must name every category of
+ * the weblog exactly once.
+ */
+ public String reorder() {
+ try {
+ WeblogEntryManager wmgr =
WebloggerFactory.getWeblogger().getWeblogEntryManager();
+ List<WeblogCategory> current =
wmgr.getWeblogCategories(getActionWeblog());
+
+ Map<String, WeblogCategory> byId = new HashMap<>();
+ for (WeblogCategory category : current) {
+ byId.put(category.getId(), category);
+ }
+ if (categoryIds.size() != current.size()
+ || !byId.keySet().equals(new HashSet<>(categoryIds))) {
+ addError("categoriesForm.error.reorder");
+ return execute();
+ }
+
+ for (int position = 0; position < categoryIds.size(); position++) {
+ WeblogCategory category = byId.get(categoryIds.get(position));
+ if (category.getPosition() != position) {
+ category.setPosition(position);
+ wmgr.saveWeblogCategory(category);
Review Comment:
Minor: `saveWeblogCategory` does a lookup by id and a `saveWeblog()` each
time, so moving the last category to the top does that once per category. The
categories are already managed, so setting the positions and saving the weblog
once would do the same.
##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+ "use strict";
+
+ var page = document.getElementById("categoriesPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.categories.flash";
+ var list = document.getElementById("categoryList");
+ var editModal = new
bootstrap.Modal(document.getElementById("category-edit-modal"));
+ var deleteModal = new
bootstrap.Modal(document.getElementById("delete-category-modal"));
+ var editForm = document.getElementById("categoryEditForm");
+ var removeForm = document.getElementById("categoryRemoveForm");
+ var nameField = document.getElementById("categoryEditForm_bean_name");
+ var descField =
document.getElementById("categoryEditForm_bean_description");
+ var imageField = document.getElementById("categoryEditForm_bean_image");
+ var imagePreview = document.getElementById("categoryImagePreview");
+ var saveButton = document.getElementById("categorySaveButton");
+ var deleteButton = document.getElementById("categoryDeleteButton");
+ var targetSelect =
document.getElementById("categoryRemove_targetCategoryId");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write a parameter as {0}
+ function format(message, value) {
+ // a function replacement, so a "$&" in the name is not expanded
+ return message.replace("{0}", function () {
+ return value;
+ });
+ }
+
+ function rowOf(element) {
+ return element.closest(".category-row");
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = document.getElementById("categoryToast");
+ toast.querySelector(".toast-body").textContent = message;
+ toast.classList.toggle("text-bg-success", !isError);
+ toast.classList.toggle("text-bg-danger", !!isError);
+ bootstrap.Toast.getOrCreateInstance(toast).show();
+ }
+
+ function reloadWith(message, categoryName) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, name: categoryName }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.reload();
+ }
+
+ function showFlash() {
+ var flash = null;
+ try {
+ flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+ sessionStorage.removeItem(FLASH_KEY);
+ } catch (e) {
+ return;
+ }
+ if (!flash) {
+ return;
+ }
+ showToast(flash.message);
+ list.querySelectorAll(".category-row").forEach(function (row) {
+ if (flash.name && row.dataset.categoryName === flash.name) {
+ row.classList.add("category-row-changed");
+ row.scrollIntoView({ block: "nearest" });
+ }
+ });
+ }
+
+ // --- posting -----------------------------------------------------------
+
+ function post(form, extra) {
+ var data = $(form).serialize() + (extra || "");
+ return $.ajax({ method: "post", url: form.action, data: data
}).done(function (html) {
+ refreshSalt(html);
+ }).fail(function (xhr) {
+ // the salt just sent is spent whether or not the post worked
+ if (xhr && xhr.responseText) {
+ refreshSalt(xhr.responseText);
+ }
+ });
+ }
+
+ function showErrors(box, errors) {
+ box.textContent = errors.join(" ");
+ box.classList.toggle("d-none", errors.length === 0);
+ }
+
+ // --- add and edit
--------------------------------------------------------
+
+ function setInvalid(field, feedbackId, message) {
+ field.classList.toggle("is-invalid", !!message);
+ document.getElementById(feedbackId).textContent = message || "";
+ }
+
+ function imageUrl() {
+ var value = imageField.value.trim();
+ if (!value) {
+ return "";
+ }
+ try {
+ var url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:" ?
url.href : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function validate() {
+ var nameOk = nameField.value.trim() !== "";
+ var image = imageUrl();
+ setInvalid(nameField, "categoryNameFeedback", nameOk ? "" :
text("msgNameRequired"));
+ setInvalid(imageField, "categoryImageFeedback", image === null ?
text("msgBadUrl") : "");
+ imagePreview.classList.toggle("d-none", !image);
+ if (image && imagePreview.getAttribute("src") !== image) {
+ imagePreview.setAttribute("src", image);
+ }
+ saveButton.disabled = !nameOk || image === null;
+ return !saveButton.disabled;
+ }
+
+ function openEditor(row) {
+ var adding = !row;
+ document.getElementById("category-edit-title").textContent = adding ?
text("titleAdd") : text("titleEdit");
+ document.getElementById("categoryEditForm_bean_id").value = adding ?
"" : row.dataset.categoryId;
+ nameField.value = adding ? "" : row.dataset.categoryName;
+ descField.value = adding ? "" : row.dataset.categoryDesc;
+ imageField.value = adding ? "" : row.dataset.categoryImage;
+ showErrors(document.getElementById("categoryEditErrors"), []);
+ setInvalid(nameField, "categoryNameFeedback", "");
+ setInvalid(imageField, "categoryImageFeedback", "");
+ imagePreview.classList.add("d-none");
+ imagePreview.removeAttribute("src");
+ saveButton.disabled = adding;
+ showChooser(false);
+ if (!adding) {
+ validate();
+ }
+ editModal.show();
+ }
+
+ function saveCategory() {
+ if (!validate()) {
+ return;
+ }
+ var adding = document.getElementById("categoryEditForm_bean_id").value
=== "";
+ var name = nameField.value.trim();
+ saveButton.disabled = true;
+ post(editForm).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showErrors(document.getElementById("categoryEditErrors"),
errors);
+ saveButton.disabled = false;
+ } else {
+ reloadWith(format(adding ? text("msgAdded") :
text("msgSaved"), name), name);
+ }
+ }).fail(function () {
+ showErrors(document.getElementById("categoryEditErrors"),
[text("msgError")]);
+ saveButton.disabled = false;
+ });
+ }
+
+ nameField.addEventListener("input", validate);
+ imageField.addEventListener("input", validate);
+ imagePreview.addEventListener("error", function () {
+ imagePreview.classList.add("d-none");
+ });
+ saveButton.addEventListener("click", saveCategory);
+ // Enter in a field saves; the form has no submit button and is never
posted directly
+ editForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ });
+ editForm.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" && event.target.matches("input")) {
Review Comment:
`saveCategory()` starts with `validate()`, which sets `saveButton.disabled`
again from the field values, so the disabled button doesn't stop a second Enter
while the first post is in flight. The second post carries the spent salt and
its error can flash before the reload. A separate in-flight flag would fix it.
Checking `event.isComposing` here would also keep an IME confirmation from
saving a half-typed name.
##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+ "use strict";
+
+ var page = document.getElementById("categoriesPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.categories.flash";
+ var list = document.getElementById("categoryList");
+ var editModal = new
bootstrap.Modal(document.getElementById("category-edit-modal"));
+ var deleteModal = new
bootstrap.Modal(document.getElementById("delete-category-modal"));
+ var editForm = document.getElementById("categoryEditForm");
+ var removeForm = document.getElementById("categoryRemoveForm");
+ var nameField = document.getElementById("categoryEditForm_bean_name");
+ var descField =
document.getElementById("categoryEditForm_bean_description");
+ var imageField = document.getElementById("categoryEditForm_bean_image");
+ var imagePreview = document.getElementById("categoryImagePreview");
+ var saveButton = document.getElementById("categorySaveButton");
+ var deleteButton = document.getElementById("categoryDeleteButton");
+ var targetSelect =
document.getElementById("categoryRemove_targetCategoryId");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write a parameter as {0}
+ function format(message, value) {
+ // a function replacement, so a "$&" in the name is not expanded
+ return message.replace("{0}", function () {
+ return value;
+ });
+ }
+
+ function rowOf(element) {
+ return element.closest(".category-row");
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = document.getElementById("categoryToast");
+ toast.querySelector(".toast-body").textContent = message;
+ toast.classList.toggle("text-bg-success", !isError);
+ toast.classList.toggle("text-bg-danger", !!isError);
+ bootstrap.Toast.getOrCreateInstance(toast).show();
+ }
+
+ function reloadWith(message, categoryName) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, name: categoryName }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.reload();
+ }
+
+ function showFlash() {
+ var flash = null;
+ try {
+ flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+ sessionStorage.removeItem(FLASH_KEY);
+ } catch (e) {
+ return;
+ }
+ if (!flash) {
+ return;
+ }
+ showToast(flash.message);
+ list.querySelectorAll(".category-row").forEach(function (row) {
+ if (flash.name && row.dataset.categoryName === flash.name) {
+ row.classList.add("category-row-changed");
+ row.scrollIntoView({ block: "nearest" });
+ }
+ });
+ }
+
+ // --- posting -----------------------------------------------------------
+
+ function post(form, extra) {
+ var data = $(form).serialize() + (extra || "");
+ return $.ajax({ method: "post", url: form.action, data: data
}).done(function (html) {
+ refreshSalt(html);
+ }).fail(function (xhr) {
+ // the salt just sent is spent whether or not the post worked
+ if (xhr && xhr.responseText) {
+ refreshSalt(xhr.responseText);
+ }
+ });
+ }
+
+ function showErrors(box, errors) {
+ box.textContent = errors.join(" ");
+ box.classList.toggle("d-none", errors.length === 0);
+ }
+
+ // --- add and edit
--------------------------------------------------------
+
+ function setInvalid(field, feedbackId, message) {
+ field.classList.toggle("is-invalid", !!message);
+ document.getElementById(feedbackId).textContent = message || "";
+ }
+
+ function imageUrl() {
+ var value = imageField.value.trim();
+ if (!value) {
+ return "";
+ }
+ try {
+ var url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:" ?
url.href : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function validate() {
+ var nameOk = nameField.value.trim() !== "";
+ var image = imageUrl();
+ setInvalid(nameField, "categoryNameFeedback", nameOk ? "" :
text("msgNameRequired"));
+ setInvalid(imageField, "categoryImageFeedback", image === null ?
text("msgBadUrl") : "");
+ imagePreview.classList.toggle("d-none", !image);
+ if (image && imagePreview.getAttribute("src") !== image) {
+ imagePreview.setAttribute("src", image);
+ }
+ saveButton.disabled = !nameOk || image === null;
+ return !saveButton.disabled;
+ }
+
+ function openEditor(row) {
+ var adding = !row;
+ document.getElementById("category-edit-title").textContent = adding ?
text("titleAdd") : text("titleEdit");
+ document.getElementById("categoryEditForm_bean_id").value = adding ?
"" : row.dataset.categoryId;
+ nameField.value = adding ? "" : row.dataset.categoryName;
+ descField.value = adding ? "" : row.dataset.categoryDesc;
+ imageField.value = adding ? "" : row.dataset.categoryImage;
+ showErrors(document.getElementById("categoryEditErrors"), []);
+ setInvalid(nameField, "categoryNameFeedback", "");
+ setInvalid(imageField, "categoryImageFeedback", "");
+ imagePreview.classList.add("d-none");
+ imagePreview.removeAttribute("src");
+ saveButton.disabled = adding;
+ showChooser(false);
+ if (!adding) {
+ validate();
+ }
+ editModal.show();
+ }
+
+ function saveCategory() {
+ if (!validate()) {
+ return;
+ }
+ var adding = document.getElementById("categoryEditForm_bean_id").value
=== "";
+ var name = nameField.value.trim();
+ saveButton.disabled = true;
+ post(editForm).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showErrors(document.getElementById("categoryEditErrors"),
errors);
+ saveButton.disabled = false;
+ } else {
+ reloadWith(format(adding ? text("msgAdded") :
text("msgSaved"), name), name);
+ }
+ }).fail(function () {
+ showErrors(document.getElementById("categoryEditErrors"),
[text("msgError")]);
+ saveButton.disabled = false;
+ });
+ }
+
+ nameField.addEventListener("input", validate);
+ imageField.addEventListener("input", validate);
+ imagePreview.addEventListener("error", function () {
+ imagePreview.classList.add("d-none");
+ });
+ saveButton.addEventListener("click", saveCategory);
+ // Enter in a field saves; the form has no submit button and is never
posted directly
+ editForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ });
+ editForm.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" && event.target.matches("input")) {
+ event.preventDefault();
+ saveCategory();
+ }
+ });
+ document.getElementById("addCategoryButton").addEventListener("click",
function () {
+ openEditor(null);
+ });
+
document.getElementById("category-edit-modal").addEventListener("shown.bs.modal",
function () {
+ nameField.focus();
+ });
+
+ // --- choosing an image from the media library ---------------------------
+ // Only when uploads are on; otherwise the page has no chooser and these
are null.
+
+ var chooser = document.getElementById("categoryImageChooser");
+ var chooserFrame = document.getElementById("categoryImageChooserFrame");
+ var editTitle = document.getElementById("category-edit-title");
+ var formTitle = "";
+
+ // the dialog shows either the form or the chooser, which needs a wider
dialog
+ function showChooser(on) {
+ if (!chooser) {
+ return;
+ }
+ var showing = !chooser.classList.contains("d-none");
+ if (on === showing) {
+ return;
+ }
+ if (on) {
+ formTitle = editTitle.textContent;
+ editTitle.textContent = chooser.dataset.title;
+ chooserFrame.setAttribute("src", chooser.dataset.src);
+ } else {
+ editTitle.textContent = formTitle || editTitle.textContent;
+ chooserFrame.setAttribute("src", "about:blank");
+ }
+ chooser.classList.toggle("d-none", !on);
+ editForm.classList.toggle("d-none", on);
+ document.querySelector("#category-edit-modal
.modal-dialog").classList.toggle("modal-lg", on);
+
document.getElementById("categoryChooserBackButton").classList.toggle("d-none",
!on);
+
document.getElementById("categoryCancelButton").classList.toggle("d-none", on);
+ saveButton.classList.toggle("d-none", on);
+ }
+
+ if (chooser) {
+
document.getElementById("categoryImageChooseButton").addEventListener("click",
function () {
+ showChooser(true);
+ });
+
document.getElementById("categoryChooserBackButton").addEventListener("click",
function () {
+ showChooser(false);
+ document.getElementById("categoryImageChooseButton").focus();
+ });
+
document.getElementById("category-edit-modal").addEventListener("hidden.bs.modal",
function () {
+ showChooser(false);
+ });
+
+ // called by the chooser in the frame (MediaFileImageChooser.jsp); it
lists only images
+ window.onSelectMediaFile = function (name, url, isImage) {
+ if (isImage !== "true") {
+ return;
+ }
+ imageField.value = url;
+ showChooser(false);
+ validate();
+ imageField.focus();
+ };
+ }
+
+ // --- delete
----------------------------------------------------------------
+
+ function openDelete(row) {
+ var id = row.dataset.categoryId;
+ var inUse = row.dataset.inUse === "true";
+ document.getElementById("category-delete-title").textContent =
format(text("titleDelete"), row.dataset.categoryName);
+ document.getElementById("categoryRemove_removeId").value = id;
+ document.getElementById("categoryDeleteText").textContent =
row.dataset.deleteText;
+ showErrors(document.getElementById("categoryDeleteErrors"), []);
+
+ // the other categories, in display order, after the "choose" option
+ while (targetSelect.options.length > 1) {
+ targetSelect.remove(1);
+ }
+ list.querySelectorAll(".category-row").forEach(function (other) {
+ if (other.dataset.categoryId !== id) {
+ targetSelect.add(new Option(other.dataset.categoryName,
other.dataset.categoryId));
+ }
+ });
+ targetSelect.value = "";
+
+ // a category nothing uses needs no target; a disabled select is not
posted
+
document.getElementById("categoryMoveTarget").classList.toggle("d-none",
!inUse);
+ targetSelect.disabled = !inUse;
+ deleteButton.disabled = inUse;
+ deleteButton.dataset.categoryName = row.dataset.categoryName;
+ deleteModal.show();
+ }
+
+ targetSelect.addEventListener("change", function () {
+ deleteButton.disabled = targetSelect.value === "";
+ });
+
+ deleteButton.addEventListener("click", function () {
+ var name = deleteButton.dataset.categoryName;
+ deleteButton.disabled = true;
+ post(removeForm).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showErrors(document.getElementById("categoryDeleteErrors"),
errors);
+ deleteButton.disabled = false;
+ } else {
+ reloadWith(format(text("msgRemoved"), name), null);
+ }
+ }).fail(function () {
+ showErrors(document.getElementById("categoryDeleteErrors"),
[text("msgError")]);
+ deleteButton.disabled = false;
+ });
+ });
+
+ // --- row actions
-----------------------------------------------------------
+
+ list.addEventListener("click", function (event) {
+ var edit = event.target.closest(".category-edit-link");
+ var remove = event.target.closest(".category-delete-link");
+ if (edit) {
+ event.preventDefault();
+ openEditor(rowOf(edit));
+ } else if (remove) {
+ openDelete(rowOf(remove));
+ }
+ });
+
+ // --- reordering
------------------------------------------------------------
+
+ var saveTimer = null;
+ var saving = false;
+ var saveAgain = false;
+
+ // Each post spends the page's salt and returns a new one, so only one save
+ // can be in flight. A change made meanwhile is sent afterwards, from the
+ // list as it is then.
+ function saveOrder() {
Review Comment:
Follow-up to the salt thread: `saving` keeps reorder posts apart from each
other, but the edit and delete forms use the same one-time salt. A Save or
Delete sent while an order save is in flight, or just as the 600ms timer fires,
goes out with a spent salt and one of the two fails with the security error.
Putting the in-flight guard in `post()` would cover all three forms (and the
blogroll page has the same shape).
##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+ "use strict";
+
+ var page = document.getElementById("categoriesPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.categories.flash";
+ var list = document.getElementById("categoryList");
+ var editModal = new
bootstrap.Modal(document.getElementById("category-edit-modal"));
+ var deleteModal = new
bootstrap.Modal(document.getElementById("delete-category-modal"));
+ var editForm = document.getElementById("categoryEditForm");
+ var removeForm = document.getElementById("categoryRemoveForm");
+ var nameField = document.getElementById("categoryEditForm_bean_name");
+ var descField =
document.getElementById("categoryEditForm_bean_description");
+ var imageField = document.getElementById("categoryEditForm_bean_image");
+ var imagePreview = document.getElementById("categoryImagePreview");
+ var saveButton = document.getElementById("categorySaveButton");
+ var deleteButton = document.getElementById("categoryDeleteButton");
+ var targetSelect =
document.getElementById("categoryRemove_targetCategoryId");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write a parameter as {0}
+ function format(message, value) {
+ // a function replacement, so a "$&" in the name is not expanded
+ return message.replace("{0}", function () {
+ return value;
+ });
+ }
+
+ function rowOf(element) {
+ return element.closest(".category-row");
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = document.getElementById("categoryToast");
+ toast.querySelector(".toast-body").textContent = message;
+ toast.classList.toggle("text-bg-success", !isError);
+ toast.classList.toggle("text-bg-danger", !!isError);
+ bootstrap.Toast.getOrCreateInstance(toast).show();
+ }
+
+ function reloadWith(message, categoryName) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, name: categoryName }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.reload();
+ }
+
+ function showFlash() {
+ var flash = null;
+ try {
+ flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+ sessionStorage.removeItem(FLASH_KEY);
+ } catch (e) {
+ return;
+ }
+ if (!flash) {
+ return;
+ }
+ showToast(flash.message);
+ list.querySelectorAll(".category-row").forEach(function (row) {
+ if (flash.name && row.dataset.categoryName === flash.name) {
+ row.classList.add("category-row-changed");
+ row.scrollIntoView({ block: "nearest" });
+ }
+ });
+ }
+
+ // --- posting -----------------------------------------------------------
+
+ function post(form, extra) {
+ var data = $(form).serialize() + (extra || "");
+ return $.ajax({ method: "post", url: form.action, data: data
}).done(function (html) {
+ refreshSalt(html);
+ }).fail(function (xhr) {
+ // the salt just sent is spent whether or not the post worked
+ if (xhr && xhr.responseText) {
+ refreshSalt(xhr.responseText);
+ }
+ });
+ }
+
+ function showErrors(box, errors) {
+ box.textContent = errors.join(" ");
+ box.classList.toggle("d-none", errors.length === 0);
+ }
+
+ // --- add and edit
--------------------------------------------------------
+
+ function setInvalid(field, feedbackId, message) {
+ field.classList.toggle("is-invalid", !!message);
+ document.getElementById(feedbackId).textContent = message || "";
+ }
+
+ function imageUrl() {
+ var value = imageField.value.trim();
+ if (!value) {
+ return "";
+ }
+ try {
+ var url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:" ?
url.href : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function validate() {
+ var nameOk = nameField.value.trim() !== "";
+ var image = imageUrl();
+ setInvalid(nameField, "categoryNameFeedback", nameOk ? "" :
text("msgNameRequired"));
+ setInvalid(imageField, "categoryImageFeedback", image === null ?
text("msgBadUrl") : "");
+ imagePreview.classList.toggle("d-none", !image);
Review Comment:
Minor: after an image fails to load, the `error` handler hides the preview,
but the next keystroke in either field runs `validate()`, which shows it again
because the URL still parses. The `src` hasn't changed, so no new `error` event
fires and the broken image stays visible.
##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+ "use strict";
+
+ var page = document.getElementById("categoriesPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.categories.flash";
+ var list = document.getElementById("categoryList");
+ var editModal = new
bootstrap.Modal(document.getElementById("category-edit-modal"));
+ var deleteModal = new
bootstrap.Modal(document.getElementById("delete-category-modal"));
+ var editForm = document.getElementById("categoryEditForm");
+ var removeForm = document.getElementById("categoryRemoveForm");
+ var nameField = document.getElementById("categoryEditForm_bean_name");
+ var descField =
document.getElementById("categoryEditForm_bean_description");
+ var imageField = document.getElementById("categoryEditForm_bean_image");
+ var imagePreview = document.getElementById("categoryImagePreview");
+ var saveButton = document.getElementById("categorySaveButton");
+ var deleteButton = document.getElementById("categoryDeleteButton");
+ var targetSelect =
document.getElementById("categoryRemove_targetCategoryId");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write a parameter as {0}
+ function format(message, value) {
+ // a function replacement, so a "$&" in the name is not expanded
+ return message.replace("{0}", function () {
+ return value;
+ });
+ }
+
+ function rowOf(element) {
+ return element.closest(".category-row");
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = document.getElementById("categoryToast");
+ toast.querySelector(".toast-body").textContent = message;
+ toast.classList.toggle("text-bg-success", !isError);
+ toast.classList.toggle("text-bg-danger", !!isError);
+ bootstrap.Toast.getOrCreateInstance(toast).show();
+ }
+
+ function reloadWith(message, categoryName) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, name: categoryName }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.reload();
+ }
+
+ function showFlash() {
+ var flash = null;
+ try {
+ flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+ sessionStorage.removeItem(FLASH_KEY);
+ } catch (e) {
+ return;
+ }
+ if (!flash) {
+ return;
+ }
+ showToast(flash.message);
+ list.querySelectorAll(".category-row").forEach(function (row) {
+ if (flash.name && row.dataset.categoryName === flash.name) {
+ row.classList.add("category-row-changed");
+ row.scrollIntoView({ block: "nearest" });
+ }
+ });
+ }
+
+ // --- posting -----------------------------------------------------------
+
+ function post(form, extra) {
+ var data = $(form).serialize() + (extra || "");
+ return $.ajax({ method: "post", url: form.action, data: data
}).done(function (html) {
+ refreshSalt(html);
+ }).fail(function (xhr) {
+ // the salt just sent is spent whether or not the post worked
+ if (xhr && xhr.responseText) {
+ refreshSalt(xhr.responseText);
+ }
+ });
+ }
+
+ function showErrors(box, errors) {
+ box.textContent = errors.join(" ");
+ box.classList.toggle("d-none", errors.length === 0);
+ }
+
+ // --- add and edit
--------------------------------------------------------
+
+ function setInvalid(field, feedbackId, message) {
+ field.classList.toggle("is-invalid", !!message);
+ document.getElementById(feedbackId).textContent = message || "";
+ }
+
+ function imageUrl() {
+ var value = imageField.value.trim();
+ if (!value) {
+ return "";
+ }
+ try {
+ var url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:" ?
url.href : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function validate() {
+ var nameOk = nameField.value.trim() !== "";
+ var image = imageUrl();
+ setInvalid(nameField, "categoryNameFeedback", nameOk ? "" :
text("msgNameRequired"));
+ setInvalid(imageField, "categoryImageFeedback", image === null ?
text("msgBadUrl") : "");
+ imagePreview.classList.toggle("d-none", !image);
+ if (image && imagePreview.getAttribute("src") !== image) {
+ imagePreview.setAttribute("src", image);
+ }
+ saveButton.disabled = !nameOk || image === null;
+ return !saveButton.disabled;
+ }
+
+ function openEditor(row) {
+ var adding = !row;
+ document.getElementById("category-edit-title").textContent = adding ?
text("titleAdd") : text("titleEdit");
+ document.getElementById("categoryEditForm_bean_id").value = adding ?
"" : row.dataset.categoryId;
+ nameField.value = adding ? "" : row.dataset.categoryName;
+ descField.value = adding ? "" : row.dataset.categoryDesc;
+ imageField.value = adding ? "" : row.dataset.categoryImage;
+ showErrors(document.getElementById("categoryEditErrors"), []);
+ setInvalid(nameField, "categoryNameFeedback", "");
+ setInvalid(imageField, "categoryImageFeedback", "");
+ imagePreview.classList.add("d-none");
+ imagePreview.removeAttribute("src");
+ saveButton.disabled = adding;
+ showChooser(false);
+ if (!adding) {
+ validate();
+ }
+ editModal.show();
+ }
+
+ function saveCategory() {
+ if (!validate()) {
+ return;
+ }
+ var adding = document.getElementById("categoryEditForm_bean_id").value
=== "";
+ var name = nameField.value.trim();
+ saveButton.disabled = true;
+ post(editForm).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showErrors(document.getElementById("categoryEditErrors"),
errors);
+ saveButton.disabled = false;
+ } else {
+ reloadWith(format(adding ? text("msgAdded") :
text("msgSaved"), name), name);
+ }
+ }).fail(function () {
+ showErrors(document.getElementById("categoryEditErrors"),
[text("msgError")]);
+ saveButton.disabled = false;
+ });
+ }
+
+ nameField.addEventListener("input", validate);
+ imageField.addEventListener("input", validate);
+ imagePreview.addEventListener("error", function () {
+ imagePreview.classList.add("d-none");
+ });
+ saveButton.addEventListener("click", saveCategory);
+ // Enter in a field saves; the form has no submit button and is never
posted directly
+ editForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ });
+ editForm.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" && event.target.matches("input")) {
+ event.preventDefault();
+ saveCategory();
+ }
+ });
+ document.getElementById("addCategoryButton").addEventListener("click",
function () {
+ openEditor(null);
+ });
+
document.getElementById("category-edit-modal").addEventListener("shown.bs.modal",
function () {
+ nameField.focus();
+ });
+
+ // --- choosing an image from the media library ---------------------------
+ // Only when uploads are on; otherwise the page has no chooser and these
are null.
+
+ var chooser = document.getElementById("categoryImageChooser");
+ var chooserFrame = document.getElementById("categoryImageChooserFrame");
+ var editTitle = document.getElementById("category-edit-title");
+ var formTitle = "";
+
+ // the dialog shows either the form or the chooser, which needs a wider
dialog
+ function showChooser(on) {
+ if (!chooser) {
+ return;
+ }
+ var showing = !chooser.classList.contains("d-none");
+ if (on === showing) {
+ return;
+ }
+ if (on) {
+ formTitle = editTitle.textContent;
+ editTitle.textContent = chooser.dataset.title;
+ chooserFrame.setAttribute("src", chooser.dataset.src);
+ } else {
+ editTitle.textContent = formTitle || editTitle.textContent;
+ chooserFrame.setAttribute("src", "about:blank");
+ }
+ chooser.classList.toggle("d-none", !on);
+ editForm.classList.toggle("d-none", on);
+ document.querySelector("#category-edit-modal
.modal-dialog").classList.toggle("modal-lg", on);
+
document.getElementById("categoryChooserBackButton").classList.toggle("d-none",
!on);
+
document.getElementById("categoryCancelButton").classList.toggle("d-none", on);
+ saveButton.classList.toggle("d-none", on);
+ }
+
+ if (chooser) {
+
document.getElementById("categoryImageChooseButton").addEventListener("click",
function () {
+ showChooser(true);
+ });
+
document.getElementById("categoryChooserBackButton").addEventListener("click",
function () {
+ showChooser(false);
+ document.getElementById("categoryImageChooseButton").focus();
+ });
+
document.getElementById("category-edit-modal").addEventListener("hidden.bs.modal",
function () {
+ showChooser(false);
+ });
+
+ // called by the chooser in the frame (MediaFileImageChooser.jsp); it
lists only images
+ window.onSelectMediaFile = function (name, url, isImage) {
+ if (isImage !== "true") {
+ return;
+ }
+ imageField.value = url;
+ showChooser(false);
+ validate();
+ imageField.focus();
+ };
+ }
+
+ // --- delete
----------------------------------------------------------------
+
+ function openDelete(row) {
+ var id = row.dataset.categoryId;
+ var inUse = row.dataset.inUse === "true";
+ document.getElementById("category-delete-title").textContent =
format(text("titleDelete"), row.dataset.categoryName);
+ document.getElementById("categoryRemove_removeId").value = id;
+ document.getElementById("categoryDeleteText").textContent =
row.dataset.deleteText;
+ showErrors(document.getElementById("categoryDeleteErrors"), []);
+
+ // the other categories, in display order, after the "choose" option
+ while (targetSelect.options.length > 1) {
+ targetSelect.remove(1);
+ }
+ list.querySelectorAll(".category-row").forEach(function (other) {
+ if (other.dataset.categoryId !== id) {
+ targetSelect.add(new Option(other.dataset.categoryName,
other.dataset.categoryId));
+ }
+ });
+ targetSelect.value = "";
+
+ // a category nothing uses needs no target; a disabled select is not
posted
+
document.getElementById("categoryMoveTarget").classList.toggle("d-none",
!inUse);
+ targetSelect.disabled = !inUse;
Review Comment:
Whether a target is sent depends on `data-in-use` from when the page loaded.
If an entry lands in the category afterwards (another tab, a blog client), the
select is disabled, no target is posted, `removeWeblogCategory` throws "Cannot
remove category with entries", and the dialog shows only the generic error with
no way to choose a target short of reloading. The old form always posted a
target. Could the server return a specific error for this case, or the dialog
always offer the target select?
##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,144 @@
package org.apache.roller.weblogger.ui.struts2.editor;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
+
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.roller.weblogger.WebloggerException;
import org.apache.roller.weblogger.business.WebloggerFactory;
import org.apache.roller.weblogger.business.WeblogEntryManager;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
import org.apache.roller.weblogger.pojos.WeblogCategory;
import org.apache.roller.weblogger.pojos.WeblogPermission;
import org.apache.roller.weblogger.ui.struts2.util.UIAction;
-import org.apache.struts2.convention.annotation.AllowedMethods;
+import org.apache.roller.weblogger.util.cache.CacheManager;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
/**
- * Manage weblog categories.
+ * Manage weblog categories: list them with their entry counts, and change
+ * their order. Adding, editing and removing are done by CategoryEdit and
+ * CategoryRemove.
*/
-// TODO: make this work @AllowedMethods({"execute","move"})
public class Categories extends UIAction {
- private static Log log = LogFactory.getLog(Categories.class);
+ private static final Log log = LogFactory.getLog(Categories.class);
+
+ // all categories from the action weblog, in display order
+ private List<WeblogCategory> allCategories = Collections.emptyList();
- // all categories from the action weblog
- private List<WeblogCategory> allCategories;
+ // number of entries in each category, by category id
+ private Map<String, Long> entryCounts = Collections.emptyMap();
- public Categories() {
- this.actionName = "categories";
- this.desiredMenu = "editor";
- this.pageTitle = "categoriesForm.rootTitle";
- }
+ // category ids in their new order, posted by reorder
+ private List<String> categoryIds = new ArrayList<>();
- @Override
- public List<String> requiredWeblogPermissionActions() {
- return Collections.singletonList(WeblogPermission.POST);
- }
+ public Categories() {
+ this.actionName = "categories";
+ this.desiredMenu = "editor";
+ this.pageTitle = "categoriesForm.rootTitle";
+ }
- @Override
- public String execute() {
- try {
+ @Override
+ public List<String> requiredWeblogPermissionActions() {
+ return Collections.singletonList(WeblogPermission.POST);
+ }
+
+ @Override
+ public String execute() {
+ try {
WeblogEntryManager wmgr =
WebloggerFactory.getWeblogger().getWeblogEntryManager();
- allCategories =
wmgr.getWeblogCategories(getActionWeblog());
- } catch (WebloggerException ex) {
- log.error("Error building categories list", ex);
- addError("Error building categories list");
- }
-
- return LIST;
- }
-
- public String move() {
- return execute();
- }
-
- public List<WeblogCategory> getAllCategories() {
- return allCategories;
- }
-
- public void setAllCategories(List<WeblogCategory> allCategories) {
- this.allCategories = allCategories;
- }
+ allCategories = wmgr.getWeblogCategories(getActionWeblog());
+ entryCounts = wmgr.getWeblogCategoryEntryCounts(getActionWeblog());
+ } catch (WebloggerException ex) {
+ log.error("Error building categories list", ex);
+ addError("generic.error.check.logs");
+ }
+ return LIST;
+ }
+
+ /**
+ * Save a new display order. The posted ids must name every category of
+ * the weblog exactly once.
+ */
+ public String reorder() {
+ try {
+ WeblogEntryManager wmgr =
WebloggerFactory.getWeblogger().getWeblogEntryManager();
+ List<WeblogCategory> current =
wmgr.getWeblogCategories(getActionWeblog());
+
+ Map<String, WeblogCategory> byId = new HashMap<>();
+ for (WeblogCategory category : current) {
+ byId.put(category.getId(), category);
+ }
+ if (categoryIds.size() != current.size()
+ || !byId.keySet().equals(new HashSet<>(categoryIds))) {
+ addError("categoriesForm.error.reorder");
+ return execute();
+ }
+
+ for (int position = 0; position < categoryIds.size(); position++) {
+ WeblogCategory category = byId.get(categoryIds.get(position));
+ if (category.getPosition() != position) {
+ category.setPosition(position);
+ wmgr.saveWeblogCategory(category);
+ }
+ }
+ // the weblog's own list is sorted only when it is loaded, and
themes
+ // and new categories read it, so put it in the new order too
+ getActionWeblog().getWeblogCategories()
+
.sort(Comparator.comparingInt(WeblogCategory::getPosition));
Review Comment:
Thanks for adding this. One thing I'm not sure about: it relies on
EclipseLink treating a pure reorder of this list as a change it carries into
the shared cache, and the test covers a mocked list. Have you seen the new
order on the public site after a reorder, without a restart? If it doesn't
carry over, evicting or refreshing the weblog after the save would be the safer
route.
##########
app/src/main/resources/ApplicationResources_ja.properties:
##########
@@ -96,23 +96,15 @@ categoryForm.image=\u753B\u50CF
# -----------------------------------------------------------
CategoriesForm.jsp
categoriesForm.subtitle=<span>{0}</span>\u306E\u30AB\u30C6\u30B4\u30EA\u3092\u7BA1\u7406\u3057\u307E\u3059
-categoriesForm.root=\u30EB\u30FC\u30C8
categoriesForm.rootTitle=\u30AB\u30C6\u30B4\u30EA
categoriesForm.rootPrompt=\u30AB\u30C6\u30B4\u30EA\u306E\u6700\u4E0A\u4F4D\u3067\u3059\u3002\u6700\u4F4E\u3067\u3082\u4E00\u3064\u306E\u30AB\u30C6\u30B4\u30EA\u304C\u5FC5\u8981\u3067\u3059\u3002\u30AB\u30C6\u30B4\u30EA\u306F\u30D6\u30ED\u30B0\u30A8\u30F3\u30C8\u30EA\u3092\u5206\u985E\u3059\u308B\u305F\u3081\u306B\u4F7F\u308F\u308C\u3001\u30D6\u30ED\u30B0\u306E\u8AAD\u8005\u306F\u30AB\u30C6\u30B4\u30EA\u6BCE\u306B\u5206\u304B\u308C\u305F\u30CB\u30E5\u30FC\u30B9\u30D5\u30A3\u30FC\u30C9\u306E\u4E2D\u304B\u3089\u597D\u304D\u306A\u3082\u306E\u3092\u8CFC\u8AAD\u3059\u308B\u3053\u3068\u304C\u3067\u304D\u307E\u3059\u3002
categoriesForm.addCategory=\u30AB\u30C6\u30B4\u30EA\u306E\u8FFD\u52A0
-categoriesForm.move=\u79FB\u52D5
-categoriesForm.move.confirm=\u9078\u629E\u3057\u305F\u30AB\u30C6\u30B4\u30EA\u3092\u79FB\u52D5\u3057\u307E\u3059\u304B\uFF1F
-categoriesForm.remove=\u524A\u9664
# ---------------------------------------------------------
CategoryDeleteOK.jsp
-categoryDeleteOK.removeCategory=\u30AB\u30C6\u30B4\u30EA\u3092\u524A\u9664\u3057\u307E\u3059
categoryDeleteOK.warningCatInUse=<strong>\u8B66\u544A\uFF1A</strong>\u3053\u306E\u30AB\u30C6\u30B4\u30EA\u306F\u4F7F\u7528\u3055\u308C\u3066\u3044\u307E\u3059\u3002
Review Comment:
The page now prints `categoryDeleteOK.warningCatInUse` escaped inside the
delete dialog, and the English text changed meaning (it's now about the
blogging-client default). This Japanese value still has `<strong>` in it, so it
would show the tags as text, and like the de/es/fr/ru/zh_CN values it still
carries the old meaning. German `noEntriesInCat` also still says to click 'Ok'.
Removing the stale translations so they fall back to English may be the
simplest fix.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]