EntryUtil.java

/*
 * Copyright (c) 2007-2017 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.repository.util;

import com.google.common.collect.Multimap;
import lombok.Getter;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Statement;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.model.vocabulary.RDF;
import org.entrystore.AuthorizationException;
import org.entrystore.Context;
import org.entrystore.ContextManager;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.Resource;
import org.entrystore.impl.RepositoryProperties;
import org.entrystore.repository.RepositoryManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.URI;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static com.google.common.base.Preconditions.checkArgument;


/**
 * Helper methods to make Entry handling easier, mostly sorting methods.
 *
 * @author Hannes Ebner
 */
public class EntryUtil {

	static Logger log = LoggerFactory.getLogger(EntryUtil.class);

	static ValueFactory valueFactory = SimpleValueFactory.getInstance();

	/**
	 * Sorts a list of entries after the modification date.
	 *
	 * @param entries
	 *            The list of entries to sort.
	 * @param ascending
	 *            True if the entry with the earliest date should come first.
	 * @param prioritizedResourceType
	 *            Determines which ResourceType should always have higher priority
	 *            than entries with a different one.
	 */
	public static void sortAfterModificationDate(List<Entry> entries, final boolean ascending, final GraphType prioritizedResourceType) {
		entries.sort((e1, e2) -> {
			int result = 0;
			if (e1 != null && e2 != null) {
				result = e1.getModifiedDate().compareTo(e2.getModifiedDate());
				if (!ascending) {
					result *= -1;
				}
			}
			return result;
		});
		prioritizeBuiltinType(entries, prioritizedResourceType, true);
	}

	/**
	 * Sorts a list of entries after the creation date.
	 *
	 * @param entries
	 *            The list of entries to sort.
	 * @param ascending
	 *            True if the entry with the earliest date should come first.
	 * @param prioritizedResourceType
	 *            Determines which ResourceType should always have higher priority
	 *            than entries with a different one.
	 */
	public static void sortAfterCreationDate(List<Entry> entries, final boolean ascending, final GraphType prioritizedResourceType) {
		entries.sort((e1, e2) -> {
			int result = 0;
			if (e1 != null && e2 != null) {
				result = e1.getCreationDate().compareTo(e2.getCreationDate());
				if (!ascending) {
					result *= -1;
				}
			}
			return result;
		});
		prioritizeBuiltinType(entries, prioritizedResourceType, true);
	}

	/**
	 * Sorts a list of entries after the file size. Folders are listed before
	 * entries with ResourceType.None and they are sorted among themselves after
	 * the amount of children they contain.
	 *
	 * @param entries
	 *            The list of entries to sort.
	 * @param ascending
	 *            True if the entry with the smallest size should come first.
	 * @param prioritizedResourceType
	 *            Determines which ResourceType should always have higher priority
	 *            than entries with a different one.
	 */
	public static void sortAfterFileSize(List<Entry> entries, final boolean ascending, final GraphType prioritizedResourceType) {
		entries.sort((e1, e2) -> {
			int result = 0;
			if (e1 != null && e2 != null) {
				GraphType e1BT = e1.getGraphType();
				GraphType e2BT = e2.getGraphType();
				if (GraphType.None.equals(e1BT) && GraphType.None.equals(e2BT)) {
					long size1 = e1.getFileSize();
					long size2 = e2.getFileSize();
					if (size1 < size2) {
						result = -1;
					} else if (size1 > size2) {
						result = 1;
					}
				} else if (GraphType.List.equals(e1BT) && GraphType.List.equals(e2BT)) {
					Resource e1Res = e1.getResource();
					Resource e2Res = e2.getResource();
					if (e1Res == null) {
						log.warn("No resource found for list: {}", e1.getEntryURI());
						return 0;
					}
					if (e2Res == null) {
						log.warn("No resource found for list: {}", e2.getEntryURI());
						return 0;
					}
					org.entrystore.List e1List = (org.entrystore.List) e1Res;
					org.entrystore.List e2List = (org.entrystore.List) e2Res;
					int size1 = e1List.getChildren().size();
					int size2 = e2List.getChildren().size();
					if (size1 < size2) {
						result = -1;
					} else if (size1 > size2) {
						result = 1;
					}
				}
				if (!ascending) {
					result *= -1;
				}
			}
			return result;
		});
		prioritizeBuiltinType(entries, prioritizedResourceType, true);
	}

