mraible commented on code in PR #246:
URL: https://github.com/apache/roller/pull/246#discussion_r4559376599
##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Bookmarks.java:
##########
@@ -68,202 +75,222 @@ public Bookmarks() {
this.pageTitle = "bookmarksForm.rootTitle";
}
+ /**
+ * Loads the requested blogroll. Without one, opens the default blogroll if
+ * it has links, otherwise the first blogroll that has links, so the page
+ * doesn't open on an empty list while the links are elsewhere.
+ */
@Override
public void myPrepare() {
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- if (!StringUtils.isEmpty(getFolderId())) {
- setFolder(bmgr.getFolderById(getActionWeblog(),
getFolderId()));
- } else {
- setFolder(bmgr.getDefaultFolder(getActionWeblog()));
- if (getFolder() != null) {
- setFolderId(getFolder().getId());
- }
+ List<WeblogBookmarkFolder> folders = new
ArrayList<>(bmgr.getAllFolders(getActionWeblog()));
+ folders.sort(Comparator
+ .comparing((WeblogBookmarkFolder f) ->
!DEFAULT_FOLDER.equals(f.getName()))
+ .thenComparing(f ->
StringUtils.defaultString(f.getName()), String.CASE_INSENSITIVE_ORDER));
+ allFolders = folders;
+
+ WeblogBookmarkFolder requested = StringUtils.isEmpty(getFolderId())
+ ? null : bmgr.getFolderById(getActionWeblog(),
getFolderId());
+ if (requested == null) {
+ requested = folders.stream()
+ .filter(f -> !f.getBookmarks().isEmpty())
+ .findFirst()
+ .orElse(folders.isEmpty() ? null : folders.get(0));
Review Comment:
This fallback also runs for `deleteFolder()`. If the posted `folderId` no
longer exists, `folder` becomes the first blogroll with links and
`deleteFolder()` deletes that one. Example: the default blogroll is empty,
"Friends" has links, and "Old" is open in two tabs. Delete "Old" in one tab,
then confirm the delete in the other: "Friends" and its links are removed. The
old code looked the folder up by id and did nothing when it was gone.
`folderCreated()` would announce the wrong blogroll the same way.
Could the fallback apply only when no `folderId` was posted (or only in
`execute()`), so the actions that change things get `folder == null` and report
not found?
##########
app/src/main/webapp/WEB-INF/jsps/editor/Bookmarks.jsp:
##########
@@ -17,838 +17,418 @@
--%>
<%@ include file="/WEB-INF/jsps/taglibs-struts2.jsp" %>
+<%-- Blogroll: the weblog's blogrolls (bookmark folders) on the left, or in a
strip above on a
+ phone, and the links of one of them. Links are dragged (or moved with the
arrow keys or touch-screen buttons on their
+ handle) to reorder; selected links can be moved or deleted. Dialogs post
with AJAX and reload
+ the page, which then shows a short confirmation. Text the script needs is
in data-* attributes. --%>
+
+<s:url var="importURL" action="bookmarksImport">
+ <s:param name="weblog" value="%{actionWeblog.handle}"/>
+</s:url>
+
+<div id="blogrollPage" class="blogroll-page"
+ data-folder-id="<s:property value='folderId'/>"
+ data-folder-name="<s:property value='folder.name'/>"
+ data-folder-url="<s:url action='bookmarks' escapeAmp='false'><s:param
name='weblog' value='%{actionWeblog.handle}'/></s:url>"
+ data-msg-link-added="<s:text escapeHtml="true"
name='bookmarkForm.created'/>"
+ data-msg-link-saved="<s:text escapeHtml="true"
name='bookmarkForm.updated'/>"
+ data-msg-folder-added="<s:text escapeHtml="true"
name='folderForm.created'/>"
+ data-msg-folder-renamed="<s:text escapeHtml="true"
name='folderForm.updatedNamed'/>"
+ data-msg-folder-deleted="<s:text escapeHtml="true"
name='bookmarksForm.folderDeleted'/>"
+ data-msg-deleted-one="<s:text escapeHtml="true"
name='bookmarksForm.deletedOne'/>"
+ data-msg-deleted-many="<s:text escapeHtml="true"
name='bookmarksForm.deletedMany'/>"
+ data-msg-moved-one="<s:text escapeHtml="true"
name='bookmarksForm.movedOne'/>"
+ data-msg-moved-many="<s:text escapeHtml="true"
name='bookmarksForm.movedMany'/>"
+ data-msg-order-saved="<s:text escapeHtml="true"
name='bookmarksForm.orderSaved'/>"
+ data-msg-error="<s:text escapeHtml="true"
name='generic.error.check.logs'/>"
+ data-msg-name-required="<s:text escapeHtml="true"
name='bookmarkForm.nameRequired'/>"
+ data-msg-url-required="<s:text escapeHtml="true"
name='bookmarkForm.urlRequired'/>"
+ data-msg-bad-url="<s:text escapeHtml="true" name='bookmarkForm.badUrl'/>"
+ data-msg-folder-name-required="<s:text escapeHtml="true"
name='bookmarksForm.blogroll.requiredFields'/>"
+ data-msg-selected="<s:text escapeHtml="true"
name='bookmarksForm.selectedCount'/>"
+ data-title-add-link="<s:text escapeHtml="true"
name='bookmarksForm.addLink.title'/>"
+ data-title-edit-link="<s:text escapeHtml="true"
name='bookmarksForm.editLink.title'/>"
+ data-title-add-folder="<s:text escapeHtml="true"
name='bookmarksForm.addBlogroll.title'/>"
+ data-title-rename-folder="<s:text escapeHtml="true"
name='bookmarksForm.editBlogroll.title'/>"
+ data-title-delete-one="<s:text escapeHtml="true"
name='bookmarksForm.deleteOne.title'/>"
+ data-title-delete-many="<s:text escapeHtml="true"
name='bookmarksForm.deleteMany.title'/>">
+
+<div class="blogroll-header">
+ <p class="subtitle mb-0">
+ <s:text name="bookmarksForm.subtitle">
+ <s:param value="weblog"/>
+ </s:text>
+ </p>
+ <div class="blogroll-header-actions">
+ <a class="btn btn-outline-secondary" href="<s:property
value='%{importURL}'/>">
+ <span class="bi bi-upload" aria-hidden="true"></span>
+ <s:text escapeHtml="true" name="bookmarksForm.importBookmarks"/>
+ </a>
+ <button type="button" id="addFolderButton" class="btn
btn-outline-primary">
+ <span class="bi bi-folder-plus" aria-hidden="true"></span>
+ <s:text escapeHtml="true" name="bookmarksForm.addFolder"/>
+ </button>
+ </div>
+</div>
-<%--
-Blogroll link management
-
-We used to call them Bookmarks and Folders, now we call them Blogroll links
and Blogrolls.
---%>
-
-<%--
================================================================================================
--%>
-
-<%-- Main blogroll/folder management interface, a checkbox-table with some
buttons --%>
-
-<p class="subtitle"><s:text name="bookmarksForm.subtitle"> <s:param
value="weblog"/> </s:text></p>
-
-<s:if test="folder.name == 'default'">
- <p class="pagetip"><s:text name="bookmarksForm.rootPrompt"/></p>
-</s:if>
-<s:else>
- <p class="pagetip"><s:text name="bookmarksForm.otherPrompt"/></p>
-</s:else>
-
-
-<%-- table of blogroll links with selection checkboxes, wrapped in a form --%>
-
-<s:form action="bookmarks!delete" theme="bootstrap" cssClass="form-horizontal">
- <s:hidden name="salt"/>
- <s:hidden name="weblog"/>
- <s:hidden name="folderId"/>
-
- <%-- for default blogroll, show page "tip" and read-only folder name --%>
-
- <s:if test="folder.name == 'default'">
-
- <div class="row mb-3">
- <label class="col-sm-3 col-form-label" for="bookmarks_folder_name">
- <s:text name="bookmarksForm.blogrollName"/>
- </label>
- <div class="col-sm-9 controls">
- <div class="form-control"><s:property
value="folder.name"/></div>
+<div class="blogroll-layout">
+
+ <%-- the blogrolls; a GET link each, so a blogroll can be reloaded,
bookmarked and reached with Back --%>
+ <nav class="blogroll-nav" aria-label="<s:text escapeHtml="true"
name='bookmarksForm.folders'/>">
+ <ul class="blogroll-folders">
+ <s:iterator var="f" value="allFolders">
+ <s:url var="folderURL" action="bookmarks" escapeAmp="false">
+ <s:param name="weblog" value="%{actionWeblog.handle}"/>
+ <s:param name="folderId" value="%{#f.id}"/>
+ </s:url>
+ <li>
+ <a href="<s:property value='%{folderURL}'/>"
+ class="blogroll-folder<s:if test='#f.id == folderId'>
active</s:if>"
+ <s:if test="#f.id ==
folderId">aria-current="page"</s:if>>
+ <span class="blogroll-folder-name">
+ <s:if test="#f.name == 'default'"><span class="bi
bi-star" aria-hidden="true"></span></s:if>
+ <s:property value="#f.name"/>
Review Comment:
Blogroll names are sanitized on the way in too
(`WeblogBookmarkFolder.setName`), but they are printed with a plain
`s:property` here and in the title, the move list and the delete dialog, so I
think a blogroll named `Matt's friends` shows its entity as literal text.
`plainText(#f.name)` would match what the links do.
Related: `FolderEdit.myValidate` passes the raw name to `hasBookmarkFolder`,
which compares against the sanitized stored names. A second `Matt's friends`
would get past that check and then fail in `addBookmarkFolder` with the generic
error instead of the duplicate-name message. `BookmarkEdit` already sanitizes
before comparing.
##########
app/src/main/webapp/roller-ui/scripts/blogroll.js:
##########
@@ -0,0 +1,565 @@
+/*
+* 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.
+*/
+
+/*
+ * Blogroll page (Bookmarks.jsp): add, edit and delete dialogs for links and
+ * blogrolls that post with AJAX, moving selected links to another blogroll,
+ * 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("blogrollPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.blogroll.flash";
+ var list = document.getElementById("linkList");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write parameters as {0}, {1}
+ function format(message) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ return message.replace(/\{(\d)\}/g, function (match, i) {
+ return args[i] !== undefined ? args[i] : match;
+ });
+ }
+
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = byId("blogrollToast");
+ 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();
+ }
+
+ // reloads the page, or opens another blogroll, then shows the message and
+ // highlights the links named, by id or by name
+ function reloadWith(message, highlight, folderId) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, highlight: highlight || [] }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.href = text("folderUrl") + "&folderId=" +
encodeURIComponent(folderId || text("folderId"));
+ }
+
+ 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);
+ if (list) {
+ list.querySelectorAll(".link-row").forEach(function (row) {
+ if (flash.highlight.indexOf(row.dataset.linkId) >= 0
+ || flash.highlight.indexOf(row.dataset.linkName) >= 0)
{
+ row.classList.add("link-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);
+ }
+
+ // posts a dialog's form; errors from the server go in the dialog
+ function submit(form, extra, button, errorBox, onSuccess) {
+ button.disabled = true;
+ post(form, extra).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showErrors(errorBox, errors);
+ button.disabled = false;
+ } else {
+ onSuccess(html);
+ }
+ }).fail(function () {
+ showErrors(errorBox, [text("msgError")]);
+ button.disabled = false;
+ });
+ }
+
+ function setInvalid(field, feedbackId, message) {
+ field.classList.toggle("is-invalid", !!message);
+ byId(feedbackId).textContent = message || "";
+ }
+
+ // "" for an empty field, null for one that isn't an http(s) URL
+ function httpUrl(field) {
+ var value = field.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;
+ }
+ }
+
+ // --- add and edit a link ----------------------------------------------
+
+ var linkModal = new bootstrap.Modal(byId("link-edit-modal"));
+ var linkForm = byId("linkEditForm");
+ var linkUrl = byId("linkEditForm_bean_url");
+ var linkName = byId("linkEditForm_bean_name");
+ var linkDesc = byId("linkEditForm_bean_description");
+ var linkFeed = byId("linkEditForm_bean_feedUrl");
+ var linkImage = byId("linkEditForm_bean_image");
+ var linkPreview = byId("linkImagePreview");
+ var linkSave = byId("linkSaveButton");
+ var linkErrors = byId("linkEditErrors");
+ var nameTouched = false;
+ var touched = {};
+
+ // shows a field's error only once the user has typed in it, or on save
+ function validateLink(all) {
+ var url = httpUrl(linkUrl);
+ var feed = httpUrl(linkFeed);
+ var image = httpUrl(linkImage);
+ var nameOk = linkName.value.trim() !== "";
+ var show = function (field) {
+ return all || touched[field.id];
+ };
+ setInvalid(linkUrl, "linkUrlFeedback", !show(linkUrl) ? "" :
+ url === "" ? text("msgUrlRequired") : url === null ?
text("msgBadUrl") : "");
+ setInvalid(linkName, "linkNameFeedback", show(linkName) && !nameOk ?
text("msgNameRequired") : "");
+ setInvalid(linkFeed, "linkFeedFeedback", feed === null ?
text("msgBadUrl") : "");
+ setInvalid(linkImage, "linkImageFeedback", image === null ?
text("msgBadUrl") : "");
+ linkPreview.classList.toggle("d-none", !image);
+ if (image && linkPreview.getAttribute("src") !== image) {
+ linkPreview.setAttribute("src", image);
+ }
+ var ok = nameOk && url !== "" && url !== null && feed !== null &&
image !== null;
+ linkSave.disabled = !ok;
+ return ok;
+ }
+
+ // a new link's name starts as the site's host name, until the user types
one
+ function suggestName() {
+ if (nameTouched || linkForm.dataset.adding !== "true") {
+ return;
+ }
+ var url = httpUrl(linkUrl);
+ linkName.value = url ? new URL(url).hostname.replace(/^www\./, "") :
"";
+ }
+
+ function openLinkEditor(row) {
+ var adding = !row;
+ var data = adding ? {} : row.dataset;
+ byId("link-edit-title").textContent = format(adding ?
text("titleAddLink") : text("titleEditLink"), text("folderName"));
+ linkForm.dataset.adding = adding ? "true" : "false";
+ linkForm.action = linkForm.action.replace(/bookmark(Add|Edit)!save/,
adding ? "bookmarkAdd!save" : "bookmarkEdit!save");
+ byId("linkEditForm_bean_id").value = adding ? "" : data.linkId;
+ linkUrl.value = data.linkUrl || "";
+ linkName.value = data.linkName || "";
+ linkDesc.value = data.linkDesc || "";
+ linkFeed.value = data.linkFeed || "";
+ linkImage.value = data.linkImage || "";
+ byId("linkAdvanced").open = !!(data.linkFeed || data.linkImage);
+ nameTouched = !adding;
+ touched = {};
+ showErrors(linkErrors, []);
+ linkPreview.classList.add("d-none");
+ linkPreview.removeAttribute("src");
+ validateLink(false);
+ linkModal.show();
+ }
+
+ function saveLink() {
+ if (!validateLink(true)) {
+ return;
+ }
+ var adding = linkForm.dataset.adding === "true";
+ var name = linkName.value.trim();
+ submit(linkForm, "", linkSave, linkErrors, function () {
+ reloadWith(format(adding ? text("msgLinkAdded") :
text("msgLinkSaved"), name), [name]);
+ });
+ }
+
+ [linkUrl, linkName, linkFeed, linkImage].forEach(function (field) {
+ field.addEventListener("input", function () {
+ touched[field.id] = true;
+ if (field === linkName) {
+ nameTouched = true;
+ }
+ if (field === linkUrl) {
+ suggestName();
+ }
+ validateLink(false);
+ });
+ });
+ linkPreview.addEventListener("error", function () {
+ linkPreview.classList.add("d-none");
+ });
+ linkSave.addEventListener("click", saveLink);
+ linkForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ });
+ linkForm.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" && event.target.matches("input")) {
Review Comment:
`saveLink()` runs on Enter whether or not a save is already in flight, since
only the button is disabled. Pressing Enter twice sends a second post with the
spent salt, and its error can show just before the first post's reload.
Returning early when the save button is disabled would do it. Checking
`event.isComposing` here would also stop an IME confirmation from saving a
half-typed name.
##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Bookmarks.java:
##########
@@ -68,202 +75,222 @@ public Bookmarks() {
this.pageTitle = "bookmarksForm.rootTitle";
}
+ /**
+ * Loads the requested blogroll. Without one, opens the default blogroll if
+ * it has links, otherwise the first blogroll that has links, so the page
+ * doesn't open on an empty list while the links are elsewhere.
+ */
@Override
public void myPrepare() {
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- if (!StringUtils.isEmpty(getFolderId())) {
- setFolder(bmgr.getFolderById(getActionWeblog(),
getFolderId()));
- } else {
- setFolder(bmgr.getDefaultFolder(getActionWeblog()));
- if (getFolder() != null) {
- setFolderId(getFolder().getId());
- }
+ List<WeblogBookmarkFolder> folders = new
ArrayList<>(bmgr.getAllFolders(getActionWeblog()));
+ folders.sort(Comparator
+ .comparing((WeblogBookmarkFolder f) ->
!DEFAULT_FOLDER.equals(f.getName()))
+ .thenComparing(f ->
StringUtils.defaultString(f.getName()), String.CASE_INSENSITIVE_ORDER));
+ allFolders = folders;
+
+ WeblogBookmarkFolder requested = StringUtils.isEmpty(getFolderId())
+ ? null : bmgr.getFolderById(getActionWeblog(),
getFolderId());
+ if (requested == null) {
+ requested = folders.stream()
+ .filter(f -> !f.getBookmarks().isEmpty())
+ .findFirst()
+ .orElse(folders.isEmpty() ? null : folders.get(0));
}
+ setFolder(requested);
} catch (WebloggerException ex) {
log.error("Error looking up folder", ex);
}
}
/**
- * Present the bookmarks available in the folder specified by the request.
+ * Shows the blogroll.
*/
+ @SkipValidation
@Override
public String execute() {
-
- // build list of folders that the user can navigate to
- List<WeblogBookmarkFolder> newFolders = new ArrayList<>();
-
- try {
- // Build list of all folders, except for current one
- BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- List<WeblogBookmarkFolder> folders =
bmgr.getAllFolders(getActionWeblog());
- for (WeblogBookmarkFolder fd : folders) {
- if (!fd.getId().equals(getFolderId())) {
- newFolders.add(fd);
- }
- }
-
- } catch (WebloggerException ex) {
- log.error("Error building folders list", ex);
- addError("Error building folders list");
- }
-
- if (!newFolders.isEmpty()) {
- setAllFolders(newFolders);
- }
-
return LIST;
}
/**
- * Delete bookmarks.
+ * Deletes the selected links.
*/
public String delete() {
-
- BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
-
+ List<WeblogBookmark> links = selectedLinks();
+ if (links == null) {
+ addError("bookmarksForm.error.notFound");
+ return execute();
+ }
try {
- WeblogBookmark bookmark;
- String bookmarks[] = getSelectedBookmarks();
- if (null != bookmarks && bookmarks.length > 0) {
- if (log.isDebugEnabled()) {
- log.debug("Processing delete of " + bookmarks.length
- + " bookmarks.");
- }
- for (int j = 0; j < bookmarks.length; j++) {
- if (log.isDebugEnabled()) {
- log.debug("Deleting bookmark - " + bookmarks[j]);
- }
- bookmark = bmgr.getBookmark(getActionWeblog(),
bookmarks[j]);
- if (bookmark != null) {
- bmgr.removeBookmark(bookmark);
- }
-
- }
+ BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
+ for (WeblogBookmark link : links) {
+ folder.getBookmarks().remove(link);
+ bmgr.removeBookmark(link);
}
-
- // flush changes
WebloggerFactory.getWeblogger().flush();
-
- // notify caches
CacheManager.invalidate(getActionWeblog());
-
+ addMessage(links.size() == 1 ? "bookmarksForm.deletedOne" :
"bookmarksForm.deletedMany",
+ String.valueOf(links.size()));
} catch (WebloggerException ex) {
- log.error("Error doing bookmark deletes", ex);
- addError("Error doing bookmark deletes");
+ log.error("Error deleting links", ex);
+ addError("generic.error.check.logs");
}
-
return execute();
}
+ /**
+ * Deletes the blogroll being viewed and its links, then shows the default
blogroll.
+ */
public String deleteFolder() {
-
+ if (folder == null) {
+ addError("folderForm.notFound");
+ return execute();
+ }
+ if (isDefaultFolder()) {
+ addError("bookmarksForm.error.deleteDefault");
+ return execute();
+ }
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- WeblogBookmarkFolder fd = bmgr.getFolderById(getActionWeblog(),
getFolderId());
-
- if (fd != null) {
-
- if ( "default".equals( fd.getName() ) ) {
- addError("Cannot delete default bookmark");
- return execute();
- }
- bmgr.removeFolder(fd);
-
- // flush changes
- WebloggerFactory.getWeblogger().flush();
-
- // notify caches
- CacheManager.invalidate(getActionWeblog());
-
- // re-route to default folder
- setFolder(bmgr.getDefaultFolder(getActionWeblog()));
- setFolderId(getFolder().getId());
- }
+ String name = folder.getName();
+ bmgr.removeFolder(folder);
+ WebloggerFactory.getWeblogger().flush();
+ CacheManager.invalidate(getActionWeblog());
+ addMessage("bookmarksForm.folderDeleted", name);
+ allFolders = new ArrayList<>(allFolders);
+ allFolders.remove(folder);
+ setFolder(bmgr.getDefaultFolder(getActionWeblog()));
} catch (WebloggerException ex) {
log.error("Error deleting folder", ex);
+ addError("generic.error.check.logs");
}
return execute();
}
@SkipValidation
public String folderCreated() {
- // action from FolderEdit upon creation of a new folder, to display
- // a success message prior to showing the new folder.
- addMessage("folderForm.created");
+ // reached by a redirect from FolderEdit after it creates a folder, and
+ // from BookmarksImport after it imports links into a new one
+ if (folder != null) {
+ addMessage("folderForm.created", folder.getName());
+ }
return execute();
}
/**
- * View the contents of another bookmark folder.
+ * Moves the selected links to the end of another blogroll.
*/
- public String view() {
-
+ public String move() {
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- if (!StringUtils.isEmpty(viewFolderId)) {
- setFolder(bmgr.getFolderById(getActionWeblog(), viewFolderId));
- setFolderId(viewFolderId);
+ WeblogBookmarkFolder target =
StringUtils.isEmpty(getTargetFolderId())
+ ? null : bmgr.getFolderById(getActionWeblog(),
getTargetFolderId());
+ List<WeblogBookmark> links = selectedLinks();
+ if (target == null || links == null || target.equals(folder)) {
+ addError("bookmarksForm.error.move");
+ return execute();
}
- } catch (WebloggerException ex) {
- log.error("Error looking up folder", ex);
+ for (WeblogBookmark link : links) {
+ folder.getBookmarks().remove(link);
+ target.addBookmark(link);
Review Comment:
`move()` doesn't check whether the target already has a link with this name,
so it can break the unique-name-per-blogroll rule that
`BookmarkEdit.myValidate` enforces with `hasBookmarkOfName`. Moving "Example"
into a blogroll that already has an "Example" leaves two. Skipping those links
with a message, or failing the move, would keep the rule intact.
##########
app/src/main/webapp/roller-ui/scripts/blogroll.js:
##########
@@ -0,0 +1,565 @@
+/*
+* 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.
+*/
+
+/*
+ * Blogroll page (Bookmarks.jsp): add, edit and delete dialogs for links and
+ * blogrolls that post with AJAX, moving selected links to another blogroll,
+ * 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("blogrollPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.blogroll.flash";
+ var list = document.getElementById("linkList");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write parameters as {0}, {1}
+ function format(message) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ return message.replace(/\{(\d)\}/g, function (match, i) {
+ return args[i] !== undefined ? args[i] : match;
+ });
+ }
+
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = byId("blogrollToast");
+ 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();
+ }
+
+ // reloads the page, or opens another blogroll, then shows the message and
+ // highlights the links named, by id or by name
+ function reloadWith(message, highlight, folderId) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, highlight: highlight || [] }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.href = text("folderUrl") + "&folderId=" +
encodeURIComponent(folderId || text("folderId"));
+ }
+
+ 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);
+ if (list) {
+ list.querySelectorAll(".link-row").forEach(function (row) {
+ if (flash.highlight.indexOf(row.dataset.linkId) >= 0
+ || flash.highlight.indexOf(row.dataset.linkName) >= 0)
{
+ row.classList.add("link-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);
+ }
+
+ // posts a dialog's form; errors from the server go in the dialog
+ function submit(form, extra, button, errorBox, onSuccess) {
+ button.disabled = true;
+ post(form, extra).done(function (html) {
+ var errors = actionErrors(html);
Review Comment:
Any 200 response without `#errors li` counts as success. If the session has
expired and the post is redirected to the login page, I think the user would
get an "added" or "deleted" toast for something that didn't happen. I haven't
confirmed this, since the salt filter may reject the post first. Checking for
something only this page has in the response (`#blogrollPage`, say) would rule
it out.
##########
app/src/main/webapp/roller-ui/scripts/blogroll.js:
##########
@@ -0,0 +1,565 @@
+/*
+* 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.
+*/
+
+/*
+ * Blogroll page (Bookmarks.jsp): add, edit and delete dialogs for links and
+ * blogrolls that post with AJAX, moving selected links to another blogroll,
+ * 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("blogrollPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.blogroll.flash";
+ var list = document.getElementById("linkList");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write parameters as {0}, {1}
+ function format(message) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ return message.replace(/\{(\d)\}/g, function (match, i) {
+ return args[i] !== undefined ? args[i] : match;
+ });
+ }
+
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = byId("blogrollToast");
+ 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();
+ }
+
+ // reloads the page, or opens another blogroll, then shows the message and
+ // highlights the links named, by id or by name
+ function reloadWith(message, highlight, folderId) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, highlight: highlight || [] }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.href = text("folderUrl") + "&folderId=" +
encodeURIComponent(folderId || text("folderId"));
Review Comment:
After deleting a blogroll this is called with `""`, and `"" ||
text("folderId")` falls back to the id of the blogroll that was just deleted.
It works today only because `myPrepare()` falls back when the id isn't found.
If that fallback is narrowed (see my comment on `myPrepare`), this needs
`folderId === undefined ? text("folderId") : folderId` or similar.
##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Bookmarks.java:
##########
@@ -68,202 +75,222 @@ public Bookmarks() {
this.pageTitle = "bookmarksForm.rootTitle";
}
+ /**
+ * Loads the requested blogroll. Without one, opens the default blogroll if
+ * it has links, otherwise the first blogroll that has links, so the page
+ * doesn't open on an empty list while the links are elsewhere.
+ */
@Override
public void myPrepare() {
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- if (!StringUtils.isEmpty(getFolderId())) {
- setFolder(bmgr.getFolderById(getActionWeblog(),
getFolderId()));
- } else {
- setFolder(bmgr.getDefaultFolder(getActionWeblog()));
- if (getFolder() != null) {
- setFolderId(getFolder().getId());
- }
+ List<WeblogBookmarkFolder> folders = new
ArrayList<>(bmgr.getAllFolders(getActionWeblog()));
+ folders.sort(Comparator
+ .comparing((WeblogBookmarkFolder f) ->
!DEFAULT_FOLDER.equals(f.getName()))
+ .thenComparing(f ->
StringUtils.defaultString(f.getName()), String.CASE_INSENSITIVE_ORDER));
+ allFolders = folders;
+
+ WeblogBookmarkFolder requested = StringUtils.isEmpty(getFolderId())
+ ? null : bmgr.getFolderById(getActionWeblog(),
getFolderId());
+ if (requested == null) {
+ requested = folders.stream()
+ .filter(f -> !f.getBookmarks().isEmpty())
+ .findFirst()
+ .orElse(folders.isEmpty() ? null : folders.get(0));
}
+ setFolder(requested);
} catch (WebloggerException ex) {
log.error("Error looking up folder", ex);
}
}
/**
- * Present the bookmarks available in the folder specified by the request.
+ * Shows the blogroll.
*/
+ @SkipValidation
@Override
public String execute() {
-
- // build list of folders that the user can navigate to
- List<WeblogBookmarkFolder> newFolders = new ArrayList<>();
-
- try {
- // Build list of all folders, except for current one
- BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- List<WeblogBookmarkFolder> folders =
bmgr.getAllFolders(getActionWeblog());
- for (WeblogBookmarkFolder fd : folders) {
- if (!fd.getId().equals(getFolderId())) {
- newFolders.add(fd);
- }
- }
-
- } catch (WebloggerException ex) {
- log.error("Error building folders list", ex);
- addError("Error building folders list");
- }
-
- if (!newFolders.isEmpty()) {
- setAllFolders(newFolders);
- }
-
return LIST;
}
/**
- * Delete bookmarks.
+ * Deletes the selected links.
*/
public String delete() {
-
- BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
-
+ List<WeblogBookmark> links = selectedLinks();
+ if (links == null) {
+ addError("bookmarksForm.error.notFound");
+ return execute();
+ }
try {
- WeblogBookmark bookmark;
- String bookmarks[] = getSelectedBookmarks();
- if (null != bookmarks && bookmarks.length > 0) {
- if (log.isDebugEnabled()) {
- log.debug("Processing delete of " + bookmarks.length
- + " bookmarks.");
- }
- for (int j = 0; j < bookmarks.length; j++) {
- if (log.isDebugEnabled()) {
- log.debug("Deleting bookmark - " + bookmarks[j]);
- }
- bookmark = bmgr.getBookmark(getActionWeblog(),
bookmarks[j]);
- if (bookmark != null) {
- bmgr.removeBookmark(bookmark);
- }
-
- }
+ BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
+ for (WeblogBookmark link : links) {
+ folder.getBookmarks().remove(link);
+ bmgr.removeBookmark(link);
}
-
- // flush changes
WebloggerFactory.getWeblogger().flush();
-
- // notify caches
CacheManager.invalidate(getActionWeblog());
-
+ addMessage(links.size() == 1 ? "bookmarksForm.deletedOne" :
"bookmarksForm.deletedMany",
+ String.valueOf(links.size()));
} catch (WebloggerException ex) {
- log.error("Error doing bookmark deletes", ex);
- addError("Error doing bookmark deletes");
+ log.error("Error deleting links", ex);
+ addError("generic.error.check.logs");
}
-
return execute();
}
+ /**
+ * Deletes the blogroll being viewed and its links, then shows the default
blogroll.
+ */
public String deleteFolder() {
-
+ if (folder == null) {
+ addError("folderForm.notFound");
+ return execute();
+ }
+ if (isDefaultFolder()) {
+ addError("bookmarksForm.error.deleteDefault");
+ return execute();
+ }
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- WeblogBookmarkFolder fd = bmgr.getFolderById(getActionWeblog(),
getFolderId());
-
- if (fd != null) {
-
- if ( "default".equals( fd.getName() ) ) {
- addError("Cannot delete default bookmark");
- return execute();
- }
- bmgr.removeFolder(fd);
-
- // flush changes
- WebloggerFactory.getWeblogger().flush();
-
- // notify caches
- CacheManager.invalidate(getActionWeblog());
-
- // re-route to default folder
- setFolder(bmgr.getDefaultFolder(getActionWeblog()));
- setFolderId(getFolder().getId());
- }
+ String name = folder.getName();
+ bmgr.removeFolder(folder);
+ WebloggerFactory.getWeblogger().flush();
+ CacheManager.invalidate(getActionWeblog());
+ addMessage("bookmarksForm.folderDeleted", name);
+ allFolders = new ArrayList<>(allFolders);
+ allFolders.remove(folder);
+ setFolder(bmgr.getDefaultFolder(getActionWeblog()));
} catch (WebloggerException ex) {
log.error("Error deleting folder", ex);
+ addError("generic.error.check.logs");
}
return execute();
}
@SkipValidation
public String folderCreated() {
- // action from FolderEdit upon creation of a new folder, to display
- // a success message prior to showing the new folder.
- addMessage("folderForm.created");
+ // reached by a redirect from FolderEdit after it creates a folder, and
+ // from BookmarksImport after it imports links into a new one
+ if (folder != null) {
+ addMessage("folderForm.created", folder.getName());
+ }
return execute();
}
/**
- * View the contents of another bookmark folder.
+ * Moves the selected links to the end of another blogroll.
*/
- public String view() {
-
+ public String move() {
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
- if (!StringUtils.isEmpty(viewFolderId)) {
- setFolder(bmgr.getFolderById(getActionWeblog(), viewFolderId));
- setFolderId(viewFolderId);
+ WeblogBookmarkFolder target =
StringUtils.isEmpty(getTargetFolderId())
+ ? null : bmgr.getFolderById(getActionWeblog(),
getTargetFolderId());
+ List<WeblogBookmark> links = selectedLinks();
+ if (target == null || links == null || target.equals(folder)) {
+ addError("bookmarksForm.error.move");
+ return execute();
}
- } catch (WebloggerException ex) {
- log.error("Error looking up folder", ex);
+ for (WeblogBookmark link : links) {
+ folder.getBookmarks().remove(link);
+ target.addBookmark(link);
+ link.setFolder(target);
+ // recalculated by saveBookmark: after the target's last link
+ link.setPriority(null);
+ bmgr.saveBookmark(link);
+ }
+ WebloggerFactory.getWeblogger().flush();
+ CacheManager.invalidate(getActionWeblog());
+ addMessage(links.size() == 1 ? "bookmarksForm.movedOne" :
"bookmarksForm.movedMany",
+ List.of(String.valueOf(links.size()), target.getName()));
+ } catch (WebloggerException e) {
+ log.error("Error moving links", e);
+ addError("bookmarksForm.error.move");
}
return execute();
}
/**
- * Move bookmarks to a new folder.
+ * Saves a new order for the links of the blogroll. The posted ids must be
+ * exactly the blogroll's links.
*/
- public String move() {
-
+ public String reorder() {
+ if (folder == null) {
+ addError("folderForm.notFound");
+ return execute();
+ }
+ List<WeblogBookmark> links = folder.getBookmarks();
+ Set<String> ids = new HashSet<>(bookmarkIds);
+ if (ids.size() != bookmarkIds.size() || ids.size() != links.size()
+ || links.stream().anyMatch(link ->
!ids.contains(link.getId()))) {
+ addError("bookmarksForm.error.reorder");
+ return execute();
+ }
try {
BookmarkManager bmgr =
WebloggerFactory.getWeblogger().getBookmarkManager();
-
- if (log.isDebugEnabled()) {
- log.debug("Moving bookmarks to folder - " +
getTargetFolderId());
+ for (WeblogBookmark link : links) {
+ link.setPriority(bookmarkIds.indexOf(link.getId()));
Review Comment:
Minor: `saveBookmark` does a lookup and a `saveWeblog()` per link, and
`indexOf` makes the loop quadratic, so one drag on a long blogroll does a lot
of work. A map of id to index, with one weblog save after the loop, would be
cheaper.
##########
app/src/main/webapp/roller-ui/scripts/blogroll.js:
##########
@@ -0,0 +1,565 @@
+/*
+* 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.
+*/
+
+/*
+ * Blogroll page (Bookmarks.jsp): add, edit and delete dialogs for links and
+ * blogrolls that post with AJAX, moving selected links to another blogroll,
+ * 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("blogrollPage");
+ if (!page) {
+ return;
+ }
+
+ var FLASH_KEY = "roller.blogroll.flash";
+ var list = document.getElementById("linkList");
+
+ function text(name) {
+ return page.dataset[name] || "";
+ }
+
+ // messages come from resource bundles, which write parameters as {0}, {1}
+ function format(message) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ return message.replace(/\{(\d)\}/g, function (match, i) {
+ return args[i] !== undefined ? args[i] : match;
+ });
+ }
+
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ // --- confirmation after a change -------------------------------------
+
+ function showToast(message, isError) {
+ var toast = byId("blogrollToast");
+ 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();
+ }
+
+ // reloads the page, or opens another blogroll, then shows the message and
+ // highlights the links named, by id or by name
+ function reloadWith(message, highlight, folderId) {
+ try {
+ sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message:
message, highlight: highlight || [] }));
+ } catch (e) {
+ // storage unavailable: reload without the confirmation
+ }
+ location.href = text("folderUrl") + "&folderId=" +
encodeURIComponent(folderId || text("folderId"));
+ }
+
+ 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);
+ if (list) {
+ list.querySelectorAll(".link-row").forEach(function (row) {
+ if (flash.highlight.indexOf(row.dataset.linkId) >= 0
+ || flash.highlight.indexOf(row.dataset.linkName) >= 0)
{
+ row.classList.add("link-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);
+ }
+
+ // posts a dialog's form; errors from the server go in the dialog
+ function submit(form, extra, button, errorBox, onSuccess) {
+ button.disabled = true;
+ post(form, extra).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showErrors(errorBox, errors);
+ button.disabled = false;
+ } else {
+ onSuccess(html);
+ }
+ }).fail(function () {
+ showErrors(errorBox, [text("msgError")]);
+ button.disabled = false;
+ });
+ }
+
+ function setInvalid(field, feedbackId, message) {
+ field.classList.toggle("is-invalid", !!message);
+ byId(feedbackId).textContent = message || "";
+ }
+
+ // "" for an empty field, null for one that isn't an http(s) URL
+ function httpUrl(field) {
+ var value = field.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;
+ }
+ }
+
+ // --- add and edit a link ----------------------------------------------
+
+ var linkModal = new bootstrap.Modal(byId("link-edit-modal"));
+ var linkForm = byId("linkEditForm");
+ var linkUrl = byId("linkEditForm_bean_url");
+ var linkName = byId("linkEditForm_bean_name");
+ var linkDesc = byId("linkEditForm_bean_description");
+ var linkFeed = byId("linkEditForm_bean_feedUrl");
+ var linkImage = byId("linkEditForm_bean_image");
+ var linkPreview = byId("linkImagePreview");
+ var linkSave = byId("linkSaveButton");
+ var linkErrors = byId("linkEditErrors");
+ var nameTouched = false;
+ var touched = {};
+
+ // shows a field's error only once the user has typed in it, or on save
+ function validateLink(all) {
+ var url = httpUrl(linkUrl);
+ var feed = httpUrl(linkFeed);
+ var image = httpUrl(linkImage);
+ var nameOk = linkName.value.trim() !== "";
+ var show = function (field) {
+ return all || touched[field.id];
+ };
+ setInvalid(linkUrl, "linkUrlFeedback", !show(linkUrl) ? "" :
+ url === "" ? text("msgUrlRequired") : url === null ?
text("msgBadUrl") : "");
+ setInvalid(linkName, "linkNameFeedback", show(linkName) && !nameOk ?
text("msgNameRequired") : "");
+ setInvalid(linkFeed, "linkFeedFeedback", feed === null ?
text("msgBadUrl") : "");
+ setInvalid(linkImage, "linkImageFeedback", image === null ?
text("msgBadUrl") : "");
+ linkPreview.classList.toggle("d-none", !image);
+ if (image && linkPreview.getAttribute("src") !== image) {
+ linkPreview.setAttribute("src", image);
+ }
+ var ok = nameOk && url !== "" && url !== null && feed !== null &&
image !== null;
+ linkSave.disabled = !ok;
+ return ok;
+ }
+
+ // a new link's name starts as the site's host name, until the user types
one
+ function suggestName() {
+ if (nameTouched || linkForm.dataset.adding !== "true") {
+ return;
+ }
+ var url = httpUrl(linkUrl);
+ linkName.value = url ? new URL(url).hostname.replace(/^www\./, "") :
"";
+ }
+
+ function openLinkEditor(row) {
+ var adding = !row;
+ var data = adding ? {} : row.dataset;
+ byId("link-edit-title").textContent = format(adding ?
text("titleAddLink") : text("titleEditLink"), text("folderName"));
+ linkForm.dataset.adding = adding ? "true" : "false";
+ linkForm.action = linkForm.action.replace(/bookmark(Add|Edit)!save/,
adding ? "bookmarkAdd!save" : "bookmarkEdit!save");
+ byId("linkEditForm_bean_id").value = adding ? "" : data.linkId;
+ linkUrl.value = data.linkUrl || "";
+ linkName.value = data.linkName || "";
+ linkDesc.value = data.linkDesc || "";
+ linkFeed.value = data.linkFeed || "";
+ linkImage.value = data.linkImage || "";
+ byId("linkAdvanced").open = !!(data.linkFeed || data.linkImage);
+ nameTouched = !adding;
+ touched = {};
+ showErrors(linkErrors, []);
+ linkPreview.classList.add("d-none");
+ linkPreview.removeAttribute("src");
+ validateLink(false);
+ linkModal.show();
+ }
+
+ function saveLink() {
+ if (!validateLink(true)) {
+ return;
+ }
+ var adding = linkForm.dataset.adding === "true";
+ var name = linkName.value.trim();
+ submit(linkForm, "", linkSave, linkErrors, function () {
+ reloadWith(format(adding ? text("msgLinkAdded") :
text("msgLinkSaved"), name), [name]);
+ });
+ }
+
+ [linkUrl, linkName, linkFeed, linkImage].forEach(function (field) {
+ field.addEventListener("input", function () {
+ touched[field.id] = true;
+ if (field === linkName) {
+ nameTouched = true;
+ }
+ if (field === linkUrl) {
+ suggestName();
+ }
+ validateLink(false);
+ });
+ });
+ linkPreview.addEventListener("error", function () {
+ linkPreview.classList.add("d-none");
+ });
+ linkSave.addEventListener("click", saveLink);
+ linkForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ });
+ linkForm.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" && event.target.matches("input")) {
+ event.preventDefault();
+ saveLink();
+ }
+ });
+ byId("link-edit-modal").addEventListener("shown.bs.modal", function () {
+ (linkForm.dataset.adding === "true" ? linkUrl : linkName).focus();
+ });
+ ["addLinkButton", "emptyAddLinkButton"].forEach(function (id) {
+ var button = byId(id);
+ if (button) {
+ button.addEventListener("click", function () {
+ openLinkEditor(null);
+ });
+ }
+ });
+
+ // --- add and rename a blogroll ------------------------------------------
+
+ var folderModal = new bootstrap.Modal(byId("folder-edit-modal"));
+ var folderForm = byId("folderEditForm");
+ var folderName = byId("folderEditForm_bean_name");
+ var folderSave = byId("folderSaveButton");
+ var folderErrors = byId("folderEditErrors");
+
+ function validateFolder(show) {
+ var ok = folderName.value.trim() !== "";
+ setInvalid(folderName, "folderNameFeedback", show && !ok ?
text("msgFolderNameRequired") : "");
+ folderSave.disabled = !ok;
+ return ok;
+ }
+
+ function openFolderEditor(renaming) {
+ byId("folder-edit-title").textContent = renaming ?
text("titleRenameFolder") : text("titleAddFolder");
+ folderForm.dataset.adding = renaming ? "false" : "true";
+ folderForm.action = folderForm.action.replace(/folder(Add|Edit)!save/,
renaming ? "folderEdit!save" : "folderAdd!save");
+ byId("folderEditForm_bean_id").value = renaming ? text("folderId") :
"";
+ folderName.value = renaming ? text("folderName") : "";
+ showErrors(folderErrors, []);
+ validateFolder(false);
+ folderModal.show();
+ }
+
+ function saveFolder() {
+ if (!validateFolder(true)) {
+ return;
+ }
+ var adding = folderForm.dataset.adding === "true";
+ var name = folderName.value.trim();
+ submit(folderForm, "", folderSave, folderErrors, function (html) {
+ // a new blogroll is opened: its id is in the page the save returns
+ var created = new DOMParser().parseFromString(html,
"text/html").getElementById("blogrollPage");
+ var folderId = adding && created ? created.dataset.folderId : null;
+ reloadWith(format(adding ? text("msgFolderAdded") :
text("msgFolderRenamed"), name), [], folderId);
+ });
+ }
+
+ folderName.addEventListener("input", function () {
+ validateFolder(true);
+ });
+ folderSave.addEventListener("click", saveFolder);
+ folderForm.addEventListener("submit", function (event) {
+ event.preventDefault();
+ saveFolder();
+ });
+ byId("folder-edit-modal").addEventListener("shown.bs.modal", function () {
+ folderName.focus();
+ folderName.select();
+ });
+ byId("addFolderButton").addEventListener("click", function () {
+ openFolderEditor(false);
+ });
+ if (byId("renameFolderButton")) {
+ byId("renameFolderButton").addEventListener("click", function () {
+ openFolderEditor(true);
+ });
+ }
+
+ // --- delete a blogroll
---------------------------------------------------
+
+ if (byId("delete-folder-modal")) {
+ var folderDeleteModal = new
bootstrap.Modal(byId("delete-folder-modal"));
+ byId("deleteFolderButton").addEventListener("click", function () {
+ showErrors(byId("deleteFolderErrors"), []);
+ folderDeleteModal.show();
+ });
+ byId("deleteFolderConfirm").addEventListener("click", function () {
+ var name = text("folderName");
+ submit(byId("folderDeleteForm"), "", this,
byId("deleteFolderErrors"), function () {
+ // the default blogroll opens: no folder id
+ reloadWith(format(text("msgFolderDeleted"), name), [], "");
+ });
+ });
+ }
+
+ // the rest works on the links, when there are any
+ if (!list) {
+ showFlash();
+ return;
+ }
+
+ // --- selecting, moving and deleting links
-----------------------------------
+
+ var selectAll = byId("selectAllLinks");
+ var selectedCount = byId("selectedCount");
+ var selectAllLabel = selectedCount.textContent;
+ var moveSelect = byId("linksMoveForm_targetFolderId");
+ var moveButton = byId("moveLinksButton");
+ var deleteButton = byId("deleteLinksButton");
+ var deleteModal = new bootstrap.Modal(byId("delete-links-modal"));
+ var deleteConfirm = byId("deleteLinksConfirm");
+ var toDelete = [];
+
+ function boxes() {
+ return
Array.prototype.slice.call(list.querySelectorAll(".link-select"));
+ }
+
+ function selectedIds() {
+ return boxes().filter(function (box) {
+ return box.checked;
+ }).map(function (box) {
+ return box.value;
+ });
+ }
+
+ function idsParam(ids) {
+ return ids.map(function (id) {
+ return "&selectedBookmarks=" + encodeURIComponent(id);
+ }).join("");
+ }
+
+ function namesOf(ids) {
+ return boxes().filter(function (box) {
+ return ids.indexOf(box.value) >= 0;
+ }).map(function (box) {
+ return box.closest(".link-row").dataset.linkName;
+ });
+ }
+
+ function selectionChanged() {
+ var ids = selectedIds();
+ var all = boxes().length;
+ selectAll.checked = ids.length === all;
+ selectAll.indeterminate = ids.length > 0 && ids.length < all;
+ selectedCount.textContent = ids.length ? format(text("msgSelected"),
ids.length) : selectAllLabel;
+ deleteButton.disabled = ids.length === 0;
+ if (moveSelect) {
+ moveSelect.disabled = ids.length === 0;
+ moveButton.disabled = ids.length === 0;
+ }
+ boxes().forEach(function (box) {
+ box.closest(".link-row").classList.toggle("link-row-selected",
box.checked);
+ });
+ }
+
+ selectAll.addEventListener("change", function () {
+ boxes().forEach(function (box) {
+ box.checked = selectAll.checked;
+ });
+ selectionChanged();
+ });
+ list.addEventListener("change", function (event) {
+ if (event.target.matches(".link-select")) {
+ selectionChanged();
+ }
+ });
+
+ function openDelete(ids) {
+ toDelete = ids;
+ var names = namesOf(ids);
+ byId("delete-links-title").textContent = ids.length === 1
+ ? format(text("titleDeleteOne"), names[0])
+ : format(text("titleDeleteMany"), ids.length);
+ showErrors(byId("deleteLinksErrors"), []);
+ deleteConfirm.disabled = false;
+ deleteModal.show();
+ }
+
+ deleteButton.addEventListener("click", function () {
+ openDelete(selectedIds());
+ });
+ deleteConfirm.addEventListener("click", function () {
+ submit(byId("linksDeleteForm"), idsParam(toDelete), deleteConfirm,
byId("deleteLinksErrors"), function () {
+ reloadWith(format(toDelete.length === 1 ? text("msgDeletedOne") :
text("msgDeletedMany"), toDelete.length));
+ });
+ });
+
+ if (moveButton) {
+ moveButton.addEventListener("click", function () {
+ var ids = selectedIds();
+ var target = moveSelect.options[moveSelect.selectedIndex];
+ moveButton.disabled = true;
+ post(byId("linksMoveForm"), idsParam(ids)).done(function (html) {
+ var errors = actionErrors(html);
+ if (errors.length > 0) {
+ showToast(errors.join(" "), true);
+ moveButton.disabled = false;
+ } else {
+ // open the target blogroll, with the moved links
highlighted
+ reloadWith(format(ids.length === 1 ? text("msgMovedOne") :
text("msgMovedMany"), ids.length, target.text),
+ ids, target.value);
+ }
+ }).fail(function () {
+ showToast(text("msgError"), true);
+ moveButton.disabled = false;
+ });
+ });
+ }
+
+ // --- row actions
-----------------------------------------------------------
+
+ list.addEventListener("click", function (event) {
+ var edit = event.target.closest(".link-edit");
+ var remove = event.target.closest(".link-delete");
+ if (edit) {
+ event.preventDefault();
+ openLinkEditor(edit.closest(".link-row"));
+ } else if (remove) {
+ openDelete([remove.closest(".link-row").dataset.linkId]);
+ }
+ });
+
+ // --- 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:
Same salt issue as on the categories page: `saving` only keeps reorder posts
apart from each other. The dialogs and the move post use the same one-time
salt, so a delete or move sent while an order save is in flight (or just as the
600ms timer fires) goes out with a spent salt and gets the generic error. One
shared in-flight guard in `post()` would cover every form.
##########
app/src/main/webapp/WEB-INF/jsps/editor/Bookmarks.jsp:
##########
@@ -17,838 +17,418 @@
--%>
<%@ include file="/WEB-INF/jsps/taglibs-struts2.jsp" %>
+<%-- Blogroll: the weblog's blogrolls (bookmark folders) on the left, or in a
strip above on a
+ phone, and the links of one of them. Links are dragged (or moved with the
arrow keys or touch-screen buttons on their
+ handle) to reorder; selected links can be moved or deleted. Dialogs post
with AJAX and reload
+ the page, which then shows a short confirmation. Text the script needs is
in data-* attributes. --%>
+
+<s:url var="importURL" action="bookmarksImport">
+ <s:param name="weblog" value="%{actionWeblog.handle}"/>
+</s:url>
+
+<div id="blogrollPage" class="blogroll-page"
+ data-folder-id="<s:property value='folderId'/>"
+ data-folder-name="<s:property value='folder.name'/>"
+ data-folder-url="<s:url action='bookmarks' escapeAmp='false'><s:param
name='weblog' value='%{actionWeblog.handle}'/></s:url>"
+ data-msg-link-added="<s:text escapeHtml="true"
name='bookmarkForm.created'/>"
+ data-msg-link-saved="<s:text escapeHtml="true"
name='bookmarkForm.updated'/>"
+ data-msg-folder-added="<s:text escapeHtml="true"
name='folderForm.created'/>"
+ data-msg-folder-renamed="<s:text escapeHtml="true"
name='folderForm.updatedNamed'/>"
+ data-msg-folder-deleted="<s:text escapeHtml="true"
name='bookmarksForm.folderDeleted'/>"
+ data-msg-deleted-one="<s:text escapeHtml="true"
name='bookmarksForm.deletedOne'/>"
+ data-msg-deleted-many="<s:text escapeHtml="true"
name='bookmarksForm.deletedMany'/>"
+ data-msg-moved-one="<s:text escapeHtml="true"
name='bookmarksForm.movedOne'/>"
+ data-msg-moved-many="<s:text escapeHtml="true"
name='bookmarksForm.movedMany'/>"
+ data-msg-order-saved="<s:text escapeHtml="true"
name='bookmarksForm.orderSaved'/>"
+ data-msg-error="<s:text escapeHtml="true"
name='generic.error.check.logs'/>"
+ data-msg-name-required="<s:text escapeHtml="true"
name='bookmarkForm.nameRequired'/>"
+ data-msg-url-required="<s:text escapeHtml="true"
name='bookmarkForm.urlRequired'/>"
+ data-msg-bad-url="<s:text escapeHtml="true" name='bookmarkForm.badUrl'/>"
+ data-msg-folder-name-required="<s:text escapeHtml="true"
name='bookmarksForm.blogroll.requiredFields'/>"
+ data-msg-selected="<s:text escapeHtml="true"
name='bookmarksForm.selectedCount'/>"
+ data-title-add-link="<s:text escapeHtml="true"
name='bookmarksForm.addLink.title'/>"
+ data-title-edit-link="<s:text escapeHtml="true"
name='bookmarksForm.editLink.title'/>"
+ data-title-add-folder="<s:text escapeHtml="true"
name='bookmarksForm.addBlogroll.title'/>"
+ data-title-rename-folder="<s:text escapeHtml="true"
name='bookmarksForm.editBlogroll.title'/>"
+ data-title-delete-one="<s:text escapeHtml="true"
name='bookmarksForm.deleteOne.title'/>"
+ data-title-delete-many="<s:text escapeHtml="true"
name='bookmarksForm.deleteMany.title'/>">
+
+<div class="blogroll-header">
+ <p class="subtitle mb-0">
+ <s:text name="bookmarksForm.subtitle">
+ <s:param value="weblog"/>
+ </s:text>
+ </p>
+ <div class="blogroll-header-actions">
+ <a class="btn btn-outline-secondary" href="<s:property
value='%{importURL}'/>">
+ <span class="bi bi-upload" aria-hidden="true"></span>
+ <s:text escapeHtml="true" name="bookmarksForm.importBookmarks"/>
+ </a>
+ <button type="button" id="addFolderButton" class="btn
btn-outline-primary">
+ <span class="bi bi-folder-plus" aria-hidden="true"></span>
+ <s:text escapeHtml="true" name="bookmarksForm.addFolder"/>
+ </button>
+ </div>
+</div>
-<%--
-Blogroll link management
-
-We used to call them Bookmarks and Folders, now we call them Blogroll links
and Blogrolls.
---%>
-
-<%--
================================================================================================
--%>
-
-<%-- Main blogroll/folder management interface, a checkbox-table with some
buttons --%>
-
-<p class="subtitle"><s:text name="bookmarksForm.subtitle"> <s:param
value="weblog"/> </s:text></p>
-
-<s:if test="folder.name == 'default'">
- <p class="pagetip"><s:text name="bookmarksForm.rootPrompt"/></p>
-</s:if>
-<s:else>
- <p class="pagetip"><s:text name="bookmarksForm.otherPrompt"/></p>
-</s:else>
-
-
-<%-- table of blogroll links with selection checkboxes, wrapped in a form --%>
-
-<s:form action="bookmarks!delete" theme="bootstrap" cssClass="form-horizontal">
- <s:hidden name="salt"/>
- <s:hidden name="weblog"/>
- <s:hidden name="folderId"/>
-
- <%-- for default blogroll, show page "tip" and read-only folder name --%>
-
- <s:if test="folder.name == 'default'">
-
- <div class="row mb-3">
- <label class="col-sm-3 col-form-label" for="bookmarks_folder_name">
- <s:text name="bookmarksForm.blogrollName"/>
- </label>
- <div class="col-sm-9 controls">
- <div class="form-control"><s:property
value="folder.name"/></div>
+<div class="blogroll-layout">
+
+ <%-- the blogrolls; a GET link each, so a blogroll can be reloaded,
bookmarked and reached with Back --%>
+ <nav class="blogroll-nav" aria-label="<s:text escapeHtml="true"
name='bookmarksForm.folders'/>">
+ <ul class="blogroll-folders">
+ <s:iterator var="f" value="allFolders">
+ <s:url var="folderURL" action="bookmarks" escapeAmp="false">
+ <s:param name="weblog" value="%{actionWeblog.handle}"/>
+ <s:param name="folderId" value="%{#f.id}"/>
+ </s:url>
+ <li>
+ <a href="<s:property value='%{folderURL}'/>"
+ class="blogroll-folder<s:if test='#f.id == folderId'>
active</s:if>"
+ <s:if test="#f.id ==
folderId">aria-current="page"</s:if>>
+ <span class="blogroll-folder-name">
+ <s:if test="#f.name == 'default'"><span class="bi
bi-star" aria-hidden="true"></span></s:if>
+ <s:property value="#f.name"/>
+ </span>
+ <span class="blogroll-folder-count"><s:property
value="linkCount(#f)"/></span>
+ </a>
+ </li>
+ </s:iterator>
+ </ul>
+ </nav>
+
+ <section class="blogroll-main" aria-labelledby="blogrollTitle">
+
+ <div class="blogroll-main-header">
+ <div class="blogroll-title-block">
+ <h3 id="blogrollTitle" class="blogroll-title"><s:property
value="folder.name"/></h3>
+ <span class="blogroll-meta">
+ <s:text escapeHtml="true"
name="bookmarksForm.linkCount"><s:param value="links.size()"/></s:text>
+ <s:if test="defaultFolder">
+ · <s:text escapeHtml="true"
name="bookmarksForm.defaultNote"/>
+ </s:if>
+ </span>
</div>
- </div>
-
- </s:if>
-
- <s:if test="folder.name != 'default'">
-
- <%-- Blogroll / Folder Name --%>
-
- <%-- for other blogrolls, show textarea so user can rename it --%>
- <%-- don't use Struts tags here so button can be on same line as text
input --%>
-
- <div class="row mb-3">
- <label class="col-sm-3 col-form-label" for="bookmarks_folder_name">
- <s:text name="bookmarksForm.blogrollName"/>
- </label>
- <div class="col-sm-9 controls">
- <div style="display:flex; gap:1em;">
- <input type="text" name="folder.name" style="flex:1;"
- value="<s:property value='folder.name'/>"
id="bookmarks_folder_name" class="form-control"
- onchange="nameChanged()"
- onkeyup="nameChanged()"/>
- <button type="button" id="rename_button" class="btn
btn-success"
- onclick="renameFolder(); return false;"
- onsubmit="return false;">
- <s:text name="generic.rename"/>
+ <div class="blogroll-main-actions">
+ <button type="button" id="addLinkButton" class="btn
btn-primary">
+ <span class="bi bi-plus-lg" aria-hidden="true"></span>
+ <s:text escapeHtml="true"
name="bookmarksForm.addBookmark"/>
+ </button>
+ <s:if test="!defaultFolder">
+ <button type="button" id="renameFolderButton" class="btn
btn-outline-secondary">
+ <span class="bi bi-pencil" aria-hidden="true"></span>
+ <s:text escapeHtml="true" name="generic.rename"/>
</button>
- <button type="button" id="rename_cancel" class="btn
btn-outline-secondary"
- onclick="cancelRenameFolder(); return false;"
- onsubmit="return false;">
- <s:text name="generic.cancel"/>
+ <button type="button" id="deleteFolderButton" class="btn
btn-outline-danger">
+ <span class="bi bi-trash" aria-hidden="true"></span>
+ <s:text escapeHtml="true"
name="bookmarksForm.deleteFolder"/>
</button>
- </div>
+ </s:if>
</div>
</div>
- </s:if>
+ <p class="pagetip" id="blogrollTip">
+ <s:if test="defaultFolder"><s:text escapeHtml="true"
name="bookmarksForm.rootPrompt"/></s:if>
+ <s:else><s:text escapeHtml="true"
name="bookmarksForm.otherPrompt"/></s:else>
+ </p>
- <%-- allow user to select the bookmark folder to view --%>
+ <%-- carries the salt for the reorder post; the link ids are added by
the script --%>
+ <s:form id="linksReorderForm" action="bookmarks!reorder"
theme="simple">
+ <s:hidden name="salt"/>
+ <s:hidden name="weblog"/>
+ <s:hidden name="folderId"/>
+ </s:form>
- <div class="form-group ">
- <label class="col-sm-3 control-label" for="bookmarks_viewFolderId">
- <s:text name="bookmarksForm.switchTo"/>
- </label>
- <div class="col-sm-9 controls">
- <s:select name="viewFolderId" list="allFolders" listKey="id"
listValue="name" emptyOption="true"
- theme="simple" cssClass="form-control"
onchange="viewChanged()"/>
- </div>
- </div>
+ <s:if test="!links.isEmpty()">
- <table class="rollertable table table-striped">
-
- <tr class="rHeaderTr">
- <th width="5%">
- <input name="control" type="checkbox"
- onclick="toggleFunctionAll(this.checked);
selectionChanged()"
- title="<s:text name="bookmarksForm.selectAllLabel"/>"/>
- </th>
- <th class="rollertable" width="25%"><s:text
name="generic.name"/></th>
- <th class="rollertable" width="70%"><s:text
name="bookmarksForm.url"/></th>
- <th class="rollertable" width="5%"><s:text
name="generic.edit"/></th>
- </tr>
-
- <s:if test="folder.bookmarks.size > 0">
-
- <%-- Bookmarks --%>
- <s:iterator var="bookmark" value="folder.bookmarks"
status="rowstatus">
- <tr class="rollertable_odd">
-
- <td class="rollertable center"
style="vertical-align:middle">
- <input type="checkbox" name="selectedBookmarks"
onchange="selectionChanged()"
- title="<s:text
name="bookmarksForm.selectOneLabel"><s:param value="#bookmark.name"/></s:text>"
- value="<s:property value="#bookmark.id"/>"/>
- </td>
-
- <td>
- <str:truncateNicely lower="40" upper="50">
- <s:property value="#bookmark.name"/>
- </str:truncateNicely>
- </td>
-
- <td>
- <s:if test="#bookmark.url != null">
- <a href="<s:property value="#bookmark.url" />"
target="_blank">
- <str:truncateNicely lower="70" upper="90">
- <s:property value="#bookmark.url"/>
- </str:truncateNicely>
- <span class="bi bi-play-circle"></span>
+ <%-- move and delete the selected links; the ids are added by the
script --%>
+ <s:form id="linksMoveForm" action="bookmarks!move" theme="simple"
cssClass="blogroll-bulkbar">
+ <s:hidden name="salt"/>
+ <s:hidden name="weblog"/>
+ <s:hidden name="folderId"/>
+ <div class="form-check mb-0">
+ <input type="checkbox" class="form-check-input"
id="selectAllLinks">
+ <label class="form-check-label" for="selectAllLinks"
id="selectedCount"><s:text escapeHtml="true"
name="bookmarksForm.selectAllLabel"/></label>
+ </div>
+ <div class="blogroll-bulk-actions">
+ <s:if test="!moveTargets.isEmpty()">
+ <div class="input-group input-group-sm blogroll-move">
+ <label class="input-group-text"
for="linksMoveForm_targetFolderId"><s:text escapeHtml="true"
name="bookmarksForm.moveTo"/></label>
+ <s:select name="targetFolderId"
id="linksMoveForm_targetFolderId" list="moveTargets"
+ listKey="id" listValue="name"
cssClass="form-select" disabled="true"/>
+ <button type="button" id="moveLinksButton"
class="btn btn-outline-secondary" disabled>
+ <s:text escapeHtml="true"
name="bookmarksForm.move"/>
+ </button>
+ </div>
+ </s:if>
+ <button type="button" id="deleteLinksButton" class="btn
btn-sm btn-outline-danger" disabled>
+ <span class="bi bi-trash" aria-hidden="true"></span>
+ <s:text escapeHtml="true" name="bookmarksForm.delete"/>
+ </button>
+ </div>
+ </s:form>
+
+ <ul id="linkList" class="link-list" aria-describedby="blogrollTip">
+ <s:iterator var="bookmark" value="links">
+ <li class="link-row"
+ data-link-id="<s:property value='#bookmark.id'/>"
+ data-link-name="<s:property
value='plainText(#bookmark.name)'/>"
+ data-link-url="<s:property value='#bookmark.url'/>"
+ data-link-feed="<s:property
value='#bookmark.feedUrl'/>"
+ data-link-desc="<s:property
value='plainText(#bookmark.description)'/>"
Review Comment:
The edit dialog is filled from these attributes (`linkDesc.value =
data.linkDesc`), and `plainText()` strips tags and unescapes entities. So
opening Edit to change only the URL and saving rewrites the stored name and
description without their markup: a description stored as `<b>Great</b> blog`
comes back as `Great blog`. Plain text is right for display, but the dialog
probably needs the stored value so an unrelated edit doesn't change it.
--
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]