001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas/
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see https://www.gnu.org/licenses/.
027 */
028
029package edu.wisc.ssec.mcidasv.util.pathwatcher;
030
031import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
032import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
033import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
034import static java.nio.file.StandardWatchEventKinds.OVERFLOW;
035
036import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.concurrentMap;
037import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.concurrentSet;
038
039import java.io.IOException;
040
041import java.nio.file.FileSystem;
042import java.nio.file.FileSystems;
043import java.nio.file.Files;
044import java.nio.file.Path;
045import java.nio.file.PathMatcher;
046import java.nio.file.Paths;
047import java.nio.file.WatchEvent;
048import java.nio.file.WatchKey;
049import java.nio.file.WatchService;
050
051import java.util.Arrays;
052import java.util.Map;
053import java.util.Set;
054import java.util.concurrent.atomic.AtomicBoolean;
055import java.util.stream.Collectors;
056
057import org.slf4j.Logger;
058import org.slf4j.LoggerFactory;
059
060// Adapted from https://gist.github.com/hindol-viz/394ebc553673e2cd0699
061
062/**
063 * A simple class which can monitor files and notify interested parties
064 * (i.e. listeners) of file changes.
065 *
066 * This class is kept lean by only keeping methods that are actually being
067 * called.
068 */
069public class SimpleDirectoryWatchService implements DirectoryWatchService,
070        Runnable
071{
072    
073    /** Logging object. */
074    private static final Logger logger =
075        LoggerFactory.getLogger(SimpleDirectoryWatchService.class);
076        
077    /**
078     * {@code WatchService} used to monitor changes in various
079     * {@link Path Paths}.
080     */
081    private final WatchService mWatchService;
082    
083    /** Whether or not this {@link DirectoryWatchService} is running. */
084    private final AtomicBoolean mIsRunning;
085    
086    /**
087     * Mapping of monitoring {@literal "registration"} keys to the
088     * {@link Path} that it will be watching.
089     */
090    private final Map<WatchKey, Path> mWatchKeyToDirPathMap;
091    
092    /**
093     * Mapping of {@link Path Paths} to the {@link Set} of
094     * {@link OnFileChangeListener OnFileChangeListeners} listening for
095     * changes to the associated {@code Path}.
096     */
097    private final Map<Path, Set<OnFileChangeListener>> mDirPathToListenersMap;
098    
099    /**
100     * Mapping of {@link OnFileChangeListener OnFileChangeListeners} to the
101     * {@link Set} of patterns being used to observe changes in
102     * {@link Path Paths} of interest.
103     */
104    private final Map<OnFileChangeListener, Set<PathMatcher>>
105        mListenerToFilePatternsMap;
106        
107    /**
108     * A simple no argument constructor for creating a
109     * {@code SimpleDirectoryWatchService}.
110     *
111     * @throws IOException If an I/O error occurs.
112     */
113    public SimpleDirectoryWatchService() throws IOException {
114        mWatchService = FileSystems.getDefault().newWatchService();
115        mIsRunning = new AtomicBoolean(false);
116        mWatchKeyToDirPathMap = concurrentMap();
117        mDirPathToListenersMap = concurrentMap();
118        mListenerToFilePatternsMap = concurrentMap();
119    }
120    
121    /**
122     * Utility method used to make {@literal "valid"} casts of the given
123     * {@code event} to a specific type of {@link WatchEvent}.
124     *
125     * @param <T> Type to which {@code event} will be casted.
126     * @param event Event to cast.
127     *
128     * @return {@code event} casted to {@code WatchEvent<T>}.
129     */
130    @SuppressWarnings("unchecked")
131    private static <T> WatchEvent<T> cast(WatchEvent<?> event) {
132        return (WatchEvent<T>)event;
133    }
134    
135    /**
136     * Returns a {@link PathMatcher} that performs {@literal "glob"} matches
137     * with the given {@code globPattern} against the {@code String}
138     * representation of {@link Path} objects.
139     *
140     * @param globPattern Pattern to match against. {@code null} or empty
141     *                    {@code String} values will be converted to {@code *}.
142     *
143     * @return Path matching object for the given {@code globPattern}.
144     *
145     * @throws IOException if there was a problem creating the
146     *                     {@code PathMatcher}.
147     */
148    private static PathMatcher matcherForGlobExpression(String globPattern)
149        throws IOException
150    {
151        if ((globPattern == null) || globPattern.isEmpty()) {
152            globPattern = "*";
153        }
154        
155        return FileSystems.getDefault().getPathMatcher("glob:"+globPattern);
156    }
157    
158    /**
159     * Check the given {@code input} {@link Path} against the given {@code
160     * pattern}.
161     *
162     * @param input Path to check.
163     * @param pattern Pattern to check against. Cannot be {@code null}.
164     *
165     * @return Whether or not {@code input} matches {@code pattern}.
166     */
167    public static boolean matches(Path input, PathMatcher pattern) {
168        return pattern.matches(input);
169    }
170    
171    /**
172     * Check the given {@code input} {@link Path} against <i>all</i> of the
173     * specified {@code patterns}.
174     *
175     * @param input Path to check.
176     * @param patterns {@link Set} of patterns to attempt to match
177     *                 {@code input} against. Cannot be {@code null}.
178     *
179     * @return Whether or not {@code input} matches any of the given
180     *         {@code patterns}.
181     */
182    private static boolean matchesAny(Path input, Set<PathMatcher> patterns) {
183        for (PathMatcher pattern : patterns) {
184            if (matches(input, pattern)) {
185                return true;
186            }
187        }
188        
189        return false;
190    }
191    
192    /**
193     * Get the path associated with the given {@link WatchKey}.
194     *
195     * @param key {@code WatchKey} whose corresponding {@link Path} is being
196     *            requested.
197     *
198     * @return Either the correspond {@code Path} or {@code null}.
199     */
200    private Path getDirPath(WatchKey key) {
201        return mWatchKeyToDirPathMap.get(key);
202    }
203    
204    /**
205     * Get the {@link OnFileChangeListener OnFileChangeListeners} associated
206     * with the given {@code path}.
207     *
208     * @param path Path whose listeners should be returned. Cannot be
209     *             {@code null}.
210     *
211     * @return Either the {@link Set} of listeners associated with {@code path}
212     *         or {@code null}.
213     */
214    private Set<OnFileChangeListener> getListeners(Path path) {
215        return mDirPathToListenersMap.get(path);
216    }
217    
218    /**
219     * Get the {@link Set} of patterns associated with the given
220     * {@link OnFileChangeListener}.
221     *
222     * @param listener Listener of interest.
223     *
224     * @return Either the {@code Set} of patterns associated with
225     *         {@code listener} or {@code null}.
226     */
227    private Set<PathMatcher> getPatterns(OnFileChangeListener listener) {
228        return mListenerToFilePatternsMap.get(listener);
229    }
230    
231    /**
232     * Get the {@link Path} associated with the given
233     * {@link OnFileChangeListener}.
234     *
235     * @param listener Listener whose path is requested.
236     *
237     * @return Either the {@code Path} associated with {@code listener} or
238     *         {@code null}.
239     */
240    private Path getDir(OnFileChangeListener listener) {
241        
242        Set<Map.Entry<Path, Set<OnFileChangeListener>>> entries =
243                mDirPathToListenersMap.entrySet();
244                
245        Path result = null;
246        for (Map.Entry<Path, Set<OnFileChangeListener>> entry : entries) {
247            Set<OnFileChangeListener> listeners = entry.getValue();
248            if (listeners.contains(listener)) {
249                result = entry.getKey();
250                break;
251            }
252        }
253        
254        return result;
255    }
256    
257    /**
258     * Get the monitoring {@literal "registration"} key associated with the
259     * given {@link Path}.
260     *
261     * @param dir {@code Path} whose {@link WatchKey} is requested.
262     *
263     * @return Either the {@code WatchKey} corresponding to {@code dir} or
264     *         {@code null}.
265     */
266    private WatchKey getWatchKey(Path dir) {
267        Set<Map.Entry<WatchKey, Path>> entries =
268            mWatchKeyToDirPathMap.entrySet();
269            
270        WatchKey key = null;
271        for (Map.Entry<WatchKey, Path> entry : entries) {
272            if (entry.getValue().equals(dir)) {
273                key = entry.getKey();
274                break;
275            }
276        }
277        
278        return key;
279    }
280    
281    /**
282     * Get the {@link Set} of
283     * {@link OnFileChangeListener OnFileChangeListeners} that should be
284     * notified that {@code file} has changed.
285     *
286     * @param dir Directory containing {@code file}.
287     * @param file File that changed.
288     *
289     * @return {@code Set} of listeners that should be notified that
290     *         {@code file} has changed.
291     */
292    private Set<OnFileChangeListener> matchedListeners(Path dir, Path file) {
293        return getListeners(dir)
294                .stream()
295                .filter(listener -> matchesAny(file, getPatterns(listener)))
296                .collect(Collectors.toSet());
297    }
298    
299    /**
300     * Method responsible for notifying listeners when a file matching their
301     * relevant pattern has changed.
302     *
303     * Note: {@literal "change"} means one of:
304     * <ul>
305     *   <li>file creation</li>
306     *   <li>file removal</li>
307     *   <li>file contents changing</li>
308     * </ul>
309     *
310     * @param key {@link #mWatchService} {@literal "registration"} key for
311     *            one of the {@link Path Paths} being watched. Cannot be
312     *            {@code null}.
313     *
314     * @see #run()
315     */
316    private void notifyListeners(WatchKey key) {
317        for (WatchEvent<?> event : key.pollEvents()) {
318            WatchEvent.Kind eventKind = event.kind();
319            
320            // Overflow occurs when the watch event queue is overflown
321            // with events.
322            if (eventKind.equals(OVERFLOW)) {
323                // TODO: Notify all listeners.
324                return;
325            }
326            
327            WatchEvent<Path> pathEvent = cast(event);
328            Path file = pathEvent.context();
329            String completePath =  getDirPath(key).resolve(file).toString();
330            
331            if (eventKind.equals(ENTRY_CREATE)) {
332                matchedListeners(getDirPath(key), file)
333                    .forEach(l -> l.onFileCreate(completePath));
334            } else if (eventKind.equals(ENTRY_MODIFY)) {
335                matchedListeners(getDirPath(key), file)
336                    .forEach(l -> l.onFileModify(completePath));
337            } else if (eventKind.equals(ENTRY_DELETE)) {
338                matchedListeners(getDirPath(key), file)
339                    .forEach(l -> l.onFileDelete(completePath));
340            }
341        }
342    }
343
344    /**
345     * Method responsible for notifying listeners when the path they are 
346     * watching has been deleted (or otherwise {@literal "invalidated"} 
347     * somehow).
348     * 
349     * @param key Key that has become invalid. Cannot be {@code null}.
350     */
351    private void notifyListenersOfInvalidation(WatchKey key) {
352        Path dir = getDirPath(key);
353        getListeners(dir).forEach(l -> l.onWatchInvalidation(dir.toString()));
354    }
355    
356    /**
357     * {@inheritDoc}
358     */
359    @Override public void register(OnFileChangeListener listener,
360                                   String dirPath, String... globPatterns)
361            throws IOException
362    {
363        Path dir = Paths.get(dirPath);
364        
365        if (!Files.isDirectory(dir)) {
366            throw new IllegalArgumentException(dirPath + " not a directory.");
367        }
368        
369        if (!mDirPathToListenersMap.containsKey(dir)) {
370            // May throw
371            WatchKey key = dir.register(
372                mWatchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE
373            );
374            
375            mWatchKeyToDirPathMap.put(key, dir);
376            mDirPathToListenersMap.put(dir, concurrentSet());
377        }
378        
379        getListeners(dir).add(listener);
380        
381        Set<PathMatcher> patterns = concurrentSet();
382        
383        for (String globPattern : globPatterns) {
384            patterns.add(matcherForGlobExpression(globPattern));
385        }
386        
387        if (patterns.isEmpty()) {
388            // Match everything if no filter is found
389            patterns.add(matcherForGlobExpression("*"));
390        }
391        
392        mListenerToFilePatternsMap.put(listener, patterns);
393        
394        logger.trace("Watching files matching {} under '{}' for changes",
395                Arrays.toString(globPatterns), dirPath);
396    }
397    
398    /**
399     * {@inheritDoc}
400     */
401    public void unregister(OnFileChangeListener listener) {
402        Path dir = getDir(listener);
403        
404        mDirPathToListenersMap.get(dir).remove(listener);
405        
406        // is this step truly needed?
407        if (mDirPathToListenersMap.get(dir).isEmpty()) {
408            mDirPathToListenersMap.remove(dir);
409        }
410        
411        mListenerToFilePatternsMap.remove(listener);
412        
413        WatchKey key = getWatchKey(dir);
414        if (key != null) {
415            mWatchKeyToDirPathMap.remove(key);
416            key.cancel();
417        }
418        logger.trace("listener unregistered");
419    }
420    
421    /**
422     * {@inheritDoc}
423     */
424    @Override public void unregisterAll() {
425        // can't simply clear the key->dir map; need to cancel
426        mWatchKeyToDirPathMap.keySet().forEach(WatchKey::cancel);
427        
428        mWatchKeyToDirPathMap.clear();
429        mDirPathToListenersMap.clear();
430        mListenerToFilePatternsMap.clear();
431    }
432    
433    /**
434     * Start this {@code SimpleDirectoryWatchService} instance by spawning a
435     * new thread.
436     *
437     * @see #stop()
438     */
439    @Override public void start() {
440        if (mIsRunning.compareAndSet(false, true)) {
441            String name = DirectoryWatchService.class.getSimpleName();
442            Thread runnerThread = new Thread(this, name);
443            runnerThread.start();
444        }
445    }
446    
447    /**
448     * Stop this {@code SimpleDirectoryWatchService} thread.
449     *
450     * <p>The killing happens lazily, giving the running thread an opportunity
451     * to finish the work at hand.</p>
452     *
453     * @see #start()
454     */
455    @Override public void stop() {
456        // Kill thread lazily
457        mIsRunning.set(false);
458    }
459    
460    /**
461     * {@inheritDoc}
462     */
463    @Override public boolean isRunning() {
464        return mIsRunning.get();
465    }
466    
467    /**
468     * {@inheritDoc}
469     */
470    @Override public void run() {
471        logger.info("Starting file watcher service.");
472        
473        while (mIsRunning.get()) {
474            WatchKey key;
475            try {
476                key = mWatchService.take();
477            } catch (InterruptedException e) {
478                logger.trace("{} service interrupted.",
479                        DirectoryWatchService.class.getSimpleName());
480                break;
481            }
482            
483            if (null == getDirPath(key)) {
484                logger.error("Watch key not recognized.");
485                continue;
486            }
487            
488            notifyListeners(key);
489            
490            // Reset key to allow further events for this key to be processed.
491            boolean valid = key.reset();
492            if (!valid) {
493                // order matters here; if you remove the key first, we can't
494                // work out who the appropriate listeners are.
495                notifyListenersOfInvalidation(key);
496                mWatchKeyToDirPathMap.remove(key);
497            }
498        }
499        
500        mIsRunning.set(false);
501        logger.trace("Stopping file watcher service.");
502    }
503}