	/**
	 * Sorts a list of entries after its titles.
	 *
	 * @param entries
	 *            The list of entries to sort.
	 * @param language
	 *            The language of the title to prioritize. May be null if any
	 *            title string should be taken. If no titles match the desired
	 *            language, any title is used as fallback for sorting.
	 * @param ascending
	 *            True if the entries should be sorted A-Z. Does not take the
	 *            locale into consideration.
	 * @param prioritizedResourceType
	 *            Determines which ResourceType should always have higher priority
	 *            than entries with a different one.
	 */
	public static void sortAfterTitle(List<Entry> entries, final String language, final boolean ascending, final GraphType prioritizedResourceType) {
		entries.sort((e1, e2) -> {
			int result = 0;
			if (e1 != null && e2 != null) {
				String title1 = getTitle(e1, language);
				String title2 = getTitle(e2, language);
				if (title1 != null && title2 != null) {
					result = title1.compareToIgnoreCase(title2);
				} else if (title1 == null && title2 != null) {
					result = 1;
				} else if (title1 != null) {
					result = -1;
				}
				if (!ascending) {
					result *= -1;
				}
			}
			return result;
		});
		prioritizeBuiltinType(entries, prioritizedResourceType, true);
	}

	/**
	 * Reorders the list of entries with the given ResourceType first or last,
	 * depending on the boolean parameter.
	 *
	 * @param entries
	 *            The list of entries to reorder.
	 * @param resourceType
	 *            The ResourceType to be prioritized.
	 * @param top
	 *            Determines whether the entries with the prioritized
	 *            ResourceType should come first or last in the list.
	 */
	public static void prioritizeBuiltinType(List<Entry> entries, final GraphType resourceType, final boolean top) {
		if (entries == null || resourceType == null) {
			return;
		}

		entries.sort((e1, e2) -> {
			int result = 0;
			if (e1 != null && e2 != null) {
				GraphType e1BT = e1.getGraphType();
				GraphType e2BT = e2.getGraphType();
				if (resourceType.equals(e1BT) && !resourceType.equals(e2BT)) {
					result = -1;
				} else if (!resourceType.equals(e1BT) && resourceType.equals(e2BT)) {
					result = 1;
				}
				if (!top) {
					result *= -1;
				}
			}
			return result;
		});
	}

	/**
	 * Requests a literal value from a metadata graph.
	 *
	 * @param graph
	 *            The graph to be used to search for the title.
	 * @param resourceURI
	 *            The root resource to be used for matching.
	 * @param language
	 *            The language to prioritize. May be null if any title string
	 *            should be taken. If no titles match the desired language, any
	 *            title is used as fallback.
	 * @return Returns a literal value (whichever matches first)
	 *         from a specific graph with the given resource URI as root node.
	 *         If no titles exist null is returned.
	 */
	public static String getLabel(Model graph, URI resourceURI, Set<IRI> predicates, String language) {
		String fallback = null;
		if (graph != null && resourceURI != null) {
			IRI resURI = valueFactory.createIRI(resourceURI.toString());
			for (IRI titlePred : predicates) {
				for (Statement statement : graph.filter(resURI, titlePred, null)) {
					Value value = statement.getObject();
					Literal lit = null;
					if (value instanceof Literal) {
						lit = (Literal) value;
					} else if (value instanceof org.eclipse.rdf4j.model.Resource) {
						Iterator<Statement> indirectLables = graph.filter((org.eclipse.rdf4j.model.Resource) value, RDF.VALUE, null).iterator();
						if (indirectLables.hasNext()) {
							Value indirectValue = indirectLables.next().getObject();
							if (indirectValue instanceof Literal) {
								lit = (Literal) indirectValue;
							}
						}
					}
					if (lit != null) {
						if (language != null) {
							if (lit.getLanguage().isPresent() && lit.getLanguage().get().equalsIgnoreCase(language)) {
								return lit.stringValue();
							} else {
								fallback = lit.stringValue();
							}
						} else {
							return lit.stringValue();
						}
					}
				}
			}
		}
		return fallback;
	}

