snoopdave commented on code in PR #246:
URL: https://github.com/apache/roller/pull/246#discussion_r4569389571


##########
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:
   🤖Claude: fixed in 7d910483d: the fallback now applies only when the request 
names no folder. If a requested blogroll is gone, `folder` stays null, so 
`delete`, `move`, `reorder` and `deleteFolder` report not found. `execute()` 
shows the fallback. Tests cover `deleteFolder` and `delete` with a stale id.



##########
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:
   🤖Claude: fixed in 7d910483d: `move()` checks every selected link against the 
target (and against each other) before it changes anything, and fails with 
`bookmarksForm.error.moveDuplicate` if a name would be taken twice. A test 
covers it.



##########
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">
+                        &middot; <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:
   🤖Claude: fixed in 7d910483d: the rows carry the stored name and description 
as `data-link-stored-*`, and the edit dialog uses them. The display text stays 
plain.



##########
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:
   🤖Claude: fixed in 7d910483d: the blogroll list, title, move list and delete 
dialog use `plainText(...)`. `FolderEdit.myValidate` now sanitizes the name 
before it compares, as `BookmarkEdit` does.



##########
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:
   🤖Claude: fixed in 7d910483d: `post()` sends all posts one at a time through 
one queue, so the dialogs, move and order saves can't spend the same salt.



##########
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:
   🤖Claude: fixed in 7d910483d: `saveLink` and `saveFolder` return early while 
a post is in flight (a busy flag on the form, since validation re-enables the 
button), and Enter is ignored while `event.isComposing`.



##########
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:
   🤖Claude: fixed in 7d910483d: `reloadWith` uses the current folder id only 
when none is passed, so `""` after a delete now means the default view.



##########
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:
   🤖Claude: fixed in 7d910483d: `post()` treats a 200 response without 
`id="blogrollPage"` as a failure, unless it carries action errors to show. I 
did not test an expired session.



-- 
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]

Reply via email to