diff --git a/content/content-tool/tool/src/java/org/sakaiproject/content/entityproviders/ContentEntityProvider.java b/content/content-tool/tool/src/java/org/sakaiproject/content/entityproviders/ContentEntityProvider.java index 73a8230874b2..d426dcb13167 100644 --- a/content/content-tool/tool/src/java/org/sakaiproject/content/entityproviders/ContentEntityProvider.java +++ b/content/content-tool/tool/src/java/org/sakaiproject/content/entityproviders/ContentEntityProvider.java @@ -33,6 +33,8 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; +import javax.servlet.http.HttpServletResponse; + import org.apache.commons.lang3.StringUtils; import org.sakaiproject.authz.api.SecurityService; import org.sakaiproject.component.cover.ComponentManager; @@ -42,6 +44,7 @@ import org.sakaiproject.content.api.ContentResource; import org.sakaiproject.content.api.ResourceTypeRegistry; import org.sakaiproject.content.tool.ListItem; +import org.sakaiproject.entitybroker.exception.EntityException; import org.sakaiproject.entity.api.EntityManager; import org.sakaiproject.entity.api.EntityPermissionException; import org.sakaiproject.entity.api.Reference; @@ -80,6 +83,7 @@ * Entity provider for the Content / Resources tool */ @Slf4j +@Setter public class ContentEntityProvider extends AbstractEntityProvider implements EntityProvider, AutoRegisterEntityProvider, ActionsExecutable, Outputable, Describeable { public final static String ENTITY_PREFIX = "content"; @@ -89,6 +93,13 @@ public class ContentEntityProvider extends AbstractEntityProvider implements Ent private static final String PARAMETER_DEPTH = "depth"; private static final String PARAMETER_TIMESTAMP = "timestamp"; + private ContentHostingService contentHostingService; + private SiteService siteService; + private ToolManager toolManager; + private SecurityService securityService; + private UserDirectoryService userDirectoryService; + private EntityManager entityManager; + @Override public String getEntityPrefix() { return ENTITY_PREFIX; @@ -167,20 +178,17 @@ public List getContentCollectionForSite(EntityView view) { // get siteId String siteId = view.getPathSegment(2); - - if(log.isDebugEnabled()) { - log.debug("Content for site: " + siteId); - } + log.debug("Content for site: {}", siteId); // check siteId supplied if (StringUtils.isBlank(siteId)) { throw new IllegalArgumentException("siteId a must be set in order to get the resources for a site, via the URL /content/site/siteId"); } - + // return the ListItem list for the site return getSiteListItems(siteId); - } + @EntityCustomAction(action="resources", viewKey=EntityView.VIEW_LIST) public List getResources(EntityView view, Map params) throws EntityPermissionException { @@ -240,6 +248,18 @@ public List getResources(EntityView view, Map par return resourceDetails; } + @EntityCustomAction(action="htmlForRef", viewKey=EntityView.VIEW_SHOW) + public String getHtmlForRef(EntityView view, Map params) throws EntityPermissionException { + + String ref = (String) params.get("ref"); + + if (StringUtils.isBlank(ref)) { + throw new EntityException("You need to supply the ref parameter.", null, HttpServletResponse.SC_BAD_REQUEST); + } + + return contentHostingService.getHtmlForRef(ref).orElse(""); + } + /** * * @param entity The entity to load details of. @@ -703,32 +723,10 @@ private List getResources(String siteId) { return items; } - @Override public String[] getHandledOutputFormats() { - return new String[] { Formats.XML, Formats.JSON}; + return new String[] { Formats.XML, Formats.JSON, Formats.HTML}; } - - @Setter - private ContentHostingService contentHostingService; - - @Setter - private SiteService siteService; - - @Setter - private ToolManager toolManager; - - @Setter - private SecurityService securityService; - - @Setter - private UserDirectoryService userDirectoryService; - - @Setter - private EntityManager entityManager; - - - /** * Simplified helper class to represent an individual content item @@ -807,6 +805,4 @@ private String getDisplayName(String uuid) { return null; } } - - } diff --git a/kernel/api/src/main/java/org/sakaiproject/content/api/ContentHostingService.java b/kernel/api/src/main/java/org/sakaiproject/content/api/ContentHostingService.java index 18cf565522b0..db5b53f85ba1 100644 --- a/kernel/api/src/main/java/org/sakaiproject/content/api/ContentHostingService.java +++ b/kernel/api/src/main/java/org/sakaiproject/content/api/ContentHostingService.java @@ -27,6 +27,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.Stack; import java.util.TreeSet; @@ -245,6 +246,10 @@ public interface ContentHostingService extends EntityProducer static final String ID_LENGTH_EXCEPTION = "id_length_exception"; + public static final String DOCX_MIMETYPE + = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + public static final String ODT_MIMETYPE = "application/vnd.oasis.opendocument.text"; + /** * For a given id, return its UUID (creating it if it does not already exist) */ @@ -2081,4 +2086,5 @@ public void restoreResource(String id) throws PermissionException, IdUsedExcepti public String expandMacros(String url); + public Optional getHtmlForRef(String ref); } diff --git a/kernel/kernel-impl/pom.xml b/kernel/kernel-impl/pom.xml index 070d1226e266..00d4c4df2848 100644 --- a/kernel/kernel-impl/pom.xml +++ b/kernel/kernel-impl/pom.xml @@ -104,6 +104,32 @@ poi-ooxml ${sakai.poi.version} + + fr.opensagres.xdocreport + fr.opensagres.xdocreport.document + 2.0.2 + + + fr.opensagres.xdocreport + org.apache.poi.xwpf.converter.xhtml + 1.0.6 + + + org.odftoolkit + odftoolkit + 1.0.0-BETA1 + pom + + + fr.opensagres.xdocreport + fr.opensagres.xdocreport.converter.odt.odfdom + 2.0.2 + + + org.zwobble.mammoth + mammoth + 1.4.1 + org.sakaiproject.kernel sakai-kernel-private diff --git a/kernel/kernel-impl/src/main/java/org/sakaiproject/content/impl/BaseContentService.java b/kernel/kernel-impl/src/main/java/org/sakaiproject/content/impl/BaseContentService.java index ebb12f86a7fb..ae6812778279 100644 --- a/kernel/kernel-impl/src/main/java/org/sakaiproject/content/impl/BaseContentService.java +++ b/kernel/kernel-impl/src/main/java/org/sakaiproject/content/impl/BaseContentService.java @@ -23,11 +23,13 @@ import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; +import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.SocketException; import java.net.URI; @@ -46,6 +48,7 @@ import java.util.Locale; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Random; import java.util.Set; import java.util.SortedSet; @@ -71,14 +74,21 @@ import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.ArrayUtils; -import org.apache.tika.io.TikaInputStream; -import org.apache.tika.metadata.Metadata; +import fr.opensagres.odfdom.converter.xhtml.XHTMLConverter; + import org.apache.tika.detect.DefaultDetector; import org.apache.tika.detect.Detector; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; import org.apache.tika.mime.MimeTypes; - import org.apache.tika.parser.txt.CharsetDetector; import org.apache.tika.parser.txt.CharsetMatch; + +import org.odftoolkit.odfdom.doc.OdfTextDocument; + +import org.zwobble.mammoth.DocumentConverter; +import org.zwobble.mammoth.Result; + import org.sakaiproject.authz.api.AuthzRealmLockException; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -14350,7 +14360,44 @@ public String expandMacros(String url) { return url; } - + + public Optional getHtmlForRef(String ref) { + + try { + ContentResource cr = getResource(ref); + + byte[] content = cr.getContent(); + String contentType = cr.getContentType(); + + switch (cr.getContentType()) { + case DOCX_MIMETYPE: + try (InputStream in = cr.streamContent()) { + Result result = new DocumentConverter().convertToHtml(in); + String html = result.getValue(); + if (log.isDebugEnabled()) { + result.getWarnings().forEach(w -> log.debug("Warning while converting {} to html: {}", ref, w)); + } + return Optional.of(html); + } + case ODT_MIMETYPE: + try (InputStream in = cr.streamContent()) { + OdfTextDocument document = OdfTextDocument.loadDocument(in); + StringWriter sw = new StringWriter(); + XHTMLConverter.getInstance().convert( document, sw, null ); + return Optional.of(sw.toString()); + } catch ( Throwable e ) { + e.printStackTrace(); + } + + return Optional.of(""); + default: + } + } catch (Exception e) { + log.error("Failed to get html for ref {}", ref, e); + } + return Optional.empty(); + } + /** * Helper to get the value for a given macro. * @param macroName diff --git a/library/pom.xml b/library/pom.xml index 9a9c6bdd70dc..08b3e96d4474 100644 --- a/library/pom.xml +++ b/library/pom.xml @@ -278,6 +278,16 @@ multiselect-two-sides 2.5.5 + + org.webjars + pdf-js + 2.3.200 + + + org.webjars + viewerjs + 0.5.8 + diff --git a/library/src/morpheus-master/sass/modules/grader/_base.scss b/library/src/morpheus-master/sass/modules/grader/_base.scss index bedc1a607c83..2b60e85d8632 100644 --- a/library/src/morpheus-master/sass/modules/grader/_base.scss +++ b/library/src/morpheus-master/sass/modules/grader/_base.scss @@ -82,6 +82,39 @@ sakai-grader { .inline-feedback-button { margin-top: 8px; } + .preview { + padding: 5px; + margin-top: 10px; + border: solid black 1px; + + sakai-document-viewer { + .document-link { margin-bottom: 8px; font-weight: bold; } + + .preview-outer { + background: gray; + text-align: center; + padding: 30px; + .preview-middle { + background: black; + background: white; + } + .preview-inner { + width: 75%; + background: white; + margin-top: 20px; + margin-bottom: 20px; + display: inline-block; + padding: 20 10 20 10; + text-align: left; + } + .nomargins { + width: 100%; + margin-top: 0px; + margin-bottom: 0px; + } + } + } + } } #grader-rubric-link { diff --git a/webcomponents/bundle/src/main/bundle/document-viewer.properties b/webcomponents/bundle/src/main/bundle/document-viewer.properties new file mode 100644 index 000000000000..12ca7a19cf4d --- /dev/null +++ b/webcomponents/bundle/src/main/bundle/document-viewer.properties @@ -0,0 +1,2 @@ +failed_to_load_document=Failed to load document. Please use the file link above. +viewing=Viewing diff --git a/webcomponents/bundle/src/main/bundle/grader.properties b/webcomponents/bundle/src/main/bundle/grader.properties index a7274d7f9dbf..f6d485542a3b 100644 --- a/webcomponents/bundle/src/main/bundle/grader.properties +++ b/webcomponents/bundle/src/main/bundle/grader.properties @@ -1,4 +1,3 @@ -previewing=Previewing attempt=Attempt allow_resubmission=Allow Resubmission number_resubmissions_allowed=Number of resubmissions allowed @@ -64,3 +63,4 @@ checkgrade_label=Checkmark grade input box comment_present=There is a comment on this submission notes_present=There are private notes on this submission profile_image='s profile image +inline_feedback_instruction=This is the submitted text, with your feedback. To add more feedback, click 'Add Feedback' at the bottom of the submission, then click 'Done' when you're finished. Your changes won't be saved until you click one of the save buttons in the grader. diff --git a/webcomponents/tool/src/main/frontend/grader/sakai-grader.js b/webcomponents/tool/src/main/frontend/grader/sakai-grader.js index ee671e839280..53255f868fd5 100644 --- a/webcomponents/tool/src/main/frontend/grader/sakai-grader.js +++ b/webcomponents/tool/src/main/frontend/grader/sakai-grader.js @@ -5,6 +5,7 @@ import "/webcomponents/fa-icon.js"; import "./sakai-grader-file-picker.js"; import "../sakai-date-picker.js"; import "../sakai-group-picker.js"; +import "../sakai-document-viewer.js"; import {gradableDataMixin} from "./sakai-gradable-data-mixin.js"; import {Submission} from "./submission.js"; import "/rubrics-service/webcomponents/rubric-association-requirements.js"; @@ -147,15 +148,14 @@ class SakaiGrader extends gradableDataMixin(SakaiElement) {
${this.submission.submittedTime ? html` ${this.submittedTextMode ? html` -
This is the submitted text, with your feedback. To add more feedback, click 'Add Feedback' at - the bottom of the submission, then click 'Done' when you're finished. Your changes won't be saved until you click one of the save buttons in the grader.
+
${unsafeHTML(this.i18n["inline_feedback_instruction"])}
${unsafeHTML(this.submission.feedbackText)}
` : html` ${this.selectedAttachmentRef ? html` - +
` : ""} `} ` : ""} @@ -191,8 +191,8 @@ class SakaiGrader extends gradableDataMixin(SakaiElement) {
${this.submission.submittedAttachments.length > 0 ? html`
${this.i18n["submitted_attachments"]}:
- ${Object.keys(this.submission.submittedAttachments).map(k => html` - ${parseInt(k) + 1} + ${this.submission.submittedAttachments.map(r => html` + ${this.fileNameFromRef(r)} `)}` : ""}
diff --git a/webcomponents/tool/src/main/frontend/sakai-document-viewer.js b/webcomponents/tool/src/main/frontend/sakai-document-viewer.js new file mode 100644 index 000000000000..2a247e872007 --- /dev/null +++ b/webcomponents/tool/src/main/frontend/sakai-document-viewer.js @@ -0,0 +1,115 @@ +import {SakaiElement} from "/webcomponents/sakai-element.js"; +import {html} from "/webcomponents/assets/lit-element/lit-element.js"; +import {unsafeHTML} from "/webcomponents/assets/lit-html/directives/unsafe-html.js"; + +/** + * Loads a document from Sakai content hosting from the supplied ref attribute. Ref is a Sakai entity reference. + * + * Formats currently supported: + * DOCX + * ODP + * ODT + * PDF + * + * PDFs are opened with PDF.js, the same plugin used natively by Chrome and Firefox. ODP (slides) are displayed using + * ViewerJS. DOCX and ODT are converted to html on the server and retrieved vi a Fetch call. ViewerJS and PDF.js loads + * happen in an iframe. You can specify the height of that with the height attribute. Light dom is in use, so you can + * style this from the usual Sakai SASS build. + * + * Usage: + * + * + * '@author Adrian Fish + */ +class SakaiDocumentViewer extends SakaiElement { + + constructor() { + + super(); + + this.documentMarkup = ""; + this.height = "600px"; + + this.loadTranslations("document-viewer").then(t => { + + this.i18n = t; + this.documentFailureMessage = `
${this.i18n["failed_to_load_document"]}
`; + }); + } + + static get properties() { + + return { + ref: String, + height: String, + //INTERNAL + documentMarkup: String, + i18n: Object, + nomargins: Boolean, + }; + } + + set ref(newValue) { + + this._ref = newValue; + this.loadDocumentMarkup(newValue); + } + + get ref() { return this._ref; } + + render() { + + return html` + +
+
+
+ ${unsafeHTML(this.documentMarkup)} +
+
+
+ `; + } + + fileNameFromRef(ref) { return ref.substring(ref.lastIndexOf("\/") + 1); } + + loadDocumentMarkup(documentRef) { + + this.nomargins = false; + + if (documentRef.endsWith("\.pdf") || documentRef.endsWith("\.PDF")) { + this.nomargins = true; + // Let PDFJS handle this. We can just literally use the viewer, like Firefox and Chrome do. + this.documentMarkup = `