	public static String getLabel(Model graph, URI resourceURI, IRI predicate, String language) {
		Set<IRI> predicates = new HashSet<>();
		predicates.add(predicate);
		return getLabel(graph, resourceURI, predicates, language);
	}

	public static IRI getResourceAsURI(Model graph, URI resourceURI, IRI predicate) {
		if (graph != null && resourceURI != null) {
			IRI resURI = valueFactory.createIRI(resourceURI.toString());
			for (Statement statement : graph.filter(resURI, predicate, null)) {
				Value value = statement.getObject();
				if (value instanceof IRI) {
					return (IRI) value;
				}
			}
		}
		return null;
	}

	public static String getResource(Model graph, URI resourceURI, IRI predicate) {
		IRI result = getResourceAsURI(graph, resourceURI, predicate);
		if (result != null) {
			return result.stringValue();
		}
		return null;
	}

	/**
	 * Requests the title of an Entry.
	 *
	 * @param graph
	 *            The graph to be used to search for the title.
	 * @param resourceURI
	 *            The root resource to be used for matching.
	 * @param language
	 *            The language to prioritize. May be null if any title string
	 *            should be taken. If no titles match the desired language, any
	 *            title is used as fallback.
	 * @return Returns the dcterms:title or dc:title (whichever matches first)
	 *         from a specific graph with the given resource URI as root node.
	 *         If no titles exist null is returned.
	 */
	public static String getTitle(Model graph, URI resourceURI, String language) {
		if (graph != null && resourceURI != null) {
			Set<IRI> titlePredicates = new HashSet<>();
			titlePredicates.add(valueFactory.createIRI(NS.dcterms + "title"));
			titlePredicates.add(valueFactory.createIRI(NS.dc + "title"));
			return getLabel(graph, resourceURI, titlePredicates, language);
		}
		return null;
	}

	public static String getTitle(Entry entry, String language) {
		if (entry != null) {
			try {
				return getTitle(entry.getMetadataGraph(), entry.getResourceURI(), language);
			} catch (AuthorizationException ae) {
				log.debug("AuthorizationException: " + ae.getMessage());
			}
		}
		return null;
	}

	public static String getName(Entry entry) {
		String result = null;
		if (entry != null) {
			String name = getLabel(entry.getMetadataGraph(), entry.getResourceURI(), valueFactory.createIRI(NS.foaf + "name"), null);
			if (name != null) {
				return name;
			}
			String givenName = getLabel(entry.getMetadataGraph(), entry.getResourceURI(), valueFactory.createIRI(NS.foaf + "givenName"), null);
			String familyName = getLabel(entry.getMetadataGraph(), entry.getResourceURI(), valueFactory.createIRI(NS.foaf + "familyName"), null);
			if (givenName != null) {
				result = givenName;
			}
			if (familyName != null) {
				if (result != null) {
					result += " " + familyName;
				} else {
					result = familyName;
				}
			}
		}
		return result;
	}

	public static String getStructuredName(Entry entry) {
		String result = null;
		if (entry != null) {
			Set<IRI> foafFirstName = new HashSet<>();
			Set<IRI> foafSurname = new HashSet<>();
			foafFirstName.add(valueFactory.createIRI(NS.foaf + "firstName"));
			foafFirstName.add(valueFactory.createIRI(NS.foaf + "givenName"));
			foafSurname.add(valueFactory.createIRI(NS.foaf + "surname"));
			foafSurname.add(valueFactory.createIRI(NS.foaf + "lastName"));
			foafSurname.add(valueFactory.createIRI(NS.foaf + "familyName"));
			String firstName = getLabel(entry.getMetadataGraph(), entry.getResourceURI(), foafFirstName, null);
			String surname = getLabel(entry.getMetadataGraph(), entry.getResourceURI(), foafSurname, null);
			if (surname != null) {
				result = surname;
			}
			if (firstName != null) {
				if (result != null) {
					result += ";" + firstName;
				} else {
					result = firstName;
				}
			}
		}
		return result;
	}

