imio.smartweb.common 1.2.42__py3-none-any.whl → 1.2.44__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imio/smartweb/common/browser/tiny/process.py +1 -1
- imio/smartweb/common/configure.zcml +1 -0
- imio/smartweb/common/ia/__init__.py +0 -0
- imio/smartweb/common/ia/behaviors/__init__.py +0 -0
- imio/smartweb/common/ia/behaviors/configure.zcml +15 -0
- imio/smartweb/common/ia/behaviors/title.py +17 -0
- imio/smartweb/common/ia/browser/__init__.py +0 -0
- imio/smartweb/common/ia/browser/categorization_button_add.py +63 -0
- imio/smartweb/common/ia/browser/categorization_button_edit.py +87 -0
- imio/smartweb/common/ia/browser/configure.zcml +43 -0
- imio/smartweb/common/ia/browser/static/Makefile +12 -0
- imio/smartweb/common/ia/browser/static/package.json +28 -0
- imio/smartweb/common/ia/browser/static/smartweb-common-ia_suggested_titles-compiled.js +1 -0
- imio/smartweb/common/ia/browser/static/src/ia_suggested_titles.js +247 -0
- imio/smartweb/common/ia/browser/static/webpack.config.js +41 -0
- imio/smartweb/common/ia/browser/views.py +150 -0
- imio/smartweb/common/ia/configure.zcml +10 -0
- imio/smartweb/common/ia/widgets/configure.zcml +17 -0
- imio/smartweb/common/ia/widgets/html_snippet_widget.pt +352 -0
- imio/smartweb/common/ia/widgets/html_snippet_widget.py +65 -0
- imio/smartweb/common/ia/widgets/suggested_ia_titles_input.pt +45 -0
- imio/smartweb/common/ia/widgets/widget.py +40 -0
- imio/smartweb/common/profiles/default/metadata.xml +1 -1
- imio/smartweb/common/profiles/default/registry/registry.xml +9 -0
- imio/smartweb/common/rest/endpoint.py +10 -1
- imio/smartweb/common/upgrades/configure.zcml +19 -0
- imio/smartweb/common/upgrades/profiles/1035_to_1036/registry/bundles.xml +17 -0
- imio/smartweb/common/utils.py +4 -2
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/METADATA +19 -1
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/RECORD +36 -16
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/WHEEL +1 -1
- imio/smartweb/common/browser/ia.py +0 -33
- /imio.smartweb.common-1.2.42-py3.12-nspkg.pth → /imio.smartweb.common-1.2.44-py3.12-nspkg.pth +0 -0
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/licenses/LICENSE.GPL +0 -0
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/licenses/LICENSE.rst +0 -0
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/namespace_packages.txt +0 -0
- {imio_smartweb_common-1.2.42.dist-info → imio_smartweb_common-1.2.44.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from imio.smartweb.common.config import IPA_URL
|
|
2
|
+
from imio.smartweb.common.config import APPLICATION_ID
|
|
3
|
+
from imio.smartweb.common.config import PROJECT_ID
|
|
4
|
+
from imio.smartweb.common.utils import get_vocabulary
|
|
5
|
+
from plone import api
|
|
6
|
+
from Products.Five import BrowserView
|
|
7
|
+
from zope.i18n import translate
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseIAView(BrowserView):
|
|
14
|
+
"""
|
|
15
|
+
Base view providing common headers and configuration for IA-related features.
|
|
16
|
+
This class is shared across multiple projects, including imio.smartweb.core.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_headers = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def headers(self):
|
|
23
|
+
if self._headers is None:
|
|
24
|
+
self._headers = {
|
|
25
|
+
"accept": "application/json",
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"x-imio-application": APPLICATION_ID,
|
|
28
|
+
"x-imio-municipality": PROJECT_ID,
|
|
29
|
+
}
|
|
30
|
+
return self._headers
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def headers_json(self):
|
|
34
|
+
return json.dumps(self.headers)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProcessSuggestedTitlesView(BaseIAView):
|
|
38
|
+
|
|
39
|
+
def __call__(self):
|
|
40
|
+
self.request.response.setHeader(
|
|
41
|
+
"Content-Type", "application/json; charset=utf-8"
|
|
42
|
+
)
|
|
43
|
+
current_html = self.request.form.get("text", "")
|
|
44
|
+
payload = {
|
|
45
|
+
"input": current_html,
|
|
46
|
+
"expansion_target": 50,
|
|
47
|
+
}
|
|
48
|
+
url = f"{IPA_URL}/suggest-titles"
|
|
49
|
+
response = requests.post(url, headers=self.headers, json=payload)
|
|
50
|
+
if response.status_code != 200:
|
|
51
|
+
return current_html
|
|
52
|
+
data = response.json()
|
|
53
|
+
if not data:
|
|
54
|
+
return current_html
|
|
55
|
+
return json.dumps(data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaseProcessCategorizeContentView(BaseIAView):
|
|
59
|
+
|
|
60
|
+
timeout = (10, 30)
|
|
61
|
+
|
|
62
|
+
def __init__(self, context, request):
|
|
63
|
+
super().__init__(context, request)
|
|
64
|
+
self.current_lang = api.portal.get_current_language()[:2]
|
|
65
|
+
|
|
66
|
+
def _get_structured_data_from_vocabulary(self, vocabulary_name, obj=None):
|
|
67
|
+
voc = get_vocabulary(vocabulary_name, obj)
|
|
68
|
+
voc_translated_dict = [
|
|
69
|
+
{
|
|
70
|
+
"title": translate(t.title, target_language=self.current_lang),
|
|
71
|
+
"token": t.token,
|
|
72
|
+
}
|
|
73
|
+
for t in voc
|
|
74
|
+
]
|
|
75
|
+
return voc_translated_dict
|
|
76
|
+
|
|
77
|
+
def _ask_categorization_to_ia(self, text, voc):
|
|
78
|
+
payload = {"input": text, "vocabulary": voc, "unique": False}
|
|
79
|
+
url = f"{IPA_URL}/categorize-content"
|
|
80
|
+
try:
|
|
81
|
+
response = requests.post(
|
|
82
|
+
url, headers=self.headers, json=payload, timeout=self.timeout
|
|
83
|
+
)
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
return response.json() or {}
|
|
86
|
+
except requests.RequestException:
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
def _merge_existing_tokens(self, ia_list, existing_tokens, full_voc):
|
|
90
|
+
"""Ajoute les tokens déjà enregistrés mais absents de la réponse IA."""
|
|
91
|
+
ia_tokens = {t.get("token") for t in ia_list}
|
|
92
|
+
for token in existing_tokens or []:
|
|
93
|
+
if token not in ia_tokens:
|
|
94
|
+
item = next((t for t in full_voc if t.get("token") == token), None)
|
|
95
|
+
if item:
|
|
96
|
+
ia_list.append(item)
|
|
97
|
+
return ia_list
|
|
98
|
+
|
|
99
|
+
def _process_iam(self, all_text, results):
|
|
100
|
+
iam_voc = self._get_structured_data_from_vocabulary(
|
|
101
|
+
"imio.smartweb.vocabulary.IAm"
|
|
102
|
+
)
|
|
103
|
+
data = self._ask_categorization_to_ia(all_text, iam_voc)
|
|
104
|
+
if not data:
|
|
105
|
+
return
|
|
106
|
+
ia_iam = [
|
|
107
|
+
{"title": r.get("title"), "token": r.get("token")}
|
|
108
|
+
for r in data.get("result", [])
|
|
109
|
+
]
|
|
110
|
+
ia_iam = self._merge_existing_tokens(
|
|
111
|
+
ia_iam, getattr(self.context, "iam", []), iam_voc
|
|
112
|
+
)
|
|
113
|
+
results["form-widgets-IAm-iam"] = ia_iam
|
|
114
|
+
|
|
115
|
+
def _process_topics(self, all_text, results):
|
|
116
|
+
topics_voc = self._get_structured_data_from_vocabulary(
|
|
117
|
+
"imio.smartweb.vocabulary.Topics"
|
|
118
|
+
)
|
|
119
|
+
data = self._ask_categorization_to_ia(all_text, topics_voc)
|
|
120
|
+
if not data:
|
|
121
|
+
return
|
|
122
|
+
ia_topics = [
|
|
123
|
+
{"title": r.get("title"), "token": r.get("token")}
|
|
124
|
+
for r in data.get("result", [])
|
|
125
|
+
]
|
|
126
|
+
ia_topics = self._merge_existing_tokens(
|
|
127
|
+
ia_topics, getattr(self.context, "topics", []), topics_voc
|
|
128
|
+
)
|
|
129
|
+
results["form-widgets-ITopics-topics"] = ia_topics
|
|
130
|
+
|
|
131
|
+
def _get_all_text(self):
|
|
132
|
+
"""To implement in child class"""
|
|
133
|
+
raise NotImplementedError("_get_all_text must be defined in subclasses")
|
|
134
|
+
|
|
135
|
+
def _process_specific(self, all_text="", results={}):
|
|
136
|
+
"""To implement in child class"""
|
|
137
|
+
raise NotImplementedError("_process_specific must be defined in subclasses")
|
|
138
|
+
|
|
139
|
+
def __call__(self):
|
|
140
|
+
self.request.response.setHeader(
|
|
141
|
+
"Content-Type", "application/json; charset=utf-8"
|
|
142
|
+
)
|
|
143
|
+
all_text = self._get_all_text()
|
|
144
|
+
results = {}
|
|
145
|
+
self._process_iam(all_text, results)
|
|
146
|
+
self._process_topics(all_text, results)
|
|
147
|
+
self._process_specific(all_text, results)
|
|
148
|
+
return json.dumps(
|
|
149
|
+
{"ok": True, "message": "Catégorisation calculée", "data": results}
|
|
150
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<configure
|
|
2
|
+
xmlns="http://namespaces.zope.org/zope"
|
|
3
|
+
xmlns:browser="http://namespaces.zope.org/browser"
|
|
4
|
+
xmlns:plone="http://namespaces.plone.org/plone">
|
|
5
|
+
|
|
6
|
+
<include package=".behaviors" />
|
|
7
|
+
<include package=".browser" />
|
|
8
|
+
<include package=".widgets" />
|
|
9
|
+
|
|
10
|
+
</configure>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<configure
|
|
2
|
+
xmlns="http://namespaces.zope.org/zope"
|
|
3
|
+
xmlns:z3c="http://namespaces.zope.org/z3c"
|
|
4
|
+
xmlns:browser="http://namespaces.zope.org/browser">
|
|
5
|
+
|
|
6
|
+
<adapter factory=".widget.SuggestedIATitlesWidget"
|
|
7
|
+
for="z3c.form.interfaces.IFormLayer
|
|
8
|
+
imio.smartweb.common.interfaces.IImioSmartwebCommonLayer"
|
|
9
|
+
/>
|
|
10
|
+
|
|
11
|
+
<z3c:widgetTemplate
|
|
12
|
+
widget=".widget.ISuggestedIATitlesWidget"
|
|
13
|
+
mode="input"
|
|
14
|
+
layer="z3c.form.interfaces.IFormLayer"
|
|
15
|
+
template="suggested_ia_titles_input.pt" />
|
|
16
|
+
|
|
17
|
+
</configure>
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
<tal:root xmlns="http://www.w3.org/1999/xhtml" i18n:domain="imio.smartweb"
|
|
2
|
+
xmlns:tal="http://xml.zope.org/namespaces/tal">
|
|
3
|
+
<div class="autotoc-section">
|
|
4
|
+
<!-- ATTENTION,
|
|
5
|
+
dans Agenda et actualités, le text est directement sur le context, du coups, on ne peut pas disabled le bouton
|
|
6
|
+
ou si on veut le dire il faut faire un callback pour récupérer si il y a qqchose dans le text.
|
|
7
|
+
disabled python:view.is_disabled and 'disabled' or nothing"
|
|
8
|
+
-->
|
|
9
|
+
<button type="button"
|
|
10
|
+
tal:attributes="id string:${view/wid}-btn;
|
|
11
|
+
data-endpoint view/endpoint;
|
|
12
|
+
class python:view.klass and f'suggestedcategorization-generate-button {view.klass}' or 'suggestedcategorization-generate-button';"
|
|
13
|
+
i18n:translate="">
|
|
14
|
+
Catégoriser via IA
|
|
15
|
+
</button>
|
|
16
|
+
<span tal:attributes="id string:${view/wid}-status"
|
|
17
|
+
style="margin-left:.5rem;"></span>
|
|
18
|
+
<span tal:condition="view/is_disabled"
|
|
19
|
+
style="margin-left:.1rem;font-size: 14px;"
|
|
20
|
+
i18n:translate="">(Not enough content to categorize)</span>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<script type="text/javascript">
|
|
24
|
+
(function(){
|
|
25
|
+
var btnId = '${view/wid}-btn';
|
|
26
|
+
var statusId = '${view/wid}-status';
|
|
27
|
+
var btn = document.getElementById(btnId);
|
|
28
|
+
if(!btn) return;
|
|
29
|
+
|
|
30
|
+
var status = document.getElementById(statusId);
|
|
31
|
+
var endpoint = btn.getAttribute('data-endpoint');
|
|
32
|
+
var form = btn.closest('form') || document.querySelector('form');
|
|
33
|
+
|
|
34
|
+
// ---------- UI helpers ----------
|
|
35
|
+
function setStatus(msg){ if(status){ status.textContent = msg; } }
|
|
36
|
+
function ok(msg){ setStatus(msg || 'Mis à jour ✔'); }
|
|
37
|
+
function err(msg){ setStatus('Erreur : ' + (msg || 'inconnue')); }
|
|
38
|
+
|
|
39
|
+
// ---------- CSRF ----------
|
|
40
|
+
function getCsrf(){
|
|
41
|
+
var t = form && form.querySelector('input[name="_authenticator"]');
|
|
42
|
+
return t ? t.value : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------- Helpers IDs / champs ----------
|
|
46
|
+
function resolveTarget(id){
|
|
47
|
+
if (!id) return null;
|
|
48
|
+
id = (id[0] === '#') ? id.slice(1) : id;
|
|
49
|
+
|
|
50
|
+
var el = document.getElementById(id);
|
|
51
|
+
if (el && (id.indexOf('formfield-') === 0 || el.matches('div, fieldset, section'))) {
|
|
52
|
+
var inner = el.querySelector('select, input, textarea');
|
|
53
|
+
if (inner) el = inner;
|
|
54
|
+
}
|
|
55
|
+
if (!el && form){
|
|
56
|
+
try { el = form.querySelector('#'+(window.CSS && CSS.escape ? CSS.escape(id) : id)+', [name="'+id+'"]'); }
|
|
57
|
+
catch(e){ el = form.querySelector('#'+id+', [name="'+id+'"]'); }
|
|
58
|
+
}
|
|
59
|
+
return el || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isPatSelect2Input(el){
|
|
63
|
+
return el && el.tagName === 'INPUT' && el.classList.contains('pat-select2');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeEntry(item){
|
|
67
|
+
// Supporte: "token" OU {token,title} OU {value,text}
|
|
68
|
+
if (item && typeof item === 'object'){
|
|
69
|
+
var v = item.value || item.token || '';
|
|
70
|
+
var t = item.text || item.title || v;
|
|
71
|
+
return {value: String(v), text: String(t)};
|
|
72
|
+
}
|
|
73
|
+
var s = String(item == null ? '' : item);
|
|
74
|
+
return {value: s, text: s};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- SELECT classique (<select>) ----------
|
|
78
|
+
function setSelectSequential(selectEl, values, options){
|
|
79
|
+
options = options || {};
|
|
80
|
+
var replace = (options.replace !== false);
|
|
81
|
+
var delay = typeof options.delay === 'number' ? options.delay : 0;
|
|
82
|
+
var isMulti = !!selectEl.multiple;
|
|
83
|
+
|
|
84
|
+
var seq = (Array.isArray(values) ? values : [values]).map(normalizeEntry);
|
|
85
|
+
|
|
86
|
+
if (replace){
|
|
87
|
+
for (var i=0; i<selectEl.options.length; i++){ selectEl.options[i].selected = false; }
|
|
88
|
+
try { selectEl.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
var i = 0;
|
|
92
|
+
function addNext(){
|
|
93
|
+
if (i >= seq.length){
|
|
94
|
+
if (!isMulti && seq.length){
|
|
95
|
+
selectEl.value = seq[0].value;
|
|
96
|
+
try { selectEl.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
var item = seq[i++]; // {value, text}
|
|
101
|
+
|
|
102
|
+
var opt = null;
|
|
103
|
+
for (var j=0; j<selectEl.options.length; j++){
|
|
104
|
+
if (selectEl.options[j].value === item.value){ opt = selectEl.options[j]; break; }
|
|
105
|
+
}
|
|
106
|
+
if (!opt){
|
|
107
|
+
// fallback label
|
|
108
|
+
for (var k=0; k<selectEl.options.length; k++){
|
|
109
|
+
if (selectEl.options[k].text === item.text){ opt = selectEl.options[k]; break; }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!opt){
|
|
113
|
+
opt = new Option(item.text, item.value, false, false);
|
|
114
|
+
selectEl.add(opt);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
opt.selected = true;
|
|
118
|
+
try { selectEl.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
119
|
+
|
|
120
|
+
if (delay > 0) setTimeout(addNext, delay);
|
|
121
|
+
else if (window.requestAnimationFrame) requestAnimationFrame(addNext);
|
|
122
|
+
else setTimeout(addNext, 0);
|
|
123
|
+
}
|
|
124
|
+
addNext();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- INPUT pat-select2 (remote) ----------
|
|
128
|
+
function setPatSelect2(el, values, options){
|
|
129
|
+
options = options || {};
|
|
130
|
+
var replace = (options.replace !== false);
|
|
131
|
+
|
|
132
|
+
// Lire le séparateur depuis la pattern (défaut ;)
|
|
133
|
+
var sep = ';';
|
|
134
|
+
var cfg = el.getAttribute('data-pat-select2');
|
|
135
|
+
if (cfg){
|
|
136
|
+
try { var parsed = JSON.parse(cfg); if (parsed && parsed.separator) sep = parsed.separator; } catch(e){}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Normaliser en {value, text}
|
|
140
|
+
function norm(item){
|
|
141
|
+
if (item && typeof item === 'object'){
|
|
142
|
+
var v = item.value || item.token || '';
|
|
143
|
+
var t = item.text || item.title || v;
|
|
144
|
+
return {value:String(v), text:String(t)};
|
|
145
|
+
}
|
|
146
|
+
var s = String(item == null ? '' : item);
|
|
147
|
+
return {value:s, text:s};
|
|
148
|
+
}
|
|
149
|
+
var seq = (Array.isArray(values) ? values : [values]).map(norm);
|
|
150
|
+
var tokens = seq.map(function(x){ return x.value; });
|
|
151
|
+
|
|
152
|
+
// Append vs replace
|
|
153
|
+
if (!replace && el.value){
|
|
154
|
+
var current = el.value.split(sep).filter(Boolean);
|
|
155
|
+
var set = Object.create(null);
|
|
156
|
+
current.forEach(function(v){ set[v] = true; });
|
|
157
|
+
tokens.forEach(function(v){ if (!set[v]) current.push(v); });
|
|
158
|
+
tokens = current;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 1) Écrire les tokens dans l'input (ce qui sera soumis)
|
|
162
|
+
el.value = tokens.join(sep);
|
|
163
|
+
|
|
164
|
+
// 2) Si Select2 (v3) est présent, pousser *puis* figer les labels
|
|
165
|
+
var hadSelect2 = false;
|
|
166
|
+
if (window.jQuery){
|
|
167
|
+
var $el = window.jQuery(el);
|
|
168
|
+
var api = $el.data('select2');
|
|
169
|
+
if (api){
|
|
170
|
+
hadSelect2 = true;
|
|
171
|
+
var data = seq.map(function(x){ return {id: x.value, text: x.text}; });
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// Important: ne PAS déclencher de change ici avant d'avoir injecté les labels
|
|
175
|
+
// Fixer d'abord les ids (facultatif, mais garde la cohérence interne)
|
|
176
|
+
$el.select2('val', tokens);
|
|
177
|
+
|
|
178
|
+
// Puis injecter les labels
|
|
179
|
+
$el.select2('data', data);
|
|
180
|
+
|
|
181
|
+
// Enfin, notifier (léger) : input + (éventuellement) change
|
|
182
|
+
el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
183
|
+
// Si tu veux éviter que pat-select2 réécrase juste derrière, commente la ligne suivante :
|
|
184
|
+
el.dispatchEvent(new Event('change', {bubbles:true}));
|
|
185
|
+
} catch(e){}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3) Fallback DOM si l'API select2 n'est pas dispo OU si la pattern a écrasé les labels
|
|
190
|
+
// => on remplace manuellement le texte des chips par les labels
|
|
191
|
+
try {
|
|
192
|
+
var container = document.getElementById('s2id_' + el.id);
|
|
193
|
+
if (container){
|
|
194
|
+
var chips = container.querySelectorAll('.select2-search-choice > div');
|
|
195
|
+
if (chips && chips.length){
|
|
196
|
+
// Construire un map token->label
|
|
197
|
+
var map = Object.create(null);
|
|
198
|
+
seq.forEach(function(x){ map[x.value] = x.text; });
|
|
199
|
+
|
|
200
|
+
chips.forEach(function(div){
|
|
201
|
+
var current = (div.textContent || '').trim();
|
|
202
|
+
if (map[current]) {
|
|
203
|
+
div.textContent = map[current];
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch(e){}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
// ---------- Checkboxes / Radios ----------
|
|
214
|
+
function setCheckboxGroup(el, value){
|
|
215
|
+
var name = el.name;
|
|
216
|
+
if (!name || !form){ el.checked = !!value; return; }
|
|
217
|
+
var vals = Array.isArray(value) ? value.map(String) : (value == null ? [] : [String(value)]);
|
|
218
|
+
var boxes = form.querySelectorAll('input[type="checkbox"][name="'+name+'"]');
|
|
219
|
+
boxes.forEach(function(b){
|
|
220
|
+
b.checked = vals.indexOf(b.value) !== -1 || (vals.length === 0 && !!value === true);
|
|
221
|
+
try { b.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function setRadioGroup(el, value){
|
|
225
|
+
var name = el.name;
|
|
226
|
+
if (!name || !form) return;
|
|
227
|
+
var v = (value == null) ? '' : String(value);
|
|
228
|
+
var radios = form.querySelectorAll('input[type="radio"][name="'+name+'"]');
|
|
229
|
+
radios.forEach(function(r){ r.checked = (r.value === v); });
|
|
230
|
+
try { el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- Dispatcher ----------
|
|
234
|
+
function setFieldById(id, value){
|
|
235
|
+
var el = resolveTarget(id);
|
|
236
|
+
if (!el) return;
|
|
237
|
+
|
|
238
|
+
// Support format objet {values:[...], append:true}
|
|
239
|
+
var values = value;
|
|
240
|
+
var replace = true;
|
|
241
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && ('values' in value || 'append' in value)){
|
|
242
|
+
values = value.values != null ? value.values : value;
|
|
243
|
+
replace = value.append ? false : true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (el.tagName === 'SELECT'){ setSelectSequential(el, values, {replace:replace, delay:0}); return; }
|
|
247
|
+
if (isPatSelect2Input(el)){ setPatSelect2(el, values, {replace:replace}); return; }
|
|
248
|
+
|
|
249
|
+
if (el.tagName === 'INPUT'){
|
|
250
|
+
var t = (el.getAttribute('type') || '').toLowerCase();
|
|
251
|
+
if (t === 'checkbox'){ setCheckboxGroup(el, values); return; }
|
|
252
|
+
if (t === 'radio'){ setRadioGroup(el, values); return; }
|
|
253
|
+
el.value = Array.isArray(values) ? values.join(', ') : (values == null ? '' : String(values));
|
|
254
|
+
try { el.dispatchEvent(new Event('input', {bubbles:true})); } catch(e){}
|
|
255
|
+
try { el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (el.tagName === 'TEXTAREA'){
|
|
259
|
+
el.value = Array.isArray(values) ? values.join('\n') : (values == null ? '' : String(values));
|
|
260
|
+
try { el.dispatchEvent(new Event('input', {bubbles:true})); } catch(e){}
|
|
261
|
+
try { el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (el.hasAttribute('contenteditable')){
|
|
265
|
+
el.textContent = Array.isArray(values) ? values.join(', ') : (values == null ? '' : String(values));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function applyValues(data){
|
|
270
|
+
if (!data) return;
|
|
271
|
+
Object.keys(data).forEach(function(id){
|
|
272
|
+
setFieldById(id, data[id]);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* BASIC collect
|
|
277
|
+
function collect(){
|
|
278
|
+
var out = {};
|
|
279
|
+
var t = document.getElementById('form-widgets-IBasic-title');
|
|
280
|
+
if (t) out['form-widgets-IBasic-title'] = t.value;
|
|
281
|
+
return out;
|
|
282
|
+
}*/
|
|
283
|
+
|
|
284
|
+
// (optionnel) collecte minimale pour le backend
|
|
285
|
+
function collect() {
|
|
286
|
+
var out = {};
|
|
287
|
+
var fields = [
|
|
288
|
+
'form-widgets-IBasic-title',
|
|
289
|
+
'form-widgets-IIASmartTitle-title',
|
|
290
|
+
'form-widgets-IRichTextBehavior-text'
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
fields.forEach(function (id) {
|
|
294
|
+
var el = document.getElementById(id);
|
|
295
|
+
if (el && el.value) {
|
|
296
|
+
out[id] = el.value;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------- Action bouton ----------
|
|
304
|
+
btn.addEventListener('click', function(){
|
|
305
|
+
|
|
306
|
+
if (!endpoint){ err('Endpoint manquant'); return; }
|
|
307
|
+
btn.disabled = true;
|
|
308
|
+
setStatus('Traitement…');
|
|
309
|
+
|
|
310
|
+
// edit ici; si tu réutilises en add, adapte si besoin
|
|
311
|
+
var payload = {
|
|
312
|
+
mode: 'edit',
|
|
313
|
+
formdata: collect()
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
fetch(endpoint, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: {
|
|
319
|
+
'accept': 'application/json',
|
|
320
|
+
'Content-Type': 'application/json',
|
|
321
|
+
'x-imio-application': '${view/x_imio_application}',
|
|
322
|
+
'x-imio-municipality': '${view/x_imio_municipality}',
|
|
323
|
+
'X-CSRF-TOKEN': getCsrf()
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify(payload),
|
|
326
|
+
credentials: 'same-origin'
|
|
327
|
+
})
|
|
328
|
+
.then(function(resp){
|
|
329
|
+
console.log(resp.status);
|
|
330
|
+
if (!resp.ok) throw new Error('HTTP '+resp.status);
|
|
331
|
+
return resp.text().then(function(t){ return t ? JSON.parse(t) : {}; });
|
|
332
|
+
})
|
|
333
|
+
.then(function(data){
|
|
334
|
+
var payload = data && (data.data || data);
|
|
335
|
+
applyValues(payload);
|
|
336
|
+
ok((data && data.message) || 'Appliqué');
|
|
337
|
+
})
|
|
338
|
+
.catch(function(e){
|
|
339
|
+
err(e && e.message);
|
|
340
|
+
})
|
|
341
|
+
.finally(function(){
|
|
342
|
+
btn.disabled = false;
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
})();
|
|
346
|
+
</script>
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
</tal:root>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# imio/smartweb/core/browser/categorization_button_edit.py
|
|
3
|
+
from imio.smartweb.common.config import APPLICATION_ID
|
|
4
|
+
from imio.smartweb.common.config import PROJECT_ID
|
|
5
|
+
from z3c.form import widget as z3c_widget
|
|
6
|
+
from zope.browserpage.viewpagetemplatefile import ViewPageTemplateFile
|
|
7
|
+
|
|
8
|
+
FIELD_NAME = "categorization_ia_link" # Internal id for dummy field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EditHtmlSnippetWidget(z3c_widget.Widget):
|
|
12
|
+
"""Widget HTML (bouton + JS) avec template ZPT."""
|
|
13
|
+
|
|
14
|
+
template = ViewPageTemplateFile("html_snippet_widget.pt")
|
|
15
|
+
x_imio_application = APPLICATION_ID
|
|
16
|
+
x_imio_municipality = PROJECT_ID
|
|
17
|
+
|
|
18
|
+
def update(self):
|
|
19
|
+
# edit : context == objet
|
|
20
|
+
base = self.context.absolute_url()
|
|
21
|
+
self.endpoint = f"{base}/@@ProcessCategorizeContent"
|
|
22
|
+
# Unique id for button + status zone
|
|
23
|
+
self.wid = getattr(self, "name", FIELD_NAME)
|
|
24
|
+
|
|
25
|
+
# Désactiver le bouton si aucune section texte avec contenu n'est présente
|
|
26
|
+
self.klass = getattr(self, "klass", "")
|
|
27
|
+
has_text_content = False
|
|
28
|
+
|
|
29
|
+
# Vérifie si le contexte contient au moins une section texte avec du contenu
|
|
30
|
+
try:
|
|
31
|
+
for item in getattr(self.context, "objectItems", lambda: [])():
|
|
32
|
+
obj = item[1]
|
|
33
|
+
# Vérifier si la section texte a du contenu (non vide)
|
|
34
|
+
text_output = getattr(getattr(obj, "text", None), "output", "")
|
|
35
|
+
if text_output and text_output.strip():
|
|
36
|
+
has_text_content = True
|
|
37
|
+
break
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
if not has_text_content:
|
|
42
|
+
self.klass = f"{self.klass} disabled".strip() if self.klass else "disabled"
|
|
43
|
+
self.is_disabled = True
|
|
44
|
+
else:
|
|
45
|
+
self.is_disabled = False
|
|
46
|
+
|
|
47
|
+
def render(self):
|
|
48
|
+
return self.template()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AddHtmlSnippetWidget(z3c_widget.Widget):
|
|
52
|
+
template = ViewPageTemplateFile("html_snippet_widget.pt")
|
|
53
|
+
x_imio_application = APPLICATION_ID
|
|
54
|
+
x_imio_municipality = PROJECT_ID
|
|
55
|
+
|
|
56
|
+
def update(self):
|
|
57
|
+
# ++add++ : context = container ; edit : context = object
|
|
58
|
+
base = self.context.absolute_url()
|
|
59
|
+
self.endpoint = f"{base}/@@ProcessCategorizeContent"
|
|
60
|
+
self.wid = getattr(self, "name", "categorization_ia_link")
|
|
61
|
+
self.klass = getattr(self, "klass", "")
|
|
62
|
+
self.is_disabled = False
|
|
63
|
+
|
|
64
|
+
def render(self):
|
|
65
|
+
return self.template()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<tal:domain i18n:domain="imio.smartweb">
|
|
2
|
+
<div class="suggestedtitles-with-button"
|
|
3
|
+
data-slug-url="${context/absolute_url}/@@processsuggestedtitles"
|
|
4
|
+
data-source-selector="#form-widgets-title">
|
|
5
|
+
|
|
6
|
+
<input id="" name="" class="" title="" lang="" disabled=""
|
|
7
|
+
readonly="" alt="" tabindex="" accesskey="" size="" maxlength=""
|
|
8
|
+
style="" value="" type="text"
|
|
9
|
+
tal:attributes="id view/id;
|
|
10
|
+
name view/name;
|
|
11
|
+
class view/klass;
|
|
12
|
+
style view/style;
|
|
13
|
+
title view/title;
|
|
14
|
+
lang view/lang;
|
|
15
|
+
value view/value;
|
|
16
|
+
disabled view/disabled;
|
|
17
|
+
tabindex view/tabindex;
|
|
18
|
+
readonly view/readonly;
|
|
19
|
+
size view/size;
|
|
20
|
+
maxlength view/maxlength;
|
|
21
|
+
placeholder view/placeholder;
|
|
22
|
+
autocapitalize view/autocapitalize;" />
|
|
23
|
+
|
|
24
|
+
<a id="ia-suggest-open"
|
|
25
|
+
href="#ia-suggest-modal"
|
|
26
|
+
class="suggestedtitles-generate-button pat-plone-modal"
|
|
27
|
+
tal:attributes="data-pat-plone-modal view/data_pat_plone_modal;"
|
|
28
|
+
i18n:translate="">
|
|
29
|
+
Générer via IA
|
|
30
|
+
</a>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div id="ia-suggest-modal" style="display:none">
|
|
34
|
+
<p class="formHelp ia-modale" i18n:translate="">Select a title :</p>
|
|
35
|
+
<div class="ia-suggest-list"></div>
|
|
36
|
+
<button type="button"
|
|
37
|
+
class="btn btn-secondary plone-btn me-1 plone-modal-close"
|
|
38
|
+
data-bs-dismiss="modal"
|
|
39
|
+
data-dismiss="modal"
|
|
40
|
+
aria-label="Close"
|
|
41
|
+
i18n:translate="">
|
|
42
|
+
Close
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
</tal:domain>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from imio.smartweb.locales import SmartwebMessageFactory as _
|
|
2
|
+
from plone import api
|
|
3
|
+
from plone.app.z3cform.interfaces import IPloneFormLayer
|
|
4
|
+
from z3c.form.browser.text import TextWidget
|
|
5
|
+
from z3c.form.interfaces import IFieldWidget
|
|
6
|
+
from z3c.form.interfaces import IWidget
|
|
7
|
+
from z3c.form.widget import FieldWidget
|
|
8
|
+
from zope.component import adapter
|
|
9
|
+
from zope.i18n import translate
|
|
10
|
+
from zope.interface import implementer
|
|
11
|
+
from zope.interface import implementer_only
|
|
12
|
+
from zope.schema.interfaces import ITextLine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ISuggestedIATitlesWidget(IWidget):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@implementer_only(ISuggestedIATitlesWidget)
|
|
20
|
+
class SuggestedIATitlesWidget(TextWidget):
|
|
21
|
+
klass = "text-widget form-control suggestedtitles-with-button"
|
|
22
|
+
|
|
23
|
+
def update(self):
|
|
24
|
+
super().update()
|
|
25
|
+
self.pattern_options = {
|
|
26
|
+
"sourceSelector": "#form-widgets-title",
|
|
27
|
+
"buttonLabel": "Get titles",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def data_pat_plone_modal(self):
|
|
32
|
+
language = api.portal.get_current_language(context=self.context)
|
|
33
|
+
title = translate(_("Title suggestions"), target_language=language)
|
|
34
|
+
return f"title: {title}; width: 600; loadLinksWithinModal: false"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@implementer(IFieldWidget)
|
|
38
|
+
@adapter(ITextLine, IPloneFormLayer)
|
|
39
|
+
def SuggestedIATitlesFieldWidget(field, request) -> IFieldWidget:
|
|
40
|
+
return FieldWidget(field, SuggestedIATitlesWidget(request))
|