TopicsImportService.java
/*
* Copyright © 2024-2026 The CTAN Team and individual authors
*
* This file is distributed under the 3-clause BSD license.
* See file LICENSE for details.
*/
package org.ctan.site.services.catalogue;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.ctan.site.domain.catalogue.Topic;
import org.ctan.site.domain.catalogue.Topic.TopicTexts;
import org.ctan.site.stores.TopicStore;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.extern.slf4j.Slf4j;
/**
* This service deals with the topics from a file.
*
* <p>
* The file encoding is UTF-8.
* </p>
*
* <pre>
* <topics>
* <topic name='aaa'>
* <en>
* <title>aaa-en-title</title>
* <description>aaa-en-desc</description>
* </en>
* <de>
* <title>aaa-de-title</title>
* <description>aaa-de-desc</description>
* </de>
* </topic>
* ...
* </topics>
* </pre>
*
* <ul>
* <li>The root node is <code>topics</code>.</li>
* <li>The first level contains nodes of type <code>topic</code>.
* <ul>
* <li>The topic node has a mandatory attribute <code>name</code>.</li>
* <li>The second level contains nodes of type <code>en</code> or
* <code>de</code> for the locale.
* <ul>
* <li>The topic node has a mandatory attribute <code>name</code>.</li>
* <li>The third level contains nodes for the texts of type <code>title</code>
* and <code>description</code>.</li>
* </ul>
* </li>
* </ul>
* </li>
* </ul>
*
* @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
*/
@Slf4j
public class TopicsImportService extends AbstractImportService<TopicStore> {
/**
* The field <code>TOPICS_FILE</code> contains the file name for the topics.
*/
private static final String TOPICS_FILE = "topics";
/**
* The field <code>file</code> contains the topics file.
*/
private File file;
/**
* This is the constructor for <code>TopicsImportService</code>.
*
* @param entries the entries directory
* @param store the topics store
*/
@SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW")
public TopicsImportService(File entries, TopicStore store) {
super(entries, store);
file = new File(entries, TOPICS_FILE);
if (!file.isFile()) {
throw new IllegalArgumentException("topics file is not a file");
}
}
/**
* The method <code>drop</code> provides means to drop some topics.
*
* @param topicsToBeDropped a list of topics to be dropped
*/
public void drop(List<Topic> topicsToBeDropped) {
store.remove(topicsToBeDropped);
}
/**
* The method <code>parseDetails</code> provides means to parse a details
* section for one language.
*
* @param key the topic key
* @param elements the XML elements
* @return the container for the details
*/
private TopicTexts parseDetails(String key, Element top, String locale) {
Elements elements = top.getElementsByTag(locale);
if (elements.size() == 0) {
return null;
} else if (elements.size() > 1) {
log.info(key + ": overlapping language sections for " + locale);
}
TopicTexts td = new TopicTexts();
Element element = elements.get(0);
for (var text : element.children()) {
switch (text.normalName()) {
case "title":
td.setTitle(text.text());
break;
case "description":
td.setDescription(text.text());
break;
default:
log.info(key + ": unknown type " + text.normalName());
}
}
return td;
}
/**
* The method <code>updateExisting</code> provides means to parse an XML
* file and add the topics found to the database. As a side effect a list of
* topics is collected which are not in the XML files.
*
* @return the topics to be dropped
* @throws IOException in case of an I/O error
*/
public List<Topic> updateExisting()
throws IOException {
var doc = Jsoup.parse(file);
var topicsToBeDropped = new ArrayList<>(store.findAll());
Map<String, Topic> all = topicsToBeDropped.stream()
.collect(Collectors.toMap(x -> x.getKey(), x -> x));
for (var el : doc.getElementsByTag("topic")) {
updateTopic(el, all, topicsToBeDropped);
}
return topicsToBeDropped;
}
/**
* The method <code>updateTopic</code> provides means to update a topic from
* a DOM element.
*
* @param el the DOM element
* @param all the map of all topics
* @param topicsToBeDropped the topics to be dropped
*/
private void updateTopic(Element el,
Map<String, Topic> all,
List<Topic> topicsToBeDropped) {
var key = el.attr("name");
var t = all.get(key);
if (t == null) {
t = Topic.builder()
.key(key)
.build();
} else {
topicsToBeDropped.remove(t);
}
var det = el.attr("details");
TopicTexts d = parseDetails(key, el, "en");
if (d != null) {
t.setTextsEn(d);
} else if (!"".equals(det)) {
t.setTextsEn(TopicTexts.builder()
.description(det)
.build());
} else {
t.setTextsEn(null);
}
t.setTextsDe(parseDetails(key, el, "de"));
store.save(t);
}
}