	public static String getFirstName(Entry entry) {
		if (entry != null) {
			Set<IRI> foafFN = new HashSet<>();
			foafFN.add(valueFactory.createIRI(NS.foaf + "givenName"));
			foafFN.add(valueFactory.createIRI(NS.foaf + "firstName"));
			return getLabel(entry.getMetadataGraph(), entry.getResourceURI(), foafFN, null);
		}
		return null;
	}

	public static String getLastName(Entry entry) {
		if (entry != null) {
			Set<IRI> foafLN = new HashSet<>();
			foafLN.add(valueFactory.createIRI(NS.foaf + "surname"));
			foafLN.add(valueFactory.createIRI(NS.foaf + "lastName"));
			foafLN.add(valueFactory.createIRI(NS.foaf + "familyName"));
			return getLabel(entry.getMetadataGraph(), entry.getResourceURI(), foafLN, null);
		}
		return null;
	}

	public static String getEmail(Entry entry) {
		if (entry != null) {
			return getLabel(entry.getMetadataGraph(), entry.getResourceURI(), valueFactory.createIRI(NS.foaf + "mbox"), null);
		}
		return null;
	}

	public static String getMemberOf(Entry entry) {
		if (entry != null) {
			return getResource(entry.getMetadataGraph(), entry.getResourceURI(), valueFactory.createIRI("http://open.vocab.org/terms/isMemberOf"));
		}
		return null;
	}

	public static String getFOAFTitle(Entry entry) {
		if (entry != null) {
			return getLabel(entry.getMetadataGraph(), entry.getResourceURI(), valueFactory.createIRI(NS.foaf + "title"), null);
		}
		return null;
	}

	/**
	 * Retrieves all titles from the metadata of an Entry. Includes cached
	 * external metadata if it exists.
	 *
	 * @param entry
	 *            Entry of which the titles should be returned.
	 * @return Returns all key/value (title/language) pairs for dcterms:title
	 *         and dc:title
	 */
	public static Map<String, Set<String>> getTitles(Entry entry) {
		List<IRI> titlePredicates = new ArrayList<>();
		titlePredicates.add(valueFactory.createIRI(NS.foaf, "name"));
		titlePredicates.add(valueFactory.createIRI(NS.vcard, "fn"));
		titlePredicates.add(valueFactory.createIRI(NS.dcterms, "title"));
		titlePredicates.add(valueFactory.createIRI(NS.dc, "title"));
		titlePredicates.add(valueFactory.createIRI(NS.skos, "prefLabel"));
		titlePredicates.add(valueFactory.createIRI(NS.skos, "altLabel"));
		titlePredicates.add(valueFactory.createIRI(NS.skos, "hiddenLabel"));
		titlePredicates.add(valueFactory.createIRI(NS.rdfs, "label"));
		titlePredicates.add(valueFactory.createIRI(NS.schema, "name"));

		return getLiteralValues(entry, titlePredicates);
	}

	/**
	 * Retrieves all descriptions from the metadata of an Entry. Includes cached
	 * external metadata if it exists.
	 *
	 * @param entry
	 *            Entry from where the descriptions should be returned.
	 * @return Returns all key/value (description/language) pairs for dcterms:description
	 *         and dc:description
	 */
	public static Map<String, Set<String>> getDescriptions(Entry entry) {
		List<IRI> descPreds = new ArrayList<>();
		descPreds.add(valueFactory.createIRI(NS.dcterms, "description"));
		descPreds.add(valueFactory.createIRI(NS.dc, "description"));
		return getLiteralValues(entry, descPreds);
	}

	public static String getDescription(Entry entry, String language) {
		checkArgument(language != null, "language parameter must not be null");

		Map<String, Set<String>> descriptions = getDescriptions(entry);

		if (descriptions.isEmpty()) {
			return null;
		}

		String description = null;
		for (String key : descriptions.keySet()) {

			for (String lang : descriptions.get(key)) {
				if (language.equals(lang)) {
					description = key;
					break;
				}
			}

			if (description != null) {
				break;
			}
		}

		return description;
	}

