View Javadoc
1   /*
2    * This file is part of ***  M y C o R e  ***
3    * See http://www.mycore.de/ for details.
4    *
5    * MyCoRe is free software: you can redistribute it and/or modify
6    * it under the terms of the GNU General Public License as published by
7    * the Free Software Foundation, either version 3 of the License, or
8    * (at your option) any later version.
9    *
10   * MyCoRe is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with MyCoRe.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  
19  package org.mycore.datamodel.common;
20  
21  import java.io.File;
22  import java.io.FileNotFoundException;
23  import java.io.IOException;
24  import java.net.URI;
25  import java.net.URISyntaxException;
26  import java.nio.file.Files;
27  import java.nio.file.Path;
28  import java.nio.file.Paths;
29  import java.text.MessageFormat;
30  import java.util.ArrayList;
31  import java.util.Collection;
32  import java.util.Collections;
33  import java.util.Date;
34  import java.util.HashSet;
35  import java.util.Iterator;
36  import java.util.List;
37  import java.util.Locale;
38  import java.util.Optional;
39  import java.util.concurrent.TimeUnit;
40  import java.util.stream.Collectors;
41  import java.util.stream.Stream;
42  
43  import org.apache.logging.log4j.LogManager;
44  import org.apache.logging.log4j.Logger;
45  import org.jdom2.Document;
46  import org.jdom2.JDOMException;
47  import org.mycore.common.MCRCache;
48  import org.mycore.common.MCRPersistenceException;
49  import org.mycore.common.config.MCRConfiguration2;
50  import org.mycore.common.config.MCRConfigurationBase;
51  import org.mycore.common.config.MCRConfigurationException;
52  import org.mycore.common.content.MCRContent;
53  import org.mycore.common.content.MCRJDOMContent;
54  import org.mycore.datamodel.ifs2.MCRMetadataStore;
55  import org.mycore.datamodel.ifs2.MCRMetadataVersion;
56  import org.mycore.datamodel.ifs2.MCRObjectIDFileSystemDate;
57  import org.mycore.datamodel.ifs2.MCRStore;
58  import org.mycore.datamodel.ifs2.MCRStoreCenter;
59  import org.mycore.datamodel.ifs2.MCRStoreManager;
60  import org.mycore.datamodel.ifs2.MCRStoredMetadata;
61  import org.mycore.datamodel.ifs2.MCRVersionedMetadata;
62  import org.mycore.datamodel.ifs2.MCRVersioningMetadataStore;
63  import org.mycore.datamodel.metadata.MCRObject;
64  import org.mycore.datamodel.metadata.MCRObjectID;
65  import org.mycore.datamodel.metadata.history.MCRMetadataHistoryManager;
66  import org.xml.sax.SAXException;
67  
68  /**
69   * Manages persistence of MCRObject and MCRDerivate xml metadata.
70   * Provides methods to create, retrieve, update and delete object metadata
71   * using IFS2 MCRMetadataStore instances.
72   *
73   * For configuration, at least the following properties must be set:
74   *
75   * MCR.Metadata.Store.BaseDir=/path/to/metadata/dir
76   * MCR.Metadata.Store.SVNBase=file:///path/to/local/svndir/
77   *
78   * Both directories will be created if they do not exist yet.
79   * For each project and type, a subdirectory will be created,
80   * for example %MCR.Metadata.Store.BaseDir%/DocPortal/document/.
81   *
82   * The default IFS2 store is MCRVersioningMetadataStore, which
83   * versions metadata using SVN in local repositories below SVNBase.
84   * If you do not want versioning and would like to have better
85   * performance, change the following property to
86   *
87   * MCR.Metadata.Store.DefaultClass=org.mycore.datamodel.ifs2.MCRMetadataStore
88   *
89   * It is also possible to change individual properties per project and object type
90   * and overwrite the defaults, for example
91   *
92   * MCR.IFS2.Store.Class=org.mycore.datamodel.ifs2.MCRVersioningMetadataStore
93   * MCR.IFS2.Store.SVNRepositoryURL=file:///use/other/location/for/document/versions/
94   * MCR.IFS2.Store.SlotLayout=2-2-2-2
95   *
96   * See documentation of MCRStore and MCRMetadataStore for details.
97   *
98   * @author Frank Lützenkirchen
99   * @author Jens Kupferschmidt
100  * @author Thomas Scheffler (yagee)
101  */
102 public class MCRDefaultXMLMetadataManager implements MCRXMLMetadataManagerAdapter {
103 
104     private static final Logger LOGGER = LogManager.getLogger();
105 
106     private static final String DEFAULT_SVN_DIRECTORY_NAME = "versions-metadata";
107 
108     /** The singleton */
109     private static MCRDefaultXMLMetadataManager SINGLETON;
110 
111     private HashSet<String> createdStores;
112 
113     /**
114      * The default IFS2 Metadata store class to use, set by MCR.Metadata.Store.DefaultClass
115      */
116     @SuppressWarnings("rawtypes")
117     private Class defaultClass;
118 
119     /**
120      * The default subdirectory slot layout for IFS2 metadata store, is 4-2-2 for 8-digit IDs,
121      * that means DocPortal_document_0000001 will be stored in the file
122      * DocPortal/document/0000/00/DocPortal_document_00000001.xml
123      */
124     private String defaultLayout;
125 
126     /**
127      * The base directory for all IFS2 metadata stores used, set by MCR.Metadata.Store.BaseDir
128      */
129     private Path basePath;
130 
131     /**
132      * The local base directory for IFS2 versioned metadata using SVN, set URI by MCR.Metadata.Store.SVNBase
133      */
134     private Path svnPath;
135 
136     /**
137      * The local file:// uri of all SVN versioned metadata, set URI by MCR.Metadata.Store.SVNBase
138      */
139     private URI svnBase;
140 
141     protected MCRDefaultXMLMetadataManager() {
142         this.createdStores = new HashSet<>();
143         reload();
144     }
145 
146     /** Returns the singleton */
147     public static synchronized MCRDefaultXMLMetadataManager instance() {
148         if (SINGLETON == null) {
149             SINGLETON = new MCRDefaultXMLMetadataManager();
150         }
151         return SINGLETON;
152     }
153 
154     public synchronized void reload() {
155         String pattern = MCRConfiguration2.getString("MCR.Metadata.ObjectID.NumberPattern").orElse("0000000000");
156         defaultLayout = pattern.length() - 4 + "-2-2";
157 
158         String base = MCRConfiguration2.getStringOrThrow("MCR.Metadata.Store.BaseDir");
159         basePath = Paths.get(base);
160         checkPath(basePath, "base");
161 
162         defaultClass = MCRConfiguration2.<MCRVersioningMetadataStore>getClass("MCR.Metadata.Store.DefaultClass")
163             .orElse(MCRVersioningMetadataStore.class);
164         if (MCRVersioningMetadataStore.class.isAssignableFrom(defaultClass)) {
165             Optional<String> svnBaseOpt = MCRConfiguration2.getString("MCR.Metadata.Store.SVNBase");
166             if (svnBaseOpt.isEmpty()) {
167                 svnPath = Paths.get(MCRConfiguration2.getStringOrThrow("MCR.datadir"))
168                     .resolve(DEFAULT_SVN_DIRECTORY_NAME);
169                 checkPath(svnPath, "svn");
170                 svnBase = svnPath.toUri();
171             } else {
172                 try {
173                     String svnBaseValue = svnBaseOpt.get();
174                     if (!svnBaseValue.endsWith("/")) {
175                         svnBaseValue += '/';
176                     }
177                     svnBase = new URI(svnBaseValue);
178                     LOGGER.info("SVN Base: {}", svnBase);
179                     if (svnBase.getScheme() == null) {
180                         String workingDirectory = (new File(".")).getAbsolutePath();
181                         URI root = new File(MCRConfiguration2.getString("MCR.datadir").orElse(workingDirectory))
182                             .toURI();
183                         URI resolved = root.resolve(svnBase);
184                         LOGGER.warn("Resolved {} to {}", svnBase, resolved);
185                         svnBase = resolved;
186                     }
187                 } catch (URISyntaxException ex) {
188                     String msg = "Syntax error in MCR.Metadata.Store.SVNBase property: " + svnBase;
189                     throw new MCRConfigurationException(msg, ex);
190                 }
191                 if (svnBase.getScheme().equals("file")) {
192                     svnPath = Paths.get(svnBase);
193                     checkPath(svnPath, "svn");
194                 }
195             }
196         }
197         closeCreatedStores();
198     }
199 
200     private synchronized void closeCreatedStores() {
201         for (String storeId : createdStores) {
202             MCRStoreCenter.instance().removeStore(storeId);
203         }
204         createdStores.clear();
205     }
206 
207     /**
208      * Checks the directory configured exists and is readable and writable, or creates it
209      * if it does not exist yet.
210      *
211      * @param path the path to check
212      * @param type metadata store type
213      */
214     private void checkPath(Path path, String type) {
215         if (!Files.exists(path)) {
216             try {
217                 if (!Files.exists(Files.createDirectories(path))) {
218                     throw new MCRConfigurationException(
219                         "The metadata store " + type + " directory " + path.toAbsolutePath() + " does not exist.");
220                 }
221             } catch (Exception ex) {
222                 String msg = "Exception while creating metadata store " + type + " directory " + path.toAbsolutePath();
223                 throw new MCRConfigurationException(msg, ex);
224             }
225         } else {
226             if (!Files.isDirectory(path)) {
227                 throw new MCRConfigurationException(
228                     "Metadata store " + type + " " + path.toAbsolutePath() + " is a file, not a directory");
229             }
230             if (!Files.isReadable(path)) {
231                 throw new MCRConfigurationException(
232                     "Metadata store " + type + " directory " + path.toAbsolutePath() + " is not readable");
233             }
234             if (!Files.isWritable(path)) {
235                 throw new MCRConfigurationException(
236                     "Metadata store " + type + " directory " + path.toAbsolutePath() + " is not writeable");
237             }
238         }
239     }
240 
241     /**
242      * Returns IFS2 MCRMetadataStore for the given MCRObjectID base, which is {project}_{type}
243      *
244      * @param base the MCRObjectID base, e.g. DocPortal_document
245      */
246     private MCRMetadataStore getStore(String base) {
247         String[] split = base.split("_");
248         return getStore(split[0], split[1], false);
249     }
250 
251     /**
252      * Returns IFS2 MCRMetadataStore for the given MCRObjectID base, which is {project}_{type}
253      *
254      * @param base the MCRObjectID base, e.g. DocPortal_document
255      * @param readOnly If readOnly, the store will not be created if it does not exist yet. Instead an exception
256      *                 is thrown.
257      * @return the metadata store
258      */
259     private MCRMetadataStore getStore(String base, boolean readOnly) {
260         String[] split = base.split("_");
261         return getStore(split[0], split[1], readOnly);
262     }
263 
264     /**
265      * Returns IFS2 MCRMetadataStore used to store metadata of the given MCRObjectID
266      *
267      * @param mcrid the mycore object identifier
268      * @param readOnly If readOnly, the store will not be created if it does not exist yet. Instead an exception
269      *                 is thrown.
270      * @return the metadata store
271      */
272     private MCRMetadataStore getStore(MCRObjectID mcrid, boolean readOnly) {
273         return getStore(mcrid.getProjectId(), mcrid.getTypeId(), readOnly);
274     }
275 
276     /**
277      * Returns IFS2 MCRMetadataStore for the given project and object type
278      *
279      * @param project the project, e.g. DocPortal
280      * @param type the object type, e.g. document
281      * @param readOnly if readOnly, this method will throw an exception if the store does not exist's yet
282      * @return the metadata store
283      */
284     private MCRMetadataStore getStore(String project, String type, boolean readOnly) {
285         String projectType = getStoryKey(project, type);
286         String prefix = "MCR.IFS2.Store." + projectType + ".";
287         String forceXML = MCRConfiguration2.getString(prefix + "ForceXML").orElse(null);
288         if (forceXML == null) {
289             synchronized (this) {
290                 forceXML = MCRConfiguration2.getString(prefix + "ForceXML").orElse(null);
291                 if (forceXML == null) {
292                     try {
293                         setupStore(project, type, prefix, readOnly);
294                     } catch (ReflectiveOperationException e) {
295                         throw new MCRPersistenceException(
296                             new MessageFormat("Could not instantiate store for project {0} and object type {1}.",
297                                 Locale.ROOT).format(new Object[] { project, type }),
298                             e);
299                     }
300                 }
301             }
302         }
303         MCRMetadataStore store = MCRStoreManager.getStore(projectType);
304         if (store == null) {
305             throw new MCRPersistenceException(
306                 new MessageFormat("Metadata store for project {0} and object type {1} is unconfigured.", Locale.ROOT)
307                     .format(new Object[] { project, type }));
308         }
309         return store;
310     }
311 
312     public void verifyStore(String base) {
313         MCRMetadataStore store = getStore(base);
314         if (store instanceof MCRVersioningMetadataStore) {
315             LOGGER.info("Verifying SVN history of {}.", base);
316             ((MCRVersioningMetadataStore) (getStore(base))).verify();
317         } else {
318             LOGGER.warn("Cannot verify unversioned store {}!", base);
319         }
320     }
321 
322     @SuppressWarnings("unchecked")
323     private void setupStore(String project, String objectType, String configPrefix, boolean readOnly)
324         throws ReflectiveOperationException {
325         String baseID = getStoryKey(project, objectType);
326         Class<? extends MCRStore> clazz = MCRConfiguration2.<MCRStore>getClass(configPrefix + "Class")
327             .orElseGet(() -> {
328                 MCRConfiguration2.set(configPrefix + "Class", defaultClass.getName());
329                 return defaultClass;
330             });
331         if (MCRVersioningMetadataStore.class.isAssignableFrom(clazz)) {
332             String property = configPrefix + "SVNRepositoryURL";
333             String svnURL = MCRConfiguration2.getString(property).orElse(null);
334             if (svnURL == null) {
335                 String relativeURI = new MessageFormat("{0}/{1}/", Locale.ROOT)
336                     .format(new Object[] { project, objectType });
337                 URI repURI = svnBase.resolve(relativeURI);
338                 LOGGER.info("Resolved {} to {} for {}", relativeURI, repURI.toASCIIString(), property);
339                 MCRConfiguration2.set(property, repURI.toASCIIString());
340                 checkAndCreateDirectory(svnPath.resolve(project), project, objectType, configPrefix, readOnly);
341             }
342         }
343 
344         Path typePath = basePath.resolve(project).resolve(objectType);
345         checkAndCreateDirectory(typePath, project, objectType, configPrefix, readOnly);
346 
347         String slotLayout = MCRConfiguration2.getString(configPrefix + "SlotLayout").orElse(null);
348         if (slotLayout == null) {
349             MCRConfiguration2.set(configPrefix + "SlotLayout", defaultLayout);
350         }
351         MCRConfiguration2.set(configPrefix + "BaseDir", typePath.toAbsolutePath().toString());
352         MCRConfiguration2.set(configPrefix + "ForceXML", String.valueOf(true));
353         String value = "derivate".equals(objectType) ? "mycorederivate" : "mycoreobject";
354         MCRConfiguration2.set(configPrefix + "ForceDocType", value);
355         createdStores.add(baseID);
356         MCRStoreManager.createStore(baseID, clazz);
357     }
358 
359     private void checkAndCreateDirectory(Path path, String project, String objectType, String configPrefix,
360         boolean readOnly) {
361         if (Files.exists(path)) {
362             return;
363         }
364         if (readOnly) {
365             throw new MCRPersistenceException(String.format(Locale.ENGLISH,
366                 "Path does not exists ''%s'' to set up store for project ''%s'' and objectType ''%s'' "
367                     + "and config prefix ''%s''. We are not willing to create it for an read only operation.",
368                 path.toAbsolutePath(), project, objectType, configPrefix));
369         }
370         try {
371             if (!Files.exists(Files.createDirectories(path))) {
372                 throw new FileNotFoundException(path.toAbsolutePath() + " does not exists.");
373             }
374         } catch (Exception e) {
375             throw new MCRPersistenceException(String.format(Locale.ENGLISH,
376                 "Couldn'e create directory ''%s'' to set up store for project ''%s'' and objectType ''%s'' "
377                     + "and config prefix ''%s''",
378                 path.toAbsolutePath(), project, objectType, configPrefix));
379         }
380     }
381 
382     private String getStoryKey(String project, String objectType) {
383         return project + "_" + objectType;
384     }
385 
386     public void create(MCRObjectID mcrid, MCRContent xml, Date lastModified)
387         throws MCRPersistenceException {
388         try {
389             MCRStoredMetadata sm = getStore(mcrid, false).create(xml, mcrid.getNumberAsInteger());
390             sm.setLastModified(lastModified);
391             MCRConfigurationBase.systemModified();
392         } catch (Exception exc) {
393             throw new MCRPersistenceException("Error while storing object: " + mcrid, exc);
394         }
395     }
396 
397     public void delete(MCRObjectID mcrid) throws MCRPersistenceException {
398         try {
399             getStore(mcrid, true).delete(mcrid.getNumberAsInteger());
400             MCRConfigurationBase.systemModified();
401         } catch (Exception exc) {
402             throw new MCRPersistenceException("Error while deleting object: " + mcrid, exc);
403         }
404     }
405 
406     public void update(MCRObjectID mcrid, MCRContent xml, Date lastModified)
407         throws MCRPersistenceException {
408         if (!exists(mcrid)) {
409             throw new MCRPersistenceException("Object to update does not exist: " + mcrid);
410         }
411         try {
412             MCRStoredMetadata sm = getStore(mcrid, false).retrieve(mcrid.getNumberAsInteger());
413             sm.update(xml);
414             sm.setLastModified(lastModified);
415             MCRConfigurationBase.systemModified();
416         } catch (Exception exc) {
417             throw new MCRPersistenceException("Unable to update object " + mcrid, exc);
418         }
419     }
420 
421     public MCRContent retrieveContent(MCRObjectID mcrid) throws IOException {
422         MCRContent metadata;
423         MCRStoredMetadata storedMetadata = retrieveStoredMetadata(mcrid);
424         if (storedMetadata == null || storedMetadata.isDeleted()) {
425             return null;
426         }
427         metadata = storedMetadata.getMetadata();
428         return metadata;
429     }
430 
431     public MCRContent retrieveContent(MCRObjectID mcrid, String revision) throws IOException {
432         LOGGER.info("Getting object {} in revision {}", mcrid, revision);
433         MCRMetadataVersion version = getMetadataVersion(mcrid, Long.parseLong(revision));
434         if (version != null) {
435             MCRContent content = version.retrieve();
436             try {
437                 Document doc = content.asXML();
438                 doc.getRootElement().setAttribute("rev", version.getRevision());
439                 return new MCRJDOMContent(doc);
440             } catch (JDOMException | SAXException e) {
441                 throw new MCRPersistenceException("Could not parse XML from default store", e);
442             }
443         }
444         return null;
445     }
446 
447     /**
448      * Returns the {@link MCRMetadataVersion} of the given id and revision.
449      *
450      * @param mcrId
451      *            the id of the object to be retrieved
452      * @param rev
453      *            the revision to be returned, specify -1 if you want to
454      *            retrieve the latest revision (includes deleted objects also)
455      * @return a {@link MCRMetadataVersion} representing the {@link MCRObject} of the
456      *         given revision or <code>null</code> if there is no such object
457      *         with the given revision
458      * @throws IOException version metadata couldn't be retrieved due an i/o error
459      */
460     private MCRMetadataVersion getMetadataVersion(MCRObjectID mcrId, long rev) throws IOException {
461         MCRVersionedMetadata versionedMetaData = getVersionedMetaData(mcrId);
462         if (versionedMetaData == null) {
463             return null;
464         }
465         return versionedMetaData.getRevision(rev);
466     }
467 
468     public List<MCRMetadataVersion> listRevisions(MCRObjectID id) throws IOException {
469         MCRVersionedMetadata vm = getVersionedMetaData(id);
470         if (vm == null) {
471             return null;
472         }
473         return vm.listVersions();
474     }
475 
476     private MCRVersionedMetadata getVersionedMetaData(MCRObjectID id) throws IOException {
477         if (id == null) {
478             return null;
479         }
480         MCRMetadataStore metadataStore = getStore(id, true);
481         if (!(metadataStore instanceof MCRVersioningMetadataStore)) {
482             return null;
483         }
484         MCRVersioningMetadataStore verStore = (MCRVersioningMetadataStore) metadataStore;
485         return verStore.retrieve(id.getNumberAsInteger());
486     }
487 
488     /**
489      * Retrieves stored metadata xml as IFS2 metadata object.
490      *
491      * @param mcrid the MCRObjectID
492      */
493     private MCRStoredMetadata retrieveStoredMetadata(MCRObjectID mcrid) throws IOException {
494         return getStore(mcrid, true).retrieve(mcrid.getNumberAsInteger());
495     }
496 
497     public int getHighestStoredID(String project, String type) {
498         MCRMetadataStore store;
499         try {
500             store = getStore(project, type, true);
501         } catch (MCRPersistenceException persistenceException) {
502             // store does not exists -> return 0
503             return 0;
504         }
505         int highestStoredID = store.getHighestStoredID();
506         //fixes MCR-1534 (IDs once deleted should never be used again)
507         return Math.max(highestStoredID, MCRMetadataHistoryManager.getHighestStoredID(project, type)
508             .map(MCRObjectID::getNumberAsInteger)
509             .orElse(0));
510     }
511 
512     public boolean exists(MCRObjectID mcrid) throws MCRPersistenceException {
513         try {
514             if (mcrid == null) {
515                 return false;
516             }
517             MCRMetadataStore store;
518             try {
519                 store = getStore(mcrid, true);
520             } catch (MCRPersistenceException persistenceException) {
521                 // the store couldn't be retrieved, the object does not exists
522                 return false;
523             }
524             return store.exists(mcrid.getNumberAsInteger());
525         } catch (Exception exc) {
526             throw new MCRPersistenceException("Unable to check if object exists " + mcrid, exc);
527         }
528     }
529 
530     public List<String> listIDsForBase(String base) {
531         MCRMetadataStore store;
532         try {
533             store = getStore(base, true);
534         } catch (MCRPersistenceException e) {
535             LOGGER.warn("Store for '{}' does not exist.", base);
536             return Collections.emptyList();
537         }
538 
539         List<String> list = new ArrayList<>();
540         Iterator<Integer> it = store.listIDs(MCRStore.ASCENDING);
541         String[] idParts = MCRObjectID.getIDParts(base);
542         while (it.hasNext()) {
543             list.add(MCRObjectID.formatID(idParts[0], idParts[1], it.next()));
544         }
545         return list;
546     }
547 
548     public List<String> listIDsOfType(String type) {
549         try (Stream<Path> streamBasePath = list(basePath)) {
550             return streamBasePath.flatMap(projectPath -> {
551                 final String project = projectPath.getFileName().toString();
552                 return list(projectPath).flatMap(typePath -> {
553                     if (type.equals(typePath.getFileName().toString())) {
554                         final String base = getStoryKey(project, type);
555                         return listIDsForBase(base).stream();
556                     }
557                     return Stream.empty();
558                 });
559             }).collect(Collectors.toList());
560         }
561     }
562 
563     public List<String> listIDs() {
564         try (Stream<Path> streamBasePath = list(basePath)) {
565             return streamBasePath.flatMap(projectPath -> {
566                 final String project = projectPath.getFileName().toString();
567                 return list(projectPath).flatMap(typePath -> {
568                     final String type = typePath.getFileName().toString();
569                     final String base = getStoryKey(project, type);
570                     return listIDsForBase(base).stream();
571                 });
572             }).collect(Collectors.toList());
573         }
574     }
575 
576     public Collection<String> getObjectTypes() {
577         try (Stream<Path> streamBasePath = list(basePath)) {
578             return streamBasePath.flatMap(this::list)
579                 .map(Path::getFileName)
580                 .map(Path::toString)
581                 .filter(MCRObjectID::isValidType)
582                 .distinct()
583                 .collect(Collectors.toSet());
584         }
585     }
586 
587     public Collection<String> getObjectBaseIds() {
588         try (Stream<Path> streamBasePath = list(basePath)) {
589             return streamBasePath.flatMap(this::list)
590                 .filter(p -> MCRObjectID.isValidType(p.getFileName().toString()))
591                 .map(p -> p.getParent().getFileName() + "_" + p.getFileName())
592                 .collect(Collectors.toSet());
593         }
594     }
595 
596     /**
597      * Returns the entries of the given path. Throws a MCRException if an I/O-Exceptions occur.
598      *
599      * @return stream of project directories
600      */
601     private Stream<Path> list(Path path) {
602         try {
603             return Files.list(path);
604         } catch (IOException ioException) {
605             throw new MCRPersistenceException(
606                 "unable to list files of IFS2 metadata directory " + path.toAbsolutePath(), ioException);
607         }
608     }
609 
610     public List<MCRObjectIDDate> retrieveObjectDates(List<String> ids) throws IOException {
611         List<MCRObjectIDDate> objidlist = new ArrayList<>(ids.size());
612         for (String id : ids) {
613             MCRStoredMetadata sm = this.retrieveStoredMetadata(MCRObjectID.getInstance(id));
614             objidlist.add(new MCRObjectIDFileSystemDate(sm, id));
615         }
616         return objidlist;
617     }
618 
619     public long getLastModified(MCRObjectID id) throws IOException {
620         MCRMetadataStore store = getStore(id, true);
621         MCRStoredMetadata metadata = store.retrieve(id.getNumberAsInteger());
622         if (metadata != null) {
623             return metadata.getLastModified().getTime();
624         }
625         return -1;
626     }
627 
628     public MCRCache.ModifiedHandle getLastModifiedHandle(final MCRObjectID id, final long expire, TimeUnit unit) {
629         return new StoreModifiedHandle(this, id, expire, unit);
630     }
631 
632     private static final class StoreModifiedHandle implements MCRCache.ModifiedHandle {
633         private final MCRDefaultXMLMetadataManager mm;
634 
635         private final long expire;
636 
637         private final MCRObjectID id;
638 
639         private StoreModifiedHandle(MCRDefaultXMLMetadataManager mm, MCRObjectID id, long time, TimeUnit unit) {
640             this.mm = mm;
641             this.expire = unit.toMillis(time);
642             this.id = id;
643         }
644 
645         @Override
646         public long getCheckPeriod() {
647             return expire;
648         }
649 
650         @Override
651         public long getLastModified() throws IOException {
652             return mm.getLastModified(id);
653         }
654     }
655 }