From 3d52ef99d912a2ddd90be090ea73bacb37a2bd9a Mon Sep 17 00:00:00 2001 From: vagisha Date: Wed, 2 Apr 2025 15:17:33 -0700 Subject: [PATCH 01/12] Post message to Bluesky. First iteration --- .../PanoramaPublicController.java | 289 ++++++++++++++++++ .../bluesky/BlueskyException.java | 50 +++ .../bluesky/BlueskyResponse.java | 55 ++++ .../TargetedMSExperimentWebPart.java | 1 + .../view/manageBlueskyCredentials.jsp | 32 ++ 5 files changed, 427 insertions(+) create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 6f55dd0d..a33795f6 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -139,6 +139,8 @@ import org.labkey.api.view.template.ClientDependency; import org.labkey.api.wiki.WikiRendererType; import org.labkey.api.wiki.WikiRenderingService; +import org.labkey.panoramapublic.bluesky.BlueskyException; +import org.labkey.panoramapublic.bluesky.BlueskyService; import org.labkey.panoramapublic.catalog.CatalogEntrySettings; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentParent; import org.labkey.panoramapublic.chromlib.ChromLibStateManager; @@ -302,6 +304,7 @@ public ModelAndView getView(Object o, BindException errors) view.addView(qView); view.addView(getPXCredentialsLink()); view.addView(getDataCiteCredentialsLink()); + view.addView(getBlueskyCredentialsLink()); view.addView(getPanoramaPublicCatalogSettingsLink()); view.addView(getPostSupportMessageLink()); view.setFrame(WebPartView.FrameType.PORTAL); @@ -323,6 +326,13 @@ private ModelAndView getDataCiteCredentialsLink() new Link.LinkBuilder("Set DataCite Credentials").href(url).build())); } + private ModelAndView getBlueskyCredentialsLink() + { + ActionURL url = new ActionURL(ManageBlueskyCredentials.class, getContainer()); + return new HtmlView(DIV(at(style, "margin-top:20px;"), + new Link.LinkBuilder("Set Bluesky Credentials").href(url).build())); + } + private ModelAndView getPanoramaPublicCatalogSettingsLink() { ActionURL url = new ActionURL(ManageCatalogEntrySettings.class, getContainer()); @@ -1209,6 +1219,111 @@ public void setPassword(String password) } } + @RequiresPermission(AdminOperationsPermission.class) + public static class ManageBlueskyCredentials extends FormViewAction + { + @Override + public void validateCommand(BlueskyCredentialsForm form, Errors errors) + { + String user = form.getUserName(); + String password = form.getPassword(); + + if (StringUtils.isBlank(user)) + { + errors.reject(ERROR_MSG, "User name cannot be blank"); + } + if (StringUtils.isBlank(password)) + { + errors.reject(ERROR_MSG, "Password cannot be blank"); + } + } + + @Override + public boolean handlePost(BlueskyCredentialsForm form, BindException errors) + { + try + { + new BlueskyService().login(form.getUserName(), form.getPassword()); + } + catch (BlueskyException e) + { + errors.reject(ERROR_MSG, "Bluesky login failed with the provided credentials. " + e.getMessage()); + return false; + } + WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, true); + map.put(BlueskyService.USER, form.getUserName()); + map.put(BlueskyService.PASSWORD, form.getPassword()); + map.save(); + return true; + } + + @Override + public URLHelper getSuccessURL(BlueskyCredentialsForm form) + { + return null; + } + + @Override + public ModelAndView getSuccessView(BlueskyCredentialsForm form) + { + ActionURL adminUrl = new ActionURL(PanoramaPublicAdminViewAction.class, getContainer()); + return new HtmlView( + DIV("Bluesky credentials saved!", + BR(), + new Link.LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); + } + + @Override + public ModelAndView getView(BlueskyCredentialsForm form, boolean reshow, BindException errors) + { + if(!reshow) + { + WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, false); + if(map != null) + { + form.setUserName(map.get(BlueskyService.USER)); + } + } + JspView view = new JspView<>("/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp", form, errors); + view.setFrame(WebPartView.FrameType.PORTAL); + view.setTitle("Bluesky Credentials"); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + addPanoramaPublicAdminConsoleNav(root, getContainer()); + root.addChild("Set Bluesky Credentials"); + } + } + + public static class BlueskyCredentialsForm + { + private String _userName; + private String _password; + + public String getUserName() + { + return _userName; + } + + public void setUserName(String userName) + { + _userName = userName; + } + + public String getPassword() + { + return _password; + } + + public void setPassword(String password) + { + _password = password; + } + } + @RequiresPermission(AdminOperationsPermission.class) public static class ManageCatalogEntrySettings extends FormViewAction { @@ -5214,6 +5329,180 @@ public void setDoi(String doi) // END Actions for DataCite DOI assignment // ------------------------------------------------------------------------ + // ------------------------------------------------------------------------ + // BEGIN Actions for posting to Bluesky + // ------------------------------------------------------------------------ + @RequiresPermission(AdminOperationsPermission.class) + public static class BlueskyAction extends ConfirmAction + { + private ExperimentAnnotations _expAnnot; + private BlueskyException _exception; + private String _blueSkyPostUrl; + + @Override + public void validateCommand(ExperimentIdForm form, Errors errors) + { + _expAnnot = form.lookupExperiment(); + if(_expAnnot == null) + { + errors.reject(ERROR_MSG, "Cannot find experiment with ID " + form.getId()); + return; + } + ensureCorrectContainer(getContainer(), _expAnnot.getContainer(), getViewContext()); + if(!_expAnnot.isJournalCopy()) + { + errors.reject(ERROR_MSG, "Posting to Bluesky is only allowed on experiments in the Panorama Public project"); + return; + } + + if (_expAnnot.getShortUrl() == null) + { + errors.reject(ERROR_MSG, "Experiment id " + _expAnnot.getId() + " does not have a short URL. It cannot be posted to Bluesky."); + return; + } + + JournalSubmission js = SubmissionManager.getSubmissionForJournalCopy(_expAnnot); + if (js == null) + { + errors.reject(ERROR_MSG, "Cannot find a submission request for copied experiement Id " + _expAnnot.getId()); + return; + } + if (!js.isLatestExperimentCopy(_expAnnot.getId())) + { + errors.reject(ERROR_MSG, "Experiment id " + _expAnnot.getId() + " is not the last copied submission. " + + getActionName(this.getClass()) + " is only allowed in the last copy of the submitted data"); + } + } + + @Override + public boolean handlePost(ExperimentIdForm form, BindException errors) + { + WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, false); + String user = null; + String password = null; + if(map != null) + { + user = map.get(BlueskyService.USER); + password = map.get(BlueskyService.PASSWORD); + } + if(StringUtils.isBlank(user)) + { + errors.reject(ERROR_MSG, "Cannot find Bluesky username."); + } + if(StringUtils.isBlank(password)) + { + errors.reject(ERROR_MSG, "Cannot find Bluesky password."); + } + if(errors.getErrorCount() > 0) + { + return false; + } + + try + { + // Post to Bluesky + BlueskyService svc = new BlueskyService(); + svc.login(user, password); + _blueSkyPostUrl = svc.createBlueskyPost(_expAnnot); + + if (_blueSkyPostUrl != null) + { + WritablePropertyMap propertyMap = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, true); + propertyMap.put(_expAnnot.getShortUrl().renderShortURL(), _blueSkyPostUrl); + propertyMap.save(); + } + else + { + throw new BlueskyException("The post URL was not included in the response from Bluesky"); + } + } + catch (BlueskyException e) + { + _exception = e; + LOG.error(e); + return false; + } + return true; + } + + @Override + public @NotNull URLHelper getSuccessURL(ExperimentIdForm form) + { + return null; + } + + @Override + public ModelAndView getConfirmView(ExperimentIdForm form, BindException errors) + { + WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, false); + + Renderable announcementDiv = DIV(cl("bluebox").at(style, "padding:25px;"), + DIV(BlueskyService.ANNOUNCEMENT_TEXT), + DIV(at(style, "font-weight:bold;"), _expAnnot.getTitle()), + DIV(new Link.LinkBuilder(_expAnnot.getShortUrl().renderShortURL()) + .href(_expAnnot.getShortUrl().renderShortURL()) + .clearClasses() + .build()) + ); + String blueskyPostUrl = map != null ? map.get(_expAnnot.getShortUrl().renderShortURL()) : null; + if (blueskyPostUrl != null) + { + Link blueskyLink = new Link.LinkBuilder((blueskyPostUrl)) + .href(BlueskyService.convertToClickableUrl(blueskyPostUrl)) + .clearClasses() + .build(); + return new HtmlView( + DIV("This data has already been announced on Bluesky at ", + blueskyLink, + ". Would you like to post the following message again? ", + BR(), + announcementDiv + )); + } + else + { + return new HtmlView( + DIV("The following message will be posted to Bluesky: ", + BR(), + announcementDiv, + BR(), + DIV("Are you sure you want to continue?"))); + } + } + @Override + public ModelAndView getSuccessView(ExperimentIdForm form) + { + Link blueskyLink = new Link.LinkBuilder((_blueSkyPostUrl)) + .href(BlueskyService.convertToClickableUrl(_blueSkyPostUrl)) + .clearClasses() + .build(); + + return new HtmlView( + DIV("Posted to Bluesky!", + blueskyLink, + BR(), + DIV(new Link.LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); + } + + @Override + public ModelAndView getFailView(ExperimentIdForm form, BindException errors) + { + if(_exception != null) + { + return new HtmlView(_exception.getHtmlString()); + } + else + { + return super.getFailView(form, errors); + } + } + } + + // ------------------------------------------------------------------------ + // END Actions for posting to Bluesky + // ------------------------------------------------------------------------ + + // ------------------------------------------------------------------------ // BEGIN Experiment annotation actions // ------------------------------------------------------------------------ diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java new file mode 100644 index 00000000..8ebf5153 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java @@ -0,0 +1,50 @@ +package org.labkey.panoramapublic.bluesky; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.HtmlStringBuilder; + +public class BlueskyException extends Exception +{ + private BlueskyResponse _response; + + public BlueskyException(String message) + { + super(message); + } + + public BlueskyException(String message, Throwable cause) + { + super(message, cause); + } + + public BlueskyException(@NotNull String message, @NotNull BlueskyResponse response) + { + super("Request failed - " + message + ". Code: " + response.getResponseCode() + "; Message " + response.getMessage() + "; Body: " + response.getResponseBody()); + _response = response; + } + + public HtmlString getHtmlString() + { + if(_response != null) + { + return HtmlStringBuilder.of(HtmlString.unsafe("
")) + .append("Response code: ").append(_response.getResponseCode()) + .append(HtmlString.BR) + .append("Message: ").append(_response.getMessage()) + .append(HtmlString.BR) + .append(_response.getResponseBody()) + .append(HtmlString.BR).append(HtmlString.BR) + .append("Exception: ") + .append(HtmlString.BR) + .append(ExceptionUtil.renderException(this)) + .append(HtmlString.unsafe("
")) + .getHtmlString(); + } + else + { + return ExceptionUtil.renderException(this); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java new file mode 100644 index 00000000..eb20d3fe --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java @@ -0,0 +1,55 @@ +package org.labkey.panoramapublic.bluesky; + +import org.json.JSONException; +import org.json.JSONObject; +import org.labkey.panoramapublic.datacite.DataCiteException; +import org.labkey.panoramapublic.datacite.DataCiteService; +import org.labkey.panoramapublic.datacite.Doi; + +import java.net.HttpURLConnection; + +class BlueskyResponse +{ + private final int _responseCode; + private final String _message; + private final String _responseBody; + + public BlueskyResponse(int responseCode, String message, String responseBody) + { + _responseCode = responseCode; + _message = message; + _responseBody = responseBody; + } + + public int getResponseCode() + { + return _responseCode; + } + + public String getMessage() + { + return _message; + } + + public String getResponseBody() + { + return _responseBody; + } + + public boolean success() + { + return _responseCode == HttpURLConnection.HTTP_OK || _responseCode == HttpURLConnection.HTTP_CREATED; + } + + private JSONObject getJsonObject(String response) throws DataCiteException + { + try + { + return new JSONObject(response); + } + catch (JSONException e) + { + throw new DataCiteException("Error parsing JSON from response: " + response); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java b/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java index f78f6866..6c2e8cb3 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java +++ b/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java @@ -84,6 +84,7 @@ else if(expAnnotations.getContainer().equals(container)) navTree.addChild("Data Validation", new ActionURL(PanoramaPublicController.ViewPxValidationsAction.class, container).addParameter("id", expAnnotations.getId())); navTree.addChild("DOI", new ActionURL(PanoramaPublicController.DoiOptionsAction.class, container).addParameter("id", expAnnotations.getId())); navTree.addChild("Make Data Public", new ActionURL(PanoramaPublicController.MakePublicAction.class, container).addParameter("id", expAnnotations.getId())); + navTree.addChild("Post to Bluesky", new ActionURL(PanoramaPublicController.BlueskyAction.class, container).addParameter("id", expAnnotations.getId())); JournalSubmission submission = SubmissionManager.getSubmissionForExperiment(expAnnotations); if (submission != null) diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp new file mode 100644 index 00000000..89634cc7 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp @@ -0,0 +1,32 @@ +<%@ page import="org.labkey.api.view.ActionURL" %> +<%@ page import="org.labkey.api.view.HttpView" %> +<%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PanoramaPublicAdminViewAction" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.BlueskyCredentialsForm" %> +<%@ page extends="org.labkey.api.jsp.FormPage" %> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> + +<% + BlueskyCredentialsForm form = ((JspView) HttpView.currentView()).getModelBean(); + ActionURL panoramaPublicAdminUrl = urlFor(PanoramaPublicAdminViewAction.class); +%> +

+ + + + + + + + + + + + + + + +
User:
Password:
<%=button("Save Credentials").submit(true)%><%=button("Cancel").href(panoramaPublicAdminUrl)%>
+ +
+

From d191cd6823d39dfa551f6554724d0b8e379a4a2b Mon Sep 17 00:00:00 2001 From: vagisha Date: Mon, 7 Apr 2025 18:12:45 -0700 Subject: [PATCH 02/12] - Save a test Bluesky account - Add hashtags (different hash tags when posting to test account) - Added checkbox in ConfirmView to "Post to test account" --- .../PanoramaPublicController.java | 101 ++++++++++++++---- .../view/manageBlueskyCredentials.jsp | 9 ++ 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index a33795f6..00175a30 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -246,6 +246,7 @@ import static org.labkey.api.util.DOM.Attribute.type; import static org.labkey.api.util.DOM.Attribute.valign; import static org.labkey.api.util.DOM.Attribute.value; +import static org.labkey.api.util.DOM.LK.CHECKBOX; import static org.labkey.api.util.DOM.LK.ERRORS; import static org.labkey.api.util.DOM.LK.FORM; import static org.labkey.panoramapublic.proteomexchange.NcbiUtils.PUBMED_ID; @@ -1225,17 +1226,22 @@ public static class ManageBlueskyCredentials extends FormViewAction("/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp", form, errors); @@ -1303,6 +1323,9 @@ public static class BlueskyCredentialsForm private String _userName; private String _password; + private String _testAccountUser; + private String _testAccountPassword; + public String getUserName() { return _userName; @@ -1322,6 +1345,26 @@ public void setPassword(String password) { _password = password; } + + public String getTestAccountUser() + { + return _testAccountUser; + } + + public void setTestAccountUser(String testAccountUser) + { + _testAccountUser = testAccountUser; + } + + public String getTestAccountPassword() + { + return _testAccountPassword; + } + + public void setTestAccountPassword(String testAccountPassword) + { + _testAccountPassword = testAccountPassword; + } } @RequiresPermission(AdminOperationsPermission.class) @@ -5333,14 +5376,14 @@ public void setDoi(String doi) // BEGIN Actions for posting to Bluesky // ------------------------------------------------------------------------ @RequiresPermission(AdminOperationsPermission.class) - public static class BlueskyAction extends ConfirmAction + public static class BlueskyAction extends ConfirmAction { private ExperimentAnnotations _expAnnot; private BlueskyException _exception; private String _blueSkyPostUrl; @Override - public void validateCommand(ExperimentIdForm form, Errors errors) + public void validateCommand(BlueskyForm form, Errors errors) { _expAnnot = form.lookupExperiment(); if(_expAnnot == null) @@ -5375,23 +5418,23 @@ public void validateCommand(ExperimentIdForm form, Errors errors) } @Override - public boolean handlePost(ExperimentIdForm form, BindException errors) + public boolean handlePost(BlueskyForm form, BindException errors) { WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, false); String user = null; String password = null; if(map != null) { - user = map.get(BlueskyService.USER); - password = map.get(BlueskyService.PASSWORD); + user = form.isTestAccount() ? map.get(BlueskyService.TEST_USER) : map.get(BlueskyService.USER); + password = form.isTestAccount() ? map.get(BlueskyService.TEST_PASSWORD) : map.get(BlueskyService.PASSWORD); } if(StringUtils.isBlank(user)) { - errors.reject(ERROR_MSG, "Cannot find Bluesky username."); + errors.reject(ERROR_MSG, String.format("Cannot find username for Bluesky %saccount.", form.isTestAccount() ? "test " : "")); } if(StringUtils.isBlank(password)) { - errors.reject(ERROR_MSG, "Cannot find Bluesky password."); + errors.reject(ERROR_MSG, String.format("Cannot find password for Bluesky %saccount.", form.isTestAccount() ? "test " : "")); } if(errors.getErrorCount() > 0) { @@ -5403,7 +5446,7 @@ public boolean handlePost(ExperimentIdForm form, BindException errors) // Post to Bluesky BlueskyService svc = new BlueskyService(); svc.login(user, password); - _blueSkyPostUrl = svc.createBlueskyPost(_expAnnot); + _blueSkyPostUrl = svc.createBlueskyPost(_expAnnot, form.isTestAccount()); if (_blueSkyPostUrl != null) { @@ -5426,13 +5469,13 @@ public boolean handlePost(ExperimentIdForm form, BindException errors) } @Override - public @NotNull URLHelper getSuccessURL(ExperimentIdForm form) + public @NotNull URLHelper getSuccessURL(BlueskyForm form) { return null; } @Override - public ModelAndView getConfirmView(ExperimentIdForm form, BindException errors) + public ModelAndView getConfirmView(BlueskyForm form, BindException errors) { WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, false); @@ -5444,6 +5487,7 @@ public ModelAndView getConfirmView(ExperimentIdForm form, BindException errors) .clearClasses() .build()) ); + Renderable testPostCb = DIV(at(style, "margin-top:20px;"),CHECKBOX(at(name,"testAccount")), "Post to test account"); String blueskyPostUrl = map != null ? map.get(_expAnnot.getShortUrl().renderShortURL()) : null; if (blueskyPostUrl != null) { @@ -5455,22 +5499,21 @@ public ModelAndView getConfirmView(ExperimentIdForm form, BindException errors) DIV("This data has already been announced on Bluesky at ", blueskyLink, ". Would you like to post the following message again? ", - BR(), - announcementDiv + announcementDiv, + testPostCb )); } else { return new HtmlView( DIV("The following message will be posted to Bluesky: ", - BR(), announcementDiv, - BR(), + testPostCb, DIV("Are you sure you want to continue?"))); } } @Override - public ModelAndView getSuccessView(ExperimentIdForm form) + public ModelAndView getSuccessView(BlueskyForm form) { Link blueskyLink = new Link.LinkBuilder((_blueSkyPostUrl)) .href(BlueskyService.convertToClickableUrl(_blueSkyPostUrl)) @@ -5485,7 +5528,7 @@ public ModelAndView getSuccessView(ExperimentIdForm form) } @Override - public ModelAndView getFailView(ExperimentIdForm form, BindException errors) + public ModelAndView getFailView(BlueskyForm form, BindException errors) { if(_exception != null) { @@ -5498,6 +5541,20 @@ public ModelAndView getFailView(ExperimentIdForm form, BindException errors) } } + public static class BlueskyForm extends ExperimentIdForm + { + private boolean _testAccount; + + public boolean isTestAccount() + { + return _testAccount; + } + + public void setTestAccount(boolean testAccount) + { + _testAccount = testAccount; + } + } // ------------------------------------------------------------------------ // END Actions for posting to Bluesky // ------------------------------------------------------------------------ diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp index 89634cc7..1415e3dc 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp @@ -22,6 +22,15 @@ + + User (test account): + + + + Password (test account): + + + <%=button("Save Credentials").submit(true)%> <%=button("Cancel").href(panoramaPublicAdminUrl)%> From 597e30117d7da38ae3ce1a45e8aeaee843192edc Mon Sep 17 00:00:00 2001 From: vagisha Date: Wed, 9 Apr 2025 15:32:37 -0700 Subject: [PATCH 03/12] - Added missing file - BlueskyService - Added option in Bluesky settings to save Panorama log file. --- .../PanoramaPublicController.java | 108 +++- .../bluesky/BlueskyService.java | 470 ++++++++++++++++++ .../PanoramaPublicLogoAttachmentParent.java | 38 ++ .../bluesky/PanoramaPublicLogoManager.java | 70 +++ .../PanoramaPublicLogoResourceType.java | 36 ++ .../view/manageBlueskyCredentials.jsp | 28 +- 6 files changed, 732 insertions(+), 18 deletions(-) create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 00175a30..2ae1f544 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -130,6 +130,8 @@ import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; import org.labkey.api.util.Link; +import org.labkey.api.util.MimeMap; +import org.labkey.api.util.MimeMap.MimeType; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; import org.labkey.api.util.TestContext; @@ -141,6 +143,8 @@ import org.labkey.api.wiki.WikiRenderingService; import org.labkey.panoramapublic.bluesky.BlueskyException; import org.labkey.panoramapublic.bluesky.BlueskyService; +import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoAttachmentParent; +import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoManager; import org.labkey.panoramapublic.catalog.CatalogEntrySettings; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentParent; import org.labkey.panoramapublic.chromlib.ChromLibStateManager; @@ -305,7 +309,7 @@ public ModelAndView getView(Object o, BindException errors) view.addView(qView); view.addView(getPXCredentialsLink()); view.addView(getDataCiteCredentialsLink()); - view.addView(getBlueskyCredentialsLink()); + view.addView(getBlueskySettingsLink()); view.addView(getPanoramaPublicCatalogSettingsLink()); view.addView(getPostSupportMessageLink()); view.setFrame(WebPartView.FrameType.PORTAL); @@ -327,11 +331,11 @@ private ModelAndView getDataCiteCredentialsLink() new Link.LinkBuilder("Set DataCite Credentials").href(url).build())); } - private ModelAndView getBlueskyCredentialsLink() + private ModelAndView getBlueskySettingsLink() { - ActionURL url = new ActionURL(ManageBlueskyCredentials.class, getContainer()); + ActionURL url = new ActionURL(ManageBlueskySettings.class, getContainer()); return new HtmlView(DIV(at(style, "margin-top:20px;"), - new Link.LinkBuilder("Set Bluesky Credentials").href(url).build())); + new Link.LinkBuilder("Bluesky Settings").href(url).build())); } private ModelAndView getPanoramaPublicCatalogSettingsLink() @@ -1221,10 +1225,10 @@ public void setPassword(String password) } @RequiresPermission(AdminOperationsPermission.class) - public static class ManageBlueskyCredentials extends FormViewAction + public static class ManageBlueskySettings extends FormViewAction { @Override - public void validateCommand(BlueskyCredentialsForm form, Errors errors) + public void validateCommand(BlueskySettingsForm form, Errors errors) { if (StringUtils.isBlank(form.getUserName())) { @@ -1245,7 +1249,7 @@ public void validateCommand(BlueskyCredentialsForm form, Errors errors) } @Override - public boolean handlePost(BlueskyCredentialsForm form, BindException errors) + public boolean handlePost(BlueskySettingsForm form, BindException errors) { try { @@ -1273,27 +1277,48 @@ public boolean handlePost(BlueskyCredentialsForm form, BindException errors) map.put(BlueskyService.TEST_USER, form.getUserName()); map.put(BlueskyService.TEST_PASSWORD, form.getPassword()); map.save(); + + List files = getAttachmentFileList(); + AttachmentFile panoramaLogoFile = files.stream().findFirst().orElse(null); + if (panoramaLogoFile != null) + { + MimeType mimeType = new MimeType(panoramaLogoFile.getContentType()); + if (!mimeType.isImage()) + { + errors.reject(ERROR_MSG, "Logo is not an image file"); + return false; + } + try + { + PanoramaPublicLogoManager.saveNewDataLogo(panoramaLogoFile, getUser()); + } + catch (IOException e) + { + errors.reject(ERROR_MSG, "Unable to save logo file. " + e.getMessage()); + return false; + } + } return true; } @Override - public URLHelper getSuccessURL(BlueskyCredentialsForm form) + public URLHelper getSuccessURL(BlueskySettingsForm form) { return null; } @Override - public ModelAndView getSuccessView(BlueskyCredentialsForm form) + public ModelAndView getSuccessView(BlueskySettingsForm form) { ActionURL adminUrl = new ActionURL(PanoramaPublicAdminViewAction.class, getContainer()); return new HtmlView( - DIV("Bluesky credentials saved!", + DIV("Bluesky settings saved!", BR(), new Link.LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); } @Override - public ModelAndView getView(BlueskyCredentialsForm form, boolean reshow, BindException errors) + public ModelAndView getView(BlueskySettingsForm form, boolean reshow, BindException errors) { if(!reshow) { @@ -1302,11 +1327,16 @@ public ModelAndView getView(BlueskyCredentialsForm form, boolean reshow, BindExc { form.setUserName(map.get(BlueskyService.USER)); form.setTestAccountUser(map.get(BlueskyService.TEST_USER)); + Attachment logoAttachment = PanoramaPublicLogoManager.getNewDataLogo(); + if (logoAttachment != null) + { + form.setImageFileName(logoAttachment.getName()); + } } } JspView view = new JspView<>("/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp", form, errors); view.setFrame(WebPartView.FrameType.PORTAL); - view.setTitle("Bluesky Credentials"); + view.setTitle("Bluesky Settings"); return view; } @@ -1318,7 +1348,7 @@ public void addNavTrail(NavTree root) } } - public static class BlueskyCredentialsForm + public static class BlueskySettingsForm { private String _userName; private String _password; @@ -1326,6 +1356,8 @@ public static class BlueskyCredentialsForm private String _testAccountUser; private String _testAccountPassword; + private String _imageFileName; + public String getUserName() { return _userName; @@ -1365,6 +1397,56 @@ public void setTestAccountPassword(String testAccountPassword) { _testAccountPassword = testAccountPassword; } + + public String getImageFileName() + { + return _imageFileName; + } + + public void setImageFileName(String imageFileName) + { + _imageFileName = imageFileName; + } + } + + @RequiresPermission(ReadPermission.class) + public class DownloadLogoForBlueskyAction extends BaseDownloadAction + { + @Nullable + @Override + public Pair getAttachment(AttachmentForm form) + { + AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + if (ap == null) return null; + Attachment attachment = PanoramaPublicLogoManager.getNewDataLogo(); + if (attachment != null) + { + return new Pair<>(ap, attachment.getName()); + } + return null; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public class DeleteLogoForBlueskyAction extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + PanoramaPublicLogoManager.deleteExistingNewDataLogo(getUser()); + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return new ActionURL(ManageBlueskySettings.class, getContainer()); + } } @RequiresPermission(AdminOperationsPermission.class) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java new file mode 100644 index 00000000..6e6a4b01 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java @@ -0,0 +1,470 @@ +package org.labkey.panoramapublic.bluesky; + +import org.apache.commons.io.IOUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.labkey.api.attachments.Attachment; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.panoramapublic.catalog.CatalogImageAttachmentParent; +import org.labkey.panoramapublic.model.CatalogEntry; +import org.labkey.panoramapublic.model.ExperimentAnnotations; +import org.labkey.panoramapublic.query.CatalogEntryManager; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.stream.Collectors; + +public class BlueskyService +{ + public static final String CREDENTIALS = "Bluesky Credentials"; + public static final String USER = "Bluesky User"; + public static final String PASSWORD = "Bluesky Password"; + + public static final String TEST_USER = "Bluesky Test User"; + public static final String TEST_PASSWORD = "Bluesky Test Password"; + + public static final String BLUESKY_LINK = "Bluesky link"; + + public static String ANNOUNCEMENT_TEXT = "New data available on Panorama Public!"; + // public static String[] HASHTAGS = {"proteomics", "proteomicssky", "massspec", "massspecsky"}; + public static String[] TEST_HASHTAGS = {"panoramapublictest", "panoramawebtest"}; + + // Endpoint for authentication and creating a session with Bluesky + private static final String AUTH_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"; + // Endpoint for creating any type of record in Bluesky, including posts + private static final String POST_URL = "https://bsky.social/xrpc/com.atproto.repo.createRecord"; + + + private String _accessJwt; + private String _did; // decentralized Identifier + + protected static final Logger logger = LogHelper.getLogger(BlueskyService.class, "BlueskyService logger"); + + /** + * Get the DID (Decentralized Identifier) of the logged-in user + * @return DID string + */ + public String getDid() + { + return _did; + } + + /** + * Login to Bluesky and obtain auth tokens + */ + public void login(String identifier, String password) throws BlueskyException + { + JSONObject requestBody = new JSONObject(); + requestBody.put("identifier", identifier); + requestBody.put("password", password); + + HttpURLConnection connection = null; + try + { + URL url = new URL(AUTH_URL); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + connection.setConnectTimeout(10000); // 10 seconds + connection.setReadTimeout(10000); // 10 seconds + + // Write request body + try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) + { + wr.write(requestBody.toString().getBytes(StandardCharsets.UTF_8)); + wr.flush(); + } + + int responseCode = connection.getResponseCode(); + String response; + try (InputStream in = connection.getInputStream()) + { + response = IOUtils.toString(in, StandardCharsets.UTF_8); + } + catch (IOException e) + { + try (InputStream in = connection.getErrorStream()) + { + response = IOUtils.toString(in, StandardCharsets.UTF_8); + } + } + if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_CREATED) + { + JSONObject responseJson = new JSONObject(response); + _accessJwt = responseJson.getString("accessJwt"); + _did = responseJson.getString("did"); + } + else + { + throw new BlueskyException("Bluesky login failed", new BlueskyResponse(responseCode, connection.getResponseMessage(), response)); + } + } + catch (IOException e) + { + throw new BlueskyException("Bluesky login failed", e); + } + finally + { + if (connection != null) + { + connection.disconnect(); + } + } + } + + public String createBlueskyPost(ExperimentAnnotations exptAnnotations, boolean testPost) throws BlueskyException + { + if (_accessJwt == null || _did == null) + { + throw new BlueskyException("Not logged in. Call login() first."); + } + + String title = exptAnnotations.getTitle(); + String panoramaPublicLink = exptAnnotations.getShortUrl().renderShortURL(); + // "https://panoramaweb.org/KsL1do.url" // https://panoramaweb.org/zobellia_sulfatases.url"; + + String[] hashtags = TEST_HASHTAGS; // testPost ? TEST_HASHTAGS : HASHTAGS; + String postText = getPostText(hashtags); + // Create the post record + JSONObject record = new JSONObject(); + record.put("$type", "app.bsky.feed.post"); + record.put("text", postText); + record.put("createdAt", java.time.Instant.now().toString()); + + addHashTags(record, postText, hashtags); + + // Create the embed object for a web card + JSONObject embed = new JSONObject(); + embed.put("$type", "app.bsky.embed.external"); + + JSONObject external = new JSONObject(); + external.put("uri", panoramaPublicLink); + external.put("title", title); + external.put("description", panoramaPublicLink); + + JSONObject blobResponse = null; + try + { + CatalogEntry catalogEntry = CatalogEntryManager.getEntryForExperiment(exptAnnotations); + if (catalogEntry != null && catalogEntry.getApproved()) + { + // Add the catalog entry image provided by the submitter + Attachment attachment = catalogEntry.getAttachment(); +// ActionURL downloadLink = PanoramaPublicController.getCatalogImageDownloadUrl(exptAnnotations, catalogEntry.getImageFileName()); +// blobResponse= uploadCatalogImage(downloadLink); + blobResponse = attachment != null ? uploadImage(catalogEntry.getAttachment(), + new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer())) : null; + } + else + { + // If a catalog entry was not provided, use the Panorama Public logo + Attachment logoAttachment = PanoramaPublicLogoManager.getNewDataLogo(); + blobResponse = uploadImage(logoAttachment, PanoramaPublicLogoAttachmentParent.get()); +// if (logoAttachment != null) +// { +// File file = logoAttachment.getFile(); +// Path imageFilePath = (file != null && file.exists()) ? file.toPath() : null; +// if (imageFilePath != null && Files.exists(imageFilePath)) +// { +// blobResponse = uploadImage(imageFilePath); +// } +// else +// { +// logger.warn("Unable to find image file. " + imageFilePath != null ? imageFilePath.toString() : ""); +// } +// } + } + // Get the blob reference from the response + JSONObject blob = blobResponse != null ? blobResponse.getJSONObject("blob") : null; + if (blob != null) + { + external.put("thumb", blob); + } + else + { + logger.warn("Blob reference not found in Bluesky response"); + } + } + catch (BlueskyException | JSONException e) + { + // We will log the error, but submit the post without an image. + logger.error(e.getMessage()); + } + + embed.put("external", external); + record.put("embed", embed); + + // Create the request body + JSONObject requestBody = new JSONObject(); + requestBody.put("repo", _did); + requestBody.put("collection", "app.bsky.feed.post"); + requestBody.put("record", record); + + HttpURLConnection connection = null; + try + { + URL url = new URL(POST_URL); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + _accessJwt); + connection.setDoOutput(true); + connection.setConnectTimeout(10000); // 10 seconds + connection.setReadTimeout(10000); // 10 seconds + + // Write request body + try (OutputStream os = connection.getOutputStream()) + { + byte[] input = requestBody.toString().getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + String response; + try (InputStream in = connection.getInputStream()) + { + response = IOUtils.toString(in, StandardCharsets.UTF_8); + } + catch (IOException e) + { + try (InputStream in = connection.getErrorStream()) + { + response = IOUtils.toString(in, StandardCharsets.UTF_8); + } + } + + if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_CREATED) + { + JSONObject responseJson = new JSONObject(response); + if (responseJson.has("uri")) + { + return responseJson.getString("uri"); + } + else + { + throw new BlueskyException("Post creation failed - Missing URI in response", + new BlueskyResponse(responseCode, connection.getResponseMessage(), response)); + } + } + else + { + throw new BlueskyException("Post creation failed.", new BlueskyResponse(responseCode, connection.getResponseMessage(), response)); + } + } + catch (IOException e) + { + throw new BlueskyException("Post creation failed.", e); + } + finally + { + if (connection != null) + { + connection.disconnect(); + } + } + } + + private String getPostText(String[] hashtags) + { + return ANNOUNCEMENT_TEXT + " " + Arrays.stream(hashtags).map(tag -> "#" + tag).collect(Collectors.joining(", ")); + } + + private void addHashTags(JSONObject record, String postText, String[] hashtags) + { + // Create facets for the hashtags + JSONArray facets = new JSONArray(); + + for (String tag : hashtags) { + // Find the tag in the text (including the # character) + String hashtagInText = "#" + tag; + int tagStart = postText.indexOf(hashtagInText); + + if (tagStart != -1) { + // Convert to byte position for UTF-8 + byte[] beforeTagBytes = postText.substring(0, tagStart).getBytes(StandardCharsets.UTF_8); + int byteStart = beforeTagBytes.length; + + byte[] tagBytes = hashtagInText.getBytes(StandardCharsets.UTF_8); + int byteEnd = byteStart + tagBytes.length; + + // Create the tag facet + JSONObject tagFacet = new JSONObject(); + + // Create index object + JSONObject indices = new JSONObject(); + indices.put("byteStart", byteStart); + indices.put("byteEnd", byteEnd); + tagFacet.put("index", indices); + + // Create features array + JSONArray features = new JSONArray(); + JSONObject tagFeature = new JSONObject(); + tagFeature.put("$type", "app.bsky.richtext.facet#tag"); + tagFeature.put("tag", tag); // Tag without the # character + features.put(tagFeature); + tagFacet.put("features", features); + + facets.put(tagFacet); + } + } + + // Add facets to the record if we have any + if (facets.length() > 0) + { + record.put("facets", facets); + } + } + +// @Nullable +// private JSONObject uploadImage(Path imageFilePath) throws BlueskyException +// { +// if (imageFilePath == null || !Files.exists(imageFilePath)) +// { +// return null; +// } +// +// try +// { +// // Get image bytes +// byte[] imageBytes = Files.readAllBytes(imageFilePath); +// String mimeType = PageFlowUtil.getContentTypeFor(imageFilePath.getFileName().toString()); +// return uploadToBluesky(imageBytes, mimeType); +// } +// catch (IOException e) +// { +// throw new BlueskyException("Failed to upload image to Bluesky", e); +// } +// } + + @NotNull + private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType) throws IOException, BlueskyException + { + // Upload to Bluesky + String BLOB_UPLOAD_URL = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob"; + URL uploadUrl = new URL(BLOB_UPLOAD_URL); + HttpURLConnection uploadConnection = (HttpURLConnection) uploadUrl.openConnection(); + uploadConnection.setRequestMethod("POST"); + uploadConnection.setRequestProperty("Content-Type", mimeType); + uploadConnection.setRequestProperty("Authorization", "Bearer " + _accessJwt); + uploadConnection.setDoOutput(true); + + // Write image bytes + try (OutputStream os = uploadConnection.getOutputStream()) + { + os.write(imageBytes); + } + + // Process response + int responseCode = uploadConnection.getResponseCode(); + String response; + try (InputStream in = uploadConnection.getInputStream()) + { + response = IOUtils.toString(in, StandardCharsets.UTF_8); + } + catch (IOException e) + { + try (InputStream in = uploadConnection.getErrorStream()) + { + response = IOUtils.toString(in, StandardCharsets.UTF_8); + } + throw new BlueskyException("Failed to upload image to Bluesky", + new BlueskyResponse(responseCode, uploadConnection.getResponseMessage(), response)); + } + return new JSONObject(response); + } + + private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent) throws BlueskyException + { + try (InputStream is = AttachmentService.get().getInputStream(parent, attachment.getName())) + { + byte[] imageBytes = is.readAllBytes(); + String mimeType = PageFlowUtil.getContentTypeFor(attachment.getName()); + return uploadToBluesky(imageBytes, mimeType); + } + catch (FileNotFoundException e) + { + logger.error("Image attachment file not found " + attachment.getName(), e); + } + catch (IOException e) + { + logger.error("Error reading image file " + attachment.getName(), e); + } + return null; + } + public JSONObject uploadCatalogImage(ActionURL catalogEntryUrl) throws BlueskyException + { + try { + // Download the image first + URL url = new URL(catalogEntryUrl.getURIString()); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + + // Get image bytes + byte[] imageBytes; + try (InputStream in = connection.getInputStream()) + { + imageBytes = IOUtils.toByteArray(in); + } + + // Determine MIME type + String mimeType = connection.getContentType(); + if (mimeType == null || mimeType.isEmpty()) + { + mimeType = "image/png"; // Default to PNG if unknown + } + + return uploadToBluesky(imageBytes, mimeType); + } + catch (IOException e) + { + throw new BlueskyException("Failed to upload image", e); + } + } + + + + public static String convertToClickableUrl(String postUri) + { + // Handle URIs with or without leading slashes + String cleanUri = postUri.replaceFirst("^//", ""); + + // Extract the DID and post ID + String did = ""; + String postId = ""; + + if (cleanUri.startsWith("at://")) { + cleanUri = cleanUri.substring(5); // Remove "at://" + } + + String[] parts = cleanUri.split("/"); + if (parts.length >= 1) { + did = parts[0]; + } + + if (parts.length >= 3) { + postId = parts[parts.length - 1]; + } + + return "https://bsky.app/profile/" + did + "/post/" + postId; + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java new file mode 100644 index 00000000..9d14a197 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java @@ -0,0 +1,38 @@ +package org.labkey.panoramapublic.bluesky; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.attachments.AttachmentType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.panoramapublic.model.Journal; +import org.labkey.panoramapublic.query.JournalManager; + +public class PanoramaPublicLogoAttachmentParent extends ContainerManager.ContainerParent +{ + private PanoramaPublicLogoAttachmentParent(Container c) + { + super(c); + } + + @Nullable + public static PanoramaPublicLogoAttachmentParent get() + { + // Associate with the Panorama Public project. + Journal panoramaPublic = JournalManager.getJournal(JournalManager.PANORAMA_PUBLIC); + if (panoramaPublic != null) + { + Container project = panoramaPublic.getProject(); + return new PanoramaPublicLogoAttachmentParent(project); + } + return null; + } + + @Override + public @NotNull AttachmentType getAttachmentType() + { + return PanoramaPublicLogoResourceType.get(); + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java new file mode 100644 index 00000000..1e8b9462 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java @@ -0,0 +1,70 @@ +package org.labkey.panoramapublic.bluesky; + +import jakarta.servlet.ServletException; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.attachments.Attachment; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.attachments.InputStreamAttachmentFile; +import org.labkey.api.security.User; +import org.labkey.api.util.FileUtil; + +import java.io.IOException; +import java.util.Collections; + +public class PanoramaPublicLogoManager +{ + public static String LOGO_FILE_PREFIX = "PanoramaPublicLogo-NewData"; + @Nullable + public static Attachment getNewDataLogo() + { + AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + if (ap == null) return null; + + return AttachmentService.get().getAttachments(ap).stream() + .filter(a -> a.getName() != null && LOGO_FILE_PREFIX.equals(FileUtil.getBaseName(a.getName()))) + .findFirst() + .orElse(null); + } + + public static void saveNewDataLogo(AttachmentFile file, User user) throws IOException + { + String logoFileName = getLogoFileName(file.getFilename(), LOGO_FILE_PREFIX); + AttachmentFile attachmentFile = new InputStreamAttachmentFile(file.openInputStream(), logoFileName); + PanoramaPublicLogoAttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + AttachmentService svc = AttachmentService.get(); + deleteExistingNewDataLogo(svc, user); + AttachmentService.get().addAttachments(ap, Collections.singletonList(attachmentFile), user); + } + + private static String getLogoFileName(String name, String fileNamePrefix) + { + String extension = FileUtil.getExtension(name); + if (extension == null) + { + throw new IllegalArgumentException("Unable to get file extension from logo file - " + name); + } + + return fileNamePrefix + "." + extension; + } + + public static void deleteExistingNewDataLogo(User user) + { + deleteExistingNewDataLogo(AttachmentService.get(), user); + } + + private static void deleteExistingNewDataLogo(AttachmentService svc, User user) + { + PanoramaPublicLogoAttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + if (ap == null) return; + + for (Attachment attachment : svc.getAttachments(ap)) + { + if (attachment.getName().startsWith(PanoramaPublicLogoManager.LOGO_FILE_PREFIX)) + { + svc.deleteAttachment(ap, attachment.getName(), user); + } + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java new file mode 100644 index 00000000..e6a48f8a --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java @@ -0,0 +1,36 @@ +package org.labkey.panoramapublic.bluesky; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.attachments.AttachmentType; +import org.labkey.api.data.CoreSchema; +import org.labkey.api.data.SQLFragment; + +public class PanoramaPublicLogoResourceType implements AttachmentType +{ + private static final PanoramaPublicLogoResourceType INSTANCE = new PanoramaPublicLogoResourceType(); + + public static PanoramaPublicLogoResourceType get() + { + return INSTANCE; + } + + private PanoramaPublicLogoResourceType() + { + } + + @Override + public @NotNull String getUniqueName() + { + return getClass().getName(); + } + + @Override + public void addWhereSql(SQLFragment sql, String parentColumn, String documentNameColumn) + { + sql.append(parentColumn).append(" IN (SELECT EntityId FROM ") + .append(CoreSchema.getInstance().getTableInfoContainers(), "c").append(")") + .append(" AND (") + .append(documentNameColumn) + .append(" LIKE '" + PanoramaPublicLogoManager.LOGO_FILE_PREFIX + "%' )"); + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp index 1415e3dc..fba4b031 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp @@ -1,17 +1,18 @@ <%@ page import="org.labkey.api.view.ActionURL" %> <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.BlueskySettingsForm" %> <%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PanoramaPublicAdminViewAction" %> -<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.BlueskyCredentialsForm" %> <%@ page extends="org.labkey.api.jsp.FormPage" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <% - BlueskyCredentialsForm form = ((JspView) HttpView.currentView()).getModelBean(); + BlueskySettingsForm form = ((JspView) HttpView.currentView()).getModelBean(); ActionURL panoramaPublicAdminUrl = urlFor(PanoramaPublicAdminViewAction.class); %>

- + @@ -32,8 +33,25 @@ - - + + + + + +
User:
<%=button("Save Credentials").submit(true)%><%=button("Cancel").href(panoramaPublicAdminUrl)%>Panorama Public Logo: + + + <% if (form.getImageFileName() != null) { %> + <%=link("View Logo", urlFor(PanoramaPublicController.DownloadLogoForBlueskyAction.class))%> + <%=link("Delete Logo", urlFor(PanoramaPublicController.DeleteLogoForBlueskyAction.class)).usePost()%> + <% } %> +
+ PNG or JPG/JPEG file in 16x9 ascpect ratio that will be included in the Bluesky post +
+
+ <%=button("Save").submit(true)%> + <%=button("Cancel").href(panoramaPublicAdminUrl)%> +
From 7bc609b56b677605d0826445f2bb814bf16ee296 Mon Sep 17 00:00:00 2001 From: vagisha Date: Thu, 10 Apr 2025 12:05:15 -0700 Subject: [PATCH 04/12] Refactored to use Apache HttpClient etc. --- .../PanoramaPublicController.java | 18 +- .../bluesky/BlueskyService.java | 569 +++++++++--------- 2 files changed, 276 insertions(+), 311 deletions(-) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 2ae1f544..664d402c 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -130,7 +130,6 @@ import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; import org.labkey.api.util.Link; -import org.labkey.api.util.MimeMap; import org.labkey.api.util.MimeMap.MimeType; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; @@ -5526,20 +5525,11 @@ public boolean handlePost(BlueskyForm form, BindException errors) try { // Post to Bluesky - BlueskyService svc = new BlueskyService(); - svc.login(user, password); - _blueSkyPostUrl = svc.createBlueskyPost(_expAnnot, form.isTestAccount()); + _blueSkyPostUrl = new BlueskyService().createBlueskyPost(_expAnnot, form.isTestAccount(), user, password); - if (_blueSkyPostUrl != null) - { - WritablePropertyMap propertyMap = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, true); - propertyMap.put(_expAnnot.getShortUrl().renderShortURL(), _blueSkyPostUrl); - propertyMap.save(); - } - else - { - throw new BlueskyException("The post URL was not included in the response from Bluesky"); - } + WritablePropertyMap propertyMap = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, true); + propertyMap.put(_expAnnot.getShortUrl().renderShortURL(), _blueSkyPostUrl); + propertyMap.save(); } catch (BlueskyException e) { diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java index 6e6a4b01..c8cdfadf 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyService.java @@ -1,35 +1,36 @@ package org.labkey.panoramapublic.bluesky; -import org.apache.commons.io.IOUtils; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.labkey.api.attachments.Attachment; import org.labkey.api.attachments.AttachmentParent; import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.util.FileUtil; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.ActionURL; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentParent; import org.labkey.panoramapublic.model.CatalogEntry; import org.labkey.panoramapublic.model.ExperimentAnnotations; import org.labkey.panoramapublic.query.CatalogEntryManager; -import java.io.DataOutputStream; -import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; import java.util.stream.Collectors; @@ -45,111 +46,125 @@ public class BlueskyService public static final String BLUESKY_LINK = "Bluesky link"; public static String ANNOUNCEMENT_TEXT = "New data available on Panorama Public!"; - // public static String[] HASHTAGS = {"proteomics", "proteomicssky", "massspec", "massspecsky"}; + public static String[] HASHTAGS = {"proteomics", "proteomicssky", "massspec", "massspecsky"}; public static String[] TEST_HASHTAGS = {"panoramapublictest", "panoramawebtest"}; + // API endpoints // Endpoint for authentication and creating a session with Bluesky private static final String AUTH_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"; // Endpoint for creating any type of record in Bluesky, including posts private static final String POST_URL = "https://bsky.social/xrpc/com.atproto.repo.createRecord"; - - - private String _accessJwt; - private String _did; // decentralized Identifier + private static final String BLOB_UPLOAD_URL = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob"; protected static final Logger logger = LogHelper.getLogger(BlueskyService.class, "BlueskyService logger"); - /** - * Get the DID (Decentralized Identifier) of the logged-in user - * @return DID string - */ - public String getDid() - { - return _did; - } - /** * Login to Bluesky and obtain auth tokens */ - public void login(String identifier, String password) throws BlueskyException + @NotNull + public LoginInfo login(@NotNull String identifier, @NotNull String password) throws BlueskyException { JSONObject requestBody = new JSONObject(); requestBody.put("identifier", identifier); requestBody.put("password", password); - HttpURLConnection connection = null; - try - { - URL url = new URL(AUTH_URL); - connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setDoOutput(true); - connection.setConnectTimeout(10000); // 10 seconds - connection.setReadTimeout(10000); // 10 seconds - - // Write request body - try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) - { - wr.write(requestBody.toString().getBytes(StandardCharsets.UTF_8)); - wr.flush(); - } + HttpPost httpPost = new HttpPost(AUTH_URL); + httpPost.setHeader("Content-Type", "application/json"); + httpPost.setEntity(new StringEntity(requestBody.toString(), ContentType.APPLICATION_JSON)); - int responseCode = connection.getResponseCode(); - String response; - try (InputStream in = connection.getInputStream()) - { - response = IOUtils.toString(in, StandardCharsets.UTF_8); - } - catch (IOException e) - { - try (InputStream in = connection.getErrorStream()) - { - response = IOUtils.toString(in, StandardCharsets.UTF_8); - } - } - if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_CREATED) - { - JSONObject responseJson = new JSONObject(response); - _accessJwt = responseJson.getString("accessJwt"); - _did = responseJson.getString("did"); - } - else - { - throw new BlueskyException("Bluesky login failed", new BlueskyResponse(responseCode, connection.getResponseMessage(), response)); - } + BlueskyResponse response; + try (CloseableHttpClient httpClient = HttpClients.createDefault()) + { + response = getResponse(httpClient, httpPost, "Bluesky login failed"); + JSONObject responseJson = response.getJsonObject(); + String accessJwt = responseJson.getString("accessJwt"); + String did = responseJson.getString("did"); + return new LoginInfo(accessJwt, did); } - catch (IOException e) + catch (IOException | JSONException e) { throw new BlueskyException("Bluesky login failed", e); } - finally + } + + public static class LoginInfo + { + private final String _accessJwt; + private final String _did; // DID (Decentralized Identifier) of the logged-in user + + public LoginInfo(String accessJwt, String did) { - if (connection != null) - { - connection.disconnect(); - } + _accessJwt = accessJwt; + _did = did; + } + + public String getAccessJwt() + { + return _accessJwt; + } + + public String getDid() + { + return _did; } } - public String createBlueskyPost(ExperimentAnnotations exptAnnotations, boolean testPost) throws BlueskyException + /** + * Create a post on Bluesky announcing the data + */ + @NotNull + public String createBlueskyPost(@NotNull ExperimentAnnotations exptAnnotations, boolean testPost, String identifier, String password) throws BlueskyException { - if (_accessJwt == null || _did == null) + LoginInfo loginInfo = login(identifier, password); + return createBlueskyPost(exptAnnotations, testPost, loginInfo); + } + + @NotNull + public String createBlueskyPost(@NotNull ExperimentAnnotations exptAnnotations, boolean testPost, @NotNull LoginInfo loginInfo) throws BlueskyException + { + JSONObject requestBody = createRequestBody(exptAnnotations, testPost, loginInfo); + + HttpPost httpPost = new HttpPost(POST_URL); + httpPost.setHeader("Content-Type", "application/json"); + httpPost.setHeader("Authorization", "Bearer " + loginInfo.getAccessJwt()); + httpPost.setEntity(new StringEntity(requestBody.toString(), ContentType.APPLICATION_JSON)); + + BlueskyResponse response; + try (CloseableHttpClient httpClient = HttpClients.createDefault()) + { + response = getResponse(httpClient, httpPost, "Post creation failed"); + JSONObject responseJson = response.getJsonObject(); + if (responseJson.has("uri")) + { + return responseJson.getString("uri"); + } + else + { + throw new BlueskyException("Post creation failed - Missing URI in response.", response); + } + + + } + catch (IOException | JSONException e) { - throw new BlueskyException("Not logged in. Call login() first."); + throw new BlueskyException("Post creation failed.", e); } + } + @NotNull + private JSONObject createRequestBody(ExperimentAnnotations exptAnnotations, boolean testPost, LoginInfo loginInfo) throws BlueskyException + { String title = exptAnnotations.getTitle(); String panoramaPublicLink = exptAnnotations.getShortUrl().renderShortURL(); - // "https://panoramaweb.org/KsL1do.url" // https://panoramaweb.org/zobellia_sulfatases.url"; - String[] hashtags = TEST_HASHTAGS; // testPost ? TEST_HASHTAGS : HASHTAGS; + String[] hashtags = testPost ? TEST_HASHTAGS : HASHTAGS; String postText = getPostText(hashtags); + // Create the post record JSONObject record = new JSONObject(); record.put("$type", "app.bsky.feed.post"); record.put("text", postText); - record.put("createdAt", java.time.Instant.now().toString()); + record.put("createdAt", Instant.now().toString()); addHashTags(record, postText, hashtags); @@ -162,53 +177,40 @@ public String createBlueskyPost(ExperimentAnnotations exptAnnotations, boolean t external.put("title", title); external.put("description", panoramaPublicLink); - JSONObject blobResponse = null; - try + + AttachmentParent attachmentParent; + // First check if the user has provided a catalog entry for the data. + Attachment attachment = getCatalogEntryAttachment(exptAnnotations); + if (attachment != null) { - CatalogEntry catalogEntry = CatalogEntryManager.getEntryForExperiment(exptAnnotations); - if (catalogEntry != null && catalogEntry.getApproved()) - { - // Add the catalog entry image provided by the submitter - Attachment attachment = catalogEntry.getAttachment(); -// ActionURL downloadLink = PanoramaPublicController.getCatalogImageDownloadUrl(exptAnnotations, catalogEntry.getImageFileName()); -// blobResponse= uploadCatalogImage(downloadLink); - blobResponse = attachment != null ? uploadImage(catalogEntry.getAttachment(), - new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer())) : null; - } - else - { - // If a catalog entry was not provided, use the Panorama Public logo - Attachment logoAttachment = PanoramaPublicLogoManager.getNewDataLogo(); - blobResponse = uploadImage(logoAttachment, PanoramaPublicLogoAttachmentParent.get()); -// if (logoAttachment != null) -// { -// File file = logoAttachment.getFile(); -// Path imageFilePath = (file != null && file.exists()) ? file.toPath() : null; -// if (imageFilePath != null && Files.exists(imageFilePath)) -// { -// blobResponse = uploadImage(imageFilePath); -// } -// else -// { -// logger.warn("Unable to find image file. " + imageFilePath != null ? imageFilePath.toString() : ""); -// } -// } - } - // Get the blob reference from the response - JSONObject blob = blobResponse != null ? blobResponse.getJSONObject("blob") : null; - if (blob != null) - { - external.put("thumb", blob); - } - else + attachmentParent = new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer()); + } + else + { + // If the data does not have a catalog entry, or we were unable to get it, use the Panorama Public logo + attachmentParent = PanoramaPublicLogoAttachmentParent.get(); + if (attachmentParent == null) { - logger.warn("Blob reference not found in Bluesky response"); + throw new BlueskyException("Unable to initialize PanoramaPublicLogoAttachmentParent. Perhaps a Panorama Public project does not exist on the server."); } + attachment = PanoramaPublicLogoManager.getNewDataLogo(); } - catch (BlueskyException | JSONException e) + + if (attachment == null) { - // We will log the error, but submit the post without an image. - logger.error(e.getMessage()); + throw new BlueskyException("Unable to find an image file to include in the post."); + } + + JSONObject blobResponse = uploadImage(attachment, attachmentParent, loginInfo); + // Get the blob reference from the response + if (blobResponse.has("blob")) + { + JSONObject blob = blobResponse.getJSONObject("blob"); + external.put("thumb", blob); + } + else + { + throw new BlueskyException("Blob reference not found in Bluesky response after uploading the image file."); } embed.put("external", external); @@ -216,233 +218,203 @@ public String createBlueskyPost(ExperimentAnnotations exptAnnotations, boolean t // Create the request body JSONObject requestBody = new JSONObject(); - requestBody.put("repo", _did); + requestBody.put("repo", loginInfo.getDid()); requestBody.put("collection", "app.bsky.feed.post"); requestBody.put("record", record); + return requestBody; + } - HttpURLConnection connection = null; - try - { - URL url = new URL(POST_URL); - connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty("Authorization", "Bearer " + _accessJwt); - connection.setDoOutput(true); - connection.setConnectTimeout(10000); // 10 seconds - connection.setReadTimeout(10000); // 10 seconds - - // Write request body - try (OutputStream os = connection.getOutputStream()) - { - byte[] input = requestBody.toString().getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); - } - - int responseCode = connection.getResponseCode(); - String response; - try (InputStream in = connection.getInputStream()) - { - response = IOUtils.toString(in, StandardCharsets.UTF_8); - } - catch (IOException e) - { - try (InputStream in = connection.getErrorStream()) - { - response = IOUtils.toString(in, StandardCharsets.UTF_8); - } - } - - if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_CREATED) - { - JSONObject responseJson = new JSONObject(response); - if (responseJson.has("uri")) - { - return responseJson.getString("uri"); - } - else - { - throw new BlueskyException("Post creation failed - Missing URI in response", - new BlueskyResponse(responseCode, connection.getResponseMessage(), response)); - } - } - else - { - throw new BlueskyException("Post creation failed.", new BlueskyResponse(responseCode, connection.getResponseMessage(), response)); - } - } - catch (IOException e) - { - throw new BlueskyException("Post creation failed.", e); - } - finally - { - if (connection != null) - { - connection.disconnect(); - } - } + private Attachment getCatalogEntryAttachment(ExperimentAnnotations exptAnnotations) + { + CatalogEntry catalogEntry = CatalogEntryManager.getEntryForExperiment(exptAnnotations); + return (catalogEntry != null && catalogEntry.getApproved()) ? catalogEntry.getAttachment() : null; +// { +// // Use the catalog entry image provided by the submitter +// attachment = catalogEntry.getAttachment(); +// attachmentParent = new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer()); +// } } + /** + * Generate post text with hashtags + */ private String getPostText(String[] hashtags) { - return ANNOUNCEMENT_TEXT + " " + Arrays.stream(hashtags).map(tag -> "#" + tag).collect(Collectors.joining(", ")); + return ANNOUNCEMENT_TEXT + " " + formatHashtags(hashtags); } + /** + * Convert an array of hashtag strings to a comma-separated string with '#' prefix + */ + private String formatHashtags(String[] hashtags) + { + return Arrays.stream(hashtags) + .map(tag -> "#" + tag) + .collect(Collectors.joining(", ")); + } + + /** + * Add hashtag facets to the record for Bluesky's rich text formatting + */ private void addHashTags(JSONObject record, String postText, String[] hashtags) { - // Create facets for the hashtags + JSONArray facets = createHashtagFacets(postText, hashtags); + + // Add facets to the record if we have any + if (!facets.isEmpty()) + { + record.put("facets", facets); + } + } + + /** + * Create hashtag facets for Bluesky's rich text formatting + */ + private JSONArray createHashtagFacets(String postText, String[] hashtags) + { JSONArray facets = new JSONArray(); for (String tag : hashtags) { - // Find the tag in the text (including the # character) - String hashtagInText = "#" + tag; - int tagStart = postText.indexOf(hashtagInText); - - if (tagStart != -1) { - // Convert to byte position for UTF-8 - byte[] beforeTagBytes = postText.substring(0, tagStart).getBytes(StandardCharsets.UTF_8); - int byteStart = beforeTagBytes.length; - - byte[] tagBytes = hashtagInText.getBytes(StandardCharsets.UTF_8); - int byteEnd = byteStart + tagBytes.length; - - // Create the tag facet - JSONObject tagFacet = new JSONObject(); - - // Create index object - JSONObject indices = new JSONObject(); - indices.put("byteStart", byteStart); - indices.put("byteEnd", byteEnd); - tagFacet.put("index", indices); - - // Create features array - JSONArray features = new JSONArray(); - JSONObject tagFeature = new JSONObject(); - tagFeature.put("$type", "app.bsky.richtext.facet#tag"); - tagFeature.put("tag", tag); // Tag without the # character - features.put(tagFeature); - tagFacet.put("features", features); - + JSONObject tagFacet = createSingleHashtagFacet(postText, tag); + if (tagFacet != null) { facets.put(tagFacet); } } - // Add facets to the record if we have any - if (facets.length() > 0) - { - record.put("facets", facets); - } + return facets; } -// @Nullable -// private JSONObject uploadImage(Path imageFilePath) throws BlueskyException -// { -// if (imageFilePath == null || !Files.exists(imageFilePath)) -// { -// return null; -// } -// -// try -// { -// // Get image bytes -// byte[] imageBytes = Files.readAllBytes(imageFilePath); -// String mimeType = PageFlowUtil.getContentTypeFor(imageFilePath.getFileName().toString()); -// return uploadToBluesky(imageBytes, mimeType); -// } -// catch (IOException e) -// { -// throw new BlueskyException("Failed to upload image to Bluesky", e); -// } -// } + /** + * Create a single hashtag facet for Bluesky's rich text formatting + */ + private JSONObject createSingleHashtagFacet(String postText, String tag) + { + // Find the tag in the text (including the # character) + String hashtagInText = "#" + tag; + int tagStart = postText.indexOf(hashtagInText); + + if (tagStart == -1) { + return null; + } + + // Convert to byte position for UTF-8 + byte[] beforeTagBytes = postText.substring(0, tagStart).getBytes(StandardCharsets.UTF_8); + int byteStart = beforeTagBytes.length; + + byte[] tagBytes = hashtagInText.getBytes(StandardCharsets.UTF_8); + int byteEnd = byteStart + tagBytes.length; + + // Create the tag facet + JSONObject tagFacet = new JSONObject(); + // Create index object + JSONObject indices = new JSONObject(); + indices.put("byteStart", byteStart); + indices.put("byteEnd", byteEnd); + tagFacet.put("index", indices); + + // Create features array + JSONArray features = new JSONArray(); + JSONObject tagFeature = new JSONObject(); + tagFeature.put("$type", "app.bsky.richtext.facet#tag"); + tagFeature.put("tag", tag); // Tag without the # character + features.put(tagFeature); + tagFacet.put("features", features); + + return tagFacet; + } + + /** + * Upload image bytes to Bluesky + */ @NotNull - private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType) throws IOException, BlueskyException + private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType, LoginInfo loginInfo) throws BlueskyException { - // Upload to Bluesky - String BLOB_UPLOAD_URL = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob"; - URL uploadUrl = new URL(BLOB_UPLOAD_URL); - HttpURLConnection uploadConnection = (HttpURLConnection) uploadUrl.openConnection(); - uploadConnection.setRequestMethod("POST"); - uploadConnection.setRequestProperty("Content-Type", mimeType); - uploadConnection.setRequestProperty("Authorization", "Bearer " + _accessJwt); - uploadConnection.setDoOutput(true); - - // Write image bytes - try (OutputStream os = uploadConnection.getOutputStream()) + HttpPost httpPost = new HttpPost(BLOB_UPLOAD_URL); + httpPost.setHeader("Content-Type", mimeType); + httpPost.setHeader("Authorization", "Bearer " + loginInfo.getAccessJwt()); + httpPost.setEntity(new ByteArrayEntity(imageBytes, ContentType.create(mimeType))); + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) + { + BlueskyResponse response = getResponse(httpClient, httpPost, "Failed to upload image to Bluesky"); + return response.getJsonObject(); + } + catch (IOException | JSONException e) { - os.write(imageBytes); + throw new BlueskyException("Failed to upload image to Bluesky", e); } + } - // Process response - int responseCode = uploadConnection.getResponseCode(); - String response; - try (InputStream in = uploadConnection.getInputStream()) + @NotNull + private static BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost httpPost, String failureMessage) throws IOException, BlueskyException + { + BlueskyResponse response = httpClient.execute(httpPost, new BlueskyResponseHandler()); + String responseContent = response.getResponseBody() != null ? response.getResponseBody() : ""; + + if (!response.success()) { - response = IOUtils.toString(in, StandardCharsets.UTF_8); + throw new BlueskyException(failureMessage, response); } - catch (IOException e) + + if (responseContent.isEmpty()) { - try (InputStream in = uploadConnection.getErrorStream()) - { - response = IOUtils.toString(in, StandardCharsets.UTF_8); - } - throw new BlueskyException("Failed to upload image to Bluesky", - new BlueskyResponse(responseCode, uploadConnection.getResponseMessage(), response)); + throw new BlueskyException("Received empty response from Bluesky", response); } - return new JSONObject(response); + + return response; } - private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent) throws BlueskyException + /** + * Upload an image attachment to Bluesky + */ + @NotNull + private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent, LoginInfo loginInfo) throws BlueskyException { try (InputStream is = AttachmentService.get().getInputStream(parent, attachment.getName())) { byte[] imageBytes = is.readAllBytes(); String mimeType = PageFlowUtil.getContentTypeFor(attachment.getName()); - return uploadToBluesky(imageBytes, mimeType); + return uploadToBluesky(imageBytes, mimeType, loginInfo); } catch (FileNotFoundException e) { - logger.error("Image attachment file not found " + attachment.getName(), e); + logger.error("Image attachment file not found: " + attachment.getName(), e); + throw new BlueskyException("Image attachment file not found", e); } catch (IOException e) { - logger.error("Error reading image file " + attachment.getName(), e); + logger.error("Error reading image file: " + attachment.getName(), e); + throw new BlueskyException("Error reading image file", e); } - return null; } - public JSONObject uploadCatalogImage(ActionURL catalogEntryUrl) throws BlueskyException + + /** + * Custom response handler to handle HTTP responses from Bluesky + */ + private static class BlueskyResponseHandler implements HttpClientResponseHandler { - try { - // Download the image first - URL url = new URL(catalogEntryUrl.getURIString()); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - - // Get image bytes - byte[] imageBytes; - try (InputStream in = connection.getInputStream()) + @Override + public BlueskyResponse handleResponse(ClassicHttpResponse response) throws IOException + { + try { - imageBytes = IOUtils.toByteArray(in); + HttpEntity entity = response.getEntity(); + String content = entity != null ? EntityUtils.toString(entity) : null; + return new BlueskyResponse(response.getCode(), response.getReasonPhrase(), content); } - - // Determine MIME type - String mimeType = connection.getContentType(); - if (mimeType == null || mimeType.isEmpty()) + catch (ParseException e) { - mimeType = "image/png"; // Default to PNG if unknown + throw new IOException("Failed to parse response content", e); } - - return uploadToBluesky(imageBytes, mimeType); - } - catch (IOException e) - { - throw new BlueskyException("Failed to upload image", e); } } - - + /** + * Convert a Bluesky post URI to a clickable URL + * Example: at:///app.bsky.feed.post/ + * Convert to: https://bsky.app/profile//post/ + */ public static String convertToClickableUrl(String postUri) { // Handle URIs with or without leading slashes @@ -452,16 +424,19 @@ public static String convertToClickableUrl(String postUri) String did = ""; String postId = ""; - if (cleanUri.startsWith("at://")) { + if (cleanUri.startsWith("at://")) + { cleanUri = cleanUri.substring(5); // Remove "at://" } String[] parts = cleanUri.split("/"); - if (parts.length >= 1) { + if (parts.length >= 1) + { did = parts[0]; } - if (parts.length >= 3) { + if (parts.length >= 3) + { postId = parts[parts.length - 1]; } From 8993fdd8f9a4247526d0f291c61880cc9122b051 Mon Sep 17 00:00:00 2001 From: vagisha Date: Thu, 10 Apr 2025 15:12:26 -0700 Subject: [PATCH 05/12] Clean up imports. Rename jsp to manageBlueskySettings.jsp --- .../PanoramaPublicController.java | 4 ++-- .../panoramapublic/bluesky/BlueskyResponse.java | 17 +++++++---------- .../PanoramaPublicLogoAttachmentParent.java | 4 +--- .../bluesky/PanoramaPublicLogoManager.java | 1 - .../bluesky/PanoramaPublicLogoResourceType.java | 3 +-- ...redentials.jsp => manageBlueskySettings.jsp} | 3 ++- 6 files changed, 13 insertions(+), 19 deletions(-) rename panoramapublic/src/org/labkey/panoramapublic/view/{manageBlueskyCredentials.jsp => manageBlueskySettings.jsp} (95%) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 664d402c..b70b922b 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -1333,7 +1333,7 @@ public ModelAndView getView(BlueskySettingsForm form, boolean reshow, BindExcept } } } - JspView view = new JspView<>("/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp", form, errors); + JspView view = new JspView<>("/org/labkey/panoramapublic/view/manageBlueskySettings.jsp", form, errors); view.setFrame(WebPartView.FrameType.PORTAL); view.setTitle("Bluesky Settings"); return view; @@ -1343,7 +1343,7 @@ public ModelAndView getView(BlueskySettingsForm form, boolean reshow, BindExcept public void addNavTrail(NavTree root) { addPanoramaPublicAdminConsoleNav(root, getContainer()); - root.addChild("Set Bluesky Credentials"); + root.addChild("Manage Bluesky Settings"); } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java index eb20d3fe..eebe112a 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java @@ -1,14 +1,11 @@ package org.labkey.panoramapublic.bluesky; + +import org.apache.hc.core5.http.HttpStatus; import org.json.JSONException; import org.json.JSONObject; -import org.labkey.panoramapublic.datacite.DataCiteException; -import org.labkey.panoramapublic.datacite.DataCiteService; -import org.labkey.panoramapublic.datacite.Doi; - -import java.net.HttpURLConnection; -class BlueskyResponse +public class BlueskyResponse { private final int _responseCode; private final String _message; @@ -38,18 +35,18 @@ public String getResponseBody() public boolean success() { - return _responseCode == HttpURLConnection.HTTP_OK || _responseCode == HttpURLConnection.HTTP_CREATED; + return _responseCode == HttpStatus.SC_OK || _responseCode == HttpStatus.SC_CREATED; } - private JSONObject getJsonObject(String response) throws DataCiteException + public JSONObject getJsonObject() throws BlueskyException { try { - return new JSONObject(response); + return new JSONObject(_responseBody); } catch (JSONException e) { - throw new DataCiteException("Error parsing JSON from response: " + response); + throw new BlueskyException("Error parsing JSON from response", this); } } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java index 9d14a197..f3e464f4 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java @@ -2,8 +2,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentService; import org.labkey.api.attachments.AttachmentType; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; @@ -20,7 +18,7 @@ private PanoramaPublicLogoAttachmentParent(Container c) @Nullable public static PanoramaPublicLogoAttachmentParent get() { - // Associate with the Panorama Public project. + // Associate with the Panorama Public project, if it exists Journal panoramaPublic = JournalManager.getJournal(JournalManager.PANORAMA_PUBLIC); if (panoramaPublic != null) { diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java index 1e8b9462..18d7ea06 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java @@ -1,6 +1,5 @@ package org.labkey.panoramapublic.bluesky; -import jakarta.servlet.ServletException; import org.jetbrains.annotations.Nullable; import org.labkey.api.attachments.Attachment; import org.labkey.api.attachments.AttachmentFile; diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java index e6a48f8a..3388b4ed 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java @@ -30,7 +30,6 @@ public void addWhereSql(SQLFragment sql, String parentColumn, String documentNam sql.append(parentColumn).append(" IN (SELECT EntityId FROM ") .append(CoreSchema.getInstance().getTableInfoContainers(), "c").append(")") .append(" AND (") - .append(documentNameColumn) - .append(" LIKE '" + PanoramaPublicLogoManager.LOGO_FILE_PREFIX + "%' )"); + .append(documentNameColumn).append(" LIKE '").append(PanoramaPublicLogoManager.LOGO_FILE_PREFIX).append("%' )"); } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp similarity index 95% rename from panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp rename to panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp index fba4b031..84616f82 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskyCredentials.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp @@ -8,7 +8,8 @@ <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <% - BlueskySettingsForm form = ((JspView) HttpView.currentView()).getModelBean(); + JspView currentView = HttpView.currentView(); + BlueskySettingsForm form = currentView.getModelBean(); ActionURL panoramaPublicAdminUrl = urlFor(PanoramaPublicAdminViewAction.class); %>

From 35cf930aa9341bb933259e8e07df022a6dcdd03a Mon Sep 17 00:00:00 2001 From: vagisha Date: Mon, 21 Apr 2025 15:30:01 -0700 Subject: [PATCH 06/12] - Added API endpoint and hashtags are configurable through the Bluesky admin console - Added BlueskySettings and BlueskySettingsManager classes - Added option to auto-post to Bluesky when a dataset is made public by submitter. Confirmation message includes the Bluesky post URL. --- .../PanoramaPublicController.java | 358 ++++++++-------- .../PanoramaPublicNotification.java | 6 +- ...eskyService.java => BlueskyApiClient.java} | 394 ++++++++++-------- .../bluesky/BlueskySettings.java | 162 +++++++ .../bluesky/BlueskySettingsManager.java | 119 ++++++ .../bluesky/PanoramaPublicLogoManager.java | 8 +- .../query/CatalogEntryManager.java | 6 + .../view/manageBlueskySettings.jsp | 68 ++- 8 files changed, 752 insertions(+), 369 deletions(-) rename panoramapublic/src/org/labkey/panoramapublic/bluesky/{BlueskyService.java => BlueskyApiClient.java} (52%) create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index b70b922b..6d154c94 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -130,6 +130,7 @@ import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; import org.labkey.api.util.Link; +import org.labkey.api.util.Link.LinkBuilder; import org.labkey.api.util.MimeMap.MimeType; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; @@ -140,8 +141,10 @@ import org.labkey.api.view.template.ClientDependency; import org.labkey.api.wiki.WikiRendererType; import org.labkey.api.wiki.WikiRenderingService; +import org.labkey.panoramapublic.bluesky.BlueskyApiClient; import org.labkey.panoramapublic.bluesky.BlueskyException; -import org.labkey.panoramapublic.bluesky.BlueskyService; +import org.labkey.panoramapublic.bluesky.BlueskySettings; +import org.labkey.panoramapublic.bluesky.BlueskySettingsManager; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoAttachmentParent; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoManager; import org.labkey.panoramapublic.catalog.CatalogEntrySettings; @@ -242,13 +245,16 @@ import static org.labkey.api.util.DOM.Attribute.border; import static org.labkey.api.util.DOM.Attribute.checked; import static org.labkey.api.util.DOM.Attribute.colspan; +import static org.labkey.api.util.DOM.Attribute.height; import static org.labkey.api.util.DOM.Attribute.href; import static org.labkey.api.util.DOM.Attribute.method; import static org.labkey.api.util.DOM.Attribute.name; +import static org.labkey.api.util.DOM.Attribute.src; import static org.labkey.api.util.DOM.Attribute.style; import static org.labkey.api.util.DOM.Attribute.type; import static org.labkey.api.util.DOM.Attribute.valign; import static org.labkey.api.util.DOM.Attribute.value; +import static org.labkey.api.util.DOM.Attribute.width; import static org.labkey.api.util.DOM.LK.CHECKBOX; import static org.labkey.api.util.DOM.LK.ERRORS; import static org.labkey.api.util.DOM.LK.FORM; @@ -302,7 +308,7 @@ public ModelAndView getView(Object o, BindException errors) LI("Creates a new security group for project administrators"), LI("Creates an entry in the Journal table of the panoramapublic schema") ), - DIV(new Link.LinkBuilder("Create a new journal group").href(new ActionURL(CreateJournalGroupAction.class, getContainer()))) + DIV(new LinkBuilder("Create a new journal group").href(new ActionURL(CreateJournalGroupAction.class, getContainer()))) ))); view.addView(qView); @@ -320,28 +326,28 @@ private ModelAndView getPXCredentialsLink() { ActionURL url = new ActionURL(ManageProteomeXchangeCredentials.class, getContainer()); return new HtmlView(DIV(at(style, "margin-top:20px;"), - new Link.LinkBuilder("Set ProteomeXchange Credentials").href(url).build())); + new LinkBuilder("Set ProteomeXchange Credentials").href(url).build())); } private ModelAndView getDataCiteCredentialsLink() { ActionURL url = new ActionURL(ManageDataCiteCredentials.class, getContainer()); return new HtmlView(DIV(at(style, "margin-top:20px;"), - new Link.LinkBuilder("Set DataCite Credentials").href(url).build())); + new LinkBuilder("Set DataCite Credentials").href(url).build())); } private ModelAndView getBlueskySettingsLink() { ActionURL url = new ActionURL(ManageBlueskySettings.class, getContainer()); return new HtmlView(DIV(at(style, "margin-top:20px;"), - new Link.LinkBuilder("Bluesky Settings").href(url).build())); + new LinkBuilder("Bluesky Settings").href(url).build())); } private ModelAndView getPanoramaPublicCatalogSettingsLink() { ActionURL url = new ActionURL(ManageCatalogEntrySettings.class, getContainer()); return new HtmlView(DIV(at(style, "margin-top:20px;"), - new Link.LinkBuilder("Panorama Public Catalog Settings").href(url).build())); + new LinkBuilder("Panorama Public Catalog Settings").href(url).build())); } private ModelAndView getPostSupportMessageLink() @@ -351,7 +357,7 @@ private ModelAndView getPostSupportMessageLink() { ActionURL url = new ActionURL(CreatePanoramaPublicMessageAction.class, panoramaPublic.getProject()); return new HtmlView(DIV(at(style, "margin-top:20px;"), - new Link.LinkBuilder("Post to Panorama Public Support Messages").href(url).build())); + new LinkBuilder("Post to Panorama Public Support Messages").href(url).build())); } return null; } @@ -1019,7 +1025,7 @@ public ModelAndView getSuccessView(DataCiteCredentialsForm form) return new HtmlView( DIV("DataCite credentials saved!", BR(), - new Link.LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); + new LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); } @Override @@ -1166,7 +1172,7 @@ public ModelAndView getSuccessView(PXCredentialsForm form) return new HtmlView( DIV("ProteomeXchange credentials saved!", BR(), - new Link.LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); + new LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); } @Override @@ -1224,59 +1230,68 @@ public void setPassword(String password) } @RequiresPermission(AdminOperationsPermission.class) - public static class ManageBlueskySettings extends FormViewAction + public static class ManageBlueskySettings extends FormViewAction { @Override - public void validateCommand(BlueskySettingsForm form, Errors errors) + public void validateCommand(BlueskySettings form, Errors errors) { - if (StringUtils.isBlank(form.getUserName())) + if (StringUtils.isBlank(form.getAccount())) { - errors.reject(ERROR_MSG, "User name cannot be blank"); + errors.reject(ERROR_MSG, "Account cannot be blank"); } if (StringUtils.isBlank(form.getPassword())) { errors.reject(ERROR_MSG, "Password cannot be blank"); } - if (StringUtils.isBlank(form.getTestAccountUser())) + if (StringUtils.isBlank(form.getTestAccount())) { - errors.reject(ERROR_MSG, "User name for test account cannot be blank"); + errors.reject(ERROR_MSG, "Test account cannot be blank"); } if (StringUtils.isBlank(form.getTestAccountPassword())) { errors.reject(ERROR_MSG, "Password for test account cannot be blank"); } + if (StringUtils.isBlank(form.getAuthEndpoint())) + { + errors.reject(ERROR_MSG, "Auth URL cannot be blank"); + } + if (StringUtils.isBlank(form.getPostEndpoint())) + { + errors.reject(ERROR_MSG, "Post URL cannot be blank"); + } + if (StringUtils.isBlank(form.getBlobUploadEndpoint())) + { + errors.reject(ERROR_MSG, "Image upload URL cannot be blank"); + } } @Override - public boolean handlePost(BlueskySettingsForm form, BindException errors) + public boolean handlePost(BlueskySettings form, BindException errors) { + BlueskyApiClient client = BlueskyApiClient.getInstance(); try { - new BlueskyService().login(form.getUserName(), form.getPassword()); + client.login(form, false); } catch (BlueskyException e) { - errors.reject(ERROR_MSG, "Bluesky login failed for the user account " + form.getUserName() + ". " + e.getMessage()); + errors.reject(ERROR_MSG, String.format("Bluesky login failed for the account '%s' using auth endpoint '%s'. Error was %s", + form.getAccount(), form.getAuthEndpoint(), e.getMessage())); return false; } try { - new BlueskyService().login(form.getTestAccountUser(), form.getTestAccountPassword()); + client.login(form, true); } catch (BlueskyException e) { - errors.reject(ERROR_MSG, "Bluesky login failed for the test user account " + form.getTestAccountUser() + ". " + e.getMessage()); + errors.reject(ERROR_MSG, String.format("Bluesky login failed for the test account '%s' using auth endpoint '%s'. Error was %s", + form.getTestAccount(), form.getAuthEndpoint(), e.getMessage())); return false; } - WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, true); - map.put(BlueskyService.USER, form.getUserName()); - map.put(BlueskyService.PASSWORD, form.getPassword()); - map.put(BlueskyService.TEST_USER, form.getUserName()); - map.put(BlueskyService.TEST_PASSWORD, form.getPassword()); - map.save(); - + String logoFileName; List files = getAttachmentFileList(); AttachmentFile panoramaLogoFile = files.stream().findFirst().orElse(null); if (panoramaLogoFile != null) @@ -1289,49 +1304,59 @@ public boolean handlePost(BlueskySettingsForm form, BindException errors) } try { - PanoramaPublicLogoManager.saveNewDataLogo(panoramaLogoFile, getUser()); + logoFileName = PanoramaPublicLogoManager.saveNewDataLogo(panoramaLogoFile, getUser()); } catch (IOException e) { errors.reject(ERROR_MSG, "Unable to save logo file. " + e.getMessage()); return false; } + form.setImageFileName(logoFileName); } + else if (form.getImageFileName() == null) + { + errors.reject(ERROR_MSG, "Logo image file was not uploaded"); + return false; + } + + if (!StringUtils.isBlank(form.getHashtags())) + { + form.setHashtags(StringUtils.join(form.getHashtagArray(), ", ")); + } + if (!StringUtils.isBlank(form.getTestHashtags())) + { + form.setTestHashtags(StringUtils.join(form.getTestHashtagArray(), ", ")); + } + + BlueskySettingsManager.saveSettings(form); return true; } @Override - public URLHelper getSuccessURL(BlueskySettingsForm form) + public URLHelper getSuccessURL(BlueskySettings form) { return null; } @Override - public ModelAndView getSuccessView(BlueskySettingsForm form) + public ModelAndView getSuccessView(BlueskySettings form) { ActionURL adminUrl = new ActionURL(PanoramaPublicAdminViewAction.class, getContainer()); return new HtmlView( DIV("Bluesky settings saved!", BR(), - new Link.LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); + new LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); } @Override - public ModelAndView getView(BlueskySettingsForm form, boolean reshow, BindException errors) + public ModelAndView getView(BlueskySettings form, boolean reshow, BindException errors) { if(!reshow) { - WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, false); - if(map != null) - { - form.setUserName(map.get(BlueskyService.USER)); - form.setTestAccountUser(map.get(BlueskyService.TEST_USER)); - Attachment logoAttachment = PanoramaPublicLogoManager.getNewDataLogo(); - if (logoAttachment != null) - { - form.setImageFileName(logoAttachment.getName()); - } - } + form = BlueskySettingsManager.getSettings(); + // Passwords should not be displayed in the form. Make the user re-enter them. + form.setPassword(null); + form.setTestAccountPassword(null); } JspView view = new JspView<>("/org/labkey/panoramapublic/view/manageBlueskySettings.jsp", form, errors); view.setFrame(WebPartView.FrameType.PORTAL); @@ -1347,67 +1372,6 @@ public void addNavTrail(NavTree root) } } - public static class BlueskySettingsForm - { - private String _userName; - private String _password; - - private String _testAccountUser; - private String _testAccountPassword; - - private String _imageFileName; - - public String getUserName() - { - return _userName; - } - - public void setUserName(String userName) - { - _userName = userName; - } - - public String getPassword() - { - return _password; - } - - public void setPassword(String password) - { - _password = password; - } - - public String getTestAccountUser() - { - return _testAccountUser; - } - - public void setTestAccountUser(String testAccountUser) - { - _testAccountUser = testAccountUser; - } - - public String getTestAccountPassword() - { - return _testAccountPassword; - } - - public void setTestAccountPassword(String testAccountPassword) - { - _testAccountPassword = testAccountPassword; - } - - public String getImageFileName() - { - return _imageFileName; - } - - public void setImageFileName(String imageFileName) - { - _imageFileName = imageFileName; - } - } - @RequiresPermission(ReadPermission.class) public class DownloadLogoForBlueskyAction extends BaseDownloadAction { @@ -1417,7 +1381,13 @@ public Pair getAttachment(AttachmentForm form) { AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); if (ap == null) return null; - Attachment attachment = PanoramaPublicLogoManager.getNewDataLogo(); + + BlueskySettings settings = BlueskySettingsManager.getSettings(); + if (StringUtils.isBlank(settings.getImageFileName())) + { + return null; + } + Attachment attachment = PanoramaPublicLogoManager.getNewDataLogo(settings.getImageFileName()); if (attachment != null) { return new Pair<>(ap, attachment.getName()); @@ -1438,6 +1408,7 @@ public void validateCommand(Object target, Errors errors) public boolean handlePost(Object o, BindException errors) { PanoramaPublicLogoManager.deleteExistingNewDataLogo(getUser()); + BlueskySettingsManager.removeLogoFileName(); return true; } @@ -1563,7 +1534,7 @@ public ModelAndView getSuccessView(ManageCatalogEntryForm form) return new HtmlView( DIV("Panorama Public catalog entry settings were saved.", BR(), - new Link.LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); + new LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); } @Override @@ -2799,7 +2770,7 @@ public ModelAndView getSuccessView(PublishExperimentForm form) DIV(dataPrivate), DIV(pxdAssigned), BR(), BR(), - new Link.LinkBuilder("Back to Experiment Details").href(returnUrl).build())); + new LinkBuilder("Back to Experiment Details").href(returnUrl).build())); view.setTitle(getSuccessViewTitle()); return view; @@ -2879,7 +2850,7 @@ private static HtmlView getStartValidationView(@Nullable DOM.Renderable message, { ActionURL noPxSubmissionUrl = submitUrl.clone().replaceParameter("getPxid", "false"); componentList.add(DIV(at(style, "margin-bottom:10px;"), "If you do not want a ProteomeXchange ID click the link to ", - new Link.LinkBuilder("Submit without a ProteomeXchange ID").href(noPxSubmissionUrl).build())); + new LinkBuilder("Submit without a ProteomeXchange ID").href(noPxSubmissionUrl).build())); } componentList.add(DIV(at(style, "top-bottom:10px;"), new Button.ButtonBuilder("Back to Experiment Details") .href(getViewExperimentDetailsURL(experimentAnnotations.getId(), container)).build())); @@ -2946,7 +2917,7 @@ private static HtmlView getDataValidationIncompleteView(DataValidation dataValid "Data validation with Id " + dataValidation.getId() + " is incomplete.", pipelineJobStatus != null ? SPAN("The status of the pipeline job (Id: " + dataValidation.getJobId() + ") is ", - new Link.LinkBuilder(pipelineJobStatus.getStatus()).href(jobDetailsUrl).build()) + new LinkBuilder(pipelineJobStatus.getStatus()).href(jobDetailsUrl).build()) : " The pipeline job (Id: " + dataValidation.getJobId() + ") may have been deleted."), forSubmit, experimentAnnotations, container, null); } @@ -2969,7 +2940,7 @@ private static DOM.Renderable getSubfolderListHtml(Container parent, List !child.equals(parent)) - .map(child -> LI(new Link.LinkBuilder(parent.getParsedPath().relativize(child.getParsedPath()).toString()) + .map(child -> LI(new LinkBuilder(parent.getParsedPath().relativize(child.getParsedPath()).toString()) .href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(child)) .clearClasses()))); } @@ -3720,18 +3691,18 @@ private static HtmlView getValidationSummary(Status status, ExperimentAnnotation User createdByUser = UserManager.getUser(validation.getCreatedBy()); ActionURL validationDetailsUrl = getPxValidationStatusUrl(exptAnnotations.getId(), validation.getId(), container); return new HtmlView(TABLE(cl("lk-fields-table"), - displayExperimentTitle ? row("Experiment: ", DIV(exptAnnotations.getTitle(), HtmlString.NBSP, new Link.LinkBuilder("View Details") + displayExperimentTitle ? row("Experiment: ", DIV(exptAnnotations.getTitle(), HtmlString.NBSP, new LinkBuilder("View Details") .href(getViewExperimentDetailsURL(exptAnnotations.getId(), container)).build())) : HtmlString.EMPTY_STRING, row("Last Validation Date: ", validation.getFormattedDate()), createdByUser != null ? - row("Created By: ", new Link.LinkBuilder(createdByUser.getDisplayName(user)) + row("Created By: ", new LinkBuilder(createdByUser.getDisplayName(user)) .href(PageFlowUtil.urlProvider(UserUrls.class).getUserDetailsURL(container, createdByUser.getUserId(), null)) .clearClasses().build()) : row("Created By: ", "Unknown User " + validation.getCreatedBy()), row("ProteomeXchange Status:", SPAN(getValidationStatusForSummary(validation, statusFile, exptAnnotations, user), - HtmlString.NBSP, new Link.LinkBuilder("[Details]").href(validationDetailsUrl).build())), + HtmlString.NBSP, new LinkBuilder("[Details]").href(validationDetailsUrl).build())), row("Validation Log:", statusFile != null ? - new Link.LinkBuilder("View log").href(PageFlowUtil.urlProvider(PipelineStatusUrls.class) + new LinkBuilder("View log").href(PageFlowUtil.urlProvider(PipelineStatusUrls.class) .urlDetails(container, validation.getJobId())).build() : SPAN("Log file not found for job Id " + validation.getJobId())) )); @@ -4576,7 +4547,7 @@ public ModelAndView getSuccessView(PxActionsForm form) SPAN("Response from PX server: "), BR(), DIV(at(style, "white-space: pre-wrap;margin:10px 0px 10px 0px;"), _pxResponse), - DIV(new Link.LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); + DIV(new LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); } private PxXml createPxXml(ExperimentAnnotations expAnnot, JournalExperiment je, Submission submission, Status validationStatus, String pxChanageLog, boolean submittingToPx) throws PxException @@ -5088,18 +5059,18 @@ public ModelAndView getView(ExperimentIdForm form, BindException errors) return new HtmlView( DIV(DIV(at(style, "margin-bottom:10px;"), "DOI assigned to the data is " + _expAnnot.getDoi()), DIV( - new Link.LinkBuilder("Publish DOI").clearClasses().addClass("btn btn-default").href(new ActionURL(PublishDoiAction.class, getContainer()).addParameter("id", _expAnnot.getId())), + new LinkBuilder("Publish DOI").clearClasses().addClass("btn btn-default").href(new ActionURL(PublishDoiAction.class, getContainer()).addParameter("id", _expAnnot.getId())), SPAN(at(style, "margin:5px;")), - new Link.LinkBuilder("Delete DOI").clearClasses().addClass("btn btn-default").href(new ActionURL(DeleteDoiAction.class, getContainer()).addParameter("id", _expAnnot.getId()))), + new LinkBuilder("Delete DOI").clearClasses().addClass("btn btn-default").href(new ActionURL(DeleteDoiAction.class, getContainer()).addParameter("id", _expAnnot.getId()))), updateDoiForm)); } else { return new HtmlView( - DIV(DIV(new Link.LinkBuilder("Assign New DOI").clearClasses().addClass("btn btn-default").href(getAssignDoiUrl(_expAnnot, getContainer(), false)), + DIV(DIV(new LinkBuilder("Assign New DOI").clearClasses().addClass("btn btn-default").href(getAssignDoiUrl(_expAnnot, getContainer(), false)), SPAN(at(style, "margin:5px;")), - new Link.LinkBuilder("Assign New Test DOI").clearClasses().addClass("btn btn-default").href(getAssignDoiUrl(_expAnnot, getContainer(), true))), + new LinkBuilder("Assign New Test DOI").clearClasses().addClass("btn btn-default").href(getAssignDoiUrl(_expAnnot, getContainer(), true))), updateDoiForm)); } } @@ -5192,7 +5163,7 @@ public ModelAndView getSuccessView(DoiForm form) return new HtmlView( DIV(getSuccessMessage(), BR(), - DIV(new Link.LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); + DIV(new LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); } protected abstract DOM.Renderable getSuccessMessage(); @@ -5501,35 +5472,11 @@ public void validateCommand(BlueskyForm form, Errors errors) @Override public boolean handlePost(BlueskyForm form, BindException errors) { - WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.CREDENTIALS, false); - String user = null; - String password = null; - if(map != null) - { - user = form.isTestAccount() ? map.get(BlueskyService.TEST_USER) : map.get(BlueskyService.USER); - password = form.isTestAccount() ? map.get(BlueskyService.TEST_PASSWORD) : map.get(BlueskyService.PASSWORD); - } - if(StringUtils.isBlank(user)) - { - errors.reject(ERROR_MSG, String.format("Cannot find username for Bluesky %saccount.", form.isTestAccount() ? "test " : "")); - } - if(StringUtils.isBlank(password)) - { - errors.reject(ERROR_MSG, String.format("Cannot find password for Bluesky %saccount.", form.isTestAccount() ? "test " : "")); - } - if(errors.getErrorCount() > 0) - { - return false; - } - + BlueskySettings settings = BlueskySettingsManager.getSettings(); try { // Post to Bluesky - _blueSkyPostUrl = new BlueskyService().createBlueskyPost(_expAnnot, form.isTestAccount(), user, password); - - WritablePropertyMap propertyMap = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, true); - propertyMap.put(_expAnnot.getShortUrl().renderShortURL(), _blueSkyPostUrl); - propertyMap.save(); + _blueSkyPostUrl = BlueskyApiClient.getInstance().createPost(_expAnnot, settings, form.isTestAccount()); } catch (BlueskyException e) { @@ -5549,30 +5496,40 @@ public boolean handlePost(BlueskyForm form, BindException errors) @Override public ModelAndView getConfirmView(BlueskyForm form, BindException errors) { - WritablePropertyMap map = PropertyManager.getEncryptedStore().getWritableProperties(BlueskyService.BLUESKY_LINK, false); + CatalogEntry entry = CatalogEntryManager.getApprovedEntryForExperiment(_expAnnot); + ActionURL imageUrl = entry != null + ? PanoramaPublicController.getCatalogImageDownloadUrl(_expAnnot, entry.getImageFileName()) + : new ActionURL(DownloadLogoForBlueskyAction.class, getContainer()); Renderable announcementDiv = DIV(cl("bluebox").at(style, "padding:25px;"), - DIV(BlueskyService.ANNOUNCEMENT_TEXT), + DIV(BlueskyApiClient.ANNOUNCEMENT_TEXT), + DIV(IMG(at(src, imageUrl) + .at(width, 320).at(height, 180))), DIV(at(style, "font-weight:bold;"), _expAnnot.getTitle()), - DIV(new Link.LinkBuilder(_expAnnot.getShortUrl().renderShortURL()) + DIV(new LinkBuilder(_expAnnot.getShortUrl().renderShortURL()) .href(_expAnnot.getShortUrl().renderShortURL()) .clearClasses() .build()) ); Renderable testPostCb = DIV(at(style, "margin-top:20px;"),CHECKBOX(at(name,"testAccount")), "Post to test account"); - String blueskyPostUrl = map != null ? map.get(_expAnnot.getShortUrl().renderShortURL()) : null; + String blueskyPostUrl = BlueskySettingsManager.getPostUrlForExperiment(_expAnnot); if (blueskyPostUrl != null) { - Link blueskyLink = new Link.LinkBuilder((blueskyPostUrl)) - .href(BlueskyService.convertToClickableUrl(blueskyPostUrl)) - .clearClasses() - .build(); + String webUrl = BlueskyApiClient.tryFormatBlueskyUrl(blueskyPostUrl); return new HtmlView( DIV("This data has already been announced on Bluesky at ", - blueskyLink, + webUrl == null + ? blueskyPostUrl + : new LinkBuilder((blueskyPostUrl)).href(webUrl) + .clearClasses() + .build(), ". Would you like to post the following message again? ", announcementDiv, - testPostCb + testPostCb, + DIV(new LinkBuilder("Clear saved post URL") + .usePost() + .href(new ActionURL(ClearSavedBlueskyPostUrlAction.class, getContainer()).addParameter("id", _expAnnot.getId())) + .build()) )); } else @@ -5587,16 +5544,16 @@ public ModelAndView getConfirmView(BlueskyForm form, BindException errors) @Override public ModelAndView getSuccessView(BlueskyForm form) { - Link blueskyLink = new Link.LinkBuilder((_blueSkyPostUrl)) - .href(BlueskyService.convertToClickableUrl(_blueSkyPostUrl)) - .clearClasses() - .build(); + String webUrl = BlueskyApiClient.tryFormatBlueskyUrl(_blueSkyPostUrl); return new HtmlView( DIV("Posted to Bluesky!", - blueskyLink, + webUrl == null + ? _blueSkyPostUrl + : new LinkBuilder((_blueSkyPostUrl)).href(webUrl).clearClasses() + .build(), BR(), - DIV(new Link.LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); + DIV(new LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); } @Override @@ -5627,6 +5584,40 @@ public void setTestAccount(boolean testAccount) _testAccount = testAccount; } } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ClearSavedBlueskyPostUrlAction extends FormHandlerAction + { + @Override + public void validateCommand(ExperimentIdForm form, Errors errors) + { + ExperimentAnnotations expAnnot = form.lookupExperiment(); + if(expAnnot == null) + { + errors.reject(ERROR_MSG, "Cannot find experiment with ID " + form.getId()); + } + ensureCorrectContainer(getContainer(), expAnnot.getContainer(), getViewContext()); + if (expAnnot.getShortUrl() == null) + { + errors.reject(ERROR_MSG, "Experiment Id " + expAnnot.getId() + " does not have a short access URL"); + } + } + + @Override + public boolean handlePost(ExperimentIdForm form, BindException errors) + { + BlueskySettingsManager.clearPostUrlForExperiment(form.lookupExperiment()); + return true; + } + + @Override + public URLHelper getSuccessURL(ExperimentIdForm experimentIdForm) + { + return PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(getContainer()); + } + + + } // ------------------------------------------------------------------------ // END Actions for posting to Bluesky // ------------------------------------------------------------------------ @@ -5911,7 +5902,7 @@ public ModelAndView getView(final ViewExperimentAnnotationsForm form, BindExcept if (getViewContext().hasPermission(AdminOperationsPermission.class) && DataValidationManager.getValidationJobCount(exptAnnotations.getId()) > 1) { ActionURL url = new ActionURL(ViewPxValidationsAction.class, getContainer()).addParameter("id", exptAnnotations.getId()); - viewAllLink = new Link.LinkBuilder("View All Validation Jobs").href(url).build(); + viewAllLink = new LinkBuilder("View All Validation Jobs").href(url).build(); } view.addView(new HtmlView(DIV(at(style, "margin-top:15px;"), getRerunDataValidationButton(exptAnnotations, getContainer()), @@ -6019,7 +6010,7 @@ else if(children.size() > 0) ActionURL url = PanoramaPublicController.getDataValidationCheckUrl(exptAnnotations.getId(), exptAnnotations.getContainer(), true); url.addReturnURL(getViewExperimentDetailsURL(exptAnnotations.getId(), exptAnnotations.getContainer())); - vBox.addView(new HtmlView(DIV(new Link.LinkBuilder("Validate for ProteomeXchange").href(url).build()))); + vBox.addView(new HtmlView(DIV(new LinkBuilder("Validate for ProteomeXchange").href(url).build()))); result.addView(vBox); } @@ -6761,6 +6752,7 @@ public static class MakePublicAction extends FormViewAction "#" + tag) .collect(Collectors.joining(", ")); } - /** - * Add hashtag facets to the record for Bluesky's rich text formatting - */ - private void addHashTags(JSONObject record, String postText, String[] hashtags) - { - JSONArray facets = createHashtagFacets(postText, hashtags); - - // Add facets to the record if we have any - if (!facets.isEmpty()) - { - record.put("facets", facets); - } - } - /** * Create hashtag facets for Bluesky's rich text formatting */ - private JSONArray createHashtagFacets(String postText, String[] hashtags) + private static JSONArray buildHashtagFacets(String postText, String[] hashtags) { JSONArray facets = new JSONArray(); @@ -286,51 +278,107 @@ private JSONArray createHashtagFacets(String postText, String[] hashtags) /** * Create a single hashtag facet for Bluesky's rich text formatting - */ - private JSONObject createSingleHashtagFacet(String postText, String tag) + */ + @Nullable + private static JSONObject createSingleHashtagFacet(@NotNull String postText, @NotNull String tag) { // Find the tag in the text (including the # character) String hashtagInText = "#" + tag; int tagStart = postText.indexOf(hashtagInText); - if (tagStart == -1) { + if (tagStart == -1) + { return null; } // Convert to byte position for UTF-8 - byte[] beforeTagBytes = postText.substring(0, tagStart).getBytes(StandardCharsets.UTF_8); - int byteStart = beforeTagBytes.length; - - byte[] tagBytes = hashtagInText.getBytes(StandardCharsets.UTF_8); - int byteEnd = byteStart + tagBytes.length; - - // Create the tag facet - JSONObject tagFacet = new JSONObject(); - - // Create index object - JSONObject indices = new JSONObject(); - indices.put("byteStart", byteStart); - indices.put("byteEnd", byteEnd); - tagFacet.put("index", indices); - - // Create features array - JSONArray features = new JSONArray(); - JSONObject tagFeature = new JSONObject(); - tagFeature.put("$type", "app.bsky.richtext.facet#tag"); - tagFeature.put("tag", tag); // Tag without the # character - features.put(tagFeature); - tagFacet.put("features", features); - - return tagFacet; + int byteStart = postText.substring(0, tagStart).getBytes(StandardCharsets.UTF_8).length; + int byteEnd = byteStart + hashtagInText.getBytes(StandardCharsets.UTF_8).length; + + JSONObject indices = new JSONObject() + .put("byteStart", byteStart) + .put("byteEnd", byteEnd); + + JSONObject feature = new JSONObject() + .put("$type", "app.bsky.richtext.facet#tag") + .put("tag", tag); // Tag without the # character + + return new JSONObject() + .put("index", indices) + .put("features", new JSONArray().put(feature)); + } + + @NotNull + private JSONObject buildEmbed(@NotNull ExperimentAnnotations exptAnnotations, @NotNull BlueskySettings settings, @NotNull LoginInfo loginInfo) throws BlueskyException + { + // Create the embed object for a web card + JSONObject embed = new JSONObject() + .put("$type", "app.bsky.embed.external"); + + String panoramaLink = exptAnnotations.getShortUrl().renderShortURL(); + + JSONObject external = new JSONObject() + .put("uri", panoramaLink) + .put("title", exptAnnotations.getTitle()) + .put("description", panoramaLink); + + // Upload and grab the blob reference for the logo image + external.put("thumb", getImageBlobReference(exptAnnotations, settings, loginInfo)); + + embed.put("external", external); + return embed; + } + + private JSONObject getImageBlobReference(ExperimentAnnotations exptAnnotations, BlueskySettings settings, LoginInfo loginInfo) throws BlueskyException + { + AttachmentParent attachmentParent = null; + // First check if the data submitter provided a catalog entry for the data. + Attachment attachment = getCatalogEntryAttachment(exptAnnotations); + if (attachment != null) + { + attachmentParent = new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer()); + } + else if (!StringUtils.isBlank(settings.getImageFileName())) + { + // If the data does not have a catalog entry, or we were unable to get it, use the Panorama Public logo + attachmentParent = PanoramaPublicLogoAttachmentParent.get(); + if (attachmentParent == null) + { + throw new BlueskyException("Unable to initialize PanoramaPublicLogoAttachmentParent. Perhaps a Panorama Public project does not exist on the server."); + } + attachment = PanoramaPublicLogoManager.getNewDataLogo(settings.getImageFileName()); + } + + if (attachment == null) + { + throw new BlueskyException("Unable to find an image file to include in the post."); + } + + JSONObject blobResponse = uploadImage(attachment, attachmentParent, settings, loginInfo); + // Get the blob reference from the response + if (blobResponse.has("blob")) + { + return blobResponse.getJSONObject("blob"); + } + else + { + throw new BlueskyException("Blob reference not found in Bluesky response after uploading the image file."); + } + } + + private static Attachment getCatalogEntryAttachment(ExperimentAnnotations exptAnnotations) + { + CatalogEntry catalogEntry = CatalogEntryManager.getApprovedEntryForExperiment(exptAnnotations); + return (catalogEntry != null) ? catalogEntry.getAttachment() : null; } /** * Upload image bytes to Bluesky */ @NotNull - private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType, LoginInfo loginInfo) throws BlueskyException + private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType, BlueskySettings settings, LoginInfo loginInfo) throws BlueskyException { - HttpPost httpPost = new HttpPost(BLOB_UPLOAD_URL); + HttpPost httpPost = new HttpPost(settings.getBlobUploadEndpoint()); httpPost.setHeader("Content-Type", mimeType); httpPost.setHeader("Authorization", "Bearer " + loginInfo.getAccessJwt()); httpPost.setEntity(new ByteArrayEntity(imageBytes, ContentType.create(mimeType))); @@ -347,7 +395,7 @@ private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType, LoginInfo } @NotNull - private static BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost httpPost, String failureMessage) throws IOException, BlueskyException + private BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost httpPost, String failureMessage) throws IOException, BlueskyException { BlueskyResponse response = httpClient.execute(httpPost, new BlueskyResponseHandler()); String responseContent = response.getResponseBody() != null ? response.getResponseBody() : ""; @@ -369,13 +417,14 @@ private static BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpP * Upload an image attachment to Bluesky */ @NotNull - private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent, LoginInfo loginInfo) throws BlueskyException + private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent, + BlueskySettings settings, LoginInfo loginInfo) throws BlueskyException { try (InputStream is = AttachmentService.get().getInputStream(parent, attachment.getName())) { byte[] imageBytes = is.readAllBytes(); String mimeType = PageFlowUtil.getContentTypeFor(attachment.getName()); - return uploadToBluesky(imageBytes, mimeType, loginInfo); + return uploadToBluesky(imageBytes, mimeType, settings, loginInfo); } catch (FileNotFoundException e) { @@ -410,36 +459,27 @@ public BlueskyResponse handleResponse(ClassicHttpResponse response) throws IOExc } } + private static final Pattern AT_URI = Pattern.compile( + "^at://([^/]+)/app\\.bsky\\.feed\\.post/([^/]+)$" + ); + + /** - * Convert a Bluesky post URI to a clickable URL - * Example: at:///app.bsky.feed.post/ + * Converts an AT Protocol URI to a Bluesky web URL. + * Returns null if the input cannot be converted to a valid Bluesky URL. + * Example: at:///app.bsky.feed.post/ * Convert to: https://bsky.app/profile//post/ */ - public static String convertToClickableUrl(String postUri) + public static String tryFormatBlueskyUrl(String postUri) { - // Handle URIs with or without leading slashes - String cleanUri = postUri.replaceFirst("^//", ""); - - // Extract the DID and post ID - String did = ""; - String postId = ""; - - if (cleanUri.startsWith("at://")) - { - cleanUri = cleanUri.substring(5); // Remove "at://" - } - - String[] parts = cleanUri.split("/"); - if (parts.length >= 1) + if (StringUtils.isBlank(postUri)) { - did = parts[0]; - } - - if (parts.length >= 3) - { - postId = parts[parts.length - 1]; + return null; } - return "https://bsky.app/profile/" + did + "/post/" + postId; + Matcher m = AT_URI.matcher(postUri.trim()); + return m.matches() + ? "https://bsky.app/profile/" + m.group(1) + "/post/" + m.group(2) + : null; } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java new file mode 100644 index 00000000..659f448c --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java @@ -0,0 +1,162 @@ +package org.labkey.panoramapublic.bluesky; + +import java.util.Arrays; +import org.apache.commons.lang3.StringUtils; + +public class BlueskySettings +{ + private String _account; + private String _password; + + private String _testAccount; + private String _testAccountPassword; + + private String _imageFileName; + + private boolean _autopost; + + private String _hashtags; + private String _testHashtags; + + private String _authEndpoint; + private String _postEndpoint; + private String _blobUploadEndpoint; + + public String getAccount() + { + return _account; + } + + public void setAccount(String account) + { + _account = account; + } + + public String getPassword() + { + return _password; + } + + public void setPassword(String password) + { + _password = password; + } + + public String getTestAccount() + { + return _testAccount; + } + + public void setTestAccount(String testAccount) + { + _testAccount = testAccount; + } + + public String getTestAccountPassword() + { + return _testAccountPassword; + } + + public void setTestAccountPassword(String testAccountPassword) + { + _testAccountPassword = testAccountPassword; + } + + public String getImageFileName() + { + return _imageFileName; + } + + public void setImageFileName(String imageFileName) + { + _imageFileName = imageFileName; + } + + public boolean isAutopost() + { + return _autopost; + } + + public void setAutopost(boolean autopost) + { + _autopost = autopost; + } + + public String getHashtags() + { + return _hashtags; + } + + public String[] getHashtagArray() + { + return convertToArray(_hashtags); + } + + public void setHashtags(String hashtags) + { + _hashtags = hashtags; + } + + public String getTestHashtags() + { + return _testHashtags; + } + + public String[] getTestHashtagArray() + { + return convertToArray(_testHashtags); + } + + public void setTestHashtags(String testHashtags) + { + _testHashtags = testHashtags; + } + + private static String[] convertToArray(String hashtags) + { + if (StringUtils.isBlank(hashtags)) + { + return new String[0]; + } + + return Arrays.stream(hashtags.split(",")) + .map(String::trim) // remove surrounding whitespace + .filter(tag -> !tag.isEmpty()) // filter out empty tokens + .map(tag -> tag.replaceAll("\\s+", "")) // remove all internal spaces + .map(tag -> tag.startsWith("#") // strip leading ‘#’ if present + ? tag.substring(1) + : tag) + .distinct() // remove duplicates + .toArray(String[]::new); + } + + public String getAuthEndpoint() + { + return _authEndpoint; + } + + public void setAuthEndpoint(String authEndpoint) + { + _authEndpoint = authEndpoint; + } + + public String getPostEndpoint() + { + return _postEndpoint; + } + + public void setPostEndpoint(String postEndpoint) + { + _postEndpoint = postEndpoint; + } + + public String getBlobUploadEndpoint() + { + return _blobUploadEndpoint; + } + + public void setBlobUploadEndpoint(String blobUploadEndpoint) + { + _blobUploadEndpoint = blobUploadEndpoint; + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java new file mode 100644 index 00000000..1458b13e --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java @@ -0,0 +1,119 @@ +package org.labkey.panoramapublic.bluesky; + +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; +import org.labkey.panoramapublic.model.ExperimentAnnotations; + +public class BlueskySettingsManager +{ + private static final String CREDENTIALS = "Bluesky credentials"; + private static final String ACCOUNT = "Bluesky account"; + private static final String PASSWORD = "Bluesky password"; + + private static final String TEST_ACCOUNT = "Bluesky test account"; + private static final String TEST_ACCOUNT_PASSWORD = "Bluesky test password"; + + private static final String SETTINGS = "Bluesky settings"; + private static final String AUTH_URL = "Bluesky auth endpoint"; + private static final String POST_URL = "Bluesky post endpoint"; + private static final String BLOB_UPLOAD_URL = "Image blob upload endpoint"; + + private static final String HASHTAGS = "Hashtags"; + private static final String TEST_HASHTAGS = "Hashtags for test account"; + private static final String PANORAMA_LOGO_FILENAME = "Panorama logo file name"; + private static final String AUTOPOST = "Auto‑post to Bluesky on publish"; + + + public static final String BLUESKY_LINKS = "Bluesky post links"; + + public static BlueskySettings getSettings() + { + BlueskySettings settings = new BlueskySettings(); + WritablePropertyMap credentialsMap = PropertyManager.getEncryptedStore().getWritableProperties(CREDENTIALS, false); + if(credentialsMap != null) + { + settings.setAccount(credentialsMap.get(ACCOUNT)); + settings.setPassword(credentialsMap.get(PASSWORD)); + settings.setTestAccount(credentialsMap.get(TEST_ACCOUNT)); + settings.setTestAccountPassword(credentialsMap.get(TEST_ACCOUNT_PASSWORD)); + } + WritablePropertyMap settingsMap = PropertyManager.getNormalStore().getWritableProperties(SETTINGS, false); + if (settingsMap != null) + { + settings.setAuthEndpoint(settingsMap.get(AUTH_URL)); + settings.setPostEndpoint(settingsMap.get(POST_URL)); + settings.setBlobUploadEndpoint(settingsMap.get(BLOB_UPLOAD_URL)); + settings.setHashtags(settingsMap.get(HASHTAGS)); + settings.setTestHashtags(settingsMap.get(TEST_HASHTAGS)); + settings.setAutopost(Boolean.valueOf(settingsMap.get(AUTOPOST))); + settings.setImageFileName(settingsMap.get(PANORAMA_LOGO_FILENAME)); + } + return settings; + } + + public static void saveSettings(BlueskySettings settings) + { + WritablePropertyMap credentialsMap = PropertyManager.getEncryptedStore().getWritableProperties(CREDENTIALS, true); + credentialsMap.put(ACCOUNT, settings.getAccount()); + credentialsMap.put(PASSWORD, settings.getPassword()); + credentialsMap.put(TEST_ACCOUNT, settings.getTestAccount()); + credentialsMap.put(TEST_ACCOUNT_PASSWORD, settings.getTestAccountPassword()); + credentialsMap.save(); + + WritablePropertyMap settingsMap = PropertyManager.getNormalStore().getWritableProperties(SETTINGS, true); + settingsMap.put(PANORAMA_LOGO_FILENAME, settings.getImageFileName()); + settingsMap.put(AUTOPOST, Boolean.toString(settings.isAutopost())); + settingsMap.put(AUTH_URL, settings.getAuthEndpoint()); + settingsMap.put(POST_URL, settings.getPostEndpoint()); + settingsMap.put(BLOB_UPLOAD_URL, settings.getBlobUploadEndpoint()); + settingsMap.put(HASHTAGS, settings.getHashtags()); + settingsMap.put(TEST_HASHTAGS, settings.getTestHashtags()); + settingsMap.save(); + } + + public static void removeLogoFileName() + { + BlueskySettings settings = getSettings(); + settings.setImageFileName(null); + saveSettings(settings); + } + + public static String getPostUrlForExperiment(ExperimentAnnotations experimentAnnotations) + { + if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) + { + return null; + } + + WritablePropertyMap map = PropertyManager.getNormalStore() + .getWritableProperties(BlueskySettingsManager.BLUESKY_LINKS, false); + return map != null ? map.get(experimentAnnotations.getShortUrl().renderShortURL()) : null; + } + + public static void savePostUrlForExperiment(ExperimentAnnotations experimentAnnotations, String blueskyPostUrl) + { + if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) + { + return; + } + WritablePropertyMap propertyMap = PropertyManager.getNormalStore() + .getWritableProperties(BlueskySettingsManager.BLUESKY_LINKS, true); + propertyMap.put(experimentAnnotations.getShortUrl().renderShortURL(), blueskyPostUrl); + propertyMap.save(); + } + + public static void clearPostUrlForExperiment(ExperimentAnnotations experimentAnnotations) + { + if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) + { + return; + } + if (getPostUrlForExperiment(experimentAnnotations) != null) + { + WritablePropertyMap propertyMap = PropertyManager.getNormalStore() + .getWritableProperties(BlueskySettingsManager.BLUESKY_LINKS, true); + propertyMap.remove(experimentAnnotations.getShortUrl().renderShortURL()); + propertyMap.save(); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java index 18d7ea06..0889af85 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java @@ -1,5 +1,6 @@ package org.labkey.panoramapublic.bluesky; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.labkey.api.attachments.Attachment; import org.labkey.api.attachments.AttachmentFile; @@ -16,18 +17,18 @@ public class PanoramaPublicLogoManager { public static String LOGO_FILE_PREFIX = "PanoramaPublicLogo-NewData"; @Nullable - public static Attachment getNewDataLogo() + public static Attachment getNewDataLogo(@NotNull String filename) { AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); if (ap == null) return null; return AttachmentService.get().getAttachments(ap).stream() - .filter(a -> a.getName() != null && LOGO_FILE_PREFIX.equals(FileUtil.getBaseName(a.getName()))) + .filter(a -> a.getName() != null && filename.equals(a.getName())) .findFirst() .orElse(null); } - public static void saveNewDataLogo(AttachmentFile file, User user) throws IOException + public static String saveNewDataLogo(AttachmentFile file, User user) throws IOException { String logoFileName = getLogoFileName(file.getFilename(), LOGO_FILE_PREFIX); AttachmentFile attachmentFile = new InputStreamAttachmentFile(file.openInputStream(), logoFileName); @@ -35,6 +36,7 @@ public static void saveNewDataLogo(AttachmentFile file, User user) throws IOExce AttachmentService svc = AttachmentService.get(); deleteExistingNewDataLogo(svc, user); AttachmentService.get().addAttachments(ap, Collections.singletonList(attachmentFile), user); + return logoFileName; } private static String getLogoFileName(String name, String fileNamePrefix) diff --git a/panoramapublic/src/org/labkey/panoramapublic/query/CatalogEntryManager.java b/panoramapublic/src/org/labkey/panoramapublic/query/CatalogEntryManager.java index 8fd6d5fc..cd2de735 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/query/CatalogEntryManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/query/CatalogEntryManager.java @@ -116,6 +116,12 @@ public static void updateEntry(@NotNull CatalogEntry entry, @NotNull ExperimentA return getEntryForShortUrl(expAnnotations.getShortUrl()); } + public static @Nullable CatalogEntry getApprovedEntryForExperiment(@NotNull ExperimentAnnotations expAnnotations) + { + CatalogEntry entry = getEntryForShortUrl(expAnnotations.getShortUrl()); + return (entry != null && entry.getApproved()) ? entry : null; + } + public static void deleteEntryForExperiment(CatalogEntry entry, @NotNull ExperimentAnnotations expAnnotations, User user) { if (entry != null) diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp index 84616f82..a356d7b7 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp @@ -2,22 +2,22 @@ <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.api.view.JspView" %> <%@ page import="org.labkey.panoramapublic.PanoramaPublicController" %> -<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.BlueskySettingsForm" %> <%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PanoramaPublicAdminViewAction" %> +<%@ page import="org.labkey.panoramapublic.bluesky.BlueskySettings" %> <%@ page extends="org.labkey.api.jsp.FormPage" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <% - JspView currentView = HttpView.currentView(); - BlueskySettingsForm form = currentView.getModelBean(); + JspView currentView = HttpView.currentView(); + BlueskySettings form = currentView.getModelBean(); ActionURL panoramaPublicAdminUrl = urlFor(PanoramaPublicAdminViewAction.class); %>

- - + + @@ -25,23 +25,71 @@ - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -45,7 +46,7 @@ @@ -53,7 +54,14 @@ + + + + + @@ -84,14 +92,14 @@ From 497ea1137dda831abd67f0c053303ae3392bc7c9 Mon Sep 17 00:00:00 2001 From: vagisha Date: Tue, 22 Apr 2025 18:23:14 -0700 Subject: [PATCH 08/12] Added some tests. --- .../panoramapublic/PanoramaPublicModule.java | 2 + .../bluesky/BlueskyApiClient.java | 173 +++++++++++++++--- .../bluesky/BlueskySettings.java | 2 +- 3 files changed, 151 insertions(+), 26 deletions(-) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java index 197b2d6d..52134ea6 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java @@ -43,6 +43,7 @@ import org.labkey.api.view.ViewContext; import org.labkey.api.view.WebPartFactory; import org.labkey.api.view.WebPartView; +import org.labkey.panoramapublic.bluesky.BlueskyApiClient; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentType; import org.labkey.panoramapublic.model.Journal; import org.labkey.panoramapublic.model.speclib.SpecLibKey; @@ -378,6 +379,7 @@ public Set getUnitTests() set.add(ContainerJoin.TestCase.class); set.add(Formula.TestCase.class); set.add(CatalogEntryManager.TestCase.class); + set.add(BlueskyApiClient.TestCase.class); return set; } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java index ef015062..935cad52 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java @@ -18,6 +18,8 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; import org.labkey.api.attachments.Attachment; import org.labkey.api.attachments.AttachmentParent; import org.labkey.api.attachments.AttachmentService; @@ -59,18 +61,9 @@ public LoginInfo login(@NotNull BlueskySettings settings, boolean testAccount) t String account = testAccount ? settings.getTestAccount() : settings.getAccount(); String password = testAccount ? settings.getTestAccountPassword() : settings.getPassword(); - if(StringUtils.isBlank(account)) - { - throw new BlueskyException(String.format("Cannot find Bluesky %saccount.", testAccount ? "test " : "")); - } - if(StringUtils.isBlank(password)) - { - throw new BlueskyException(String.format("Cannot find password for Bluesky %saccount.", testAccount ? "test " : "")); - } - if(StringUtils.isBlank(settings.getAuthEndpoint())) - { - throw new BlueskyException("Bluesky auth endpoint not configured"); - } + validateNotBlank(account, String.format("Cannot find Bluesky %saccount.", testAccount ? "test " : "")); + validateNotBlank(password, String.format("Cannot find password for Bluesky %saccount.", testAccount ? "test " : "")); + validateNotBlank(settings.getAuthEndpoint(), "Bluesky auth endpoint not configured"); JSONObject requestBody = new JSONObject(); requestBody.put("identifier", account); @@ -97,6 +90,14 @@ public LoginInfo login(@NotNull BlueskySettings settings, boolean testAccount) t } } + private static void validateNotBlank(String value, String error) throws BlueskyException + { + if(StringUtils.isBlank(value)) + { + throw new BlueskyException(error); + } + } + public LoginInfo login(@NotNull BlueskySettings settings) throws BlueskyException { return login(settings, false); // Login to the primary account @@ -157,18 +158,10 @@ public String createPostIfNotExists(@NotNull ExperimentAnnotations exptAnnotatio public String createPost(@NotNull ExperimentAnnotations exptAnnotations, @NotNull BlueskySettings settings, @NotNull LoginInfo loginInfo, boolean testPost) throws BlueskyException { - if(StringUtils.isBlank(settings.getAuthEndpoint())) - { - throw new BlueskyException("Bluesky auth endpoint not configured"); - } - if(StringUtils.isBlank(settings.getPostEndpoint())) - { - throw new BlueskyException("Bluesky post endpoint not configured"); - } - if(StringUtils.isBlank(settings.getBlobUploadEndpoint())) - { - throw new BlueskyException("Bluesky image upload endpoint not configured"); - } + validateNotBlank(settings.getAuthEndpoint(), "Bluesky auth endpoint not configured"); + validateNotBlank(settings.getPostEndpoint(), "Bluesky post endpoint not configured"); + validateNotBlank(settings.getBlobUploadEndpoint(), ("Bluesky image upload endpoint not configured")); + if (exptAnnotations.getShortUrl() == null) { throw new BlueskyException(String.format("Experiment with Id %d does not have a short access URL", exptAnnotations.getId())); @@ -268,7 +261,7 @@ private static String formatHashtags(@NotNull String[] hashtags) { return Arrays.stream(hashtags) .map(tag -> "#" + tag) - .collect(Collectors.joining(", ")); + .collect(Collectors.joining(" ")); } /** @@ -494,4 +487,134 @@ public static String tryConvertToWebUrl(String atProtocolUri) ? "https://bsky.app/profile/" + m.group(1) + "/post/" + m.group(2) : null; } + + public static class TestCase extends Assert + { + @Test + public void testConvertToArray() + { + String input = "proteomics, proteomicssky, massspec, massspecsky"; + String[] expected = {"proteomics", "proteomicssky", "massspec", "massspecsky"}; + Arrays.sort(expected); + compareSorted(expected, input); + + // Test for null string + assertArrayEquals(new String[0], BlueskySettings.convertToArray(null)); + + // Test for empty string + assertArrayEquals(new String[0], BlueskySettings.convertToArray("")); + + // Test for input with leading, trailing and internal spaces + input = " proteomics, proteomics sky, massspec , massspecsky "; + compareSorted(expected, input); + + // Test for input with duplicates + input = " proteomics, massspec, proteomics sky, massspec , massspecsky, proteomics "; + compareSorted(expected, input); + + // Test for input with '#' characters + input = " #proteomics, proteomics sky, # massspec , massspecsky, #massspec "; + compareSorted(expected, input); + + // Test for single tag TODO: remove + assertArrayEquals(new String[]{"proteomics"}, BlueskySettings.convertToArray("#proteomics")); + input = "proteomics,proteomics sky,#massspec, massspecsky,proteomics"; + compareSorted(expected, input); + } + + private void compareSorted(String[] expected, String input) + { + String[] actual = BlueskySettings.convertToArray(input); + Arrays.sort(actual); + assertArrayEquals(expected, actual); + } + + @Test + public void testHashtagGettersAndSetters() + { + BlueskySettings settings = new BlueskySettings(); + + // Test primary account hashtags + settings.setHashtags(" #proteomics, proteomics sky, # massspec , massspecsky, #massspec "); + String[] expectedMainTags = {"proteomics", "proteomicssky", "massspec", "massspecsky"}; + // Set test account hashtags + settings.setTestHashtags(" #panoramapublictest, panoramaweb test "); + String[] expectedTestTags = {"panoramapublictest", "panoramawebtest"}; + + assertArrayEquals(expectedMainTags, settings.getHashtagArray()); + assertArrayEquals(expectedTestTags, settings.getTestHashtagArray()); + } + + @Test + public void testAccountGetters() + { + BlueskySettings settings = new BlueskySettings(); + + settings.setAccount("main-account"); + settings.setTestAccount("test-account"); + + // Test account getter with parameter + Assert.assertEquals("main-account", settings.getAccount(false)); + Assert.assertEquals("test-account", settings.getAccount(true)); + } + + @Test + public void testGetPostText() + { + String announcementText = "New data available on Panorama Public!"; + // Test with null hashtags + BlueskySettings settings = new BlueskySettings(); + settings.setAnnouncementText(announcementText); + settings.setHashtags(null); + settings.setTestHashtags(null); + + // Primary account, no hashtags + String text = BlueskyApiClient.getPostText(settings, false); + Assert.assertEquals(announcementText, text); + // Test account, no hashtags + String testText = BlueskyApiClient.getPostText(settings, true); + Assert.assertEquals(announcementText, testText); + + // Test with empty hashtags + settings = new BlueskySettings(); + settings.setAnnouncementText(announcementText); + settings.setHashtags(""); + settings.setTestHashtags(""); + + // Primary account, no hashtags + text = BlueskyApiClient.getPostText(settings, false); + Assert.assertEquals(announcementText, text); + // Test account, no hashtags + testText = BlueskyApiClient.getPostText(settings, true); + Assert.assertEquals(announcementText, testText); + + // Test with single hashtag + settings = new BlueskySettings(); + announcementText = "Check out this new data on Panorama Public!"; + settings.setAnnouncementText(announcementText); + settings.setHashtags("skyline"); + settings.setTestHashtags("panoramapublic"); + + // Primary account, single hashtag + text = BlueskyApiClient.getPostText(settings, false); + Assert.assertEquals(announcementText + " #skyline", text); + // Test account, single hashtag + testText = BlueskyApiClient.getPostText(settings, true); + Assert.assertEquals(announcementText + " #panoramapublic", testText); + + // Test with multiple hashtags + settings = new BlueskySettings(); + announcementText = "New Panorama Public data available."; + settings.setAnnouncementText(announcementText); + settings.setHashtags("proteomics, proteomicssky, massspec, massspecsky"); + settings.setTestHashtags("panoramapublictest, panoramawebtest"); + + // Primary account, multiple hashtags + text = BlueskyApiClient.getPostText(settings, false); + Assert.assertEquals(announcementText + " #proteomics #proteomicssky #massspec #massspecsky", text); + // Test account, multiple hashtags + testText = BlueskyApiClient.getPostText(settings, true); + Assert.assertEquals(announcementText + " #panoramapublictest #panoramawebtest", testText); + } + } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java index c45551f4..1cef822d 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java @@ -114,7 +114,7 @@ public void setTestHashtags(String testHashtags) _testHashtags = testHashtags; } - private static String[] convertToArray(String hashtags) + public static String[] convertToArray(String hashtags) { if (StringUtils.isBlank(hashtags)) { From 13d00f9d1b9a5cf7785e67f170c2da13bf7acfec Mon Sep 17 00:00:00 2001 From: vagisha Date: Wed, 23 Apr 2025 16:48:40 -0700 Subject: [PATCH 09/12] - Added ClientConfig. - Report account and endpoint in exception message. --- .../PanoramaPublicController.java | 6 +- .../bluesky/BlueskyApiClient.java | 223 +++++++++++++----- .../bluesky/BlueskyException.java | 21 +- .../bluesky/BlueskyResponse.java | 16 +- .../bluesky/BlueskySettings.java | 5 + .../view/manageBlueskySettings.jsp | 12 +- 6 files changed, 208 insertions(+), 75 deletions(-) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index a424ace9..53bf48ed 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -1360,7 +1360,7 @@ public ModelAndView getView(BlueskySettings form, boolean reshow, BindException if (form.getAuthEndpoint() == null) form.setAuthEndpoint(BlueskyIntegrationManager.DEFAULT_AUTH_URL); if (form.getPostEndpoint() == null) form.setPostEndpoint(BlueskyIntegrationManager.DEFAULT_POST_URL); - if (form.getImageFileName() == null) form.setBlobUploadEndpoint(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL); + if (form.getBlobUploadEndpoint() == null) form.setBlobUploadEndpoint(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL); if (form.getAnnouncementText() == null) form.setAnnouncementText("New data available on Panorama Public!"); // Passwords should not be displayed in the form. Make the user re-enter them. @@ -5508,7 +5508,7 @@ public void validateCommand(BlueskyForm form, Errors errors) if (!_expAnnot.isPublic()) { - errors.reject(ERROR_MSG, "Cannot create a post on Bluesky for an experiment that is not public. (Experiment id: " + _expAnnot.getId() + ")."); + errors.reject(ERROR_MSG, "Cannot create a post on Bluesky for an experiment that is not public."); } } @@ -5547,7 +5547,7 @@ public ModelAndView getConfirmView(BlueskyForm form, BindException errors) : new ActionURL(DownloadPanoramaLogoForBlueskyAction.class, getContainer()); Renderable announcementDiv = DIV(cl("bluebox").at(style, "padding:25px;"), - DIV(BlueskyApiClient.getPostText(settings, form.isTestAccount())), + DIV(BlueskyApiClient.getAnnouncement(settings, form.isTestAccount())), DIV(IMG(at(src, imageUrl).at(width, 320).at(height, 180))), DIV(at(style, "font-weight:bold;"), _expAnnot.getTitle()), DIV(new LinkBuilder(_expAnnot.getShortUrl().renderShortURL()) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java index 935cad52..929c97cd 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java @@ -58,27 +58,26 @@ private BlueskyApiClient() {} */ public LoginInfo login(@NotNull BlueskySettings settings, boolean testAccount) throws BlueskyException { - String account = testAccount ? settings.getTestAccount() : settings.getAccount(); - String password = testAccount ? settings.getTestAccountPassword() : settings.getPassword(); + ClientConfig config = new ClientConfig(settings, testAccount); - validateNotBlank(account, String.format("Cannot find Bluesky %saccount.", testAccount ? "test " : "")); - validateNotBlank(password, String.format("Cannot find password for Bluesky %saccount.", testAccount ? "test " : "")); - validateNotBlank(settings.getAuthEndpoint(), "Bluesky auth endpoint not configured"); + validateNotBlank(config.getAccount(), String.format("Cannot find Bluesky %saccount.", testAccount ? "test " : "")); + validateNotBlank(config.getPassword(), String.format("Cannot find password for Bluesky %saccount.", testAccount ? "test " : "")); + validateNotBlank(config.getAuthEndpoint(), "Bluesky auth endpoint not configured"); JSONObject requestBody = new JSONObject(); - requestBody.put("identifier", account); - requestBody.put("password", password); + requestBody.put("identifier", config.getAccount()); + requestBody.put("password", config.getPassword()); - HttpPost httpPost = new HttpPost(settings.getAuthEndpoint()); + HttpPost httpPost = new HttpPost(config.getAuthEndpoint()); httpPost.setHeader("Content-Type", "application/json"); httpPost.setEntity(new StringEntity(requestBody.toString(), ContentType.APPLICATION_JSON)); - logger.debug(String.format("Logging into Bluesky as '%s' at endpoint '%s'", account, settings.getAuthEndpoint())); + logger.debug(String.format("Logging into Bluesky as '%s' at endpoint '%s'", config.getAccount(), config.getAuthEndpoint())); BlueskyResponse response; try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - response = getResponse(httpClient, httpPost, "Bluesky login failed"); + response = getResponse(httpClient, httpPost, config, "Bluesky login failed"); JSONObject responseJson = response.getJsonObject(); String accessJwt = responseJson.getString("accessJwt"); String did = responseJson.getString("did"); @@ -86,7 +85,7 @@ public LoginInfo login(@NotNull BlueskySettings settings, boolean testAccount) t } catch (IOException | JSONException e) { - throw new BlueskyException("Bluesky login failed", e); + throw new BlueskyException("Bluesky login failed", config.getAccount(), config.getAuthEndpoint(), e); } } @@ -134,16 +133,16 @@ public String getDid() * Create a post on Bluesky announcing the data */ @NotNull - public String createPost(@NotNull ExperimentAnnotations exptAnnotations, BlueskySettings settings, boolean testPost) throws BlueskyException + public String createPost(@NotNull ExperimentAnnotations exptAnnotations, BlueskySettings settings, boolean useTestAccount) throws BlueskyException { - return createPost(exptAnnotations, settings, login(settings, testPost), testPost); + return createPost(exptAnnotations, settings, login(settings, useTestAccount), useTestAccount); } /** * Create a post on Bluesky announcing the data. The post is created only if a post wasn't already created. */ @Nullable - public String createPostIfNotExists(@NotNull ExperimentAnnotations exptAnnotations, BlueskySettings settings, boolean testPost) throws BlueskyException + public String createPostIfNotExists(@NotNull ExperimentAnnotations exptAnnotations, BlueskySettings settings, boolean useTestAccount) throws BlueskyException { if (BlueskyIntegrationManager.getBlueskyUriForExperiment(exptAnnotations) != null) { @@ -151,37 +150,38 @@ public String createPostIfNotExists(@NotNull ExperimentAnnotations exptAnnotatio return null; } - return createPost(exptAnnotations, settings, testPost); + return createPost(exptAnnotations, settings, useTestAccount); } @NotNull public String createPost(@NotNull ExperimentAnnotations exptAnnotations, @NotNull BlueskySettings settings, - @NotNull LoginInfo loginInfo, boolean testPost) throws BlueskyException + @NotNull LoginInfo loginInfo, boolean useTestAccount) throws BlueskyException { - validateNotBlank(settings.getAuthEndpoint(), "Bluesky auth endpoint not configured"); - validateNotBlank(settings.getPostEndpoint(), "Bluesky post endpoint not configured"); - validateNotBlank(settings.getBlobUploadEndpoint(), ("Bluesky image upload endpoint not configured")); + ClientConfig config = new ClientConfig(settings, useTestAccount); + validateNotBlank(config.getAuthEndpoint(), "Bluesky auth endpoint not configured"); + validateNotBlank(config.getPostEndpoint(), "Bluesky post endpoint not configured"); + validateNotBlank(config.getBlobUploadEndpoint(), "Bluesky image upload endpoint not configured"); if (exptAnnotations.getShortUrl() == null) { throw new BlueskyException(String.format("Experiment with Id %d does not have a short access URL", exptAnnotations.getId())); } - JSONObject requestBody = createRequestBody(exptAnnotations, testPost, settings, loginInfo); + JSONObject requestBody = createRequestBody(exptAnnotations, config, loginInfo); - HttpPost httpPost = new HttpPost(settings.getPostEndpoint()); + HttpPost httpPost = new HttpPost(config.getPostEndpoint()); httpPost.setHeader("Content-Type", "application/json"); httpPost.setHeader("Authorization", "Bearer " + loginInfo.getAccessJwt()); httpPost.setEntity(new StringEntity(requestBody.toString(), ContentType.APPLICATION_JSON)); - logger.debug(String.format("Posting into Bluesky at endpoint '%s' for Panorama Public data at '%s'", - settings.getPostEndpoint(), exptAnnotations.getShortUrl().renderShortURL())); + logger.debug(String.format("Posting to Bluesky account %s at endpoint '%s' for Panorama Public data at '%s'", + config.getAccount(), config.getPostEndpoint(), exptAnnotations.getShortUrl().renderShortURL())); BlueskyResponse response; String blueskyAtUri; try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - response = getResponse(httpClient, httpPost, "Post creation failed"); + response = getResponse(httpClient, httpPost, config, "Post creation failed"); JSONObject responseJson = response.getJsonObject(); if (responseJson.has("uri")) { @@ -198,10 +198,10 @@ public String createPost(@NotNull ExperimentAnnotations exptAnnotations, @NotNul } catch (IOException | JSONException e) { - throw new BlueskyException("Post creation failed.", e); + throw new BlueskyException("Post creation failed.", config.getAccount(), config.getPostEndpoint(), e); } - if (!testPost) + if (!useTestAccount) { BlueskyIntegrationManager.saveBlueskyUriForExperiment(exptAnnotations, blueskyAtUri); } @@ -210,10 +210,10 @@ public String createPost(@NotNull ExperimentAnnotations exptAnnotations, @NotNul } @NotNull - private JSONObject createRequestBody(@NotNull ExperimentAnnotations exptAnnotations, boolean testPost, - @NotNull BlueskySettings settings, @NotNull LoginInfo loginInfo) throws BlueskyException + private JSONObject createRequestBody(@NotNull ExperimentAnnotations exptAnnotations, + @NotNull ClientConfig config, @NotNull LoginInfo loginInfo) throws BlueskyException { - JSONObject record = buildRecord(exptAnnotations, testPost, settings, loginInfo); + JSONObject record = buildRecord(exptAnnotations, config, loginInfo); return new JSONObject() .put("repo", loginInfo.getDid()) @@ -222,10 +222,10 @@ private JSONObject createRequestBody(@NotNull ExperimentAnnotations exptAnnotati } @NotNull - private JSONObject buildRecord(@NotNull ExperimentAnnotations exptAnnotations, boolean testPost, - @NotNull BlueskySettings settings, @NotNull LoginInfo loginInfo) throws BlueskyException + private JSONObject buildRecord(@NotNull ExperimentAnnotations exptAnnotations, + @NotNull ClientConfig config, @NotNull LoginInfo loginInfo) throws BlueskyException { - String text = getPostText(settings, testPost); + String text = getAnnouncement(config); JSONObject record = new JSONObject() .put("$type", "app.bsky.feed.post") @@ -233,25 +233,29 @@ private JSONObject buildRecord(@NotNull ExperimentAnnotations exptAnnotations, b .put("createdAt", Instant.now().toString()); // Add hashtag facets if any - String[] hashtags = testPost ? settings.getTestHashtagArray() : settings.getHashtagArray(); - JSONArray facets = buildHashtagFacets(text, hashtags); + JSONArray facets = buildHashtagFacets(text, config.getHashtags()); if (!facets.isEmpty()) { record.put("facets", facets); } // Build and attach the embed object - record.put("embed", buildEmbed(exptAnnotations, settings, loginInfo)); + record.put("embed", buildEmbed(exptAnnotations, config, loginInfo)); return record; } /** * Generate post text with hashtags */ - public static String getPostText(@NotNull BlueskySettings settings, boolean testPost) + private static String getAnnouncement(@NotNull ClientConfig config) { - String[] hashtags = testPost ? settings.getTestHashtagArray() : settings.getHashtagArray(); - return String.format("%s%s%s", settings.getAnnouncementText(), hashtags.length > 0 ? " " : "", formatHashtags(hashtags)); + String[] hashtags = config.getHashtags(); + return String.format("%s%s%s", config.getAnnouncementText(), hashtags.length > 0 ? " " : "", formatHashtags(hashtags)); + } + + public static String getAnnouncement(@NotNull BlueskySettings settings, boolean isTestAccount) + { + return getAnnouncement(new ClientConfig(settings, isTestAccount)); } /** @@ -314,7 +318,7 @@ private static JSONObject createSingleHashtagFacet(@NotNull String postText, @No } @NotNull - private JSONObject buildEmbed(@NotNull ExperimentAnnotations exptAnnotations, @NotNull BlueskySettings settings, @NotNull LoginInfo loginInfo) throws BlueskyException + private JSONObject buildEmbed(@NotNull ExperimentAnnotations exptAnnotations, @NotNull ClientConfig config, @NotNull LoginInfo loginInfo) throws BlueskyException { // Create the embed object for a web card JSONObject embed = new JSONObject() @@ -328,13 +332,13 @@ private JSONObject buildEmbed(@NotNull ExperimentAnnotations exptAnnotations, @N .put("description", panoramaLink); // Upload and grab the blob reference for the logo image - external.put("thumb", getImageBlobReference(exptAnnotations, settings, loginInfo)); + external.put("thumb", getImageBlobReference(exptAnnotations, config, loginInfo)); embed.put("external", external); return embed; } - private JSONObject getImageBlobReference(ExperimentAnnotations exptAnnotations, BlueskySettings settings, LoginInfo loginInfo) throws BlueskyException + private JSONObject getImageBlobReference(ExperimentAnnotations exptAnnotations, ClientConfig config, LoginInfo loginInfo) throws BlueskyException { AttachmentParent attachmentParent = null; // First check if the data submitter provided a catalog entry for the data. @@ -343,7 +347,7 @@ private JSONObject getImageBlobReference(ExperimentAnnotations exptAnnotations, { attachmentParent = new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer()); } - else if (!StringUtils.isBlank(settings.getImageFileName())) + else if (!StringUtils.isBlank(config.getImageFileName())) { // If the data does not have a catalog entry, or we were unable to get it, use the Panorama Public logo attachmentParent = PanoramaPublicLogoAttachmentParent.get(); @@ -351,7 +355,7 @@ else if (!StringUtils.isBlank(settings.getImageFileName())) { throw new BlueskyException("Unable to initialize PanoramaPublicLogoAttachmentParent. Perhaps a Panorama Public project does not exist on the server."); } - attachment = PanoramaPublicLogoManager.getNewDataLogo(settings.getImageFileName()); + attachment = PanoramaPublicLogoManager.getNewDataLogo(config.getImageFileName()); } if (attachment == null) @@ -359,7 +363,7 @@ else if (!StringUtils.isBlank(settings.getImageFileName())) throw new BlueskyException("Unable to find an image file to include in the post."); } - JSONObject blobResponse = uploadImage(attachment, attachmentParent, settings, loginInfo); + JSONObject blobResponse = uploadImage(attachment, attachmentParent, config, loginInfo); // Get the blob reference from the response if (blobResponse.has("blob")) { @@ -381,28 +385,29 @@ private static Attachment getCatalogEntryAttachment(ExperimentAnnotations exptAn * Upload image bytes to Bluesky */ @NotNull - private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType, BlueskySettings settings, LoginInfo loginInfo) throws BlueskyException + private JSONObject uploadToBluesky(byte[] imageBytes, String mimeType, ClientConfig config, LoginInfo loginInfo) throws BlueskyException { - HttpPost httpPost = new HttpPost(settings.getBlobUploadEndpoint()); + String endpoint = config.getBlobUploadEndpoint(); + HttpPost httpPost = new HttpPost(endpoint); httpPost.setHeader("Content-Type", mimeType); httpPost.setHeader("Authorization", "Bearer " + loginInfo.getAccessJwt()); httpPost.setEntity(new ByteArrayEntity(imageBytes, ContentType.create(mimeType))); try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - BlueskyResponse response = getResponse(httpClient, httpPost, "Failed to upload image to Bluesky"); + BlueskyResponse response = getResponse(httpClient, httpPost, config, "Failed to upload image to Bluesky"); return response.getJsonObject(); } catch (IOException | JSONException e) { - throw new BlueskyException("Failed to upload image to Bluesky", e); + throw new BlueskyException("Failed to upload image to Bluesky", config.getAccount(), endpoint, e); } } @NotNull - private BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost httpPost, String failureMessage) throws IOException, BlueskyException + private BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost httpPost, ClientConfig config, String failureMessage) throws IOException, BlueskyException { - BlueskyResponse response = httpClient.execute(httpPost, new BlueskyResponseHandler()); + BlueskyResponse response = httpClient.execute(httpPost, new BlueskyResponseHandler(config, httpPost.getRequestUri())); String responseContent = response.getResponseBody() != null ? response.getResponseBody() : ""; if (!response.success()) @@ -423,13 +428,13 @@ private BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost htt */ @NotNull private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent, - BlueskySettings settings, LoginInfo loginInfo) throws BlueskyException + ClientConfig config, LoginInfo loginInfo) throws BlueskyException { try (InputStream is = AttachmentService.get().getInputStream(parent, attachment.getName())) { byte[] imageBytes = is.readAllBytes(); String mimeType = PageFlowUtil.getContentTypeFor(attachment.getName()); - return uploadToBluesky(imageBytes, mimeType, settings, loginInfo); + return uploadToBluesky(imageBytes, mimeType, config, loginInfo); } catch (FileNotFoundException e) { @@ -448,6 +453,15 @@ private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull Attachme */ private static class BlueskyResponseHandler implements HttpClientResponseHandler { + private final String _account; + private final String _apiEndpoint; + + public BlueskyResponseHandler(ClientConfig config, String apiEndpoint) + { + _account = config.getAccount(); + _apiEndpoint = apiEndpoint; + } + @Override public BlueskyResponse handleResponse(ClassicHttpResponse response) throws IOException { @@ -455,7 +469,7 @@ public BlueskyResponse handleResponse(ClassicHttpResponse response) throws IOExc { HttpEntity entity = response.getEntity(); String content = entity != null ? EntityUtils.toString(entity) : null; - return new BlueskyResponse(response.getCode(), response.getReasonPhrase(), content); + return new BlueskyResponse(response.getCode(), response.getReasonPhrase(), content, _account, _apiEndpoint); } catch (ParseException e) { @@ -488,6 +502,71 @@ public static String tryConvertToWebUrl(String atProtocolUri) : null; } + private static class ClientConfig + { + private final String _account; + private final String _password; + private final String _imageFileName; + private final String[] _hashtags; + + private final String _announcementText; + private final String _authEndpoint; + private final String _postEndpoint; + private final String _blobUploadEndpoint; + + public ClientConfig(BlueskySettings settings, boolean test) + { + _account = settings.getAccount(test); // Get either test or primary account + _password = settings.getPassword(test); // Get either test or primary password + _imageFileName = settings.getImageFileName(); + _hashtags = test ? settings.getTestHashtagArray() : settings.getHashtagArray(); // Get the appropriate hashtags + _announcementText = settings.getAnnouncementText(); + _authEndpoint = settings.getAuthEndpoint(); + _postEndpoint = settings.getPostEndpoint(); + _blobUploadEndpoint = settings.getBlobUploadEndpoint(); + } + + public String getAccount() + { + return _account; + } + + public String getPassword() + { + return _password; + } + + public String getImageFileName() + { + return _imageFileName; + } + + public String[] getHashtags() + { + return _hashtags; + } + + public String getAnnouncementText() + { + return _announcementText; + } + + public String getAuthEndpoint() + { + return _authEndpoint; + } + + public String getPostEndpoint() + { + return _postEndpoint; + } + + public String getBlobUploadEndpoint() + { + return _blobUploadEndpoint; + } + } + public static class TestCase extends Assert { @Test @@ -530,7 +609,7 @@ private void compareSorted(String[] expected, String input) } @Test - public void testHashtagGettersAndSetters() + public void testHashtagGetters() { BlueskySettings settings = new BlueskySettings(); @@ -543,23 +622,39 @@ public void testHashtagGettersAndSetters() assertArrayEquals(expectedMainTags, settings.getHashtagArray()); assertArrayEquals(expectedTestTags, settings.getTestHashtagArray()); + + ClientConfig config = new ClientConfig(settings, false); + assertArrayEquals(expectedMainTags, config.getHashtags()); + config = new ClientConfig(settings, true); + assertArrayEquals(expectedTestTags, config.getHashtags()); } @Test - public void testAccountGetters() + public void testCredentialsGetters() { BlueskySettings settings = new BlueskySettings(); settings.setAccount("main-account"); + settings.setPassword("main-account-password"); settings.setTestAccount("test-account"); + settings.setTestAccountPassword("test-account-password"); // Test account getter with parameter Assert.assertEquals("main-account", settings.getAccount(false)); Assert.assertEquals("test-account", settings.getAccount(true)); + Assert.assertEquals("main-account-password", settings.getPassword(false)); + Assert.assertEquals("test-account-password", settings.getPassword(true)); + + ClientConfig config = new ClientConfig(settings, false); + Assert.assertEquals("main-account", config.getAccount()); + Assert.assertEquals("main-account-password", config.getPassword()); + config = new ClientConfig(settings, true); + Assert.assertEquals("test-account", config.getAccount()); + Assert.assertEquals("test-account-password", config.getPassword()); } @Test - public void testGetPostText() + public void testGetAnnouncementText() { String announcementText = "New data available on Panorama Public!"; // Test with null hashtags @@ -569,10 +664,10 @@ public void testGetPostText() settings.setTestHashtags(null); // Primary account, no hashtags - String text = BlueskyApiClient.getPostText(settings, false); + String text = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, false)); Assert.assertEquals(announcementText, text); // Test account, no hashtags - String testText = BlueskyApiClient.getPostText(settings, true); + String testText = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, true)); Assert.assertEquals(announcementText, testText); // Test with empty hashtags @@ -582,10 +677,10 @@ public void testGetPostText() settings.setTestHashtags(""); // Primary account, no hashtags - text = BlueskyApiClient.getPostText(settings, false); + text = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, false)); Assert.assertEquals(announcementText, text); // Test account, no hashtags - testText = BlueskyApiClient.getPostText(settings, true); + testText = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, true)); Assert.assertEquals(announcementText, testText); // Test with single hashtag @@ -596,10 +691,10 @@ public void testGetPostText() settings.setTestHashtags("panoramapublic"); // Primary account, single hashtag - text = BlueskyApiClient.getPostText(settings, false); + text = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, false)); Assert.assertEquals(announcementText + " #skyline", text); // Test account, single hashtag - testText = BlueskyApiClient.getPostText(settings, true); + testText = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, true)); Assert.assertEquals(announcementText + " #panoramapublic", testText); // Test with multiple hashtags @@ -610,10 +705,10 @@ public void testGetPostText() settings.setTestHashtags("panoramapublictest, panoramawebtest"); // Primary account, multiple hashtags - text = BlueskyApiClient.getPostText(settings, false); + text = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, false)); Assert.assertEquals(announcementText + " #proteomics #proteomicssky #massspec #massspecsky", text); // Test account, multiple hashtags - testText = BlueskyApiClient.getPostText(settings, true); + testText = BlueskyApiClient.getAnnouncement(new ClientConfig(settings, true)); Assert.assertEquals(announcementText + " #panoramapublictest #panoramawebtest", testText); } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java index 466d8345..9403136a 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java @@ -19,9 +19,24 @@ public BlueskyException(String message, Throwable cause) super(message, cause); } + public BlueskyException(String message, String blueskyAccount, String blueskyEndpoint, Throwable cause) + { + super(String.format("%s. Account: %s; Endpoint: %s", + message, + blueskyAccount != null ? blueskyAccount : "MISSING", + blueskyEndpoint != null ? blueskyEndpoint : "MISSING"), + cause); + } + public BlueskyException(@NotNull String message, @NotNull BlueskyResponse response) { - super("Request failed - " + message + ". Code: " + response.getStatusCode() + "; Message " + response.getMessage() + "; Body: " + response.getResponseBody()); + super(String.format("Request failed - %s. Code: %s; Account: %s; Endpoint: %s; Message: %s; Body: %s", + message, + response.getStatusCode(), + response.getAccount(), + response.getEndpoint(), + response.getMessage(), + response.getResponseBody())); _response = response; } @@ -30,6 +45,10 @@ public HtmlString getHtmlString() if(_response != null) { return HtmlStringBuilder.of(HtmlString.unsafe("
")) + .append("Bluesky account: ").append(_response.getAccount()) + .append(HtmlString.BR) + .append("Bluesky endpoint: ").append(_response.getEndpoint()) + .append(HtmlString.BR) .append("Response status code: ").append(_response.getStatusCode()) .append(HtmlString.BR) .append("Message: ").append(_response.getMessage()) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java index 1404c9aa..f3b900f8 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java @@ -10,12 +10,16 @@ public class BlueskyResponse private final int _statusCode; private final String _message; private final String _responseBody; + private final String _account; + private final String _endpoint; - public BlueskyResponse(int statusCode, String message, String responseBody) + public BlueskyResponse(int statusCode, String message, String responseBody, String blueskyAccount, String endpoint) { _statusCode = statusCode; _message = message; _responseBody = responseBody; + _account = blueskyAccount; + _endpoint = endpoint; } public int getStatusCode() @@ -33,6 +37,16 @@ public String getResponseBody() return _responseBody; } + public String getAccount() + { + return _account; + } + + public String getEndpoint() + { + return _endpoint; + } + public boolean success() { return _statusCode == HttpStatus.SC_OK || _statusCode == HttpStatus.SC_CREATED; diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java index 1cef822d..9fce1a64 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java @@ -176,4 +176,9 @@ public String getAccount (boolean test) { return test ? getTestAccount() : getAccount(); } + + public String getPassword (boolean test) + { + return test ? getTestAccountPassword() : getPassword(); + } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp index 22cf0494..406f70cd 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp @@ -38,7 +38,7 @@
@@ -46,7 +46,7 @@ @@ -54,7 +54,7 @@ @@ -69,7 +69,7 @@ @@ -77,7 +77,7 @@ @@ -98,7 +98,7 @@ -
+
PNG or JPG/JPEG file in 16x9 aspect ratio that will be included in the Bluesky post
From 627cd6e81c71239e26a5a042369f14c8ca055689 Mon Sep 17 00:00:00 2001 From: vagisha Date: Thu, 24 Apr 2025 13:06:46 -0700 Subject: [PATCH 10/12] Added BlueskyLinksManager. Renamed BlueskyIntegrationManager to BlueskySettingsManager. Fix typos. --- .../PanoramaPublicController.java | 57 ++++++++++--------- .../bluesky/BlueskyApiClient.java | 12 +++- .../bluesky/BlueskyLinksManager.java | 50 ++++++++++++++++ ...nager.java => BlueskySettingsManager.java} | 46 +-------------- .../view/manageBlueskySettings.jsp | 8 +-- 5 files changed, 94 insertions(+), 79 deletions(-) create mode 100644 panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java rename panoramapublic/src/org/labkey/panoramapublic/bluesky/{BlueskyIntegrationManager.java => BlueskySettingsManager.java} (69%) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 53bf48ed..99cda4cb 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -143,7 +143,8 @@ import org.labkey.api.wiki.WikiRenderingService; import org.labkey.panoramapublic.bluesky.BlueskyApiClient; import org.labkey.panoramapublic.bluesky.BlueskyException; -import org.labkey.panoramapublic.bluesky.BlueskyIntegrationManager; +import org.labkey.panoramapublic.bluesky.BlueskySettingsManager; +import org.labkey.panoramapublic.bluesky.BlueskyLinksManager; import org.labkey.panoramapublic.bluesky.BlueskySettings; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoAttachmentParent; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoManager; @@ -1278,8 +1279,8 @@ public boolean handlePost(BlueskySettings form, BindException errors) } catch (BlueskyException e) { - errors.reject(ERROR_MSG, String.format("Bluesky login failed for the account '%s' using auth endpoint '%s'. Error was %s", - form.getAccount(), form.getAuthEndpoint(), e.getMessage())); + errors.reject(ERROR_MSG, String.format("Bluesky login failed for the account '%s'. Error was %s", + form.getAccount(), e.getMessage())); return false; } @@ -1289,11 +1290,20 @@ public boolean handlePost(BlueskySettings form, BindException errors) } catch (BlueskyException e) { - errors.reject(ERROR_MSG, String.format("Bluesky login failed for the test account '%s' using auth endpoint '%s'. Error was %s", - form.getTestAccount(), form.getAuthEndpoint(), e.getMessage())); + errors.reject(ERROR_MSG, String.format("Bluesky login failed for the test account '%s'. Error was %s", + form.getTestAccount(), e.getMessage())); return false; } + if (!StringUtils.isBlank(form.getHashtags())) + { + form.setHashtags(StringUtils.join(form.getHashtagArray(), ", ")); + } + if (!StringUtils.isBlank(form.getTestHashtags())) + { + form.setTestHashtags(StringUtils.join(form.getTestHashtagArray(), ", ")); + } + String logoFileName; List files = getAttachmentFileList(); AttachmentFile panoramaLogoFile = files.stream().findFirst().orElse(null); @@ -1322,16 +1332,7 @@ else if (form.getImageFileName() == null) return false; } - if (!StringUtils.isBlank(form.getHashtags())) - { - form.setHashtags(StringUtils.join(form.getHashtagArray(), ", ")); - } - if (!StringUtils.isBlank(form.getTestHashtags())) - { - form.setTestHashtags(StringUtils.join(form.getTestHashtagArray(), ", ")); - } - - BlueskyIntegrationManager.saveSettings(form); + BlueskySettingsManager.saveSettings(form); return true; } @@ -1356,11 +1357,11 @@ public ModelAndView getView(BlueskySettings form, boolean reshow, BindException { if(!reshow) { - form = BlueskyIntegrationManager.getSettings(); + form = BlueskySettingsManager.getSettings(); - if (form.getAuthEndpoint() == null) form.setAuthEndpoint(BlueskyIntegrationManager.DEFAULT_AUTH_URL); - if (form.getPostEndpoint() == null) form.setPostEndpoint(BlueskyIntegrationManager.DEFAULT_POST_URL); - if (form.getBlobUploadEndpoint() == null) form.setBlobUploadEndpoint(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL); + if (form.getAuthEndpoint() == null) form.setAuthEndpoint(BlueskySettingsManager.DEFAULT_AUTH_URL); + if (form.getPostEndpoint() == null) form.setPostEndpoint(BlueskySettingsManager.DEFAULT_POST_URL); + if (form.getBlobUploadEndpoint() == null) form.setBlobUploadEndpoint(BlueskySettingsManager.DEFAULT_IMAGE_UPLOAD_URL); if (form.getAnnouncementText() == null) form.setAnnouncementText("New data available on Panorama Public!"); // Passwords should not be displayed in the form. Make the user re-enter them. @@ -1391,7 +1392,7 @@ public Pair getAttachment(AttachmentForm form) AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); if (ap == null) return null; - BlueskySettings settings = BlueskyIntegrationManager.getSettings(); + BlueskySettings settings = BlueskySettingsManager.getSettings(); if (StringUtils.isBlank(settings.getImageFileName())) { return null; @@ -1417,7 +1418,7 @@ public void validateCommand(Object target, Errors errors) public boolean handlePost(Object o, BindException errors) { PanoramaPublicLogoManager.deleteExistingNewDataLogo(getUser()); - BlueskyIntegrationManager.removeLogoFileName(); + BlueskySettingsManager.removeLogoFileName(); return true; } @@ -5515,7 +5516,7 @@ public void validateCommand(BlueskyForm form, Errors errors) @Override public boolean handlePost(BlueskyForm form, BindException errors) { - BlueskySettings settings = BlueskyIntegrationManager.getSettings(); + BlueskySettings settings = BlueskySettingsManager.getSettings(); try { // Post to Bluesky @@ -5539,7 +5540,7 @@ public boolean handlePost(BlueskyForm form, BindException errors) @Override public ModelAndView getConfirmView(BlueskyForm form, BindException errors) { - BlueskySettings settings = BlueskyIntegrationManager.getSettings(); + BlueskySettings settings = BlueskySettingsManager.getSettings(); CatalogEntry entry = CatalogEntryManager.getApprovedEntryForExperiment(_expAnnot); ActionURL imageUrl = entry != null @@ -5556,7 +5557,7 @@ public ModelAndView getConfirmView(BlueskyForm form, BindException errors) .build()) ); - String blueskyAtUri = BlueskyIntegrationManager.getBlueskyUriForExperiment(_expAnnot); + String blueskyAtUri = BlueskyLinksManager.getBlueskyUriForExperiment(_expAnnot); String account = settings.getAccount(form.isTestAccount()); if (blueskyAtUri != null) { @@ -5581,7 +5582,7 @@ public ModelAndView getConfirmView(BlueskyForm form, BindException errors) return new HtmlView( DIV(String.format("The following message will be posted to the Bluesky %saccount %s", form.isTestAccount() ? "test " : "", account), BR(), - String.format("Post URL: %s", settings.getPostEndpoint()), + String.format("URL: %s", settings.getPostEndpoint()), announcementDiv, DIV("Are you sure you want to continue?"))); } @@ -5592,7 +5593,7 @@ public ModelAndView getSuccessView(BlueskyForm form) String webUrl = BlueskyApiClient.tryConvertToWebUrl(_blueskyAtUri); return new HtmlView( - DIV("Posted to Bluesky!", + DIV("Posted to Bluesky! ", webUrl == null ? _blueskyAtUri : new LinkBuilder((_blueskyAtUri)).href(webUrl).clearClasses() @@ -5651,7 +5652,7 @@ public void validateCommand(ExperimentIdForm form, Errors errors) @Override public boolean handlePost(ExperimentIdForm form, BindException errors) { - BlueskyIntegrationManager.clearBlueskyUriForExperiment(form.lookupExperiment()); + BlueskyLinksManager.clearBlueskyUriForExperiment(form.lookupExperiment()); return true; } @@ -7095,7 +7096,7 @@ private void publishDoi() private void postToBluesky() { - BlueskySettings settings = BlueskyIntegrationManager.getSettings(); + BlueskySettings settings = BlueskySettingsManager.getSettings(); if (!settings.isAutopost()) { logger.info("Auto-post to Bluesky is disabled. Unable to create a post for experiment Id " + _expAnnot.getId()); diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java index 929c97cd..e6dfc974 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java @@ -79,7 +79,15 @@ public LoginInfo login(@NotNull BlueskySettings settings, boolean testAccount) t { response = getResponse(httpClient, httpPost, config, "Bluesky login failed"); JSONObject responseJson = response.getJsonObject(); + if (!responseJson.has("accessJwt")) + { + throw new BlueskyException("'accessJwt' not found in the login response", response); + } String accessJwt = responseJson.getString("accessJwt"); + if (!responseJson.has("did")) + { + throw new BlueskyException("'did' not found in the login response", response); + } String did = responseJson.getString("did"); return new LoginInfo(accessJwt, did); } @@ -144,7 +152,7 @@ public String createPost(@NotNull ExperimentAnnotations exptAnnotations, Bluesky @Nullable public String createPostIfNotExists(@NotNull ExperimentAnnotations exptAnnotations, BlueskySettings settings, boolean useTestAccount) throws BlueskyException { - if (BlueskyIntegrationManager.getBlueskyUriForExperiment(exptAnnotations) != null) + if (BlueskyLinksManager.getBlueskyUriForExperiment(exptAnnotations) != null) { // There is already a post on Bluesky related to this data return null; @@ -203,7 +211,7 @@ public String createPost(@NotNull ExperimentAnnotations exptAnnotations, @NotNul if (!useTestAccount) { - BlueskyIntegrationManager.saveBlueskyUriForExperiment(exptAnnotations, blueskyAtUri); + BlueskyLinksManager.saveBlueskyUriForExperiment(exptAnnotations, blueskyAtUri); } return blueskyAtUri; diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java new file mode 100644 index 00000000..977be2a3 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java @@ -0,0 +1,50 @@ +package org.labkey.panoramapublic.bluesky; + +import org.labkey.api.data.PropertyManager; +import org.labkey.panoramapublic.model.ExperimentAnnotations; + +public class BlueskyLinksManager +{ + // Category name for propertysets. Properties belonging to this category will have the short URL as the key + // and the Bluesky post URI (AT protocol) as the value. + public static final String BLUESKY_URIS = "Bluesky AT protocol URIs"; + + public static String getBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) + { + if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) + { + return null; + } + + PropertyManager.WritablePropertyMap map = PropertyManager.getNormalStore() + .getWritableProperties(BLUESKY_URIS, false); + return map != null ? map.get(experimentAnnotations.getShortUrl().renderShortURL()) : null; + } + + public static void saveBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations, String atProtocolUri) + { + if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) + { + return; + } + PropertyManager.WritablePropertyMap propertyMap = PropertyManager.getNormalStore() + .getWritableProperties(BLUESKY_URIS, true); + propertyMap.put(experimentAnnotations.getShortUrl().renderShortURL(), atProtocolUri); + propertyMap.save(); + } + + public static void clearBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) + { + if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) + { + return; + } + if (getBlueskyUriForExperiment(experimentAnnotations) != null) + { + PropertyManager.WritablePropertyMap propertyMap = PropertyManager.getNormalStore() + .getWritableProperties(BLUESKY_URIS, true); + propertyMap.remove(experimentAnnotations.getShortUrl().renderShortURL()); + propertyMap.save(); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyIntegrationManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java similarity index 69% rename from panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyIntegrationManager.java rename to panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java index 265b6e80..7359880d 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyIntegrationManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java @@ -2,9 +2,8 @@ import org.labkey.api.data.PropertyManager; import org.labkey.api.data.PropertyManager.WritablePropertyMap; -import org.labkey.panoramapublic.model.ExperimentAnnotations; -public class BlueskyIntegrationManager +public class BlueskySettingsManager { // Category name for propertysets private static final String CREDENTIALS = "Bluesky credentials"; @@ -29,10 +28,6 @@ public class BlueskyIntegrationManager private static final String ANNOUNCEMENT_TEXT = "Announcement text"; - // Category name for propertysets. Properties belonging to this category will have the data short URL as the name - // and the Bluesky post URI (AT protocol) as the value. - public static final String BLUESKY_URIS = "Bluesky AT protocol URIs"; - // Default values for the endpoints public static final String DEFAULT_AUTH_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"; public static final String DEFAULT_POST_URL = "https://bsky.social/xrpc/com.atproto.repo.createRecord"; @@ -91,43 +86,4 @@ public static void removeLogoFileName() settings.setImageFileName(null); saveSettings(settings); } - - public static String getBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) - { - if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) - { - return null; - } - - WritablePropertyMap map = PropertyManager.getNormalStore() - .getWritableProperties(BlueskyIntegrationManager.BLUESKY_URIS, false); - return map != null ? map.get(experimentAnnotations.getShortUrl().renderShortURL()) : null; - } - - public static void saveBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations, String atProtocolUri) - { - if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) - { - return; - } - WritablePropertyMap propertyMap = PropertyManager.getNormalStore() - .getWritableProperties(BlueskyIntegrationManager.BLUESKY_URIS, true); - propertyMap.put(experimentAnnotations.getShortUrl().renderShortURL(), atProtocolUri); - propertyMap.save(); - } - - public static void clearBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) - { - if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) - { - return; - } - if (getBlueskyUriForExperiment(experimentAnnotations) != null) - { - WritablePropertyMap propertyMap = PropertyManager.getNormalStore() - .getWritableProperties(BlueskyIntegrationManager.BLUESKY_URIS, true); - propertyMap.remove(experimentAnnotations.getShortUrl().renderShortURL()); - propertyMap.save(); - } - } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp index 406f70cd..74000c2d 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp @@ -4,7 +4,7 @@ <%@ page import="org.labkey.panoramapublic.PanoramaPublicController" %> <%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PanoramaPublicAdminViewAction" %> <%@ page import="org.labkey.panoramapublic.bluesky.BlueskySettings" %> -<%@ page import="org.labkey.panoramapublic.bluesky.BlueskyIntegrationManager" %> +<%@ page import="org.labkey.panoramapublic.bluesky.BlueskySettingsManager" %> <%@ page extends="org.labkey.api.jsp.FormPage" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> @@ -38,7 +38,7 @@
@@ -46,7 +46,7 @@ @@ -54,7 +54,7 @@ From a43636fba171b764ec191d31d6fc2048cfc455a7 Mon Sep 17 00:00:00 2001 From: vagisha Date: Thu, 24 Apr 2025 13:30:51 -0700 Subject: [PATCH 11/12] Fixed comments and typos --- .../panoramapublic/bluesky/BlueskyApiClient.java | 13 ++++--------- .../panoramapublic/bluesky/BlueskyLinksManager.java | 4 ++-- .../bluesky/BlueskySettingsManager.java | 4 ++-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java index e6dfc974..54307f01 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java @@ -267,7 +267,7 @@ public static String getAnnouncement(@NotNull BlueskySettings settings, boolean } /** - * Convert an array of hashtag strings to a comma-separated string with '#' prefix + * Convert an array of hashtag strings to a space-separated string with '#' prefix */ private static String formatHashtags(@NotNull String[] hashtags) { @@ -524,10 +524,10 @@ private static class ClientConfig public ClientConfig(BlueskySettings settings, boolean test) { - _account = settings.getAccount(test); // Get either test or primary account - _password = settings.getPassword(test); // Get either test or primary password + _account = settings.getAccount(test); + _password = settings.getPassword(test); _imageFileName = settings.getImageFileName(); - _hashtags = test ? settings.getTestHashtagArray() : settings.getHashtagArray(); // Get the appropriate hashtags + _hashtags = test ? settings.getTestHashtagArray() : settings.getHashtagArray(); _announcementText = settings.getAnnouncementText(); _authEndpoint = settings.getAuthEndpoint(); _postEndpoint = settings.getPostEndpoint(); @@ -602,11 +602,6 @@ public void testConvertToArray() // Test for input with '#' characters input = " #proteomics, proteomics sky, # massspec , massspecsky, #massspec "; compareSorted(expected, input); - - // Test for single tag TODO: remove - assertArrayEquals(new String[]{"proteomics"}, BlueskySettings.convertToArray("#proteomics")); - input = "proteomics,proteomics sky,#massspec, massspecsky,proteomics"; - compareSorted(expected, input); } private void compareSorted(String[] expected, String input) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java index 977be2a3..54794a19 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java @@ -5,8 +5,8 @@ public class BlueskyLinksManager { - // Category name for propertysets. Properties belonging to this category will have the short URL as the key - // and the Bluesky post URI (AT protocol) as the value. + // Category name for propertysets. Properties that belong to this category will have the short URL as the key + // and the Bluesky post (AT protocol) URI as the value. public static final String BLUESKY_URIS = "Bluesky AT protocol URIs"; public static String getBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java index 7359880d..a9ad5497 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java @@ -7,7 +7,7 @@ public class BlueskySettingsManager { // Category name for propertysets private static final String CREDENTIALS = "Bluesky credentials"; - // Property names for properties that belog to the "Bluesky credenditals" category + // Property names for properties that belong to the "Bluesky credentials" category private static final String ACCOUNT = "Bluesky account"; private static final String PASSWORD = "Bluesky password"; @@ -16,7 +16,7 @@ public class BlueskySettingsManager // Category name for propertysets private static final String SETTINGS = "Bluesky settings"; - // Property names for properties that belog to the "Bluesky settings" category + // Property names for properties that belong to the "Bluesky settings" category private static final String AUTH_URL = "Bluesky auth endpoint"; private static final String POST_URL = "Bluesky post endpoint"; private static final String BLOB_UPLOAD_URL = "Image blob upload endpoint"; From e934e2aa070dd9f1926b01248bf32f46a8eba79c Mon Sep 17 00:00:00 2001 From: vagisha Date: Mon, 28 Apr 2025 18:14:58 -0700 Subject: [PATCH 12/12] - Register PanoramaPublicLogoResourceType attachment type in PanoramaPublicModule.java - CR feedback --- .../org/labkey/panoramapublic/PanoramaPublicController.java | 2 +- .../src/org/labkey/panoramapublic/PanoramaPublicModule.java | 2 ++ .../bluesky/PanoramaPublicLogoResourceType.java | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 99cda4cb..d50fe52d 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -5525,7 +5525,7 @@ public boolean handlePost(BlueskyForm form, BindException errors) catch (BlueskyException e) { _exception = e; - LOG.error(e); + LOG.error(e.getMessage(), e); return false; } return true; diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java index 52134ea6..0a5c28cb 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java @@ -44,6 +44,7 @@ import org.labkey.api.view.WebPartFactory; import org.labkey.api.view.WebPartView; import org.labkey.panoramapublic.bluesky.BlueskyApiClient; +import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoResourceType; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentType; import org.labkey.panoramapublic.model.Journal; import org.labkey.panoramapublic.model.speclib.SpecLibKey; @@ -105,6 +106,7 @@ protected void init() addController(PanoramaPublicController.NAME, PanoramaPublicController.class); PanoramaPublicSchema.register(this); AttachmentService.get().registerAttachmentType(CatalogImageAttachmentType.get()); + AttachmentService.get().registerAttachmentType(PanoramaPublicLogoResourceType.get()); } @Override diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java index 3388b4ed..f7f414e8 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java @@ -30,6 +30,8 @@ public void addWhereSql(SQLFragment sql, String parentColumn, String documentNam sql.append(parentColumn).append(" IN (SELECT EntityId FROM ") .append(CoreSchema.getInstance().getTableInfoContainers(), "c").append(")") .append(" AND (") - .append(documentNameColumn).append(" LIKE '").append(PanoramaPublicLogoManager.LOGO_FILE_PREFIX).append("%' )"); + .append(documentNameColumn).append(" LIKE ") + .appendStringLiteral(PanoramaPublicLogoManager.LOGO_FILE_PREFIX + "%", CoreSchema.getInstance().getSqlDialect()) + .append(") "); } }
User:Account:
Password:
User (test account):Test account:
Password (test account):Test account password:
Auth URL: + +
e.g. https://bsky.social/xrpc/com.atproto.server.createSession
+
Post URL: + +
e.g. https://bsky.social/xrpc/com.atproto.repo.createRecord
+
Image upload URL: + +
e.g. https://bsky.social/xrpc/com.atproto.repo.uploadBlob
+
Hashtags: + +
Hashtags associated with the post, comma-separated (e.g proteomics, proteomicssky, massspec, massspecsky)
+
Hashtags (test account): + +
Hashtags associated with the post to the test account, comma-separated.
+
Auto-post to Bluesky on publish: + /> +
Panorama Public Logo: - - <% if (form.getImageFileName() != null) { %> <%=link("View Logo", urlFor(PanoramaPublicController.DownloadLogoForBlueskyAction.class))%> <%=link("Delete Logo", urlFor(PanoramaPublicController.DeleteLogoForBlueskyAction.class)).usePost()%> <% } %> + + +
PNG or JPG/JPEG file in 16x9 ascpect ratio that will be included in the Bluesky post
From 99dcfb899a25d11a445cab212b970606a3b830fb Mon Sep 17 00:00:00 2001 From: vagisha Date: Tue, 22 Apr 2025 15:49:49 -0700 Subject: [PATCH 07/12] - Renamed BlueskySettingsManager to BlueskyIntegrationManager - Added buttons for "Post to primary account" and "Post to test account". - Save the AT uri only if posting to primary account. - Announcement text is configurable through the settings page. --- .../PanoramaPublicController.java | 129 ++++++++++++------ .../bluesky/BlueskyApiClient.java | 46 ++++--- .../bluesky/BlueskyException.java | 4 +- ...er.java => BlueskyIntegrationManager.java} | 34 +++-- .../bluesky/BlueskyResponse.java | 12 +- .../bluesky/BlueskySettings.java | 17 +++ .../TargetedMSExperimentWebPart.java | 2 +- .../view/manageBlueskySettings.jsp | 20 ++- 8 files changed, 180 insertions(+), 84 deletions(-) rename panoramapublic/src/org/labkey/panoramapublic/bluesky/{BlueskySettingsManager.java => BlueskyIntegrationManager.java} (72%) diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 6d154c94..a424ace9 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -143,8 +143,8 @@ import org.labkey.api.wiki.WikiRenderingService; import org.labkey.panoramapublic.bluesky.BlueskyApiClient; import org.labkey.panoramapublic.bluesky.BlueskyException; +import org.labkey.panoramapublic.bluesky.BlueskyIntegrationManager; import org.labkey.panoramapublic.bluesky.BlueskySettings; -import org.labkey.panoramapublic.bluesky.BlueskySettingsManager; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoAttachmentParent; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoManager; import org.labkey.panoramapublic.catalog.CatalogEntrySettings; @@ -255,7 +255,6 @@ import static org.labkey.api.util.DOM.Attribute.valign; import static org.labkey.api.util.DOM.Attribute.value; import static org.labkey.api.util.DOM.Attribute.width; -import static org.labkey.api.util.DOM.LK.CHECKBOX; import static org.labkey.api.util.DOM.LK.ERRORS; import static org.labkey.api.util.DOM.LK.FORM; import static org.labkey.panoramapublic.proteomexchange.NcbiUtils.PUBMED_ID; @@ -1263,6 +1262,10 @@ public void validateCommand(BlueskySettings form, Errors errors) { errors.reject(ERROR_MSG, "Image upload URL cannot be blank"); } + if (StringUtils.isBlank(form.getAnnouncementText())) + { + errors.reject(ERROR_MSG, "Announcement text cannot be blank"); + } } @Override @@ -1271,7 +1274,7 @@ public boolean handlePost(BlueskySettings form, BindException errors) BlueskyApiClient client = BlueskyApiClient.getInstance(); try { - client.login(form, false); + client.login(form); } catch (BlueskyException e) { @@ -1282,7 +1285,7 @@ public boolean handlePost(BlueskySettings form, BindException errors) try { - client.login(form, true); + client.loginTestAccount(form); } catch (BlueskyException e) { @@ -1328,7 +1331,7 @@ else if (form.getImageFileName() == null) form.setTestHashtags(StringUtils.join(form.getTestHashtagArray(), ", ")); } - BlueskySettingsManager.saveSettings(form); + BlueskyIntegrationManager.saveSettings(form); return true; } @@ -1353,7 +1356,13 @@ public ModelAndView getView(BlueskySettings form, boolean reshow, BindException { if(!reshow) { - form = BlueskySettingsManager.getSettings(); + form = BlueskyIntegrationManager.getSettings(); + + if (form.getAuthEndpoint() == null) form.setAuthEndpoint(BlueskyIntegrationManager.DEFAULT_AUTH_URL); + if (form.getPostEndpoint() == null) form.setPostEndpoint(BlueskyIntegrationManager.DEFAULT_POST_URL); + if (form.getImageFileName() == null) form.setBlobUploadEndpoint(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL); + if (form.getAnnouncementText() == null) form.setAnnouncementText("New data available on Panorama Public!"); + // Passwords should not be displayed in the form. Make the user re-enter them. form.setPassword(null); form.setTestAccountPassword(null); @@ -1373,7 +1382,7 @@ public void addNavTrail(NavTree root) } @RequiresPermission(ReadPermission.class) - public class DownloadLogoForBlueskyAction extends BaseDownloadAction + public class DownloadPanoramaLogoForBlueskyAction extends BaseDownloadAction { @Nullable @Override @@ -1382,7 +1391,7 @@ public Pair getAttachment(AttachmentForm form) AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); if (ap == null) return null; - BlueskySettings settings = BlueskySettingsManager.getSettings(); + BlueskySettings settings = BlueskyIntegrationManager.getSettings(); if (StringUtils.isBlank(settings.getImageFileName())) { return null; @@ -1397,7 +1406,7 @@ public Pair getAttachment(AttachmentForm form) } @RequiresPermission(AdminOperationsPermission.class) - public class DeleteLogoForBlueskyAction extends FormHandlerAction + public class DeletePanoramaLogoForBlueskyAction extends FormHandlerAction { @Override public void validateCommand(Object target, Errors errors) @@ -1408,7 +1417,7 @@ public void validateCommand(Object target, Errors errors) public boolean handlePost(Object o, BindException errors) { PanoramaPublicLogoManager.deleteExistingNewDataLogo(getUser()); - BlueskySettingsManager.removeLogoFileName(); + BlueskyIntegrationManager.removeLogoFileName(); return true; } @@ -5428,11 +5437,39 @@ public void setDoi(String doi) // BEGIN Actions for posting to Bluesky // ------------------------------------------------------------------------ @RequiresPermission(AdminOperationsPermission.class) - public static class BlueskyAction extends ConfirmAction + public static class PostToBlueskyOptionsAction extends SimpleViewAction + { + @Override + public ModelAndView getView(ExperimentIdForm form, BindException errors) throws Exception + { + return new HtmlView( + DIV( + new Button.ButtonBuilder("Post to Primary Account") + .href(new ActionURL(PostToBlueskyAction.class, getContainer()) + .addParameter("id", form.getId())) + .build(), + HtmlString.NBSP, + new Button.ButtonBuilder("Post to Test Account") + .href(new ActionURL(PostToBlueskyAction.class, getContainer()) + .addParameter("id", form.getId()) + .addParameter("testAccount", Boolean.TRUE)) + .build() + )); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Post to Bluesky"); + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class PostToBlueskyAction extends ConfirmAction { private ExperimentAnnotations _expAnnot; private BlueskyException _exception; - private String _blueSkyPostUrl; + private String _blueskyAtUri; @Override public void validateCommand(BlueskyForm form, Errors errors) @@ -5452,31 +5489,37 @@ public void validateCommand(BlueskyForm form, Errors errors) if (_expAnnot.getShortUrl() == null) { - errors.reject(ERROR_MSG, "Experiment id " + _expAnnot.getId() + " does not have a short URL. It cannot be posted to Bluesky."); + errors.reject(ERROR_MSG, "Cannot create a post on Bluesky for an experiment that does not have a short URL. (Experiment id: " + _expAnnot.getId() + ")."); return; } JournalSubmission js = SubmissionManager.getSubmissionForJournalCopy(_expAnnot); if (js == null) { - errors.reject(ERROR_MSG, "Cannot find a submission request for copied experiement Id " + _expAnnot.getId()); + errors.reject(ERROR_MSG, "Cannot find a submission request for copied experiment Id " + _expAnnot.getId()); return; } if (!js.isLatestExperimentCopy(_expAnnot.getId())) { errors.reject(ERROR_MSG, "Experiment id " + _expAnnot.getId() + " is not the last copied submission. " + getActionName(this.getClass()) + " is only allowed in the last copy of the submitted data"); + return; + } + + if (!_expAnnot.isPublic()) + { + errors.reject(ERROR_MSG, "Cannot create a post on Bluesky for an experiment that is not public. (Experiment id: " + _expAnnot.getId() + ")."); } } @Override public boolean handlePost(BlueskyForm form, BindException errors) { - BlueskySettings settings = BlueskySettingsManager.getSettings(); + BlueskySettings settings = BlueskyIntegrationManager.getSettings(); try { // Post to Bluesky - _blueSkyPostUrl = BlueskyApiClient.getInstance().createPost(_expAnnot, settings, form.isTestAccount()); + _blueskyAtUri = BlueskyApiClient.getInstance().createPost(_expAnnot, settings, form.isTestAccount()); } catch (BlueskyException e) { @@ -5496,61 +5539,63 @@ public boolean handlePost(BlueskyForm form, BindException errors) @Override public ModelAndView getConfirmView(BlueskyForm form, BindException errors) { + BlueskySettings settings = BlueskyIntegrationManager.getSettings(); + CatalogEntry entry = CatalogEntryManager.getApprovedEntryForExperiment(_expAnnot); ActionURL imageUrl = entry != null ? PanoramaPublicController.getCatalogImageDownloadUrl(_expAnnot, entry.getImageFileName()) - : new ActionURL(DownloadLogoForBlueskyAction.class, getContainer()); + : new ActionURL(DownloadPanoramaLogoForBlueskyAction.class, getContainer()); Renderable announcementDiv = DIV(cl("bluebox").at(style, "padding:25px;"), - DIV(BlueskyApiClient.ANNOUNCEMENT_TEXT), - DIV(IMG(at(src, imageUrl) - .at(width, 320).at(height, 180))), + DIV(BlueskyApiClient.getPostText(settings, form.isTestAccount())), + DIV(IMG(at(src, imageUrl).at(width, 320).at(height, 180))), DIV(at(style, "font-weight:bold;"), _expAnnot.getTitle()), DIV(new LinkBuilder(_expAnnot.getShortUrl().renderShortURL()) .href(_expAnnot.getShortUrl().renderShortURL()) .clearClasses() .build()) ); - Renderable testPostCb = DIV(at(style, "margin-top:20px;"),CHECKBOX(at(name,"testAccount")), "Post to test account"); - String blueskyPostUrl = BlueskySettingsManager.getPostUrlForExperiment(_expAnnot); - if (blueskyPostUrl != null) + + String blueskyAtUri = BlueskyIntegrationManager.getBlueskyUriForExperiment(_expAnnot); + String account = settings.getAccount(form.isTestAccount()); + if (blueskyAtUri != null) { - String webUrl = BlueskyApiClient.tryFormatBlueskyUrl(blueskyPostUrl); + String webUrl = BlueskyApiClient.tryConvertToWebUrl(blueskyAtUri); return new HtmlView( DIV("This data has already been announced on Bluesky at ", webUrl == null - ? blueskyPostUrl - : new LinkBuilder((blueskyPostUrl)).href(webUrl) + ? blueskyAtUri + : new LinkBuilder((blueskyAtUri)).href(webUrl) .clearClasses() .build(), - ". Would you like to post the following message again? ", + String.format(". Would you like to post the following message again to the account %s?", account), announcementDiv, - testPostCb, DIV(new LinkBuilder("Clear saved post URL") .usePost() - .href(new ActionURL(ClearSavedBlueskyPostUrlAction.class, getContainer()).addParameter("id", _expAnnot.getId())) + .href(new ActionURL(ClearSavedBlueskyUriAction.class, getContainer()).addParameter("id", _expAnnot.getId())) .build()) )); } else { return new HtmlView( - DIV("The following message will be posted to Bluesky: ", + DIV(String.format("The following message will be posted to the Bluesky %saccount %s", form.isTestAccount() ? "test " : "", account), + BR(), + String.format("Post URL: %s", settings.getPostEndpoint()), announcementDiv, - testPostCb, DIV("Are you sure you want to continue?"))); } } @Override public ModelAndView getSuccessView(BlueskyForm form) { - String webUrl = BlueskyApiClient.tryFormatBlueskyUrl(_blueSkyPostUrl); + String webUrl = BlueskyApiClient.tryConvertToWebUrl(_blueskyAtUri); return new HtmlView( DIV("Posted to Bluesky!", webUrl == null - ? _blueSkyPostUrl - : new LinkBuilder((_blueSkyPostUrl)).href(webUrl).clearClasses() + ? _blueskyAtUri + : new LinkBuilder((_blueskyAtUri)).href(webUrl).clearClasses() .build(), BR(), DIV(new LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); @@ -5586,7 +5631,7 @@ public void setTestAccount(boolean testAccount) } @RequiresPermission(AdminOperationsPermission.class) - public static class ClearSavedBlueskyPostUrlAction extends FormHandlerAction + public static class ClearSavedBlueskyUriAction extends FormHandlerAction { @Override public void validateCommand(ExperimentIdForm form, Errors errors) @@ -5606,7 +5651,7 @@ public void validateCommand(ExperimentIdForm form, Errors errors) @Override public boolean handlePost(ExperimentIdForm form, BindException errors) { - BlueskySettingsManager.clearPostUrlForExperiment(form.lookupExperiment()); + BlueskyIntegrationManager.clearBlueskyUriForExperiment(form.lookupExperiment()); return true; } @@ -6752,7 +6797,7 @@ public static class MakePublicAction extends FormViewAction 0 ? " " : "", formatHashtags(hashtags)); } /** @@ -459,7 +471,7 @@ public BlueskyResponse handleResponse(ClassicHttpResponse response) throws IOExc } } - private static final Pattern AT_URI = Pattern.compile( + private static final Pattern AT_PROTOCOL_URI = Pattern.compile( "^at://([^/]+)/app\\.bsky\\.feed\\.post/([^/]+)$" ); @@ -470,14 +482,14 @@ public BlueskyResponse handleResponse(ClassicHttpResponse response) throws IOExc * Example: at:///app.bsky.feed.post/ * Convert to: https://bsky.app/profile//post/ */ - public static String tryFormatBlueskyUrl(String postUri) + public static String tryConvertToWebUrl(String atProtocolUri) { - if (StringUtils.isBlank(postUri)) + if (StringUtils.isBlank(atProtocolUri)) { return null; } - Matcher m = AT_URI.matcher(postUri.trim()); + Matcher m = AT_PROTOCOL_URI.matcher(atProtocolUri.trim()); return m.matches() ? "https://bsky.app/profile/" + m.group(1) + "/post/" + m.group(2) : null; diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java index 8ebf5153..466d8345 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java @@ -21,7 +21,7 @@ public BlueskyException(String message, Throwable cause) public BlueskyException(@NotNull String message, @NotNull BlueskyResponse response) { - super("Request failed - " + message + ". Code: " + response.getResponseCode() + "; Message " + response.getMessage() + "; Body: " + response.getResponseBody()); + super("Request failed - " + message + ". Code: " + response.getStatusCode() + "; Message " + response.getMessage() + "; Body: " + response.getResponseBody()); _response = response; } @@ -30,7 +30,7 @@ public HtmlString getHtmlString() if(_response != null) { return HtmlStringBuilder.of(HtmlString.unsafe("
")) - .append("Response code: ").append(_response.getResponseCode()) + .append("Response status code: ").append(_response.getStatusCode()) .append(HtmlString.BR) .append("Message: ").append(_response.getMessage()) .append(HtmlString.BR) diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyIntegrationManager.java similarity index 72% rename from panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java rename to panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyIntegrationManager.java index 1458b13e..265b6e80 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyIntegrationManager.java @@ -4,16 +4,20 @@ import org.labkey.api.data.PropertyManager.WritablePropertyMap; import org.labkey.panoramapublic.model.ExperimentAnnotations; -public class BlueskySettingsManager +public class BlueskyIntegrationManager { + // Category name for propertysets private static final String CREDENTIALS = "Bluesky credentials"; + // Property names for properties that belog to the "Bluesky credenditals" category private static final String ACCOUNT = "Bluesky account"; private static final String PASSWORD = "Bluesky password"; private static final String TEST_ACCOUNT = "Bluesky test account"; private static final String TEST_ACCOUNT_PASSWORD = "Bluesky test password"; + // Category name for propertysets private static final String SETTINGS = "Bluesky settings"; + // Property names for properties that belog to the "Bluesky settings" category private static final String AUTH_URL = "Bluesky auth endpoint"; private static final String POST_URL = "Bluesky post endpoint"; private static final String BLOB_UPLOAD_URL = "Image blob upload endpoint"; @@ -22,9 +26,17 @@ public class BlueskySettingsManager private static final String TEST_HASHTAGS = "Hashtags for test account"; private static final String PANORAMA_LOGO_FILENAME = "Panorama logo file name"; private static final String AUTOPOST = "Auto‑post to Bluesky on publish"; + private static final String ANNOUNCEMENT_TEXT = "Announcement text"; - public static final String BLUESKY_LINKS = "Bluesky post links"; + // Category name for propertysets. Properties belonging to this category will have the data short URL as the name + // and the Bluesky post URI (AT protocol) as the value. + public static final String BLUESKY_URIS = "Bluesky AT protocol URIs"; + + // Default values for the endpoints + public static final String DEFAULT_AUTH_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"; + public static final String DEFAULT_POST_URL = "https://bsky.social/xrpc/com.atproto.repo.createRecord"; + public static final String DEFAULT_IMAGE_UPLOAD_URL = "https://bsky.social/xrpc/com.atproto.repo.uploadBlob"; public static BlueskySettings getSettings() { @@ -43,6 +55,7 @@ public static BlueskySettings getSettings() settings.setAuthEndpoint(settingsMap.get(AUTH_URL)); settings.setPostEndpoint(settingsMap.get(POST_URL)); settings.setBlobUploadEndpoint(settingsMap.get(BLOB_UPLOAD_URL)); + settings.setAnnouncementText(settingsMap.get(ANNOUNCEMENT_TEXT)); settings.setHashtags(settingsMap.get(HASHTAGS)); settings.setTestHashtags(settingsMap.get(TEST_HASHTAGS)); settings.setAutopost(Boolean.valueOf(settingsMap.get(AUTOPOST))); @@ -66,6 +79,7 @@ public static void saveSettings(BlueskySettings settings) settingsMap.put(AUTH_URL, settings.getAuthEndpoint()); settingsMap.put(POST_URL, settings.getPostEndpoint()); settingsMap.put(BLOB_UPLOAD_URL, settings.getBlobUploadEndpoint()); + settingsMap.put(ANNOUNCEMENT_TEXT, settings.getAnnouncementText()); settingsMap.put(HASHTAGS, settings.getHashtags()); settingsMap.put(TEST_HASHTAGS, settings.getTestHashtags()); settingsMap.save(); @@ -78,7 +92,7 @@ public static void removeLogoFileName() saveSettings(settings); } - public static String getPostUrlForExperiment(ExperimentAnnotations experimentAnnotations) + public static String getBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) { if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) { @@ -86,32 +100,32 @@ public static String getPostUrlForExperiment(ExperimentAnnotations experimentAnn } WritablePropertyMap map = PropertyManager.getNormalStore() - .getWritableProperties(BlueskySettingsManager.BLUESKY_LINKS, false); + .getWritableProperties(BlueskyIntegrationManager.BLUESKY_URIS, false); return map != null ? map.get(experimentAnnotations.getShortUrl().renderShortURL()) : null; } - public static void savePostUrlForExperiment(ExperimentAnnotations experimentAnnotations, String blueskyPostUrl) + public static void saveBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations, String atProtocolUri) { if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) { return; } WritablePropertyMap propertyMap = PropertyManager.getNormalStore() - .getWritableProperties(BlueskySettingsManager.BLUESKY_LINKS, true); - propertyMap.put(experimentAnnotations.getShortUrl().renderShortURL(), blueskyPostUrl); + .getWritableProperties(BlueskyIntegrationManager.BLUESKY_URIS, true); + propertyMap.put(experimentAnnotations.getShortUrl().renderShortURL(), atProtocolUri); propertyMap.save(); } - public static void clearPostUrlForExperiment(ExperimentAnnotations experimentAnnotations) + public static void clearBlueskyUriForExperiment(ExperimentAnnotations experimentAnnotations) { if (experimentAnnotations == null || experimentAnnotations.getShortUrl() == null) { return; } - if (getPostUrlForExperiment(experimentAnnotations) != null) + if (getBlueskyUriForExperiment(experimentAnnotations) != null) { WritablePropertyMap propertyMap = PropertyManager.getNormalStore() - .getWritableProperties(BlueskySettingsManager.BLUESKY_LINKS, true); + .getWritableProperties(BlueskyIntegrationManager.BLUESKY_URIS, true); propertyMap.remove(experimentAnnotations.getShortUrl().renderShortURL()); propertyMap.save(); } diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java index eebe112a..1404c9aa 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java @@ -7,20 +7,20 @@ public class BlueskyResponse { - private final int _responseCode; + private final int _statusCode; private final String _message; private final String _responseBody; - public BlueskyResponse(int responseCode, String message, String responseBody) + public BlueskyResponse(int statusCode, String message, String responseBody) { - _responseCode = responseCode; + _statusCode = statusCode; _message = message; _responseBody = responseBody; } - public int getResponseCode() + public int getStatusCode() { - return _responseCode; + return _statusCode; } public String getMessage() @@ -35,7 +35,7 @@ public String getResponseBody() public boolean success() { - return _responseCode == HttpStatus.SC_OK || _responseCode == HttpStatus.SC_CREATED; + return _statusCode == HttpStatus.SC_OK || _statusCode == HttpStatus.SC_CREATED; } public JSONObject getJsonObject() throws BlueskyException diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java index 659f448c..c45551f4 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java @@ -22,6 +22,8 @@ public class BlueskySettings private String _postEndpoint; private String _blobUploadEndpoint; + private String _announcementText; + public String getAccount() { return _account; @@ -159,4 +161,19 @@ public void setBlobUploadEndpoint(String blobUploadEndpoint) { _blobUploadEndpoint = blobUploadEndpoint; } + + public String getAnnouncementText() + { + return _announcementText; + } + + public void setAnnouncementText(String announcementText) + { + _announcementText = announcementText; + } + + public String getAccount (boolean test) + { + return test ? getTestAccount() : getAccount(); + } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java b/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java index 6c2e8cb3..dc14c858 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java +++ b/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java @@ -84,7 +84,7 @@ else if(expAnnotations.getContainer().equals(container)) navTree.addChild("Data Validation", new ActionURL(PanoramaPublicController.ViewPxValidationsAction.class, container).addParameter("id", expAnnotations.getId())); navTree.addChild("DOI", new ActionURL(PanoramaPublicController.DoiOptionsAction.class, container).addParameter("id", expAnnotations.getId())); navTree.addChild("Make Data Public", new ActionURL(PanoramaPublicController.MakePublicAction.class, container).addParameter("id", expAnnotations.getId())); - navTree.addChild("Post to Bluesky", new ActionURL(PanoramaPublicController.BlueskyAction.class, container).addParameter("id", expAnnotations.getId())); + navTree.addChild("Post to Bluesky", new ActionURL(PanoramaPublicController.PostToBlueskyOptionsAction.class, container).addParameter("id", expAnnotations.getId())); JournalSubmission submission = SubmissionManager.getSubmissionForExperiment(expAnnotations); if (submission != null) diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp index a356d7b7..22cf0494 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp @@ -4,6 +4,7 @@ <%@ page import="org.labkey.panoramapublic.PanoramaPublicController" %> <%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PanoramaPublicAdminViewAction" %> <%@ page import="org.labkey.panoramapublic.bluesky.BlueskySettings" %> +<%@ page import="org.labkey.panoramapublic.bluesky.BlueskyIntegrationManager" %> <%@ page extends="org.labkey.api.jsp.FormPage" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> @@ -37,7 +38,7 @@
Auth URL: -
e.g. https://bsky.social/xrpc/com.atproto.server.createSession
+
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_AUTH_URL)%>
Post URL: -
e.g. https://bsky.social/xrpc/com.atproto.repo.createRecord
+
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_POST_URL)%>
Image upload URL: -
e.g. https://bsky.social/xrpc/com.atproto.repo.uploadBlob
+
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL)%>
+
Announcement text: +
Panorama Public Logo: <% if (form.getImageFileName() != null) { %> - <%=link("View Logo", urlFor(PanoramaPublicController.DownloadLogoForBlueskyAction.class))%> - <%=link("Delete Logo", urlFor(PanoramaPublicController.DeleteLogoForBlueskyAction.class)).usePost()%> + <%=link("View Logo", urlFor(PanoramaPublicController.DownloadPanoramaLogoForBlueskyAction.class))%> + <%=link("Delete Logo", urlFor(PanoramaPublicController.DeletePanoramaLogoForBlueskyAction.class)).usePost()%> <% } %>
- PNG or JPG/JPEG file in 16x9 ascpect ratio that will be included in the Bluesky post + PNG or JPG/JPEG file in 16x9 aspect ratio that will be included in the Bluesky post
Auth URL: -
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_AUTH_URL)%>
+
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_AUTH_URL)%>
Post URL: -
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_POST_URL)%>
+
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_POST_URL)%>
Image upload URL: -
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL)%>
+
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL)%>
Hashtags: -
Hashtags associated with the post, comma-separated (e.g proteomics, proteomicssky, massspec, massspecsky)
+
Hashtags associated with the post, comma-separated (e.g proteomics, proteomicssky, massspec, massspecsky)
Hashtags (test account): -
Hashtags associated with the post to the test account, comma-separated.
+
Hashtags associated with the post to the test account, comma-separated.
Auth URL: -
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_AUTH_URL)%>
+
e.g. <%=h(BlueskySettingsManager.DEFAULT_AUTH_URL)%>
Post URL: -
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_POST_URL)%>
+
e.g. <%=h(BlueskySettingsManager.DEFAULT_POST_URL)%>
Image upload URL: -
e.g. <%=h(BlueskyIntegrationManager.DEFAULT_IMAGE_UPLOAD_URL)%>
+
e.g. <%=h(BlueskySettingsManager.DEFAULT_IMAGE_UPLOAD_URL)%>