	/**
	 * Retrieves all literals of subjects, tags and keywords from the metadata of an Entry.
	 * Includes cached external metadata if it exists.
	 *
	 * @param entry Entry from where the keywords should be returned.
	 * @return Returns all matching literal-language pairs.
	 */
	public static Map<String, Set<String>> getTagLiterals(Entry entry) {
		List<IRI> preds = new ArrayList<>();
		preds.add(valueFactory.createIRI(NS.dcterms, "subject"));
		preds.add(valueFactory.createIRI(NS.dc, "subject"));
		preds.add(valueFactory.createIRI(NS.dcat, "keyword"));
		return getLiteralValues(entry, preds);
	}

	/**
	 * Retrieves all resource values of subjects, tags and keywords from the metadata of an Entry.
	 * Includes cached external metadata if it exists.
	 *
	 * @param entry Entry from where the keywords should be returned.
	 * @return Returns all matching literal-language pairs.
	 */
	public static List<String> getTagResources(Entry entry) {
		Set<IRI> preds = new HashSet<>();
		preds.add(valueFactory.createIRI(NS.dc, "subject"));
		preds.add(valueFactory.createIRI(NS.dcterms, "subject"));
		return getResourceValues(entry, preds);
	}

	/**
	 * Retrieves literals from statements that match a set of predicates.
	 *
	 * @param entry Entry from where the metadata graph should be used for matching.
	 * @param predicates A list of predicates to use for statement matching.
	 * @return Returns all matching literal-language pairs.
	 */
	public static Map<String, Set<String>> getLiteralValues(Entry entry, List<IRI> predicates) {
		if (entry == null || predicates == null) {
			throw new IllegalArgumentException("Parameters must not be null");
		}

		Model graph = null;
		try {
			graph = entry.getMetadataGraph();
		} catch (AuthorizationException ae) {
			log.debug("AuthorizationException: " + ae.getMessage());
		}

		if (graph != null) {
			IRI resourceURI = valueFactory.createIRI(entry.getResourceURI().toString());
			Map<String, Set<String>> result = new LinkedHashMap<>();
			for (IRI pred : predicates) {
				for (Statement statement : graph.filter(resourceURI, pred, null)) {
					Value value = statement.getObject();
					Set<String> valuesSet;

					if (value instanceof Literal lit) {

						valuesSet = result.get(lit.stringValue()) == null ? new HashSet<>() : result.get(lit.stringValue());

						valuesSet.add(lit.getLanguage().orElse(null));
						result.put(lit.stringValue(), valuesSet);
					} else if (value instanceof org.eclipse.rdf4j.model.Resource) {
						Iterator<Statement> stmnts2 = graph.filter((org.eclipse.rdf4j.model.Resource) value, RDF.VALUE, null).iterator();
						if (stmnts2.hasNext()) {
							Value value2 = stmnts2.next().getObject();
							if (value2 instanceof Literal lit2) {
								valuesSet = result.get(lit2.stringValue()) == null ? new HashSet<>() : result.get(lit2.stringValue());
								valuesSet.add(lit2.getLanguage().orElse(null));
								result.put(lit2.stringValue(), valuesSet);
							}
						}
					}
				}
			}
			return result;
		}
		return null;
	}

	/**
	 * Retrieves resource values from statements that match a set of predicates.
	 *
	 * @param entry	Entry from where the metadata graph should be used for matching.
	 * @param predicates A list of predicates to use for statement matching.
	 * @return Returns a list of URIs.
	 */
    public static List<String> getResourceValues(Entry entry, Set<IRI> predicates) {
		if (entry == null || predicates == null) {
			throw new IllegalArgumentException("Parameters must not be null");
		}

		Model graph = null;
        try {
            graph = entry.getMetadataGraph();
        } catch (AuthorizationException ae) {
            log.debug("AuthorizationException: " + ae.getMessage());
        }

        if (graph != null) {
            return getResourceValues(graph, entry.getResourceURI(), predicates);
        }

        return new ArrayList<>();
    }

