diff --git a/README.md b/README.md index d3f2ae9b3f2ed44f0e96134a4c24e00bdd471411..3fdd1f816674189d19a3f75fe214ce986f55bfe6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ ## AIDmi - AI, describe my image! -AIDmi provides a button that will use an AI API to send an image to return a detailed description. +AIDmi provides a button that will use an AI API +to send an image to return a detailed description. ## Installation @@ -9,10 +10,10 @@ Enable aidmi. drush en aidmi - Configure the settings under Configure > Web services > AIDmi Settings - - Select API: Only Gemini at the moment. - - API Key Input Method: API key in Settings or File Path - - File Path is recommended. Make sure you save the file outside of web/ (Example on page) - - API Instructions: Default text provided that is sent to AI for image description. +- Select API: Only Gemini at the moment. +- API Key Input Method: API key in Settings or File Path +- File Path is recommended outside of web/ (Example on page) +- API Instructions: Default text provided for AI image description. ## Development of CKEditor5 Button diff --git a/aidmi.libraries.yml b/aidmi.libraries.yml index 3595812ae8255fad9f8b88558021b250951c84ed..8d7775b33f21e456c36a750762d341345d90de41 100644 --- a/aidmi.libraries.yml +++ b/aidmi.libraries.yml @@ -11,4 +11,3 @@ aidmi_ckeditor: - core/drupal.dialog.ajax - core/jquery - core/once - \ No newline at end of file diff --git a/aidmi.module b/aidmi.module index e19114d4d2a5c12a2ec9b9e88ff59f1f1daa00a0..bc5c233402639e9c3253beb6ceb8527c8ca9a485 100644 --- a/aidmi.module +++ b/aidmi.module @@ -1,14 +1,18 @@ <?php -use Drupal\editor\Entity\Editor; -use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition; +/** + * @file + * AIDmi - AI, describe my image. + */ + +use Drupal\Core\Routing\RouteMatchInterface; /** * Implements hook_help(). * * Provides a help page for the module. */ -function aidmi_help($route_name, \Drupal\Core\Routing\RouteMatchInterface $route_match) { +function aidmi_help($route_name, RouteMatchInterface $route_match) { switch ($route_name) { case 'help.page.aidmi': return '<p>' . t('The AIDmi module allows users to upload images and get 508-compliant image descriptions generated by AI.') . '</p>'; diff --git a/aidmi.routing.yml b/aidmi.routing.yml index 947661737127b4e344ec3a6ea4868c711b2150c5..fd3cb6377020d4e30b8bd9f7a841bcc03877ed21 100644 --- a/aidmi.routing.yml +++ b/aidmi.routing.yml @@ -25,4 +25,4 @@ aidmi.describe_content_ajax: requirements: _permission: 'access content' options: - _format: 'json' \ No newline at end of file + _format: 'json' diff --git a/aidmi.services.yml b/aidmi.services.yml index 2ac0a30dce8ed5fa2011770c41081d88e1313d51..a325e26b83d0a3f615b2c5141e57cb6a991724bf 100644 --- a/aidmi.services.yml +++ b/aidmi.services.yml @@ -6,4 +6,3 @@ services: aidmi.aidmicontroller: class: 'Drupal\aidmi\Controller\AidmiController' arguments: ['@aidmi.gemini_ai_service'] - \ No newline at end of file diff --git a/css/aidmi.css b/css/aidmi.css index b1ae4ed21a66aec866d5e60cc0d7345ccfb1da48..a17ce663fe39389b0eda293381dffa6df0a8303e 100644 --- a/css/aidmi.css +++ b/css/aidmi.css @@ -1,63 +1,63 @@ .aidmi-dialog { - height: auto; - } + height: auto; +} .aidmi-dialog button { - margin-right: 10px; + margin-right: 10px; } -.aidmi-dialog-content-scrollable { - max-height: 300px; - overflow-y: auto; - padding: 10px; +.aidmi-dialog-content-scrollable { + max-height: 300px; + overflow-y: auto; + padding: 10px; } .aidmi-dialog-subtitle { - font-size: 0.9em; - color: #666; - margin-top: -10px; - margin-bottom: 10px; + font-size: 0.9em; + color: #666; + margin-top: -10px; + margin-bottom: 10px; } .aidmi-dialog-image { - max-width: 100%; - max-height: 400px; + max-width: 100%; + max-height: 400px; } .aidmi-highlight-missing { - border: 2px solid red; - padding: 10px; - margin: 10px 0; + border: 2px solid red; + padding: 10px; + margin: 10px 0; } .aidmi-dialog-textarea { - resize: vertical; - width: 100%; + resize: vertical; + width: 100%; } .ckeditor5-toolbar-button-aidmi { - background-image: url(../icons/aidmi.svg); + background-image: url(../icons/aidmi.svg); } /* CKEditor5 AIDmi icon */ .aidmi-icon-str1 { - stroke:#2D2A2B; - stroke-width:0.83; - stroke-miterlimit:2.61313; + stroke: #2d2a2b; + stroke-width: 0.83; + stroke-miterlimit: 2.61313; } .aidmi-icon-str0 { - stroke:black; - stroke-width:0.83; - stroke-miterlimit:2.61313; + stroke: black; + stroke-width: 0.83; + stroke-miterlimit: 2.61313; } .aidmi-icon-fil2 { - fill:none; + fill: none; } .aidmi-icon-fil0 { - fill:none; - fill-rule:nonzero; + fill: none; + fill-rule: nonzero; } .aidmi-icon-fil1 { - fill:black; - fill-rule:nonzero; -} \ No newline at end of file + fill: black; + fill-rule: nonzero; +} diff --git a/js/aidmi.dialog.js b/js/aidmi.dialog.js index 57847d5d4d8dd44717ed7f8c3225263e8c5b89ca..5323a73bfd1d8f3453f0f5fc091daa4b92d296b0 100644 --- a/js/aidmi.dialog.js +++ b/js/aidmi.dialog.js @@ -1,78 +1,77 @@ (function ($, Drupal) { - Drupal.behaviors.aidmiDialog = { - attach: function (context, settings) { - Drupal.aidmi = Drupal.aidmi || {}; - - Drupal.aidmi.aidmiDialog = function (content, callback) { - let aidmiEditedText; - // Create a temporary container to hold the content. - const tempElement = document.createElement('div'); - tempElement.setAttribute('role', 'dialog'); - tempElement.setAttribute('aria-labelledby', 'aidmi-dialog-title'); - tempElement.setAttribute('aria-describedby', 'aidmi-dialog-description'); - tempElement.setAttribute('aria-modal', 'true'); // Ensures screen readers treat the dialog as a modal. + Drupal.behaviors.aidmiDialog = { + attach: function (context, settings) { + Drupal.aidmi = Drupal.aidmi || {}; - // Set title text. - let tempETitle = 'AIDmi Description'; - let tempESubTitle = 'Please evaluate and edit the description as needed.'; + Drupal.aidmi.aidmiDialog = function (content, callback) { + let aidmiEditedText; + // Create a temporary container to hold the content. + const tempElement = document.createElement('div'); + tempElement.setAttribute('role', 'dialog'); + tempElement.setAttribute('aria-labelledby', 'aidmi-dialog-title'); + tempElement.setAttribute('aria-describedby', 'aidmi-dialog-description'); + tempElement.setAttribute('aria-modal', 'true'); // Ensures screen readers treat the dialog as a modal. - // Create dialog content with a textarea for editing. - tempElement.innerHTML = ` - <div> - <h2 id="aidmi-dialog-title">${Drupal.t(tempETitle)}</h2> - <p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t(tempESubTitle)}</p> - <textarea id="aidmi-dialog-textarea" class="aidmi-dialog-textarea" rows="6" style="width: 100%;">${Drupal.t(content)}</textarea> - </div>`; + // Set title text. + let tempETitle = 'AIDmi Description'; + let tempESubTitle = 'Please evaluate and edit the description as needed.'; - // Store the element that triggered the dialog, to return focus later. - const previousFocus = document.activeElement; + // Create dialog content with a textarea for editing. + tempElement.innerHTML = ` + <div> + <h2 id="aidmi-dialog-title">${Drupal.t(tempETitle)}</h2> + <p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t(tempESubTitle)}</p> + <textarea id="aidmi-dialog-textarea" class="aidmi-dialog-textarea" rows="6" style="width: 100%;">${Drupal.t(content)}</textarea> + </div>`; - // Use the Drupal off-canvas dialog to show the content. - const options = { - dialogClass: 'aidmi-dialog', - title: tempETitle, - width: '400px', - buttons: [ - { - text: Drupal.t('Insert Text'), - click: function () { - // Get the value from the textarea. - aidmiEditedText = document.getElementById('aidmi-dialog-textarea').value; + // Store the element that triggered the dialog, to return focus later. + const previousFocus = document.activeElement; - // Pass the modified text back through the callback. - callback(aidmiEditedText); + // Use the Drupal off-canvas dialog to show the content. + const options = { + dialogClass: 'aidmi-dialog', + title: tempETitle, + width: '400px', + buttons: [ + { + text: Drupal.t('Insert Text'), + click: function () { + // Get the value from the textarea. + aidmiEditedText = document.getElementById('aidmi-dialog-textarea').value; - // Close the dialog. - dialogInstance.close(); - } - }, - { - text: Drupal.t('Cancel'), - click: function () { - // Return false when Cancel is clicked. - callback(false); + // Pass the modified text back through the callback. + callback(aidmiEditedText); - // Close the dialog. - dialogInstance.close(); - } - } - ], - close: function () { - // Return focus to the original element when the dialog is closed. - previousFocus.focus(); - } - }; + // Close the dialog. + dialogInstance.close(); + } + }, + { + text: Drupal.t('Cancel'), + click: function () { + // Return false when Cancel is clicked. + callback(false); - // Open the dialog using Drupal's dialog API. - const dialogInstance = Drupal.dialog(tempElement, options); - dialogInstance.showModal(); + // Close the dialog. + dialogInstance.close(); + } + } + ], + close: function () { + // Return focus to the original element when the dialog is closed. + previousFocus.focus(); + } + }; - // Set focus on the textarea for screen readers. - const textareaElement = document.getElementById('aidmi-dialog-textarea'); - textareaElement.setAttribute('tabindex', '-1'); - textareaElement.focus(); + // Open the dialog using Drupal's dialog API. + const dialogInstance = Drupal.dialog(tempElement, options); + dialogInstance.showModal(); - } + // Set focus on the textarea for screen readers. + const textareaElement = document.getElementById('aidmi-dialog-textarea'); + textareaElement.setAttribute('tabindex', '-1'); + textareaElement.focus(); + }; } }; -})(jQuery, Drupal); \ No newline at end of file +})(jQuery, Drupal); diff --git a/js/build/aidmiPlugin.js b/js/build/aidmiPlugin.js index 7ee8d72b8044624ff2e1659440430b43fa2216c4..ee768b05d43b49b3e4e2f93a9ad45fb0ef9d8883 100644 --- a/js/build/aidmiPlugin.js +++ b/js/build/aidmiPlugin.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.aidmiPlugin=t())}(self,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/ui.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function i(a){var n=t[a];if(void 0!==n)return n.exports;var l=t[a]={exports:{}};return e[a](l,l.exports,i),l.exports}i.d=(e,t)=>{for(var a in t)i.o(t,a)&&!i.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var a={};return(()=>{"use strict";i.d(a,{default:()=>d});var e=i("ckeditor5/src/core.js"),t=i("ckeditor5/src/ui.js");const n='<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="20px" height="20px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"\r\nviewBox="0 0 20 20"\r\n xmlns:xlink="http://www.w3.org/1999/xlink">\r\n <g id="aidmi-icon-layer">\r\n <metadata id="aidmi-icon"/>\r\n <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M8.62 17.95c-0.58,0.31 -1.2,0.42 -1.84,0.33 -0.63,-0.08 -1.23,-0.36 -1.7,-0.83l-0.01 -0.01 -0.01 0c-0.75,-0.09 -1.4,-0.45 -1.87,-0.96 -0.46,-0.52 -0.74,-1.18 -0.74,-1.91l0 -0.07 -0.02 -0.01c-0.02,-0.01 -0.04,-0.02 -0.05,-0.03 -0.1,-0.05 -0.17,-0.1 -0.27,-0.17 -0.09,-0.07 -0.18,-0.14 -0.27,-0.21 -0.07,-0.06 -0.13,-0.12 -0.19,-0.18 -0.11,-0.11 -0.21,-0.23 -0.3,-0.35 -0.14,-0.19 -0.26,-0.39 -0.36,-0.6 0,-0.02 -0.01,-0.03 -0.02,-0.03 -0.07,-0.18 -0.13,-0.36 -0.17,-0.55 -0.06,-0.24 -0.09,-0.49 -0.09,-0.73 0,-0.2 0.02,-0.38 0.06,-0.57 0.04,-0.22 0.1,-0.44 0.18,-0.64l0.02 -0.04c0.16,-0.37 0.39,-0.72 0.68,-1.02l0.01 -0.01 0 -0.03c-0.1,-0.4 -0.1,-0.81 -0.02,-1.21 0.03,-0.1 0.06,-0.19 0.09,-0.28 0.03,-0.09 0.05,-0.18 0.09,-0.26 0.15,-0.35 0.37,-0.67 0.66,-0.94l0.01 -0.01 0 -0.03c-0.11,-0.67 0.04,-1.32 0.37,-1.87 0.34,-0.56 0.86,-1 1.52,-1.25l0.02 0 0 -0.02c0.45,-1.12 1.56,-1.86 2.81,-1.86 0.45,0.01 0.88,0.1 1.27,0.29l0 15.62c0.01,0.17 0.05,0.31 0.14,0.44z"/>\r\n <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M15.59 16.48c-0.47,0.51 -1.12,0.87 -1.87,0.96l-0.01 0 -0.01 0.01c-0.47,0.47 -1.07,0.75 -1.7,0.83 -0.64,0.09 -1.26,-0.02 -1.84,-0.32 0.09,-0.14 0.14,-0.28 0.14,-0.45l0 -15.62c0.4,-0.19 0.82,-0.28 1.27,-0.29 1.25,0 2.37,0.74 2.81,1.86l0.01 0.02 0.02 0c0.66,0.25 1.18,0.69 1.51,1.25 0.34,0.55 0.48,1.2 0.38,1.87l0 0.03 0.01 0.01c0.28,0.27 0.51,0.59 0.66,0.94 0.04,0.08 0.06,0.17 0.09,0.27 0.03,0.09 0.06,0.18 0.08,0.27 0.09,0.4 0.09,0.81 -0.01,1.21l-0.01 0.03 0.02 0.01c0.29,0.3 0.52,0.65 0.68,1.03l0.01 0.03c0.06,0.14 0.11,0.28 0.14,0.43 -0.68,-0.48 -1.54,-0.85 -2.54,-0.85 -2.77,0 -4.44,2.84 -4.44,2.84l-0.01 0.02 0.01 0.03c0,0 1.67,2.83 4.44,2.83 0.23,0 0.46,-0.02 0.68,-0.06 -0.13,0.3 -0.31,0.57 -0.52,0.81z"/>\r\n <path class="aidmi-icon-fil1" d="M20.04 12.85c0,0 -1.73,-2.91 -4.61,-2.91 -2.89,0 -4.62,2.91 -4.62,2.91 0,0 1.73,2.91 4.62,2.91 2.88,0 4.61,-2.91 4.61,-2.91zm-8.55 0c0.28,-0.39 0.6,-0.75 0.96,-1.08 0.74,-0.68 1.75,-1.3 2.98,-1.3 1.22,0 2.24,0.62 2.98,1.3 0.36,0.33 0.68,0.69 0.96,1.08 -0.04,0.05 -0.07,0.1 -0.12,0.15 -0.19,0.26 -0.47,0.59 -0.84,0.93 -0.74,0.68 -1.76,1.3 -2.98,1.3 -1.23,0 -2.24,-0.62 -2.98,-1.3 -0.36,-0.33 -0.68,-0.69 -0.96,-1.08l0 0z"/>\r\n <path class="aidmi-icon-fil1" d="M15.63 11.82c-0.88,0 -1.43,0.84 -0.99,1.51 0.21,0.31 0.58,0.5 0.99,0.5 0.89,0 1.44,-0.84 1,-1.51 -0.21,-0.31 -0.59,-0.5 -1,-0.5zm-1.6 1c0,-1.08 1.34,-1.76 2.41,-1.22 0.49,0.25 0.8,0.72 0.8,1.22 0,1.09 -1.34,1.77 -2.41,1.23 -0.5,-0.26 -0.8,-0.72 -0.8,-1.23z"/>\r\n <line class="aidmi-icon-fil2 aidmi-icon-str1" x1="12.92" y1="8.92" x2="17.64" y2= "16.79" />\r\n </g>\r\n</svg>\r\n';class l extends e.Plugin{init(){const e=this.editor,i=e.plugins.get("SourceEditing");e.ui.componentFactory.add("aidmi",(a=>{const l=new t.ButtonView(a);return l.set({label:e.t("AI, describe my image!"),icon:n,tooltip:!0,isEnabled:!i||!i.isSourceEditingMode}),this.listenTo(l,"execute",(async()=>{try{l.label="Processing...",l.isEnabled=!1,l.icon='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" style="color:#1C2033" class="h-full w-full">\n <rect width="20" height="20" x="0" y="0" rx="0" fill="transparent" stroke="transparent" stroke-width="0" stroke-opacity="0%" paint-order="stroke" /><svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20" fill="#1C2033" x="0" y="0" role="img" style="display:inline-block;vertical-align:middle">\n <g fill="#1C2033">\n <rect width="6" height="14" x="1" y="4" fill="currentColor">\n <animate id="svgSpinnersBarsScaleFade0" fill="freeze" attributeName="y" begin="0;svgSpinnersBarsScaleFade1.end-0.25s" dur="0.75s" values="1;5" />\n <animate fill="freeze" attributeName="height" begin="0;svgSpinnersBarsScaleFade1.end-0.25s" dur="0.75s" values="22;14" />\n <animate fill="freeze" attributeName="opacity" begin="0;svgSpinnersBarsScaleFade1.end-0.25s" dur="0.75s" values="1;.2" />\n </rect>\n <rect width="6" height="14" x="9" y="4" fill="currentColor" opacity=".4">\n <animate fill="freeze" attributeName="y" begin="svgSpinnersBarsScaleFade0.begin+0.15s" dur="0.75s" values="1;5" />\n <animate fill="freeze" attributeName="height" begin="svgSpinnersBarsScaleFade0.begin+0.15s" dur="0.75s" values="22;14" />\n <animate fill="freeze" attributeName="opacity" begin="svgSpinnersBarsScaleFade0.begin+0.15s" dur="0.75s" values="1;.2" />\n </rect>\n <rect width="6" height="14" x="17" y="4" fill="currentColor" opacity=".3">\n <animate id="svgSpinnersBarsScaleFade1" fill="freeze" attributeName="y" begin="svgSpinnersBarsScaleFade0.begin+0.3s" dur="0.75s" values="1;5" />\n <animate fill="freeze" attributeName="height" begin="svgSpinnersBarsScaleFade0.begin+0.3s" dur="0.75s" values="22;14" />\n <animate fill="freeze" attributeName="opacity" begin="svgSpinnersBarsScaleFade0.begin+0.3s" dur="0.75s" values="1;.2" />\n </rect>\n </g>\n </svg>\n</svg>';let t=null,i=null,a=this._aidmiJSONImages(e.getData());a.length>0&&(t=await this._aidmiContentAjax(e.getData(),a)),t&&t.length>0&&(i=JSON.parse(t),this._openDialog(e,i,a))}catch(e){console.log("Error:",e)}finally{l.label="AI, describe my image!",l.icon=n,l.isEnabled=!0}})),e.model.document.on("change:data",(()=>{const t=this._aidmiJSONImages(e.getData()).length>0;l.isEnabled=t&&(!i||!i.isSourceEditingMode)})),i&&i.on("change:isSourceEditingMode",((e,t,i)=>{l.isEnabled=!i})),l}))}_aidmiJSONImages(e){const t=document.createElement("div");t.innerHTML=e;let i=t.querySelectorAll("img, drupal-media"),a=[];return i.forEach((e=>{let t={"data-entity-uuid":e.getAttribute("data-entity-uuid"),type:e.tagName.toLowerCase()};a.push(t)})),a}_aidmiContentAjax(e,t){const i=jQuery;return new Promise(((a,n)=>{i.ajax({url:"/admin/aidmi/describe-content-ajax",method:"POST",data:{content:e,imagesJSON:JSON.stringify(t)},success:function(e){a(e)},error:function(e,t,i){n(i)}})}))}_openDialog(e,t,i){const a=document.createElement("div");a.setAttribute("role","dialog"),a.setAttribute("aria-labelledby","aidmi-dialog-title"),a.setAttribute("aria-describedby","aidmi-dialog-description"),a.setAttribute("aria-modal","true");a.innerHTML=`<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t("Please evaluate and edit the description as needed.")}</p>`;let n=0;t.forEach((e=>{e.images&&Array.isArray(e.images)&&(n+=e.images.length)})),t.forEach((e=>{e.images&&Array.isArray(e.images)&&e.images.forEach((e=>{let t="";t+=`<div class="image-option-group" id="option-group-${e["data-entity-uuid"]}">`,t+=`\n \x3c!-- Image Display --\x3e\n <img class="aidmi-dialog-image" data-entity-uuid="${e["data-entity-uuid"]}" src="${e.src}" alt="${Drupal.t(e.before_alt)}" />`;let i=!1,n="Current Alt Tag - Read only",l=e.before_alt;(!l||l.length<6)&&(n+=" (Option disabled since alt is either blank or too short)",i=!0),t+=`\n \x3c!-- Current Alt Tag Section --\x3e\n <div>\n <label for="aidmi-alt-old-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="aidmi-alt-selection-${e["data-entity-uuid"]}" id="aidmi-alt-old-${e["data-entity-uuid"]}" value="current" aria-labelledby="label-old-${e["data-entity-uuid"]}" ${i?"disabled":""} required/>\n <span id="label-old-${e["data-entity-uuid"]}">${Drupal.t(n)}</span>\n </label>`,i||(t+=`\n <textarea id="aidmi-dialog-textarea-oldalt-${e["data-entity-uuid"]}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${e["data-entity-uuid"]}" readonly="readonly">${Drupal.t(e.before_alt)}</textarea>`),t+="\n </div>",t+=`\n \x3c!-- Suggested Alt Tag Section --\x3e\n <div>\n <label for="aidmi-alt-new-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="aidmi-alt-selection-${e["data-entity-uuid"]}" id="aidmi-alt-new-${e["data-entity-uuid"]}" value="suggested" aria-labelledby="label-new-${e["data-entity-uuid"]}" required>\n <span id="label-new-${e["data-entity-uuid"]}">${Drupal.t("Suggested Alt Tag - Review and modify as required")}</span>\n </label>\n <textarea id="aidmi-dialog-textarea-newalt-${e["data-entity-uuid"]}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${e["data-entity-uuid"]}">${Drupal.t(e.recommendation)}</textarea>\n </div>`,t+=`\n \x3c!-- Set as Decorative Image --\x3e\n <div>\n <label for="aidmi-alt-decorative-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="aidmi-alt-selection-${e["data-entity-uuid"]}" id="aidmi-alt-decorative-${e["data-entity-uuid"]}" value="decorative" aria-labelledby="label-decorative-${e["data-entity-uuid"]}" required>\n <span id="label-decorative-${e["data-entity-uuid"]}">${Drupal.t("Set as Decorative Image")}</span>\n </label>\n </div>`,t+="</div><hr/>",a.innerHTML+=t}))}));const l={dialogClass:"aidmi-dialog",title:"AIDmi Description",resizable:!0,width:"700px",buttons:[{text:Drupal.t("Insert Text"),class:"aidmi-insert-button",click:()=>{const i=this._collectSelections(t);i?(this._modifyImagesInContent(e,i),r.close()):alert(Drupal.t("Please select an option for each image before inserting."))}},{text:Drupal.t("Cancel"),click:function(){r.close()}}]},r=Drupal.dialog(a,l);r.showModal(),document.querySelectorAll('input[type="radio"]').forEach((e=>{e.addEventListener("change",(e=>{const t=e.target.name.replace("aidmi-alt-selection-","option-group-");document.getElementById(t).classList.remove("aidmi-highlight-missing")}))})),document.querySelector('input[type="radio"]').focus()}_collectSelections(e){let t=!0;const i=[];return e.forEach((e=>{e.images&&Array.isArray(e.images)&&e.images.forEach((e=>{const a=e["data-entity-uuid"],n=document.querySelector(`input[name="aidmi-alt-selection-${a}"]:checked`);if(n){const e=n.value;let t="";"current"===e?t=document.getElementById(`aidmi-dialog-textarea-oldalt-${a}`).value:"suggested"===e&&(t=document.getElementById(`aidmi-dialog-textarea-newalt-${a}`).value),i.push({uuid:a,altText:"decorative"===e?"":t,isDecorative:"decorative"===e})}else t=!1,document.getElementById(`option-group-${a}`).classList.add("aidmi-highlight-missing")}))})),t?i:null}_modifyImagesInContent(e,t){let i=e.getData();const a=document.createElement("div");a.innerHTML=i,t.forEach((e=>{const t=a.querySelector(`[data-entity-uuid="${e.uuid}"]`);t?e.isDecorative?(t.setAttribute("alt",""),t.setAttribute("role","presentation")):(t.setAttribute("alt",e.altText),t.removeAttribute("role")):console.warn(`Image with UUID ${e.uuid} not found in content.`)})),e.setData(a.innerHTML),e.editing.view.document.fire("change:data")}}class r extends e.Plugin{static get requires(){return[l]}static get pluginName(){return"AIDmi"}}const d={AIDmi:r}})(),a=a.default})())); \ No newline at end of file +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.aidmiPlugin=t())}(self,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/ui.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function i(a){var n=t[a];if(void 0!==n)return n.exports;var l=t[a]={exports:{}};return e[a](l,l.exports,i),l.exports}i.d=(e,t)=>{for(var a in t)i.o(t,a)&&!i.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var a={};return(()=>{"use strict";i.d(a,{default:()=>d});var e=i("ckeditor5/src/core.js"),t=i("ckeditor5/src/ui.js");const n='<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="20px" height="20px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"\r\nviewBox="0 0 20 20"\r\n xmlns:xlink="http://www.w3.org/1999/xlink">\r\n <g id="aidmi-icon-layer">\r\n <metadata id="aidmi-icon"/>\r\n <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M8.62 17.95c-0.58,0.31 -1.2,0.42 -1.84,0.33 -0.63,-0.08 -1.23,-0.36 -1.7,-0.83l-0.01 -0.01 -0.01 0c-0.75,-0.09 -1.4,-0.45 -1.87,-0.96 -0.46,-0.52 -0.74,-1.18 -0.74,-1.91l0 -0.07 -0.02 -0.01c-0.02,-0.01 -0.04,-0.02 -0.05,-0.03 -0.1,-0.05 -0.17,-0.1 -0.27,-0.17 -0.09,-0.07 -0.18,-0.14 -0.27,-0.21 -0.07,-0.06 -0.13,-0.12 -0.19,-0.18 -0.11,-0.11 -0.21,-0.23 -0.3,-0.35 -0.14,-0.19 -0.26,-0.39 -0.36,-0.6 0,-0.02 -0.01,-0.03 -0.02,-0.03 -0.07,-0.18 -0.13,-0.36 -0.17,-0.55 -0.06,-0.24 -0.09,-0.49 -0.09,-0.73 0,-0.2 0.02,-0.38 0.06,-0.57 0.04,-0.22 0.1,-0.44 0.18,-0.64l0.02 -0.04c0.16,-0.37 0.39,-0.72 0.68,-1.02l0.01 -0.01 0 -0.03c-0.1,-0.4 -0.1,-0.81 -0.02,-1.21 0.03,-0.1 0.06,-0.19 0.09,-0.28 0.03,-0.09 0.05,-0.18 0.09,-0.26 0.15,-0.35 0.37,-0.67 0.66,-0.94l0.01 -0.01 0 -0.03c-0.11,-0.67 0.04,-1.32 0.37,-1.87 0.34,-0.56 0.86,-1 1.52,-1.25l0.02 0 0 -0.02c0.45,-1.12 1.56,-1.86 2.81,-1.86 0.45,0.01 0.88,0.1 1.27,0.29l0 15.62c0.01,0.17 0.05,0.31 0.14,0.44z"/>\r\n <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M15.59 16.48c-0.47,0.51 -1.12,0.87 -1.87,0.96l-0.01 0 -0.01 0.01c-0.47,0.47 -1.07,0.75 -1.7,0.83 -0.64,0.09 -1.26,-0.02 -1.84,-0.32 0.09,-0.14 0.14,-0.28 0.14,-0.45l0 -15.62c0.4,-0.19 0.82,-0.28 1.27,-0.29 1.25,0 2.37,0.74 2.81,1.86l0.01 0.02 0.02 0c0.66,0.25 1.18,0.69 1.51,1.25 0.34,0.55 0.48,1.2 0.38,1.87l0 0.03 0.01 0.01c0.28,0.27 0.51,0.59 0.66,0.94 0.04,0.08 0.06,0.17 0.09,0.27 0.03,0.09 0.06,0.18 0.08,0.27 0.09,0.4 0.09,0.81 -0.01,1.21l-0.01 0.03 0.02 0.01c0.29,0.3 0.52,0.65 0.68,1.03l0.01 0.03c0.06,0.14 0.11,0.28 0.14,0.43 -0.68,-0.48 -1.54,-0.85 -2.54,-0.85 -2.77,0 -4.44,2.84 -4.44,2.84l-0.01 0.02 0.01 0.03c0,0 1.67,2.83 4.44,2.83 0.23,0 0.46,-0.02 0.68,-0.06 -0.13,0.3 -0.31,0.57 -0.52,0.81z"/>\r\n <path class="aidmi-icon-fil1" d="M20.04 12.85c0,0 -1.73,-2.91 -4.61,-2.91 -2.89,0 -4.62,2.91 -4.62,2.91 0,0 1.73,2.91 4.62,2.91 2.88,0 4.61,-2.91 4.61,-2.91zm-8.55 0c0.28,-0.39 0.6,-0.75 0.96,-1.08 0.74,-0.68 1.75,-1.3 2.98,-1.3 1.22,0 2.24,0.62 2.98,1.3 0.36,0.33 0.68,0.69 0.96,1.08 -0.04,0.05 -0.07,0.1 -0.12,0.15 -0.19,0.26 -0.47,0.59 -0.84,0.93 -0.74,0.68 -1.76,1.3 -2.98,1.3 -1.23,0 -2.24,-0.62 -2.98,-1.3 -0.36,-0.33 -0.68,-0.69 -0.96,-1.08l0 0z"/>\r\n <path class="aidmi-icon-fil1" d="M15.63 11.82c-0.88,0 -1.43,0.84 -0.99,1.51 0.21,0.31 0.58,0.5 0.99,0.5 0.89,0 1.44,-0.84 1,-1.51 -0.21,-0.31 -0.59,-0.5 -1,-0.5zm-1.6 1c0,-1.08 1.34,-1.76 2.41,-1.22 0.49,0.25 0.8,0.72 0.8,1.22 0,1.09 -1.34,1.77 -2.41,1.23 -0.5,-0.26 -0.8,-0.72 -0.8,-1.23z"/>\r\n <line class="aidmi-icon-fil2 aidmi-icon-str1" x1="12.92" y1="8.92" x2="17.64" y2= "16.79" />\r\n </g>\r\n</svg>\r\n';class l extends e.Plugin{init(){const e=this.editor,i=e.plugins.get("SourceEditing");e.ui.componentFactory.add("aidmi",(a=>{const l=new t.ButtonView(a);return l.set({label:e.t("AI, describe my image!"),icon:n,tooltip:!0,isEnabled:!i||!i.isSourceEditingMode}),this.listenTo(l,"execute",(async()=>{try{l.label="Processing...",l.isEnabled=!1,l.icon='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" style="color:#1C2033" class="h-full w-full">\n <rect width="20" height="20" x="0" y="0" rx="0" fill="transparent" stroke="transparent" stroke-width="0" stroke-opacity="0%" paint-order="stroke" /><svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20" fill="#1C2033" x="0" y="0" role="img" style="display:inline-block;vertical-align:middle">\n <g fill="#1C2033">\n <rect width="6" height="14" x="1" y="4" fill="currentColor">\n <animate id="svgSpinnersBarsScaleFade0" fill="freeze" attributeName="y" begin="0;svgSpinnersBarsScaleFade1.end-0.25s" dur="0.75s" values="1;5" />\n <animate fill="freeze" attributeName="height" begin="0;svgSpinnersBarsScaleFade1.end-0.25s" dur="0.75s" values="22;14" />\n <animate fill="freeze" attributeName="opacity" begin="0;svgSpinnersBarsScaleFade1.end-0.25s" dur="0.75s" values="1;.2" />\n </rect>\n <rect width="6" height="14" x="9" y="4" fill="currentColor" opacity=".4">\n <animate fill="freeze" attributeName="y" begin="svgSpinnersBarsScaleFade0.begin+0.15s" dur="0.75s" values="1;5" />\n <animate fill="freeze" attributeName="height" begin="svgSpinnersBarsScaleFade0.begin+0.15s" dur="0.75s" values="22;14" />\n <animate fill="freeze" attributeName="opacity" begin="svgSpinnersBarsScaleFade0.begin+0.15s" dur="0.75s" values="1;.2" />\n </rect>\n <rect width="6" height="14" x="17" y="4" fill="currentColor" opacity=".3">\n <animate id="svgSpinnersBarsScaleFade1" fill="freeze" attributeName="y" begin="svgSpinnersBarsScaleFade0.begin+0.3s" dur="0.75s" values="1;5" />\n <animate fill="freeze" attributeName="height" begin="svgSpinnersBarsScaleFade0.begin+0.3s" dur="0.75s" values="22;14" />\n <animate fill="freeze" attributeName="opacity" begin="svgSpinnersBarsScaleFade0.begin+0.3s" dur="0.75s" values="1;.2" />\n </rect>\n </g>\n </svg>\n</svg>';let t=null,i=null,a=this._aidmiJSONImages(e.getData());a.length>0&&(t=await this._aidmiContentAjax(e.getData(),a)),t&&t.length>0&&(i=JSON.parse(t),this._openDialog(e,i,a))}catch(e){console.log("Error:",e)}finally{l.label="AI, describe my image!",l.icon=n,l.isEnabled=!0}})),e.model.document.on("change:data",(()=>{const t=this._aidmiJSONImages(e.getData()).length>0;l.isEnabled=t&&(!i||!i.isSourceEditingMode)})),i&&i.on("change:isSourceEditingMode",((e,t,i)=>{l.isEnabled=!i})),l}))}_aidmiJSONImages(e){const t=document.createElement("div");t.innerHTML=e;let i=t.querySelectorAll("img, drupal-media"),a=[];return i.forEach((e=>{let t={"data-entity-uuid":e.getAttribute("data-entity-uuid"),type:e.tagName.toLowerCase()};a.push(t)})),a}_aidmiContentAjax(e,t){const i=jQuery;return new Promise(((a,n)=>{i.ajax({url:"/admin/aidmi/describe-content-ajax",method:"POST",data:{content:e,imagesJSON:JSON.stringify(t)},success:function(e){a(e)},error:function(e,t,i){n(i)}})}))}_openDialog(e,t,i){const a=document.createElement("div");a.setAttribute("role","dialog"),a.setAttribute("aria-labelledby","aidmi-dialog-title"),a.setAttribute("aria-describedby","aidmi-dialog-description"),a.setAttribute("aria-modal","true");a.innerHTML=`<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t("Please evaluate and edit the description as needed.")}</p>`;let n=0;t.forEach((e=>{e.images&&Array.isArray(e.images)&&(n+=e.images.length)})),t.forEach((e=>{e.images&&Array.isArray(e.images)&&e.images.forEach((e=>{let t="";t+=`<div class="image-option-group" id="option-group-${e["data-entity-uuid"]}">`,t+=`\n \x3c!-- Image Display --\x3e\n <img class="aidmi-dialog-image" data-entity-uuid="${e["data-entity-uuid"]}" src="${e.src}" alt="${Drupal.t(e.before_alt)}" />`;let i=!1,n="Current Alt Tag - Read only",l=e.before_alt;(!l||l.length<6)&&(n+=" (Option disabled since alt is either blank or too short)",i=!0),t+=`\n \x3c!-- Current Alt Tag Section --\x3e\n <div>\n <label for="aidmi-alt-old-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="aidmi-alt-selection-${e["data-entity-uuid"]}" id="aidmi-alt-old-${e["data-entity-uuid"]}" value="current" aria-labelledby="label-old-${e["data-entity-uuid"]}" ${i?"disabled":""} required/>\n <span id="label-old-${e["data-entity-uuid"]}">${Drupal.t(n)}</span>\n </label>`,i||(t+=`\n <textarea id="aidmi-dialog-textarea-oldalt-${e["data-entity-uuid"]}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${e["data-entity-uuid"]}" readonly="readonly">${Drupal.t(e.before_alt)}</textarea>`),t+="\n </div>",t+=`\n \x3c!-- Suggested Alt Tag Section --\x3e\n <div>\n <label for="aidmi-alt-new-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="aidmi-alt-selection-${e["data-entity-uuid"]}" id="aidmi-alt-new-${e["data-entity-uuid"]}" value="suggested" aria-labelledby="label-new-${e["data-entity-uuid"]}" required>\n <span id="label-new-${e["data-entity-uuid"]}">${Drupal.t("Suggested Alt Tag - Review and modify as required")}</span>\n </label>\n <textarea id="aidmi-dialog-textarea-newalt-${e["data-entity-uuid"]}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${e["data-entity-uuid"]}">${Drupal.t(e.recommendation)}</textarea>\n </div>`,t+=`\n \x3c!-- Set as Decorative Image --\x3e\n <div>\n <label for="aidmi-alt-decorative-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="aidmi-alt-selection-${e["data-entity-uuid"]}" id="aidmi-alt-decorative-${e["data-entity-uuid"]}" value="decorative" aria-labelledby="label-decorative-${e["data-entity-uuid"]}" required>\n <span id="label-decorative-${e["data-entity-uuid"]}">${Drupal.t("Set as Decorative Image")}</span>\n </label>\n </div>`,t+="</div><hr/>",a.innerHTML+=t}))}));const l={dialogClass:"aidmi-dialog",title:"AIDmi Description",resizable:!0,width:"700px",buttons:[{text:Drupal.t("Insert Text"),class:"aidmi-insert-button",click:()=>{const i=this._collectSelections(t);i?(this._modifyImagesInContent(e,i),r.close()):alert(Drupal.t("Please select an option for each image before inserting."))}},{text:Drupal.t("Cancel"),click:function(){r.close()}}]},r=Drupal.dialog(a,l);r.showModal(),document.querySelectorAll('input[type="radio"]').forEach((e=>{e.addEventListener("change",(e=>{const t=e.target.name.replace("aidmi-alt-selection-","option-group-");document.getElementById(t).classList.remove("aidmi-highlight-missing")}))})),document.querySelector('input[type="radio"]').focus()}_collectSelections(e){let t=!0;const i=[];return e.forEach((e=>{e.images&&Array.isArray(e.images)&&e.images.forEach((e=>{const a=e["data-entity-uuid"],n=document.querySelector(`input[name="aidmi-alt-selection-${a}"]:checked`);if(n){const e=n.value;let t="";"current"===e?t=document.getElementById(`aidmi-dialog-textarea-oldalt-${a}`).value:"suggested"===e&&(t=document.getElementById(`aidmi-dialog-textarea-newalt-${a}`).value),i.push({uuid:a,altText:"decorative"===e?"":t,isDecorative:"decorative"===e})}else t=!1,document.getElementById(`option-group-${a}`).classList.add("aidmi-highlight-missing")}))})),t?i:null}_modifyImagesInContent(e,t){let i=e.getData();const a=document.createElement("div");a.innerHTML=i,t.forEach((e=>{const t=a.querySelector(`[data-entity-uuid="${e.uuid}"]`);t?e.isDecorative?(t.setAttribute("alt",""),t.setAttribute("role","presentation")):(t.setAttribute("alt",e.altText),t.removeAttribute("role")):console.warn(`Image with UUID ${e.uuid} not found in content.`)})),e.setData(a.innerHTML),e.editing.view.document.fire("change:data")}}class r extends e.Plugin{static get requires(){return[l]}static get pluginName(){return"AIDmi"}}const d={AIDmi:r}})(),a=a.default})())); \ No newline at end of file diff --git a/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js b/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js index 84100251a73dac797e1356ea0b5865029e740a32..d07136b589f1e2b540359cee603c42fa153d65ef 100644 --- a/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js +++ b/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js @@ -1,347 +1,346 @@ import { Plugin } from 'ckeditor5/src/core'; import { - ButtonView, - FocusCycler, - LabeledFieldView, - SwitchButtonView, - View, - ViewCollection, - createLabeledInputText, - injectCssTransitionDisabler, - submitHandler, + ButtonView, + FocusCycler, + LabeledFieldView, + SwitchButtonView, + View, + ViewCollection, + createLabeledInputText, + injectCssTransitionDisabler, + submitHandler, } from 'ckeditor5/src/ui'; import iconAidmi from '../../../../icons/aidmi.svg'; import iconAidmiLoading from '../../../../icons/aidmi_loading.svg'; export default class AIDmiUI extends Plugin { - init() { - const editor = this.editor; + init() { + const editor = this.editor; - // Check if the SourceEditing plugin is available. - const sourceEditing = editor.plugins.get('SourceEditing'); + // Check if the SourceEditing plugin is available. + const sourceEditing = editor.plugins.get('SourceEditing'); - // This will register the AIDmi toolbar button. - editor.ui.componentFactory.add('aidmi', (locale) => { - const button = new ButtonView(locale); - - // Create the toolbar button. - button.set({ - label: editor.t('AI, describe my image!'), - icon: iconAidmi, - tooltip: true, - isEnabled: !sourceEditing || !sourceEditing.isSourceEditingMode // Initially disable if in source mode - }); - - this.listenTo(button, 'execute', async () => { - try { - // Show loading state - button.label = 'Processing...'; - button.isEnabled = false; // Disable the button - - // Change to loading icon. - button.icon = iconAidmiLoading; - - let output = null; - let parsedOutput = null; - let imagesJSON = this._aidmiJSONImages(editor.getData()); - if (imagesJSON.length > 0) { - output = await this._aidmiContentAjax(editor.getData(), imagesJSON); - } - if (output && output.length > 0) { - parsedOutput = JSON.parse(output); - this._openDialog(editor, parsedOutput, imagesJSON); - } - } - catch (error) { - console.log("Error:", error); - } - finally { - // Restore button state after processing is done - button.label = 'AI, describe my image!'; - - button.icon = iconAidmi; // Restore the original icon - button.isEnabled = true; // Re-enable the button - } - - }); - - // Add a listener to enable or disable the button based on content changes. - editor.model.document.on('change:data', () => { - // Leverage the _aidmiJSONImages function to check for images in the content - const hasImages = this._aidmiJSONImages(editor.getData()).length > 0; - button.isEnabled = hasImages && (!sourceEditing || !sourceEditing.isSourceEditingMode); - }); - - // Listen for changes in the source editing mode if the plugin is available. - if (sourceEditing) { - sourceEditing.on('change:isSourceEditingMode', (evt, name, value) => { - button.isEnabled = !value; // Disable button if in source editing mode. - }); - } + // This will register the AIDmi toolbar button. + editor.ui.componentFactory.add('aidmi', (locale) => { + const button = new ButtonView(locale); - return button; - }); + // Create the toolbar button. + button.set({ + label: editor.t('AI, describe my image!'), + icon: iconAidmi, + tooltip: true, + isEnabled: !sourceEditing || !sourceEditing.isSourceEditingMode // Initially disable if in source mode + }); - } + this.listenTo(button, 'execute', async () => { + try { + // Show loading state + button.label = 'Processing...'; + button.isEnabled = false; // Disable the button - _aidmiJSONImages(content) { - // Create a temporary DOM element to parse the HTML string - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = content; // Convert the HTML string to DOM elements - // Find all images with the class 'aidmi-dialog-image' - let images = tempDiv.querySelectorAll('img, drupal-media'); - + // Change to loading icon. + button.icon = iconAidmiLoading; - // Initialize an empty array to store the JSON data - let imageJsonArray = []; - - // Loop through each image and extract 'data-entity-uuid' and 'src' - images.forEach((image) => { - let imageObject = { - "data-entity-uuid": image.getAttribute('data-entity-uuid'), - "type": image.tagName.toLowerCase() - }; - - // Push the image object to the array - imageJsonArray.push(imageObject); - }); - - // Convert the array to a JSON string for output or further use - return imageJsonArray; - } - - _aidmiContentAjax(content, imagesJSON) { - const $ = jQuery; - return new Promise((resolve, reject) => { - $.ajax({ - url: '/admin/aidmi/describe-content-ajax', - method: 'POST', - data: { - content: content, - imagesJSON: JSON.stringify(imagesJSON) - }, - success: function(response) { - resolve(response); - }, - error: function(xhr, status, error) { - reject(error); - } - }); - }); - } - - _openDialog(editor, data, callback) { - let aidmiEditedText; - // Create a temporary container to hold the content. - const tempElement = document.createElement('div'); - tempElement.setAttribute('role', 'dialog'); - tempElement.setAttribute('aria-labelledby', 'aidmi-dialog-title'); - tempElement.setAttribute('aria-describedby', 'aidmi-dialog-description'); - tempElement.setAttribute('aria-modal', 'true'); // Ensures screen readers treat the dialog as a modal. - - // Set title text. - let tempETitle = 'AIDmi Description'; - let tempESubTitle = 'Please evaluate and edit the description as needed.'; - - // Create dialog content with a textarea for editing. - tempElement.innerHTML = `<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t(tempESubTitle)}</p>`; - - // Variable to store the number of images - let imageCount = 0; - data.forEach(item => { - if (item.images && Array.isArray(item.images)) { - imageCount += item.images.length; - } - }); - // Loop through the images - data.forEach(item => { - // If item has an images array, loop through it - if (item.images && Array.isArray(item.images)) { - item.images.forEach((image) => { - let formImageInnerHTML = ''; - // Start of image div alt option area. - formImageInnerHTML += `<div class="image-option-group" id="option-group-${image['data-entity-uuid']}">`; - // Add Image Display. - formImageInnerHTML += ` - <!-- Image Display --> - <img class="aidmi-dialog-image" data-entity-uuid="${image['data-entity-uuid']}" src="${image.src}" alt="${Drupal.t(image.before_alt)}" />`; - // Add Current Alt Tag Section. - let imageOldSelectDisabled = false; - let imageLabel = 'Current Alt Tag - Read only'; - let imageBeforeAlt = image.before_alt; - if (!imageBeforeAlt || imageBeforeAlt.length < 6) { - // Old alt shouldn't be an allowed option. - imageLabel += ' (Option disabled since alt is either blank or too short)'; - imageOldSelectDisabled = true; - } - formImageInnerHTML += ` - <!-- Current Alt Tag Section --> - <div> - <label for="aidmi-alt-old-${image['data-entity-uuid']}" class="aidmi-dialog-label"> - <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-old-${image['data-entity-uuid']}" value="current" aria-labelledby="label-old-${image['data-entity-uuid']}" ${imageOldSelectDisabled ? 'disabled' : ''} required/> - <span id="label-old-${image['data-entity-uuid']}">${Drupal.t(imageLabel)}</span> - </label>`; - - // If radio is disabled, don't show the alt textarea. - if (!imageOldSelectDisabled) { - formImageInnerHTML += ` - <textarea id="aidmi-dialog-textarea-oldalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${image['data-entity-uuid']}" readonly="readonly">${Drupal.t(image.before_alt)}</textarea>`; - } - - formImageInnerHTML += ` - </div>`; - - // Add Suggested Alt Tag Section. - formImageInnerHTML += ` - <!-- Suggested Alt Tag Section --> - <div> - <label for="aidmi-alt-new-${image['data-entity-uuid']}" class="aidmi-dialog-label"> - <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-new-${image['data-entity-uuid']}" value="suggested" aria-labelledby="label-new-${image['data-entity-uuid']}" required> - <span id="label-new-${image['data-entity-uuid']}">${Drupal.t('Suggested Alt Tag - Review and modify as required')}</span> - </label> - <textarea id="aidmi-dialog-textarea-newalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${image['data-entity-uuid']}">${Drupal.t(image.recommendation)}</textarea> - </div>`; - // Add Set as Decorative Image. - formImageInnerHTML += ` - <!-- Set as Decorative Image --> - <div> - <label for="aidmi-alt-decorative-${image['data-entity-uuid']}" class="aidmi-dialog-label"> - <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-decorative-${image['data-entity-uuid']}" value="decorative" aria-labelledby="label-decorative-${image['data-entity-uuid']}" required> - <span id="label-decorative-${image['data-entity-uuid']}">${Drupal.t('Set as Decorative Image')}</span> - </label> - </div>`; - // End of image div alt option area. - formImageInnerHTML += `</div><hr/>`; - - // Insert it. - tempElement.innerHTML += formImageInnerHTML; - }); - } - }); + let output = null; + let parsedOutput = null; + let imagesJSON = this._aidmiJSONImages(editor.getData()); + if (imagesJSON.length > 0) { + output = await this._aidmiContentAjax(editor.getData(), imagesJSON); + } + if (output && output.length > 0) { + parsedOutput = JSON.parse(output); + this._openDialog(editor, parsedOutput, imagesJSON); + } + } + catch (error) { + console.log("Error:", error); + } + finally { + // Restore button state after processing is done + button.label = 'AI, describe my image!'; - // Use the Drupal off-canvas dialog to show the content. - const options = { - dialogClass: 'aidmi-dialog', - title: tempETitle, - resizable: true, - width: '700px', - buttons: [ - { - text: Drupal.t('Insert Text'), - class: 'aidmi-insert-button', // Add a class to the insert button for reference - click: () => { - // Validate all selections before inserting text - const selections = this._collectSelections(data); - if (selections) { - // Modify the images in the editor content based on the selections - this._modifyImagesInContent(editor, selections); - dialogInstance.close(); - } else { - // Show a message if validation fails - alert(Drupal.t('Please select an option for each image before inserting.')); - } - } - }, - { - text: Drupal.t('Cancel'), - click: function () { - // Close the dialog. - dialogInstance.close(); - } - } - ] - }; - - // Open the dialog using Drupal's dialog API. - const dialogInstance = Drupal.dialog(tempElement, options); - dialogInstance.showModal(); - - // Add event listeners to all radio buttons to validate the selection and remove highlights - document.querySelectorAll('input[type="radio"]').forEach(radio => { - radio.addEventListener('change', (event) => { - const groupId = event.target.name.replace('aidmi-alt-selection-', 'option-group-'); - // Remove the highlight class when a selection is made - document.getElementById(groupId).classList.remove('aidmi-highlight-missing'); - }); + button.icon = iconAidmi; // Restore the original icon + button.isEnabled = true; // Re-enable the button + } + + }); + + // Add a listener to enable or disable the button based on content changes. + editor.model.document.on('change:data', () => { + // Leverage the _aidmiJSONImages function to check for images in the content + const hasImages = this._aidmiJSONImages(editor.getData()).length > 0; + button.isEnabled = hasImages && (!sourceEditing || !sourceEditing.isSourceEditingMode); + }); + + // Listen for changes in the source editing mode if the plugin is available. + if (sourceEditing) { + sourceEditing.on('change:isSourceEditingMode', (evt, name, value) => { + button.isEnabled = !value; // Disable button if in source editing mode. }); + } + + return button; + }); + + } + + _aidmiJSONImages(content) { + // Create a temporary DOM element to parse the HTML string + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; // Convert the HTML string to DOM elements + // Find all images with the class 'aidmi-dialog-image' + let images = tempDiv.querySelectorAll('img, drupal-media'); - // Set focus on the first input for screen readers - document.querySelector('input[type="radio"]').focus(); - } + // Initialize an empty array to store the JSON data + let imageJsonArray = []; - _collectSelections(data) { - let allSelected = true; - const selections = []; + // Loop through each image and extract 'data-entity-uuid' and 'src' + images.forEach((image) => { + let imageObject = { + "data-entity-uuid": image.getAttribute('data-entity-uuid'), + "type": image.tagName.toLowerCase() + }; + + // Push the image object to the array + imageJsonArray.push(imageObject); + }); - // Loop through the images - data.forEach(item => { - if (item.images && Array.isArray(item.images)) { - item.images.forEach((image) => { - const uuid = image['data-entity-uuid']; - const selectedRadio = document.querySelector(`input[name="aidmi-alt-selection-${uuid}"]:checked`); - - // If no radio button is selected, set `allSelected` to false - if (!selectedRadio) { - allSelected = false; - // Highlight the missing selection group - document.getElementById(`option-group-${uuid}`).classList.add('aidmi-highlight-missing'); - } else { - // Get the selected value and corresponding alt text or set as decorative - const altValue = selectedRadio.value; - let altText = ''; - if (altValue === 'current') { - altText = document.getElementById(`aidmi-dialog-textarea-oldalt-${uuid}`).value; - } else if (altValue === 'suggested') { - altText = document.getElementById(`aidmi-dialog-textarea-newalt-${uuid}`).value; - } - selections.push({ - uuid: uuid, - altText: altValue === 'decorative' ? '' : altText, // Leave alt text empty for decorative images - isDecorative: altValue === 'decorative' - }); - } - }); - } + // Convert the array to a JSON string for output or further use + return imageJsonArray; + } + + _aidmiContentAjax(content, imagesJSON) { + const $ = jQuery; + return new Promise((resolve, reject) => { + $.ajax({ + url: '/admin/aidmi/describe-content-ajax', + method: 'POST', + data: { + content: content, + imagesJSON: JSON.stringify(imagesJSON) + }, + success: function(response) { + resolve(response); + }, + error: function(xhr, status, error) { + reject(error); + } + }); + }); + } + + _openDialog(editor, data, callback) { + let aidmiEditedText; + // Create a temporary container to hold the content. + const tempElement = document.createElement('div'); + tempElement.setAttribute('role', 'dialog'); + tempElement.setAttribute('aria-labelledby', 'aidmi-dialog-title'); + tempElement.setAttribute('aria-describedby', 'aidmi-dialog-description'); + tempElement.setAttribute('aria-modal', 'true'); // Ensures screen readers treat the dialog as a modal. + + // Set title text. + let tempETitle = 'AIDmi Description'; + let tempESubTitle = 'Please evaluate and edit the description as needed.'; + + // Create dialog content with a textarea for editing. + tempElement.innerHTML = `<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t(tempESubTitle)}</p>`; + + // Variable to store the number of images + let imageCount = 0; + data.forEach(item => { + if (item.images && Array.isArray(item.images)) { + imageCount += item.images.length; + } + }); + // Loop through the images + data.forEach(item => { + // If item has an images array, loop through it + if (item.images && Array.isArray(item.images)) { + item.images.forEach((image) => { + let formImageInnerHTML = ''; + // Start of image div alt option area. + formImageInnerHTML += `<div class="image-option-group" id="option-group-${image['data-entity-uuid']}">`; + // Add Image Display. + formImageInnerHTML += ` + <!-- Image Display --> + <img class="aidmi-dialog-image" data-entity-uuid="${image['data-entity-uuid']}" src="${image.src}" alt="${Drupal.t(image.before_alt)}" />`; + // Add Current Alt Tag Section. + let imageOldSelectDisabled = false; + let imageLabel = 'Current Alt Tag - Read only'; + let imageBeforeAlt = image.before_alt; + if (!imageBeforeAlt || imageBeforeAlt.length < 6) { + // Old alt shouldn't be an allowed option. + imageLabel += ' (Option disabled since alt is either blank or too short)'; + imageOldSelectDisabled = true; + } + formImageInnerHTML += ` + <!-- Current Alt Tag Section --> + <div> + <label for="aidmi-alt-old-${image['data-entity-uuid']}" class="aidmi-dialog-label"> + <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-old-${image['data-entity-uuid']}" value="current" aria-labelledby="label-old-${image['data-entity-uuid']}" ${imageOldSelectDisabled ? 'disabled' : ''} required/> + <span id="label-old-${image['data-entity-uuid']}">${Drupal.t(imageLabel)}</span> + </label>`; + + // If radio is disabled, don't show the alt textarea. + if (!imageOldSelectDisabled) { + formImageInnerHTML += ` + <textarea id="aidmi-dialog-textarea-oldalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${image['data-entity-uuid']}" readonly="readonly">${Drupal.t(image.before_alt)}</textarea>`; + } + + formImageInnerHTML += ` + </div>`; + + // Add Suggested Alt Tag Section. + formImageInnerHTML += ` + <!-- Suggested Alt Tag Section --> + <div> + <label for="aidmi-alt-new-${image['data-entity-uuid']}" class="aidmi-dialog-label"> + <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-new-${image['data-entity-uuid']}" value="suggested" aria-labelledby="label-new-${image['data-entity-uuid']}" required> + <span id="label-new-${image['data-entity-uuid']}">${Drupal.t('Suggested Alt Tag - Review and modify as required')}</span> + </label> + <textarea id="aidmi-dialog-textarea-newalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${image['data-entity-uuid']}">${Drupal.t(image.recommendation)}</textarea> + </div>`; + // Add Set as Decorative Image. + formImageInnerHTML += ` + <!-- Set as Decorative Image --> + <div> + <label for="aidmi-alt-decorative-${image['data-entity-uuid']}" class="aidmi-dialog-label"> + <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-decorative-${image['data-entity-uuid']}" value="decorative" aria-labelledby="label-decorative-${image['data-entity-uuid']}" required> + <span id="label-decorative-${image['data-entity-uuid']}">${Drupal.t('Set as Decorative Image')}</span> + </label> + </div>`; + // End of image div alt option area. + formImageInnerHTML += `</div><hr/>`; + + // Insert it. + tempElement.innerHTML += formImageInnerHTML; }); - - return allSelected ? selections : null; - } - - // Function to modify the images in the CKEditor content based on the selected options - _modifyImagesInContent(editor, selections) { - // Get the current content of the editor - let content = editor.getData(); - - // Create a temporary DOM element to manipulate the HTML content - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = content; // Convert the HTML string to DOM elements - - // Loop through the selections and modify the corresponding images in the content - selections.forEach(selection => { - // Find the image element by its data-entity-uuid attribute - const imageElement = tempDiv.querySelector(`[data-entity-uuid="${selection.uuid}"]`); - - if (imageElement) { - // Update the alt attribute or set as decorative based on the selection - if (selection.isDecorative) { - imageElement.setAttribute('alt', ''); // Set alt attribute to empty for decorative images - imageElement.setAttribute('role', 'presentation'); // Add role="presentation" to decorative images - } else { - imageElement.setAttribute('alt', selection.altText); // Update the alt attribute - imageElement.removeAttribute('role'); // Remove role attribute if it's not a decorative image - } - + } + }); + + // Use the Drupal off-canvas dialog to show the content. + const options = { + dialogClass: 'aidmi-dialog', + title: tempETitle, + resizable: true, + width: '700px', + buttons: [ + { + text: Drupal.t('Insert Text'), + class: 'aidmi-insert-button', // Add a class to the insert button for reference + click: () => { + // Validate all selections before inserting text + const selections = this._collectSelections(data); + if (selections) { + // Modify the images in the editor content based on the selections + this._modifyImagesInContent(editor, selections); + dialogInstance.close(); } else { - console.warn(`Image with UUID ${selection.uuid} not found in content.`); + // Show a message if validation fails + alert(Drupal.t('Please select an option for each image before inserting.')); } + } + }, + { + text: Drupal.t('Cancel'), + click: function () { + // Close the dialog. + dialogInstance.close(); + } + } + ] + }; + + // Open the dialog using Drupal's dialog API. + const dialogInstance = Drupal.dialog(tempElement, options); + dialogInstance.showModal(); + + // Add event listeners to all radio buttons to validate the selection and remove highlights + document.querySelectorAll('input[type="radio"]').forEach(radio => { + radio.addEventListener('change', (event) => { + const groupId = event.target.name.replace('aidmi-alt-selection-', 'option-group-'); + // Remove the highlight class when a selection is made + document.getElementById(groupId).classList.remove('aidmi-highlight-missing'); + }); + }); + + // Set focus on the first input for screen readers + document.querySelector('input[type="radio"]').focus(); + } + + _collectSelections(data) { + let allSelected = true; + const selections = []; + + // Loop through the images + data.forEach(item => { + if (item.images && Array.isArray(item.images)) { + item.images.forEach((image) => { + const uuid = image['data-entity-uuid']; + const selectedRadio = document.querySelector(`input[name="aidmi-alt-selection-${uuid}"]:checked`); + + // If no radio button is selected, set `allSelected` to false + if (!selectedRadio) { + allSelected = false; + // Highlight the missing selection group + document.getElementById(`option-group-${uuid}`).classList.add('aidmi-highlight-missing'); + } else { + // Get the selected value and corresponding alt text or set as decorative + const altValue = selectedRadio.value; + let altText = ''; + if (altValue === 'current') { + altText = document.getElementById(`aidmi-dialog-textarea-oldalt-${uuid}`).value; + } else if (altValue === 'suggested') { + altText = document.getElementById(`aidmi-dialog-textarea-newalt-${uuid}`).value; + } + selections.push({ + uuid: uuid, + altText: altValue === 'decorative' ? '' : altText, // Leave alt text empty for decorative images + isDecorative: altValue === 'decorative' + }); + } }); + } + }); + + return allSelected ? selections : null; + } + + // Function to modify the images in the CKEditor content based on the selected options + _modifyImagesInContent(editor, selections) { + // Get the current content of the editor + let content = editor.getData(); + + // Create a temporary DOM element to manipulate the HTML content + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; // Convert the HTML string to DOM elements + + // Loop through the selections and modify the corresponding images in the content + selections.forEach(selection => { + // Find the image element by its data-entity-uuid attribute + const imageElement = tempDiv.querySelector(`[data-entity-uuid="${selection.uuid}"]`); + + if (imageElement) { + // Update the alt attribute or set as decorative based on the selection + if (selection.isDecorative) { + imageElement.setAttribute('alt', ''); // Set alt attribute to empty for decorative images + imageElement.setAttribute('role', 'presentation'); // Add role="presentation" to decorative images + } else { + imageElement.setAttribute('alt', selection.altText); // Update the alt attribute + imageElement.removeAttribute('role'); // Remove role attribute if it's not a decorative image + } + + } else { + console.warn(`Image with UUID ${selection.uuid} not found in content.`); + } + }); - // Set the modified content back to the editor - editor.setData(tempDiv.innerHTML); + // Set the modified content back to the editor + editor.setData(tempDiv.innerHTML); - // Optional: Trigger the change event to update the editor state - editor.editing.view.document.fire('change:data'); - } + // Optional: Trigger the change event to update the editor state + editor.editing.view.document.fire('change:data'); + } } diff --git a/src/Controller/AidmiController.php b/src/Controller/AidmiController.php index 8336de1932bd5ec768a981e1b50de1511e8796bb..e10e7a8b0bfa69ff6dbb18623a15ba5360e87f81 100644 --- a/src/Controller/AidmiController.php +++ b/src/Controller/AidmiController.php @@ -2,13 +2,13 @@ namespace Drupal\aidmi\Controller; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\aidmi\Service\GeminiAiService; -use Drupal\Core\Render\Element; -use Symfony\Component\HttpFoundation\JsonResponse; -use Drupal\file\Entity\File; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Response; +/** + * + */ class AidmiController { protected $geminiAiService; @@ -16,56 +16,74 @@ class AidmiController { $this->geminiAiService = $geminiAiService; } + /** + * + */ public static function create(ContainerInterface $container): self { return new static( $container->get('aidmi.ai_service') ); } + /** + * + */ public function analyzeImage(int $fid): Response { // Set the file ID in drupalSettings so it can be accessed in JavaScript. $build = []; $build['#attached']['drupalSettings']['aidmi'] = [ 'imageFid' => $fid, ]; - + $description = $this->geminiAiService->analyzeImage($fid); - + // Return a response for the page. return new Response($description); } + /** + * + */ public function analyzeImageAjax(string $uuid): Response { $fid = $this->getFileIdByUuid($uuid); if ($fid) { $description = $this->geminiAiService->analyzeImage($fid); return new Response($description); } - return null; + return NULL; } + /** + * + */ public function analyzeContentAjax(): Response { // Get the raw POST data from the request. $content = \Drupal::request()->request->get('content'); $imagesJSON = \Drupal::request()->request->get('imagesJSON'); - $description = null; - + $description = NULL; + if (!empty($content)) { $description = $this->geminiAiService->analyzeContent($content, $imagesJSON); - } + } return new Response($description); } - + + /** + * + */ public function getFileIdByUuid(string $uuid) { // Query for the file entity using the UUID. $file = \Drupal::entityTypeManager()->getStorage('file')->loadByProperties(['uuid' => $uuid]); if ($file) { - $file = reset($file); // Get the first result. + // Get the first result. + $file = reset($file); return $file->id(); - } else { - return null; + } + else { + return NULL; } } + } diff --git a/src/Form/AidmiSettingsForm.php b/src/Form/AidmiSettingsForm.php index 8129ecf16883b6be96e4dee967a1cc96cb14ad88..bdf8669d5f7e710e027fb98b8759aed9f75eee33 100644 --- a/src/Form/AidmiSettingsForm.php +++ b/src/Form/AidmiSettingsForm.php @@ -1,4 +1,5 @@ <?php + namespace Drupal\aidmi\Form; use Drupal\Core\Form\ConfigFormBase; @@ -40,21 +41,24 @@ class AidmiSettingsForm extends ConfigFormBase { $file_status_message = ''; $site_root = \Drupal::root(); - + // Check if the file input method is selected and if the file exists. if ($api_input_method === 'file_path' && !empty($api_key_file_path)) { - $full_file_path = $site_root . '/' . ltrim($api_key_file_path, '/'); // Ensure no double slashes. + // Ensure no double slashes. + $full_file_path = $site_root . '/' . ltrim($api_key_file_path, '/'); if (file_exists($full_file_path)) { $file_status_message = $this->t('File found: @path', ['@path' => $full_file_path]); - \Drupal::messenger()->addStatus($file_status_message); // Add as a status message. + // Add as a status message. + \Drupal::messenger()->addStatus($file_status_message); } else { $file_status_message = $this->t('File not found at the provided path: @path', ['@path' => $full_file_path]); - \Drupal::messenger()->addError($file_status_message); // Add as an error message. + // Add as an error message. + \Drupal::messenger()->addError($file_status_message); } } - // Radio buttons for API selection + // Radio buttons for API selection. $form['selected_api'] = [ '#type' => 'radios', '#title' => $this->t('Select API'), @@ -65,7 +69,7 @@ class AidmiSettingsForm extends ConfigFormBase { '#description' => $this->t('Select which API to use for processing.'), '#required' => TRUE, ]; - + // Add a radio button to select the input method. $form['api_input_method'] = [ '#type' => 'radios', @@ -92,9 +96,9 @@ class AidmiSettingsForm extends ConfigFormBase { 'id' => 'api-key-field', ], ]; - + $recommended_path = $site_root . '/../api_keys/aidmi.key'; - + $form['api_key_file_path'] = [ '#type' => 'textfield', '#title' => $this->t('API Key File Path'), @@ -118,7 +122,7 @@ class AidmiSettingsForm extends ConfigFormBase { ], ]; - // Field for the API instructions + // Field for the API instructions. $form['api_instructions'] = [ '#type' => 'textarea', '#title' => $this->t('API Instructions'), @@ -135,28 +139,32 @@ class AidmiSettingsForm extends ConfigFormBase { */ public function submitForm(array &$form, FormStateInterface $form_state) { - // Prepare the config object for saving. - $config = $this->configFactory()->getEditable('aidmi.settings'); - - // Get the selected API input method. - $api_input_method = $config->get('api_input_method'); - - if ($api_input_method === 'file_path') { - // Clear the old API key if the file path is chosen. - $config->set('api_key', ''); - } else { - // Save the entered API key if the API key method is chosen. - $config->set('api_key', $form_state->getValue('api_key')); - } - - // Save the API input method and the file path (if applicable). - $config->set('api_input_method', $api_input_method) - ->set('api_input_method', $form_state->getValue('api_input_method')) // Save selected API Key method - ->set('selected_api', $form_state->getValue('selected_api')) // Save selected API - ->set('api_key_file_path', $form_state->getValue('api_key_file_path')) - ->set('api_instructions', $form_state->getValue('api_instructions')) - ->save(); + // Prepare the config object for saving. + $config = $this->configFactory()->getEditable('aidmi.settings'); + + // Get the selected API input method. + $api_input_method = $config->get('api_input_method'); + + if ($api_input_method === 'file_path') { + // Clear the old API key if the file path is chosen. + $config->set('api_key', ''); + } + else { + // Save the entered API key if the API key method is chosen. + $config->set('api_key', $form_state->getValue('api_key')); + } + + // Save the API input method and the file path (if applicable). + $config->set('api_input_method', $api_input_method) + // Save selected API Key method. + ->set('api_input_method', $form_state->getValue('api_input_method')) + // Save selected API. + ->set('selected_api', $form_state->getValue('selected_api')) + ->set('api_key_file_path', $form_state->getValue('api_key_file_path')) + ->set('api_instructions', $form_state->getValue('api_instructions')) + ->save(); parent::submitForm($form, $form_state); } + } diff --git a/src/Plugin/CKEditor5Plugin/aidmi.php b/src/Plugin/CKEditor5Plugin/aidmi.php deleted file mode 100644 index 96677f1bc1892b347c08a077f359f182f8c5ddd0..0000000000000000000000000000000000000000 --- a/src/Plugin/CKEditor5Plugin/aidmi.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php - -declare(strict_types = 1); - -namespace Drupal\aidmi\Plugin\CKEditor5Plugin; - -use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault; -use Drupal\Core\Url; -use Drupal\editor\EditorInterface; - -/** - * Plugin class to add dialog url for embedded content. - */ -class aidmi extends CKEditor5PluginDefault { - - /** - * {@inheritdoc} - */ - public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array { - $aidmi_dialog_url = Url::fromRoute('ckeditor5_aidmi.dialog') - ->toString(TRUE) - ->getGeneratedUrl(); - $static_plugin_config['aidmi']['dialogURL'] = $aidmi_dialog_url; - $aidmi_preview_url = Url::fromRoute('ckeditor5_aidmi.preview', [ - 'editor' => $editor->id(), - ]) - ->toString(TRUE) - ->getGeneratedUrl(); - // $static_plugin_config['aidmi']['previewURL'] = $aidmi_preview_url; - return $static_plugin_config; - } - -} diff --git a/src/Service/GeminiAiService.php b/src/Service/GeminiAiService.php index f297e0bec21ac6fbb4633a9c677efc377ad3cccb..8387eb2c572df45426384dbf9d626e54a486eea9 100644 --- a/src/Service/GeminiAiService.php +++ b/src/Service/GeminiAiService.php @@ -2,25 +2,32 @@ namespace Drupal\aidmi\Service; -use Drupal\file\Entity\File; // Correct namespace for Drupal File Entity use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\Component\Utility\Html; -use Drupal\media\Entity\Media; +use Drupal\file\Entity\File; +// Correct namespace for Drupal File Entity. +use Drupal\file\FileInterface; +/** + * + */ class GeminiAiService { - // Gemini Base URL and Header for API Key. + /** + * Gemini Base URL and Header for API Key. + */ private string $baseUrl = 'https://generativelanguage.googleapis.com'; private string $apiKeyHeader = 'x-goog-api-key'; - - // Gemini Models. - private string $GeminiPro = 'models/gemini-pro'; - private string $GeminiPro10 = 'models/gemini-1.0-pro'; - private string $GeminiPro10Latest = 'models/gemini-1.0-pro-latest'; - private string $GeminiPro15 = 'models/gemini-1.5-pro'; - private string $GeminiPro15Flash = 'models/gemini-1.5-flash'; - private string $GeminiProVision = 'models/gemini-pro-vision'; - private string $Embedding = 'models/embedding-001'; - private string $AQA = 'models/aqa'; + + /** + * Gemini Models. + */ + private string $geminiPro = 'models/gemini-pro'; + private string $geminiPro10 = 'models/gemini-1.0-pro'; + private string $geminiPro10Latest = 'models/gemini-1.0-pro-latest'; + private string $geminiPro15 = 'models/gemini-1.5-pro'; + private string $geminiPro15Flash = 'models/gemini-1.5-flash'; + private string $geminiProVision = 'models/gemini-pro-vision'; + private string $embedding = 'models/embedding-001'; + private string $aqa = 'models/aqa'; /** * The configuration factory service. @@ -49,7 +56,6 @@ class GeminiAiService { * @var \Drupal\aidmi\Controller\AidmiController */ protected $aidmiController; - public function __construct(ConfigFactoryInterface $configFactory) { $this->configFactory = $configFactory; @@ -65,98 +71,115 @@ class GeminiAiService { if ($apiInputMethod === 'file_path') { // Get the API key file path from the configuration. $apiKeyFilePath = $config->get('api_key_file_path'); - $siteRoot = \Drupal::root(); // Get the site root. - $fullFilePath = $siteRoot . '/' . ltrim($apiKeyFilePath, '/'); // Full file path. + // Get the site root. + $siteRoot = \Drupal::root(); + // Full file path. + $fullFilePath = $siteRoot . '/' . ltrim($apiKeyFilePath, '/'); // Check if the file exists and is readable. if (file_exists($fullFilePath) && is_readable($fullFilePath)) { // Read the API key from the file. $apiKey = trim(file_get_contents($fullFilePath)); - } else { - // Log an error and default the API key to an empty string or a default value. + } + else { + // Log an error and default the API key. \Drupal::logger('aidmi')->error('API key file not found or not readable at @path', ['@path' => $fullFilePath]); - $apiKey = '0'; // Fallback if file is not found or readable. + // Fallback if file is not found or readable. + $apiKey = '0'; } - } else { - // Get the API key from the configuration if the input method is 'api_key'. + } + else { + // Get the API key from the configuration. $apiKey = $config->get('api_key'); - + // Default to '0' if the API key is empty. if (empty($apiKey)) { $apiKey = '0'; } } - + $this->apiKey = $apiKey; $this->apiInstructions = $config->get('api_instructions'); } + /** + * + */ public function analyzeImage(int $fid): string { try { $file = File::load($fid); if (!$file) { throw new \Exception(t('File not found.')); } - + $imagePath = $file->getFileUri(); $imageContent = file_get_contents($imagePath); if (!$imageContent) { throw new \Exception(t('Unable to open image file.')); } - + $output = $this->geminiReviewImage($imagePath); return $output; - } catch (\Exception $e) { - return 'Error: ' . $e->getMessage(); + } + catch (\Exception $e) { + return 'Error: ' . $e->getMessage(); } } + /** + * + */ public function analyzeContent(string $content, string $imagesJSON): string { - try { + try { $output = $this->geminiReviewContent($content, $imagesJSON); $output = str_replace("```json", "", $output); $output = str_replace("```", "", $output); - + return $output; - } catch (\Exception $e) { + } + catch (\Exception $e) { return 'Error: ' . $e->getMessage(); } } - + + /** + * + */ public function getFileIdByUuid(string $uuid, string $type) { - $file = null; - + $file = NULL; + // If type is drupal-media, load media entity first. if ($type == 'drupal-media') { $media = $this->loadMediaByUuid($uuid); if ($media && $media->bundle() === 'image') { - // Check if the media entity has the 'field_media_image' field and it's not empty. + // Check if the media entity has the field. if ($media->hasField('field_media_image') && !$media->get('field_media_image')->isEmpty()) { $file_id = $media->get('field_media_image')->target_id; // Load the file entity using the file ID. $file = File::load($file_id); } } - } else { + } + else { // If type is not drupal-media, directly get file entity by UUID. $files = \Drupal::entityTypeManager()->getStorage('file')->loadByProperties(['uuid' => $uuid]); if (!empty($files)) { $file = reset($files); } } - + // Check if the file is loaded and valid. - if ($file instanceof \Drupal\file\FileInterface) { + if ($file instanceof FileInterface) { return $file; } elseif ($media && $media->bundle() != 'image') { - return null; + return NULL; } else { throw new \Exception(t('File not found for UUID: @uuid', ['@uuid' => $uuid])); } } - + /** * Load a media entity by its UUID. * @@ -166,26 +189,28 @@ class GeminiAiService { * @return \Drupal\media\MediaInterface|null * The media entity object if found, otherwise null. */ - function loadMediaByUuid($uuid) { + public function loadMediaByUuid($uuid) { // Load the media entity using the UUID. $media_entities = \Drupal::entityTypeManager() ->getStorage('media') ->loadByProperties(['uuid' => $uuid]); - + // Check if any media entities are returned. if (!empty($media_entities)) { // Get the first media entity from the array. return reset($media_entities); } - - return null; + + return NULL; } - function geminiReviewImage(string $imagePath):string { + /** + * + */ + public function geminiReviewImage(string $imagePath):string { // Keeping all models in here for now. - - // Connect - $ch = curl_init($this->baseUrl . "/v1/" . $this->GeminiPro15Flash . ":streamGenerateContent"); + // Connect. + $ch = curl_init($this->baseUrl . "/v1/" . $this->geminiPro15Flash . ":streamGenerateContent"); // Setup request to send json via POST. // The text prompt you want to send. $prompt = $this->apiInstructions; @@ -193,43 +218,42 @@ class GeminiAiService { $imageData = base64_encode(file_get_contents($imagePath)); // Prepare the data payload. $data = [ - 'contents' => [ - 'parts' => [ + 'contents' => [ + 'parts' => [ [ - 'text' => $prompt, + 'text' => $prompt, ], [ 'inlineData' => [ - 'mimeType' => 'image/jpeg', - 'data' => $imageData, - ] + 'mimeType' => 'image/jpeg', + 'data' => $imageData, + ], ], - ], - ] + ], + ], ]; - + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, array($this->apiKeyHeader .': '. $this->apiKey, 'Content-Type:application/json')); + curl_setopt($ch, CURLOPT_HTTPHEADER, [$this->apiKeyHeader . ': ' . $this->apiKey, 'Content-Type:application/json']); // Return response instead of printing. - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); // Send request. $result = curl_exec($ch); curl_close($ch); // Decode and handle the response. - $resultData = json_decode($result, true); - //return $result; - - $fullDescription = null; + $resultData = json_decode($result, TRUE); + // Return $result;. + $fullDescription = NULL; // Iterate through each candidate in the response data. foreach ($resultData as $entry) { if (isset($entry['candidates'][0]['content']['parts'])) { - // Concatenate all the text parts together. - foreach ($entry['candidates'][0]['content']['parts'] as $part) { - if (isset($part['text'])) { - $fullDescription .= $part['text']; - } + // Concatenate all the text parts together. + foreach ($entry['candidates'][0]['content']['parts'] as $part) { + if (isset($part['text'])) { + $fullDescription .= $part['text']; } + } } } @@ -237,90 +261,90 @@ class GeminiAiService { if (isset($fullDescription)) { return $fullDescription; } - else { + else { return '**Error**'; } } - - function geminiReviewContent(string $content, string $imagesJSON):string { - // Decode JSON string into PHP array - $jsonArray = json_decode($imagesJSON, true); + /** + * + */ + public function geminiReviewContent(string $content, string $imagesJSON):string { + // Decode JSON string into PHP array. + $jsonArray = json_decode($imagesJSON, TRUE); - // Connect - $ch = curl_init($this->baseUrl . "/v1/" . $this->GeminiPro15Flash . ":streamGenerateContent"); + // Connect. + $ch = curl_init($this->baseUrl . "/v1/" . $this->geminiPro15Flash . ":streamGenerateContent"); // Setup request to send json via POST. // The text prompt you want to send. $prompt = $this->apiInstructions; $prompt = 'Response can only be in JSON. Do not provide null value if something is empty, just keep it "" blank. Exact JSON output example: [{"images": [{"data-entity-uuid": "", "src": "", "before_alt": "", "recommendation": "", "decorative_image" = ""}]}, {"message": "error or other message if needed"}]. Review this content and describe the image. Actual images from the content are attached and referenced by data-entity-uuid. Provide a json output with each image by data-entity-uuid, image src, the before alt text if available, and your recommendation. If there is a problem, respond with only an error JSON. Provide a true or false for decorative image where just an alt tag alone is decorative, but alt="" is false. ' . $prompt; - + $generateContent = []; $generateContent[] = ['text' => $prompt]; foreach ($jsonArray as $item) { - $imagePath = null; - $imageContent = null; + $imagePath = NULL; + $imageContent = NULL; // Get file entity by UUID. $file = $this->getFileIdByUuid($item['data-entity-uuid'], $item['type']); - - if ($file instanceof \Drupal\file\FileInterface) { + + if ($file instanceof FileInterface) { $imagePath = \Drupal::service('file_system')->realpath($file->getFileUri()); // Use the file_url_generator service to generate the URL. $url = \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri()); - if (file_exists($imagePath)) { // Check if file path is valid. + // Check if file path is valid. + if (file_exists($imagePath)) { $imageContent = file_get_contents($imagePath); } } - + // If there is an image, do it. - if ($imageContent) { + if ($imageContent) { // Attach images for Gemini AI review. - - $generateContent[] = ['text' => "data-entity-uuid: " . $item['data-entity-uuid'] .", src: ". $url]; + $generateContent[] = ['text' => "data-entity-uuid: " . $item['data-entity-uuid'] . ", src: " . $url]; $generateContent[] = [ 'inlineData' => [ - 'mimeType' => 'image/jpeg', - 'data' => base64_encode($imageContent), - ] - ]; + 'mimeType' => 'image/jpeg', + 'data' => base64_encode($imageContent), + ], + ]; } } - - + // Read the image file and encode it in base64. $imageData = base64_encode(file_get_contents($imagePath)); // Prepare the data payload. $data = [ - 'contents' => [ - 'parts' => $generateContent, - ] + 'contents' => [ + 'parts' => $generateContent, + ], ]; - + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, array($this->apiKeyHeader .': '. $this->apiKey, 'Content-Type:application/json')); + curl_setopt($ch, CURLOPT_HTTPHEADER, [$this->apiKeyHeader . ': ' . $this->apiKey, 'Content-Type:application/json']); // Return response instead of printing. - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); // Send request. $result = curl_exec($ch); curl_close($ch); // Decode and handle the response. - $resultData = json_decode($result, true); - //return $result; - - $fullDescription = null; + $resultData = json_decode($result, TRUE); + // Return $result;. + $fullDescription = NULL; // Iterate through each candidate in the response data. foreach ($resultData as $entry) { if (isset($entry['candidates'][0]['content']['parts'])) { - // Concatenate all the text parts together. - foreach ($entry['candidates'][0]['content']['parts'] as $part) { - if (isset($part['text'])) { - $fullDescription .= $part['text']; - } + // Concatenate all the text parts together. + foreach ($entry['candidates'][0]['content']['parts'] as $part) { + if (isset($part['text'])) { + $fullDescription .= $part['text']; } + } } } @@ -328,9 +352,9 @@ class GeminiAiService { if (isset($fullDescription)) { return $fullDescription; } - else { + else { return '**Error**'; } } - + }