diff --git a/CHANGES.md b/CHANGES.md
index 70e95ef..d60fa05 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,4 +1,37 @@
## Changelog ##
+
+- [0.7]:
+ - Fixed some minor bugs, removed some more unnecessary code and added recent activity overview and plugin description.
+
+- [0.6]:
+ - Fix for a bug preventing the change of the priority of annotation types.
+ - Ensured compatibility with Moodle 4.3 and PHP 8.2.
+ - Changed code to comply with new moodle coding standards.
+ - Deleting unnecessary elements like tasks and grading.
+
+- [0.5]:
+ - Added backup functionality.
+ - Added privacy functionality.
+ - Added course reset functionality.
+
+- [0.4]:
+ - On the annotations summary you can now view the total amount of annotations for each user and each annotation type.
+ - On the overview you can now view the annotations filtered by users.
+ - When creating a module instance you can now add annotation types.
+ - Submissions can now only be edited if they are not already annotated.
+
+- [0.3]:
+ - Teachers can now add, edit and delete annotation type templates and annotation types for the concrete module instance.
+ - Added a first version of the annotations summary.
+ - Renamed the table annopy_annotationtype_templates to annopy_atype_templates.
+
+- [0.2]:
+ - Users can now add, edit and delete annotations in submissions.
+
+- [0.1]:
+ - Teachers can now add submissions that participants can later annotate.
+ - The submission of an AnnoPy can now be viewed on the overview page.
+
- [0.0.1]:
- Added plugin template files.
- Added first capabilities, events and database structure.
diff --git a/README.md b/README.md
index d0c7105..2655b73 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
# License #
-2023 coactum GmbH
-
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
@@ -14,13 +12,32 @@ PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see .
+@copyright 2023 coactum GmbH
+
# AnnoPy #
## Description ##
-TODO Describe the plugin shortly here.
+In the AnnoPy activity, teachers can upload a wide variety of multimedia texts which participants can then annotate.
+
+As a collaborative tool, AnnoPy can be used in many different ways in school or university learning contexts, for example to improve participants literary skills or to evaluate their understanding of a text.
+
+For example, AnnoPy can be used in language didactics to promote comparative reading, to practise identifying linguistic patterns in texts or to open up a new perspective on explanatory texts. AnnoPy can also be used to analyze texts on a content level, for example with regard to semantic, grammatical, lexical or text-literary issues. In subjects such as mathematics or computer science, on the other hand, teachers can use AnnoPy to have their own lecture notes worked through and then see at a glance where there are still difficulties in understanding.
+
+Teachers can first upload any multimedia text for annotation; depending on the didactic context, this can also contain images, formulas or programming code, for example.
+All participants can then annotate this text by marking the desired text passages and then selecting a type for each annotation and leaving a short comment if necessary.
+Just like reusable templates, the available annotation types can be flexibly adapted by the teacher depending on the context.
+Finally, teachers can view and analyze all of the participants annotations in detail in a clearly visualized evaluation.
+
+Core features of the plugin:
+
+* Upload of various types of multimedia texts by teachers
+* Separate annotation of these texts including comments by each individual participant
+* Cumulative display of all annotations on the overview page, sorted by participant
+* Annotation types and templates can be individually customized by teachers
+* A clear and detailed evaluation of all annotations
-TODO Provide more detailed description here.
+Further information on the concept behind AnnoPy and its possible use in teaching and learning can be found in German on the current project website (https://annopy.de/).
## Quick installation instructions ##
diff --git a/amd/build/annotations.min.js b/amd/build/annotations.min.js
new file mode 100644
index 0000000..edd5e19
--- /dev/null
+++ b/amd/build/annotations.min.js
@@ -0,0 +1,10 @@
+define("mod_annopy/annotations",["exports","jquery","./highlighting"],(function(_exports,_jquery,_highlighting){var obj;
+/**
+ * Module for the annotation functions of the module.
+ *
+ * @module mod_annopy/annotations
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=(obj=_jquery)&&obj.__esModule?obj:{default:obj};_exports.init=(cmid,canaddannotation,myuserid,focusannotation,userid)=>{var edited=!1,annotations=Array(),newannotation=!1;function editAnnotation(annotationid){if(edited==annotationid)(0,_highlighting.removeAllTempHighlights)(),resetForms(),edited=!1;else if(canaddannotation&&myuserid==annotations[annotationid].userid){(0,_highlighting.removeAllTempHighlights)(),resetForms(),edited=annotationid;var submission=annotations[annotationid].submission;(0,_jquery.default)(".annotation-box-"+annotationid).hide(),(0,_jquery.default)(".annotation-form-"+submission+' input[name="startcontainer"]').val(annotations[annotationid].startcontainer),(0,_jquery.default)(".annotation-form-"+submission+' input[name="endcontainer"]').val(annotations[annotationid].endcontainer),(0,_jquery.default)(".annotation-form-"+submission+' input[name="startoffset"]').val(annotations[annotationid].startoffset),(0,_jquery.default)(".annotation-form-"+submission+' input[name="endoffset"]').val(annotations[annotationid].endoffset),(0,_jquery.default)(".annotation-form-"+submission+' input[name="annotationstart"]').val(annotations[annotationid].annotationstart),(0,_jquery.default)(".annotation-form-"+submission+' input[name="annotationend"]').val(annotations[annotationid].annotationend),(0,_jquery.default)(".annotation-form-"+submission+' input[name="exact"]').val(annotations[annotationid].exact),(0,_jquery.default)(".annotation-form-"+submission+' input[name="prefix"]').val(annotations[annotationid].prefix),(0,_jquery.default)(".annotation-form-"+submission+' input[name="suffix"]').val(annotations[annotationid].suffix),(0,_jquery.default)(".annotation-form-"+submission+' input[name="annotationid"]').val(annotationid),(0,_jquery.default)(".annotation-form-"+submission+' textarea[name="text"]').val(annotations[annotationid].text),(0,_jquery.default)(".annotation-form-"+submission+" select").val(annotations[annotationid].type),(0,_jquery.default)("#annotationpreview-temp-"+submission).html(annotations[annotationid].exact.replaceAll("<","<").replaceAll(">",">")),(0,_jquery.default)("#annotationpreview-temp-"+submission).css("border-color","#"+annotations[annotationid].color),(0,_jquery.default)(".annotationarea-"+submission+" .annotation-form").insertBefore(".annotation-box-"+annotationid),(0,_jquery.default)(".annotationarea-"+submission+" .annotation-form").show(),(0,_jquery.default)(".annotationarea-"+submission+" #id_text").focus()}else(0,_jquery.default)(".annotation-box-"+annotationid).focus()}function resetForms(){(0,_jquery.default)(".annotation-form").hide(),(0,_jquery.default)('.annotation-form input[name^="annotationid"]').val(null),(0,_jquery.default)('.annotation-form input[name^="startcontainer"]').val(-1),(0,_jquery.default)('.annotation-form input[name^="endcontainer"]').val(-1),(0,_jquery.default)('.annotation-form input[name^="startoffset"]').val(-1),(0,_jquery.default)('.annotation-form input[name^="endoffset"]').val(-1),(0,_jquery.default)('.annotation-form textarea[name^="text"]').val(""),(0,_jquery.default)(".annotation-box").not(".annotation-form").show()}(0,_jquery.default)(".annotation-form div.col-md-3").removeClass("col-md-3"),(0,_jquery.default)(".annotation-form div.col-md-9").removeClass("col-md-9"),(0,_jquery.default)(".annotation-form div.form-group").removeClass("form-group"),(0,_jquery.default)(".annotation-form div.row").removeClass("row"),(0,_jquery.default)(document).on("click",".annopy_submission #id_cancel",(function(e){e.preventDefault(),(0,_highlighting.removeAllTempHighlights)(),resetForms(),edited=!1})),(0,_jquery.default)(".annopy_submission textarea").keypress((function(e){13==e.which&&((0,_jquery.default)(this).parents(":eq(2)").submit(),e.preventDefault())})),(0,_jquery.default)(document).on("mouseup",".originaltext",(function(){if(""!==window.getSelection().getRangeAt(0).cloneContents().textContent&&canaddannotation){(0,_highlighting.removeAllTempHighlights)(),resetForms(),newannotation=function(root){const ranges=[window.getSelection().getRangeAt(0)];if(ranges.collapsed)return null;const rangeSelectors=ranges.map((range=>(0,_highlighting.describe)(root,range))),annotation={target:rangeSelectors.map((selectors=>({selector:selectors})))};return(0,_highlighting.anchor)(annotation,root),annotation}(this);var submission=this.id.replace(/submission-/,"");(0,_jquery.default)(".annotation-form-"+submission+' input[name="startcontainer"]').val(newannotation.target[0].selector[0].startContainer),(0,_jquery.default)(".annotation-form-"+submission+' input[name="endcontainer"]').val(newannotation.target[0].selector[0].endContainer),(0,_jquery.default)(".annotation-form-"+submission+' input[name="startoffset"]').val(newannotation.target[0].selector[0].startOffset),(0,_jquery.default)(".annotation-form-"+submission+' input[name="endoffset"]').val(newannotation.target[0].selector[0].endOffset),(0,_jquery.default)(".annotation-form-"+submission+' input[name="annotationstart"]').val(newannotation.target[0].selector[1].start),(0,_jquery.default)(".annotation-form-"+submission+' input[name="annotationend"]').val(newannotation.target[0].selector[1].end),(0,_jquery.default)(".annotation-form-"+submission+' input[name="exact"]').val(newannotation.target[0].selector[2].exact),(0,_jquery.default)(".annotation-form-"+submission+' input[name="prefix"]').val(newannotation.target[0].selector[2].prefix),(0,_jquery.default)(".annotation-form-"+submission+' input[name="suffix"]').val(newannotation.target[0].selector[2].suffix),(0,_jquery.default)(".annotation-form-"+submission+" select").val(1),(0,_jquery.default)("#annotationpreview-temp-"+submission).html(newannotation.target[0].selector[2].exact.replaceAll("<","<").replaceAll(">",">")),(0,_jquery.default)(".annotationarea-"+submission+" .annotation-form").show(),(0,_jquery.default)(".annotation-form-"+submission+" #id_text").focus()}})),_jquery.default.ajax({url:"./annotations.php",data:{id:cmid,getannotations:1,userid:userid},success:function(response){annotations=JSON.parse(response),function(){for(let annotation of Object.values(annotations)){const newannotation={annotation:annotation,target:[[{type:"RangeSelector",startContainer:annotation.startcontainer,startOffset:parseInt(annotation.startoffset),endContainer:annotation.endcontainer,endOffset:parseInt(annotation.endoffset)},{type:"TextPositionSelector",start:parseInt(annotation.annotationstart),end:parseInt(annotation.annotationend)},{type:"TextQuoteSelector",exact:annotation.exact,prefix:annotation.prefix,suffix:annotation.suffix}]].map((selectors=>({selector:selectors})))};(0,_highlighting.anchor)(newannotation,(0,_jquery.default)("#submission-"+annotation.submission)[0])}}(),(0,_jquery.default)(".annotated").mouseenter((function(){var id=this.id.replace("annotated-","");(0,_jquery.default)(".annotation-box-"+id).addClass("hovered"),(0,_jquery.default)(".annotated-"+id).css("background-color","lightblue")})),(0,_jquery.default)(".annotated").mouseleave((function(){var id=this.id.replace("annotated-","");(0,_jquery.default)(".annotation-box-"+id).removeClass("hovered"),(0,_jquery.default)(".annotated-"+id).css("background-color",(0,_jquery.default)(".annotated-"+id).css("textDecorationColor"))})),(0,_jquery.default)(document).on("mouseover",".annotated_temp",(function(){(0,_jquery.default)(".annotated_temp").addClass("hovered")})),(0,_jquery.default)(document).on("mouseleave",".annotated_temp",(function(){(0,_jquery.default)(".annotated_temp").removeClass("hovered")})),(0,_jquery.default)(document).on("click",".annotated",(function(){editAnnotation(this.id.replace("annotated-",""))})),(0,_jquery.default)(document).on("click",".edit-annotation",(function(){editAnnotation(this.id.replace("edit-annotation-",""))})),(0,_jquery.default)(document).on("mouseover",".hoverannotation",(function(){var id=this.id.replace("hoverannotation-","");(0,_jquery.default)(".annotated-"+id).css("background-color","lightblue")})),(0,_jquery.default)(document).on("mouseleave",".hoverannotation",(function(){var id=this.id.replace("hoverannotation-","");(0,_jquery.default)(".annotated-"+id).css("background-color",(0,_jquery.default)(".annotated-"+id).css("textDecorationColor"))})),0!=focusannotation&&((0,_jquery.default)(".annotated-"+focusannotation).attr("tabindex",-1),(0,_jquery.default)(".annotated-"+focusannotation).focus())},complete:function(){(0,_jquery.default)("#overlay").hide()},error:function(){}})}}));
+
+//# sourceMappingURL=annotations.min.js.map
\ No newline at end of file
diff --git a/amd/build/annotations.min.js.map b/amd/build/annotations.min.js.map
new file mode 100644
index 0000000..aa8405f
--- /dev/null
+++ b/amd/build/annotations.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"annotations.min.js","sources":["../src/annotations.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module for the annotation functions of the module.\n *\n * @module mod_annopy/annotations\n * @copyright 2023 coactum GmbH\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport {removeAllTempHighlights, anchor, describe} from './highlighting';\n\nexport const init = (cmid, canaddannotation, myuserid, focusannotation, userid) => {\n\n var edited = false;\n var annotations = Array();\n\n var newannotation = false;\n\n // Remove col-mds from moodle form.\n $('.annotation-form div.col-md-3').removeClass('col-md-3');\n $('.annotation-form div.col-md-9').removeClass('col-md-9');\n $('.annotation-form div.form-group').removeClass('form-group');\n $('.annotation-form div.row').removeClass('row');\n\n // Onclick listener if form is canceled.\n $(document).on('click', '.annopy_submission #id_cancel', function(e) {\n e.preventDefault();\n\n removeAllTempHighlights(); // Remove other temporary highlights.\n\n resetForms(); // Remove old form contents.\n\n edited = false;\n });\n\n // Listen for return key pressed to submit annotation form.\n $('.annopy_submission textarea').keypress(function(e) {\n if (e.which == 13) {\n $(this).parents(':eq(2)').submit();\n e.preventDefault();\n }\n });\n\n // If user selects text for new annotation\n $(document).on('mouseup', '.originaltext', function() {\n\n var selectedrange = window.getSelection().getRangeAt(0);\n\n if (selectedrange.cloneContents().textContent !== '' && canaddannotation) {\n\n removeAllTempHighlights(); // Remove other temporary highlights.\n\n resetForms(); // Reset the annotation forms.\n\n // Create new annotation.\n newannotation = createAnnotation(this);\n\n var submission = this.id.replace(/submission-/, '');\n\n // RangeSelector.\n $('.annotation-form-' + submission + ' input[name=\"startcontainer\"]').val(\n newannotation.target[0].selector[0].startContainer);\n $('.annotation-form-' + submission + ' input[name=\"endcontainer\"]').val(\n newannotation.target[0].selector[0].endContainer);\n $('.annotation-form-' + submission + ' input[name=\"startoffset\"]').val(\n newannotation.target[0].selector[0].startOffset);\n $('.annotation-form-' + submission + ' input[name=\"endoffset\"]').val(\n newannotation.target[0].selector[0].endOffset);\n\n // TextPositionSelector.\n $('.annotation-form-' + submission + ' input[name=\"annotationstart\"]').val(\n newannotation.target[0].selector[1].start);\n $('.annotation-form-' + submission + ' input[name=\"annotationend\"]').val(\n newannotation.target[0].selector[1].end);\n\n // TextQuoteSelector.\n $('.annotation-form-' + submission + ' input[name=\"exact\"]').val(\n newannotation.target[0].selector[2].exact);\n $('.annotation-form-' + submission + ' input[name=\"prefix\"]').val(\n newannotation.target[0].selector[2].prefix);\n $('.annotation-form-' + submission + ' input[name=\"suffix\"]').val(\n newannotation.target[0].selector[2].suffix);\n\n $('.annotation-form-' + submission + ' select').val(1);\n\n // Prevent JavaScript injection (if annotated text in original submission is JavaScript code in script tags).\n $('#annotationpreview-temp-' + submission).html(\n newannotation.target[0].selector[2].exact.replaceAll('<', '<').replaceAll('>', '>'));\n\n $('.annotationarea-' + submission + ' .annotation-form').show();\n $('.annotation-form-' + submission + ' #id_text').focus();\n }\n });\n\n // Fetch and recreate annotations.\n $.ajax({\n url: './annotations.php',\n data: {'id': cmid, 'getannotations': 1, 'userid': userid},\n success: function(response) {\n annotations = JSON.parse(response);\n\n recreateAnnotations();\n\n // Highlight annotation and all annotated text if annotated text is hovered\n $('.annotated').mouseenter(function() {\n var id = this.id.replace('annotated-', '');\n $('.annotation-box-' + id).addClass('hovered');\n $('.annotated-' + id).css(\"background-color\", 'lightblue');\n });\n\n $('.annotated').mouseleave(function() {\n var id = this.id.replace('annotated-', '');\n $('.annotation-box-' + id).removeClass('hovered');\n $('.annotated-' + id).css(\"background-color\", $('.annotated-' + id).css('textDecorationColor'));\n });\n\n // Highlight whole temp annotation if part of temp annotation is hovered\n $(document).on('mouseover', '.annotated_temp', function() {\n $('.annotated_temp').addClass('hovered');\n });\n\n $(document).on('mouseleave', '.annotated_temp', function() {\n $('.annotated_temp').removeClass('hovered');\n });\n\n // Onclick listener for editing annotation.\n $(document).on('click', '.annotated', function() {\n var id = this.id.replace('annotated-', '');\n editAnnotation(id);\n });\n\n // Onclick listener for editing annotation.\n $(document).on('click', '.edit-annotation', function() {\n var id = this.id.replace('edit-annotation-', '');\n editAnnotation(id);\n });\n\n // Highlight annotation if hoverannotation button is hovered\n $(document).on('mouseover', '.hoverannotation', function() {\n var id = this.id.replace('hoverannotation-', '');\n $('.annotated-' + id).css(\"background-color\", 'lightblue');\n });\n\n $(document).on('mouseleave', '.hoverannotation', function() {\n var id = this.id.replace('hoverannotation-', '');\n $('.annotated-' + id).css(\"background-color\", $('.annotated-' + id).css('textDecorationColor'));\n });\n\n\n // Focus annotation if needed.\n if (focusannotation != 0) {\n $('.annotated-' + focusannotation).attr('tabindex', -1);\n $('.annotated-' + focusannotation).focus();\n }\n\n },\n complete: function() {\n $('#overlay').hide();\n },\n error: function() {\n // For output: alert('Error fetching annotations');\n }\n });\n\n /**\n * Recreate annotations.\n *\n */\n function recreateAnnotations() {\n\n for (let annotation of Object.values(annotations)) {\n\n const rangeSelectors = [[\n {type: \"RangeSelector\", startContainer: annotation.startcontainer, startOffset: parseInt(annotation.startoffset),\n endContainer: annotation.endcontainer, endOffset: parseInt(annotation.endoffset)},\n {type: \"TextPositionSelector\", start: parseInt(annotation.annotationstart),\n end: parseInt(annotation.annotationend)},\n {type: \"TextQuoteSelector\", exact: annotation.exact, prefix: annotation.prefix, suffix: annotation.suffix}\n ]];\n\n const target = rangeSelectors.map(selectors => ({\n selector: selectors,\n }));\n\n /** @type {AnnotationData} */\n const newannotation = {\n annotation: annotation,\n target: target,\n };\n\n anchor(newannotation, $(\"#submission-\" + annotation.submission)[0]);\n }\n }\n\n /**\n * Edit annotation.\n *\n * @param {int} annotationid\n */\n function editAnnotation(annotationid) {\n\n if (edited == annotationid) {\n removeAllTempHighlights(); // Remove other temporary highlights.\n resetForms(); // Remove old form contents.\n edited = false;\n } else if (canaddannotation && myuserid == annotations[annotationid].userid) {\n removeAllTempHighlights(); // Remove other temporary highlights.\n resetForms(); // Remove old form contents.\n\n edited = annotationid;\n\n var submission = annotations[annotationid].submission;\n\n $('.annotation-box-' + annotationid).hide(); // Hide edited annotation-box.\n\n $('.annotation-form-' + submission + ' input[name=\"startcontainer\"]').val(annotations[annotationid].startcontainer);\n $('.annotation-form-' + submission + ' input[name=\"endcontainer\"]').val(annotations[annotationid].endcontainer);\n $('.annotation-form-' + submission + ' input[name=\"startoffset\"]').val(annotations[annotationid].startoffset);\n $('.annotation-form-' + submission + ' input[name=\"endoffset\"]').val(annotations[annotationid].endoffset);\n $('.annotation-form-' + submission + ' input[name=\"annotationstart\"]').val(annotations[annotationid].annotationstart);\n $('.annotation-form-' + submission + ' input[name=\"annotationend\"]').val(annotations[annotationid].annotationend);\n $('.annotation-form-' + submission + ' input[name=\"exact\"]').val(annotations[annotationid].exact);\n $('.annotation-form-' + submission + ' input[name=\"prefix\"]').val(annotations[annotationid].prefix);\n $('.annotation-form-' + submission + ' input[name=\"suffix\"]').val(annotations[annotationid].suffix);\n\n $('.annotation-form-' + submission + ' input[name=\"annotationid\"]').val(annotationid);\n\n $('.annotation-form-' + submission + ' textarea[name=\"text\"]').val(annotations[annotationid].text);\n\n $('.annotation-form-' + submission + ' select').val(annotations[annotationid].type);\n\n // Prevent JavaScript injection (if annotated text in original submission is JavaScript code in script tags).\n $('#annotationpreview-temp-' + submission).html(\n annotations[annotationid].exact.replaceAll('<', '<').replaceAll('>', '>'));\n $('#annotationpreview-temp-' + submission).css('border-color', '#' + annotations[annotationid].color);\n\n $('.annotationarea-' + submission + ' .annotation-form').insertBefore('.annotation-box-' + annotationid);\n $('.annotationarea-' + submission + ' .annotation-form').show();\n $('.annotationarea-' + submission + ' #id_text').focus();\n } else {\n $('.annotation-box-' + annotationid).focus();\n }\n }\n\n /**\n * Reset all annotation forms\n */\n function resetForms() {\n $('.annotation-form').hide();\n\n $('.annotation-form input[name^=\"annotationid\"]').val(null);\n\n $('.annotation-form input[name^=\"startcontainer\"]').val(-1);\n $('.annotation-form input[name^=\"endcontainer\"]').val(-1);\n $('.annotation-form input[name^=\"startoffset\"]').val(-1);\n $('.annotation-form input[name^=\"endoffset\"]').val(-1);\n\n $('.annotation-form textarea[name^=\"text\"]').val('');\n\n $('.annotation-box').not('.annotation-form').show(); // To show again edited annotation.\n }\n};\n\n/**\n * Create a new annotation that is associated with the selected region of\n * the current document.\n *\n * @param {object} root - The root element\n * @return {object} - The new annotation\n */\nfunction createAnnotation(root) {\n\n const ranges = [window.getSelection().getRangeAt(0)];\n\n if (ranges.collapsed) {\n return null;\n }\n\n const rangeSelectors = ranges.map(range => describe(root, range));\n\n const target = rangeSelectors.map(selectors => ({\n selector: selectors,\n }));\n\n /** @type {AnnotationData} */\n const annotation = {\n target,\n };\n\n anchor(annotation, root);\n\n return annotation;\n}"],"names":["obj","_jquery","__esModule","default","_exports","init","cmid","canaddannotation","myuserid","focusannotation","userid","edited","annotations","Array","newannotation","editAnnotation","annotationid","removeAllTempHighlights","resetForms","submission","$","hide","val","startcontainer","endcontainer","startoffset","endoffset","annotationstart","annotationend","exact","prefix","suffix","text","type","html","replaceAll","css","color","insertBefore","show","focus","not","removeClass","document","on","e","preventDefault","keypress","which","this","parents","submit","window","getSelection","getRangeAt","cloneContents","textContent","root","ranges","collapsed","rangeSelectors","map","range","describe","annotation","target","selectors","selector","anchor","createAnnotation","id","replace","startContainer","endContainer","startOffset","endOffset","start","end","ajax","url","data","getannotations","success","response","JSON","parse","Object","values","parseInt","recreateAnnotations","mouseenter","addClass","mouseleave","attr","complete","error"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/colorpicker-layout.min.js b/amd/build/colorpicker-layout.min.js
new file mode 100644
index 0000000..52c8f36
--- /dev/null
+++ b/amd/build/colorpicker-layout.min.js
@@ -0,0 +1,10 @@
+define("mod_annopy/colorpicker-layout",["exports","jquery"],(function(_exports,_jquery){var obj;
+/**
+ * Module for layouting custom color picker element as default form element.
+ *
+ * @module mod_discourse/colorpicker-layout
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=(obj=_jquery)&&obj.__esModule?obj:{default:obj};_exports.init=colorpickerid=>{console.log("TEST!"),console.log(colorpickerid),console.log((0,_jquery.default)(".path-mod-annopy .mform fieldset #fitem"+colorpickerid)),(0,_jquery.default)(".path-mod-annopy .mform fieldset #fitem_"+colorpickerid).addClass("row"),(0,_jquery.default)(".path-mod-annopy .mform fieldset #fitem_"+colorpickerid).addClass("form-group"),(0,_jquery.default)(".path-mod-annopy .mform fieldset .fitemtitle").addClass("col-md-3"),(0,_jquery.default)(".path-mod-annopy .mform fieldset .fitemtitle").addClass("col-form-label"),(0,_jquery.default)(".path-mod-annopy .mform fieldset .fitemtitle").addClass("d-flex"),(0,_jquery.default)(".path-mod-annopy .mform fieldset .ftext").addClass("col-md-9")}}));
+
+//# sourceMappingURL=colorpicker-layout.min.js.map
\ No newline at end of file
diff --git a/amd/build/colorpicker-layout.min.js.map b/amd/build/colorpicker-layout.min.js.map
new file mode 100644
index 0000000..e7b2929
--- /dev/null
+++ b/amd/build/colorpicker-layout.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"colorpicker-layout.min.js","sources":["../src/colorpicker-layout.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module for layouting custom color picker element as default form element.\n *\n * @module mod_discourse/colorpicker-layout\n * @copyright 2023 coactum GmbH\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\n\nexport const init = (colorpickerid) => {\n console.log('TEST!');\n console.log(colorpickerid);\n console.log($('.path-mod-annopy .mform fieldset #fitem' + colorpickerid));\n\n $('.path-mod-annopy .mform fieldset #fitem_' + colorpickerid).addClass('row');\n $('.path-mod-annopy .mform fieldset #fitem_' + colorpickerid).addClass('form-group');\n\n $('.path-mod-annopy .mform fieldset .fitemtitle').addClass('col-md-3');\n $('.path-mod-annopy .mform fieldset .fitemtitle').addClass('col-form-label');\n $('.path-mod-annopy .mform fieldset .fitemtitle').addClass('d-flex');\n\n $('.path-mod-annopy .mform fieldset .ftext').addClass('col-md-9');\n\n};"],"names":["obj","_jquery","__esModule","default","_exports","init","colorpickerid","console","log","$","addClass"],"mappings":"wFAuBuB,IAAAA;;;;;;;kFAAvBC,SAAuBD,IAAvBC,UAAuBD,IAAAE,WAAAF,IAAAG,CAAAA,QAAAH,KAgBrBI,SAAAC,KAdmBC,gBACjBC,QAAQC,IAAI,SACZD,QAAQC,IAAIF,eACZC,QAAQC,KAAI,EAAAC,QAAAA,SAAE,0CAA4CH,iBAE1D,EAAAG,QAAAA,SAAE,2CAA6CH,eAAeI,SAAS,QACvE,EAAAD,QAAAA,SAAE,2CAA6CH,eAAeI,SAAS,eAEvE,EAAAD,QAAAA,SAAE,gDAAgDC,SAAS,aAC3D,EAAAD,QAAAA,SAAE,gDAAgDC,SAAS,mBAC3D,EAAAD,QAAAA,SAAE,gDAAgDC,SAAS,WAE3D,EAAAD,QAAAA,SAAE,2CAA2CC,SAAS,WAAW,CAEnE"}
\ No newline at end of file
diff --git a/amd/build/highlighting.min.js b/amd/build/highlighting.min.js
new file mode 100644
index 0000000..73e547f
--- /dev/null
+++ b/amd/build/highlighting.min.js
@@ -0,0 +1,3 @@
+define("mod_annopy/highlighting",["exports","jquery","./types","./text-range"],(function(_exports,_jquery,_types,_textRange){var obj;function highlightRange(range){let annotationid=arguments.length>1&&void 0!==arguments[1]&&arguments[1],cssClass=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"annotated",color=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"FFFF00";const textNodes=function(range){if(range.collapsed)return[];let root=range.commonAncestorContainer;root.nodeType!==Node.ELEMENT_NODE&&(root=root.parentElement);if(!root)return[];const textNodes=[],nodeIter=root.ownerDocument.createNodeIterator(root,NodeFilter.SHOW_TEXT);let node;for(;node=nodeIter.nextNode();){if(!isNodeInRange(range,node))continue;let text=node;text===range.startContainer&&range.startOffset>0?text.splitText(range.startOffset):(text===range.endContainer&&range.endOffset{prevNode&&prevNode.nextSibling===node?currentSpan.push(node):(currentSpan=[node],textNodeSpans.push(currentSpan)),prevNode=node}));const whitespace=/^\s*$/;textNodeSpans=textNodeSpans.filter((span=>span.some((node=>!whitespace.test(node.nodeValue)))));const highlights=[];return textNodeSpans.forEach((nodes=>{const highlightEl=document.createElement("annopy-highlight");highlightEl.className=cssClass,annotationid&&(highlightEl.className+=" "+cssClass+"-"+annotationid,highlightEl.style="text-decoration:underline; text-decoration-color: #"+color,highlightEl.id=cssClass+"-"+annotationid,highlightEl.style.backgroundColor="#"+color);nodes[0].parentNode.replaceChild(highlightEl,nodes[0]),nodes.forEach((node=>highlightEl.appendChild(node))),highlights.push(highlightEl)})),highlights}function isNodeInRange(range,node){try{var _node$nodeValue$lengt,_node$nodeValue;const length=null!==(_node$nodeValue$lengt=null===(_node$nodeValue=node.nodeValue)||void 0===_node$nodeValue?void 0:_node$nodeValue.length)&&void 0!==_node$nodeValue$lengt?_node$nodeValue$lengt:node.childNodes.length;return range.comparePoint(node,0)<=0&&range.comparePoint(node,length)>=0}catch(e){return!1}}function querySelector(anchor){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return anchor.toRange(options)}function replaceWith(node,replacements){const parent=node.parentNode;replacements.forEach((r=>parent.insertBefore(r,node))),node.remove()}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.anchor=function(annotation,root){const highlight=anchor=>{const range=function(anchor){if(!anchor.range)return null;try{return anchor.range.toRange()}catch{return null}}(anchor);if(!range)return;let highlights=[];highlights=annotation.annotation?highlightRange(range,annotation.annotation.id,"annotated",annotation.annotation.color):highlightRange(range,!1,"annotated_temp"),highlights.forEach((h=>{h._annotation=anchor.annotation})),anchor.highlights=highlights};annotation.target||(annotation.target=[]);const anchors=annotation.target.map((target=>{if(!target.selector||!target.selector.some((s=>"TextQuoteSelector"===s.type)))return{annotation:annotation,target:target};let anchor;try{const range=function(root,selectors){let options=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},position=null,quote=null,range=null;for(let selector of selectors)switch(selector.type){case"TextPositionSelector":position=selector,options.hint=position.start;break;case"TextQuoteSelector":quote=selector;break;case"RangeSelector":range=selector}const maybeAssertQuote=range=>{var _quote;if(null!==(_quote=quote)&&void 0!==_quote&&_quote.exact&&range.toString()!==quote.exact)throw new Error("quote mismatch");return range};let queryselector=!1;try{if(range)return queryselector=querySelector(_types.RangeAnchor.fromSelector(root,range),options),queryselector||maybeAssertQuote}catch(error){try{if(position)return queryselector=querySelector(_types.TextPositionAnchor.fromSelector(root,position),options),queryselector||maybeAssertQuote}catch(error){try{if(quote)return queryselector=querySelector(_types.TextQuoteAnchor.fromSelector(root,quote),options),queryselector}catch(error){return!1}}}return!1}(root,target.selector),textRange=_textRange.TextRange.fromRange(range);anchor={annotation:annotation,target:target,range:textRange}}catch(err){anchor={annotation:annotation,target:target}}return anchor}));for(let anchor of anchors)highlight(anchor);return annotation.$orphan=anchors.length>0&&anchors.every((anchor=>anchor.target.selector&&!anchor.range)),anchors},_exports.describe=function(root,range){const types=[_types.RangeAnchor,_types.TextPositionAnchor,_types.TextQuoteAnchor],result=[];for(let type of types)try{const anchor=type.fromRange(root,range);result.push(anchor.toSelector())}catch(error){continue}return result},_exports.removeAllTempHighlights=function(){const highlights=Array.from((0,_jquery.default)("body")[0].querySelectorAll(".annotated_temp"));void 0!==highlights&&0!=highlights.length&&function(highlights){for(var i=0;i {\n\n // Only annotations with an associated quote can currently be anchored.\n // This is because the quote is used to verify anchoring with other selector\n // types.\n if (\n !target.selector ||\n !target.selector.some(s => s.type === 'TextQuoteSelector')\n ) {\n return {annotation, target};\n }\n\n /** @type {Anchor} */\n let anchor;\n try {\n const range = htmlAnchor(root, target.selector);\n // Convert the `Range` to a `TextRange` which can be converted back to\n // a `Range` later. The `TextRange` representation allows for highlights\n // to be inserted during anchoring other annotations without \"breaking\"\n // this anchor.\n\n\n const textRange = TextRange.fromRange(range);\n\n anchor = {annotation, target, range: textRange};\n\n } catch (err) {\n\n anchor = {annotation, target};\n }\n\n return anchor;\n };\n\n /**\n * Highlight the text range that `anchor` refers to.\n *\n * @param {Anchor} anchor\n */\n const highlight = anchor => {\n\n const range = resolveAnchor(anchor);\n\n if (!range) {\n return;\n }\n\n let highlights = [];\n\n if (annotation.annotation) {\n highlights = highlightRange(range, annotation.annotation.id, 'annotated', annotation.annotation.color);\n } else {\n highlights = highlightRange(range, false, 'annotated_temp');\n }\n\n highlights.forEach(h => {\n h._annotation = anchor.annotation;\n });\n anchor.highlights = highlights;\n\n };\n\n // Remove existing anchors for this annotation.\n // this.detach(annotation, false /* notify */); // To be replaced by own method\n\n // Resolve selectors to ranges and insert highlights.\n if (!annotation.target) {\n annotation.target = [];\n }\n const anchors = annotation.target.map(locate);\n\n for (let anchor of anchors) {\n\n highlight(anchor);\n }\n\n // Set flag indicating whether anchoring succeeded. For each target,\n // anchoring is successful either if there are no selectors (ie. this is a\n // Page Note) or we successfully resolved the selectors to a range.\n annotation.$orphan =\n anchors.length > 0 &&\n anchors.every(anchor => anchor.target.selector && !anchor.range);\n\n return anchors;\n}\n\n/**\n * Resolve an anchor's associated document region to a concrete `Range`.\n *\n * This may fail if anchoring failed or if the document has been mutated since\n * the anchor was created in a way that invalidates the anchor.\n *\n * @param {Anchor} anchor\n * @return {Range|null}\n */\nfunction resolveAnchor(anchor) {\n\n if (!anchor.range) {\n return null;\n }\n try {\n return anchor.range.toRange();\n } catch {\n return null;\n }\n}\n\n/**\n * Wraps the DOM Nodes within the provided range with a highlight\n * element of the specified class and returns the highlight Elements.\n *\n * Modified for handling annotations.\n *\n * @param {Range} range - Range to be highlighted\n * @param {int} annotationid - ID of annotation\n * @param {string} cssClass - A CSS class to use for the highlight\n * @param {string} color - Color of the highlighting\n * @return {HighlightElement[]} - Elements wrapping text in `normedRange` to add a highlight effect\n */\n function highlightRange(range, annotationid = false, cssClass = 'annotated', color = 'FFFF00') {\n\n const textNodes = wholeTextNodesInRange(range);\n\n // Group text nodes into spans of adjacent nodes. If a group of text nodes are\n // adjacent, we only need to create one highlight element for the group.\n let textNodeSpans = [];\n let prevNode = null;\n let currentSpan = null;\n\n textNodes.forEach(node => {\n if (prevNode && prevNode.nextSibling === node) {\n currentSpan.push(node);\n } else {\n currentSpan = [node];\n textNodeSpans.push(currentSpan);\n }\n prevNode = node;\n });\n\n // Filter out text node spans that consist only of white space. This avoids\n // inserting highlight elements in places that can only contain a restricted\n // subset of nodes such as table rows and lists.\n const whitespace = /^\\s*$/;\n textNodeSpans = textNodeSpans.filter(span =>\n // Check for at least one text node with non-space content.\n span.some(node => !whitespace.test(node.nodeValue))\n );\n\n // Wrap each text node span with a `` element.\n const highlights = /** @type {HighlightElement[]} */ ([]);\n\n textNodeSpans.forEach(nodes => {\n const highlightEl = document.createElement('annopy-highlight');\n highlightEl.className = cssClass;\n\n if (annotationid) {\n highlightEl.className += ' ' + cssClass + '-' + annotationid;\n highlightEl.style = \"text-decoration:underline; text-decoration-color: #\" + color;\n highlightEl.id = cssClass + '-' + annotationid;\n highlightEl.style.backgroundColor = '#' + color;\n }\n\n const parent = /** @type {Node} */ (nodes[0].parentNode);\n parent.replaceChild(highlightEl, nodes[0]);\n nodes.forEach(node => highlightEl.appendChild(node));\n\n highlights.push(highlightEl);\n\n });\n\n return highlights;\n}\n\n/**\n * Return text nodes which are entirely inside `range`.\n *\n * If a range starts or ends part-way through a text node, the node is split\n * and the part inside the range is returned.\n *\n * @param {Range} range\n * @return {Text[]}\n */\n function wholeTextNodesInRange(range) {\n if (range.collapsed) {\n // Exit early for an empty range to avoid an edge case that breaks the algorithm\n // below. Splitting a text node at the start of an empty range can leave the\n // range ending in the left part rather than the right part.\n return [];\n }\n\n /** @type {Node|null} */\n let root = range.commonAncestorContainer;\n if (root.nodeType !== Node.ELEMENT_NODE) {\n // If the common ancestor is not an element, set it to the parent element to\n // ensure that the loop below visits any text nodes generated by splitting\n // the common ancestor.\n //\n // Note that `parentElement` may be `null`.\n root = root.parentElement;\n }\n\n if (!root) {\n // If there is no root element then we won't be able to insert highlights,\n // so exit here.\n return [];\n }\n\n const textNodes = [];\n const nodeIter = /** @type {Document} */ (\n root.ownerDocument\n ).createNodeIterator(\n root,\n NodeFilter.SHOW_TEXT // Only return `Text` nodes.\n );\n let node;\n while ((node = nodeIter.nextNode())) {\n if (!isNodeInRange(range, node)) {\n continue;\n }\n let text = /** @type {Text} */ (node);\n\n if (text === range.startContainer && range.startOffset > 0) {\n // Split `text` where the range starts. The split will create a new `Text`\n // node which will be in the range and will be visited in the next loop iteration.\n text.splitText(range.startOffset);\n continue;\n }\n\n if (text === range.endContainer && range.endOffset < text.data.length) {\n // Split `text` where the range ends, leaving it as the part in the range.\n text.splitText(range.endOffset);\n }\n\n textNodes.push(text);\n }\n\n return textNodes;\n}\n\n/**\n * Returns true if any part of `node` lies within `range`.\n *\n * @param {Range} range\n * @param {Node} node\n * @return {bool} - If node is in range\n */\nfunction isNodeInRange(range, node) {\n try {\n const length = node.nodeValue?.length ?? node.childNodes.length;\n return (\n // Check start of node is before end of range.\n range.comparePoint(node, 0) <= 0 &&\n // Check end of node is after start of range.\n range.comparePoint(node, length) >= 0\n );\n } catch (e) {\n // `comparePoint` may fail if the `range` and `node` do not share a common\n // ancestor or `node` is a doctype.\n return false;\n }\n}\n\n/**\n * @param {RangeAnchor|TextPositionAnchor|TextQuoteAnchor} anchor\n * @param {Object} [options]\n * @return {obj} - range\n */\n function querySelector(anchor, options = {}) {\n\n return anchor.toRange(options);\n}\n\n/**\n * Anchor a set of selectors.\n *\n * This function converts a set of selectors into a document range.\n * It encapsulates the core anchoring algorithm, using the selectors alone or\n * in combination to establish the best anchor within the document.\n *\n * @param {Element} root - The root element of the anchoring context.\n * @param {Selector[]} selectors - The selectors to try.\n * @param {Object} [options]\n * @return {object} the query selector\n */\n function htmlAnchor(root, selectors, options = {}) {\n let position = null;\n let quote = null;\n let range = null;\n\n // Collect all the selectors\n for (let selector of selectors) {\n switch (selector.type) {\n case 'TextPositionSelector':\n position = selector;\n options.hint = position.start; // TextQuoteAnchor hint\n break;\n case 'TextQuoteSelector':\n quote = selector;\n break;\n case 'RangeSelector':\n range = selector;\n break;\n }\n }\n\n /**\n * Assert the quote matches the stored quote, if applicable\n * @param {Range} range\n * @return {Range} range\n */\n const maybeAssertQuote = range => {\n\n if (quote?.exact && range.toString() !== quote.exact) {\n throw new Error('quote mismatch');\n } else {\n return range;\n }\n };\n\n let queryselector = false;\n\n try {\n if (range) {\n\n let anchor = RangeAnchor.fromSelector(root, range);\n\n queryselector = querySelector(anchor, options);\n\n if (queryselector) {\n return queryselector;\n } else {\n return maybeAssertQuote;\n }\n }\n } catch (error) {\n try {\n if (position) {\n\n let anchor = TextPositionAnchor.fromSelector(root, position);\n\n queryselector = querySelector(anchor, options);\n if (queryselector) {\n return queryselector;\n } else {\n return maybeAssertQuote;\n }\n }\n } catch (error) {\n try {\n if (quote) {\n\n let anchor = TextQuoteAnchor.fromSelector(root, quote);\n\n queryselector = querySelector(anchor, options);\n\n return queryselector;\n }\n } catch (error) {\n return false;\n }\n }\n }\n return false;\n}\n\n/**\n * Remove all temporary highlights under a given root element.\n */\n export function removeAllTempHighlights() {\n const highlights = Array.from($('body')[0].querySelectorAll('.annotated_temp'));\n if (highlights !== undefined && highlights.length != 0) {\n removeHighlights(highlights);\n }\n}\n\n/**\n * Remove highlights from a range previously highlighted with `highlightRange`.\n *\n * @param {HighlightElement[]} highlights - The highlight elements returned by `highlightRange`\n */\n function removeHighlights(highlights) {\n\n for (var i = 0; i < highlights.length; i++) {\n if (highlights[i].parentNode) {\n const children = Array.from(highlights[i].childNodes);\n replaceWith(highlights[i], children);\n }\n }\n}\n\n/**\n * Replace a child `node` with `replacements`.\n *\n * nb. This is like `ChildNode.replaceWith` but it works in older browsers.\n *\n * @param {ChildNode} node\n * @param {Node[]} replacements\n */\nfunction replaceWith(node, replacements) {\n const parent = /** @type {Node} */ (node.parentNode);\n\n replacements.forEach(r => parent.insertBefore(r, node));\n node.remove();\n}"],"names":["obj","highlightRange","range","annotationid","arguments","length","undefined","cssClass","color","textNodes","collapsed","root","commonAncestorContainer","nodeType","Node","ELEMENT_NODE","parentElement","nodeIter","ownerDocument","createNodeIterator","NodeFilter","SHOW_TEXT","node","nextNode","isNodeInRange","text","startContainer","startOffset","splitText","endContainer","endOffset","data","push","wholeTextNodesInRange","textNodeSpans","prevNode","currentSpan","forEach","nextSibling","whitespace","filter","span","some","test","nodeValue","highlights","nodes","highlightEl","document","createElement","className","style","id","backgroundColor","parentNode","replaceChild","appendChild","_node$nodeValue$lengt","_node$nodeValue","childNodes","comparePoint","e","querySelector","anchor","options","toRange","replaceWith","replacements","parent","r","insertBefore","remove","annotation","highlight","resolveAnchor","h","_annotation","target","anchors","map","selector","s","type","selectors","position","quote","hint","start","maybeAssertQuote","_quote","exact","toString","Error","queryselector","RangeAnchor","fromSelector","error","TextPositionAnchor","TextQuoteAnchor","htmlAnchor","textRange","TextRange","fromRange","err","$orphan","every","types","result","toSelector","Array","from","$","default","querySelectorAll","i","children","removeHighlights","_jquery","__esModule"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/match-quote.min.js b/amd/build/match-quote.min.js
new file mode 100644
index 0000000..fc1bee2
--- /dev/null
+++ b/amd/build/match-quote.min.js
@@ -0,0 +1,3 @@
+define("mod_annopy/match-quote",["exports","./string-match"],(function(_exports,_stringMatch){var obj;function search(text,str,maxErrors){let matchPos=0,exactMatches=[];for(;-1!==matchPos;)matchPos=text.indexOf(str,matchPos),-1!==matchPos&&(exactMatches.push({start:matchPos,end:matchPos+str.length,errors:0}),matchPos+=1);return exactMatches.length>0?exactMatches:(0,_stringMatch.default)(text,str,maxErrors)}function textMatchScore(text,str){if(0===str.length||0===text.length)return 0;return 1-search(text,str,str.length)[0].errors/str.length}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.matchQuote=function(text,quote){let context=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(0===quote.length)return null;const maxErrors=Math.min(256,quote.length/2),matches=search(text,quote,maxErrors);if(0===matches.length)return null;const scoreMatch=match=>{const quoteScore=1-match.errors/quote.length,prefixScore=context.prefix?textMatchScore(text.slice(Math.max(0,match.start-context.prefix.length),match.start),context.prefix):1,suffixScore=context.suffix?textMatchScore(text.slice(match.end,match.end+context.suffix.length),context.suffix):1;let posScore=1;if("number"==typeof context.hint){posScore=1-Math.abs(match.start-context.hint)/text.length}return(50*quoteScore+20*prefixScore+20*suffixScore+2*posScore)/92},scoredMatches=matches.map((m=>({start:m.start,end:m.end,score:scoreMatch(m)})));return scoredMatches.sort(((a,b)=>b.score-a.score)),scoredMatches[0]},_stringMatch=(obj=_stringMatch)&&obj.__esModule?obj:{default:obj}}));
+
+//# sourceMappingURL=match-quote.min.js.map
\ No newline at end of file
diff --git a/amd/build/match-quote.min.js.map b/amd/build/match-quote.min.js.map
new file mode 100644
index 0000000..b583708
--- /dev/null
+++ b/amd/build/match-quote.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"match-quote.min.js","sources":["../src/match-quote.js"],"sourcesContent":["/**\n * Functions for quote matching for the annotations and highlighting.\n *\n * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)\n * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),\n * sometimes referred to as the \"Simplified BSD License\".\n */\n\nimport approxSearch from './string-match';\n\n/**\n * @typedef {import('approx-string-match').Match} StringMatch\n */\n\n/**\n * @typedef Match\n * @prop {number} start - Start offset of match in text\n * @prop {number} end - End offset of match in text\n * @prop {number} score -\n * Score for the match between 0 and 1.0, where 1.0 indicates a perfect match\n * for the quote and context.\n */\n\n/**\n * Find the best approximate matches for `str` in `text` allowing up to `maxErrors` errors.\n *\n * @param {string} text\n * @param {string} str\n * @param {number} maxErrors\n * @return {StringMatch[]}\n */\nfunction search(text, str, maxErrors) {\n // Do a fast search for exact matches. The `approx-string-match` library\n // doesn't currently incorporate this optimization itself.\n let matchPos = 0;\n let exactMatches = [];\n while (matchPos !== -1) {\n matchPos = text.indexOf(str, matchPos);\n if (matchPos !== -1) {\n exactMatches.push({\n start: matchPos,\n end: matchPos + str.length,\n errors: 0,\n });\n matchPos += 1;\n }\n }\n if (exactMatches.length > 0) {\n return exactMatches;\n }\n\n // If there are no exact matches, do a more expensive search for matches\n // with errors.\n return approxSearch(text, str, maxErrors);\n}\n\n/**\n * Compute a score between 0 and 1.0 for the similarity between `text` and `str`.\n *\n * @param {string} text\n * @param {string} str\n * @return {int}\n */\nfunction textMatchScore(text, str) {\n // `search` will return no matches if either the text or pattern is empty,\n // otherwise it will return at least one match if the max allowed error count\n // is at least `str.length`.\n if (str.length === 0 || text.length === 0) {\n return 0.0;\n }\n\n const matches = search(text, str, str.length);\n\n // Prettier-ignore.\n return 1 - (matches[0].errors / str.length);\n}\n\n/**\n * Find the best approximate match for `quote` in `text`.\n *\n * Returns `null` if no match exceeding the minimum quality threshold was found.\n *\n * @param {string} text - Document text to search\n * @param {string} quote - String to find within `text`\n * @param {Object} context -\n * Context in which the quote originally appeared. This is used to choose the\n * best match.\n * @param {string} [context.prefix] - Expected text before the quote\n * @param {string} [context.suffix] - Expected text after the quote\n * @param {number} [context.hint] - Expected offset of match within text\n * @return {Match|null}\n */\nexport function matchQuote(text, quote, context = {}) {\n if (quote.length === 0) {\n return null;\n }\n\n // Choose the maximum number of errors to allow for the initial search.\n // This choice involves a tradeoff between:\n //\n // - Recall (proportion of \"good\" matches found)\n // - Precision (proportion of matches found which are \"good\")\n // - Cost of the initial search and of processing the candidate matches [1]\n //\n // [1] Specifically, the expected-time complexity of the initial search is\n // `O((maxErrors / 32) * text.length)`. See `approx-string-match` docs.\n const maxErrors = Math.min(256, quote.length / 2);\n\n // Find closest matches for `quote` in `text` based on edit distance.\n const matches = search(text, quote, maxErrors);\n\n if (matches.length === 0) {\n return null;\n }\n\n /**\n * Compute a score between 0 and 1.0 for a match candidate.\n *\n * @param {StringMatch} match\n * @return {int}\n */\n const scoreMatch = match => {\n const quoteWeight = 50; // Similarity of matched text to quote.\n const prefixWeight = 20; // Similarity of text before matched text to `context.prefix`.\n const suffixWeight = 20; // Similarity of text after matched text to `context.suffix`.\n const posWeight = 2; // Proximity to expected location. Used as a tie-breaker.\n\n const quoteScore = 1 - match.errors / quote.length;\n\n const prefixScore = context.prefix\n ? textMatchScore(\n text.slice(\n Math.max(0, match.start - context.prefix.length),\n match.start\n ),\n context.prefix\n )\n : 1.0;\n const suffixScore = context.suffix\n ? textMatchScore(\n text.slice(match.end, match.end + context.suffix.length),\n context.suffix\n )\n : 1.0;\n\n let posScore = 1.0;\n if (typeof context.hint === 'number') {\n const offset = Math.abs(match.start - context.hint);\n posScore = 1.0 - offset / text.length;\n }\n\n const rawScore =\n quoteWeight * quoteScore +\n prefixWeight * prefixScore +\n suffixWeight * suffixScore +\n posWeight * posScore;\n const maxScore = quoteWeight + prefixWeight + suffixWeight + posWeight;\n const normalizedScore = rawScore / maxScore;\n\n return normalizedScore;\n };\n\n // Rank matches based on similarity of actual and expected surrounding text\n // and actual/expected offset in the document text.\n const scoredMatches = matches.map(m => ({\n start: m.start,\n end: m.end,\n score: scoreMatch(m),\n }));\n\n // Choose match with highest score.\n scoredMatches.sort((a, b) => b.score - a.score);\n return scoredMatches[0];\n}\n"],"names":["obj","search","text","str","maxErrors","matchPos","exactMatches","indexOf","push","start","end","length","errors","approxSearch","textMatchScore","quote","context","arguments","undefined","Math","min","matches","scoreMatch","match","quoteScore","prefixScore","prefix","slice","max","suffixScore","suffix","posScore","hint","abs","quoteWeight","scoredMatches","map","m","score","sort","a","b","_stringMatch","__esModule","default"],"mappings":"8FAQ0C,IAAAA,IAuB1C,SAASC,OAAOC,KAAMC,IAAKC,WAGzB,IAAIC,SAAW,EACXC,aAAe,GACnB,MAAqB,IAAdD,UACLA,SAAWH,KAAKK,QAAQJ,IAAKE,WACX,IAAdA,WACFC,aAAaE,KAAK,CAChBC,MAAOJ,SACPK,IAAKL,SAAWF,IAAIQ,OACpBC,OAAQ,IAEVP,UAAY,GAGhB,OAAIC,aAAaK,OAAS,EACjBL,cAKF,EAAAO,sBAAaX,KAAMC,IAAKC,UACjC,CASA,SAASU,eAAeZ,KAAMC,KAI5B,GAAmB,IAAfA,IAAIQ,QAAgC,IAAhBT,KAAKS,OAC3B,OAAO,EAMT,OAAO,EAHSV,OAAOC,KAAMC,IAAKA,IAAIQ,QAGlB,GAAGC,OAAST,IAAIQ,MACtC,6EAiBO,SAAoBT,KAAMa,OAAqB,IAAdC,QAAOC,UAAAN,OAAA,QAAAO,IAAAD,UAAA,GAAAA,UAAA,GAAG,CAAA,EAChD,GAAqB,IAAjBF,MAAMJ,OACR,OAAO,KAYT,MAAMP,UAAYe,KAAKC,IAAI,IAAKL,MAAMJ,OAAS,GAGzCU,QAAUpB,OAAOC,KAAMa,MAAOX,WAEpC,GAAuB,IAAnBiB,QAAQV,OACV,OAAO,KAST,MAAMW,WAAaC,QACjB,MAKMC,WAAa,EAAID,MAAMX,OAASG,MAAMJ,OAEtCc,YAAcT,QAAQU,OACxBZ,eACEZ,KAAKyB,MACHR,KAAKS,IAAI,EAAGL,MAAMd,MAAQO,QAAQU,OAAOf,QACzCY,MAAMd,OAERO,QAAQU,QAEV,EACEG,YAAcb,QAAQc,OACxBhB,eACEZ,KAAKyB,MAAMJ,MAAMb,IAAKa,MAAMb,IAAMM,QAAQc,OAAOnB,QACjDK,QAAQc,QAEV,EAEJ,IAAIC,SAAW,EACf,GAA4B,iBAAjBf,QAAQgB,KAAmB,CAEpCD,SAAW,EADIZ,KAAKc,IAAIV,MAAMd,MAAQO,QAAQgB,MACpB9B,KAAKS,MACjC,CAUA,OArCoB,GA8BJa,WA7BK,GA8BJC,YA7BI,GA8BJI,YA7BC,EA8BJE,UACGG,EAGK,EAKlBC,cAAgBd,QAAQe,KAAIC,IAAM,CACtC5B,MAAO4B,EAAE5B,MACTC,IAAK2B,EAAE3B,IACP4B,MAAOhB,WAAWe,OAKpB,OADAF,cAAcI,MAAK,CAACC,EAAGC,IAAMA,EAAEH,MAAQE,EAAEF,QAClCH,cAAc,EACvB,EArKAO,cAA0C1C,IAA1C0C,eAA0C1C,IAAA2C,WAAA3C,IAAA4C,CAAAA,QAAA5C,IAqKzC"}
\ No newline at end of file
diff --git a/amd/build/string-match.min.js b/amd/build/string-match.min.js
new file mode 100644
index 0000000..0c13cd3
--- /dev/null
+++ b/amd/build/string-match.min.js
@@ -0,0 +1,3 @@
+define("mod_annopy/string-match",["exports"],(function(_exports){function reverse(s){return s.split("").reverse().join("")}function oneIfNotZero(n){return(n|-n)>>31&1}function advanceBlock(ctx,peq,b,hIn){let pV=ctx.P[b],mV=ctx.M[b];const hInIsNegative=hIn>>>31,eq=peq[b]|hInIsNegative,xV=eq|mV,xH=(eq&pV)+pV^pV|eq;let pH=mV|~(xH|pV),mH=pV&xH;const hOut=oneIfNotZero(pH&ctx.lastRowMask[b])-oneIfNotZero(mH&ctx.lastRowMask[b]);return pH<<=1,mH<<=1,mH|=hInIsNegative,pH|=oneIfNotZero(hIn)-hInIsNegative,pV=mH|~(xV|pH),mV=pH&xV,ctx.P[b]=pV,ctx.M[b]=mV,hOut}function findMatchEnds(text,pattern,maxErrors){if(0===pattern.length)return[];maxErrors=Math.min(maxErrors,pattern.length);const matches=[],w=32,bMax=Math.ceil(pattern.length/w)-1,ctx={P:new Uint32Array(bMax+1),M:new Uint32Array(bMax+1),lastRowMask:new Uint32Array(bMax+1)};ctx.lastRowMask.fill(1<<31),ctx.lastRowMask[bMax]=1<<(pattern.length-1)%w;const emptyPeq=new Uint32Array(bMax+1),peq=new Map,asciiPeq=[];for(let i=0;i<256;i++)asciiPeq.push(emptyPeq);for(let c=0;c=pattern.length)continue;pattern.charCodeAt(idx)===val&&(charPeq[b]|=1<0&&score[y]>=maxErrors+w;)y-=1;y===bMax&&score[y]<=maxErrors&&(score[y]{const minStart=Math.max(0,m.end-pattern.length-m.errors);return{start:findMatchEnds(reverse(text.slice(minStart,m.end)),patRev,m.errors).reduce(((min,rm)=>m.end-rm.end {\n // Find start of each match by reversing the pattern and matching segment\n // of text and searching for an approx match with the same number of\n // errors.\n const minStart = Math.max(0, m.end - pattern.length - m.errors);\n const textRev = reverse(text.slice(minStart, m.end));\n\n // If there are multiple possible start points, choose the one that\n // maximizes the length of the match.\n const start = findMatchEnds(textRev, patRev, m.errors).reduce((min, rm) => {\n if (m.end - rm.end < min) {\n return m.end - rm.end;\n }\n return min;\n }, m.end);\n\n return {\n start,\n end: m.end,\n errors: m.errors,\n };\n });\n }\n\n /**\n * Internal context used when calculating blocks of a column.\n */\n // interface Context {\n // /**\n // * Bit-arrays of positive vertical deltas.\n // *\n // * ie. `P[b][i]` is set if the vertical delta for the i'th row in the b'th\n // * block is positive.\n // */\n // P: Uint32Array;\n // /** Bit-arrays of negative vertical deltas. */\n // M: Uint32Array;\n // /** Bit masks with a single bit set indicating the last row in each block. */\n // lastRowMask: Uint32Array;\n // }\n\n /**\n * Return 1 if a number is non-zero or zero otherwise, without using\n * conditional operators.\n *\n * This should get inlined into `advanceBlock` below by the JIT.\n *\n * Adapted from https://stackoverflow.com/a/3912218/434243\n * @param {int} n\n * @return {bool}\n */\n function oneIfNotZero(n) {\n return ((n | -n) >> 31) & 1;\n }\n\n /**\n * Block calculation step of the algorithm.\n *\n * From Fig 8. on p. 408 of [1], additionally optimized to replace conditional\n * checks with bitwise operations as per Section 4.2.3 of [2].\n *\n * @param {obj} ctx - The pattern context object\n * @param {array} peq - The `peq` array for the current character (`ctx.peq.get(ch)`)\n * @param {int} b - The block level\n * @param {obj} hIn - Horizontal input delta ∈ {1,0,-1}\n * @return {obj} Horizontal output delta ∈ {1,0,-1}\n */\n function advanceBlock(ctx, peq, b, hIn) {\n let pV = ctx.P[b];\n let mV = ctx.M[b];\n const hInIsNegative = hIn >>> 31; // 1 if hIn < 0 or 0 otherwise.\n const eq = peq[b] | hInIsNegative;\n\n // Step 1: Compute horizontal deltas.\n const xV = eq | mV;\n const xH = (((eq & pV) + pV) ^ pV) | eq;\n\n let pH = mV | ~(xH | pV);\n let mH = pV & xH;\n\n // Step 2: Update score (value of last row of this block).\n const hOut =\n oneIfNotZero(pH & ctx.lastRowMask[b]) -\n oneIfNotZero(mH & ctx.lastRowMask[b]);\n\n // Step 3: Update vertical deltas for use when processing next char.\n pH <<= 1;\n mH <<= 1;\n\n mH |= hInIsNegative;\n pH |= oneIfNotZero(hIn) - hInIsNegative; // Set pH[0] if hIn > 0.\n\n pV = mH | ~(xV | pH);\n mV = pH & xV;\n\n ctx.P[b] = pV;\n ctx.M[b] = mV;\n\n return hOut;\n }\n\n /**\n * Find the ends and error counts for matches of `pattern` in `text`.\n *\n * Only the matches with the lowest error count are reported. Other matches\n * with error counts <= maxErrors are discarded.\n *\n * This is the block-based search algorithm from Fig. 9 on p.410 of [1].\n *\n * @param {string} text\n * @param {string} pattern\n * @param {array} maxErrors\n * @return {obj} Matches with the `start` property set.\n */\n function findMatchEnds(text, pattern, maxErrors) {\n if (pattern.length === 0) {\n return [];\n }\n\n // Clamp error count so we can rely on the `maxErrors` and `pattern.length`\n // rows being in the same block below.\n maxErrors = Math.min(maxErrors, pattern.length);\n\n const matches = [];\n\n // Word size.\n const w = 32;\n\n // Index of maximum block level.\n const bMax = Math.ceil(pattern.length / w) - 1;\n\n // Context used across block calculations.\n const ctx = {\n P: new Uint32Array(bMax + 1),\n M: new Uint32Array(bMax + 1),\n lastRowMask: new Uint32Array(bMax + 1),\n };\n ctx.lastRowMask.fill(1 << 31);\n ctx.lastRowMask[bMax] = 1 << (pattern.length - 1) % w;\n\n // Dummy \"peq\" array for chars in the text which do not occur in the pattern.\n const emptyPeq = new Uint32Array(bMax + 1);\n\n // Map of UTF-16 character code to bit vector indicating positions in the\n // pattern that equal that character.\n const peq = new Map();\n\n // Version of `peq` that only stores mappings for small characters. This\n // allows faster lookups when iterating through the text because a simple\n // array lookup can be done instead of a hash table lookup.\n const asciiPeq = [];\n for (let i = 0; i < 256; i++) {\n asciiPeq.push(emptyPeq);\n }\n\n // Calculate `ctx.peq` - a map of character values to bitmasks indicating\n // positions of that character within the pattern, where each bit represents\n // a position in the pattern.\n for (let c = 0; c < pattern.length; c += 1) {\n const val = pattern.charCodeAt(c);\n if (peq.has(val)) {\n // Duplicate char in pattern.\n continue;\n }\n\n const charPeq = new Uint32Array(bMax + 1);\n peq.set(val, charPeq);\n if (val < asciiPeq.length) {\n asciiPeq[val] = charPeq;\n }\n\n for (let b = 0; b <= bMax; b += 1) {\n charPeq[b] = 0;\n\n // Set all the bits where the pattern matches the current char (ch).\n // For indexes beyond the end of the pattern, always set the bit as if the\n // pattern contained a wildcard char in that position.\n for (let r = 0; r < w; r += 1) {\n const idx = b * w + r;\n if (idx >= pattern.length) {\n continue;\n }\n\n const match = pattern.charCodeAt(idx) === val;\n if (match) {\n charPeq[b] |= 1 << r;\n }\n }\n }\n }\n\n // Index of last-active block level in the column.\n let y = Math.max(0, Math.ceil(maxErrors / w) - 1);\n\n // Initialize maximum error count at bottom of each block.\n const score = new Uint32Array(bMax + 1);\n for (let b = 0; b <= y; b += 1) {\n score[b] = (b + 1) * w;\n }\n score[bMax] = pattern.length;\n\n // Initialize vertical deltas for each block.\n for (let b = 0; b <= y; b += 1) {\n ctx.P[b] = ~0;\n ctx.M[b] = 0;\n }\n\n // Process each char of the text, computing the error count for `w` chars of\n // the pattern at a time.\n for (let j = 0; j < text.length; j += 1) {\n // Lookup the bitmask representing the positions of the current char from\n // the text within the pattern.\n const charCode = text.charCodeAt(j);\n let charPeq;\n\n if (charCode < asciiPeq.length) {\n // Fast array lookup.\n charPeq = asciiPeq[charCode];\n } else {\n // Slower hash table lookup.\n charPeq = peq.get(charCode);\n if (typeof charPeq === \"undefined\") {\n charPeq = emptyPeq;\n }\n }\n\n // Calculate error count for blocks that we definitely have to process for\n // this column.\n let carry = 0;\n for (let b = 0; b <= y; b += 1) {\n carry = advanceBlock(ctx, charPeq, b, carry);\n score[b] += carry;\n }\n\n // Check if we also need to compute an additional block, or if we can reduce\n // the number of blocks processed for the next column.\n if (\n score[y] - carry <= maxErrors &&\n y < bMax &&\n (charPeq[y + 1] & 1 || carry < 0)\n ) {\n // Error count for bottom block is under threshold, increase the number of\n // blocks processed for this column & next by 1.\n y += 1;\n\n ctx.P[y] = ~0;\n ctx.M[y] = 0;\n\n let maxBlockScore;\n if (y === bMax) {\n const remainder = pattern.length % w;\n maxBlockScore = remainder === 0 ? w : remainder;\n } else {\n maxBlockScore = w;\n }\n\n score[y] =\n score[y - 1] +\n maxBlockScore -\n carry +\n advanceBlock(ctx, charPeq, y, carry);\n } else {\n // Error count for bottom block exceeds threshold, reduce the number of\n // blocks processed for the next column.\n while (y > 0 && score[y] >= maxErrors + w) {\n y -= 1;\n }\n }\n\n // If error count is under threshold, report a match.\n if (y === bMax && score[y] <= maxErrors) {\n if (score[y] < maxErrors) {\n // Discard any earlier, worse matches.\n matches.splice(0, matches.length);\n }\n\n matches.push({\n start: -1,\n end: j + 1,\n errors: score[y],\n });\n\n // Because `search` only reports the matches with the lowest error count,\n // we can \"ratchet down\" the max error threshold whenever a match is\n // encountered and thereby save a small amount of work for the remainder\n // of the text.\n maxErrors = score[y];\n }\n }\n\n return matches;\n }\n\n /**\n * Search for matches for `pattern` in `text` allowing up to `maxErrors` errors.\n *\n * Returns the start, and end positions and error counts for each lowest-cost\n * match. Only the \"best\" matches are returned.\n * @param {string} text\n * @param {string} pattern\n * @param {array} maxErrors\n * @return {obj} Matches with the `start` property set.\n */\n export default function search(\n text,\n pattern,\n maxErrors\n ) {\n const matches = findMatchEnds(text, pattern, maxErrors);\n return findMatchStarts(text, pattern, matches);\n }"],"names":["reverse","s","split","join","oneIfNotZero","n","advanceBlock","ctx","peq","b","hIn","pV","P","mV","M","hInIsNegative","eq","xV","xH","pH","mH","hOut","lastRowMask","findMatchEnds","text","pattern","maxErrors","length","Math","min","matches","w","bMax","ceil","Uint32Array","fill","emptyPeq","Map","asciiPeq","i","push","c","val","charCodeAt","has","charPeq","set","r","idx","y","max","score","j","charCode","get","carry","maxBlockScore","remainder","splice","start","end","errors","patRev","map","m","minStart","slice","reduce","rm","findMatchStarts","_exports","default"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/text-range.min.js b/amd/build/text-range.min.js
new file mode 100644
index 0000000..213be49
--- /dev/null
+++ b/amd/build/text-range.min.js
@@ -0,0 +1,3 @@
+define("mod_annopy/text-range",["exports"],(function(_exports){function nodeTextLength(node){switch(node.nodeType){case Node.ELEMENT_NODE:case Node.TEXT_NODE:return node.textContent.length;default:return 0}}function previousSiblingsTextLength(node){let sibling=node.previousSibling,length=0;for(;sibling;)length+=nodeTextLength(sibling),sibling=sibling.previousSibling;return length}function resolveOffsets(element){for(var _len=arguments.length,offsets=new Array(_len>1?_len-1:0),_key=1;_key<_len;_key++)offsets[_key-1]=arguments[_key];let nextOffset=offsets.shift();const nodeIter=element.ownerDocument.createNodeIterator(element,NodeFilter.SHOW_TEXT),results=[];let textNode,currentNode=nodeIter.nextNode(),length=0;for(;void 0!==nextOffset&¤tNode;)textNode=currentNode,length+textNode.data.length>nextOffset?(results.push({node:textNode,offset:nextOffset-length}),nextOffset=offsets.shift()):(currentNode=nodeIter.nextNode(),length+=textNode.data.length);for(;void 0!==nextOffset&&length===nextOffset;)results.push({node:textNode,offset:textNode.data.length}),nextOffset=offsets.shift();if(void 0!==nextOffset)throw new RangeError("Offset exceeds text length");return results}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.TextRange=_exports.TextPosition=_exports.RESOLVE_FORWARDS=_exports.RESOLVE_BACKWARDS=void 0;_exports.RESOLVE_FORWARDS=1;_exports.RESOLVE_BACKWARDS=2;class TextPosition{constructor(element,offset){if(offset<0)throw new Error("Offset is invalid");this.element=element,this.offset=offset}relativeTo(parent){if(!parent.contains(this.element))throw new Error("Parent is not an ancestor of current element");let el=this.element,offset=this.offset;for(;el!==parent;)offset+=previousSiblingsTextLength(el),el=el.parentElement;return new TextPosition(el,offset)}resolve(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};try{return resolveOffsets(this.element,this.offset)[0]}catch(err){if(0===this.offset&&void 0!==options.direction){const tw=document.createTreeWalker(this.element.getRootNode(),NodeFilter.SHOW_TEXT);tw.currentNode=this.element;const forwards=1===options.direction,text=forwards?tw.nextNode():tw.previousNode();if(!text)throw err;return{node:text,offset:forwards?0:text.data.length}}throw err}}static fromCharOffset(node,offset){switch(node.nodeType){case Node.TEXT_NODE:return TextPosition.fromPoint(node,offset);case Node.ELEMENT_NODE:return new TextPosition(node,offset);default:throw new Error("Node is not an element or text node")}}static fromPoint(node,offset){switch(node.nodeType){case Node.TEXT_NODE:{if(offset<0||offset>node.data.length)throw new Error("Text node offset is out of range");if(!node.parentElement)throw new Error("Text node has no parent");const textOffset=previousSiblingsTextLength(node)+offset;return new TextPosition(node.parentElement,textOffset)}case Node.ELEMENT_NODE:{if(offset<0||offset>node.childNodes.length)throw new Error("Child node offset is out of range");let textOffset=0;for(let i=0;i nextOffset) {\n results.push({node: textNode, offset: nextOffset - length});\n nextOffset = offsets.shift();\n } else {\n currentNode = nodeIter.nextNode();\n length += textNode.data.length;\n }\n }\n\n // Boundary case.\n while (nextOffset !== undefined && length === nextOffset) {\n results.push({node: textNode, offset: textNode.data.length});\n nextOffset = offsets.shift();\n }\n\n if (nextOffset !== undefined) {\n throw new RangeError('Offset exceeds text length');\n }\n\n return results;\n}\n\nexport let RESOLVE_FORWARDS = 1;\nexport let RESOLVE_BACKWARDS = 2;\n\n/**\n * Represents an offset within the text content of an element.\n *\n * This position can be resolved to a specific descendant node in the current\n * DOM subtree of the element using the `resolve` method.\n */\nexport class TextPosition {\n /**\n * Construct a `TextPosition` that refers to the text position `offset` within\n * the text content of `element`.\n *\n * @param {Element} element\n * @param {number} offset\n */\n constructor(element, offset) {\n if (offset < 0) {\n throw new Error('Offset is invalid');\n }\n\n /** Element that `offset` is relative to. */\n this.element = element;\n\n /** Character offset from the start of the element's `textContent`. */\n this.offset = offset;\n }\n\n /**\n * Return a copy of this position with offset relative to a given ancestor\n * element.\n *\n * @param {Element} parent - Ancestor of `this.element`\n * @return {TextPosition}\n */\n relativeTo(parent) {\n if (!parent.contains(this.element)) {\n throw new Error('Parent is not an ancestor of current element');\n }\n\n let el = this.element;\n let offset = this.offset;\n while (el !== parent) {\n offset += previousSiblingsTextLength(el);\n el = /** @type {Element} */ (el.parentElement);\n }\n\n return new TextPosition(el, offset);\n }\n\n /**\n * Resolve the position to a specific text node and offset within that node.\n *\n * Throws if `this.offset` exceeds the length of the element's text. In the\n * case where the element has no text and `this.offset` is 0, the `direction`\n * option determines what happens.\n *\n * Offsets at the boundary between two nodes are resolved to the start of the\n * node that begins at the boundary.\n *\n * @param {Object} [options]\n * @param {RESOLVE_FORWARDS|RESOLVE_BACKWARDS} [options.direction] -\n * Specifies in which direction to search for the nearest text node if\n * `this.offset` is `0` and `this.element` has no text. If not specified\n * an error is thrown.\n * @return {{ node: Text, offset: number }}\n * @throws {RangeError}\n */\n resolve(options = {}) {\n try {\n return resolveOffsets(this.element, this.offset)[0];\n } catch (err) {\n if (this.offset === 0 && options.direction !== undefined) {\n const tw = document.createTreeWalker(\n this.element.getRootNode(),\n NodeFilter.SHOW_TEXT\n );\n tw.currentNode = this.element;\n const forwards = options.direction === RESOLVE_FORWARDS;\n const text = /** @type {Text|null} */ (\n forwards ? tw.nextNode() : tw.previousNode()\n );\n if (!text) {\n throw err;\n }\n return {node: text, offset: forwards ? 0 : text.data.length};\n } else {\n throw err;\n }\n }\n }\n\n /**\n * Construct a `TextPosition` that refers to the `offset`th character within\n * `node`.\n *\n * @param {Node} node\n * @param {number} offset\n * @return {TextPosition}\n */\n static fromCharOffset(node, offset) {\n switch (node.nodeType) {\n case Node.TEXT_NODE:\n return TextPosition.fromPoint(node, offset);\n case Node.ELEMENT_NODE:\n return new TextPosition(/** @type {Element} */ (node), offset);\n default:\n throw new Error('Node is not an element or text node');\n }\n }\n\n /**\n * Construct a `TextPosition` representing the range start or end point (node, offset).\n *\n * @param {Node} node - Text or Element node\n * @param {number} offset - Offset within the node.\n * @return {TextPosition}\n */\n static fromPoint(node, offset) {\n\n switch (node.nodeType) {\n case Node.TEXT_NODE: {\n if (offset < 0 || offset > /** @type {Text} */ (node).data.length) {\n throw new Error('Text node offset is out of range');\n }\n\n if (!node.parentElement) {\n throw new Error('Text node has no parent');\n }\n\n // Get the offset from the start of the parent element.\n const textOffset = previousSiblingsTextLength(node) + offset;\n\n return new TextPosition(node.parentElement, textOffset);\n }\n case Node.ELEMENT_NODE: {\n if (offset < 0 || offset > node.childNodes.length) {\n throw new Error('Child node offset is out of range');\n }\n\n // Get the text length before the `offset`th child of element.\n let textOffset = 0;\n for (let i = 0; i < offset; i++) {\n textOffset += nodeTextLength(node.childNodes[i]);\n }\n\n return new TextPosition(/** @type {Element} */ (node), textOffset);\n }\n default:\n throw new Error('Point is not in an element or text node');\n }\n }\n}\n\n/**\n * Represents a region of a document as a (start, end) pair of `TextPosition` points.\n *\n * Representing a range in this way allows for changes in the DOM content of the\n * range which don't affect its text content, without affecting the text content\n * of the range itself.\n */\nexport class TextRange {\n /**\n * Construct an immutable `TextRange` from a `start` and `end` point.\n *\n * @param {TextPosition} start\n * @param {TextPosition} end\n */\n constructor(start, end) {\n this.start = start;\n this.end = end;\n }\n\n /**\n * Return a copy of this range with start and end positions relative to a\n * given ancestor. See `TextPosition.relativeTo`.\n *\n * @param {Element} element\n * @return {Range}\n */\n relativeTo(element) {\n return new TextRange(\n this.start.relativeTo(element),\n this.end.relativeTo(element)\n );\n }\n\n /**\n * Resolve the `TextRange` to a DOM range.\n *\n * The resulting DOM Range will always start and end in a `Text` node.\n * Hence `TextRange.fromRange(range).toRange()` can be used to \"shrink\" a\n * range to the text it contains.\n *\n * May throw if the `start` or `end` positions cannot be resolved to a range.\n *\n * @return {Range}\n */\n toRange() {\n let start;\n let end;\n\n if (\n this.start.element === this.end.element &&\n this.start.offset <= this.end.offset\n ) {\n // Fast path for start and end points in same element.\n [start, end] = resolveOffsets(\n this.start.element,\n this.start.offset,\n this.end.offset\n );\n } else {\n start = this.start.resolve({direction: RESOLVE_FORWARDS});\n end = this.end.resolve({direction: RESOLVE_BACKWARDS});\n }\n\n const range = new Range();\n range.setStart(start.node, start.offset);\n range.setEnd(end.node, end.offset);\n return range;\n }\n\n /**\n * Convert an existing DOM `Range` to a `TextRange`\n *\n * @param {Range} range\n * @return {TextRange}\n */\n static fromRange(range) {\n const start = TextPosition.fromPoint(\n range.startContainer,\n range.startOffset\n );\n const end = TextPosition.fromPoint(range.endContainer, range.endOffset);\n return new TextRange(start, end);\n }\n\n /**\n * Return a `TextRange` from the `start`th to `end`th characters in `root`.\n *\n * @param {Element} root\n * @param {number} start\n * @param {number} end\n * @return {Range}\n */\n static fromOffsets(root, start, end) {\n return new TextRange(\n new TextPosition(root, start),\n new TextPosition(root, end)\n );\n }\n}\n"],"names":["nodeTextLength","node","nodeType","Node","ELEMENT_NODE","TEXT_NODE","textContent","length","previousSiblingsTextLength","sibling","previousSibling","resolveOffsets","element","_len","arguments","offsets","Array","_key","nextOffset","shift","nodeIter","ownerDocument","createNodeIterator","NodeFilter","SHOW_TEXT","results","textNode","currentNode","nextNode","undefined","data","push","offset","RangeError","_exports","RESOLVE_FORWARDS","RESOLVE_BACKWARDS","TextPosition","constructor","Error","this","relativeTo","parent","contains","el","parentElement","resolve","options","err","direction","tw","document","createTreeWalker","getRootNode","forwards","text","previousNode","static","fromPoint","textOffset","childNodes","i","TextRange","start","end","toRange","range","Range","setStart","setEnd","startContainer","startOffset","endContainer","endOffset","root"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/types.min.js b/amd/build/types.min.js
new file mode 100644
index 0000000..e2387ed
--- /dev/null
+++ b/amd/build/types.min.js
@@ -0,0 +1,3 @@
+define("mod_annopy/types",["exports","./match-quote","./text-range","./xpath"],(function(_exports,_matchQuote,_textRange,_xpath){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.TextQuoteAnchor=_exports.TextPositionAnchor=_exports.RangeAnchor=void 0;class RangeAnchor{constructor(root,range){this.root=root,this.range=range}static fromRange(root,range){return new RangeAnchor(root,range)}static fromSelector(root,selector){const startContainer=(0,_xpath.nodeFromXPath)(selector.startContainer,root);if(!startContainer)throw new Error("Failed to resolve startContainer XPath");const endContainer=(0,_xpath.nodeFromXPath)(selector.endContainer,root);if(!endContainer)throw new Error("Failed to resolve endContainer XPath");const startPos=_textRange.TextPosition.fromCharOffset(startContainer,selector.startOffset),endPos=_textRange.TextPosition.fromCharOffset(endContainer,selector.endOffset),range=new _textRange.TextRange(startPos,endPos).toRange();return new RangeAnchor(root,range)}toRange(){return this.range}toSelector(){const normalizedRange=_textRange.TextRange.fromRange(this.range).toRange(),textRange=_textRange.TextRange.fromRange(normalizedRange),startContainer=(0,_xpath.xpathFromNode)(textRange.start.element,this.root),endContainer=(0,_xpath.xpathFromNode)(textRange.end.element,this.root);return{type:"RangeSelector",startContainer:startContainer,startOffset:textRange.start.offset,endContainer:endContainer,endOffset:textRange.end.offset}}}_exports.RangeAnchor=RangeAnchor;class TextPositionAnchor{constructor(root,start,end){this.root=root,this.start=start,this.end=end}static fromRange(root,range){const textRange=_textRange.TextRange.fromRange(range).relativeTo(root);return new TextPositionAnchor(root,textRange.start.offset,textRange.end.offset)}static fromSelector(root,selector){return new TextPositionAnchor(root,selector.start,selector.end)}toSelector(){return{type:"TextPositionSelector",start:this.start,end:this.end}}toRange(){return _textRange.TextRange.fromOffsets(this.root,this.start,this.end).toRange()}}_exports.TextPositionAnchor=TextPositionAnchor;class TextQuoteAnchor{constructor(root,exact){let context=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.root=root,this.exact=exact,this.context=context}static fromRange(root,range){const text=root.textContent,textRange=_textRange.TextRange.fromRange(range).relativeTo(root),start=textRange.start.offset,end=textRange.end.offset;return new TextQuoteAnchor(root,text.slice(start,end),{prefix:text.slice(Math.max(0,start-32),start),suffix:text.slice(end,Math.min(text.length,end+32))})}static fromSelector(root,selector){const{prefix:prefix,suffix:suffix}=selector;return new TextQuoteAnchor(root,selector.exact,{prefix:prefix,suffix:suffix})}toSelector(){return{type:"TextQuoteSelector",exact:this.exact,prefix:this.context.prefix,suffix:this.context.suffix}}toRange(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.toPositionAnchor(options).toRange()}toPositionAnchor(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const text=this.root.textContent,match=(0,_matchQuote.matchQuote)(text,this.exact,{...this.context,hint:options.hint});if(!match)throw new Error("Quote not found");return new TextPositionAnchor(this.root,match.start,match.end)}}_exports.TextQuoteAnchor=TextQuoteAnchor}));
+
+//# sourceMappingURL=types.min.js.map
\ No newline at end of file
diff --git a/amd/build/types.min.js.map b/amd/build/types.min.js.map
new file mode 100644
index 0000000..51b6a10
--- /dev/null
+++ b/amd/build/types.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"types.min.js","sources":["../src/types.js"],"sourcesContent":["/**\n * This module exports a set of classes for converting between DOM `Range`\n * objects and different types of selectors. It is mostly a thin wrapper around a\n * set of anchoring libraries. It serves two main purposes:\n *\n * 1. Providing a consistent interface across different types of anchors.\n * 2. Insulating the rest of the code from API changes in the underlying anchoring\n * libraries.\n *\n * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)\n * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),\n * sometimes referred to as the \"Simplified BSD License\".\n */\n\nimport {matchQuote} from './match-quote';\nimport {TextRange, TextPosition} from './text-range';\nimport {nodeFromXPath, xpathFromNode} from './xpath';\n\n/**\n * @typedef {import('../../types/api').RangeSelector} RangeSelector\n * @typedef {import('../../types/api').TextPositionSelector} TextPositionSelector\n * @typedef {import('../../types/api').TextQuoteSelector} TextQuoteSelector\n */\n\n/**\n * Converts between `RangeSelector` selectors and `Range` objects.\n */\nexport class RangeAnchor {\n /**\n * @param {Node} root - A root element from which to anchor.\n * @param {Range} range - A range describing the anchor.\n */\n constructor(root, range) {\n this.root = root;\n this.range = range;\n }\n\n /**\n * @param {Node} root - A root element from which to anchor.\n * @param {Range} range - A range describing the anchor.\n * @return {RangeAnchor}\n */\n static fromRange(root, range) {\n return new RangeAnchor(root, range);\n }\n\n /**\n * Create an anchor from a serialized `RangeSelector` selector.\n *\n * @param {Element} root - A root element from which to anchor.\n * @param {RangeSelector} selector\n * @return {RangeAnchor}\n */\n static fromSelector(root, selector) {\n\n const startContainer = nodeFromXPath(selector.startContainer, root);\n\n if (!startContainer) {\n throw new Error('Failed to resolve startContainer XPath');\n }\n\n const endContainer = nodeFromXPath(selector.endContainer, root);\n if (!endContainer) {\n throw new Error('Failed to resolve endContainer XPath');\n }\n\n const startPos = TextPosition.fromCharOffset(\n startContainer,\n selector.startOffset\n );\n const endPos = TextPosition.fromCharOffset(\n endContainer,\n selector.endOffset\n );\n\n const range = new TextRange(startPos, endPos).toRange();\n return new RangeAnchor(root, range);\n }\n\n toRange() {\n return this.range;\n }\n\n /**\n * @return {RangeSelector}\n */\n toSelector() {\n // \"Shrink\" the range so that it tightly wraps its text. This ensures more\n // predictable output for a given text selection.\n\n const normalizedRange = TextRange.fromRange(this.range).toRange();\n\n const textRange = TextRange.fromRange(normalizedRange);\n const startContainer = xpathFromNode(textRange.start.element, this.root);\n const endContainer = xpathFromNode(textRange.end.element, this.root);\n\n return {\n type: 'RangeSelector',\n startContainer,\n startOffset: textRange.start.offset,\n endContainer,\n endOffset: textRange.end.offset,\n };\n }\n}\n\n/**\n * Converts between `TextPositionSelector` selectors and `Range` objects.\n */\nexport class TextPositionAnchor {\n /**\n * @param {Element} root\n * @param {number} start\n * @param {number} end\n */\n constructor(root, start, end) {\n this.root = root;\n this.start = start;\n this.end = end;\n }\n\n /**\n * @param {Element} root\n * @param {Range} range\n * @return {TextPositionAnchor}\n */\n static fromRange(root, range) {\n const textRange = TextRange.fromRange(range).relativeTo(root);\n return new TextPositionAnchor(\n root,\n textRange.start.offset,\n textRange.end.offset\n );\n }\n /**\n * @param {Element} root\n * @param {TextPositionSelector} selector\n * @return {TextPositionAnchor}\n */\n static fromSelector(root, selector) {\n return new TextPositionAnchor(root, selector.start, selector.end);\n }\n\n /**\n * @return {TextPositionSelector}\n */\n toSelector() {\n return {\n type: 'TextPositionSelector',\n start: this.start,\n end: this.end,\n };\n }\n\n toRange() {\n return TextRange.fromOffsets(this.root, this.start, this.end).toRange();\n }\n}\n\n/**\n * @typedef QuoteMatchOptions\n * @prop {number} [hint] - Expected position of match in text. See `matchQuote`.\n */\n\n/**\n * Converts between `TextQuoteSelector` selectors and `Range` objects.\n */\nexport class TextQuoteAnchor {\n /**\n * @param {Element} root - A root element from which to anchor.\n * @param {string} exact\n * @param {Object} context\n * @param {string} [context.prefix]\n * @param {string} [context.suffix]\n */\n constructor(root, exact, context = {}) {\n this.root = root;\n this.exact = exact;\n this.context = context;\n }\n\n /**\n * Create a `TextQuoteAnchor` from a range.\n *\n * Will throw if `range` does not contain any text nodes.\n *\n * @param {Element} root\n * @param {Range} range\n * @return {TextQuoteAnchor}\n */\n static fromRange(root, range) {\n const text = /** @type {string} */ (root.textContent);\n const textRange = TextRange.fromRange(range).relativeTo(root);\n\n const start = textRange.start.offset;\n const end = textRange.end.offset;\n\n // Number of characters around the quote to capture as context. We currently\n // always use a fixed amount, but it would be better if this code was aware\n // of logical boundaries in the document (paragraph, article etc.) to avoid\n // capturing text unrelated to the quote.\n //\n // In regular prose the ideal content would often be the surrounding sentence.\n // This is a natural unit of meaning which enables displaying quotes in\n // context even when the document is not available. We could use `Intl.Segmenter`\n // for this when available.\n const contextLen = 32;\n\n return new TextQuoteAnchor(root, text.slice(start, end), {\n prefix: text.slice(Math.max(0, start - contextLen), start),\n suffix: text.slice(end, Math.min(text.length, end + contextLen)),\n });\n }\n\n /**\n * @param {Element} root\n * @param {TextQuoteSelector} selector\n * @return {TextQuoteAnchor}\n */\n static fromSelector(root, selector) {\n const {prefix, suffix} = selector;\n return new TextQuoteAnchor(root, selector.exact, {prefix, suffix});\n }\n\n /**\n * @return {TextQuoteSelector}\n */\n toSelector() {\n return {\n type: 'TextQuoteSelector',\n exact: this.exact,\n prefix: this.context.prefix,\n suffix: this.context.suffix,\n };\n }\n\n /**\n * @param {QuoteMatchOptions} [options]\n * @return {TextQuoteAnchor}\n */\n toRange(options = {}) {\n return this.toPositionAnchor(options).toRange();\n }\n\n /**\n * @param {QuoteMatchOptions} [options]\n * @return {TextPositionAnchor}\n */\n toPositionAnchor(options = {}) {\n const text = /** @type {string} */ (this.root.textContent);\n const match = matchQuote(text, this.exact, {\n ...this.context,\n hint: options.hint,\n });\n\n if (!match) {\n throw new Error('Quote not found');\n }\n\n return new TextPositionAnchor(this.root, match.start, match.end);\n }\n}\n"],"names":["RangeAnchor","constructor","root","range","this","static","selector","startContainer","nodeFromXPath","Error","endContainer","startPos","TextPosition","fromCharOffset","startOffset","endPos","endOffset","TextRange","toRange","toSelector","normalizedRange","fromRange","textRange","xpathFromNode","start","element","end","type","offset","_exports","TextPositionAnchor","relativeTo","fromOffsets","TextQuoteAnchor","exact","context","arguments","length","undefined","text","textContent","slice","prefix","Math","max","suffix","min","options","toPositionAnchor","match","matchQuote","hint"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/view.min.js b/amd/build/view.min.js
deleted file mode 100644
index e69de29..0000000
diff --git a/amd/build/view.min.js.map b/amd/build/view.min.js.map
deleted file mode 100644
index e69de29..0000000
diff --git a/amd/build/xpath.min.js b/amd/build/xpath.min.js
new file mode 100644
index 0000000..96f9de2
--- /dev/null
+++ b/amd/build/xpath.min.js
@@ -0,0 +1,3 @@
+define("mod_annopy/xpath",["exports"],(function(_exports){function getPathSegment(node){const name=function(node){const nodeName=node.nodeName.toLowerCase();let result=nodeName;return"#text"===nodeName&&(result="text()"),result}(node),pos=function(node){let pos=0,tmp=node;for(;tmp;)tmp.nodeName===node.nodeName&&(pos+=1),tmp=tmp.previousSibling;return pos}(node);return"".concat(name,"[").concat(pos,"]")}function nthChildOfType(element,nodeName,index){nodeName=nodeName.toUpperCase();let matchIndex=-1;for(let i=0;i1&&void 0!==arguments[1]?arguments[1]:document.body;try{return function(xpath,root){const isSimpleXPath=null!==xpath.match(/^(\/[A-Za-z0-9-]+(\[[0-9]+\])?)+$/);if(!isSimpleXPath)throw new Error("Expression is not a simple XPath");const segments=xpath.split("/");let element=root;segments.shift();for(let segment of segments){let elementName,elementIndex;const separatorPos=segment.indexOf("[");if(-1!==separatorPos){elementName=segment.slice(0,separatorPos);const indexStr=segment.slice(separatorPos+1,segment.indexOf("]"));if(elementIndex=parseInt(indexStr)-1,elementIndex<0)return null}else elementName=segment,elementIndex=0;const child=nthChildOfType(element,elementName,elementIndex);if(!child)return null;element=child}return element}(xpath,root)}catch(err){return document.evaluate("."+xpath,root,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue}},_exports.xpathFromNode=function(node,root){let xpath="",elem=node;for(;elem!==root;){if(!elem)throw new Error("Node is not a descendant of root");xpath=getPathSegment(elem)+"/"+xpath,elem=elem.parentNode}return xpath="/"+xpath,xpath=xpath.replace(/\/$/,""),xpath}}));
+
+//# sourceMappingURL=xpath.min.js.map
\ No newline at end of file
diff --git a/amd/build/xpath.min.js.map b/amd/build/xpath.min.js.map
new file mode 100644
index 0000000..21ed853
--- /dev/null
+++ b/amd/build/xpath.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"xpath.min.js","sources":["../src/xpath.js"],"sourcesContent":["/**\n * XPATH and DOM functions used for anchoring and highlighting.\n *\n * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)\n * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),\n * sometimes referred to as the \"Simplified BSD License\".\n */\n\n/**\n * Get the node name for use in generating an xpath expression.\n *\n * @param {Node} node\n * @return {string} - Name of the node\n */\nfunction getNodeName(node) {\n const nodeName = node.nodeName.toLowerCase();\n let result = nodeName;\n if (nodeName === '#text') {\n result = 'text()';\n }\n return result;\n}\n\n/**\n * Get the index of the node as it appears in its parent's child list\n *\n * @param {Node} node\n * @return {int} - Position of the node\n */\nfunction getNodePosition(node) {\n let pos = 0;\n /** @type {Node|null} */\n let tmp = node;\n while (tmp) {\n if (tmp.nodeName === node.nodeName) {\n pos += 1;\n }\n tmp = tmp.previousSibling;\n }\n return pos;\n}\n\n/**\n * Get the path segments to the node\n *\n * @param {Node} node\n * @return {array} - Path segments\n */\nfunction getPathSegment(node) {\n const name = getNodeName(node);\n const pos = getNodePosition(node);\n return `${name}[${pos}]`;\n}\n\n/**\n * A simple XPath generator which can generate XPaths of the form\n * /tag[index]/tag[index].\n *\n * @param {Node} node - The node to generate a path to\n * @param {Node} root - Root node to which the returned path is relative\n * @return {string} - The xpath of a node\n */\nexport function xpathFromNode(node, root) {\n let xpath = '';\n\n /** @type {Node|null} */\n let elem = node;\n while (elem !== root) {\n if (!elem) {\n throw new Error('Node is not a descendant of root');\n }\n xpath = getPathSegment(elem) + '/' + xpath;\n elem = elem.parentNode;\n }\n xpath = '/' + xpath;\n xpath = xpath.replace(/\\/$/, ''); // Remove trailing slash\n\n return xpath;\n}\n\n/**\n * Return the `index`'th immediate child of `element` whose tag name is\n * `nodeName` (case insensitive).\n *\n * @param {Element} element\n * @param {string} nodeName\n * @param {number} index\n * @return {Element} - The child element or null\n */\nfunction nthChildOfType(element, nodeName, index) {\n nodeName = nodeName.toUpperCase();\n\n let matchIndex = -1;\n for (let i = 0; i < element.children.length; i++) {\n const child = element.children[i];\n if (child.nodeName.toUpperCase() === nodeName) {\n ++matchIndex;\n if (matchIndex === index) {\n return child;\n }\n }\n }\n\n return null;\n}\n\n/**\n * Evaluate a _simple XPath_ relative to a `root` element and return the\n * matching element.\n *\n * A _simple XPath_ is a sequence of one or more `/tagName[index]` strings.\n *\n * Unlike `document.evaluate` this function:\n *\n * - Only supports simple XPaths\n * - Is not affected by the document's _type_ (HTML or XML/XHTML)\n * - Ignores element namespaces when matching element names in the XPath against\n * elements in the DOM tree\n * - Is case insensitive for all elements, not just HTML elements\n *\n * The matching element is returned or `null` if no such element is found.\n * An error is thrown if `xpath` is not a simple XPath.\n *\n * @param {string} xpath\n * @param {Element} root\n * @return {Element|null}\n */\nfunction evaluateSimpleXPath(xpath, root) {\n const isSimpleXPath =\n xpath.match(/^(\\/[A-Za-z0-9-]+(\\[[0-9]+\\])?)+$/) !== null;\n if (!isSimpleXPath) {\n throw new Error('Expression is not a simple XPath');\n }\n\n const segments = xpath.split('/');\n let element = root;\n\n // Remove leading empty segment. The regex above validates that the XPath\n // has at least two segments, with the first being empty and the others non-empty.\n segments.shift();\n\n for (let segment of segments) {\n let elementName;\n let elementIndex;\n\n const separatorPos = segment.indexOf('[');\n if (separatorPos !== -1) {\n elementName = segment.slice(0, separatorPos);\n\n const indexStr = segment.slice(separatorPos + 1, segment.indexOf(']'));\n elementIndex = parseInt(indexStr) - 1;\n if (elementIndex < 0) {\n return null;\n }\n } else {\n elementName = segment;\n elementIndex = 0;\n }\n\n const child = nthChildOfType(element, elementName, elementIndex);\n if (!child) {\n return null;\n }\n\n element = child;\n }\n\n return element;\n}\n\n/**\n * Finds an element node using an XPath relative to `root`\n *\n * Example:\n * node = nodeFromXPath('/main/article[1]/p[3]', document.body)\n *\n * @param {string} xpath\n * @param {Element} [root]\n * @return {Node|null}\n */\nexport function nodeFromXPath(xpath, root = document.body) {\n try {\n return evaluateSimpleXPath(xpath, root);\n } catch (err) {\n return document.evaluate(\n '.' + xpath,\n root,\n\n // Nb. The `namespaceResolver` and `result` arguments are optional in the spec but required in Edge Legacy.\n null /* NamespaceResolver */,\n XPathResult.FIRST_ORDERED_NODE_TYPE,\n null /* Result */\n ).singleNodeValue;\n }\n}\n"],"names":["getPathSegment","node","name","nodeName","toLowerCase","result","getNodeName","pos","tmp","previousSibling","getNodePosition","concat","nthChildOfType","element","index","toUpperCase","matchIndex","i","children","length","child","xpath","root","arguments","undefined","document","body","isSimpleXPath","match","Error","segments","split","shift","segment","elementName","elementIndex","separatorPos","indexOf","slice","indexStr","parseInt","evaluateSimpleXPath","err","evaluate","XPathResult","FIRST_ORDERED_NODE_TYPE","singleNodeValue","elem","parentNode","replace"],"mappings":"0DAgDA,SAASA,eAAeC,MACtB,MAAMC,KAnCR,SAAqBD,MACnB,MAAME,SAAWF,KAAKE,SAASC,cAC/B,IAAIC,OAASF,SAIb,MAHiB,UAAbA,WACFE,OAAS,UAEJA,MACT,CA4BeC,CAAYL,MACnBM,IArBR,SAAyBN,MACvB,IAAIM,IAAM,EAENC,IAAMP,KACV,KAAOO,KACDA,IAAIL,WAAaF,KAAKE,WACxBI,KAAO,GAETC,IAAMA,IAAIC,gBAEZ,OAAOF,GACT,CAUcG,CAAgBT,MAC5B,MAAA,GAAAU,OAAUT,KAAIS,KAAAA,OAAIJ,IAAG,IACvB,CAqCA,SAASK,eAAeC,QAASV,SAAUW,OACzCX,SAAWA,SAASY,cAEpB,IAAIC,YAAc,EAClB,IAAK,IAAIC,EAAI,EAAGA,EAAIJ,QAAQK,SAASC,OAAQF,IAAK,CAChD,MAAMG,MAAQP,QAAQK,SAASD,GAC/B,GAAIG,MAAMjB,SAASY,gBAAkBZ,aACjCa,WACEA,aAAeF,OACjB,OAAOM,KAGb,CAEA,OAAO,IACT,gFA4EO,SAAuBC,OAA6B,IAAtBC,KAAIC,UAAAJ,OAAAI,QAAAC,IAAAD,UAAAC,GAAAD,UAAGE,GAAAA,SAASC,KACnD,IACE,OAvDJ,SAA6BL,MAAOC,MAClC,MAAMK,cACiD,OAArDN,MAAMO,MAAM,qCACd,IAAKD,cACH,MAAM,IAAIE,MAAM,oCAGlB,MAAMC,SAAWT,MAAMU,MAAM,KAC7B,IAAIlB,QAAUS,KAIdQ,SAASE,QAET,IAAK,IAAIC,WAAWH,SAAU,CAC5B,IAAII,YACAC,aAEJ,MAAMC,aAAeH,QAAQI,QAAQ,KACrC,IAAsB,IAAlBD,aAAqB,CACvBF,YAAcD,QAAQK,MAAM,EAAGF,cAE/B,MAAMG,SAAWN,QAAQK,MAAMF,aAAe,EAAGH,QAAQI,QAAQ,MAEjE,GADAF,aAAeK,SAASD,UAAY,EAChCJ,aAAe,EACjB,OAAO,IAEX,MACED,YAAcD,QACdE,aAAe,EAGjB,MAAMf,MAAQR,eAAeC,QAASqB,YAAaC,cACnD,IAAKf,MACH,OAAO,KAGTP,QAAUO,KACZ,CAEA,OAAOP,OACT,CAcW4B,CAAoBpB,MAAOC,KACnC,CAAC,MAAOoB,KACP,OAAOjB,SAASkB,SACd,IAAMtB,MACNC,KAGA,KACAsB,YAAYC,wBACZ,MACAC,eACJ,CACF,yBApIO,SAAuB7C,KAAMqB,MAClC,IAAID,MAAQ,GAGR0B,KAAO9C,KACX,KAAO8C,OAASzB,MAAM,CACpB,IAAKyB,KACH,MAAM,IAAIlB,MAAM,oCAElBR,MAAQrB,eAAe+C,MAAQ,IAAM1B,MACrC0B,KAAOA,KAAKC,UACd,CAIA,OAHA3B,MAAQ,IAAMA,MACdA,MAAQA,MAAM4B,QAAQ,MAAO,IAEtB5B,KACT,CAoHC"}
\ No newline at end of file
diff --git a/amd/src/annotations.js b/amd/src/annotations.js
new file mode 100644
index 0000000..eeff6fa
--- /dev/null
+++ b/amd/src/annotations.js
@@ -0,0 +1,308 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Module for the annotation functions of the module.
+ *
+ * @module mod_annopy/annotations
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+import {removeAllTempHighlights, anchor, describe} from './highlighting';
+
+export const init = (cmid, canaddannotation, myuserid, focusannotation, userid) => {
+
+ var edited = false;
+ var annotations = Array();
+
+ var newannotation = false;
+
+ // Remove col-mds from moodle form.
+ $('.annotation-form div.col-md-3').removeClass('col-md-3');
+ $('.annotation-form div.col-md-9').removeClass('col-md-9');
+ $('.annotation-form div.form-group').removeClass('form-group');
+ $('.annotation-form div.row').removeClass('row');
+
+ // Onclick listener if form is canceled.
+ $(document).on('click', '.annopy_submission #id_cancel', function(e) {
+ e.preventDefault();
+
+ removeAllTempHighlights(); // Remove other temporary highlights.
+
+ resetForms(); // Remove old form contents.
+
+ edited = false;
+ });
+
+ // Listen for return key pressed to submit annotation form.
+ $('.annopy_submission textarea').keypress(function(e) {
+ if (e.which == 13) {
+ $(this).parents(':eq(2)').submit();
+ e.preventDefault();
+ }
+ });
+
+ // If user selects text for new annotation
+ $(document).on('mouseup', '.originaltext', function() {
+
+ var selectedrange = window.getSelection().getRangeAt(0);
+
+ if (selectedrange.cloneContents().textContent !== '' && canaddannotation) {
+
+ removeAllTempHighlights(); // Remove other temporary highlights.
+
+ resetForms(); // Reset the annotation forms.
+
+ // Create new annotation.
+ newannotation = createAnnotation(this);
+
+ var submission = this.id.replace(/submission-/, '');
+
+ // RangeSelector.
+ $('.annotation-form-' + submission + ' input[name="startcontainer"]').val(
+ newannotation.target[0].selector[0].startContainer);
+ $('.annotation-form-' + submission + ' input[name="endcontainer"]').val(
+ newannotation.target[0].selector[0].endContainer);
+ $('.annotation-form-' + submission + ' input[name="startoffset"]').val(
+ newannotation.target[0].selector[0].startOffset);
+ $('.annotation-form-' + submission + ' input[name="endoffset"]').val(
+ newannotation.target[0].selector[0].endOffset);
+
+ // TextPositionSelector.
+ $('.annotation-form-' + submission + ' input[name="annotationstart"]').val(
+ newannotation.target[0].selector[1].start);
+ $('.annotation-form-' + submission + ' input[name="annotationend"]').val(
+ newannotation.target[0].selector[1].end);
+
+ // TextQuoteSelector.
+ $('.annotation-form-' + submission + ' input[name="exact"]').val(
+ newannotation.target[0].selector[2].exact);
+ $('.annotation-form-' + submission + ' input[name="prefix"]').val(
+ newannotation.target[0].selector[2].prefix);
+ $('.annotation-form-' + submission + ' input[name="suffix"]').val(
+ newannotation.target[0].selector[2].suffix);
+
+ $('.annotation-form-' + submission + ' select').val(1);
+
+ // Prevent JavaScript injection (if annotated text in original submission is JavaScript code in script tags).
+ $('#annotationpreview-temp-' + submission).html(
+ newannotation.target[0].selector[2].exact.replaceAll('<', '<').replaceAll('>', '>'));
+
+ $('.annotationarea-' + submission + ' .annotation-form').show();
+ $('.annotation-form-' + submission + ' #id_text').focus();
+ }
+ });
+
+ // Fetch and recreate annotations.
+ $.ajax({
+ url: './annotations.php',
+ data: {'id': cmid, 'getannotations': 1, 'userid': userid},
+ success: function(response) {
+ annotations = JSON.parse(response);
+
+ recreateAnnotations();
+
+ // Highlight annotation and all annotated text if annotated text is hovered
+ $('.annotated').mouseenter(function() {
+ var id = this.id.replace('annotated-', '');
+ $('.annotation-box-' + id).addClass('hovered');
+ $('.annotated-' + id).css("background-color", 'lightblue');
+ });
+
+ $('.annotated').mouseleave(function() {
+ var id = this.id.replace('annotated-', '');
+ $('.annotation-box-' + id).removeClass('hovered');
+ $('.annotated-' + id).css("background-color", $('.annotated-' + id).css('textDecorationColor'));
+ });
+
+ // Highlight whole temp annotation if part of temp annotation is hovered
+ $(document).on('mouseover', '.annotated_temp', function() {
+ $('.annotated_temp').addClass('hovered');
+ });
+
+ $(document).on('mouseleave', '.annotated_temp', function() {
+ $('.annotated_temp').removeClass('hovered');
+ });
+
+ // Onclick listener for editing annotation.
+ $(document).on('click', '.annotated', function() {
+ var id = this.id.replace('annotated-', '');
+ editAnnotation(id);
+ });
+
+ // Onclick listener for editing annotation.
+ $(document).on('click', '.edit-annotation', function() {
+ var id = this.id.replace('edit-annotation-', '');
+ editAnnotation(id);
+ });
+
+ // Highlight annotation if hoverannotation button is hovered
+ $(document).on('mouseover', '.hoverannotation', function() {
+ var id = this.id.replace('hoverannotation-', '');
+ $('.annotated-' + id).css("background-color", 'lightblue');
+ });
+
+ $(document).on('mouseleave', '.hoverannotation', function() {
+ var id = this.id.replace('hoverannotation-', '');
+ $('.annotated-' + id).css("background-color", $('.annotated-' + id).css('textDecorationColor'));
+ });
+
+
+ // Focus annotation if needed.
+ if (focusannotation != 0) {
+ $('.annotated-' + focusannotation).attr('tabindex', -1);
+ $('.annotated-' + focusannotation).focus();
+ }
+
+ },
+ complete: function() {
+ $('#overlay').hide();
+ },
+ error: function() {
+ // For output: alert('Error fetching annotations');
+ }
+ });
+
+ /**
+ * Recreate annotations.
+ *
+ */
+ function recreateAnnotations() {
+
+ for (let annotation of Object.values(annotations)) {
+
+ const rangeSelectors = [[
+ {type: "RangeSelector", startContainer: annotation.startcontainer, startOffset: parseInt(annotation.startoffset),
+ endContainer: annotation.endcontainer, endOffset: parseInt(annotation.endoffset)},
+ {type: "TextPositionSelector", start: parseInt(annotation.annotationstart),
+ end: parseInt(annotation.annotationend)},
+ {type: "TextQuoteSelector", exact: annotation.exact, prefix: annotation.prefix, suffix: annotation.suffix}
+ ]];
+
+ const target = rangeSelectors.map(selectors => ({
+ selector: selectors,
+ }));
+
+ /** @type {AnnotationData} */
+ const newannotation = {
+ annotation: annotation,
+ target: target,
+ };
+
+ anchor(newannotation, $("#submission-" + annotation.submission)[0]);
+ }
+ }
+
+ /**
+ * Edit annotation.
+ *
+ * @param {int} annotationid
+ */
+ function editAnnotation(annotationid) {
+
+ if (edited == annotationid) {
+ removeAllTempHighlights(); // Remove other temporary highlights.
+ resetForms(); // Remove old form contents.
+ edited = false;
+ } else if (canaddannotation && myuserid == annotations[annotationid].userid) {
+ removeAllTempHighlights(); // Remove other temporary highlights.
+ resetForms(); // Remove old form contents.
+
+ edited = annotationid;
+
+ var submission = annotations[annotationid].submission;
+
+ $('.annotation-box-' + annotationid).hide(); // Hide edited annotation-box.
+
+ $('.annotation-form-' + submission + ' input[name="startcontainer"]').val(annotations[annotationid].startcontainer);
+ $('.annotation-form-' + submission + ' input[name="endcontainer"]').val(annotations[annotationid].endcontainer);
+ $('.annotation-form-' + submission + ' input[name="startoffset"]').val(annotations[annotationid].startoffset);
+ $('.annotation-form-' + submission + ' input[name="endoffset"]').val(annotations[annotationid].endoffset);
+ $('.annotation-form-' + submission + ' input[name="annotationstart"]').val(annotations[annotationid].annotationstart);
+ $('.annotation-form-' + submission + ' input[name="annotationend"]').val(annotations[annotationid].annotationend);
+ $('.annotation-form-' + submission + ' input[name="exact"]').val(annotations[annotationid].exact);
+ $('.annotation-form-' + submission + ' input[name="prefix"]').val(annotations[annotationid].prefix);
+ $('.annotation-form-' + submission + ' input[name="suffix"]').val(annotations[annotationid].suffix);
+
+ $('.annotation-form-' + submission + ' input[name="annotationid"]').val(annotationid);
+
+ $('.annotation-form-' + submission + ' textarea[name="text"]').val(annotations[annotationid].text);
+
+ $('.annotation-form-' + submission + ' select').val(annotations[annotationid].type);
+
+ // Prevent JavaScript injection (if annotated text in original submission is JavaScript code in script tags).
+ $('#annotationpreview-temp-' + submission).html(
+ annotations[annotationid].exact.replaceAll('<', '<').replaceAll('>', '>'));
+ $('#annotationpreview-temp-' + submission).css('border-color', '#' + annotations[annotationid].color);
+
+ $('.annotationarea-' + submission + ' .annotation-form').insertBefore('.annotation-box-' + annotationid);
+ $('.annotationarea-' + submission + ' .annotation-form').show();
+ $('.annotationarea-' + submission + ' #id_text').focus();
+ } else {
+ $('.annotation-box-' + annotationid).focus();
+ }
+ }
+
+ /**
+ * Reset all annotation forms
+ */
+ function resetForms() {
+ $('.annotation-form').hide();
+
+ $('.annotation-form input[name^="annotationid"]').val(null);
+
+ $('.annotation-form input[name^="startcontainer"]').val(-1);
+ $('.annotation-form input[name^="endcontainer"]').val(-1);
+ $('.annotation-form input[name^="startoffset"]').val(-1);
+ $('.annotation-form input[name^="endoffset"]').val(-1);
+
+ $('.annotation-form textarea[name^="text"]').val('');
+
+ $('.annotation-box').not('.annotation-form').show(); // To show again edited annotation.
+ }
+};
+
+/**
+ * Create a new annotation that is associated with the selected region of
+ * the current document.
+ *
+ * @param {object} root - The root element
+ * @return {object} - The new annotation
+ */
+function createAnnotation(root) {
+
+ const ranges = [window.getSelection().getRangeAt(0)];
+
+ if (ranges.collapsed) {
+ return null;
+ }
+
+ const rangeSelectors = ranges.map(range => describe(root, range));
+
+ const target = rangeSelectors.map(selectors => ({
+ selector: selectors,
+ }));
+
+ /** @type {AnnotationData} */
+ const annotation = {
+ target,
+ };
+
+ anchor(annotation, root);
+
+ return annotation;
+}
\ No newline at end of file
diff --git a/amd/src/colorpicker-layout.js b/amd/src/colorpicker-layout.js
new file mode 100644
index 0000000..384844b
--- /dev/null
+++ b/amd/src/colorpicker-layout.js
@@ -0,0 +1,40 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Module for layouting custom color picker element as default form element.
+ *
+ * @module mod_discourse/colorpicker-layout
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+
+export const init = (colorpickerid) => {
+ console.log('TEST!');
+ console.log(colorpickerid);
+ console.log($('.path-mod-annopy .mform fieldset #fitem' + colorpickerid));
+
+ $('.path-mod-annopy .mform fieldset #fitem_' + colorpickerid).addClass('row');
+ $('.path-mod-annopy .mform fieldset #fitem_' + colorpickerid).addClass('form-group');
+
+ $('.path-mod-annopy .mform fieldset .fitemtitle').addClass('col-md-3');
+ $('.path-mod-annopy .mform fieldset .fitemtitle').addClass('col-form-label');
+ $('.path-mod-annopy .mform fieldset .fitemtitle').addClass('d-flex');
+
+ $('.path-mod-annopy .mform fieldset .ftext').addClass('col-md-9');
+
+};
\ No newline at end of file
diff --git a/amd/src/highlighting.js b/amd/src/highlighting.js
new file mode 100644
index 0000000..fbd4d39
--- /dev/null
+++ b/amd/src/highlighting.js
@@ -0,0 +1,459 @@
+/**
+ * Functions for the highlighting and anchoring of annotations.
+ *
+ * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)
+ * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),
+ * sometimes referred to as the "Simplified BSD License".
+ */
+
+import $ from 'jquery';
+import {RangeAnchor, TextPositionAnchor, TextQuoteAnchor} from './types';
+import {TextRange} from './text-range';
+
+/**
+ * Get anchors for new annnotation.
+ *
+ * @param {Element} root
+ * @param {Range} range
+ * @return {object} - Array with the anchors.
+ */
+export function describe(root, range) {
+ const types = [RangeAnchor, TextPositionAnchor, TextQuoteAnchor];
+ const result = [];
+
+ for (let type of types) {
+ try {
+ const anchor = type.fromRange(root, range);
+
+ result.push(anchor.toSelector());
+ } catch (error) {
+ continue;
+ }
+ }
+ return result;
+}
+
+/**
+ * Anchor an annotation's selectors in the document.
+ *
+ * _Anchoring_ resolves a set of selectors to a concrete region of the document
+ * which is then highlighted.
+ *
+ * Any existing anchors associated with `annotation` will be removed before
+ * re-anchoring the annotation.
+ *
+ * @param {AnnotationData} annotation
+ * @param {obj} root
+ * @return {obj} achor object
+ */
+ export function anchor(annotation, root) {
+ /**
+ * Resolve an annotation's selectors to a concrete range.
+ *
+ * @param {Target} target
+ * @return {obj}
+ */
+ const locate = target => {
+
+ // Only annotations with an associated quote can currently be anchored.
+ // This is because the quote is used to verify anchoring with other selector
+ // types.
+ if (
+ !target.selector ||
+ !target.selector.some(s => s.type === 'TextQuoteSelector')
+ ) {
+ return {annotation, target};
+ }
+
+ /** @type {Anchor} */
+ let anchor;
+ try {
+ const range = htmlAnchor(root, target.selector);
+ // Convert the `Range` to a `TextRange` which can be converted back to
+ // a `Range` later. The `TextRange` representation allows for highlights
+ // to be inserted during anchoring other annotations without "breaking"
+ // this anchor.
+
+
+ const textRange = TextRange.fromRange(range);
+
+ anchor = {annotation, target, range: textRange};
+
+ } catch (err) {
+
+ anchor = {annotation, target};
+ }
+
+ return anchor;
+ };
+
+ /**
+ * Highlight the text range that `anchor` refers to.
+ *
+ * @param {Anchor} anchor
+ */
+ const highlight = anchor => {
+
+ const range = resolveAnchor(anchor);
+
+ if (!range) {
+ return;
+ }
+
+ let highlights = [];
+
+ if (annotation.annotation) {
+ highlights = highlightRange(range, annotation.annotation.id, 'annotated', annotation.annotation.color);
+ } else {
+ highlights = highlightRange(range, false, 'annotated_temp');
+ }
+
+ highlights.forEach(h => {
+ h._annotation = anchor.annotation;
+ });
+ anchor.highlights = highlights;
+
+ };
+
+ // Remove existing anchors for this annotation.
+ // this.detach(annotation, false /* notify */); // To be replaced by own method
+
+ // Resolve selectors to ranges and insert highlights.
+ if (!annotation.target) {
+ annotation.target = [];
+ }
+ const anchors = annotation.target.map(locate);
+
+ for (let anchor of anchors) {
+
+ highlight(anchor);
+ }
+
+ // Set flag indicating whether anchoring succeeded. For each target,
+ // anchoring is successful either if there are no selectors (ie. this is a
+ // Page Note) or we successfully resolved the selectors to a range.
+ annotation.$orphan =
+ anchors.length > 0 &&
+ anchors.every(anchor => anchor.target.selector && !anchor.range);
+
+ return anchors;
+}
+
+/**
+ * Resolve an anchor's associated document region to a concrete `Range`.
+ *
+ * This may fail if anchoring failed or if the document has been mutated since
+ * the anchor was created in a way that invalidates the anchor.
+ *
+ * @param {Anchor} anchor
+ * @return {Range|null}
+ */
+function resolveAnchor(anchor) {
+
+ if (!anchor.range) {
+ return null;
+ }
+ try {
+ return anchor.range.toRange();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Wraps the DOM Nodes within the provided range with a highlight
+ * element of the specified class and returns the highlight Elements.
+ *
+ * Modified for handling annotations.
+ *
+ * @param {Range} range - Range to be highlighted
+ * @param {int} annotationid - ID of annotation
+ * @param {string} cssClass - A CSS class to use for the highlight
+ * @param {string} color - Color of the highlighting
+ * @return {HighlightElement[]} - Elements wrapping text in `normedRange` to add a highlight effect
+ */
+ function highlightRange(range, annotationid = false, cssClass = 'annotated', color = 'FFFF00') {
+
+ const textNodes = wholeTextNodesInRange(range);
+
+ // Group text nodes into spans of adjacent nodes. If a group of text nodes are
+ // adjacent, we only need to create one highlight element for the group.
+ let textNodeSpans = [];
+ let prevNode = null;
+ let currentSpan = null;
+
+ textNodes.forEach(node => {
+ if (prevNode && prevNode.nextSibling === node) {
+ currentSpan.push(node);
+ } else {
+ currentSpan = [node];
+ textNodeSpans.push(currentSpan);
+ }
+ prevNode = node;
+ });
+
+ // Filter out text node spans that consist only of white space. This avoids
+ // inserting highlight elements in places that can only contain a restricted
+ // subset of nodes such as table rows and lists.
+ const whitespace = /^\s*$/;
+ textNodeSpans = textNodeSpans.filter(span =>
+ // Check for at least one text node with non-space content.
+ span.some(node => !whitespace.test(node.nodeValue))
+ );
+
+ // Wrap each text node span with a `` element.
+ const highlights = /** @type {HighlightElement[]} */ ([]);
+
+ textNodeSpans.forEach(nodes => {
+ const highlightEl = document.createElement('annopy-highlight');
+ highlightEl.className = cssClass;
+
+ if (annotationid) {
+ highlightEl.className += ' ' + cssClass + '-' + annotationid;
+ highlightEl.style = "text-decoration:underline; text-decoration-color: #" + color;
+ highlightEl.id = cssClass + '-' + annotationid;
+ highlightEl.style.backgroundColor = '#' + color;
+ }
+
+ const parent = /** @type {Node} */ (nodes[0].parentNode);
+ parent.replaceChild(highlightEl, nodes[0]);
+ nodes.forEach(node => highlightEl.appendChild(node));
+
+ highlights.push(highlightEl);
+
+ });
+
+ return highlights;
+}
+
+/**
+ * Return text nodes which are entirely inside `range`.
+ *
+ * If a range starts or ends part-way through a text node, the node is split
+ * and the part inside the range is returned.
+ *
+ * @param {Range} range
+ * @return {Text[]}
+ */
+ function wholeTextNodesInRange(range) {
+ if (range.collapsed) {
+ // Exit early for an empty range to avoid an edge case that breaks the algorithm
+ // below. Splitting a text node at the start of an empty range can leave the
+ // range ending in the left part rather than the right part.
+ return [];
+ }
+
+ /** @type {Node|null} */
+ let root = range.commonAncestorContainer;
+ if (root.nodeType !== Node.ELEMENT_NODE) {
+ // If the common ancestor is not an element, set it to the parent element to
+ // ensure that the loop below visits any text nodes generated by splitting
+ // the common ancestor.
+ //
+ // Note that `parentElement` may be `null`.
+ root = root.parentElement;
+ }
+
+ if (!root) {
+ // If there is no root element then we won't be able to insert highlights,
+ // so exit here.
+ return [];
+ }
+
+ const textNodes = [];
+ const nodeIter = /** @type {Document} */ (
+ root.ownerDocument
+ ).createNodeIterator(
+ root,
+ NodeFilter.SHOW_TEXT // Only return `Text` nodes.
+ );
+ let node;
+ while ((node = nodeIter.nextNode())) {
+ if (!isNodeInRange(range, node)) {
+ continue;
+ }
+ let text = /** @type {Text} */ (node);
+
+ if (text === range.startContainer && range.startOffset > 0) {
+ // Split `text` where the range starts. The split will create a new `Text`
+ // node which will be in the range and will be visited in the next loop iteration.
+ text.splitText(range.startOffset);
+ continue;
+ }
+
+ if (text === range.endContainer && range.endOffset < text.data.length) {
+ // Split `text` where the range ends, leaving it as the part in the range.
+ text.splitText(range.endOffset);
+ }
+
+ textNodes.push(text);
+ }
+
+ return textNodes;
+}
+
+/**
+ * Returns true if any part of `node` lies within `range`.
+ *
+ * @param {Range} range
+ * @param {Node} node
+ * @return {bool} - If node is in range
+ */
+function isNodeInRange(range, node) {
+ try {
+ const length = node.nodeValue?.length ?? node.childNodes.length;
+ return (
+ // Check start of node is before end of range.
+ range.comparePoint(node, 0) <= 0 &&
+ // Check end of node is after start of range.
+ range.comparePoint(node, length) >= 0
+ );
+ } catch (e) {
+ // `comparePoint` may fail if the `range` and `node` do not share a common
+ // ancestor or `node` is a doctype.
+ return false;
+ }
+}
+
+/**
+ * @param {RangeAnchor|TextPositionAnchor|TextQuoteAnchor} anchor
+ * @param {Object} [options]
+ * @return {obj} - range
+ */
+ function querySelector(anchor, options = {}) {
+
+ return anchor.toRange(options);
+}
+
+/**
+ * Anchor a set of selectors.
+ *
+ * This function converts a set of selectors into a document range.
+ * It encapsulates the core anchoring algorithm, using the selectors alone or
+ * in combination to establish the best anchor within the document.
+ *
+ * @param {Element} root - The root element of the anchoring context.
+ * @param {Selector[]} selectors - The selectors to try.
+ * @param {Object} [options]
+ * @return {object} the query selector
+ */
+ function htmlAnchor(root, selectors, options = {}) {
+ let position = null;
+ let quote = null;
+ let range = null;
+
+ // Collect all the selectors
+ for (let selector of selectors) {
+ switch (selector.type) {
+ case 'TextPositionSelector':
+ position = selector;
+ options.hint = position.start; // TextQuoteAnchor hint
+ break;
+ case 'TextQuoteSelector':
+ quote = selector;
+ break;
+ case 'RangeSelector':
+ range = selector;
+ break;
+ }
+ }
+
+ /**
+ * Assert the quote matches the stored quote, if applicable
+ * @param {Range} range
+ * @return {Range} range
+ */
+ const maybeAssertQuote = range => {
+
+ if (quote?.exact && range.toString() !== quote.exact) {
+ throw new Error('quote mismatch');
+ } else {
+ return range;
+ }
+ };
+
+ let queryselector = false;
+
+ try {
+ if (range) {
+
+ let anchor = RangeAnchor.fromSelector(root, range);
+
+ queryselector = querySelector(anchor, options);
+
+ if (queryselector) {
+ return queryselector;
+ } else {
+ return maybeAssertQuote;
+ }
+ }
+ } catch (error) {
+ try {
+ if (position) {
+
+ let anchor = TextPositionAnchor.fromSelector(root, position);
+
+ queryselector = querySelector(anchor, options);
+ if (queryselector) {
+ return queryselector;
+ } else {
+ return maybeAssertQuote;
+ }
+ }
+ } catch (error) {
+ try {
+ if (quote) {
+
+ let anchor = TextQuoteAnchor.fromSelector(root, quote);
+
+ queryselector = querySelector(anchor, options);
+
+ return queryselector;
+ }
+ } catch (error) {
+ return false;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * Remove all temporary highlights under a given root element.
+ */
+ export function removeAllTempHighlights() {
+ const highlights = Array.from($('body')[0].querySelectorAll('.annotated_temp'));
+ if (highlights !== undefined && highlights.length != 0) {
+ removeHighlights(highlights);
+ }
+}
+
+/**
+ * Remove highlights from a range previously highlighted with `highlightRange`.
+ *
+ * @param {HighlightElement[]} highlights - The highlight elements returned by `highlightRange`
+ */
+ function removeHighlights(highlights) {
+
+ for (var i = 0; i < highlights.length; i++) {
+ if (highlights[i].parentNode) {
+ const children = Array.from(highlights[i].childNodes);
+ replaceWith(highlights[i], children);
+ }
+ }
+}
+
+/**
+ * Replace a child `node` with `replacements`.
+ *
+ * nb. This is like `ChildNode.replaceWith` but it works in older browsers.
+ *
+ * @param {ChildNode} node
+ * @param {Node[]} replacements
+ */
+function replaceWith(node, replacements) {
+ const parent = /** @type {Node} */ (node.parentNode);
+
+ replacements.forEach(r => parent.insertBefore(r, node));
+ node.remove();
+}
\ No newline at end of file
diff --git a/amd/src/match-quote.js b/amd/src/match-quote.js
new file mode 100644
index 0000000..be6a5f6
--- /dev/null
+++ b/amd/src/match-quote.js
@@ -0,0 +1,174 @@
+/**
+ * Functions for quote matching for the annotations and highlighting.
+ *
+ * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)
+ * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),
+ * sometimes referred to as the "Simplified BSD License".
+ */
+
+import approxSearch from './string-match';
+
+/**
+ * @typedef {import('approx-string-match').Match} StringMatch
+ */
+
+/**
+ * @typedef Match
+ * @prop {number} start - Start offset of match in text
+ * @prop {number} end - End offset of match in text
+ * @prop {number} score -
+ * Score for the match between 0 and 1.0, where 1.0 indicates a perfect match
+ * for the quote and context.
+ */
+
+/**
+ * Find the best approximate matches for `str` in `text` allowing up to `maxErrors` errors.
+ *
+ * @param {string} text
+ * @param {string} str
+ * @param {number} maxErrors
+ * @return {StringMatch[]}
+ */
+function search(text, str, maxErrors) {
+ // Do a fast search for exact matches. The `approx-string-match` library
+ // doesn't currently incorporate this optimization itself.
+ let matchPos = 0;
+ let exactMatches = [];
+ while (matchPos !== -1) {
+ matchPos = text.indexOf(str, matchPos);
+ if (matchPos !== -1) {
+ exactMatches.push({
+ start: matchPos,
+ end: matchPos + str.length,
+ errors: 0,
+ });
+ matchPos += 1;
+ }
+ }
+ if (exactMatches.length > 0) {
+ return exactMatches;
+ }
+
+ // If there are no exact matches, do a more expensive search for matches
+ // with errors.
+ return approxSearch(text, str, maxErrors);
+}
+
+/**
+ * Compute a score between 0 and 1.0 for the similarity between `text` and `str`.
+ *
+ * @param {string} text
+ * @param {string} str
+ * @return {int}
+ */
+function textMatchScore(text, str) {
+ // `search` will return no matches if either the text or pattern is empty,
+ // otherwise it will return at least one match if the max allowed error count
+ // is at least `str.length`.
+ if (str.length === 0 || text.length === 0) {
+ return 0.0;
+ }
+
+ const matches = search(text, str, str.length);
+
+ // Prettier-ignore.
+ return 1 - (matches[0].errors / str.length);
+}
+
+/**
+ * Find the best approximate match for `quote` in `text`.
+ *
+ * Returns `null` if no match exceeding the minimum quality threshold was found.
+ *
+ * @param {string} text - Document text to search
+ * @param {string} quote - String to find within `text`
+ * @param {Object} context -
+ * Context in which the quote originally appeared. This is used to choose the
+ * best match.
+ * @param {string} [context.prefix] - Expected text before the quote
+ * @param {string} [context.suffix] - Expected text after the quote
+ * @param {number} [context.hint] - Expected offset of match within text
+ * @return {Match|null}
+ */
+export function matchQuote(text, quote, context = {}) {
+ if (quote.length === 0) {
+ return null;
+ }
+
+ // Choose the maximum number of errors to allow for the initial search.
+ // This choice involves a tradeoff between:
+ //
+ // - Recall (proportion of "good" matches found)
+ // - Precision (proportion of matches found which are "good")
+ // - Cost of the initial search and of processing the candidate matches [1]
+ //
+ // [1] Specifically, the expected-time complexity of the initial search is
+ // `O((maxErrors / 32) * text.length)`. See `approx-string-match` docs.
+ const maxErrors = Math.min(256, quote.length / 2);
+
+ // Find closest matches for `quote` in `text` based on edit distance.
+ const matches = search(text, quote, maxErrors);
+
+ if (matches.length === 0) {
+ return null;
+ }
+
+ /**
+ * Compute a score between 0 and 1.0 for a match candidate.
+ *
+ * @param {StringMatch} match
+ * @return {int}
+ */
+ const scoreMatch = match => {
+ const quoteWeight = 50; // Similarity of matched text to quote.
+ const prefixWeight = 20; // Similarity of text before matched text to `context.prefix`.
+ const suffixWeight = 20; // Similarity of text after matched text to `context.suffix`.
+ const posWeight = 2; // Proximity to expected location. Used as a tie-breaker.
+
+ const quoteScore = 1 - match.errors / quote.length;
+
+ const prefixScore = context.prefix
+ ? textMatchScore(
+ text.slice(
+ Math.max(0, match.start - context.prefix.length),
+ match.start
+ ),
+ context.prefix
+ )
+ : 1.0;
+ const suffixScore = context.suffix
+ ? textMatchScore(
+ text.slice(match.end, match.end + context.suffix.length),
+ context.suffix
+ )
+ : 1.0;
+
+ let posScore = 1.0;
+ if (typeof context.hint === 'number') {
+ const offset = Math.abs(match.start - context.hint);
+ posScore = 1.0 - offset / text.length;
+ }
+
+ const rawScore =
+ quoteWeight * quoteScore +
+ prefixWeight * prefixScore +
+ suffixWeight * suffixScore +
+ posWeight * posScore;
+ const maxScore = quoteWeight + prefixWeight + suffixWeight + posWeight;
+ const normalizedScore = rawScore / maxScore;
+
+ return normalizedScore;
+ };
+
+ // Rank matches based on similarity of actual and expected surrounding text
+ // and actual/expected offset in the document text.
+ const scoredMatches = matches.map(m => ({
+ start: m.start,
+ end: m.end,
+ score: scoreMatch(m),
+ }));
+
+ // Choose match with highest score.
+ scoredMatches.sort((a, b) => b.score - a.score);
+ return scoredMatches[0];
+}
diff --git a/amd/src/string-match.js b/amd/src/string-match.js
new file mode 100644
index 0000000..8637003
--- /dev/null
+++ b/amd/src/string-match.js
@@ -0,0 +1,339 @@
+/**
+ * Functions for string matching used by the other methods.
+ *
+ * This code originaly is from the approx-string-match project (https://github.com/robertknight/approx-string-match-js)
+ * by Robert Knight wich is released under the MIT License (https://opensource.org/licenses/MIT).
+ */
+
+/**
+ * Represents a match returned by a call to `search`.
+ * @param {string} s - Document text to search
+ * @return {string}
+ */
+ function reverse(s) {
+ return s.split("").reverse().join("");
+ }
+
+ /**
+ * Given the ends of approximate matches for `pattern` in `text`, find
+ * the start of the matches.
+ *
+ * @param {string} text
+ * @param {string} pattern
+ * @param {array} matches
+ * @return {obj} Matches with the `start` property set.
+ */
+ function findMatchStarts(text, pattern, matches) {
+ const patRev = reverse(pattern);
+
+ return matches.map((m) => {
+ // Find start of each match by reversing the pattern and matching segment
+ // of text and searching for an approx match with the same number of
+ // errors.
+ const minStart = Math.max(0, m.end - pattern.length - m.errors);
+ const textRev = reverse(text.slice(minStart, m.end));
+
+ // If there are multiple possible start points, choose the one that
+ // maximizes the length of the match.
+ const start = findMatchEnds(textRev, patRev, m.errors).reduce((min, rm) => {
+ if (m.end - rm.end < min) {
+ return m.end - rm.end;
+ }
+ return min;
+ }, m.end);
+
+ return {
+ start,
+ end: m.end,
+ errors: m.errors,
+ };
+ });
+ }
+
+ /**
+ * Internal context used when calculating blocks of a column.
+ */
+ // interface Context {
+ // /**
+ // * Bit-arrays of positive vertical deltas.
+ // *
+ // * ie. `P[b][i]` is set if the vertical delta for the i'th row in the b'th
+ // * block is positive.
+ // */
+ // P: Uint32Array;
+ // /** Bit-arrays of negative vertical deltas. */
+ // M: Uint32Array;
+ // /** Bit masks with a single bit set indicating the last row in each block. */
+ // lastRowMask: Uint32Array;
+ // }
+
+ /**
+ * Return 1 if a number is non-zero or zero otherwise, without using
+ * conditional operators.
+ *
+ * This should get inlined into `advanceBlock` below by the JIT.
+ *
+ * Adapted from https://stackoverflow.com/a/3912218/434243
+ * @param {int} n
+ * @return {bool}
+ */
+ function oneIfNotZero(n) {
+ return ((n | -n) >> 31) & 1;
+ }
+
+ /**
+ * Block calculation step of the algorithm.
+ *
+ * From Fig 8. on p. 408 of [1], additionally optimized to replace conditional
+ * checks with bitwise operations as per Section 4.2.3 of [2].
+ *
+ * @param {obj} ctx - The pattern context object
+ * @param {array} peq - The `peq` array for the current character (`ctx.peq.get(ch)`)
+ * @param {int} b - The block level
+ * @param {obj} hIn - Horizontal input delta ∈ {1,0,-1}
+ * @return {obj} Horizontal output delta ∈ {1,0,-1}
+ */
+ function advanceBlock(ctx, peq, b, hIn) {
+ let pV = ctx.P[b];
+ let mV = ctx.M[b];
+ const hInIsNegative = hIn >>> 31; // 1 if hIn < 0 or 0 otherwise.
+ const eq = peq[b] | hInIsNegative;
+
+ // Step 1: Compute horizontal deltas.
+ const xV = eq | mV;
+ const xH = (((eq & pV) + pV) ^ pV) | eq;
+
+ let pH = mV | ~(xH | pV);
+ let mH = pV & xH;
+
+ // Step 2: Update score (value of last row of this block).
+ const hOut =
+ oneIfNotZero(pH & ctx.lastRowMask[b]) -
+ oneIfNotZero(mH & ctx.lastRowMask[b]);
+
+ // Step 3: Update vertical deltas for use when processing next char.
+ pH <<= 1;
+ mH <<= 1;
+
+ mH |= hInIsNegative;
+ pH |= oneIfNotZero(hIn) - hInIsNegative; // Set pH[0] if hIn > 0.
+
+ pV = mH | ~(xV | pH);
+ mV = pH & xV;
+
+ ctx.P[b] = pV;
+ ctx.M[b] = mV;
+
+ return hOut;
+ }
+
+ /**
+ * Find the ends and error counts for matches of `pattern` in `text`.
+ *
+ * Only the matches with the lowest error count are reported. Other matches
+ * with error counts <= maxErrors are discarded.
+ *
+ * This is the block-based search algorithm from Fig. 9 on p.410 of [1].
+ *
+ * @param {string} text
+ * @param {string} pattern
+ * @param {array} maxErrors
+ * @return {obj} Matches with the `start` property set.
+ */
+ function findMatchEnds(text, pattern, maxErrors) {
+ if (pattern.length === 0) {
+ return [];
+ }
+
+ // Clamp error count so we can rely on the `maxErrors` and `pattern.length`
+ // rows being in the same block below.
+ maxErrors = Math.min(maxErrors, pattern.length);
+
+ const matches = [];
+
+ // Word size.
+ const w = 32;
+
+ // Index of maximum block level.
+ const bMax = Math.ceil(pattern.length / w) - 1;
+
+ // Context used across block calculations.
+ const ctx = {
+ P: new Uint32Array(bMax + 1),
+ M: new Uint32Array(bMax + 1),
+ lastRowMask: new Uint32Array(bMax + 1),
+ };
+ ctx.lastRowMask.fill(1 << 31);
+ ctx.lastRowMask[bMax] = 1 << (pattern.length - 1) % w;
+
+ // Dummy "peq" array for chars in the text which do not occur in the pattern.
+ const emptyPeq = new Uint32Array(bMax + 1);
+
+ // Map of UTF-16 character code to bit vector indicating positions in the
+ // pattern that equal that character.
+ const peq = new Map();
+
+ // Version of `peq` that only stores mappings for small characters. This
+ // allows faster lookups when iterating through the text because a simple
+ // array lookup can be done instead of a hash table lookup.
+ const asciiPeq = [];
+ for (let i = 0; i < 256; i++) {
+ asciiPeq.push(emptyPeq);
+ }
+
+ // Calculate `ctx.peq` - a map of character values to bitmasks indicating
+ // positions of that character within the pattern, where each bit represents
+ // a position in the pattern.
+ for (let c = 0; c < pattern.length; c += 1) {
+ const val = pattern.charCodeAt(c);
+ if (peq.has(val)) {
+ // Duplicate char in pattern.
+ continue;
+ }
+
+ const charPeq = new Uint32Array(bMax + 1);
+ peq.set(val, charPeq);
+ if (val < asciiPeq.length) {
+ asciiPeq[val] = charPeq;
+ }
+
+ for (let b = 0; b <= bMax; b += 1) {
+ charPeq[b] = 0;
+
+ // Set all the bits where the pattern matches the current char (ch).
+ // For indexes beyond the end of the pattern, always set the bit as if the
+ // pattern contained a wildcard char in that position.
+ for (let r = 0; r < w; r += 1) {
+ const idx = b * w + r;
+ if (idx >= pattern.length) {
+ continue;
+ }
+
+ const match = pattern.charCodeAt(idx) === val;
+ if (match) {
+ charPeq[b] |= 1 << r;
+ }
+ }
+ }
+ }
+
+ // Index of last-active block level in the column.
+ let y = Math.max(0, Math.ceil(maxErrors / w) - 1);
+
+ // Initialize maximum error count at bottom of each block.
+ const score = new Uint32Array(bMax + 1);
+ for (let b = 0; b <= y; b += 1) {
+ score[b] = (b + 1) * w;
+ }
+ score[bMax] = pattern.length;
+
+ // Initialize vertical deltas for each block.
+ for (let b = 0; b <= y; b += 1) {
+ ctx.P[b] = ~0;
+ ctx.M[b] = 0;
+ }
+
+ // Process each char of the text, computing the error count for `w` chars of
+ // the pattern at a time.
+ for (let j = 0; j < text.length; j += 1) {
+ // Lookup the bitmask representing the positions of the current char from
+ // the text within the pattern.
+ const charCode = text.charCodeAt(j);
+ let charPeq;
+
+ if (charCode < asciiPeq.length) {
+ // Fast array lookup.
+ charPeq = asciiPeq[charCode];
+ } else {
+ // Slower hash table lookup.
+ charPeq = peq.get(charCode);
+ if (typeof charPeq === "undefined") {
+ charPeq = emptyPeq;
+ }
+ }
+
+ // Calculate error count for blocks that we definitely have to process for
+ // this column.
+ let carry = 0;
+ for (let b = 0; b <= y; b += 1) {
+ carry = advanceBlock(ctx, charPeq, b, carry);
+ score[b] += carry;
+ }
+
+ // Check if we also need to compute an additional block, or if we can reduce
+ // the number of blocks processed for the next column.
+ if (
+ score[y] - carry <= maxErrors &&
+ y < bMax &&
+ (charPeq[y + 1] & 1 || carry < 0)
+ ) {
+ // Error count for bottom block is under threshold, increase the number of
+ // blocks processed for this column & next by 1.
+ y += 1;
+
+ ctx.P[y] = ~0;
+ ctx.M[y] = 0;
+
+ let maxBlockScore;
+ if (y === bMax) {
+ const remainder = pattern.length % w;
+ maxBlockScore = remainder === 0 ? w : remainder;
+ } else {
+ maxBlockScore = w;
+ }
+
+ score[y] =
+ score[y - 1] +
+ maxBlockScore -
+ carry +
+ advanceBlock(ctx, charPeq, y, carry);
+ } else {
+ // Error count for bottom block exceeds threshold, reduce the number of
+ // blocks processed for the next column.
+ while (y > 0 && score[y] >= maxErrors + w) {
+ y -= 1;
+ }
+ }
+
+ // If error count is under threshold, report a match.
+ if (y === bMax && score[y] <= maxErrors) {
+ if (score[y] < maxErrors) {
+ // Discard any earlier, worse matches.
+ matches.splice(0, matches.length);
+ }
+
+ matches.push({
+ start: -1,
+ end: j + 1,
+ errors: score[y],
+ });
+
+ // Because `search` only reports the matches with the lowest error count,
+ // we can "ratchet down" the max error threshold whenever a match is
+ // encountered and thereby save a small amount of work for the remainder
+ // of the text.
+ maxErrors = score[y];
+ }
+ }
+
+ return matches;
+ }
+
+ /**
+ * Search for matches for `pattern` in `text` allowing up to `maxErrors` errors.
+ *
+ * Returns the start, and end positions and error counts for each lowest-cost
+ * match. Only the "best" matches are returned.
+ * @param {string} text
+ * @param {string} pattern
+ * @param {array} maxErrors
+ * @return {obj} Matches with the `start` property set.
+ */
+ export default function search(
+ text,
+ pattern,
+ maxErrors
+ ) {
+ const matches = findMatchEnds(text, pattern, maxErrors);
+ return findMatchStarts(text, pattern, matches);
+ }
\ No newline at end of file
diff --git a/amd/src/text-range.js b/amd/src/text-range.js
new file mode 100644
index 0000000..3f9d6d7
--- /dev/null
+++ b/amd/src/text-range.js
@@ -0,0 +1,346 @@
+/**
+ * Functions for handling text-ranges used by the other methods.
+ *
+ * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)
+ * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),
+ * sometimes referred to as the "Simplified BSD License".
+ */
+
+/**
+ * Return the combined length of text nodes contained in `node`.
+ *
+ * @param {Node} node
+ * @return {string}
+ */
+function nodeTextLength(node) {
+ switch (node.nodeType) {
+ case Node.ELEMENT_NODE:
+ case Node.TEXT_NODE:
+ // Nb. `textContent` excludes text in comments and processing instructions
+ // when called on a parent element, so we don't need to subtract that here.
+
+ return /** @type {string} */ (node.textContent).length;
+ default:
+ return 0;
+ }
+}
+
+/**
+ * Return the total length of the text of all previous siblings of `node`.
+ *
+ * @param {Node} node
+ * @return {int}
+ */
+function previousSiblingsTextLength(node) {
+ let sibling = node.previousSibling;
+ let length = 0;
+
+ while (sibling) {
+ length += nodeTextLength(sibling);
+ sibling = sibling.previousSibling;
+ }
+
+ return length;
+}
+
+/**
+ * Resolve one or more character offsets within an element to (text node, position)
+ * pairs.
+ *
+ * @param {Element} element
+ * @param {number[]} offsets - Offsets, which must be sorted in ascending order
+ * @return {{ node: Text, offset: number }[]}
+ */
+function resolveOffsets(element, ...offsets) {
+
+ let nextOffset = offsets.shift();
+ const nodeIter = /** @type {Document} */ (
+ element.ownerDocument
+ ).createNodeIterator(element, NodeFilter.SHOW_TEXT);
+ const results = [];
+
+ let currentNode = nodeIter.nextNode();
+ let textNode;
+ let length = 0;
+
+ // Find the text node containing the `nextOffset`th character from the start
+ // of `element`.
+ while (nextOffset !== undefined && currentNode) {
+ textNode = /** @type {Text} */ (currentNode);
+
+ if (length + textNode.data.length > nextOffset) {
+ results.push({node: textNode, offset: nextOffset - length});
+ nextOffset = offsets.shift();
+ } else {
+ currentNode = nodeIter.nextNode();
+ length += textNode.data.length;
+ }
+ }
+
+ // Boundary case.
+ while (nextOffset !== undefined && length === nextOffset) {
+ results.push({node: textNode, offset: textNode.data.length});
+ nextOffset = offsets.shift();
+ }
+
+ if (nextOffset !== undefined) {
+ throw new RangeError('Offset exceeds text length');
+ }
+
+ return results;
+}
+
+export let RESOLVE_FORWARDS = 1;
+export let RESOLVE_BACKWARDS = 2;
+
+/**
+ * Represents an offset within the text content of an element.
+ *
+ * This position can be resolved to a specific descendant node in the current
+ * DOM subtree of the element using the `resolve` method.
+ */
+export class TextPosition {
+ /**
+ * Construct a `TextPosition` that refers to the text position `offset` within
+ * the text content of `element`.
+ *
+ * @param {Element} element
+ * @param {number} offset
+ */
+ constructor(element, offset) {
+ if (offset < 0) {
+ throw new Error('Offset is invalid');
+ }
+
+ /** Element that `offset` is relative to. */
+ this.element = element;
+
+ /** Character offset from the start of the element's `textContent`. */
+ this.offset = offset;
+ }
+
+ /**
+ * Return a copy of this position with offset relative to a given ancestor
+ * element.
+ *
+ * @param {Element} parent - Ancestor of `this.element`
+ * @return {TextPosition}
+ */
+ relativeTo(parent) {
+ if (!parent.contains(this.element)) {
+ throw new Error('Parent is not an ancestor of current element');
+ }
+
+ let el = this.element;
+ let offset = this.offset;
+ while (el !== parent) {
+ offset += previousSiblingsTextLength(el);
+ el = /** @type {Element} */ (el.parentElement);
+ }
+
+ return new TextPosition(el, offset);
+ }
+
+ /**
+ * Resolve the position to a specific text node and offset within that node.
+ *
+ * Throws if `this.offset` exceeds the length of the element's text. In the
+ * case where the element has no text and `this.offset` is 0, the `direction`
+ * option determines what happens.
+ *
+ * Offsets at the boundary between two nodes are resolved to the start of the
+ * node that begins at the boundary.
+ *
+ * @param {Object} [options]
+ * @param {RESOLVE_FORWARDS|RESOLVE_BACKWARDS} [options.direction] -
+ * Specifies in which direction to search for the nearest text node if
+ * `this.offset` is `0` and `this.element` has no text. If not specified
+ * an error is thrown.
+ * @return {{ node: Text, offset: number }}
+ * @throws {RangeError}
+ */
+ resolve(options = {}) {
+ try {
+ return resolveOffsets(this.element, this.offset)[0];
+ } catch (err) {
+ if (this.offset === 0 && options.direction !== undefined) {
+ const tw = document.createTreeWalker(
+ this.element.getRootNode(),
+ NodeFilter.SHOW_TEXT
+ );
+ tw.currentNode = this.element;
+ const forwards = options.direction === RESOLVE_FORWARDS;
+ const text = /** @type {Text|null} */ (
+ forwards ? tw.nextNode() : tw.previousNode()
+ );
+ if (!text) {
+ throw err;
+ }
+ return {node: text, offset: forwards ? 0 : text.data.length};
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ /**
+ * Construct a `TextPosition` that refers to the `offset`th character within
+ * `node`.
+ *
+ * @param {Node} node
+ * @param {number} offset
+ * @return {TextPosition}
+ */
+ static fromCharOffset(node, offset) {
+ switch (node.nodeType) {
+ case Node.TEXT_NODE:
+ return TextPosition.fromPoint(node, offset);
+ case Node.ELEMENT_NODE:
+ return new TextPosition(/** @type {Element} */ (node), offset);
+ default:
+ throw new Error('Node is not an element or text node');
+ }
+ }
+
+ /**
+ * Construct a `TextPosition` representing the range start or end point (node, offset).
+ *
+ * @param {Node} node - Text or Element node
+ * @param {number} offset - Offset within the node.
+ * @return {TextPosition}
+ */
+ static fromPoint(node, offset) {
+
+ switch (node.nodeType) {
+ case Node.TEXT_NODE: {
+ if (offset < 0 || offset > /** @type {Text} */ (node).data.length) {
+ throw new Error('Text node offset is out of range');
+ }
+
+ if (!node.parentElement) {
+ throw new Error('Text node has no parent');
+ }
+
+ // Get the offset from the start of the parent element.
+ const textOffset = previousSiblingsTextLength(node) + offset;
+
+ return new TextPosition(node.parentElement, textOffset);
+ }
+ case Node.ELEMENT_NODE: {
+ if (offset < 0 || offset > node.childNodes.length) {
+ throw new Error('Child node offset is out of range');
+ }
+
+ // Get the text length before the `offset`th child of element.
+ let textOffset = 0;
+ for (let i = 0; i < offset; i++) {
+ textOffset += nodeTextLength(node.childNodes[i]);
+ }
+
+ return new TextPosition(/** @type {Element} */ (node), textOffset);
+ }
+ default:
+ throw new Error('Point is not in an element or text node');
+ }
+ }
+}
+
+/**
+ * Represents a region of a document as a (start, end) pair of `TextPosition` points.
+ *
+ * Representing a range in this way allows for changes in the DOM content of the
+ * range which don't affect its text content, without affecting the text content
+ * of the range itself.
+ */
+export class TextRange {
+ /**
+ * Construct an immutable `TextRange` from a `start` and `end` point.
+ *
+ * @param {TextPosition} start
+ * @param {TextPosition} end
+ */
+ constructor(start, end) {
+ this.start = start;
+ this.end = end;
+ }
+
+ /**
+ * Return a copy of this range with start and end positions relative to a
+ * given ancestor. See `TextPosition.relativeTo`.
+ *
+ * @param {Element} element
+ * @return {Range}
+ */
+ relativeTo(element) {
+ return new TextRange(
+ this.start.relativeTo(element),
+ this.end.relativeTo(element)
+ );
+ }
+
+ /**
+ * Resolve the `TextRange` to a DOM range.
+ *
+ * The resulting DOM Range will always start and end in a `Text` node.
+ * Hence `TextRange.fromRange(range).toRange()` can be used to "shrink" a
+ * range to the text it contains.
+ *
+ * May throw if the `start` or `end` positions cannot be resolved to a range.
+ *
+ * @return {Range}
+ */
+ toRange() {
+ let start;
+ let end;
+
+ if (
+ this.start.element === this.end.element &&
+ this.start.offset <= this.end.offset
+ ) {
+ // Fast path for start and end points in same element.
+ [start, end] = resolveOffsets(
+ this.start.element,
+ this.start.offset,
+ this.end.offset
+ );
+ } else {
+ start = this.start.resolve({direction: RESOLVE_FORWARDS});
+ end = this.end.resolve({direction: RESOLVE_BACKWARDS});
+ }
+
+ const range = new Range();
+ range.setStart(start.node, start.offset);
+ range.setEnd(end.node, end.offset);
+ return range;
+ }
+
+ /**
+ * Convert an existing DOM `Range` to a `TextRange`
+ *
+ * @param {Range} range
+ * @return {TextRange}
+ */
+ static fromRange(range) {
+ const start = TextPosition.fromPoint(
+ range.startContainer,
+ range.startOffset
+ );
+ const end = TextPosition.fromPoint(range.endContainer, range.endOffset);
+ return new TextRange(start, end);
+ }
+
+ /**
+ * Return a `TextRange` from the `start`th to `end`th characters in `root`.
+ *
+ * @param {Element} root
+ * @param {number} start
+ * @param {number} end
+ * @return {Range}
+ */
+ static fromOffsets(root, start, end) {
+ return new TextRange(
+ new TextPosition(root, start),
+ new TextPosition(root, end)
+ );
+ }
+}
diff --git a/amd/src/types.js b/amd/src/types.js
new file mode 100644
index 0000000..d77f530
--- /dev/null
+++ b/amd/src/types.js
@@ -0,0 +1,262 @@
+/**
+ * This module exports a set of classes for converting between DOM `Range`
+ * objects and different types of selectors. It is mostly a thin wrapper around a
+ * set of anchoring libraries. It serves two main purposes:
+ *
+ * 1. Providing a consistent interface across different types of anchors.
+ * 2. Insulating the rest of the code from API changes in the underlying anchoring
+ * libraries.
+ *
+ * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)
+ * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),
+ * sometimes referred to as the "Simplified BSD License".
+ */
+
+import {matchQuote} from './match-quote';
+import {TextRange, TextPosition} from './text-range';
+import {nodeFromXPath, xpathFromNode} from './xpath';
+
+/**
+ * @typedef {import('../../types/api').RangeSelector} RangeSelector
+ * @typedef {import('../../types/api').TextPositionSelector} TextPositionSelector
+ * @typedef {import('../../types/api').TextQuoteSelector} TextQuoteSelector
+ */
+
+/**
+ * Converts between `RangeSelector` selectors and `Range` objects.
+ */
+export class RangeAnchor {
+ /**
+ * @param {Node} root - A root element from which to anchor.
+ * @param {Range} range - A range describing the anchor.
+ */
+ constructor(root, range) {
+ this.root = root;
+ this.range = range;
+ }
+
+ /**
+ * @param {Node} root - A root element from which to anchor.
+ * @param {Range} range - A range describing the anchor.
+ * @return {RangeAnchor}
+ */
+ static fromRange(root, range) {
+ return new RangeAnchor(root, range);
+ }
+
+ /**
+ * Create an anchor from a serialized `RangeSelector` selector.
+ *
+ * @param {Element} root - A root element from which to anchor.
+ * @param {RangeSelector} selector
+ * @return {RangeAnchor}
+ */
+ static fromSelector(root, selector) {
+
+ const startContainer = nodeFromXPath(selector.startContainer, root);
+
+ if (!startContainer) {
+ throw new Error('Failed to resolve startContainer XPath');
+ }
+
+ const endContainer = nodeFromXPath(selector.endContainer, root);
+ if (!endContainer) {
+ throw new Error('Failed to resolve endContainer XPath');
+ }
+
+ const startPos = TextPosition.fromCharOffset(
+ startContainer,
+ selector.startOffset
+ );
+ const endPos = TextPosition.fromCharOffset(
+ endContainer,
+ selector.endOffset
+ );
+
+ const range = new TextRange(startPos, endPos).toRange();
+ return new RangeAnchor(root, range);
+ }
+
+ toRange() {
+ return this.range;
+ }
+
+ /**
+ * @return {RangeSelector}
+ */
+ toSelector() {
+ // "Shrink" the range so that it tightly wraps its text. This ensures more
+ // predictable output for a given text selection.
+
+ const normalizedRange = TextRange.fromRange(this.range).toRange();
+
+ const textRange = TextRange.fromRange(normalizedRange);
+ const startContainer = xpathFromNode(textRange.start.element, this.root);
+ const endContainer = xpathFromNode(textRange.end.element, this.root);
+
+ return {
+ type: 'RangeSelector',
+ startContainer,
+ startOffset: textRange.start.offset,
+ endContainer,
+ endOffset: textRange.end.offset,
+ };
+ }
+}
+
+/**
+ * Converts between `TextPositionSelector` selectors and `Range` objects.
+ */
+export class TextPositionAnchor {
+ /**
+ * @param {Element} root
+ * @param {number} start
+ * @param {number} end
+ */
+ constructor(root, start, end) {
+ this.root = root;
+ this.start = start;
+ this.end = end;
+ }
+
+ /**
+ * @param {Element} root
+ * @param {Range} range
+ * @return {TextPositionAnchor}
+ */
+ static fromRange(root, range) {
+ const textRange = TextRange.fromRange(range).relativeTo(root);
+ return new TextPositionAnchor(
+ root,
+ textRange.start.offset,
+ textRange.end.offset
+ );
+ }
+ /**
+ * @param {Element} root
+ * @param {TextPositionSelector} selector
+ * @return {TextPositionAnchor}
+ */
+ static fromSelector(root, selector) {
+ return new TextPositionAnchor(root, selector.start, selector.end);
+ }
+
+ /**
+ * @return {TextPositionSelector}
+ */
+ toSelector() {
+ return {
+ type: 'TextPositionSelector',
+ start: this.start,
+ end: this.end,
+ };
+ }
+
+ toRange() {
+ return TextRange.fromOffsets(this.root, this.start, this.end).toRange();
+ }
+}
+
+/**
+ * @typedef QuoteMatchOptions
+ * @prop {number} [hint] - Expected position of match in text. See `matchQuote`.
+ */
+
+/**
+ * Converts between `TextQuoteSelector` selectors and `Range` objects.
+ */
+export class TextQuoteAnchor {
+ /**
+ * @param {Element} root - A root element from which to anchor.
+ * @param {string} exact
+ * @param {Object} context
+ * @param {string} [context.prefix]
+ * @param {string} [context.suffix]
+ */
+ constructor(root, exact, context = {}) {
+ this.root = root;
+ this.exact = exact;
+ this.context = context;
+ }
+
+ /**
+ * Create a `TextQuoteAnchor` from a range.
+ *
+ * Will throw if `range` does not contain any text nodes.
+ *
+ * @param {Element} root
+ * @param {Range} range
+ * @return {TextQuoteAnchor}
+ */
+ static fromRange(root, range) {
+ const text = /** @type {string} */ (root.textContent);
+ const textRange = TextRange.fromRange(range).relativeTo(root);
+
+ const start = textRange.start.offset;
+ const end = textRange.end.offset;
+
+ // Number of characters around the quote to capture as context. We currently
+ // always use a fixed amount, but it would be better if this code was aware
+ // of logical boundaries in the document (paragraph, article etc.) to avoid
+ // capturing text unrelated to the quote.
+ //
+ // In regular prose the ideal content would often be the surrounding sentence.
+ // This is a natural unit of meaning which enables displaying quotes in
+ // context even when the document is not available. We could use `Intl.Segmenter`
+ // for this when available.
+ const contextLen = 32;
+
+ return new TextQuoteAnchor(root, text.slice(start, end), {
+ prefix: text.slice(Math.max(0, start - contextLen), start),
+ suffix: text.slice(end, Math.min(text.length, end + contextLen)),
+ });
+ }
+
+ /**
+ * @param {Element} root
+ * @param {TextQuoteSelector} selector
+ * @return {TextQuoteAnchor}
+ */
+ static fromSelector(root, selector) {
+ const {prefix, suffix} = selector;
+ return new TextQuoteAnchor(root, selector.exact, {prefix, suffix});
+ }
+
+ /**
+ * @return {TextQuoteSelector}
+ */
+ toSelector() {
+ return {
+ type: 'TextQuoteSelector',
+ exact: this.exact,
+ prefix: this.context.prefix,
+ suffix: this.context.suffix,
+ };
+ }
+
+ /**
+ * @param {QuoteMatchOptions} [options]
+ * @return {TextQuoteAnchor}
+ */
+ toRange(options = {}) {
+ return this.toPositionAnchor(options).toRange();
+ }
+
+ /**
+ * @param {QuoteMatchOptions} [options]
+ * @return {TextPositionAnchor}
+ */
+ toPositionAnchor(options = {}) {
+ const text = /** @type {string} */ (this.root.textContent);
+ const match = matchQuote(text, this.exact, {
+ ...this.context,
+ hint: options.hint,
+ });
+
+ if (!match) {
+ throw new Error('Quote not found');
+ }
+
+ return new TextPositionAnchor(this.root, match.start, match.end);
+ }
+}
diff --git a/amd/src/view.js b/amd/src/view.js
deleted file mode 100644
index 4019051..0000000
--- a/amd/src/view.js
+++ /dev/null
@@ -1,35 +0,0 @@
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle. If not, see .
-
-/**
- * JavaScript for the main page of the plugin.
- *
- * @module mod_annopy/view
- * @copyright 2023 coactum GmbH
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-import $ from 'jquery';
-import {get_string as getString} from 'core/str';
-
-export const init = (cmid) => {
- // getString('logintoannopy', 'mod_annopy')
- // .then(buttonString => {
- // $('.path-mod-annopy #id_submitbutton').attr('value', buttonString);
- // })
- // .catch();
-
- return cmid;
-};
\ No newline at end of file
diff --git a/amd/src/xpath.js b/amd/src/xpath.js
new file mode 100644
index 0000000..bec362b
--- /dev/null
+++ b/amd/src/xpath.js
@@ -0,0 +1,195 @@
+/**
+ * XPATH and DOM functions used for anchoring and highlighting.
+ *
+ * This code originaly is from the Hypothesis project (https://github.com/hypothesis/client)
+ * which is released under the 2-Clause BSD License (https://opensource.org/licenses/BSD-2-Clause),
+ * sometimes referred to as the "Simplified BSD License".
+ */
+
+/**
+ * Get the node name for use in generating an xpath expression.
+ *
+ * @param {Node} node
+ * @return {string} - Name of the node
+ */
+function getNodeName(node) {
+ const nodeName = node.nodeName.toLowerCase();
+ let result = nodeName;
+ if (nodeName === '#text') {
+ result = 'text()';
+ }
+ return result;
+}
+
+/**
+ * Get the index of the node as it appears in its parent's child list
+ *
+ * @param {Node} node
+ * @return {int} - Position of the node
+ */
+function getNodePosition(node) {
+ let pos = 0;
+ /** @type {Node|null} */
+ let tmp = node;
+ while (tmp) {
+ if (tmp.nodeName === node.nodeName) {
+ pos += 1;
+ }
+ tmp = tmp.previousSibling;
+ }
+ return pos;
+}
+
+/**
+ * Get the path segments to the node
+ *
+ * @param {Node} node
+ * @return {array} - Path segments
+ */
+function getPathSegment(node) {
+ const name = getNodeName(node);
+ const pos = getNodePosition(node);
+ return `${name}[${pos}]`;
+}
+
+/**
+ * A simple XPath generator which can generate XPaths of the form
+ * /tag[index]/tag[index].
+ *
+ * @param {Node} node - The node to generate a path to
+ * @param {Node} root - Root node to which the returned path is relative
+ * @return {string} - The xpath of a node
+ */
+export function xpathFromNode(node, root) {
+ let xpath = '';
+
+ /** @type {Node|null} */
+ let elem = node;
+ while (elem !== root) {
+ if (!elem) {
+ throw new Error('Node is not a descendant of root');
+ }
+ xpath = getPathSegment(elem) + '/' + xpath;
+ elem = elem.parentNode;
+ }
+ xpath = '/' + xpath;
+ xpath = xpath.replace(/\/$/, ''); // Remove trailing slash
+
+ return xpath;
+}
+
+/**
+ * Return the `index`'th immediate child of `element` whose tag name is
+ * `nodeName` (case insensitive).
+ *
+ * @param {Element} element
+ * @param {string} nodeName
+ * @param {number} index
+ * @return {Element} - The child element or null
+ */
+function nthChildOfType(element, nodeName, index) {
+ nodeName = nodeName.toUpperCase();
+
+ let matchIndex = -1;
+ for (let i = 0; i < element.children.length; i++) {
+ const child = element.children[i];
+ if (child.nodeName.toUpperCase() === nodeName) {
+ ++matchIndex;
+ if (matchIndex === index) {
+ return child;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Evaluate a _simple XPath_ relative to a `root` element and return the
+ * matching element.
+ *
+ * A _simple XPath_ is a sequence of one or more `/tagName[index]` strings.
+ *
+ * Unlike `document.evaluate` this function:
+ *
+ * - Only supports simple XPaths
+ * - Is not affected by the document's _type_ (HTML or XML/XHTML)
+ * - Ignores element namespaces when matching element names in the XPath against
+ * elements in the DOM tree
+ * - Is case insensitive for all elements, not just HTML elements
+ *
+ * The matching element is returned or `null` if no such element is found.
+ * An error is thrown if `xpath` is not a simple XPath.
+ *
+ * @param {string} xpath
+ * @param {Element} root
+ * @return {Element|null}
+ */
+function evaluateSimpleXPath(xpath, root) {
+ const isSimpleXPath =
+ xpath.match(/^(\/[A-Za-z0-9-]+(\[[0-9]+\])?)+$/) !== null;
+ if (!isSimpleXPath) {
+ throw new Error('Expression is not a simple XPath');
+ }
+
+ const segments = xpath.split('/');
+ let element = root;
+
+ // Remove leading empty segment. The regex above validates that the XPath
+ // has at least two segments, with the first being empty and the others non-empty.
+ segments.shift();
+
+ for (let segment of segments) {
+ let elementName;
+ let elementIndex;
+
+ const separatorPos = segment.indexOf('[');
+ if (separatorPos !== -1) {
+ elementName = segment.slice(0, separatorPos);
+
+ const indexStr = segment.slice(separatorPos + 1, segment.indexOf(']'));
+ elementIndex = parseInt(indexStr) - 1;
+ if (elementIndex < 0) {
+ return null;
+ }
+ } else {
+ elementName = segment;
+ elementIndex = 0;
+ }
+
+ const child = nthChildOfType(element, elementName, elementIndex);
+ if (!child) {
+ return null;
+ }
+
+ element = child;
+ }
+
+ return element;
+}
+
+/**
+ * Finds an element node using an XPath relative to `root`
+ *
+ * Example:
+ * node = nodeFromXPath('/main/article[1]/p[3]', document.body)
+ *
+ * @param {string} xpath
+ * @param {Element} [root]
+ * @return {Node|null}
+ */
+export function nodeFromXPath(xpath, root = document.body) {
+ try {
+ return evaluateSimpleXPath(xpath, root);
+ } catch (err) {
+ return document.evaluate(
+ '.' + xpath,
+ root,
+
+ // Nb. The `namespaceResolver` and `result` arguments are optional in the spec but required in Edge Legacy.
+ null /* NamespaceResolver */,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null /* Result */
+ ).singleNodeValue;
+ }
+}
diff --git a/annotation_form.php b/annotation_form.php
new file mode 100644
index 0000000..e5dc541
--- /dev/null
+++ b/annotation_form.php
@@ -0,0 +1,110 @@
+.
+
+/**
+ * File containing the class definition for the annotate form for the module.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once("$CFG->libdir/formslib.php");
+
+/**
+ * Form for annotations.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL Juv3 or later
+ */
+class mod_annopy_annotation_form extends moodleform {
+
+ /**
+ * Define the form - called by parent constructor
+ */
+ public function definition() {
+
+ global $OUTPUT;
+
+ $mform = $this->_form; // Don't forget the underscore!
+
+ $mform->addElement('hidden', 'id', null);
+ $mform->setType('id', PARAM_INT);
+
+ $mform->addElement('hidden', 'user', null);
+ $mform->setType('user', PARAM_INT);
+
+ $mform->addElement('hidden', 'submission', null);
+ $mform->setType('submission', PARAM_INT);
+
+ $mform->addElement('hidden', 'annotationmode', 1);
+ $mform->setType('annotationmode', PARAM_INT);
+
+ $mform->addElement('hidden', 'startcontainer', -1);
+ $mform->setType('startcontainer', PARAM_RAW);
+
+ $mform->addElement('hidden', 'endcontainer', -1);
+ $mform->setType('endcontainer', PARAM_RAW);
+
+ $mform->addElement('hidden', 'startoffset', -1);
+ $mform->setType('startoffset', PARAM_INT);
+
+ $mform->addElement('hidden', 'endoffset', -1);
+ $mform->setType('endoffset', PARAM_INT);
+
+ $mform->addElement('hidden', 'annotationstart', -1);
+ $mform->setType('annotationstart', PARAM_INT);
+
+ $mform->addElement('hidden', 'annotationend', -1);
+ $mform->setType('annotationend', PARAM_INT);
+
+ $mform->addElement('hidden', 'annotationid', null);
+ $mform->setType('annotationid', PARAM_INT);
+
+ $mform->addElement('hidden', 'exact', -1);
+ $mform->setType('exact', PARAM_RAW);
+
+ $mform->addElement('hidden', 'prefix', -1);
+ $mform->setType('prefix', PARAM_RAW);
+
+ $mform->addElement('hidden', 'suffix', -1);
+ $mform->setType('suffix', PARAM_RAW);
+
+ $select = $mform->addElement('select', 'type', '', $this->_customdata['types']);
+ $mform->setType('type', PARAM_INT);
+
+ $mform->addElement('textarea', 'text');
+ $mform->setType('text', PARAM_TEXT);
+
+ $this->add_action_buttons();
+
+ $mform->disable_form_change_checker();
+ }
+
+ /**
+ * Custom validation should be added here
+ * @param array $data Array with all the form data
+ * @param array $files Array with files submitted with form
+ * @return array Array with errors
+ */
+ public function validation($data, $files) {
+ return [];
+ }
+}
diff --git a/annotations.php b/annotations.php
new file mode 100644
index 0000000..adb863b
--- /dev/null
+++ b/annotations.php
@@ -0,0 +1,234 @@
+.
+
+/**
+ * File for handling the annotation form.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use core\output\notification;
+use mod_annopy\local\helper;
+
+require(__DIR__.'/../../config.php');
+
+global $DB, $CFG;
+
+// Course Module ID.
+$id = required_param('id', PARAM_INT);
+
+// Param if annotations should be returned via ajax.
+$getannotations = optional_param('getannotations', 0, PARAM_INT);
+
+// Param if annotation should be deleted.
+$deleteannotation = optional_param('deleteannotation', 0, PARAM_INT); // Annotation to be deleted.
+
+// The ID of the user whose annotations should be shown.
+$userid = optional_param('userid', 0, PARAM_INT);
+
+// Set the basic variables $course, $cm and $moduleinstance.
+if ($id) {
+ [$course, $cm] = get_course_and_cm_from_cmid($id, 'annopy');
+ $moduleinstance = $DB->get_record('annopy', ['id' => $cm->instance], '*', MUST_EXIST);
+} else {
+ throw new moodle_exception('missingparameter');
+}
+
+if (!$cm) {
+ throw new moodle_exception(get_string('incorrectmodule', 'annopy'));
+} else if (!$course) {
+ throw new moodle_exception(get_string('incorrectcourseid', 'annopy'));
+} else if (!$coursesections = $DB->get_record("course_sections", ["id" => $cm->section])) {
+ throw new moodle_exception(get_string('incorrectmodule', 'annopy'));
+}
+
+require_login($course, true, $cm);
+
+$context = context_module::instance($cm->id);
+
+$select = "annopy = " . $moduleinstance->id;
+$annotationtypes = (array) $DB->get_records_select('annopy_annotationtypes', $select, null, 'priority ASC');
+
+// Get annotation (ajax).
+if ($getannotations) {
+
+ if ($userid) {
+ $annotations = $DB->get_records('annopy_annotations', ['annopy' => $moduleinstance->id, 'userid' => $userid]);
+ } else {
+ $annotations = $DB->get_records('annopy_annotations', ['annopy' => $moduleinstance->id]);
+ }
+
+ $select = "annopy = " . $moduleinstance->id;
+
+ foreach ($annotations as $key => $annotation) {
+
+ if (!array_key_exists($annotation->type, $annotationtypes) &&
+ $DB->record_exists('annopy_annotationtypes', ['id' => $annotation->type])) {
+
+ $annotationtypes[$annotation->type] = $DB->get_record('annopy_annotationtypes', ['id' => $annotation->type]);
+ }
+
+ if (isset($annotationtypes[$annotation->type])) {
+ $annotations[$key]->color = $annotationtypes[$annotation->type]->color;
+ }
+
+ }
+
+ if ($annotations) {
+ echo json_encode($annotations);
+ } else {
+ echo json_encode([]);
+ }
+
+ die;
+}
+
+require_capability('mod/annopy:viewannotations', $context);
+
+// Header.
+$PAGE->set_url('/mod/annopy/annotations.php', ['id' => $id]);
+$PAGE->set_title(format_string($moduleinstance->name));
+
+$urlparams = ['id' => $id];
+
+$redirecturl = new moodle_url('/mod/annopy/view.php', $urlparams);
+
+// Delete annotation.
+if (has_capability('mod/annopy:deleteannotation', $context) && $deleteannotation !== 0) {
+ require_sesskey();
+
+ global $USER;
+
+ if ($DB->record_exists('annopy_annotations', ['id' => $deleteannotation, 'annopy' => $moduleinstance->id,
+ 'userid' => $USER->id])) {
+
+ $DB->delete_records('annopy_annotations', ['id' => $deleteannotation, 'annopy' => $moduleinstance->id,
+ 'userid' => $USER->id]);
+
+ // Trigger module annotation deleted event.
+ $event = \mod_annopy\event\annotation_deleted::create([
+ 'objectid' => $deleteannotation,
+ 'context' => $context,
+ ]);
+
+ $event->trigger();
+
+ redirect($redirecturl, get_string('annotationdeleted', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else {
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+}
+
+// Save annotation.
+require_once($CFG->dirroot . '/mod/annopy/annotation_form.php');
+
+// Instantiate form.
+$mform = new mod_annopy_annotation_form(null, ['types' => helper::get_annotationtypes_for_form($annotationtypes)]);
+
+if ($fromform = $mform->get_data()) {
+
+ // In this case you process validated data. $mform->get_data() returns data posted in form.
+ if ((isset($fromform->annotationid) && $fromform->annotationid !== 0) && isset($fromform->text)) { // Update annotation.
+ $annotation = $DB->get_record('annopy_annotations',
+ ['annopy' => $moduleinstance->id, 'submission' => $fromform->submission, 'id' => $fromform->annotationid]);
+
+ // Prevent changes by user in hidden form fields.
+ if (!$annotation) {
+ redirect($redirecturl, get_string('annotationinvalid', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ } else if ($annotation->userid != $USER->id) {
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ if (!isset($fromform->type)) {
+ redirect($redirecturl, get_string('annotationtypedeleted', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ $annotation->timemodified = time();
+ $annotation->text = format_text($fromform->text, 2, ['para' => false]);
+ $annotation->type = $fromform->type;
+
+ $DB->update_record('annopy_annotations', $annotation);
+
+ // Trigger module annotation updated event.
+ $event = \mod_annopy\event\annotation_updated::create([
+ 'objectid' => $fromform->annotationid,
+ 'context' => $context,
+ ]);
+
+ $event->trigger();
+
+ $urlparams = ['id' => $id, 'annotationmode' => 1, 'focusannotation' => $fromform->annotationid];
+ $redirecturl = new moodle_url('/mod/annopy/view.php', $urlparams);
+
+ redirect($redirecturl, get_string('annotationedited', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else if ((!isset($fromform->annotationid) || $fromform->annotationid === 0) && isset($fromform->text)) { // New annotation.
+
+ if ($fromform->startcontainer != -1 && $fromform->endcontainer != -1 &&
+ $fromform->startoffset != -1 && $fromform->endoffset != -1) {
+
+ if (!isset($fromform->type)) {
+ redirect($redirecturl, get_string('annotationtypedeleted', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ if (preg_match("/[^a-zA-Z0-9()-\/[\]]/", $fromform->startcontainer)
+ || preg_match("/[^a-zA-Z0-9()-\/[\]]/", $fromform->endcontainer)) {
+
+ redirect($redirecturl, get_string('annotationinvalid', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ if (!$DB->record_exists('annopy_submissions', ['annopy' => $moduleinstance->id, 'id' => $fromform->submission])) {
+ redirect($redirecturl, get_string('annotationinvalid', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ $annotation = new stdClass();
+ $annotation->annopy = (int) $moduleinstance->id;
+ $annotation->submission = (int) $fromform->submission;
+ $annotation->userid = $USER->id;
+ $annotation->timecreated = time();
+ $annotation->timemodified = 0;
+ $annotation->type = $fromform->type;
+ $annotation->startcontainer = $fromform->startcontainer;
+ $annotation->endcontainer = $fromform->endcontainer;
+ $annotation->startoffset = $fromform->startoffset;
+ $annotation->endoffset = $fromform->endoffset;
+ $annotation->annotationstart = $fromform->annotationstart;
+ $annotation->annotationend = $fromform->annotationend;
+ $annotation->exact = $fromform->exact;
+ $annotation->prefix = $fromform->prefix;
+ $annotation->suffix = $fromform->suffix;
+ $annotation->text = $fromform->text;
+
+ $newid = $DB->insert_record('annopy_annotations', $annotation);
+ // Trigger module annotation created event.
+ $event = \mod_annopy\event\annotation_created::create([
+ 'objectid' => $newid,
+ 'context' => $context,
+ ]);
+ $event->trigger();
+
+ $urlparams = ['id' => $id, 'annotationmode' => 1, 'focusannotation' => $newid];
+ $redirecturl = new moodle_url('/mod/annopy/view.php', $urlparams);
+
+ redirect($redirecturl, get_string('annotationadded', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else {
+ redirect($redirecturl, get_string('annotationinvalid', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+ }
+} else {
+ redirect($redirecturl, get_string('annotationinvalid', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+}
diff --git a/annotations_summary.php b/annotations_summary.php
new file mode 100644
index 0000000..6750900
--- /dev/null
+++ b/annotations_summary.php
@@ -0,0 +1,308 @@
+.
+
+/**
+ * Prints the annotation summary for the module.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_annopy\output\annopy_annotations_summary;
+use mod_annopy\local\helper;
+use core\output\notification;
+
+require(__DIR__.'/../../config.php');
+require_once(__DIR__.'/lib.php');
+
+// Course_module ID.
+$id = required_param('id', PARAM_INT);
+
+// ID of type that should be deleted.
+$delete = optional_param('delete', 0, PARAM_INT);
+
+// ID of type that should be deleted.
+$addtoannopy = optional_param('addtoannopy', 0, PARAM_INT);
+
+// ID of type where priority should be changed.
+$priority = optional_param('priority', 0, PARAM_INT);
+$action = optional_param('action', 0, PARAM_INT);
+
+// If template (1) or annopy (2) annotation type.
+$mode = optional_param('mode', null, PARAM_INT);
+
+// Set the basic variables $course, $cm and $moduleinstance.
+if ($id) {
+ [$course, $cm] = get_course_and_cm_from_cmid($id, 'annopy');
+ $moduleinstance = $DB->get_record('annopy', ['id' => $cm->instance], '*', MUST_EXIST);
+} else {
+ throw new moodle_exception('missingparameter');
+}
+
+if (!$cm) {
+ throw new moodle_exception(get_string('incorrectmodule', 'annopy'));
+} else if (!$course) {
+ throw new moodle_exception(get_string('incorrectcourseid', 'annopy'));
+} else if (!$coursesections = $DB->get_record("course_sections", ["id" => $cm->section])) {
+ throw new moodle_exception(get_string('incorrectmodule', 'annopy'));
+}
+
+require_login($course, true, $cm);
+
+$context = context_module::instance($cm->id);
+
+require_capability('mod/annopy:viewmyannotationsummary', $context);
+
+$canaddannotationtype = has_capability('mod/annopy:addannotationtype', $context);
+$caneditannotationtype = has_capability('mod/annopy:editannotationtype', $context);
+$candeleteannotationtype = has_capability('mod/annopy:deleteannotationtype', $context);
+
+$canaddannotationtypetemplate = has_capability('mod/annopy:addannotationtypetemplate', $context);
+$caneditannotationtypetemplate = has_capability('mod/annopy:editannotationtypetemplate', $context);
+$candeleteannotationtypetemplate = has_capability('mod/annopy:deleteannotationtypetemplate', $context);
+
+$select = "annopy = " . $moduleinstance->id;
+$annotationtypes = (array) $DB->get_records_select('annopy_annotationtypes', $select, null, 'priority ASC');
+
+global $USER;
+
+// Add type to annopy.
+if ($addtoannopy && $canaddannotationtype) {
+ require_sesskey();
+
+ $redirecturl = new moodle_url('/mod/annopy/annotations_summary.php', ['id' => $id]);
+
+ if ($DB->record_exists('annopy_atype_templates', ['id' => $addtoannopy])) {
+
+ global $USER;
+
+ $type = $DB->get_record('annopy_atype_templates', ['id' => $addtoannopy]);
+
+ if ($type->defaulttype == 1 || ($type->defaulttype == 0 && $type->userid == $USER->id)) {
+
+ if ($annotationtypes) {
+ $type->priority = $annotationtypes[array_key_last($annotationtypes)]->priority + 1;
+ } else {
+ $type->priority = 1;
+ }
+
+ $type->annopy = $moduleinstance->id;
+
+ $DB->insert_record('annopy_annotationtypes', $type);
+
+ redirect($redirecturl, get_string('annotationtypeadded', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else {
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+ } else {
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+}
+
+// Change priority.
+if ($caneditannotationtype && $mode == 2 && $priority && $action &&
+ $DB->record_exists('annopy_annotationtypes', ['id' => $priority])) {
+
+ require_sesskey();
+
+ $redirecturl = new moodle_url('/mod/annopy/annotations_summary.php', ['id' => $id]);
+
+ $type = $DB->get_record('annopy_annotationtypes', ['annopy' => $moduleinstance->id, 'id' => $priority]);
+
+ $oldpriority = 0;
+
+ // Increase priority (show more in front).
+ if ($type && $action == 1 && $type->priority != $annotationtypes[array_key_first($annotationtypes)]->priority) {
+ $oldpriority = $type->priority;
+ $type->priority -= 1;
+
+ $typeswitched = $DB->get_record('annopy_annotationtypes',
+ ['annopy' => $moduleinstance->id, 'priority' => $type->priority]);
+
+ if (!$typeswitched) { // If no type with priority+1 search for types with hihgher priority values.
+ $typeswitched = $DB->get_records_select('annopy_annotationtypes',
+ "annopy = $moduleinstance->id AND priority < $type->priority", null, 'priority ASC');
+
+ if ($typeswitched && isset($typeswitched[array_key_first($typeswitched)])) {
+ $typeswitched = $typeswitched[array_key_first($typeswitched)];
+ }
+ }
+
+ } else if ($type && $action == 2 && $type->priority != $DB->count_records('annopy_annotationtypes',
+ ['annopy' => $moduleinstance->id]) + 1) { // Decrease priority (move further back).
+
+ $oldpriority = $type->priority;
+ $type->priority += 1;
+
+ $typeswitched = $DB->get_record('annopy_annotationtypes',
+ ['annopy' => $moduleinstance->id, 'priority' => $type->priority]);
+
+ if (!$typeswitched) { // If no type with priority+1 search for types with higher priority values.
+ $typeswitched = $DB->get_records_select('annopy_annotationtypes',
+ "annopy = $moduleinstance->id AND priority > $type->priority", null, 'priority ASC');
+
+ if ($typeswitched && isset($typeswitched[array_key_first($typeswitched)])) {
+ $typeswitched = $typeswitched[array_key_first($typeswitched)];
+ }
+ }
+ } else {
+ redirect($redirecturl, get_string('prioritynotchanged', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ if ($typeswitched) {
+ // Update priority for type.
+ $DB->update_record('annopy_annotationtypes', $type);
+
+ // Update priority for type that type is switched with.
+ $typeswitched->priority = $oldpriority;
+ $DB->update_record('annopy_annotationtypes', $typeswitched);
+
+ redirect($redirecturl, get_string('prioritychanged', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else {
+ redirect($redirecturl, get_string('prioritynotchanged', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+}
+
+// Delete annotation.
+if ($delete !== 0 && $mode) {
+
+ require_sesskey();
+
+ $redirecturl = new moodle_url('/mod/annopy/annotations_summary.php', ['id' => $id]);
+
+ if ($mode == 1) { // If type is template annotation type.
+ $table = 'annopy_atype_templates';
+ } else if ($mode == 2) { // If type is annopy annotation type.
+ $table = 'annopy_annotationtypes';
+ }
+
+ if ($DB->record_exists($table, ['id' => $delete])) {
+
+ $type = $DB->get_record($table, ['id' => $delete]);
+
+ if ($mode == 2 && $candeleteannotationtype ||
+ ($type->defaulttype == 1 && has_capability('mod/annopy:managedefaultannotationtypetemplates', $context)
+ && $candeleteannotationtypetemplate)
+ || ($type->defaulttype == 0 && $type->userid == $USER->id && $candeleteannotationtypetemplate)) {
+
+ $DB->delete_records($table, ['id' => $delete]);
+ redirect($redirecturl, get_string('annotationtypedeleted', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else {
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+ } else {
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+}
+
+// Get the name for this module instance.
+$modulename = format_string($moduleinstance->name, true, [
+ 'context' => $context,
+]);
+
+$PAGE->set_url('/mod/annopy/annotations_summary.php', ['id' => $cm->id]);
+$PAGE->navbar->add(get_string('annotationssummary', 'mod_annopy'));
+
+$PAGE->set_title(get_string('modulename', 'mod_annopy').': ' . $modulename);
+$PAGE->set_heading(format_string($course->fullname));
+$PAGE->set_context($context);
+
+if ($CFG->branch < 400) {
+ $PAGE->force_settings_menu();
+}
+
+echo $OUTPUT->header();
+
+if ($CFG->branch < 400) {
+ echo $OUTPUT->heading($modulename);
+
+ if ($moduleinstance->intro) {
+ echo $OUTPUT->box(format_module_intro('annopy', $moduleinstance, $cm->id), 'generalbox', 'intro');
+ }
+}
+
+$annotationtypesforform = helper::get_annotationtypes_for_form($annotationtypes);
+$participants = helper::get_annopy_participants($context, $moduleinstance, $annotationtypesforform);
+
+$strmanager = get_string_manager();
+$annotationstotalcount = 0;
+
+foreach ($annotationtypes as $i => $type) {
+ $annotationtypes[$i]->canbeedited = $caneditannotationtype;
+ $annotationtypes[$i]->canbedeleted = $candeleteannotationtype;
+
+ if (has_capability('mod/annopy:viewparticipants', $context)) {
+ $annotationtypes[$i]->totalcount = $DB->count_records('annopy_annotations',
+ ['annopy' => $moduleinstance->id, 'type' => $type->id]);
+ } else {
+ $annotationtypes[$i]->totalcount = $DB->count_records('annopy_annotations',
+ ['annopy' => $moduleinstance->id, 'type' => $type->id, 'userid' => $USER->id]);
+ }
+
+ $annotationstotalcount += $annotationtypes[$i]->totalcount;
+
+ if ($strmanager->string_exists($type->name, 'mod_annopy')) {
+ $annotationtypes[$i]->name = get_string($type->name, 'mod_annopy');
+ } else {
+ $annotationtypes[$i]->name = $type->name;
+ }
+}
+
+$annotationtypes = array_values($annotationtypes);
+
+global $USER;
+
+$annotationtypetemplates = helper::get_all_annotationtype_templates();
+foreach ($annotationtypetemplates as $id => $templatetype) {
+ if ($templatetype->defaulttype == 1) {
+ $annotationtypetemplates[$id]->type = get_string('standard', 'mod_annopy');
+
+ if (!has_capability('mod/annopy:managedefaultannotationtypetemplates', $context)) {
+ $annotationtypetemplates[$id]->canbeedited = false;
+ $annotationtypetemplates[$id]->canbedeleted = false;
+ } else {
+ $annotationtypetemplates[$id]->canbeedited = $caneditannotationtypetemplate;
+ $annotationtypetemplates[$id]->canbedeleted = $candeleteannotationtypetemplate;
+ }
+ } else {
+ $annotationtypetemplates[$id]->type = get_string('custom', 'mod_annopy');
+
+ if ($templatetype->userid === $USER->id) {
+ $annotationtypetemplates[$id]->canbeedited = $caneditannotationtypetemplate;
+ $annotationtypetemplates[$id]->canbedeleted = $candeleteannotationtypetemplate;
+ } else {
+ $annotationtypetemplates[$id]->canbeedited = false;
+ $annotationtypetemplates[$id]->canbedeleted = false;
+ }
+ }
+
+ if ($templatetype->defaulttype == 1 && $strmanager->string_exists($templatetype->name, 'mod_annopy')) {
+ $annotationtypetemplates[$id]->name = get_string($templatetype->name, 'mod_annopy');
+ } else {
+ $annotationtypetemplates[$id]->name = $templatetype->name;
+ }
+}
+
+$annotationtypetemplates = array_values($annotationtypetemplates);
+
+// Output page.
+$page = new annopy_annotations_summary($cm->id, $context, $participants, $annotationtypes, $annotationtypetemplates,
+ sesskey(), $annotationstotalcount);
+
+echo $OUTPUT->render($page);
+
+echo $OUTPUT->footer();
diff --git a/annotationtypes.php b/annotationtypes.php
new file mode 100644
index 0000000..df6b463
--- /dev/null
+++ b/annotationtypes.php
@@ -0,0 +1,274 @@
+.
+
+/**
+ * Prints the annotation type form for the module instance.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use core\output\notification;
+use mod_annopy\output\annopy_annotations_summary;
+
+require(__DIR__.'/../../config.php');
+require_once(__DIR__.'/lib.php');
+require_once($CFG->dirroot . '/mod/annopy/annotationtypes_form.php');
+
+// Course_module ID.
+$id = required_param('id', PARAM_INT);
+
+// If template (1) or annopy (2) annotation type.
+$mode = optional_param('mode', 1, PARAM_INT);
+
+// ID of type that should be edited.
+$edit = optional_param('edit', 0, PARAM_INT);
+
+// Set the basic variables $course, $cm and $moduleinstance.
+if ($id) {
+ [$course, $cm] = get_course_and_cm_from_cmid($id, 'annopy');
+ $moduleinstance = $DB->get_record('annopy', ['id' => $cm->instance], '*', MUST_EXIST);
+} else {
+ throw new moodle_exception('missingparameter');
+}
+
+if (!$cm) {
+ throw new moodle_exception(get_string('incorrectmodule', 'annopy'));
+} else if (!$course) {
+ throw new moodle_exception(get_string('incorrectcourseid', 'annopy'));
+} else if (!$coursesections = $DB->get_record("course_sections", ["id" => $cm->section])) {
+ throw new moodle_exception(get_string('incorrectmodule', 'annopy'));
+}
+
+require_login($course, true, $cm);
+
+$context = context_module::instance($cm->id);
+
+$redirecturl = new moodle_url('/mod/annopy/annotations_summary.php', ['id' => $id]);
+
+// Capabilities check.
+if (!$edit) { // If type or template should be added.
+ if ($mode == 1 && !(has_capability('mod/annopy:addannotationtypetemplate', $context))) { // If no permission to add template.
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ } else if ($mode == 2 && !(has_capability('mod/annopy:addannotationtype', $context))) { // If no permission to add type.
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+} else if ($edit !== 0) {
+ if ($mode == 1 && !(has_capability('mod/annopy:editannotationtypetemplate', $context))) { // If no permission to edit template.
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ } else if ($mode == 2 && !(has_capability('mod/annopy:editannotationtype', $context))) { // If no permission to edit type.
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+}
+
+// Get type or template to be edited.
+if ($edit !== 0) {
+ if ($mode == 1) { // If type is template type.
+ $editedtype = $DB->get_record('annopy_atype_templates', ['id' => $edit]);
+
+ if (isset($editedtype->defaulttype) && $editedtype->defaulttype == 1
+ && !has_capability('mod/annopy:managedefaultannotationtypetemplates', $context)) {
+
+ redirect($redirecturl, get_string('notallowedtodothis', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+ } else if ($mode == 2) { // If type is annopy type.
+ $editedtype = $DB->get_record('annopy_annotationtypes', ['id' => $edit]);
+
+ if ($moduleinstance->id !== $editedtype->annopy) {
+ redirect($redirecturl, get_string('annotationtypecantbeedited', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+ }
+
+ if ($editedtype && $mode == 2 ||
+ ((isset($editedtype->defaulttype) && $editedtype->defaulttype == 1 &&
+ has_capability('mod/annopy:managedefaultannotationtypetemplates', $context))
+ || (isset($editedtype->defaulttype) && isset($editedtype->userid) &&
+ $editedtype->defaulttype == 0 && $editedtype->userid == $USER->id))) {
+
+ $editedtypeid = $edit;
+ $editedtypename = $editedtype->name;
+ $editedcolor = '#' . $editedtype->color;
+
+ if ($mode == 1) {
+ $editeddefaulttype = $editedtype->defaulttype;
+ }
+ }
+}
+
+$select = "annopy = " . $moduleinstance->id;
+$annotationtypes = (array) $DB->get_records_select('annopy_annotationtypes', $select, null, 'priority ASC');
+
+// Instantiate form.
+$mform = new mod_annopy_annotationtypes_form(null,
+ ['editdefaulttype' => has_capability('mod/annopy:managedefaultannotationtypetemplates', $context), 'mode' => $mode]);
+
+if (isset($editedtypeid)) {
+ if ($mode == 1) { // If type is template annotation type.
+ $mform->set_data(['id' => $id, 'mode' => $mode, 'typeid' => $editedtypeid,
+ 'typename' => $editedtypename, 'color' => $editedcolor, 'standardtype' => $editeddefaulttype]);
+ } else if ($mode == 2) {
+ $mform->set_data(['id' => $id, 'mode' => $mode, 'typeid' => $editedtypeid, 'typename' => $editedtypename,
+ 'color' => $editedcolor]);
+ }
+} else {
+ $mform->set_data(['id' => $id, 'mode' => $mode, 'color' => '#FFFF00']);
+}
+
+if ($mform->is_cancelled()) {
+ redirect($redirecturl);
+} else if ($fromform = $mform->get_data()) {
+
+ // In this case you process validated data. $mform->get_data() returns data posted in form.
+ if ($fromform->typeid == 0 && isset($fromform->typename)) { // Create new annotation type.
+
+ $annotationtype = new stdClass();
+ $annotationtype->timecreated = time();
+ $annotationtype->timemodified = 0;
+ $annotationtype->name = format_text($fromform->typename, 1, ['para' => false]);
+ $annotationtype->color = $fromform->color;
+
+ if (isset($fromform->standardtype) && $fromform->standardtype === 1 &&
+ has_capability('mod/annopy:managedefaultannotationtypetemplates', $context)) {
+
+ $annotationtype->userid = 0;
+ $annotationtype->defaulttype = 1;
+ } else {
+ $annotationtype->userid = $USER->id;
+ $annotationtype->defaulttype = 0;
+ }
+
+ if ($mode == 2) { // If type is annopy annotation type.
+
+ if ($annotationtypes) {
+ $annotationtype->priority = $annotationtypes[array_key_last($annotationtypes)]->priority + 1;
+ } else {
+ $annotationtype->priority = 1;
+ }
+
+ $annotationtype->annopy = $moduleinstance->id;
+ }
+
+ if ($mode == 1) { // If type is template annotation type.
+ $DB->insert_record('annopy_atype_templates', $annotationtype);
+
+ } else if ($mode == 2) { // If type is annopy annotation type.
+ $DB->insert_record('annopy_annotationtypes', $annotationtype);
+ }
+
+ redirect($redirecturl, get_string('annotationtypeadded', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else if ($fromform->typeid !== 0 && isset($fromform->typename)) { // Update existing annotation type.
+
+ if ($mode == 1) { // If type is template annotation type.
+ $annotationtype = $DB->get_record('annopy_atype_templates', ['id' => $fromform->typeid]);
+ } else if ($mode == 2) { // If type is annopy annotation type.
+ $annotationtype = $DB->get_record('annopy_annotationtypes', ['id' => $fromform->typeid]);
+ }
+
+ if ($annotationtype &&
+ ($mode == 2 ||
+ (isset($annotationtype->defaulttype) && $annotationtype->defaulttype == 1 &&
+ has_capability('mod/annopy:managedefaultannotationtypetemplates', $context))
+ || (isset($annotationtype->defaulttype) && isset($annotationtype->userid) && $annotationtype->defaulttype == 0
+ && $annotationtype->userid == $USER->id))) {
+
+ $annotationtype->timemodified = time();
+ $annotationtype->name = format_text($fromform->typename, 1, ['para' => false]);
+ $annotationtype->color = $fromform->color;
+
+ if ($mode == 1 && has_capability('mod/annopy:managedefaultannotationtypetemplates', $context)) {
+ global $USER;
+ if ($fromform->standardtype === 1 && $annotationtype->defaulttype !== $fromform->standardtype) {
+ $annotationtype->defaulttype = 1;
+ $annotationtype->userid = 0;
+ } else if ($fromform->standardtype === 0 && $annotationtype->defaulttype !== $fromform->standardtype) {
+ $annotationtype->defaulttype = 0;
+ $annotationtype->userid = $USER->id;
+ }
+ }
+
+ if ($mode == 1) { // If type is template annotation type.
+ $DB->update_record('annopy_atype_templates', $annotationtype);
+
+ } else if ($mode == 2) { // If type is annopy annotation type.
+ $DB->update_record('annopy_annotationtypes', $annotationtype);
+ }
+
+ redirect($redirecturl, get_string('annotationtypeedited', 'mod_annopy'), null, notification::NOTIFY_SUCCESS);
+ } else {
+ redirect($redirecturl, get_string('annotationtypecantbeedited', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+
+ } else {
+ redirect($redirecturl, get_string('annotationtypeinvalid', 'mod_annopy'), null, notification::NOTIFY_ERROR);
+ }
+}
+
+// Get the name for this module instance.
+$modulename = format_string($moduleinstance->name, true, [
+ 'context' => $context,
+]);
+
+$PAGE->set_url('/mod/annopy/annotationtypes.php', ['id' => $cm->id]);
+
+$navtitle = '';
+
+if (isset($editedtypeid)) {
+ $navtitle = get_string('editannotationtype', 'mod_annopy');
+} else {
+ $navtitle = get_string('addannotationtype', 'mod_annopy');
+}
+
+if ($mode == 1) { // If type is template annotation type.
+ $navtitle .= ' (' . get_string('template', 'mod_annopy') . ')';
+} else if ($mode == 2) { // If type is annopy annotation type.
+ $navtitle .= ' (' . get_string('modulename', 'mod_annopy') . ')';
+}
+
+$PAGE->navbar->add($navtitle);
+
+$PAGE->set_title(get_string('modulename', 'mod_annopy').': ' . $modulename);
+$PAGE->set_heading(format_string($course->fullname));
+$PAGE->set_context($context);
+
+if ($CFG->branch < 400) {
+ $PAGE->force_settings_menu();
+}
+
+echo $OUTPUT->header();
+
+if ($CFG->branch < 400) {
+ echo $OUTPUT->heading($modulename);
+
+ if ($moduleinstance->intro) {
+ echo $OUTPUT->box(format_module_intro('annopy', $moduleinstance, $cm->id), 'generalbox', 'intro');
+ }
+}
+
+if (isset($editedtypeid) && $mode == 1) {
+ if ($editeddefaulttype) {
+ echo $OUTPUT->notification(
+ get_string('warningeditdefaultannotationtypetemplate', 'mod_annopy'), notification::NOTIFY_ERROR);
+ }
+
+ echo $OUTPUT->notification(get_string('changetemplate', 'mod_annopy'), notification::NOTIFY_WARNING);
+}
+
+echo $OUTPUT->heading($navtitle, 4);
+
+$mform->display();
+
+echo $OUTPUT->footer();
diff --git a/annotationtypes_form.php b/annotationtypes_form.php
new file mode 100644
index 0000000..2bf1626
--- /dev/null
+++ b/annotationtypes_form.php
@@ -0,0 +1,103 @@
+.
+
+/**
+ * File containing the class definition for the annotation types form for the module.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once("$CFG->libdir/formslib.php");
+
+/**
+ * Form for annotation types.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL Juv3 or later
+ */
+class mod_annopy_annotationtypes_form extends moodleform {
+
+ /**
+ * Define the form - called by parent constructor
+ */
+ public function definition() {
+
+ global $OUTPUT, $CFG;
+
+ $mform = $this->_form; // Don't forget the underscore!
+
+ $mform->addElement('hidden', 'id', null);
+ $mform->setType('id', PARAM_INT);
+
+ $mform->addElement('hidden', 'mode', 1);
+ $mform->setType('mode', PARAM_INT);
+
+ $mform->addElement('hidden', 'typeid', null);
+ $mform->setType('typeid', PARAM_INT);
+
+ $mform->addElement('text', 'typename', get_string('explanationtypename', 'mod_annopy'));
+ $mform->setType('typename', PARAM_TEXT);
+ $mform->addRule('typename', null, 'required', null, 'client');
+
+ if ($this->_customdata['editdefaulttype']) {
+ $mform->addHelpButton('typename', 'explanationtypename', 'mod_annopy');
+ }
+
+ MoodleQuickForm::registerElementType('colorpicker',
+ "$CFG->dirroot/mod/annopy/classes/forms/mod_annopy_colorpicker_form_element.php",
+ 'mod_annopy_colorpicker_form_element');
+
+ $mform->addElement('colorpicker', 'color', get_string('explanationhexcolor', 'mod_annopy'));
+ $mform->setType('color', PARAM_ALPHANUM);
+ $mform->addRule('color', null, 'required', null, 'client');
+ $mform->addHelpButton('color', 'explanationhexcolor', 'mod_annopy');
+
+ if ($this->_customdata['mode'] == 1) { // If template annotation type.
+ if ($this->_customdata['editdefaulttype']) {
+ $mform->addElement('advcheckbox', 'standardtype', get_string('standardtype', 'mod_annopy'),
+ get_string('explanationstandardtype', 'mod_annopy'));
+ } else {
+ $mform->addElement('hidden', 'standardtype', 0);
+ }
+
+ $mform->setType('standardtype', PARAM_INT);
+ }
+
+ $this->add_action_buttons();
+ }
+
+ /**
+ * Custom validation should be added here
+ * @param array $data Array with all the form data
+ * @param array $files Array with files submitted with form
+ * @return array Array with errors
+ */
+ public function validation($data, $files) {
+ $errors = [];
+
+ if (strlen($data['color']) !== 6 || preg_match("/[^a-fA-F0-9]/", $data['color'])) {
+ $errors['color'] = get_string('errnohexcolor', 'mod_annopy');
+ }
+
+ return $errors;
+ }
+}
diff --git a/backup/moodle2/backup_annopy_activity_task.class.php b/backup/moodle2/backup_annopy_activity_task.class.php
index 41b40d2..0bfaeda 100644
--- a/backup/moodle2/backup_annopy_activity_task.class.php
+++ b/backup/moodle2/backup_annopy_activity_task.class.php
@@ -29,7 +29,6 @@
// More information about the restore process: {@link https://docs.moodle.org/dev/Restore_API}.
require_once($CFG->dirroot.'/mod/annopy/backup/moodle2/backup_annopy_stepslib.php');
-require_once($CFG->dirroot.'/mod/annopy/backup/moodle2/backup_annopy_settingslib.php');
/**
* The class provides all the settings and steps to perform one complete backup of mod_annopy.
@@ -62,10 +61,10 @@ public static function encode_content_links($content) {
$base = preg_quote($CFG->wwwroot, "/");
// Link to the list of plugin instances.
- $search = "/(".$base."\//mod\/annopy\/index.php\?id\=)([0-9]+)/";
+ $search = "/(".$base."\/mod\/annopy\/index.php\?id\=)([0-9]+)/";
$content = preg_replace($search, '$@ANNOPYINDEX*$2@$', $content);
- // Link to view by moduleid with optional userid if only items of one user should be shown.
+ // Link to view by moduleid with optional userid if only annotations of one user should be shown.
$search = "/(".$base."\/mod\/annopy\/view.php\?id\=)([0-9]+)(&|&)userid=([0-9]+)/";
$content = preg_replace($search, '$@ANNOPYVIEWBYID*$2*$4@$', $content);
diff --git a/backup/moodle2/backup_annopy_settingslib.php b/backup/moodle2/backup_annopy_settingslib.php
deleted file mode 100644
index 578297f..0000000
--- a/backup/moodle2/backup_annopy_settingslib.php
+++ /dev/null
@@ -1,27 +0,0 @@
-.
-
-/**
- * Plugin custom settings are defined here.
- *
- * @package mod_annopy
- * @category backup
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-// More information about the backup process: {@link https://docs.moodle.org/dev/Backup_API}.
-// More information about the restore process: {@link https://docs.moodle.org/dev/Restore_API}.
diff --git a/backup/moodle2/backup_annopy_stepslib.php b/backup/moodle2/backup_annopy_stepslib.php
index 1a0872b..2bdba4a 100644
--- a/backup/moodle2/backup_annopy_stepslib.php
+++ b/backup/moodle2/backup_annopy_stepslib.php
@@ -40,33 +40,56 @@ class backup_annopy_activity_structure_step extends backup_activity_structure_st
protected function define_structure() {
$userinfo = $this->get_setting_value('userinfo');
- /* // Replace with the attributes and final elements that the element will handle.
- $annopy = new backup_nested_element('annopy', array('id'), array(
- 'name', 'intro', 'introformat', 'timecreated', 'timemodified'));
+ // Replace with the attributes and final elements that the element will handle.
+ $annopy = new backup_nested_element('annopy', ['id'], [
+ 'name', 'intro', 'introformat', 'timecreated', 'timemodified']);
- $entries = new backup_nested_element('entries');
- $entry = new backup_nested_element('entry', array('id'), array(
- 'userid', 'timecreated', 'timemodified', 'text', 'format'));
+ $annotationtypes = new backup_nested_element('annotationtypes');
+ $annotationtype = new backup_nested_element('annotationtype', ['id'], [
+ 'timecreated', 'timemodified', 'name', 'color', 'priority']);
+
+ $submissions = new backup_nested_element('submissions');
+ $submission = new backup_nested_element('submission', ['id'], [
+ 'author', 'title', 'content', 'currentversion', 'format', 'timecreated', 'timemodified']);
+
+ $annotations = new backup_nested_element('annotations');
+ $annotation = new backup_nested_element('annotation', ['id'], [
+ 'userid', 'timecreated', 'timemodified', 'type', 'startcontainer', 'endcontainer',
+ 'startoffset', 'endoffset', 'annotationstart', 'annotationend', 'exact', 'prefix', 'suffix', 'text']);
// Build the tree with these elements with $root as the root of the backup tree.
- $annopy->add_child($entries);
- $entries->add_child($entry);
+ $annopy->add_child($annotationtypes);
+ $annotationtypes->add_child($annotationtype);
+
+ $annopy->add_child($submissions);
+ $submissions->add_child($submission);
+
+ $submission->add_child($annotations);
+ $annotations->add_child($annotation);
// Define the source tables for the elements.
+ $annopy->set_source_table('annopy', ['id' => backup::VAR_ACTIVITYID]);
- $annopy->set_source_table('annopy', array('id' => backup::VAR_ACTIVITYID));
+ // Annotation types.
+ $annotationtype->set_source_table('annopy_annotationtypes', ['annopy' => backup::VAR_PARENTID]);
if ($userinfo) {
- // Entries.
- $entry->set_source_table('annopy_entries', array('annopy' => backup::VAR_PARENTID));
+ // Submissions.
+ $submission->set_source_table('annopy_submissions', ['annopy' => backup::VAR_PARENTID]);
+
+ // Annotations.
+ $annotation->set_source_table('annopy_annotations', ['submission' => backup::VAR_PARENTID]);
}
// Define id annotations.
- $rating->annotate_ids('user', 'userid');
+ if ($userinfo) {
+ $submission->annotate_ids('user', 'author');
+ $annotation->annotate_ids('user', 'userid');
+ }
// Define file annotations.
$annopy->annotate_files('mod_annopy', 'intro', null); // This file area has no itemid.
- $entry->annotate_files('mod_annopy', 'entry', 'id'); */
+ $submission->annotate_files('mod_annopy', 'submission', 'id');
return $this->prepare_activity_structure($annopy);
}
diff --git a/backup/moodle2/restore_annopy_activity_task.class.php b/backup/moodle2/restore_annopy_activity_task.class.php
index 11e6357..d7529a1 100644
--- a/backup/moodle2/restore_annopy_activity_task.class.php
+++ b/backup/moodle2/restore_annopy_activity_task.class.php
@@ -57,14 +57,13 @@ protected function define_my_steps() {
* @return array.
*/
public static function define_decode_contents() {
- $contents = array();
+ $contents = [];
// Define the contents (files).
- // tablename, array(field1, field 2), $mapping.
- /*
- $contents[] = new restore_decode_content('annopy', array('intro'), 'annopy');
- $contents[] = new restore_decode_content('annopy_entries', array('text', 'feedback'), 'annopy_entry');
- */
+ // tablename, [field1, field 2], $mapping.
+ $contents[] = new restore_decode_content('annopy', ['intro'], 'annopy');
+ $contents[] = new restore_decode_content('annopy_submissions', ['content'], 'annopy_submission');
+
return $contents;
}
@@ -74,14 +73,11 @@ public static function define_decode_contents() {
* @return array.
*/
public static function define_decode_rules() {
- $rules = array();
+ $rules = [];
// Define the rules.
- /*
$rules[] = new restore_decode_rule('ANNOPYINDEX', '/mod/annopy/index.php?id=$1', 'course');
- $rules[] = new restore_decode_rule('ANNOPYVIEWBYID', '/mod/annopy/view.php?id=$1&userid=$2',
- array('course_module', 'userid'));
- */
+ $rules[] = new restore_decode_rule('ANNOPYVIEWBYID', '/mod/annopy/view.php?id=$1&userid=$2', ['course_module', 'userid']);
return $rules;
}
@@ -94,12 +90,17 @@ public static function define_decode_rules() {
* @return array.
*/
public static function define_restore_log_rules() {
- $rules = array();
+ $rules = [];
// Define the rules to restore the logs (one rule for each event / file in the plugin/event/ folder).
- /*
$rules[] = new restore_log_rule('annopy', 'view', 'view.php?id={course_module}', '{annopy}');
- */
+ $rules[] = new restore_log_rule('annopy', 'add submission', 'view.php?id={course_module}', '{annopy}');
+ $rules[] = new restore_log_rule('annopy', 'update submission', 'view.php?id={course_module}', '{annopy}');
+ $rules[] = new restore_log_rule('annopy', 'delete submission', 'view.php?id={course_module}', '{annopy}');
+ $rules[] = new restore_log_rule('annopy', 'add annotation', 'view.php?id={course_module}', '{annopy}');
+ $rules[] = new restore_log_rule('annopy', 'update annotation', 'view.php?id={course_module}', '{annopy}');
+ $rules[] = new restore_log_rule('annopy', 'delete annotation', 'view.php?id={course_module}', '{annopy}');
+
return $rules;
}
@@ -115,7 +116,7 @@ public static function define_restore_log_rules() {
* activity level. All them are rules not linked to any module instance (cmid = 0)
*/
public static function define_restore_log_rules_for_course() {
- $rules = array();
+ $rules = [];
$rules[] = new restore_log_rule('annopy', 'view all', 'index.php?id={course}', null);
diff --git a/backup/moodle2/restore_annopy_stepslib.php b/backup/moodle2/restore_annopy_stepslib.php
index 4089e28..43891bd 100644
--- a/backup/moodle2/restore_annopy_stepslib.php
+++ b/backup/moodle2/restore_annopy_stepslib.php
@@ -41,13 +41,16 @@ class restore_annopy_activity_structure_step extends restore_activity_structure_
* @return restore_path_element[].
*/
protected function define_structure() {
- $paths = array();
+ $paths = [];
$userinfo = $this->get_setting_value('userinfo');
$paths[] = new restore_path_element('annopy', '/activity/annopy');
+ $paths[] = new restore_path_element('annopy_annotationtype', '/activity/annopy/annotationtypes/annotationtype');
if ($userinfo) {
- $paths[] = new restore_path_element('annopy_entry', '/activity/annopy/entries/entry');
+ $paths[] = new restore_path_element('annopy_submission', '/activity/annopy/submissions/submission');
+ $paths[] = new restore_path_element('annopy_submission_annotation',
+ '/activity/annopy/submissions/submission/annotations/annotation');
}
return $this->prepare_activity_structure($paths);
@@ -65,37 +68,69 @@ protected function process_annopy($data) {
$oldid = $data->id;
$data->course = $this->get_courseid();
- // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset.
- // See MDL-9367.
- if (!isset($data->assesstimestart)) {
- $data->assesstimestart = 0;
- }
- $data->assesstimestart = $this->apply_date_offset($data->assesstimestart);
+ $newitemid = $DB->insert_record('annopy', $data);
+ $this->apply_activity_instance($newitemid);
+ $this->newinstanceid = $newitemid;
- if (!isset($data->assesstimefinish)) {
- $data->assesstimefinish = 0;
- }
- $data->assesstimefinish = $this->apply_date_offset($data->assesstimefinish);
+ return;
+ }
- if (!isset($data->timeopen)) {
- $data->timeopen = 0;
- }
- $data->timeopen = $this->apply_date_offset($data->timeopen);
+ /**
+ * Restore AnnoPy submission.
+ *
+ * @param object $data data.
+ */
+ protected function process_annopy_submission($data) {
+ global $DB;
- if (!isset($data->timeclose)) {
- $data->timeclose = 0;
- }
- $data->timeclose = $this->apply_date_offset($data->timeclose);
+ $data = (object) $data;
+ $oldid = $data->id;
- if ($data->scale < 0) { // Scale found, get mapping.
- $data->scale = - ($this->get_mappingid('scale', abs($data->scale)));
- }
+ $data->annopy = $this->get_new_parentid('annopy');
+ $data->author = $this->get_mappingid('user', $data->author);
- $newitemid = $DB->insert_record('annopy', $data);
- $this->apply_activity_instance($newitemid);
- $this->newinstanceid = $newitemid;
+ $newitemid = $DB->insert_record('annopy_submissions', $data);
+ $this->set_mapping('annopy_submission', $oldid, $newitemid, true); // The true parameter is necessary for file handling.
+ }
- return;
+ /**
+ * Restore AnnoPy annotationtype.
+ *
+ * @param object $data data.
+ */
+ protected function process_annopy_annotationtype($data) {
+ global $DB;
+
+ $data = (object) $data;
+ $oldid = $data->id;
+
+ $data->annopy = $this->get_new_parentid('annopy');
+
+ $newitemid = $DB->insert_record('annopy_annotationtypes', $data);
+ $this->set_mapping('annopy_annotationtype', $oldid, $newitemid);
+
+ }
+
+ /**
+ * Add annotations to restored AnnoPy submissions.
+ *
+ * @param stdClass $data Tag
+ */
+ protected function process_annopy_submission_annotation($data) {
+ global $DB;
+
+ $data = (object) $data;
+
+ $oldid = $data->id;
+
+ $data->annopy = $this->newinstanceid;
+ $data->submission = $this->get_new_parentid('annopy_submission');
+ $data->userid = $this->get_mappingid('user', $data->userid);
+ $data->type = $this->get_mappingid('annopy_annotationtype', $data->type);
+
+ $newitemid = $DB->insert_record('annopy_annotations', $data);
+
+ $this->set_mapping('annopy_annotation', $oldid, $newitemid);
}
/**
@@ -106,6 +141,6 @@ protected function after_execute() {
$this->add_related_files('mod_annopy', 'intro', null);
// Component, filearea, mapping.
- $this->add_related_files('mod_annopy', 'entry', 'annopy_entry');
+ $this->add_related_files('mod_annopy', 'submission', 'annopy_submission');
}
}
diff --git a/classes/event/annotation_created.php b/classes/event/annotation_created.php
index f2e2542..5d152d1 100644
--- a/classes/event/annotation_created.php
+++ b/classes/event/annotation_created.php
@@ -66,15 +66,15 @@ public function get_description() {
* @return \moodle_url
*/
public function get_url() {
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $this->contextinstanceid
- ));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $this->contextinstanceid,
+ ]);
}
/**
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy_annotations', 'restore' => 'annopy_annotation');
+ return ['db' => 'annopy_annotations', 'restore' => 'annopy_annotation'];
}
}
diff --git a/classes/event/annotation_deleted.php b/classes/event/annotation_deleted.php
index fdf61f7..19f971e 100644
--- a/classes/event/annotation_deleted.php
+++ b/classes/event/annotation_deleted.php
@@ -66,15 +66,15 @@ public function get_description() {
* @return \moodle_url
*/
public function get_url() {
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $this->contextinstanceid
- ));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $this->contextinstanceid,
+ ]);
}
/**
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy_annotations', 'restore' => 'annopy_annotation');
+ return ['db' => 'annopy_annotations', 'restore' => 'annopy_annotation'];
}
}
diff --git a/classes/event/annotation_updated.php b/classes/event/annotation_updated.php
index d285750..9502264 100644
--- a/classes/event/annotation_updated.php
+++ b/classes/event/annotation_updated.php
@@ -66,15 +66,15 @@ public function get_description() {
* @return \moodle_url
*/
public function get_url() {
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $this->contextinstanceid
- ));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $this->contextinstanceid,
+ ]);
}
/**
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy_annotations', 'restore' => 'annopy_annotation');
+ return ['db' => 'annopy_annotations', 'restore' => 'annopy_annotation'];
}
}
diff --git a/classes/event/course_module_viewed.php b/classes/event/course_module_viewed.php
index 6ba5f04..4088945 100644
--- a/classes/event/course_module_viewed.php
+++ b/classes/event/course_module_viewed.php
@@ -46,6 +46,6 @@ protected function init() {
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy', 'restore' => 'annopy');
+ return ['db' => 'annopy', 'restore' => 'annopy'];
}
}
diff --git a/classes/event/submission_created.php b/classes/event/submission_created.php
index 018199a..c1ac757 100644
--- a/classes/event/submission_created.php
+++ b/classes/event/submission_created.php
@@ -66,15 +66,15 @@ public function get_description() {
* @return \moodle_url
*/
public function get_url() {
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $this->contextinstanceid
- ));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $this->contextinstanceid,
+ ]);
}
/**
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy_submissions', 'restore' => 'annopy_submission');
+ return ['db' => 'annopy_submissions', 'restore' => 'annopy_submission'];
}
}
diff --git a/classes/event/submission_deleted.php b/classes/event/submission_deleted.php
index 2cb4399..7f293e6 100644
--- a/classes/event/submission_deleted.php
+++ b/classes/event/submission_deleted.php
@@ -66,15 +66,15 @@ public function get_description() {
* @return \moodle_url
*/
public function get_url() {
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $this->contextinstanceid
- ));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $this->contextinstanceid,
+ ]);
}
/**
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy_submissions', 'restore' => 'annopy_submission');
+ return ['db' => 'annopy_submissions', 'restore' => 'annopy_submission'];
}
}
diff --git a/classes/event/submission_updated.php b/classes/event/submission_updated.php
index 7e50849..44ce830 100644
--- a/classes/event/submission_updated.php
+++ b/classes/event/submission_updated.php
@@ -66,15 +66,15 @@ public function get_description() {
* @return \moodle_url
*/
public function get_url() {
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $this->contextinstanceid
- ));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $this->contextinstanceid,
+ ]);
}
/**
* Get objectid mapping for restore.
*/
public static function get_objectid_mapping() {
- return array('db' => 'annopy_submissions', 'restore' => 'annopy_submission');
+ return ['db' => 'annopy_submissions', 'restore' => 'annopy_submission'];
}
}
diff --git a/classes/forms/mod_annopy_colorpicker_form_element.php b/classes/forms/mod_annopy_colorpicker_form_element.php
new file mode 100644
index 0000000..ec11e16
--- /dev/null
+++ b/classes/forms/mod_annopy_colorpicker_form_element.php
@@ -0,0 +1,163 @@
+.
+
+/**
+ * Color picker custom form element.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("HTML/QuickForm/text.php");
+
+/**
+ * Color picker custom form element.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_annopy_colorpicker_form_element extends HTML_QuickForm_text {
+ /** @var forceltr Whether to force the display of this element to flow LTR. */
+ public $forceltr = false;
+
+ /** @var _helpbutton String html for help button, if empty then no help. */
+ public $_helpbutton = '';
+
+ /** @var _hiddenlabel If true label will be hidden. */
+ public $_hiddenlabel = false;
+
+ /**
+ * constructor
+ *
+ * @param string $elementname (optional) name of the text field
+ * @param string $elementlabel (optional) text field label
+ * @param string $attributes (optional) Either a typical HTML attribute string or an associative array
+ */
+ public function __construct($elementname = null, $elementlabel = null, $attributes = null) {
+ parent::__construct($elementname, $elementlabel, $attributes);
+ parent::setSize(30);
+ parent::setMaxlength(7);
+ }
+
+ /**
+ * Sets label to be hidden.
+ *
+ * @param bool $hiddenlabel sets if label should be hidden
+ */
+ public function sethiddenlabel($hiddenlabel) {
+ $this->_hiddenlabel = $hiddenlabel;
+ }
+
+ /**
+ * Freeze the element so that only its value is returned and set persistantfreeze to false.
+ *
+ * @return void
+ */
+ public function freeze() {
+ $this->_flagFrozen = true;
+ // No hidden element is needed refer MDL-30845.
+ $this->setPersistantFreeze(false);
+ }
+
+ /**
+ * Returns the html to be used when the element is frozen.
+ *
+ * @return string Frozen html
+ */
+ public function getfrozenhtml() {
+ $attributes = ['readonly' => 'readonly'];
+ $this->updateAttributes($attributes);
+ return $this->_getTabs() . '_getAttrString($this->_attributes) . '/>' . $this->_getPersistantData();
+ }
+
+ /**
+ * Returns HTML for this form element.
+ *
+ * @return string
+ */
+ public function tohtml() {
+ global $CFG, $PAGE;
+
+ $PAGE->requires->js_init_call('M.util.init_colour_picker', ['id_color', null]);
+ $PAGE->requires->js_call_amd('mod_annopy/colorpicker-layout', 'init', ['id_color']);
+
+ // Add the class at the last minute.
+ if ($this->get_force_ltr()) {
+ if (!isset($this->_attributes['class'])) {
+ $this->_attributes['class'] = 'form-control text-ltr';
+ } else {
+ $this->_attributes['class'] .= 'form-control text-ltr';
+ }
+ }
+
+ $this->_generateId();
+ if ($this->_flagFrozen) {
+ return $this->getfrozenhtml();
+ }
+
+ $html = $this->_getTabs() .
+ '
+
+
+
+
+ _getAttrString($this->_attributes) . '">
+
+
+
+
';
+
+ if ($this->_hiddenlabel) {
+ return '' . $html;
+ } else {
+ return $html;
+ }
+ }
+
+ /**
+ * Get html for help button.
+ *
+ * @return string html for help button
+ */
+ public function gethelpbutton() {
+ return $this->_helpbutton;
+ }
+
+ /**
+ * Get force LTR option.
+ *
+ * @return bool
+ */
+ public function get_force_ltr() {
+ return $this->forceltr;
+ }
+
+ /**
+ * Force the field to flow left-to-right.
+ *
+ * This is useful for fields such as URLs, passwords, settings, etc...
+ *
+ * @param bool $value The value to set the option to.
+ */
+ public function set_force_ltr($value) {
+ $this->forceltr = (bool) $value;
+ }
+}
diff --git a/classes/local/helper.php b/classes/local/helper.php
new file mode 100644
index 0000000..625bcc8
--- /dev/null
+++ b/classes/local/helper.php
@@ -0,0 +1,297 @@
+.
+
+/**
+ * Helper utilities for the module.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_annopy\local;
+
+use mod_annopy_annotation_form;
+use moodle_url;
+use stdClass;
+
+/**
+ * Utility class for the module.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper {
+ /**
+ * Return the editor and attachment options for a submission.
+ * @param stdClass $course The course object.
+ * @param stdClass $context The context object.
+
+ * @return array $editoroptions Array containing the editor options.
+ * @return array $attachmentoptions Array containing the attachment options.
+ */
+ public static function annopy_get_editor_and_attachment_options($course, $context) {
+ // For the editor.
+ $editoroptions = [
+ 'trusttext' => true,
+ 'maxfiles' => EDITOR_UNLIMITED_FILES,
+ 'maxbytes' => $course->maxbytes,
+ 'context' => $context,
+ 'subdirs' => false,
+ ];
+
+ // If maxfiles would be set to an int and more files are given the editor saves them all but
+ // saves the overcouting incorrect so that white box is displayed.
+
+ // For a file attachments field (not really needed here).
+ $attachmentoptions = [
+ 'subdirs' => false,
+ 'maxfiles' => 1,
+ 'maxbytes' => $course->maxbytes,
+ ];
+
+ return [
+ $editoroptions,
+ $attachmentoptions,
+ ];
+ }
+
+ /**
+ * Returns annotation types array for select form.
+ *
+ * @param stdClass $annotationtypes The annotation types.
+ * @return array action
+ */
+ public static function get_annotationtypes_for_form($annotationtypes) {
+ $types = [];
+ $strmanager = get_string_manager();
+ foreach ($annotationtypes as $key => $type) {
+ if ($strmanager->string_exists($type->name, 'mod_annopy')) {
+ $types[$key] = get_string($type->name, 'mod_annopy');
+ } else {
+ $types[$key] = $type->name;
+ }
+ }
+
+ return $types;
+ }
+
+ /**
+ * Returns all annotation type templates.
+ *
+ * @return array action
+ */
+ public static function get_all_annotationtype_templates() {
+ global $USER, $DB;
+
+ $select = "defaulttype = 1";
+ $select .= " OR userid = " . $USER->id;
+
+ $annotationtypetemplates = (array) $DB->get_records_select('annopy_atype_templates', $select);
+
+ return $annotationtypetemplates;
+ }
+
+ /**
+ * Get all participants.
+ * @param object $context The context.
+ * @param object $moduleinstance The module instance.
+ * @param object $annotationtypesforform The annotation types prepared for the form.
+ * @param bool $originalkeys If the original keys should be returned.
+ * @return array action
+ */
+ public static function get_annopy_participants($context, $moduleinstance, $annotationtypesforform, $originalkeys = false) {
+ global $USER, $DB;
+
+ $participants = get_enrolled_users($context, 'mod/annopy:potentialparticipant');
+
+ foreach ($participants as $key => $participant) {
+ if (has_capability('mod/annopy:viewparticipants', $context) || $participant->id == $USER->id) {
+ $participants[$key]->annotations = [];
+ $participants[$key]->annotationscount = 0;
+
+ foreach ($annotationtypesforform as $i => $type) {
+ $sql = "SELECT COUNT(*)
+ FROM {annopy_annotations} a
+ JOIN {annopy_submissions} s ON s.id = a.submission
+ WHERE s.annopy = :annopy AND
+ a.userid = :userid AND
+ a.type = :atype";
+ $params = ['annopy' => $moduleinstance->id, 'userid' => $participant->id, 'atype' => $i];
+ $count = $DB->count_records_sql($sql, $params);
+
+ $participants[$key]->annotations[$i] = $count;
+ $participants[$key]->annotationscount += $count;
+ }
+
+ $participants[$key]->annotations = array_values($participants[$key]->annotations);
+ } else {
+ unset($participants[$key]);
+ }
+ }
+
+ if (!$originalkeys) {
+ $participants = array_values($participants);
+ }
+
+ return $participants;
+ }
+
+ /**
+ * Prepare the annotations for the submission.
+ *
+ * @param object $cm The course module.
+ * @param object $course The course.
+ * @param object $context The context.
+ * @param object $submission The submission to be processed.
+ * @param object $strmanager The moodle strmanager object needed to check annotation types in the annotation form.
+ * @param object $annotationtypes The annotation types for the module.
+ * @param int $userid The ID of the user whose annotations should be shown.
+ * @param object $annotationmode If annotationmode is activated.
+ * @return object The submission with its annotations.
+ */
+ public static function prepare_annotations($cm, $course, $context, $submission, $strmanager, $annotationtypes,
+ $userid, $annotationmode) {
+
+ global $DB, $USER, $CFG, $OUTPUT;
+
+ // Get annotations for submission.
+ if ($userid) {
+ $submission->annotations = array_values($DB->get_records('annopy_annotations',
+ ['annopy' => $cm->instance, 'submission' => $submission->id, 'userid' => $userid]));
+ } else {
+ $submission->annotations = array_values($DB->get_records('annopy_annotations',
+ ['annopy' => $cm->instance, 'submission' => $submission->id]));
+ }
+ $submission->totalannotationscount = $DB->count_records('annopy_annotations',
+ ['annopy' => $cm->instance, 'submission' => $submission->id]);
+
+ foreach ($submission->annotations as $key => $annotation) {
+
+ // If annotation type does not exist.
+ if (!$DB->record_exists('annopy_annotationtypes', ['id' => $annotation->type])) {
+ $submission->annotations[$key]->color = 'FFFF00';
+ $submission->annotations[$key]->type = get_string('deletedannotationtype', 'mod_annopy');
+ } else {
+ $submission->annotations[$key]->color = $annotationtypes[$annotation->type]->color;
+
+ if ($strmanager->string_exists($annotationtypes[$annotation->type]->name, 'mod_annopy')) {
+ $submission->annotations[$key]->type = get_string($annotationtypes[$annotation->type]->name, 'mod_annopy');
+ } else {
+ $submission->annotations[$key]->type = $annotationtypes[$annotation->type]->name;
+ }
+ }
+
+ if (has_capability('mod/annopy:editannotation', $context) && $annotation->userid == $USER->id) {
+ $submission->annotations[$key]->canbeedited = true;
+ } else {
+ $submission->annotations[$key]->canbeedited = false;
+ }
+
+ if ($annotationmode) {
+ // Add annotater images to annotations.
+ $annotater = $DB->get_record('user', ['id' => $annotation->userid]);
+ $annotaterimage = $OUTPUT->user_picture($annotater,
+ ['courseid' => $course->id, 'link' => true, 'includefullname' => true, 'size' => 20]);
+ $submission->annotations[$key]->userpicturestr = $annotaterimage;
+
+ } else {
+ $submission->annotationform = false;
+ }
+ }
+
+ // Sort annotations and find its position.
+ usort($submission->annotations, function ($a, $b) {
+ if ($a->annotationstart === $b->annotationstart) {
+ return $a->annotationend <=> $b->annotationend;
+ }
+
+ return $a->annotationstart <=> $b->annotationstart;
+ });
+
+ $pos = 1;
+ foreach ($submission->annotations as $key => $annotation) {
+ $submission->annotations[$key]->position = $pos;
+ $pos += 1;
+ }
+
+ if ($annotationmode) {
+ // Add annotation form.
+ require_once($CFG->dirroot . '/mod/annopy/annotation_form.php');
+ $mform = new mod_annopy_annotation_form(new moodle_url('/mod/annopy/annotations.php', ['id' => $cm->id]),
+ ['types' => self::get_annotationtypes_for_form($annotationtypes)]);
+ // Set default data.
+ $mform->set_data(['id' => $cm->id, 'submission' => $submission->id]);
+
+ $submission->annotationform = $mform->render();
+ }
+
+ return $submission;
+ }
+
+ /**
+ * Returns the pagebar for the module instance.
+ * @param object $context The context for the module.
+ * @param int $userid The ID of the user whose annotations should be shown.
+ * @param object $submission The submission.
+ * @param object $moduleinstance The module instance.
+ * @param object $annotationtypesforform The annotationtypes prepared for the form.
+ *
+ * @return array action
+ */
+ public static function get_pagebar($context, $userid, $submission, $moduleinstance, $annotationtypesforform) {
+
+ if (!$submission) {
+ return false;
+ }
+
+ $participants = self::get_annopy_participants($context, $moduleinstance, $annotationtypesforform);
+
+ $pagebar = [];
+
+ foreach ($participants as $user) {
+ $obj = new stdClass();
+ if ($userid == $user->id) {
+ $obj->userid = $user->id;
+ $obj->display = '' . fullname($user) . ' (' . $user->annotationscount . ')' . '';
+ } else {
+ $obj->userid = $user->id;
+ $obj->display = $user->lastname;
+
+ if ($user->annotationscount) {
+ $obj->display .= ' (' . $user->annotationscount . ')';
+ }
+ }
+
+ array_push($pagebar, $obj);
+ }
+
+ $obj = new stdClass();
+ $obj->userid = 'all';
+
+ if (!$userid) {
+ $obj->display = '' . get_string('allannotations', 'mod_annopy') .
+ ' (' . $submission->totalannotationscount . ')' . '';
+ } else {
+ $obj->display = get_string('allannotations', 'mod_annopy') .
+ ' (' . $submission->totalannotationscount . ')';
+ }
+
+ array_push($pagebar, $obj);
+
+ return $pagebar;
+ }
+}
diff --git a/classes/local/submissionstats.php b/classes/local/submissionstats.php
new file mode 100644
index 0000000..d1dae1b
--- /dev/null
+++ b/classes/local/submissionstats.php
@@ -0,0 +1,136 @@
+.
+
+/**
+ * Stats utilities for AnnoPy submissions.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_annopy\local;
+
+use stdClass;
+use core_text;
+
+/**
+ * Utility class for AnnoPy submission stats.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class submissionstats {
+
+ /**
+ * Get the statistics for this submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @param string $submissiontimecreated The time then the submission was created.
+ * @return array submissionstats Array with the statistics of the submission.
+ */
+ public static function get_submission_stats($submissiontext, $submissiontimecreated) {
+
+ $cleantext = preg_replace('#<[^>]+>#', ' ', $submissiontext, -1, $replacementspacescount);
+
+ $submissionstats = [];
+ $submissionstats['words'] = self::get_stats_words($cleantext);
+ $submissionstats['chars'] = self::get_stats_chars($cleantext) - $replacementspacescount;
+ $submissionstats['sentences'] = self::get_stats_sentences($cleantext);
+ $submissionstats['paragraphs'] = self::get_stats_paragraphs($cleantext);
+ $submissionstats['uniquewords'] = self::get_stats_uniquewords($cleantext);
+ $submissionstats['spaces'] = self::get_stats_spaces($cleantext) - $replacementspacescount;
+ $submissionstats['charswithoutspaces'] = $submissionstats['chars'] - $submissionstats['spaces'];
+
+ $timenow = new \DateTime(date('Y-m-d G:i:s', time()));
+ $timesubmissioncreated = new \DateTime(date('Y-m-d G:i:s', $submissiontimecreated));
+
+ if ($timenow >= $timesubmissioncreated) {
+ $submissionstats['datediff'] = date_diff($timenow, $timesubmissioncreated);
+ } else {
+ $submissionstats['datediff'] = false;
+ }
+
+ return $submissionstats;
+ }
+
+ /**
+ * Get the character count statistics for this annopy submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @ return int The number of characters.
+ */
+ public static function get_stats_chars($submissiontext) {
+ return core_text::strlen($submissiontext);
+ }
+
+ /**
+ * Get the word count statistics for this annopy submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @ return int The number of words.
+ */
+ public static function get_stats_words($submissiontext) {
+ return count_words($submissiontext);
+ }
+
+ /**
+ * Get the sentence count statistics for this annopy submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @ return int The number of sentences.
+ */
+ public static function get_stats_sentences($submissiontext) {
+ $sentences = preg_split('/[!?.]+(?![0-9])/', $submissiontext);
+ $sentences = array_filter($sentences);
+ return count($sentences);
+ }
+
+ /**
+ * Get the paragraph count statistics for this annopy submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @ return int The number of paragraphs.
+ */
+ public static function get_stats_paragraphs($submissiontext) {
+ $paragraphs = explode("\n", $submissiontext);
+ $paragraphs = array_filter($paragraphs);
+ return count($paragraphs);
+ }
+
+ /**
+ * Get the unique word count statistics for this annopy submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @return int The number of unique words.
+ */
+ public static function get_stats_uniquewords($submissiontext) {
+ $items = core_text::strtolower($submissiontext);
+ $items = str_word_count($items, 1);
+ $items = array_unique($items);
+ return count($items);
+ }
+
+ /**
+ * Get the raw spaces count statistics for this annopy submission.
+ *
+ * @param string $submissiontext The text for this submission.
+ * @return int The number of spaces.
+ */
+ public static function get_stats_spaces($submissiontext) {
+ return substr_count($submissiontext, ' ');
+ }
+}
diff --git a/classes/output/annopy_annotations_summary.php b/classes/output/annopy_annotations_summary.php
new file mode 100644
index 0000000..30a0653
--- /dev/null
+++ b/classes/output/annopy_annotations_summary.php
@@ -0,0 +1,109 @@
+.
+
+/**
+ * Class containing data for the annotations summary.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_annopy\output;
+
+use renderable;
+use renderer_base;
+use templatable;
+use stdClass;
+
+/**
+ * Class containing data for the annotations summary.
+ *
+ * @package mod_annopy
+ * @copyright 2023 coactum GmbH
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class annopy_annotations_summary implements renderable, templatable {
+
+ /** @var int */
+ protected $cmid;
+ /** @var object */
+ protected $context;
+ /** @var object */
+ protected $participants;
+ /** @var object */
+ protected $annopyannotationtypes;
+ /** @var object */
+ protected $annotationtypetemplates;
+ /** @var string */
+ protected $sesskey;
+ /** @var int */
+ protected $annotationstotalcount;
+
+ /**
+ * Construct this renderable.
+ * @param int $cmid The course module id
+ * @param array $context The context
+ * @param array $participants The participants of the annopy instance
+ * @param array $annopyannotationtypes The annotationtypes used in the annopy instance
+ * @param array $annotationtypetemplates The annotationtype templates available for the current user
+ * @param string $sesskey The session key
+ * @param int $annotationstotalcount The total count of annotations
+ */
+ public function __construct($cmid, $context, $participants, $annopyannotationtypes, $annotationtypetemplates,
+ $sesskey, $annotationstotalcount) {
+
+ $this->cmid = $cmid;
+ $this->context = $context;
+ $this->participants = $participants;
+ $this->annopyannotationtypes = $annopyannotationtypes;
+ $this->annotationtypetemplates = $annotationtypetemplates;
+ $this->sesskey = $sesskey;
+ $this->annotationstotalcount = $annotationstotalcount;
+ }
+
+ /**
+ * Export this data so it can be used as the context for a mustache template.
+ *
+ * @param renderer_base $output Renderer base.
+ * @return stdClass
+ */
+ public function export_for_template(renderer_base $output) {
+ global $USER;
+
+ $data = new stdClass();
+ $data->cmid = $this->cmid;
+ $data->myuserid = $USER->id;
+ $data->participants = $this->participants;
+ $data->annopyannotationtypes = $this->annopyannotationtypes;
+ $data->annotationtypetemplates = $this->annotationtypetemplates;
+ $data->sesskey = $this->sesskey;
+ $data->annotationstotalcount = $this->annotationstotalcount;
+
+ if (has_capability('mod/annopy:addannotationtypetemplate', $this->context) ||
+ has_capability('mod/annopy:editannotationtypetemplate', $this->context)) {
+
+ $data->canmanageannotationtypetemplates = true;
+ } else {
+ $data->canmanageannotationtypetemplates = false;
+ }
+
+ $data->canaddannotationtype = has_capability('mod/annopy:addannotationtype', $this->context);
+ $data->canaddannotationtypetemplate = has_capability('mod/annopy:addannotationtypetemplate', $this->context);
+ $data->canviewparticipants = has_capability('mod/annopy:viewparticipants', $this->context);
+
+ return $data;
+ }
+}
diff --git a/classes/output/annopy_view.php b/classes/output/annopy_view.php
index eccad46..5c222d8 100644
--- a/classes/output/annopy_view.php
+++ b/classes/output/annopy_view.php
@@ -23,6 +23,8 @@
*/
namespace mod_annopy\output;
+use mod_annopy\local\submissionstats;
+use mod_annopy\local\helper;
use renderable;
use renderer_base;
use templatable;
@@ -37,15 +39,35 @@
*/
class annopy_view implements renderable, templatable {
+ /** @var object */
+ protected $cm;
+ /** @var object */
+ protected $course;
+ /** @var object */
+ protected $context;
+ /** @var object */
+ protected $moduleinstance;
+ /** @var object */
+ protected $submission;
/** @var int */
- protected $cmid;
+ protected $userid;
/**
* Construct this renderable.
- * @param int $cmid The course module id
+ * @param object $cm The course module
+ * @param object $course The course
+ * @param object $context The context
+ * @param object $moduleinstance The module instance
+ * @param object $submission The submission
+ * @param int $userid The ID of the user whose annotations should be shown
*/
- public function __construct($cmid) {
- $this->cmid = $cmid;
+ public function __construct($cm, $course, $context, $moduleinstance, $submission, $userid) {
+ $this->cm = $cm;
+ $this->course = $course;
+ $this->context = $context;
+ $this->moduleinstance = $moduleinstance;
+ $this->submission = $submission;
+ $this->userid = $userid;
}
/**
@@ -55,8 +77,53 @@ public function __construct($cmid) {
* @return stdClass
*/
public function export_for_template(renderer_base $output) {
+ global $DB, $USER, $OUTPUT;
+
$data = new stdClass();
- $data->cmid = $this->cmid;
+ $data->cmid = $this->cm->id;
+ $data->submission = $this->submission;
+
+ $select = "annopy = " . $this->cm->instance;
+ $annotationtypes = (array) $DB->get_records_select('annopy_annotationtypes', $select, null, 'priority ASC');
+
+ if ($data->submission) {
+ // Set submission author.
+ $data->submission->author = $DB->get_record('user', ['id' => $data->submission->author]);
+ $data->submission->author->userpicture = $OUTPUT->user_picture($data->submission->author,
+ ['courseid' => $this->course->id, 'link' => true, 'includefullname' => true, 'size' => 25]);
+
+ // Submission stats.
+ $data->submission->stats = submissionstats::get_submission_stats($data->submission->content,
+ $data->submission->timecreated);
+ $data->submission->canviewdetails = has_capability('mod/annopy:addsubmission', $this->context);
+
+ // Prepare annotations.
+ $data->submission = helper::prepare_annotations($this->cm, $this->course, $this->context, $data->submission,
+ get_string_manager(), $annotationtypes, $this->userid, true);
+
+ // If submission can be edited.
+ if (has_capability('mod/annopy:editsubmission', $this->context) && !$data->submission->totalannotationscount
+ && (isset($data->submission) && $data->submission->author->id == $USER->id)) {
+ $data->submission->canbeedited = true;
+ } else {
+ $data->submission->canbeedited = false;
+ }
+ }
+
+ $data->canaddsubmission = has_capability('mod/annopy:addsubmission', $this->context);
+
+ if (has_capability('mod/annopy:editsubmission', $this->context)) {
+ $data->caneditsubmission = true;
+ } else {
+ $data->caneditsubmission = false;
+ }
+
+ $data->canviewparticipants = has_capability('mod/annopy:viewparticipants', $this->context);
+
+ $data->sesskey = sesskey();
+ $data->pagebar = helper::get_pagebar($this->context, $this->userid, $this->submission, $this->moduleinstance,
+ helper::get_annotationtypes_for_form($annotationtypes));
+
return $data;
}
}
diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php
index 032a3cc..b0c921f 100644
--- a/classes/privacy/provider.php
+++ b/classes/privacy/provider.php
@@ -24,17 +24,14 @@
namespace mod_annopy\privacy;
-use \core_privacy\local\request\userlist;
-use \core_privacy\local\request\approved_contextlist;
-use \core_privacy\local\request\approved_userlist;
-use \core_privacy\local\request\writer;
-use \core_privacy\local\request\helper;
-use \core_privacy\local\metadata\collection;
-use \core_privacy\local\request\transform;
-use \core_privacy\local\request\contextlist;
-
-use \core_privacy\local\request\user_preference_provider;
-
+use core_privacy\local\request\userlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
+use core_privacy\local\request\writer;
+use core_privacy\local\request\helper;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\contextlist;
/**
* Implementation of the privacy subsystem plugin provider for the activity module.
@@ -50,9 +47,7 @@ class provider implements
\core_privacy\local\request\plugin\provider,
// This plugin is capable of determining which users have data within it.
- \core_privacy\local\request\core_userlist_provider,
-
- \core_privacy\local\request\user_preference_provider {
+ \core_privacy\local\request\core_userlist_provider {
/**
* Provides the meta data stored for a user stored by the plugin.
@@ -62,23 +57,42 @@ class provider implements
*/
public static function get_metadata(collection $items) : collection {
- /* // The table 'annopy_participants' stores all annopy participants and their data.
- $items->add_database_table('annopy_participants', [
- 'annopy' => 'privacy:metadata:annopy_participants:annopy',
- ], 'privacy:metadata:annopy_participants');
-
- // The table 'annopy_submissions' stores all group subbissions.
+ // The table 'annopy_submissions' stores all submissions.
$items->add_database_table('annopy_submissions', [
'annopy' => 'privacy:metadata:annopy_submissions:annopy',
+ 'author' => 'privacy:metadata:annopy_submissions:author',
+ 'title' => 'privacy:metadata:annopy_submissions:title',
+ 'content' => 'privacy:metadata:annopy_submissions:content',
+ 'currentversion' => 'privacy:metadata:annopy_submissions:currentversion',
+ 'format' => 'privacy:metadata:annopy_submissions:format',
+ 'timecreated' => 'privacy:metadata:annopy_submissions:timecreated',
+ 'timemodified' => 'privacy:metadata:annopy_submissions:timemodified',
], 'privacy:metadata:annopy_submissions');
- // The plguin uses multiple subsystems that save personal data.
+ // The table 'annopy_annotations' stores all annotations.
+ $items->add_database_table('annopy_annotations', [
+ 'annopy' => 'privacy:metadata:annopy_annotations:annopy',
+ 'submission' => 'privacy:metadata:annopy_annotations:submission',
+ 'userid' => 'privacy:metadata:annopy_annotations:userid',
+ 'timecreated' => 'privacy:metadata:annopy_annotations:timecreated',
+ 'timemodified' => 'privacy:metadata:annopy_annotations:timemodified',
+ 'type' => 'privacy:metadata:annopy_annotations:type',
+ 'text' => 'privacy:metadata:annopy_annotations:text',
+ ], 'privacy:metadata:annopy_annotations');
+
+ // The table 'annopy_atype_templates' stores all annotation type templates.
+ $items->add_database_table('annopy_atype_templates', [
+ 'timecreated' => 'privacy:metadata:annopy_atype_templates:timecreated',
+ 'timemodified' => 'privacy:metadata:annopy_atype_templates:timemodified',
+ 'name' => 'privacy:metadata:annopy_atype_templates:name',
+ 'color' => 'privacy:metadata:annopy_atype_templates:color',
+ 'userid' => 'privacy:metadata:annopy_atype_templates:userid',
+ ], 'privacy:metadata:annopy_atype_templates');
+
+ // The plugin uses multiple subsystems that save personal data.
$items->add_subsystem_link('core_files', [], 'privacy:metadata:core_files');
- $items->add_subsystem_link('core_rating', [], 'privacy:metadata:core_rating');
- $items->add_subsystem_link('core_message', [], 'privacy:metadata:core_message');
- // User preferences in the plugin.
- $items->add_user_preference('annopy_sortoption', 'privacy:metadata:preference:annopy_sortoption'); */
+ // No user preferences in the plugin.
return $items;
}
@@ -100,17 +114,27 @@ public static function get_contexts_for_userid(int $userid) : contextlist {
'userid' => $userid,
];
- // Get contexts of ... .
+ // Get contexts of the submissions.
+ $sql = "SELECT c.id
+ FROM {context} c
+ JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
+ JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+ JOIN {annopy} a ON a.id = cm.instance
+ JOIN {annopy_submissions} s ON s.annopy = a.id
+ WHERE s.author = :userid
+ ";
+
+ $contextlist->add_from_sql($sql, $params);
- $sql;
- /* $sql = "SELECT c.id
- FROM {context} c
- JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
- JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
- JOIN {annopy} e ON e.id = cm.instance
- JOIN {annopy_participants} p ON p.annopy = e.id
- WHERE p.moodleuserid = :userid
- "; */
+ // Get contexts for annotations.
+ $sql = "SELECT c.id
+ FROM {context} c
+ JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
+ JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+ JOIN {annopy} a ON a.id = cm.instance
+ JOIN {annopy_annotations} aa ON aa.annopy = a.id
+ WHERE aa.userid = :userid
+ ";
$contextlist->add_from_sql($sql, $params);
@@ -134,14 +158,26 @@ public static function get_users_in_context(userlist $userlist) {
'modulename' => 'annopy',
];
- // Get users.
- $sql;
- /* $sql = "SELECT p.moodleuserid
+ // Find users with submissions.
+ $sql = "SELECT s.author
+ FROM {course_modules} cm
+ JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+ JOIN {annopy} a ON a.id = cm.instance
+ JOIN {annopy_submissions} s ON s.annopy = a.id
+ WHERE cm.id = :instanceid
+ ";
+
+ $userlist->add_from_sql('author', $sql, $params);
+
+ // Find users with annotations.
+ $sql = "SELECT aa.userid
FROM {course_modules} cm
JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
- JOIN {annopy} e ON e.id = cm.instance
- JOIN {annopy_participants} p ON p.annopy = e.id
- WHERE cm.id = :instanceid"; */
+ JOIN {annopy} a ON a.id = cm.instance
+ JOIN {annopy_annotations} aa ON aa.annopy = a.id
+ WHERE cm.id = :instanceid
+ ";
+
$userlist->add_from_sql('userid', $sql, $params);
}
@@ -163,13 +199,14 @@ public static function export_user_data(approved_contextlist $contextlist) {
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$params = $contextparams;
- /* $sql = "SELECT
+ // Get all instances.
+ $sql = "SELECT
c.id AS contextid,
- p.*,
+ a.*,
cm.id AS cmid
FROM {context} c
JOIN {course_modules} cm ON cm.id = c.instanceid
- JOIN {annopy} p ON P.id = cm.instance
+ JOIN {annopy} a ON a.id = cm.instance
WHERE (
c.id {$contextsql}
)
@@ -181,9 +218,10 @@ public static function export_user_data(approved_contextlist $contextlist) {
foreach ($annopys as $annopy) {
if ($annopy) {
+
$context = \context::instance_by_id($annopy->contextid);
- // Store the main annopy data.
+ // Store the main data.
$contextdata = helper::get_context_data($context, $user);
// Write it.
@@ -192,7 +230,7 @@ public static function export_user_data(approved_contextlist $contextlist) {
// Write generic module intro files.
helper::export_context_files($context, $user);
- self::export_entries_data($userid, $annopy->id, $annopy->contextid);
+ self::export_submissions_data($userid, $annopy->id, $annopy->contextid);
self::export_annotations_data($userid, $annopy->id, $annopy->contextid);
@@ -201,168 +239,178 @@ public static function export_user_data(approved_contextlist $contextlist) {
}
}
- $annopys->close(); */
+ $annopys->close();
}
/**
- * Store all information about all ....
+ * Store all information about all submissions made by this user.
*
* @param int $userid The userid of the user whose data is to be exported.
- * @param int $annopyid The id of the annopy.
- * @param int $annopycontextid The context id of the annopy.
+ * @param int $annopyid The id of the module.
+ * @param int $annopycontextid The context id of the module.
*/
- /* protected static function export_entries_data(int $userid, $annopyid, $annopycontextid) {
+ protected static function export_submissions_data(int $userid, $annopyid, $annopycontextid) {
global $DB;
- // Find all entries for this annopy written by the user.
+ // Find all submissions for this module written by the user.
$sql = "SELECT
- e.id,
- e.annopy,
- e.userid,
- e.timecreated,
- e.timemodified,
- e.text,
- e.format,
- e.rating,
- e.feedback,
- e.formatfeedback,
- e.teacher,
- e.timemarked,
- e.baseentry
- FROM {annopy_entries} e
+ s.id,
+ s.annopy,
+ s.author,
+ s.title,
+ s.content,
+ s.currentversion,
+ s.format,
+ s.timecreated,
+ s.timemodified
+ FROM {annopy_submissions} s
WHERE (
- e.annopy = :annopyid AND
- e.userid = :userid
+ s.annopy = :annopyid AND
+ s.author = :userid
)
";
$params['userid'] = $userid;
$params['annopyid'] = $annopyid;
- // Get the annopys from the entries.
- $entries = $DB->get_recordset_sql($sql, $params);
+ // Get the submissions.
+ $submissions = $DB->get_recordset_sql($sql, $params);
- if ($entries->valid()) {
- foreach ($entries as $entry) {
- if ($entry) {
+ if ($submissions->valid()) {
+ foreach ($submissions as $submission) {
+ if ($submission) {
$context = \context::instance_by_id($annopycontextid);
- self::export_entry_data($userid, $context, ['annopy-entry-' . $entry->id], $entry);
+ self::export_submission_data($userid, $context, ['annopy-submission-' . $submission->id], $submission);
}
}
}
- $entries->close();
- } */
+ $submissions->close();
+ }
/**
- * Export all data in the entry.
+ * Export all data in the submission.
*
* @param int $userid The userid of the user whose data is to be exported.
* @param \context $context The instance of the annopy context.
* @param array $subcontext The location within the current context that this data belongs.
- * @param \stdClass $entry The entry.
+ * @param \stdClass $submission The submission.
*/
- /* protected static function export_entry_data(int $userid, \context $context, $subcontext, $entry) {
+ protected static function export_submission_data(int $userid, \context $context, $subcontext, $submission) {
- if ($entry->timecreated != 0) {
- $timecreated = transform::datetime($entry->timecreated);
+ if ($submission->timecreated != 0) {
+ $timecreated = transform::datetime($submission->timecreated);
} else {
$timecreated = null;
}
- if ($entry->timemodified != 0) {
- $timemodified = transform::datetime($entry->timemodified);
+ if ($submission->timemodified != 0) {
+ $timemodified = transform::datetime($submission->timemodified);
} else {
$timemodified = null;
}
- if ($entry->timemarked != 0) {
- $timemarked = transform::datetime($entry->timemarked);
- } else {
- $timemarked = null;
- }
-
// Store related metadata.
- $entrydata = (object) [
- 'annopy' => $entry->annopy,
- 'userid' => $entry->userid,
+ $submissiondata = (object) [
+ 'annopy' => $submission->annopy,
+ 'author' => $submission->author,
+ 'title' => $submission->title,
+ 'currentversion' => $submission->currentversion,
'timecreated' => $timecreated,
'timemodified' => $timemodified,
- 'rating' => $entry->rating,
- 'teacher' => $entry->teacher,
- 'timemarked' => $timemarked,
- 'baseentry' => $entry->baseentry,
];
- $entrydata->text = writer::with_context($context)->rewrite_pluginfile_urls($subcontext, 'mod_annopy',
- 'entry', $entry->id, $entry->text);
-
- $entrydata->text = format_text($entrydata->text, $entry->format, (object) [
- 'para' => false,
- 'context' => $context,
- ]);
-
- $entrydata->feedback = writer::with_context($context)->rewrite_pluginfile_urls($subcontext, 'mod_annopy',
- 'feedback', $entry->id, $entry->feedback);
+ $submissiondata->content = writer::with_context($context)->rewrite_pluginfile_urls($subcontext, 'mod_annopy',
+ 'submission', $submission->id, $submission->content);
- $entrydata->feedback = format_text($entrydata->feedback, $entry->formatfeedback, (object) [
+ $submissiondata->content = format_text($submissiondata->content, $submission->format, (object) [
'para' => false,
'context' => $context,
]);
- // Store the entry data.
+ // Store the submission data.
writer::with_context($context)
- ->export_data($subcontext, $entrydata)
- ->export_area_files($subcontext, 'mod_annopy', 'entry', $entry->id)
- ->export_area_files($subcontext, 'mod_annopy', 'feedback', $entry->id);
-
- // Store all ratings against this entry as the entry belongs to the user. All ratings on it are ratings of their content.
- \core_rating\privacy\provider::export_area_ratings($userid, $context, $subcontext, 'mod_annopy',
- 'entry', $entry->id, false);
- } */
+ ->export_data($subcontext, $submissiondata)
+ ->export_area_files($subcontext, 'mod_annopy', 'submission', $submission->id);
+ }
/**
- * Store all user preferences for the plugin.
+ * Store all information about all annotations made by this user.
*
* @param int $userid The userid of the user whose data is to be exported.
+ * @param int $instanceid The id of the module instance.
+ * @param int $contextid The context id of the module instance.
*/
- /* public static function export_user_preferences(int $userid) {
- $user = \core_user::get_user($userid);
-
- if ($annopysortoption = get_user_preferences('annopy_sortoption', 0, $userid)) {
- switch ($annopysortoption) {
- case 1:
- $sortoption = get_string('currenttooldest', 'mod_annopy');
- break;
- case 2:
- $sortoption = get_string('oldesttocurrent', 'mod_annopy');
- break;
- case 3:
- $sortoption = get_string('lowestgradetohighest', 'mod_annopy');
- break;
- case 4:
- $sortoption = get_string('highestgradetolowest', 'mod_annopy');
- break;
- default:
- $sortoption = get_string('currenttooldest', 'mod_annopy');
- break;
- }
+ protected static function export_annotations_data(int $userid, $instanceid, $contextid) {
+ global $DB;
- writer::export_user_preference('mod_annopy', 'annopy_sortoption', $annopysortoption, $sortoption);
- }
+ // Find all annotations for this module instance made by the user.
+ $sql = "SELECT
+ a.id,
+ a.annopy,
+ a.submission,
+ a.userid,
+ a.timecreated,
+ a.timemodified,
+ a.type,
+ a.text
+ FROM {annopy_annotations} a
+ WHERE (
+ a.annopy = :instanceid AND
+ a.userid = :userid
+ )
+ ";
+
+ $params['userid'] = $userid;
+ $params['instanceid'] = $instanceid;
+
+ // Get the annotations.
+ $annotations = $DB->get_recordset_sql($sql, $params);
+
+ if ($annotations->valid()) {
+ foreach ($annotations as $annotation) {
+ if ($annotation) {
+ $context = \context::instance_by_id($contextid);
- if ($annopypagecount = get_user_preferences('annopy_pagecount', 0, $userid)) {
- writer::export_user_preference('mod_annopy', 'annopy_pagecount', $annopypagecount,
- get_string('privacy:metadata:preference:annopy_pagecount', 'mod_annopy'));
+ self::export_annotation_data($userid, $context, ['annopy-annotation-' . $annotation->id], $annotation);
+ }
+ }
}
- if ($annopyactivepage = get_user_preferences('annopy_activepage', 0, $userid)) {
- writer::export_user_preference('mod_annopy', 'annopy_activepage', $annopyactivepage,
- get_string('privacy:metadata:preference:annopy_activepage', 'mod_annopy'));
+ $annotations->close();
+ }
+
+ /**
+ * Export all data of the annotation.
+ *
+ * @param int $userid The userid of the user whose data is to be exported.
+ * @param \context $context The instance of the context.
+ * @param array $subcontext The location within the current context that this data belongs.
+ * @param \stdClass $annotation The annotation.
+ */
+ protected static function export_annotation_data(int $userid, \context $context, $subcontext, $annotation) {
+
+ if ($annotation->timemodified != 0) {
+ $timemodified = transform::datetime($annotation->timemodified);
+ } else {
+ $timemodified = null;
}
- } */
+ // Store related metadata.
+ $annotationdata = (object) [
+ 'annopy' => $annotation->annopy,
+ 'submission' => $annotation->submission,
+ 'userid' => $annotation->userid,
+ 'timecreated' => transform::datetime($annotation->timecreated),
+ 'timemodified' => $timemodified,
+ 'type' => $annotation->type,
+ 'text' => format_text($annotation->text, 2, ['para' => false]),
+ ];
+
+ // Store the annotation data.
+ writer::with_context($context)->export_data($subcontext, $annotationdata);
+ }
/**
* Delete all data for all users in the specified context.
@@ -382,24 +430,19 @@ public static function delete_data_for_all_users_in_context(\context $context) {
return;
}
- // Delete advanced grading information (not implemented yet).
-
- /* // Delete all ratings in the context.
- \core_rating\privacy\provider::delete_ratings($context, 'mod_annopy', 'entry');
-
- // Delete all files from the entry.
+ // Delete all files from the submission.
$fs = get_file_storage();
- $fs->delete_area_files($context->id, 'mod_annopy', 'entry');
- $fs->delete_area_files($context->id, 'mod_annopy', 'feedback');
-
- // Delete all records.
- if ($DB->record_exists('annopy_participants', ['annopy' => $cm->instance])) {
- $DB->delete_records('annopy_participants', ['annopy' => $cm->instance]);
- }
+ $fs->delete_area_files($context->id, 'mod_annopy', 'submission');
+ // Delete all submissions.
if ($DB->record_exists('annopy_submissions', ['annopy' => $cm->instance])) {
$DB->delete_records('annopy_submissions', ['annopy' => $cm->instance]);
- } */
+ }
+
+ // Delete all annotations.
+ if ($DB->record_exists('annopy_annotations', ['annopy' => $cm->instance])) {
+ $DB->delete_records('annopy_annotations', ['annopy' => $cm->instance]);
+ }
}
/**
@@ -417,40 +460,44 @@ public static function delete_data_for_user(approved_contextlist $contextlist) {
// Get the course module.
$cm = $DB->get_record('course_modules', ['id' => $context->instanceid]);
- /* // Handle any advanced grading method data first (not implemented yet).
-
// Delete ratings.
- $entriessql = "SELECT
- e.id
- FROM {annopy_entries} e
- WHERE (
- e.annopy = :annopyid AND
- e.userid = :userid
- )
+ $submissionssql = "SELECT
+ s.id
+ FROM {annopy_submissions} s
+ WHERE (
+ s.annopy = :instanceid AND
+ s.author = :author
+ )
";
- $entriesparams = [
- 'annopyid' => $cm->instance,
- 'userid' => $userid,
+ $submissionsparams = [
+ 'instanceid' => $cm->instance,
+ 'author' => $userid,
];
- \core_rating\privacy\provider::delete_ratings_select($context, 'mod_annopy',
- 'entry', "IN ($entriessql)", $entriesparams);
-
- // Delete all files from the entries.
+ // Delete all files from the submissions.
$fs = get_file_storage();
- $fs->delete_area_files_select($context->id, 'mod_annopy', 'entry', "IN ($entriessql)", $entriesparams);
- $fs->delete_area_files_select($context->id, 'mod_annopy', 'feedback', "IN ($entriessql)", $entriesparams);
+ $fs->delete_area_files_select($context->id, 'mod_annopy', 'submission', "IN ($submissionssql)", $submissionsparams);
- // Delete entries for user.
- if ($DB->record_exists('annopy_entries', ['annopy' => $cm->instance, 'userid' => $userid])) {
+ // Delete submissions for user.
+ if ($DB->record_exists('annopy_submissions', ['annopy' => $cm->instance, 'author' => $userid])) {
- $DB->delete_records('annopy_entries', [
+ $DB->delete_records('annopy_submissions', [
+ 'annopy' => $cm->instance,
+ 'author' => $userid,
+ ]);
+
+ }
+
+ // Delete annotations for user.
+ if ($DB->record_exists('annopy_annotations', ['annopy' => $cm->instance, 'userid' => $userid])) {
+
+ $DB->delete_records('annopy_annotations', [
'annopy' => $cm->instance,
'userid' => $userid,
]);
- } */
+ }
}
}
@@ -466,31 +513,35 @@ public static function delete_data_for_users(approved_userlist $userlist) {
$cm = $DB->get_record('course_modules', ['id' => $context->instanceid]);
list($userinsql, $userinparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
- $params = array_merge(['annopyid' => $cm->instance], $userinparams);
-
- // Handle any advanced grading method data first (not implemented yet).
-
- // Delete ratings.
- /* $entriesselect = "SELECT
- e.id
- FROM {annopy_entries} e
- WHERE (
- e.annopy = :annopyid AND
- userid {$userinsql}
- )
+ $params = array_merge(['instanceid' => $cm->instance], $userinparams);
+
+ $submissionselect = "SELECT
+ s.id
+ FROM {annopy_submissions} s
+ WHERE (
+ s.annopy = :instanceid AND
+ author {$userinsql}
+ )
";
- \core_rating\privacy\provider::delete_ratings_select($context, 'mod_annopy', 'entry', "IN ($entriesselect)", $params);
-
- // Delete all files from the entries.
+ // Delete all files from the submissions.
$fs = get_file_storage();
- $fs->delete_area_files_select($context->id, 'mod_annopy', 'entry', "IN ($entriesselect)", $params);
- $fs->delete_area_files_select($context->id, 'mod_annopy', 'feedback', "IN ($entriesselect)", $params);
+ $fs->delete_area_files_select($context->id, 'mod_annopy', 'submission', "IN ($submissionselect)", $params);
- // Delete entries for users.
- if ($DB->record_exists_select('annopy_entries', "annopy = :annopyid AND userid {$userinsql}", $params)) {
- $DB->delete_records_select('annopy_entries', "annopy = :annopyid AND userid {$userinsql}", $params);
- } */
+ // Delete annotations for users submissions that should be deleted.
+ if ($DB->record_exists_select('annopy_annotations', "submission IN ({$submissionsselect})", $params)) {
+ $DB->delete_records_select('annopy_annotations', "submission IN ({$submissionsselect})", $params);
+ }
+
+ // Delete submissions for users.
+ if ($DB->record_exists_select('annopy_submissions', "annopy = :instanceid AND author {$userinsql}", $params)) {
+ $DB->delete_records_select('annopy_submissions', "annopy = :instanceid AND author {$userinsql}", $params);
+ }
+
+ // Delete annotations for users.
+ if ($DB->record_exists_select('annopy_annotations', "annopy = :instanceid AND userid {$userinsql}", $params)) {
+ $DB->delete_records_select('annopy_annotations', "annopy = :instanceid AND userid {$userinsql}", $params);
+ }
}
}
diff --git a/classes/search/activity.php b/classes/search/activity.php
index 998ac0a..5e532b8 100644
--- a/classes/search/activity.php
+++ b/classes/search/activity.php
@@ -51,10 +51,10 @@ public function uses_file_indexing() {
* @return array
*/
public function get_search_fileareas() {
- $fileareas = array(
+ $fileareas = [
'intro',
- 'submission'
- ); // Fileareas.
+ 'submission',
+ ]; // Fileareas.
return $fileareas;
}
}
diff --git a/classes/search/submission.php b/classes/search/submission.php
index 7cb0d45..e85394d 100644
--- a/classes/search/submission.php
+++ b/classes/search/submission.php
@@ -38,35 +38,29 @@
class submission extends \core_search\base_mod {
/**
- *
- * @var array Internal quick static cache.
- */
- protected $entriesdata = array();
-
- /**
- * Returns recordset containing required data for indexing AnnoPy entries.
+ * Returns recordset containing required data for indexing AnnoPy submissions.
*
* @param int $modifiedfrom timestamp
* @param \context|null $context Optional context to restrict scope of returned results
* @return moodle_recordset|null Recordset (or null if no results)
*/
public function get_document_recordset($modifiedfrom = 0, \context $context = null) {
- /* global $DB;
+ global $DB;
list ($contextjoin, $contextparams) = $this->get_context_restriction_sql($context, 'annopy', 'm', SQL_PARAMS_NAMED);
if ($contextjoin === null) {
return null;
}
- $sql = "SELECT me.*, m.course
- FROM {annopy_entries} me
- JOIN {annopy} m ON m.id = me.annopy
+ $sql = "SELECT s.*, a.course
+ FROM {annopy_submissions} s
+ JOIN {annopy} a ON a.id = s.annopy
$contextjoin
- WHERE me.timemodified >= :timemodified
- ORDER BY me.timemodified ASC";
+ WHERE s.timemodified >= :timemodified
+ ORDER BY s.timemodified ASC";
return $DB->get_recordset_sql($sql, array_merge($contextparams, [
- 'timemodified' => $modifiedfrom
- ])); */
+ 'timemodified' => $modifiedfrom,
+ ]));
}
/**
@@ -76,8 +70,8 @@ public function get_document_recordset($modifiedfrom = 0, \context $context = nu
* @param array $options
* @return \core_search\document
*/
- public function get_document($submission, $options = array()) {
- /* try {
+ public function get_document($submission, $options = []) {
+ try {
$cm = $this->get_cm('annopy', $submission->annopy, $submission->course);
$context = \context_module::instance($cm->id);
} catch (\dml_missing_record_exception $ex) {
@@ -95,20 +89,20 @@ public function get_document($submission, $options = array()) {
$doc = \core_search\document_factory::instance($submission->id, $this->componentname, $this->areaname);
// I am using the submission date (timecreated) for the title.
$doc->set('title', content_to_text((userdate($submission->timecreated)), $submission->format));
- $doc->set('content', content_to_text('Entry: ' . $submission->text, $submission->format));
+ $doc->set('content', content_to_text('Submission: ' . $submission->text, $submission->format));
$doc->set('contextid', $context->id);
$doc->set('courseid', $submission->course);
- $doc->set('userid', $submission->userid);
+ $doc->set('userid', $submission->author);
$doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
$doc->set('modified', $submission->timemodified);
- $doc->set('description1', content_to_text('Feedback: ' . $submission->feedback, $submission->formatfeedback));
+ $doc->set('description1', '');
// Check if this document should be considered new.
if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $submission->timemodified)) {
// If the document was created after the last index time, it must be new.
$doc->set_is_new(true);
}
- return $doc; */
+ return $doc;
}
/**
@@ -120,10 +114,10 @@ public function get_document($submission, $options = array()) {
* @return bool
*/
public function check_access($id) {
- /* global $USER;
+ global $USER;
try {
- $submission = $this->get_entry($id);
+ $submission = $this->get_submission($id);
$cminfo = $this->get_cm('annopy', $submission->annopy, $submission->course);
} catch (\dml_missing_record_exception $ex) {
return \core_search\manager::ACCESS_DELETED;
@@ -135,11 +129,11 @@ public function check_access($id) {
return \core_search\manager::ACCESS_DENIED;
}
- if ($submission->userid != $USER->id && ! has_capability('mod/annopy:manageentries', $cminfo->context)) {
+ if ($submission->author != $USER->id && ! has_capability('mod/annopy:viewparticipants', $cminfo->context)) {
return \core_search\manager::ACCESS_DENIED;
}
- return \core_search\manager::ACCESS_GRANTED; */
+ return \core_search\manager::ACCESS_GRANTED;
}
/**
@@ -149,16 +143,16 @@ public function check_access($id) {
* @return \moodle_url
*/
public function get_doc_url(\core_search\document $doc) {
- /* global $USER;
+ global $USER;
$contextmodule = \context::instance_by_id($doc->get('contextid'));
- $entryuserid = $doc->get('userid');
+ $submissionuserid = $doc->get('userid');
$url = '/mod/annopy/view.php';
- return new \moodle_url($url, array(
- 'id' => $contextmodule->instanceid
- )); */
+ return new \moodle_url($url, [
+ 'id' => $contextmodule->instanceid,
+ ]);
}
/**
@@ -168,10 +162,10 @@ public function get_doc_url(\core_search\document $doc) {
* @return \moodle_url
*/
public function get_context_url(\core_search\document $doc) {
- /* $contextmodule = \context::instance_by_id($doc->get('contextid'));
- return new \moodle_url('/mod/annopy/view.php', array(
- 'id' => $contextmodule->instanceid
- )); */
+ $contextmodule = \context::instance_by_id($doc->get('contextid'));
+ return new \moodle_url('/mod/annopy/view.php', [
+ 'id' => $contextmodule->instanceid,
+ ]);
}
/**
@@ -180,13 +174,13 @@ public function get_context_url(\core_search\document $doc) {
* Store minimal information as this might grow.
*
* @throws \dml_exception
- * @param int $entryid
+ * @param int $submissionid
* @return stdClass
*/
- protected function get_entry($entryid) {
- /* global $DB;
- return $DB->get_record_sql("SELECT me.*, m.course FROM {annopy_entries} me
- JOIN {annopy} m ON m.id = me.annopy
- WHERE me.id = ?", array('id' => $entryid), MUST_EXIST); */
+ protected function get_submission($submissionid) {
+ global $DB;
+ return $DB->get_record_sql("SELECT s.*, a.course FROM {annopy_submissions} s
+ JOIN {annopy} a ON a.id = s.annopy
+ WHERE s.id = ?", ['id' => $submissionid], MUST_EXIST);
}
}
diff --git a/classes/task/task.php b/classes/task/task.php
deleted file mode 100644
index a12d3a3..0000000
--- a/classes/task/task.php
+++ /dev/null
@@ -1,54 +0,0 @@
-.
-
-/**
- * A cron_task class for doing stuff in AnnoPy to be used by Tasks API.
- *
- * @package mod_annopy
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
- namespace mod_annopy\task;
-
-/**
- * A cron_task class to be used by Tasks API.
- *
- * @package mod_annopy
- * @copyright 2023 coactum GmbH
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class task extends \core\task\scheduled_task {
- /**
- * Return the task's name as shown in admin screens.
- *
- * @return string
- */
- public function get_name() {
- return get_string('task', 'mod_annopy');
- }
-
- /**
- * Execute the task.
- */
- public function execute() {
- global $DB;
-
- mtrace('Starting scheduled task ' . get_string('task', 'mod_annopy'));
-
- mtrace('Task finished');
- }
-}
diff --git a/db/access.php b/db/access.php
index 2c3d4cd..c145b98 100644
--- a/db/access.php
+++ b/db/access.php
@@ -25,225 +25,225 @@
defined('MOODLE_INTERNAL') || die();
-$capabilities = array(
+$capabilities = [
- 'mod/annopy:addinstance' => array(
+ 'mod/annopy:addinstance' => [
'riskbitmask' => RISK_XSS,
'captype' => 'write',
'contextlevel' => CONTEXT_COURSE,
- 'archetypes' => array(
+ 'archetypes' => [
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- ),
- 'clonepermissionsfrom' => 'moodle/course:manageactivities'
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ 'clonepermissionsfrom' => 'moodle/course:manageactivities',
+ ],
- 'mod/annopy:potentialparticipant' => array(
+ 'mod/annopy:potentialparticipant' => [
'captype' => 'read',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'student' => CAP_ALLOW,
- )
- ),
+ ],
+ ],
- 'mod/annopy:viewparticipants' => array(
+ 'mod/annopy:viewparticipants' => [
'riskbitmask' => RISK_PERSONAL,
'captype' => 'read',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:manageparticipants' => array(
+ 'mod/annopy:manageparticipants' => [
'riskbitmask' => RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:addsubmission' => array(
+ 'mod/annopy:addsubmission' => [
'riskbitmask' => RISK_XSS | RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:editsubmission' => array(
+ 'mod/annopy:editsubmission' => [
'riskbitmask' => RISK_XSS | RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:deletesubmission' => array(
+ 'mod/annopy:deletesubmission' => [
'riskbitmask' => RISK_DATALOSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:addannotation' => array(
+ 'mod/annopy:addannotation' => [
'riskbitmask' => RISK_XSS | RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:editannotation' => array(
+ 'mod/annopy:editannotation' => [
'riskbitmask' => RISK_XSS | RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:deleteannotation' => array(
+ 'mod/annopy:deleteannotation' => [
'riskbitmask' => RISK_DATALOSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:viewannotations' => array(
+ 'mod/annopy:viewannotations' => [
'riskbitmask' => RISK_PERSONAL,
'captype' => 'read',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:viewannotationsevaluation' => array(
+ 'mod/annopy:viewannotationsevaluation' => [
'riskbitmask' => RISK_PERSONAL,
'captype' => 'read',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:viewmyannotationsummary' => array(
+ 'mod/annopy:viewmyannotationsummary' => [
'riskbitmask' => RISK_PERSONAL,
'captype' => 'read',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:addannotationstyle' => array(
+ 'mod/annopy:addannotationtype' => [
'riskbitmask' => RISK_XSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:editannotationstyle' => array(
+ 'mod/annopy:editannotationtype' => [
'riskbitmask' => RISK_XSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:deleteannotationstyle' => array(
+ 'mod/annopy:deleteannotationtype' => [
'riskbitmask' => RISK_DATALOSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:addannotationstyletemplate' => array(
+ 'mod/annopy:addannotationtypetemplate' => [
'riskbitmask' => RISK_XSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:editannotationstyletemplate' => array(
+ 'mod/annopy:editannotationtypetemplate' => [
'riskbitmask' => RISK_XSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:deleteannotationstyletemplate' => array(
+ 'mod/annopy:deleteannotationtypetemplate' => [
'riskbitmask' => RISK_DATALOSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
+ 'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
- 'manager' => CAP_ALLOW
- )
- ),
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
- 'mod/annopy:managedefaultannotationstyletemplates' => array(
+ 'mod/annopy:managedefaultannotationtypetemplates' => [
'riskbitmask' => RISK_XSS | RISK_DATALOSS,
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
- 'archetypes' => array(
- 'manager' => CAP_ALLOW
- )
- ),
-);
+ 'archetypes' => [
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
+];
diff --git a/db/events.php b/db/events.php
deleted file mode 100644
index d9735b3..0000000
--- a/db/events.php
+++ /dev/null
@@ -1,34 +0,0 @@
-.
-
-/**
- * Plugin event observers are registered here. All event subscriptions that the plugin needs to listen for are defined here.
- * @package mod_annopy
- * @category event
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-// List of observers.
-$observers = array(
- /*
- 'eventname' => '\core\event\course_module_created',
- 'callback' => '\plugintype_pluginname\event\observer\course_module_created::store',
- 'priority' => 1000,
- */
-);
diff --git a/db/install.php b/db/install.php
deleted file mode 100644
index 793a936..0000000
--- a/db/install.php
+++ /dev/null
@@ -1,32 +0,0 @@
-.
-
-/**
- * Code to be executed after the plugin's database scheme has been installed is defined here.
- *
- * @package mod_annopy
- * @category upgrade
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-/**
- * Custom code to be run on installing the plugin.
- */
-function xmldb_annopy_install() {
-
- return true;
-}
diff --git a/db/install.xml b/db/install.xml
index 6bf00bd..8b718f6 100644
--- a/db/install.xml
+++ b/db/install.xml
@@ -22,25 +22,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -85,15 +72,15 @@
-
+
-
-
-
-
-
+
+
+
+
+
-
+
@@ -103,15 +90,15 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/db/messages.php b/db/messages.php
deleted file mode 100644
index ec0df62..0000000
--- a/db/messages.php
+++ /dev/null
@@ -1,36 +0,0 @@
-.
-
-/**
- * Plugin message providers are defined here.
- *
- * @package mod_annopy
- * @category message
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-$messageproviders = array (
-/* 'sendmessages' => array(
- 'capability' => 'mod/annopy:sendmessages',
- 'defaults' => array(
- 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
- 'email' => MESSAGE_PERMITTED,
- ),
- ), */
-);
diff --git a/db/tasks.php b/db/tasks.php
deleted file mode 100644
index bab3115..0000000
--- a/db/tasks.php
+++ /dev/null
@@ -1,38 +0,0 @@
-.
-
-/**
- * Sheduled tasks for the plugin.
- *
- * @package mod_annopy
- * @category task
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-$tasks = array(
- /* [
- 'classname' => 'mod_annopy\task\task',
- 'blocking' => 0,
- 'minute' => '*',
- 'hour' => '*',
- 'day' => '*',
- 'month' => '*',
- 'dayofweek' => '*',
- ], */
-);
diff --git a/db/uninstall.php b/db/uninstall.php
deleted file mode 100644
index 947dc63..0000000
--- a/db/uninstall.php
+++ /dev/null
@@ -1,32 +0,0 @@
-.
-
-/**
- * Code that is executed before the tables and data are dropped during the plugin uninstallation.
- *
- * @package mod_annopy
- * @category upgrade
- * @copyright 2023 coactum GmbH
- * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-/**
- * Custom uninstallation procedure.
- */
-function xmldb_annopy_uninstall() {
-
- return true;
-}
diff --git a/index.php b/index.php
index ce1e6aa..353af8f 100644
--- a/index.php
+++ b/index.php
@@ -28,7 +28,7 @@
$id = required_param('id', PARAM_INT); // ID of the course.
if ($id) {
- if (!$course = $DB->get_record('course', array('id' => $id))) {
+ if (!$course = $DB->get_record('course', ['id' => $id])) {
throw new moodle_exception('invalidcourseid');
}
} else {
@@ -40,9 +40,9 @@
$coursecontext = context_course::instance($course->id);
// Trigger course_module_instance_list_viewed event.
-$event = \mod_annopy\event\course_module_instance_list_viewed::create(array(
- 'context' => $coursecontext
-));
+$event = \mod_annopy\event\course_module_instance_list_viewed::create([
+ 'context' => $coursecontext,
+]);
$event->add_record_snapshot('course', $course);
$event->trigger();
@@ -51,7 +51,7 @@
$PAGE->set_pagelayout('incourse');
-$PAGE->set_url('/mod/annopy/index.php', array('id' => $id));
+$PAGE->set_url('/mod/annopy/index.php', ['id' => $id]);
$PAGE->navbar->add($modulenameplural);
@@ -63,7 +63,7 @@
// Build table with all instances.
$modinfo = get_fast_modinfo($course);
-$moduleinstances = $modinfo->get_instances_of('annopy');
+$moduleinstances = get_all_instances_in_course('annopy', $course);
// Sections.
$usesections = course_format_uses_sections($course->format);
@@ -72,12 +72,12 @@
}
if (empty($moduleinstances)) {
- notice(get_string('nonewmodules', 'mod_annopy'), new moodle_url('/course/view.php', array('id' => $course->id)));
+ notice(get_string('nonewmodules', 'mod_annopy'), new moodle_url('/course/view.php', ['id' => $course->id]));
}
$table = new html_table();
-$table->head = array();
-$table->align = array();
+$table->head = [];
+$table->align = [];
if ($usesections) {
// Add column heading based on the course format. e.g. Week, Topic.
$table->head[] = get_string('sectionname', 'format_' . $course->format);
@@ -113,9 +113,9 @@
}
// Link.
- $annopyname = format_string($annopy->name, true, array(
- 'context' => $context
- ));
+ $annopyname = format_string($annopy->name, true, [
+ 'context' => $context,
+ ]);
if (! $annopy->visible) {
// Show dimmed if the mod is hidden.
$table->data[$i][] = "coursemodule\">" . $annopyname . "";
diff --git a/lang/de/annopy.php b/lang/de/annopy.php
index 27dab60..8c6bbac 100644
--- a/lang/de/annopy.php
+++ b/lang/de/annopy.php
@@ -30,22 +30,135 @@
// Strings for mod_form.php.
$string['modulename'] = 'AnnoPy';
-$string['modulename_help'] = 'Die Aktivität AnnoPy erlaubt ... ';
+$string['modulename_help'] = 'In der Aktivität AnnoPy können Lehrende unterschiedlichste multimediale Texte hochladen welche die Teilnehmerinnen und Teilnehmer dann annotieren können.
+
+Als kollaboratives Tool kann AnnoPy in zahlreichen schulischen oder universitären Lernkontexten auf verschiedenste Arten eingesetzt werden, um zum Beispiel die literalen Kompetenzen der Teilnehmenden zu fördern oder deren Verständnis eines Textes zu evaluieren.
+
+So kann AnnoPy zum Beispiel in der Sprachdidaktik eingesetzt werden, um das textvergleichende Lesen zu fördern, das Identifizieren sprachlicher Muster in Texten zu üben oder eine neue Perspektive auf erklärende Texte zu eröffnen. AnnoPy kann ebenfalls genutzt werden, um Texte auf inhaltlicher Ebene analysieren zu lassen, etwa im Hinblick auf semantische, grammatische, lexikalische oder textliterarische Fragestellungen. In Fächern wie Mathematik oder Informatik können Lehrkräfte AnnoPy hingegen nutzen, um ihre eigenen Vorlesungsskripte durcharbeiten zu lassen und dann auf einen Blick zu sehen, an welchen Stellen noch Verständnisschwierigkeiten auftreten.
+
+Lehrende können zunächst einen beliebigen multimedialen Text zur Annotation hochladen, dieser kann je nach didaktischem Einsatzkontext auch z.B. Bilder, Formeln oder Programmiercode enthalten.
+Alle Teilnehmenden können diesen Text dann annotieren, indem sie die gewünschten Textstellen markieren und dann für jede Annotation einen Typ auswählen sowie ggf. einen kurzen Kommentar hinterlassen.
+Die verfügbaren Annotationstypen können dabei genau wie wiederverwendbare Vorlagen durch die Lehrenden je nach Kontext flexibel angepasst werden.
+In einer übersichtlich visualisierten Auswertung können Lehrende schließlich sämtliche Annotationen der Teilnehmenden ansehen und detailliert analysieren.
+
+Kernfeatures des Plugins:
+
+* Hochladen verschiedenster Arten multimedialer Texte durch die Lehrenden
+* Separate Annotation dieser Texte inkl. Kommentar durch jeden einzelnen Teilnehmenden
+* Kumulierte sowie nach Teilnehmenden sortierte Anzeige aller Annotationen auf der Übersichtsseite
+* Durch Lehrende individuell anpassbare Annotationstypen sowie -Vorlagen
+* Eine übersichtliche und detaillierte Auswertung aller Annotation
+
+Weitere Informationen zum Konzept hinter AnnoPy und dessen möglichem Einsatz in Unterricht und Lehre finden sich auf Deutsch auf der aktuellen Projektwebseite (https://annopy.de/).';
$string['modulename_link'] = 'mod/annopy/view';
$string['pluginadministration'] = 'Administration der AnnoPy-Instanz';
+$string['noannotationtypetemplates'] = 'Bisher sind keine Annotationstypvorlagen vorhanden. Nach dem Erstellen des AnnoPys müssen deshalb noch manuell Annotationstypen angelegt werden.';
// Strings for index.php.
$string['modulenameplural'] = 'AnnoPys';
$string['nonewmodules'] = 'Keine neuen Instanzen';
+// Strings for submit_form.php and submit.php.
+$string['editsubmissionnotpossible'] = 'Bearbeiten der Einreichung fehlgeschlagen';
+$string['addsubmission'] = 'Einreichung anlegen';
+$string['editsubmission'] = 'Einreichung bearbeiten';
+$string['title'] = 'Titel';
+$string['submissioncontent'] = 'Inhalt der Einreichung';
+$string['submissioncreated'] = 'Einreichung angelegt';
+$string['submissionnotcreated'] = 'Einreichung konnte nicht angelegt werden';
+$string['submissionmodified'] = 'Einreichung aktualisiert';
+$string['submissionnotmodified'] = 'Einreichung konnte nicht aktualisiert werden';
+$string['submissionfaileddoubled'] = 'Einreichung konnte nicht angelegt werden da sie bereits existiert.';
+
// Strings for the view page.
$string['viewallannopys'] = 'Alle AnnoPy-Instanzen im Kurs ansehen';
$string['overview'] = 'Übersicht';
+$string['submission'] = 'Einreichung';
+$string['author'] = 'Autor';
+$string['timecreated'] = 'Zeitpunkt der Erstellung';
+$string['lastedited'] = 'Zuletzt bearbeitet';
+$string['currentversion'] = 'Versionsnummer';
+$string['details'] = 'Details';
+$string['numwordsraw'] = '{$a->wordscount} Wörter mit {$a->charscount} Zeichen, einschließlich {$a->spacescount} Leerzeichen.';
+$string['created'] = 'vor {$a->years} Jahren, {$a->month} Monaten, {$a->days} Tagen und {$a->hours} Stunden';
+$string['nosubmission'] = 'Keine Einreichung';
+$string['allannotations'] = 'Alle Annotationen';
+
+// Strings for annotations.
+$string['annotations'] = 'Annotationen';
+$string['toggleallannotations'] = 'Alle Annotation aus- / einklappen';
+$string['toggleannotation'] = 'Annotation aus- / einklappen';
+$string['hoverannotation'] = 'Annotation hervorheben';
+$string['annotationcreated'] = 'Erstellt am {$a}';
+$string['annotationmodified'] = 'Bearbeitet am {$a}';
+$string['editannotation'] = 'Bearbeiten';
+$string['deleteannotation'] = 'Löschen';
+$string['annotationsarefetched'] = 'Annotationen werden geladen';
+$string['reloadannotations'] = 'Annotationen neu laden';
+$string['annotationadded'] = 'Annotation hinzugefügt';
+$string['annotationedited'] = 'Annotation geändert';
+$string['annotationdeleted'] = 'Annotation gelöscht';
+$string['annotationinvalid'] = 'Annotation ungültig';
+$string['annotatedtextnotfound'] = 'Annotierter Text nicht gefunden';
+$string['annotatedtextinvalid'] = 'Der ursprünglich annotierte Text ist ungültig geworden. Die Markierung für diese Annotation muss deshalb neu gesetzt werden.';
+$string['deletedannotationtype'] = 'Gelöschter Typ';
+$string['annotationtypedeleted'] = 'Annotationstyp nicht vorhanden.';
-// Strings for lib.php.
-$string['deletealluserdata'] = 'Alle Benutzerdaten löschen';
+// Strings for annotations_summary and annotationtypes_form.
+$string['annotationssummary'] = 'Annotationsauswertung';
+$string['participant'] = 'TeilnehmerIn';
+$string['backtooverview'] = 'Zurück zur Übersicht';
+$string['addannotationtype'] = 'Annotationstyp anlegen';
+$string['annotationtypeadded'] = 'Annotationstyp angelegt';
+$string['editannotationtype'] = 'Annotationstyp bearbeiten';
+$string['annotationtypeedited'] = 'Annotationstyp bearbeitet';
+$string['editannotationtypetemplate'] = 'Vorlage bearbeiten';
+$string['annotationtypecantbeedited'] = 'Annotationstyp konnte nicht geändert werden';
+$string['deleteannotationtype'] = 'Annotationstyp entfernen';
+$string['annotationtypedeleted'] = 'Annotationstyp entfernt';
+$string['deleteannotationtypetemplate'] = 'Vorlage löschen';
+$string['deleteannotationtypetemplateconfirm'] = 'Soll diese Annotationstyp-Vorlage wirklich gelöscht werden? Dadurch wird die Vorlage für das gesamte System gelöscht und kann nicht mehr in neuen AnnoPys als konkreter Annotationstyp ausgewählt werden. Diese Aktion kann nicht rückgängig gemacht werden!';
+$string['annotationtypeinvalid'] = 'Annotationstyp ungültig';
+$string['annopyannotationtypes'] = 'AnnoPy Annotationstyp';
+$string['annotationtypetemplates'] = 'Annotationstyp-Vorlagen';
+$string['annotationtypes'] = 'Annotationstypen';
+$string['template'] = 'Vorlage';
+$string['addtoannopy'] = 'Zum AnnoPy hinzufügen';
+$string['switchtotemplatetypes'] = 'Zu den Annotationstyp-Vorlagen wechseln';
+$string['switchtoannopytypes'] = 'Zu den Annotationstypen des AnnoPys wechseln';
+$string['notemplatetypes'] = 'Keine Annotationstyp-Vorlagen verfügbar';
+$string['movefor'] = 'Weiter vorne anzeigen';
+$string['moveback'] = 'Weiter hinten anzeigen';
+$string['prioritychanged'] = 'Reihenfolge geändert';
+$string['prioritynotchanged'] = 'Reihenfolge konnte nicht geändert werden';
+$string['annotationcolor'] = 'Farbe des Annotationstyps';
+$string['standardtype'] = 'Standard Annotationstyp';
+$string['manualtype'] = 'Manueller Annotationstyp';
+$string['standard'] = 'Standard';
+$string['custom'] = 'Benutzerdefiniert';
+$string['type'] = 'Art';
+$string['color'] = 'Farbe';
+$string['errnohexcolor'] = 'Kein hexadezimaler Farbwert.';
+$string['warningeditdefaultannotationtypetemplate'] = 'WARNUNG: Hierdurch wird die Annotationstyp-Vorlage systemweit geändert. Bei der Erstellung neuer AnnoPys wird dann bei der Auswahl der konkreten Annotationstypen die geänderte Vorlage zur Verfügung stehen.';
+$string['changetemplate'] = 'Die Änderung des Namens oder der Farbe des Annotationstypen wirkt sich nur auf die Vorlage aus und wird daher erst bei der Erstellung neuer AnnoPys wirksam. Die Annotationstypen in bestehenden AnnoPys sind von diesen Änderungen nicht betroffen.';
+$string['explanationtypename'] = 'Name';
+$string['explanationtypename_help'] = 'Der Name des Annotationstypen. Wird nicht übersetzt.';
+$string['explanationhexcolor'] = 'Farbe';
+$string['explanationhexcolor_help'] = 'Die Farbe des Annotationstypen als Hexadezimalwert. Dieser besteht aus genau 6 Zeichen (A-F sowie 0-9) und repräsentiert eine Farbe. Wenn die Farbe hier ausgewählt wird wird der Wert automatisch eingetragen, alternativ kann der Hexwert auch eingegeben werden. Den Hexwert von beliebigen Farben kann man z. B. unter https://www.w3schools.com/colors/colors_picker.asp herausfinden.';
+$string['explanationstandardtype'] = 'Hier kann ausgewählt werden, ob der Annotationstyp ein Standardtyp sein soll. In diesem Fall kann er von allen Lehrenden für ihre AnnoPys ausgewählt und dann in diesen verwendet werden. Andernfalls kann er nur von Ihnen selbst in Ihren AnnoPys verwendet werden.';
+$string['viewannotationsofuser'] = 'Annotationen des Benutzers ansehen';
+
+// Strings for course reset.
+$string['deletealluserdata'] = 'Die Einreichung mitsamt verbundenen Dateien, alle Annotationen und alle Annotationstypen löschen';
+$string['deleteannotations'] = 'Alle Annotationen löschen';
+$string['annotationsdeleted'] = 'Annotationen wurden gelöscht';
+$string['deletesubmissionandfiles'] = 'Die Einreichung, alle verbundenen Dateien und alle zugehörigen Annotationen löschen';
+$string['submissionandfilesdeleted'] = 'Die Einreichung, alle verbundenen Dateien und alle zugehörigen Annotationen wurden gelöscht';
+$string['deleteannotationtypes'] = 'Alle Annotationstypen löschen';
+$string['annotationtypesdeleted'] = 'Alle Annotationstypen wurden gelöscht';
// Strings for the recent activity.
+$string['newannopyannotations'] = 'Neue AnnoPy Annotationen';
// Strings for the capabilities.
$string['annopy:addinstance'] = 'Neue AnnoPy-Instanz hinzufügen';
@@ -61,16 +174,15 @@
$string['annopy:viewannotations'] = 'Annotationen ansehen';
$string['annopy:viewannotationsevaluation'] = 'Annotationsauswertung ansehen';
$string['annopy:viewmyannotationsummary'] = 'Zusammenfasung meiner Annotationen ansehen';
-$string['annopy:addannotationstyle'] = 'Annotationsstil hinzufügen';
-$string['annopy:editannotationstyle'] = 'Annotationsstil bearbeiten';
-$string['annopy:deleteannotationstyle'] = 'Annotationsstil löschen';
-$string['annopy:addannotationstyletemplate'] = 'Annotationsstilvorlage hinzufügen';
-$string['annopy:editannotationstyletemplate'] = 'Annotationsstilvorlage bearbeiten';
-$string['annopy:deleteannotationstyletemplate'] = 'Annotationsstilvorlage löschen';
-$string['annopy:managedefaultannotationstyletemplates'] = 'Standard Annotationsstil-Vorlagen verwalten';
+$string['annopy:addannotationtype'] = 'Annotationstyp hinzufügen';
+$string['annopy:editannotationtype'] = 'Annotationstyp bearbeiten';
+$string['annopy:deleteannotationtype'] = 'Annotationstyp löschen';
+$string['annopy:addannotationtypetemplate'] = 'Annotationstypvorlage hinzufügen';
+$string['annopy:editannotationtypetemplate'] = 'Annotationstypvorlage bearbeiten';
+$string['annopy:deleteannotationtypetemplate'] = 'Annotationstypvorlage löschen';
+$string['annopy:managedefaultannotationtypetemplates'] = 'Standard Annotationstyp-Vorlagen verwalten';
// Strings for the tasks.
-$string['task'] = 'Aufgabe';
// Strings for the messages.
@@ -78,14 +190,43 @@
// Strings for the admin settings.
-// Strings for the events.
-$string['eventthingcreated'] = 'AnnoPy thing angelegt';
+// Strings for events.
+$string['eventsubmissioncreated'] = 'Einreichung abgegeben';
+$string['eventsubmissionupdated'] = 'Einreichung aktualisiert';
+$string['eventsubmissiondeleted'] = 'Einreichung gelöscht';
+$string['eventannotationcreated'] = 'Annotation angelegt';
+$string['eventannotationupdated'] = 'Annotation aktualisiert';
+$string['eventannotationdeleted'] = 'Annotation gelöscht';
+
+// Strings for error messages.
+$string['errfilloutfield'] = 'Bitte Feld ausfüllen';
+$string['incorrectcourseid'] = 'Inkorrekte Kurs-ID';
+$string['incorrectmodule'] = 'Inkorrekte Kurs-Modul-ID';
+$string['notallowedtodothis'] = 'Keine Berechtigung dies zu tun.';
+$string['alreadyannotated'] = 'Der Text kann nicht mehr bearbeitet werden da Teilnehmende ihn bereits annotiert haben.';
// Strings for the privacy api.
-/*
-$string['privacy:metadata:annopy_participants'] = 'Enthält die persönlichen Daten aller AnnoPys Teilnehmenden.';
-$string['privacy:metadata:annopy_submissions'] = 'Enthält alle Daten zu AnnoPy Einreichungen.';
-$string['privacy:metadata:annopy_participants:annopy'] = 'ID des AnnoPys des Teilnehmers';
-$string['privacy:metadata:annopy_submissions:annopy'] = 'ID des AnnoPys der Einreichung';
-$string['privacy:metadata:core_message'] = 'Das AnnoPy Plugin sendet Nachrichten an Benutzer und speichert deren Inhalte in der Datenbank.';
-*/
+$string['privacy:metadata:annopy_submissions'] = 'Enthält die Einreichungen aller AnnoPys.';
+$string['privacy:metadata:annopy_annotations'] = 'Enthält die in allen AnnoPys gemacht Annotationen.';
+$string['privacy:metadata:annopy_atype_templates'] = 'Enthält die von Lehrenden angelegten Annotationstyp-Vorlagen.';
+$string['privacy:metadata:annopy_submissions:annopy'] = 'ID des AnnoPy, zu dem die Einreichung gehört.';
+$string['privacy:metadata:annopy_submissions:author'] = 'ID des Autors der Einreichung.';
+$string['privacy:metadata:annopy_submissions:title'] = 'Der Titel der Einreichung.';
+$string['privacy:metadata:annopy_submissions:content'] = 'Der Inhalt der Einreichung.';
+$string['privacy:metadata:annopy_submissions:currentversion'] = 'Aktuelle Version der Einreichung.';
+$string['privacy:metadata:annopy_submissions:format'] = 'Format der Einreichung.';
+$string['privacy:metadata:annopy_submissions:timecreated'] = 'Zeitpunkt, an dem die Einreichung erstellt wurde.';
+$string['privacy:metadata:annopy_submissions:timemodified'] = 'Zeitpunkt der letzten Änderung der Einreichung.';
+$string['privacy:metadata:annopy_annotations:annopy'] = 'ID des AnnoPys, zu dem die annotierte Einreichung gehört.';
+$string['privacy:metadata:annopy_annotations:submission'] = 'ID der Einreichung, zu dem die Annotation gehört.';
+$string['privacy:metadata:annopy_annotations:userid'] = 'ID des Benutzers, der die Annotation angelegt hat.';
+$string['privacy:metadata:annopy_annotations:timecreated'] = 'Datum, an dem die Annotation erstellt wurde.';
+$string['privacy:metadata:annopy_annotations:timemodified'] = 'Zeitpunkt der letzten Änderung der Annotation.';
+$string['privacy:metadata:annopy_annotations:type'] = 'ID des Typs der Annotation.';
+$string['privacy:metadata:annopy_annotations:text'] = 'Inhalt der Annotation.';
+$string['privacy:metadata:annopy_atype_templates:timecreated'] = 'Datum, an dem die Annotationstyp-Vorlage erstellt wurde.';
+$string['privacy:metadata:annopy_atype_templates:timemodified'] = 'Zeitpunkt der letzten Änderung der Annotationstyp-Vorlage.';
+$string['privacy:metadata:annopy_atype_templates:name'] = 'Name der Annotationstyp-Vorlage.';
+$string['privacy:metadata:annopy_atype_templates:color'] = 'Farbe der Annotationstyp-Vorlage als Hex-Wert.';
+$string['privacy:metadata:annopy_atype_templates:userid'] = 'ID des Benutzers, der die Annotationstyp-Vorlage erstellt hat.';
+$string['privacy:metadata:core_files'] = 'Es werden mit AnnoPy Einreichungen verknüpfte Dateien gespeichert.';
diff --git a/lang/en/annopy.php b/lang/en/annopy.php
index 86b3232..71c9200 100644
--- a/lang/en/annopy.php
+++ b/lang/en/annopy.php
@@ -30,22 +30,135 @@
// Strings for mod_form.php.
$string['modulename'] = 'AnnoPy';
-$string['modulename_help'] = 'The AnnoPy activity allows ... ';
+$string['modulename_help'] = 'In the AnnoPy activity, teachers can upload a wide variety of multimedia texts which participants can then annotate.
+
+As a collaborative tool, AnnoPy can be used in many different ways in school or university learning contexts, for example to improve participants literary skills or to evaluate their understanding of a text.
+
+For example, AnnoPy can be used in language didactics to promote comparative reading, to practise identifying linguistic patterns in texts or to open up a new perspective on explanatory texts. AnnoPy can also be used to analyze texts on a content level, for example with regard to semantic, grammatical, lexical or text-literary issues. In subjects such as mathematics or computer science, on the other hand, teachers can use AnnoPy to have their own lecture notes worked through and then see at a glance where there are still difficulties in understanding.
+
+Teachers can first upload any multimedia text for annotation; depending on the didactic context, this can also contain images, formulas or programming code, for example.
+All participants can then annotate this text by marking the desired text passages and then selecting a type for each annotation and leaving a short comment if necessary.
+Just like reusable templates, the available annotation types can be flexibly adapted by the teacher depending on the context.
+Finally, teachers can view and analyze all of the participants annotations in detail in a clearly visualized evaluation.
+
+Core features of the plugin:
+
+* Upload of various types of multimedia texts by teachers
+* Separate annotation of these texts including comments by each individual participant
+* Cumulative display of all annotations on the overview page, sorted by participant
+* Annotation types and templates can be individually customized by teachers
+* A clear and detailed evaluation of all annotations
+
+Further information on the concept behind AnnoPy and its possible use in teaching and learning can be found in German on the current project website (https://annopy.de/).';
$string['modulename_link'] = 'mod/annopy/view';
$string['pluginadministration'] = 'Administration of AnnoPy';
+$string['noannotationtypetemplates'] = 'No annotation type templates are available yet. After creating the AnnoPy, annotation types must therefore be created manually.';
// Strings for index.php.
$string['modulenameplural'] = 'AnnoPys';
$string['nonewmodules'] = 'No new modules';
+// Strings for submit_form.php and submit.php.
+$string['editsubmissionnotpossible'] = 'Editing submission failed';
+$string['addsubmission'] = 'Add submission';
+$string['editsubmission'] = 'Edit submission';
+$string['title'] = 'Title';
+$string['submissioncontent'] = 'Content of the submission';
+$string['submissioncreated'] = 'Submission created';
+$string['submissionnotcreated'] = 'Submission could not be created';
+$string['submissionmodified'] = 'Submission updated';
+$string['submissionnotmodified'] = 'Submission could not be updated';
+$string['submissionfaileddoubled'] = 'Submission could not be created because it already exists';
+
// Strings for the view page.
$string['viewallannopys'] = 'View all AnnoPy instances in the course';
$string['overview'] = 'Overview';
+$string['submission'] = 'Submission';
+$string['author'] = 'Author';
+$string['timecreated'] = 'Time created';
+$string['lastedited'] = 'Last edited';
+$string['currentversion'] = 'Current version';
+$string['details'] = 'Details';
+$string['numwordsraw'] = '{$a->wordscount} text words using {$a->charscount} characters, including {$a->spacescount} spaces.';
+$string['created'] = '{$a->years} years, {$a->month} months, {$a->days} days and {$a->hours} hours ago';
+$string['nosubmission'] = 'No submission';
+$string['allannotations'] = 'All annotations';
+
+// Strings for annotations.
+$string['annotations'] = 'Annotations';
+$string['toggleallannotations'] = 'Toggle all annotations';
+$string['toggleannotation'] = 'Toggle annotation';
+$string['hoverannotation'] = 'Hover annotation';
+$string['annotationcreated'] = 'Created at {$a}';
+$string['annotationmodified'] = 'Modified at {$a}';
+$string['editannotation'] = 'Edit';
+$string['deleteannotation'] = 'Delete';
+$string['annotationsarefetched'] = 'Annotations being loaded';
+$string['reloadannotations'] = 'Reload annotations';
+$string['annotationadded'] = 'Annotation added';
+$string['annotationedited'] = 'Annotation edited';
+$string['annotationdeleted'] = 'Annotation deleted';
+$string['annotationinvalid'] = 'Annotation invalid';
+$string['annotatedtextnotfound'] = 'Annotated text not found';
+$string['annotatedtextinvalid'] = 'The originally annotated text has become invalid. The marking for this annotation must therefore be redone.';
+$string['deletedannotationtype'] = 'Deleted type';
+$string['annotationtypedeleted'] = 'Annotation type does not exists.';
-// Strings for lib.php.
-$string['deletealluserdata'] = 'Delete all user data';
+// Strings for annotations_summary and annotationtypes_form.
+$string['annotationssummary'] = 'Annotations summary';
+$string['participant'] = 'Participant';
+$string['backtooverview'] = 'Back to overview';
+$string['addannotationtype'] = 'Add annotation type';
+$string['annotationtypeadded'] = 'Annotation type added';
+$string['editannotationtype'] = 'Edit annotation type';
+$string['annotationtypeedited'] = 'Annotation type edited';
+$string['editannotationtypetemplate'] = 'Edit template';
+$string['annotationtypecantbeedited'] = 'Annotation type could not be changed';
+$string['deleteannotationtype'] = 'Delete annotation type';
+$string['annotationtypedeleted'] = 'Annotation type deleted';
+$string['deleteannotationtypetemplate'] = 'Delete template';
+$string['deleteannotationtypetemplateconfirm'] = 'Should this annotation type template really be deleted? This deletes the template for the entire system so that it can no longer be used as a concrete annotation type in new AnnoPys. This action cannot be undone!';
+$string['annotationtypeinvalid'] = 'Annotation type invalid';
+$string['annopyannotationtypes'] = 'AnnoPy annotation types';
+$string['annotationtypetemplates'] = 'Annotation type templates';
+$string['annotationtypes'] = 'Annotation types';
+$string['template'] = 'Template';
+$string['addtoannopy'] = 'Add to AnnoPy';
+$string['switchtotemplatetypes'] = 'Switch to the annotation type templates';
+$string['switchtoannopytypes'] = 'Switch to the annotation types for the AnnoPy';
+$string['notemplatetypes'] = 'No annotation type templates available';
+$string['movefor'] = 'Display more in front';
+$string['moveback'] = 'Display further back';
+$string['prioritychanged'] = 'Order changed';
+$string['prioritynotchanged'] = 'Order could not be changed';
+$string['annotationcolor'] = 'Color of the annotation type';
+$string['standardtype'] = 'Standard annotation type';
+$string['manualtype'] = 'Manual annotation type';
+$string['standard'] = 'Standard';
+$string['custom'] = 'Custom';
+$string['type'] = 'Type';
+$string['color'] = 'Color';
+$string['errnohexcolor'] = 'No hexadecimal value for color.';
+$string['warningeditdefaultannotationtypetemplate'] = 'WARNING: This will change the annotation type template system-wide. When creating new AnnoPys, the changed template will then be available for selecting the concrete AnnoPy annotation types.';
+$string['changetemplate'] = 'Changing the name or color of the annotation type only affects the template and therefore only takes effect when new AnnoPys are created. The annotation types in existing AnnoPys are not affected by these changes.';
+$string['explanationtypename'] = 'Name';
+$string['explanationtypename_help'] = 'The name of the annotation type. Will not be translated.';
+$string['explanationhexcolor'] = 'Color';
+$string['explanationhexcolor_help'] = 'The color of the annotation type as hexadecimal value. This consists of exactly 6 characters (A-F as well as 0-9) and represents a color. If the color is selected here the value is entered automatically, alternatively the hex value can also be entered manually. You can find out the hexadecimal value of any color, for example, at https://www.w3schools.com/colors/colors_picker.asp.';
+$string['explanationstandardtype'] = 'Here you can select whether the annotation type should be a default type. In this case teachers can select it as annotation type that can be used in their AnnoPys. Otherwise, only you can add this annotation type to your AnnoPys.';
+$string['viewannotationsofuser'] = 'View annotations of the user';
+
+// Strings for course reset.
+$string['deletealluserdata'] = 'Delete the submission with associated files, all annotations and all annotation types.';
+$string['deleteannotations'] = 'Delete all annotations';
+$string['annotationsdeleted'] = 'Annotations were deleted';
+$string['deletesubmissionandfiles'] = 'Delete the submission, all associated files and all associated annotations';
+$string['submissionandfilesdeleted'] = 'The submission, all associated files and all associated annotations been deleted';
+$string['deleteannotationtypes'] = 'Delete all annotation types';
+$string['annotationtypesdeleted'] = 'All annotation types were deleted';
// Strings for the recent activity.
+$string['newannopyannotations'] = 'New AnnoPy annotations';
// Strings for the capabilities.
$string['annopy:addinstance'] = 'Add new AnnoPy';
@@ -61,16 +174,15 @@
$string['annopy:viewannotations'] = 'View annotations';
$string['annopy:viewannotationsevaluation'] = 'View annotations evaluation';
$string['annopy:viewmyannotationsummary'] = 'View summary of my annotations';
-$string['annopy:addannotationstyle'] = 'Add annotation style';
-$string['annopy:editannotationstyle'] = 'Edit annotation style';
-$string['annopy:deleteannotationstyle'] = 'Delete annotation style';
-$string['annopy:addannotationstyletemplate'] = 'Add annotation style template';
-$string['annopy:editannotationstyletemplate'] = 'Edit annotation style template';
-$string['annopy:deleteannotationstyletemplate'] = 'Delete annotation style template';
-$string['annopy:managedefaultannotationstyletemplates'] = 'Manage default annotation style templates';
+$string['annopy:addannotationtype'] = 'Add annotation type';
+$string['annopy:editannotationtype'] = 'Edit annotation type';
+$string['annopy:deleteannotationtype'] = 'Delete annotation type';
+$string['annopy:addannotationtypetemplate'] = 'Add annotation type template';
+$string['annopy:editannotationtypetemplate'] = 'Edit annotation type template';
+$string['annopy:deleteannotationtypetemplate'] = 'Delete annotation type template';
+$string['annopy:managedefaultannotationtypetemplates'] = 'Manage default annotation type templates';
// Strings for the tasks.
-$string['task'] = 'Task';
// Strings for the messages.
@@ -78,14 +190,43 @@
// Strings for the admin settings.
-// Strings for the events.
-$string['eventthingcreated'] = 'AnnoPy thing created';
+// Strings for events.
+$string['eventsubmissioncreated'] = 'Submission created';
+$string['eventsubmissionupdated'] = 'Submission updated';
+$string['eventsubmissiondeleted'] = 'Submission deleted';
+$string['eventannotationcreated'] = 'Annotation created';
+$string['eventannotationupdated'] = 'Annotation updated';
+$string['eventannotationdeleted'] = 'Annotation deleted';
+
+// Strings for error messages.
+$string['errfilloutfield'] = 'Please fill out this field';
+$string['incorrectcourseid'] = 'Course ID is incorrect';
+$string['incorrectmodule'] = 'Course Module ID is incorrect';
+$string['notallowedtodothis'] = 'No permissions to do this.';
+$string['alreadyannotated'] = 'The text can no longer be edited because participants have already annotated it.';
// Strings for the privacy api.
-/*
-$string['privacy:metadata:annopy_participants'] = 'Contains the personal data of all AnnoPy participants.';
-$string['privacy:metadata:annopy_submissions'] = 'Contains all data related to AnnoPy submissions.';
-$string['privacy:metadata:annopy_participants:annopy'] = 'Id of the AnnoPy activity the participant belongs to';
-$string['privacy:metadata:annopy_submissions:annopy'] = 'Id of the AnnoPy activity the submission belongs to';
-$string['privacy:metadata:core_message'] = 'The AnnoPy plugin sends messages to users and saves their content in the database.';
-*/
+$string['privacy:metadata:annopy_submissions'] = 'Contains the submissions of all AnnoPys.';
+$string['privacy:metadata:annopy_annotations'] = 'Contains the annotations made in all AnnoPys.';
+$string['privacy:metadata:annopy_atype_templates'] = 'Contains the annotation type templates created by teachers.';
+$string['privacy:metadata:annopy_submissions:annopy'] = 'ID of the AnnoPy the submission belongs to.';
+$string['privacy:metadata:annopy_submissions:author'] = 'ID of the author of the submission.';
+$string['privacy:metadata:annopy_submissions:title'] = 'Title of the submission.';
+$string['privacy:metadata:annopy_submissions:content'] = 'Content of the submission.';
+$string['privacy:metadata:annopy_submissions:currentversion'] = 'Current version of the submission.';
+$string['privacy:metadata:annopy_submissions:format'] = 'Submission format.';
+$string['privacy:metadata:annopy_submissions:timecreated'] = 'Date on which the submission was made.';
+$string['privacy:metadata:annopy_submissions:timemodified'] = 'Time the submission was last modified.';
+$string['privacy:metadata:annopy_annotations:annopy'] = 'ID of the AnnoPy the annotated submission belongs to.';
+$string['privacy:metadata:annopy_annotations:submission'] = 'ID of the submission the annotation belongs to.';
+$string['privacy:metadata:annopy_annotations:userid'] = 'ID of the user that made the annotation.';
+$string['privacy:metadata:annopy_annotations:timecreated'] = 'Date on which the annotation was created.';
+$string['privacy:metadata:annopy_annotations:timemodified'] = 'Time the annotation was last modified.';
+$string['privacy:metadata:annopy_annotations:type'] = 'ID of the type of the annotation.';
+$string['privacy:metadata:annopy_annotations:text'] = 'Content of the annotation.';
+$string['privacy:metadata:annopy_atype_templates:timecreated'] = 'Date on which the annotation type template was created.';
+$string['privacy:metadata:annopy_atype_templates:timemodified'] = 'Time the annotation type template was last modified.';
+$string['privacy:metadata:annopy_atype_templates:name'] = 'Name of the annotation type template.';
+$string['privacy:metadata:annopy_atype_templates:color'] = 'Color of the annotation type template as hexadecimal value.';
+$string['privacy:metadata:annopy_atype_templates:userid'] = 'ID of the user that made the annotation type template.';
+$string['privacy:metadata:core_files'] = 'Files associated with AnnoPy submissions are saved.';
diff --git a/lib.php b/lib.php
index bec5fc9..73dd5f2 100644
--- a/lib.php
+++ b/lib.php
@@ -27,10 +27,6 @@
*
* @uses FEATURE_MOD_INTRO
* @uses FEATURE_SHOW_DESCRIPTION
- * @uses FEATURE_GRADE_HAS_GRADE
- * @uses FEATURE_RATE
- * @uses FEATURE_GROUPS
- * @uses FEATURE_GROUPINGS
* @uses FEATURE_COMPLETION_TRACKS_VIEWS
* @uses FEATURE__BACKUP_MOODLE2
* @param string $feature Constant for requested feature.
@@ -48,10 +44,6 @@ function annopy_supports($feature) {
return true;
case FEATURE_SHOW_DESCRIPTION:
return true;
- case FEATURE_GROUPS:
- return true;
- case FEATURE_GROUPINGS:
- return true;
case FEATURE_COMPLETION_TRACKS_VIEWS:
return true;
case FEATURE_BACKUP_MOODLE2:
@@ -77,30 +69,25 @@ function annopy_supports($feature) {
function annopy_add_instance($moduleinstance, $mform = null) {
global $DB;
- // Handle access time for grading.
- /* if (empty($moduleinstance->assessed)) {
- $moduleinstance->assessed = 0;
- }
-
- if (empty($moduleinstance->ratingtime) || empty($moduleinstance->assessed)) {
- $moduleinstance->assesstimestart = 0;
- $moduleinstance->assesstimefinish = 0;
- } */
-
$moduleinstance->timecreated = time();
$moduleinstance->id = $DB->insert_record('annopy', $moduleinstance);
- /* // Add calendar dates.
- helper::annopy_update_calendar($moduleinstance, $moduleinstance->coursemodule);
+ if (isset($moduleinstance->annotationtypes) && !empty($moduleinstance->annotationtypes)) {
+ // Add annotation types for the module instance.
+ $priority = 1;
+ foreach ($moduleinstance->annotationtypes as $id => $checked) {
+ if ($checked) {
+ $type = $DB->get_record('annopy_atype_templates', ['id' => $id]);
+ $type->annopy = $moduleinstance->id;
+ $type->priority = $priority;
- // Add expected completion date.
- if (! empty($moduleinstance->completionexpected)) {
- \core_completion\api::update_completion_date_event($moduleinstance->coursemodule,
- 'annopy', $moduleinstance->id, $moduleinstance->completionexpected);
- }
+ $priority += 1;
- annopy_grade_item_update($moduleinstance); */
+ $DB->insert_record('annopy_annotationtypes', $type);
+ }
+ }
+ }
return $moduleinstance->id;
}
@@ -121,54 +108,8 @@ function annopy_update_instance($moduleinstance, $mform = null) {
$moduleinstance->timemodified = time();
$moduleinstance->id = $moduleinstance->instance;
- /* if (empty($moduleinstance->assessed)) {
- $moduleinstance->assessed = 0;
- }
-
- if (empty($moduleinstance->ratingtime) || empty($moduleinstance->assessed)) {
- $moduleinstance->assesstimestart = 0;
- $moduleinstance->assesstimefinish = 0;
- }
-
- if (empty($moduleinstance->notification)) {
- $moduleinstance->notification = 0;
- }
-
- // If the aggregation type or scale (i.e. max grade) changes then recalculate the grades for the entire moduleinstance
- // if scale changes - do we need to recheck the ratings, if ratings higher than scale how do we want to respond?
- // for count and sum aggregation types the grade we check to make sure they do not exceed the scale (i.e. max score)
- // when calculating the grade.
- $oldmoduleinstance = $DB->get_record('annopy', array('id' => $moduleinstance->id));
-
- $updategrades = false;
-
- if ($oldmoduleinstance->assessed <> $moduleinstance->assessed) {
- // Whether this moduleinstance is rated.
- $updategrades = true;
- }
-
- if ($oldmoduleinstance->scale <> $moduleinstance->scale) {
- // The scale currently in use.
- $updategrades = true;
- }
-
- if ($updategrades) {
- annopy_update_grades($moduleinstance); // Recalculate grades for the moduleinstance.
- } */
-
$DB->update_record('annopy', $moduleinstance);
- /* // Update calendar.
- helper::annopy_update_calendar($moduleinstance, $moduleinstance->coursemodule);
-
- // Update completion date.
- $completionexpected = (! empty($moduleinstance->completionexpected)) ? $moduleinstance->completionexpected : null;
- \core_completion\api::update_completion_date_event($moduleinstance->coursemodule,
- 'annopy', $moduleinstance->id, $completionexpected);
-
- // Update grade.
- annopy_grade_item_update($moduleinstance); */
-
return true;
}
@@ -185,42 +126,39 @@ function annopy_update_instance($moduleinstance, $mform = null) {
function annopy_delete_instance($id) {
global $DB;
- if (!$annopy = $DB->get_record('annopy', array('id' => $id))) {
+ if (!$annopy = $DB->get_record('annopy', ['id' => $id])) {
return false;
}
if (!$cm = get_coursemodule_from_instance('annopy', $annopy->id)) {
return false;
}
- if (!$course = $DB->get_record('course', array('id' => $cm->course))) {
+ if (!$course = $DB->get_record('course', ['id' => $cm->course])) {
return false;
}
- /*
$context = context_module::instance($cm->id);
// Delete files.
$fs = get_file_storage();
$fs->delete_area_files($context->id);
- // Update completion for calendar events.
- \core_completion\api::update_completion_date_event($cm->id, 'annopy', $annopy->id, null);
+ // Delete submission.
+ $DB->delete_records("annopy_submissions", ["annopy" => $annopy->id]);
- // Delete grades.
- annopy_grade_item_delete($annopy);
- */
+ // Delete annotations.
+ $DB->delete_records("annopy_annotations", ["annopy" => $annopy->id]);
- // Delete other db tables.
- // ...
+ // Delete annotation types for the module instance.
+ $DB->delete_records("annopy_annotationtypes", ["annopy" => $annopy->id]);
- // Delete annopy, else return false.
- if (!$DB->delete_records("annopy", array("id" => $annopy->id))) {
+ // Delete module instance, else return false.
+ if (!$DB->delete_records("annopy", ["id" => $annopy->id])) {
return false;
}
return true;
}
-
/**
* Returns a small object with summary information about what a
* user has done with a given particular instance of this module
@@ -251,13 +189,13 @@ function annopy_user_outline($course, $user, $mod, $annopy) {
* @return boolean
*/
function annopy_print_recent_activity($course, $viewfullnames, $timestart) {
- /* global $CFG, $USER, $DB, $OUTPUT;
+ global $CFG, $USER, $DB, $OUTPUT;
- $params = array(
+ $params = [
$timestart,
$course->id,
- 'annopy'
- );
+ 'annopy',
+ ];
// Moodle branch check.
if ($CFG->branch < 311) {
@@ -267,40 +205,41 @@ function annopy_print_recent_activity($course, $viewfullnames, $timestart) {
$namefields = $userfieldsapi->get_sql('u', false, '', 'userid', false)->selects;;
}
- $sql = "SELECT e.id, e.timecreated, cm.id AS cmid, $namefields
- FROM {annopy_entries} e
- JOIN {annopy} d ON d.id = e.annopy
- JOIN {course_modules} cm ON cm.instance = d.id
- JOIN {modules} md ON md.id = cm.module
- JOIN {user} u ON u.id = e.userid
- WHERE e.timecreated > ? AND d.course = ? AND md.name = ?
+ $sql = "SELECT aa.id, aa.timecreated, cm.id AS cmid, $namefields
+ FROM {annopy_annotations} aa
+ JOIN {annopy_submissions} s ON s.id = aa.submission
+ JOIN {annopy} a ON a.id = s.annopy
+ JOIN {course_modules} cm ON cm.instance = a.id
+ JOIN {modules} m ON m.id = cm.module
+ JOIN {user} u ON u.id = aa.userid
+ WHERE aa.timecreated > ? AND a.course = ? AND m.name = ?
ORDER BY timecreated DESC
";
- $newentries = $DB->get_records_sql($sql, $params);
+ $newannotations = $DB->get_records_sql($sql, $params);
$modinfo = get_fast_modinfo($course);
- $show = array();
+ $show = [];
- foreach ($newentries as $entry) {
- if (! array_key_exists($entry->cmid, $modinfo->get_cms())) {
+ foreach ($newannotations as $annotation) {
+ if (! array_key_exists($annotation->cmid, $modinfo->get_cms())) {
continue;
}
- $cm = $modinfo->get_cm($entry->cmid);
+ $cm = $modinfo->get_cm($annotation->cmid);
if (! $cm->uservisible) {
continue;
}
- if ($entry->userid == $USER->id) {
- $show[] = $entry;
+ if ($annotation->userid == $USER->id) {
+ $show[] = $annotation;
continue;
}
- $context = context_module::instance($entry->cmid);
+ $context = context_module::instance($annotation->cmid);
- $teacher = has_capability('mod/annopy:manageentries', $context);
+ $teacher = has_capability('mod/annopy:viewparticipants', $context);
- // Only teachers can see other students entries.
+ // Only teachers can see other students annotations.
if (!$teacher) {
continue;
}
@@ -317,7 +256,7 @@ function annopy_print_recent_activity($course, $viewfullnames, $timestart) {
if (! $modinfo->get_groups($cm->groupingid)) {
continue;
}
- $usersgroups = groups_get_all_groups($course->id, $entry->userid, $cm->groupingid);
+ $usersgroups = groups_get_all_groups($course->id, $annotation->userid, $cm->groupingid);
if (is_array($usersgroups)) {
$usersgroups = array_keys($usersgroups);
$intersect = array_intersect($usersgroups, $modinfo->get_groups($cm->groupingid));
@@ -326,25 +265,24 @@ function annopy_print_recent_activity($course, $viewfullnames, $timestart) {
}
}
}
- $show[] = $entry;
+ $show[] = $annotation;
}
if (empty($show)) {
return false;
}
- echo $OUTPUT->heading(get_string('newannopyentries', 'annopy') . ':', 6);
+ echo $OUTPUT->heading(get_string('newannopyannotations', 'annopy') . ':', 6);
- foreach ($show as $entry) {
- $cm = $modinfo->get_cm($entry->cmid);
- $context = context_module::instance($entry->cmid);
+ foreach ($show as $annotation) {
+ $cm = $modinfo->get_cm($annotation->cmid);
+ $context = context_module::instance($annotation->cmid);
$link = $CFG->wwwroot . '/mod/annopy/view.php?id=' . $cm->id;
- print_recent_activity_note($entry->timecreated, $entry, $cm->name, $link, false, $viewfullnames);
+ print_recent_activity_note($annotation->timecreated, $annotation, $cm->name, $link, false, $viewfullnames);
echo ' ';
}
- return true; */
- return false; // True if anything was printed, otherwise false.
+ return true;
}
/**
@@ -372,18 +310,18 @@ function annopy_print_recent_activity($course, $viewfullnames, $timestart) {
*/
function annopy_get_recent_mod_activity(&$activities, &$index, $timestart, $courseid, $cmid, $userid = 0, $groupid = 0) {
- /* global $CFG, $COURSE, $USER, $DB;
+ global $CFG, $COURSE, $USER, $DB;
if ($COURSE->id == $courseid) {
$course = $COURSE;
} else {
- $course = $DB->get_record('course', array('id' => $courseid));
+ $course = $DB->get_record('course', ['id' => $courseid]);
}
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($cmid);
- $params = array();
+ $params = [];
if ($userid) {
$userselect = 'AND u.id = :userid';
$params['userid'] = $userid;
@@ -411,35 +349,35 @@ function annopy_get_recent_mod_activity(&$activities, &$index, $timestart, $cour
$userfields = $userfieldsapi->get_sql('u', false, '', 'userid', false)->selects;
}
- $entries = $DB->get_records_sql(
- 'SELECT e.id, e.timecreated, ' . $userfields .
- ' FROM {annopy_entries} e
- JOIN {annopy} m ON m.id = e.annopy
- JOIN {user} u ON u.id = e.userid ' . $groupjoin .
- ' WHERE e.timecreated > :timestart AND
- m.id = :cminstance
+ $annotations = $DB->get_records_sql(
+ 'SELECT aa.id, aa.timecreated, ' . $userfields .
+ ' FROM {annopy_annotations} aa
+ JOIN {annopy_submissions} s ON s.id = aa.annopy
+ JOIN {annopy} a ON a.id = s.annopy
+ JOIN {user} u ON u.id = aa.userid ' . $groupjoin .
+ ' WHERE aa.timecreated > :timestart AND
+ a.id = :cminstance
' . $userselect . ' ' . $groupselect .
- ' ORDER BY e.timecreated DESC', $params);
+ ' ORDER BY aa.timecreated DESC', $params);
- if (!$entries) {
+ if (!$annotations) {
return;
}
$groupmode = groups_get_activity_groupmode($cm, $course);
$cmcontext = context_module::instance($cm->id);
- $grader = has_capability('moodle/grade:viewall', $cmcontext);
$accessallgroups = has_capability('moodle/site:accessallgroups', $cmcontext);
$viewfullnames = has_capability('moodle/site:viewfullnames', $cmcontext);
- $teacher = has_capability('mod/annopy:manageentries', $cmcontext);
+ $teacher = has_capability('mod/annopy:viewparticipants', $cmcontext);
- $show = array();
- foreach ($entries as $entry) {
- if ($entry->userid == $USER->id) {
- $show[] = $entry;
+ $show = [];
+ foreach ($annotations as $annotation) {
+ if ($annotation->userid == $USER->id) {
+ $show[] = $annotation;
continue;
}
- // Only teachers can see other students entries.
+ // Only teachers can see other students annotations.
if (!$teacher) {
continue;
}
@@ -454,7 +392,7 @@ function annopy_get_recent_mod_activity(&$activities, &$index, $timestart, $cour
if (!$modinfo->get_groups($cm->groupingid)) {
continue;
}
- $usersgroups = groups_get_all_groups($course->id, $entry->userid, $cm->groupingid);
+ $usersgroups = groups_get_all_groups($course->id, $annotation->userid, $cm->groupingid);
if (is_array($usersgroups)) {
$usersgroups = array_keys($usersgroups);
$intersect = array_intersect($usersgroups, $modinfo->get_groups($cm->groupingid));
@@ -463,35 +401,23 @@ function annopy_get_recent_mod_activity(&$activities, &$index, $timestart, $cour
}
}
}
- $show[] = $entry;
+ $show[] = $annotation;
}
if (empty($show)) {
return;
}
- if ($grader) {
- require_once($CFG->libdir.'/gradelib.php');
- $userids = array();
- foreach ($show as $id => $entry) {
- $userids[] = $entry->userid;
- }
- $grades = grade_get_grades($courseid, 'mod', 'annopy', $cm->instance, $userids);
- }
-
$aname = format_string($cm->name, true);
- foreach ($show as $entry) {
+ foreach ($show as $annotation) {
$activity = new stdClass();
$activity->type = 'annopy';
$activity->cmid = $cm->id;
$activity->name = $aname;
$activity->sectionnum = $cm->sectionnum;
- $activity->timestamp = $entry->timecreated;
+ $activity->timestamp = $annotation->timecreated;
$activity->user = new stdClass();
- if ($grader) {
- $activity->grade = $grades->items[0]->grades[$entry->userid]->str_long_grade;
- }
if ($CFG->branch < 311) {
$userfields = explode(',', user_picture::fields());
@@ -502,17 +428,17 @@ function annopy_get_recent_mod_activity(&$activities, &$index, $timestart, $cour
foreach ($userfields as $userfield) {
if ($userfield == 'id') {
// Aliased in SQL above.
- $activity->user->{$userfield} = $entry->userid;
+ $activity->user->{$userfield} = $annotation->userid;
} else {
- $activity->user->{$userfield} = $entry->{$userfield};
+ $activity->user->{$userfield} = $annotation->{$userfield};
}
}
- $activity->user->fullname = fullname($entry, $viewfullnames);
+ $activity->user->fullname = fullname($annotation, $viewfullnames);
$activities[$index++] = $activity;
}
- return; */
+ return;
}
/**
@@ -543,9 +469,9 @@ function annopy_print_recent_mod_activity($activity, $courseid, $detail, $modnam
echo '';
}
- echo '
\ No newline at end of file
diff --git a/templates/annopy_pagination.mustache b/templates/annopy_pagination.mustache
new file mode 100644
index 0000000..23dbf5f
--- /dev/null
+++ b/templates/annopy_pagination.mustache
@@ -0,0 +1,46 @@
+{{!
+ This file is part of Moodle - https://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @copyright 2023 coactum GmbH
+ @template annopy/annopy_pagination
+
+ Template for the pagination area.
+
+ Example context (json):
+ {
+ }
+}}
+
+{{#js}}
+{{/js}}
+
+
+
+
\ No newline at end of file
diff --git a/templates/annopy_view.mustache b/templates/annopy_view.mustache
index 5da8ce9..2542704 100644
--- a/templates/annopy_view.mustache
+++ b/templates/annopy_view.mustache
@@ -27,5 +27,119 @@
{{#js}}
{{/js}}
-