	public static List<String> getResourceValues(Model graph, URI resourceURI, Set<IRI> predicates) {
		if (graph == null || predicates == null) {
			throw new IllegalArgumentException("Parameters must not be null");
		}

		List<String> result = new ArrayList<>();
		for (IRI pred : predicates) {
			for (Statement statement : graph.filter(valueFactory.createIRI(resourceURI.toString()), pred, null)) {
				Value value = statement.getObject();
				if (value instanceof IRI res) {
					result.add(res.stringValue());
				}
			}
		}

		return result;
	}

	/**
	 * FIXME this does not take entries in deleted folders into consideration
	 */
	public static boolean isDeleted(Entry entry) {
		String repoURL = entry.getRepositoryManager().getRepositoryURL().toString();
		String contextID = entry.getContext().getEntry().getId();
		URI trashURI = URISplit.createURI(repoURL, contextID, RepositoryProperties.LIST_PATH, "_trash");
		Set<URI> referredBy = entry.getReferringListsInSameContext();
        return (referredBy.size() == 1) && (referredBy.contains(trashURI));
    }

	/**
	 * Fetches entries and recursively traverses the graph by following a provided set
	 * of predicates.
	 *
	 * @param entries A set of entries to start from.
	 * @param propertiesToFollow A set of predicate URIs that point to objects (entries)
	 *                           that are to be fetched during traversal.
	 * @param blacklist A map containing key/value pairs of predicate/object combinations that,
	 *                     if contained in the graph of the currently processed entry,
	 *                     trigger a stop of the traversal excluding the matching entry.
	 * @param level Current traversal level. Used for recursion, should be 0 if called manually.
	 * @param depth Maximum traversal depth of the graph.
	 * @param visited Contains URIs that have been visited during traversal.
	 *                Should be an empty map when called manually.
	 * @param context The context in which the entries reside.
	 * @param rm A RepositoryManager instance.
	 * @return Returns the merged metadata graphs of all matching entries.
	 */
	public static TraversalResult traverseAndLoadEntryMetadata(Set<IRI> entries, Set<URI> propertiesToFollow, Map<String, String> blacklist, int level, int depth, Multimap<IRI, Integer> visited, Context context, RepositoryManager rm) {
		Model resultGraph = new LinkedHashModel();
		Set<IRI> accessDenied = new HashSet<>();
		Date latestModified = null;
		for (IRI r : entries) {
	/*		if (!r.toString().startsWith(rm.getRepositoryURL().toString())) {
				log.debug("URI has external prefix, skipping: " + r);
				continue;
			}*/
			if (visited.containsEntry(r, level)) {
				log.debug("Skipping <{}>, entry already fetched and traversed on level {}", r, level);
				continue;
			}

			Model graph = null;
			try {
				URI uri = URI.create(r.toString());
				Entry fetchedEntry = null;
				ContextManager cm = rm.getContextManager();
				if (context != null) {
					//By Resource URI, may be a non-repository URI.
					Set<Entry> resEntries = context.getByResourceURI(uri);
					if (resEntries != null && !resEntries.isEmpty()) {
						fetchedEntry = (Entry) resEntries.toArray()[0];
						if (resEntries.size() > 1) {
							log.warn("Resource URI {} is used by {} entries in context {}; only using first matching entry for traversal result", uri, resEntries.size(), context.getURI());
						}
					}
					//Or by entry URI
					if (fetchedEntry == null) {
						// fallback in case the URI is an entry URI
						fetchedEntry = context.getByEntryURI(uri);
					}
				} else {
					//Check first via repository URIs (includes both resource URI and entry URI)
					fetchedEntry = cm.getEntry(uri);
					if (fetchedEntry == null) {
						// fallback in case we are not referring to repository URIs.
						Set<Entry> resEntries = cm.getLinks(uri);
						if (resEntries != null && !resEntries.isEmpty()) {
							fetchedEntry = (Entry) resEntries.toArray()[0];
						}
					}
				}

				if (fetchedEntry != null) {
					graph = new LinkedHashModel(fetchedEntry.getMetadataGraph());

					// we want to get the date of the latest modification of any of the entries in the traversal process
					Date entryDateTmp = fetchedEntry.getModifiedDate();
					if (entryDateTmp == null) {
						entryDateTmp = fetchedEntry.getCreationDate();
					}
					if (entryDateTmp != null) {
						if (latestModified == null || latestModified.before(entryDateTmp)) {
							latestModified = entryDateTmp;
						}
					} else {
						log.warn("Entry does neither have a creation nor a modification date: " + fetchedEntry.getEntryURI());
					}
				}
			} catch (AuthorizationException ae) {
				// if the starting point for traversal is not accessible we abort
				// if other entries further down the traversal are inaccessible
				// we continue without fetching them
				if (level == 0) {
					throw ae;
				} else {
					accessDenied.add(r);
					log.info("Unable to load entry due to ACL restrictions: {}", r);
					continue;
				}
			}

			if (graph != null) {
				visited.put(r, level);
				if (graphContainsPredicateObjectTuple(graph, blacklist)) {
					log.debug("Found blacklisted predicate/object tuple in graph, excluding {}", r);
				} else {
					resultGraph.addAll(graph);
					if (propertiesToFollow != null && level < depth) {
						Set<IRI> objects = new HashSet<>();
						for (URI prop : propertiesToFollow) {
							objects.addAll(valueToURI(graph.filter(null, valueFactory.createIRI(prop.toString()), null).objects()));
						}
						objects.remove(r);
						if (!objects.isEmpty()) {
							log.debug("Fetching " + objects.size() + " entr" + (objects.size() == 1 ? "y" : "ies") + " linked from <" + r + ">: " + objects);
							TraversalResult nextLevel = traverseAndLoadEntryMetadata(
									objects,
									propertiesToFollow,
									blacklist,
									level + 1,
									depth,
									visited,
									context,
									rm);
							resultGraph.addAll(nextLevel.getGraph());
							accessDenied.addAll(nextLevel.getAccessDenied());
							if (nextLevel.getLatestModified() != null) {
								if (latestModified == null || latestModified.before(nextLevel.getLatestModified())) {
									latestModified = nextLevel.getLatestModified();
								}
							} else {
								log.warn("No latest modification date on traversal level " + (level + 1));
							}
						}
					}
				}
			}
		}

		for (IRI objectToRemove : accessDenied) {
			resultGraph.removeIf(s -> objectToRemove.equals(s.getObject()));
		}
		return new TraversalResult(resultGraph, latestModified, accessDenied);
	}

