diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 6f55dd0d..d50fe52d 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.Link.LinkBuilder; +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; @@ -139,6 +141,13 @@ 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.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; import org.labkey.panoramapublic.catalog.CatalogEntrySettings; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentParent; import org.labkey.panoramapublic.chromlib.ChromLibStateManager; @@ -237,13 +246,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.ERRORS; import static org.labkey.api.util.DOM.LK.FORM; import static org.labkey.panoramapublic.proteomexchange.NcbiUtils.PUBMED_ID; @@ -296,12 +308,13 @@ 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); view.addView(getPXCredentialsLink()); view.addView(getDataCiteCredentialsLink()); + view.addView(getBlueskySettingsLink()); view.addView(getPanoramaPublicCatalogSettingsLink()); view.addView(getPostSupportMessageLink()); view.setFrame(WebPartView.FrameType.PORTAL); @@ -313,21 +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 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() @@ -337,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; } @@ -1005,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 @@ -1152,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 @@ -1209,6 +1229,206 @@ public void setPassword(String password) } } + @RequiresPermission(AdminOperationsPermission.class) + public static class ManageBlueskySettings extends FormViewAction + { + @Override + public void validateCommand(BlueskySettings form, Errors errors) + { + if (StringUtils.isBlank(form.getAccount())) + { + 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.getTestAccount())) + { + 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"); + } + if (StringUtils.isBlank(form.getAnnouncementText())) + { + errors.reject(ERROR_MSG, "Announcement text cannot be blank"); + } + } + + @Override + public boolean handlePost(BlueskySettings form, BindException errors) + { + BlueskyApiClient client = BlueskyApiClient.getInstance(); + try + { + client.login(form); + } + catch (BlueskyException e) + { + errors.reject(ERROR_MSG, String.format("Bluesky login failed for the account '%s'. Error was %s", + form.getAccount(), e.getMessage())); + return false; + } + + try + { + client.loginTestAccount(form); + } + catch (BlueskyException e) + { + 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); + 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 + { + 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; + } + + BlueskySettingsManager.saveSettings(form); + return true; + } + + @Override + public URLHelper getSuccessURL(BlueskySettings form) + { + return null; + } + + @Override + public ModelAndView getSuccessView(BlueskySettings form) + { + ActionURL adminUrl = new ActionURL(PanoramaPublicAdminViewAction.class, getContainer()); + return new HtmlView( + DIV("Bluesky settings saved!", + BR(), + new LinkBuilder("Back to Panorama Public Admin Console").href(adminUrl).build())); + } + + @Override + public ModelAndView getView(BlueskySettings form, boolean reshow, BindException errors) + { + if(!reshow) + { + form = BlueskySettingsManager.getSettings(); + + 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. + form.setPassword(null); + form.setTestAccountPassword(null); + } + JspView view = new JspView<>("/org/labkey/panoramapublic/view/manageBlueskySettings.jsp", form, errors); + view.setFrame(WebPartView.FrameType.PORTAL); + view.setTitle("Bluesky Settings"); + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + addPanoramaPublicAdminConsoleNav(root, getContainer()); + root.addChild("Manage Bluesky Settings"); + } + } + + @RequiresPermission(ReadPermission.class) + public class DownloadPanoramaLogoForBlueskyAction extends BaseDownloadAction + { + @Nullable + @Override + public Pair getAttachment(AttachmentForm form) + { + AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + if (ap == null) return null; + + 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()); + } + return null; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public class DeletePanoramaLogoForBlueskyAction extends FormHandlerAction + { + @Override + public void validateCommand(Object target, Errors errors) + { + } + + @Override + public boolean handlePost(Object o, BindException errors) + { + PanoramaPublicLogoManager.deleteExistingNewDataLogo(getUser()); + BlueskySettingsManager.removeLogoFileName(); + return true; + } + + @Override + public URLHelper getSuccessURL(Object o) + { + return new ActionURL(ManageBlueskySettings.class, getContainer()); + } + } + @RequiresPermission(AdminOperationsPermission.class) public static class ManageCatalogEntrySettings extends FormViewAction { @@ -1324,7 +1544,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 @@ -2560,7 +2780,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; @@ -2640,7 +2860,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())); @@ -2707,7 +2927,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); } @@ -2730,7 +2950,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()))); } @@ -3481,18 +3701,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())) )); @@ -4337,7 +4557,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 @@ -4849,18 +5069,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)); } } @@ -4953,7 +5173,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(); @@ -5214,6 +5434,241 @@ public void setDoi(String doi) // END Actions for DataCite DOI assignment // ------------------------------------------------------------------------ + // ------------------------------------------------------------------------ + // BEGIN Actions for posting to Bluesky + // ------------------------------------------------------------------------ + @RequiresPermission(AdminOperationsPermission.class) + 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 _blueskyAtUri; + + @Override + public void validateCommand(BlueskyForm 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, "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 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."); + } + } + + @Override + public boolean handlePost(BlueskyForm form, BindException errors) + { + BlueskySettings settings = BlueskySettingsManager.getSettings(); + try + { + // Post to Bluesky + _blueskyAtUri = BlueskyApiClient.getInstance().createPost(_expAnnot, settings, form.isTestAccount()); + } + catch (BlueskyException e) + { + _exception = e; + LOG.error(e.getMessage(), e); + return false; + } + return true; + } + + @Override + public @NotNull URLHelper getSuccessURL(BlueskyForm form) + { + return null; + } + + @Override + public ModelAndView getConfirmView(BlueskyForm form, BindException errors) + { + BlueskySettings settings = BlueskySettingsManager.getSettings(); + + CatalogEntry entry = CatalogEntryManager.getApprovedEntryForExperiment(_expAnnot); + ActionURL imageUrl = entry != null + ? PanoramaPublicController.getCatalogImageDownloadUrl(_expAnnot, entry.getImageFileName()) + : new ActionURL(DownloadPanoramaLogoForBlueskyAction.class, getContainer()); + + Renderable announcementDiv = DIV(cl("bluebox").at(style, "padding:25px;"), + 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()) + .href(_expAnnot.getShortUrl().renderShortURL()) + .clearClasses() + .build()) + ); + + String blueskyAtUri = BlueskyLinksManager.getBlueskyUriForExperiment(_expAnnot); + String account = settings.getAccount(form.isTestAccount()); + if (blueskyAtUri != null) + { + String webUrl = BlueskyApiClient.tryConvertToWebUrl(blueskyAtUri); + return new HtmlView( + DIV("This data has already been announced on Bluesky at ", + webUrl == null + ? blueskyAtUri + : new LinkBuilder((blueskyAtUri)).href(webUrl) + .clearClasses() + .build(), + String.format(". Would you like to post the following message again to the account %s?", account), + announcementDiv, + DIV(new LinkBuilder("Clear saved post URL") + .usePost() + .href(new ActionURL(ClearSavedBlueskyUriAction.class, getContainer()).addParameter("id", _expAnnot.getId())) + .build()) + )); + } + else + { + return new HtmlView( + DIV(String.format("The following message will be posted to the Bluesky %saccount %s", form.isTestAccount() ? "test " : "", account), + BR(), + String.format("URL: %s", settings.getPostEndpoint()), + announcementDiv, + DIV("Are you sure you want to continue?"))); + } + } + @Override + public ModelAndView getSuccessView(BlueskyForm form) + { + String webUrl = BlueskyApiClient.tryConvertToWebUrl(_blueskyAtUri); + + return new HtmlView( + DIV("Posted to Bluesky! ", + webUrl == null + ? _blueskyAtUri + : new LinkBuilder((_blueskyAtUri)).href(webUrl).clearClasses() + .build(), + BR(), + DIV(new LinkBuilder("Back to folder").href(PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_expAnnot.getContainer()))))); + } + + @Override + public ModelAndView getFailView(BlueskyForm form, BindException errors) + { + if(_exception != null) + { + return new HtmlView(_exception.getHtmlString()); + } + else + { + return super.getFailView(form, errors); + } + } + } + + public static class BlueskyForm extends ExperimentIdForm + { + private boolean _testAccount; + + public boolean isTestAccount() + { + return _testAccount; + } + + public void setTestAccount(boolean testAccount) + { + _testAccount = testAccount; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public static class ClearSavedBlueskyUriAction 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) + { + BlueskyLinksManager.clearBlueskyUriForExperiment(form.lookupExperiment()); + return true; + } + + @Override + public URLHelper getSuccessURL(ExperimentIdForm experimentIdForm) + { + return PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(getContainer()); + } + + + } + // ------------------------------------------------------------------------ + // END Actions for posting to Bluesky + // ------------------------------------------------------------------------ + + // ------------------------------------------------------------------------ // BEGIN Experiment annotation actions // ------------------------------------------------------------------------ @@ -5493,7 +5948,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()), @@ -5601,7 +6056,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); } @@ -6343,6 +6798,7 @@ public static class MakePublicAction extends FormViewAction 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/PanoramaPublicNotification.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java index b4cc7eb0..57bba24b 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java @@ -132,7 +132,7 @@ public static void notifyCopied(ExperimentAnnotations srcExpAnnotations, Experim } public static void notifyDataPublished(ExperimentAnnotations srcExperiment, ExperimentAnnotations journalCopy, Journal journal, - JournalExperiment je, DataCiteException doiError, boolean madePublic, boolean addedPublication, User user) + JournalExperiment je, DataCiteException doiError, boolean madePublic, boolean addedPublication, String blueskyPostUrl, User user) { StringBuilder messageBody = new StringBuilder(); messageBody.append("Dear ").append(getUserName(user)).append(",").append(NL2); @@ -161,6 +161,10 @@ else if (addedPublication) { messageBody.append(NL2).append("The data will be available under the ").append(journalCopy.getDataLicense().getDisplayName()).append(" license."); } + if (madePublic && !StringUtils.isBlank(blueskyPostUrl)) + { + messageBody.append(NL2).append("We have announced your data on Bluesky - take a look here: ").append(bold(link("Bluesky Post", blueskyPostUrl))).append("."); + } if (journalCopy.hasPxid()) { messageBody.append(NL2); diff --git a/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java new file mode 100644 index 00000000..54307f01 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyApiClient.java @@ -0,0 +1,718 @@ +package org.labkey.panoramapublic.bluesky; + +import org.apache.commons.lang3.StringUtils; +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.junit.Assert; +import org.junit.Test; +import org.labkey.api.attachments.Attachment; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; +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.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class BlueskyApiClient +{ + protected static final Logger logger = LogHelper.getLogger(BlueskyApiClient.class, "BlueskyApiClient logger"); + + private static final BlueskyApiClient INSTANCE = new BlueskyApiClient(); + + public static BlueskyApiClient getInstance() + { + return INSTANCE; + } + + private BlueskyApiClient() {} + + /** + * Login to Bluesky and obtain auth tokens + */ + public LoginInfo login(@NotNull BlueskySettings settings, boolean testAccount) throws BlueskyException + { + ClientConfig config = new ClientConfig(settings, testAccount); + + 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", config.getAccount()); + requestBody.put("password", config.getPassword()); + + 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'", config.getAccount(), config.getAuthEndpoint())); + + BlueskyResponse response; + try (CloseableHttpClient httpClient = HttpClients.createDefault()) + { + 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); + } + catch (IOException | JSONException e) + { + throw new BlueskyException("Bluesky login failed", config.getAccount(), config.getAuthEndpoint(), e); + } + } + + 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 + } + + public LoginInfo loginTestAccount(@NotNull BlueskySettings settings) throws BlueskyException + { + return login(settings, true); // Login to the test account + } + + 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) + { + _accessJwt = accessJwt; + _did = did; + } + + public String getAccessJwt() + { + return _accessJwt; + } + + public String getDid() + { + return _did; + } + } + + /** + * Create a post on Bluesky announcing the data + */ + @NotNull + public String createPost(@NotNull ExperimentAnnotations exptAnnotations, BlueskySettings settings, boolean useTestAccount) throws BlueskyException + { + 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 useTestAccount) throws BlueskyException + { + if (BlueskyLinksManager.getBlueskyUriForExperiment(exptAnnotations) != null) + { + // There is already a post on Bluesky related to this data + return null; + } + + return createPost(exptAnnotations, settings, useTestAccount); + } + + @NotNull + public String createPost(@NotNull ExperimentAnnotations exptAnnotations, @NotNull BlueskySettings settings, + @NotNull LoginInfo loginInfo, boolean useTestAccount) throws BlueskyException + { + 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, config, loginInfo); + + 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 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, config, "Post creation failed"); + JSONObject responseJson = response.getJsonObject(); + if (responseJson.has("uri")) + { + blueskyAtUri = responseJson.getString("uri"); + } + else + { + throw new BlueskyException("Post creation failed - Missing URI in response.", response); + } + if (StringUtils.isBlank(blueskyAtUri)) + { + throw new BlueskyException("Post URI in response is blank.", response); + } + } + catch (IOException | JSONException e) + { + throw new BlueskyException("Post creation failed.", config.getAccount(), config.getPostEndpoint(), e); + } + + if (!useTestAccount) + { + BlueskyLinksManager.saveBlueskyUriForExperiment(exptAnnotations, blueskyAtUri); + } + + return blueskyAtUri; + } + + @NotNull + private JSONObject createRequestBody(@NotNull ExperimentAnnotations exptAnnotations, + @NotNull ClientConfig config, @NotNull LoginInfo loginInfo) throws BlueskyException + { + JSONObject record = buildRecord(exptAnnotations, config, loginInfo); + + return new JSONObject() + .put("repo", loginInfo.getDid()) + .put("collection", "app.bsky.feed.post") + .put("record", record); + } + + @NotNull + private JSONObject buildRecord(@NotNull ExperimentAnnotations exptAnnotations, + @NotNull ClientConfig config, @NotNull LoginInfo loginInfo) throws BlueskyException + { + String text = getAnnouncement(config); + + JSONObject record = new JSONObject() + .put("$type", "app.bsky.feed.post") + .put("text", text) + .put("createdAt", Instant.now().toString()); + + // Add hashtag facets if any + JSONArray facets = buildHashtagFacets(text, config.getHashtags()); + if (!facets.isEmpty()) + { + record.put("facets", facets); + } + + // Build and attach the embed object + record.put("embed", buildEmbed(exptAnnotations, config, loginInfo)); + return record; + } + + /** + * Generate post text with hashtags + */ + private static String getAnnouncement(@NotNull ClientConfig config) + { + 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)); + } + + /** + * Convert an array of hashtag strings to a space-separated string with '#' prefix + */ + private static String formatHashtags(@NotNull String[] hashtags) + { + return Arrays.stream(hashtags) + .map(tag -> "#" + tag) + .collect(Collectors.joining(" ")); + } + + /** + * Create hashtag facets for Bluesky's rich text formatting + */ + private static JSONArray buildHashtagFacets(String postText, String[] hashtags) + { + JSONArray facets = new JSONArray(); + + for (String tag : hashtags) { + JSONObject tagFacet = createSingleHashtagFacet(postText, tag); + if (tagFacet != null) { + facets.put(tagFacet); + } + } + + return facets; + } + + /** + * Create a single hashtag facet for Bluesky's rich text formatting + */ + @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) + { + return null; + } + + // Convert to byte position for UTF-8 + 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 ClientConfig config, @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, config, loginInfo)); + + embed.put("external", external); + return embed; + } + + 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. + Attachment attachment = getCatalogEntryAttachment(exptAnnotations); + if (attachment != null) + { + attachmentParent = new CatalogImageAttachmentParent(exptAnnotations.getShortUrl(), exptAnnotations.getContainer()); + } + 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(); + if (attachmentParent == null) + { + throw new BlueskyException("Unable to initialize PanoramaPublicLogoAttachmentParent. Perhaps a Panorama Public project does not exist on the server."); + } + attachment = PanoramaPublicLogoManager.getNewDataLogo(config.getImageFileName()); + } + + if (attachment == null) + { + throw new BlueskyException("Unable to find an image file to include in the post."); + } + + JSONObject blobResponse = uploadImage(attachment, attachmentParent, config, 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, ClientConfig config, LoginInfo loginInfo) throws BlueskyException + { + 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, config, "Failed to upload image to Bluesky"); + return response.getJsonObject(); + } + catch (IOException | JSONException e) + { + throw new BlueskyException("Failed to upload image to Bluesky", config.getAccount(), endpoint, e); + } + } + + @NotNull + private BlueskyResponse getResponse(CloseableHttpClient httpClient, HttpPost httpPost, ClientConfig config, String failureMessage) throws IOException, BlueskyException + { + BlueskyResponse response = httpClient.execute(httpPost, new BlueskyResponseHandler(config, httpPost.getRequestUri())); + String responseContent = response.getResponseBody() != null ? response.getResponseBody() : ""; + + if (!response.success()) + { + throw new BlueskyException(failureMessage, response); + } + + if (responseContent.isEmpty()) + { + throw new BlueskyException("Received empty response from Bluesky", response); + } + + return response; + } + + /** + * Upload an image attachment to Bluesky + */ + @NotNull + private JSONObject uploadImage(@NotNull Attachment attachment, @NotNull AttachmentParent parent, + 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, config, loginInfo); + } + catch (FileNotFoundException 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); + throw new BlueskyException("Error reading image file", e); + } + } + + /** + * Custom response handler to handle HTTP responses from Bluesky + */ + 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 + { + try + { + HttpEntity entity = response.getEntity(); + String content = entity != null ? EntityUtils.toString(entity) : null; + return new BlueskyResponse(response.getCode(), response.getReasonPhrase(), content, _account, _apiEndpoint); + } + catch (ParseException e) + { + throw new IOException("Failed to parse response content", e); + } + } + } + + private static final Pattern AT_PROTOCOL_URI = Pattern.compile( + "^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 tryConvertToWebUrl(String atProtocolUri) + { + if (StringUtils.isBlank(atProtocolUri)) + { + return null; + } + + Matcher m = AT_PROTOCOL_URI.matcher(atProtocolUri.trim()); + return m.matches() + ? "https://bsky.app/profile/" + m.group(1) + "/post/" + m.group(2) + : 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); + _password = settings.getPassword(test); + _imageFileName = settings.getImageFileName(); + _hashtags = test ? settings.getTestHashtagArray() : settings.getHashtagArray(); + _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 + 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); + } + + private void compareSorted(String[] expected, String input) + { + String[] actual = BlueskySettings.convertToArray(input); + Arrays.sort(actual); + assertArrayEquals(expected, actual); + } + + @Test + public void testHashtagGetters() + { + 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()); + + ClientConfig config = new ClientConfig(settings, false); + assertArrayEquals(expectedMainTags, config.getHashtags()); + config = new ClientConfig(settings, true); + assertArrayEquals(expectedTestTags, config.getHashtags()); + } + + @Test + 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 testGetAnnouncementText() + { + 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.getAnnouncement(new ClientConfig(settings, false)); + Assert.assertEquals(announcementText, text); + // Test account, no hashtags + String testText = BlueskyApiClient.getAnnouncement(new ClientConfig(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.getAnnouncement(new ClientConfig(settings, false)); + Assert.assertEquals(announcementText, text); + // Test account, no hashtags + testText = BlueskyApiClient.getAnnouncement(new ClientConfig(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.getAnnouncement(new ClientConfig(settings, false)); + Assert.assertEquals(announcementText + " #skyline", text); + // Test account, single hashtag + testText = BlueskyApiClient.getAnnouncement(new ClientConfig(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.getAnnouncement(new ClientConfig(settings, false)); + Assert.assertEquals(announcementText + " #proteomics #proteomicssky #massspec #massspecsky", text); + // Test account, multiple hashtags + 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 new file mode 100644 index 00000000..9403136a --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyException.java @@ -0,0 +1,69 @@ +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(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(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; + } + + 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()) + .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/BlueskyLinksManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyLinksManager.java new file mode 100644 index 00000000..54794a19 --- /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 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) + { + 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/BlueskyResponse.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java new file mode 100644 index 00000000..f3b900f8 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskyResponse.java @@ -0,0 +1,66 @@ +package org.labkey.panoramapublic.bluesky; + + +import org.apache.hc.core5.http.HttpStatus; +import org.json.JSONException; +import org.json.JSONObject; + +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, String blueskyAccount, String endpoint) + { + _statusCode = statusCode; + _message = message; + _responseBody = responseBody; + _account = blueskyAccount; + _endpoint = endpoint; + } + + public int getStatusCode() + { + return _statusCode; + } + + public String getMessage() + { + return _message; + } + + 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; + } + + public JSONObject getJsonObject() throws BlueskyException + { + try + { + return new JSONObject(_responseBody); + } + catch (JSONException e) + { + throw new BlueskyException("Error parsing JSON from response", this); + } + } +} 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..9fce1a64 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettings.java @@ -0,0 +1,184 @@ +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; + + private String _announcementText; + + 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; + } + + public 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; + } + + public String getAnnouncementText() + { + return _announcementText; + } + + public void setAnnouncementText(String announcementText) + { + _announcementText = announcementText; + } + + 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/bluesky/BlueskySettingsManager.java b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java new file mode 100644 index 00000000..a9ad5497 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/BlueskySettingsManager.java @@ -0,0 +1,89 @@ +package org.labkey.panoramapublic.bluesky; + +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; + +public class BlueskySettingsManager +{ + // Category name for propertysets + private static final String CREDENTIALS = "Bluesky credentials"; + // 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"; + + 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 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"; + + 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"; + private static final String ANNOUNCEMENT_TEXT = "Announcement text"; + + + // 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() + { + 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.setAnnouncementText(settingsMap.get(ANNOUNCEMENT_TEXT)); + 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(ANNOUNCEMENT_TEXT, settings.getAnnouncementText()); + 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); + } +} 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..f3e464f4 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoAttachmentParent.java @@ -0,0 +1,36 @@ +package org.labkey.panoramapublic.bluesky; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +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, if it exists + 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..0889af85 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoManager.java @@ -0,0 +1,71 @@ +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; +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(@NotNull String filename) + { + AttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + if (ap == null) return null; + + return AttachmentService.get().getAttachments(ap).stream() + .filter(a -> a.getName() != null && filename.equals(a.getName())) + .findFirst() + .orElse(null); + } + + 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); + PanoramaPublicLogoAttachmentParent ap = PanoramaPublicLogoAttachmentParent.get(); + 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) + { + 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..f7f414e8 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/bluesky/PanoramaPublicLogoResourceType.java @@ -0,0 +1,37 @@ +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 ") + .appendStringLiteral(PanoramaPublicLogoManager.LOGO_FILE_PREFIX + "%", CoreSchema.getInstance().getSqlDialect()) + .append(") "); + } +} 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/expannotations/TargetedMSExperimentWebPart.java b/panoramapublic/src/org/labkey/panoramapublic/view/expannotations/TargetedMSExperimentWebPart.java index f78f6866..dc14c858 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.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 new file mode 100644 index 00000000..74000c2d --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/view/manageBlueskySettings.jsp @@ -0,0 +1,116 @@ +<%@ 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.PanoramaPublicAdminViewAction" %> +<%@ page import="org.labkey.panoramapublic.bluesky.BlueskySettings" %> +<%@ page import="org.labkey.panoramapublic.bluesky.BlueskySettingsManager" %> +<%@ page extends="org.labkey.api.jsp.FormPage" %> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> + +<% + JspView currentView = HttpView.currentView(); + BlueskySettings form = currentView.getModelBean(); + ActionURL panoramaPublicAdminUrl = urlFor(PanoramaPublicAdminViewAction.class); +%> +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account:
Password:
Test account:
Test account password:
Auth URL: + +
e.g. <%=h(BlueskySettingsManager.DEFAULT_AUTH_URL)%>
+
Post URL: + +
e.g. <%=h(BlueskySettingsManager.DEFAULT_POST_URL)%>
+
Image upload URL: + +
e.g. <%=h(BlueskySettingsManager.DEFAULT_IMAGE_UPLOAD_URL)%>
+
Announcement text: + +
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.DownloadPanoramaLogoForBlueskyAction.class))%> + <%=link("Delete Logo", urlFor(PanoramaPublicController.DeletePanoramaLogoForBlueskyAction.class)).usePost()%> + <% } %> + + + +
+ PNG or JPG/JPEG file in 16x9 aspect ratio that will be included in the Bluesky post +
+
+ <%=button("Save").submit(true)%> + <%=button("Cancel").href(panoramaPublicAdminUrl)%> +
+ +
+