001/*
002 *  Copyright 2026 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.ai.search;
017
018import java.util.ArrayList;
019import java.util.List;
020
021import javax.jcr.LoginException;
022import javax.jcr.Repository;
023import javax.jcr.RepositoryException;
024import javax.jcr.Session;
025
026import org.apache.avalon.framework.activity.Disposable;
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.context.Context;
029import org.apache.avalon.framework.context.ContextException;
030import org.apache.avalon.framework.context.Contextualizable;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.cocoon.components.ContextHelper;
034import org.apache.cocoon.environment.Request;
035import org.apache.commons.lang3.StringUtils;
036
037import org.ametys.cms.repository.Content;
038import org.ametys.core.util.StringUtils.AlphanumComparator;
039import org.ametys.plugins.repository.AmetysObject;
040import org.ametys.plugins.repository.AmetysObjectIterable;
041import org.ametys.plugins.repository.AmetysObjectResolver;
042import org.ametys.plugins.repository.AmetysRepositoryException;
043import org.ametys.plugins.repository.RepositoryConstants;
044import org.ametys.plugins.repository.collection.AmetysObjectCollection;
045import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject;
046import org.ametys.plugins.repository.jcr.JCRTraversableAmetysObject;
047import org.ametys.plugins.repository.provider.AbstractRepository;
048import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
049import org.ametys.runtime.plugin.component.AbstractLogEnabled;
050import org.ametys.runtime.plugin.component.DeferredServiceable;
051import org.ametys.runtime.plugin.component.PluginAware;
052import org.ametys.web.repository.content.WebContent;
053
054/**
055 * DAO for AI cache management.
056 */
057public class CacheDAO extends AbstractLogEnabled implements DeferredServiceable, PluginAware, Contextualizable, Component, Disposable
058{
059    /** The avalon role */
060    public static final String ROLE = CacheDAO.class.getName();
061    
062    /** The property name to store content id on Content node of the cache */
063    public static final String CACHEROOT_CONTENT_DATA_CONTENTID = "id";
064    
065    private static final String __CACHEROOT_NODENAME = "ai_cache";
066    
067    private static final String __CACHEROOT_NODETYPE = "ametys:collection";
068    private static final String __CACHEROOT_CONTENT_NODETYPE = "ametys:ai_cache_content";
069    private static final String __CACHEROOT_CONTENT_VERSION_NODETYPE = "ametys:ai_cache_content_version";
070    private static final String __CACHEROOT_CONTENT_VERSION_CHUNK_NODETYPE = "ametys:ai_cache_content_version_chunk";
071
072    private static final String __CACHEROOT_CONTENT_DATA_SITE = "site";
073    private static final String __CACHEROOT_CONTENT_DATA_LANGUAGE = "lang";
074
075    private String _pluginName;
076    private AmetysObjectResolver _ametysObjectResolver;
077    private Context _context;
078
079    private Repository _repository;
080
081    // This DAO use its own session to prevent sessions overlap due to parallel indexation
082    private Session _cacheSession;
083
084    public void setPluginInfo(String pluginName, String featureName, String id)
085    {
086        _pluginName = pluginName;
087    }
088
089    public void deferredService(ServiceManager manager) throws ServiceException
090    {
091        _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
092        _repository = (Repository) manager.lookup(AbstractRepository.ROLE);
093        try
094        {
095            _cacheSession = _repository.login(RepositoryConstants.DEFAULT_WORKSPACE);
096        }
097        catch (RepositoryException e)
098        {
099            throw new ServiceException(AbstractRepository.ROLE, "Unable to get a JCR session for cache DAO", e);
100        }
101    }
102    
103    public void contextualize(Context context) throws ContextException
104    {
105        _context = context;
106    }
107    
108    public void dispose()
109    {
110        _cacheSession.logout();
111    }
112
113    /**
114     * Get the root plugin storage object.
115     * @return the root plugin storage object.
116     * @throws AmetysRepositoryException if a repository error occurs.
117     * @throws RepositoryException if a repository error occurs.
118     * @throws LoginException if a repository error occurs.
119     */
120    public AmetysObjectCollection getCacheRootNode() throws AmetysRepositoryException, LoginException, RepositoryException
121    {
122        return _getCacheRootNode(_repository.login());
123    }
124
125    private AmetysObjectCollection _getCacheRootNode() throws RepositoryException
126    {
127        _cacheSession.refresh(true);
128        return _getCacheRootNode(_cacheSession);
129    }
130    
131    private AmetysObjectCollection _getCacheRootNode(Session session) throws AmetysRepositoryException
132    {
133        // Always switch to the default workspace, because data can be written from live too
134        Request request = ContextHelper.getRequest(_context);
135        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
136        
137        try
138        {
139            // Force the workspace.
140            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
141
142            DefaultTraversableAmetysObject pluginsNode = _ametysObjectResolver.resolveByPath("/ametys:plugins", session);
143            
144            DefaultTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured");
145            
146            return _getOrCreateNode(pluginNode, __CACHEROOT_NODENAME, __CACHEROOT_NODETYPE);
147        }
148        catch (AmetysRepositoryException e)
149        {
150            throw new AmetysRepositoryException("Unable to get the thesaurus root node", e);
151        }
152        finally
153        {
154            // Restore the workspace.
155            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
156        }
157    }
158    
159    /**
160     * Get the cache node for a given content id
161     * @param content the content
162     * @param creates Create the node if not existing
163     * @return the cache node or null if not existing and not created
164     * @throws AmetysRepositoryException if an error occurs when manipulating the repository
165     * @throws RepositoryException if an error occurs when manipulating the repository
166     */
167    public DefaultTraversableAmetysObject getCacheContentNode(Content content, boolean creates) throws AmetysRepositoryException, RepositoryException
168    {
169        String uuid = StringUtils.substringAfter(content.getId(), "://");
170        
171        AmetysObjectCollection cacheRootNode = _getCacheRootNode();
172        boolean willCreate = !cacheRootNode.hasChild(uuid);
173        
174        DefaultTraversableAmetysObject ao = _getOrCreateNode(cacheRootNode, uuid, __CACHEROOT_CONTENT_NODETYPE);
175        if (willCreate)
176        {
177            try
178            {
179                if (content instanceof WebContent webContent)
180                {
181                    ao.getNode().setProperty("ametys-internal:" + __CACHEROOT_CONTENT_DATA_SITE, webContent.getSiteName());
182                }
183    
184                if (content.getLanguage() != null)
185                {
186                    ao.getNode().setProperty("ametys-internal:" + __CACHEROOT_CONTENT_DATA_LANGUAGE, content.getLanguage());
187                }
188    
189                ao.getNode().setProperty("ametys-internal:" + CACHEROOT_CONTENT_DATA_CONTENTID, content.getId());
190            }
191            catch (RepositoryException e)
192            {
193                throw new AmetysRepositoryException(e);
194            }
195            ao.saveChanges();
196        }
197        return ao;
198    }
199    
200    /**
201     * Get the cache node for a given content id
202     * @param content the content
203     * @param version the content version
204     * @return the cache node
205     * @throws AmetysRepositoryException if an error occurs when manipulating the repository
206     * @throws RepositoryException if an error occurs when manipulating the repository
207     * @throws LoginException if an error occurs when manipulating the repository
208     */
209    public DefaultTraversableAmetysObject getNearestCacheContentVersionNode(Content content, String version) throws AmetysRepositoryException, LoginException, RepositoryException
210    {
211        DefaultTraversableAmetysObject cacheContentNode = getCacheContentNode(content, true);
212        if (!cacheContentNode.hasChild(version))
213        {
214            AmetysObjectIterable<DefaultTraversableAmetysObject> children = cacheContentNode.getChildren();
215            List<String> revisions = children.stream().map(AmetysObject::getName)
216                .toList();
217            List<String> revisionsPlusNewVersion = new ArrayList<>(revisions);
218            revisionsPlusNewVersion.add(version);
219            revisionsPlusNewVersion.sort(new AlphanumComparator());
220            
221            int i = revisionsPlusNewVersion.indexOf(version);
222            
223            if (i > 0)
224            {
225                String precedingRevision = revisionsPlusNewVersion.get(i - 1);
226                return _getOrCreateNode(cacheContentNode, precedingRevision, __CACHEROOT_CONTENT_VERSION_NODETYPE);
227            }
228            else
229            {
230                return null;
231            }
232        }
233        return getCacheContentVersionNode(content, version);
234    }
235    
236    /**
237     * Get the cache node for a given content id
238     * @param content the content
239     * @param version the content version
240     * @return the cache node
241     * @throws AmetysRepositoryException if an error occurs when manipulating the repository
242     * @throws RepositoryException if an error occurs when manipulating the repository
243     * @throws LoginException if an error occurs when manipulating the repository
244     */
245    public DefaultTraversableAmetysObject getCacheContentVersionNode(Content content, String version) throws AmetysRepositoryException, LoginException, RepositoryException
246    {
247        DefaultTraversableAmetysObject cacheContentNode = getCacheContentNode(content, false);
248        if (cacheContentNode == null)
249        {
250            return null;
251        }
252        
253        boolean copyPreceding = !cacheContentNode.hasChild(version);
254        DefaultTraversableAmetysObject ao =  _getOrCreateNode(cacheContentNode, version, __CACHEROOT_CONTENT_VERSION_NODETYPE);
255        if (copyPreceding)
256        {
257            AmetysObjectIterable<DefaultTraversableAmetysObject> children = cacheContentNode.getChildren();
258            List<String> revisions = children.stream().map(AmetysObject::getName)
259                .sorted(new AlphanumComparator())
260                .toList();
261            int i = revisions.indexOf(version);
262            
263            if (i > 0)
264            {
265                String precedingRevision = revisions.get(i - 1);
266                DefaultTraversableAmetysObject ao2 =  _getOrCreateNode(cacheContentNode, precedingRevision, __CACHEROOT_CONTENT_VERSION_NODETYPE);
267                AmetysObjectIterable<CacheContentVersionChunk> chunksToCopy = ao2.getChildren();
268                chunksToCopy.forEach(child ->
269                {
270                    try
271                    {
272                        CacheContentVersionChunk newChunk = _getOrCreateNode(ao, child.getName(), __CACHEROOT_CONTENT_VERSION_CHUNK_NODETYPE);
273                        newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_CONTENT, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_CONTENT, null));
274                        newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_EMBDEDING, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_EMBDEDING, null));
275                        newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_CREATION_DATETIME, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_CREATION_DATETIME, null));
276                        newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_CREATOR, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_CREATOR, null));
277                        newChunk.saveChanges();
278                    }
279                    catch (AmetysRepositoryException e)
280                    {
281                        getLogger().error("Unable to copy chunk {} from revision {} to revision {}", child.getName(), precedingRevision, version, e);
282                    }
283                });
284            }
285        }
286        
287        return ao;
288    }
289
290    /**
291     * Get or create a chunk node
292     * @param cacheContentVersionNode The cache content version node
293     * @param number the chunk number
294     * @param createIfNotExist true to create the chunk if it does not exist
295     * @return The retrieved or created chunk node, or null otherwise
296     * @throws AmetysRepositoryException if an error occurs when manipulating the repository
297     */
298    public CacheContentVersionChunk getChunk(DefaultTraversableAmetysObject cacheContentVersionNode, int number, boolean createIfNotExist) throws AmetysRepositoryException
299    {
300        if (!createIfNotExist && !cacheContentVersionNode.hasChild(Integer.toString(number)))
301        {
302            return null;
303        }
304        
305        return (CacheContentVersionChunk) _getOrCreateNode(cacheContentVersionNode, Integer.toString(number), __CACHEROOT_CONTENT_VERSION_CHUNK_NODETYPE);
306    }
307    
308    /**
309     * Get or create a node
310     * @param <T> The type of the node
311     * @param parentNode the parent node
312     * @param nodeName the name of the node
313     * @param nodeType the type of the node
314     * @return The retrieved or created node
315     * @throws AmetysRepositoryException if an error occurs when manipulating the repository
316     */
317    @SuppressWarnings("unchecked")
318    protected synchronized <T extends AmetysObject> T _getOrCreateNode(JCRTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
319    {
320        T definitionsNode;
321        if (parentNode.hasChild(nodeName))
322        {
323            definitionsNode = (T) parentNode.getChild(nodeName);
324        }
325        else
326        {
327            definitionsNode = (T) parentNode.createChild(nodeName, nodeType);
328            parentNode.saveChanges();
329        }
330        return definitionsNode;
331    }
332}