	/**
	 * Checks whether a graph contains a predicate/object tuple. A simple String
	 * comparison is performed, therefore it does not matter of which type
	 * (Literal, Resource) the object is.
	 */
	private static boolean graphContainsPredicateObjectTuple(Model graph, Map<String, String> tuples) {
		if (tuples != null && !tuples.isEmpty()) {
			for (Statement s : graph) {
				String predicate = s.getPredicate().stringValue();
				String object = s.getObject().stringValue();
				for (String key : tuples.keySet()) {
					if (predicate.equals(key)) {
						if (object.equals(tuples.get(key))) {
							return true;
						}
					}
				}
			}
		}
		return false;
	}

	/**
	 * Converts a set of Resources to a set of URIs. Removes non-URIs, i.e., BNodes, Literals, etc.
	 *
	 * @param values A set of Values.
	 * @return A filtered and converted set of URIs.
	 */
	public static Set<IRI> valueToURI(Set<Value> values) {
		Set<IRI> result = new HashSet<>();
		for (Value v : values) {
			if (v instanceof IRI) {
				result.add((IRI) v);
			}
		}
		return result;
	}

	@Getter
	public static class TraversalResult {

		private final Model graph;

		private final Date latestModified;

		private final Set<IRI> accessDenied;

		private TraversalResult(Model graph, Date latestModified, Set<IRI> accessDenied) {
			this.graph = graph;
			this.latestModified = latestModified;
			this.accessDenied = accessDenied;
		}

	}

}