001package jmri.util;
002
003import static jmri.util.FileUtil.HOME;
004import static jmri.util.FileUtil.PREFERENCES;
005import static jmri.util.FileUtil.PROFILE;
006import static jmri.util.FileUtil.PROGRAM;
007import static jmri.util.FileUtil.SCRIPTS;
008import static jmri.util.FileUtil.SEPARATOR;
009import static jmri.util.FileUtil.SETTINGS;
010
011import com.sun.jna.platform.win32.KnownFolders;
012import com.sun.jna.platform.win32.Shell32Util;
013import com.sun.jna.platform.win32.ShlObj;
014import java.io.BufferedReader;
015import java.io.File;
016import java.io.FileNotFoundException;
017import java.io.FileOutputStream;
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.InputStreamReader;
021import java.io.OutputStreamWriter;
022import java.io.PrintWriter;
023import java.net.MalformedURLException;
024import java.net.URI;
025import java.net.URISyntaxException;
026import java.net.URL;
027import java.nio.charset.StandardCharsets;
028import java.nio.file.FileVisitResult;
029import java.nio.file.Files;
030import java.nio.file.Path;
031import java.nio.file.SimpleFileVisitor;
032import java.nio.file.StandardCopyOption;
033import java.nio.file.attribute.BasicFileAttributes;
034import java.security.CodeSource;
035import java.util.Arrays;
036import java.util.HashMap;
037import java.util.HashSet;
038import java.util.Objects;
039import java.util.Set;
040import java.util.jar.JarFile;
041import java.util.regex.Matcher;
042import java.util.stream.Collectors;
043import javax.annotation.CheckReturnValue;
044import javax.annotation.CheckForNull;
045import javax.annotation.Nonnull;
046
047import jmri.Version;
048import jmri.beans.Bean;
049import jmri.profile.Profile;
050import jmri.profile.ProfileManager;
051import jmri.util.FileUtil.Location;
052import jmri.util.FileUtil.Property;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056/**
057 * Support the {@link jmri.util.FileUtil} static API while providing
058 * {@link java.beans.PropertyChangeSupport} for listening to changes in the
059 * paths. Also provides the underlying implementation of all FileUtil methods so
060 * they can be exposed to scripts as an object methods instead of as static
061 * methods of a class.
062 *
063 * @author Randall Wood (C) 2015, 2016, 2019, 2020
064 */
065public class FileUtilSupport extends Bean {
066
067    /* User's home directory */
068    private static final String HOME_PATH = System.getProperty("user.home") + File.separator; // NOI18N
069    //
070    // Settable directories
071    //
072    /* JMRI program path, defaults to directory JMRI is executed from */
073    private String programPath = null;
074    /* path to jmri.jar */
075    private String jarPath = null;
076    /* path to the jython scripts directory */
077    private final HashMap<Profile, String> scriptsPaths = new HashMap<>();
078    /* path to the user's files directory */
079    private final HashMap<Profile, String> userFilesPaths = new HashMap<>();
080    /* path to profiles in use */
081    private final HashMap<Profile, String> profilePaths = new HashMap<>();
082
083    // initialize logging
084    private static final Logger log = LoggerFactory.getLogger(FileUtilSupport.class);
085
086    private static volatile FileUtilSupport defaultInstance;
087
088    private FileUtilSupport() {
089        super(false);
090    }
091
092    /**
093     * Get the default instance of a FileUtilSupport object.
094     * <p>
095     * Unlike most implementations of getDefault(), this does not return an
096     * object held by {@link jmri.InstanceManager} due to the need for this
097     * default instance to be available prior to the creation of an
098     * InstanceManager.
099     *
100     * @return the default FileUtilSupport instance, creating it if necessary
101     */
102    public static FileUtilSupport getDefault() {
103        // 1st pass non-synchronized for lower latency
104        FileUtilSupport checkingInstance = FileUtilSupport.defaultInstance;
105        if ( checkingInstance != null ) {
106            return checkingInstance;
107        }
108
109        // if null, synchronize
110        synchronized ( FileUtilSupport.class ) {
111            if ( FileUtilSupport.defaultInstance == null ) {
112                FileUtilSupport.defaultInstance = new FileUtilSupport();
113            }
114        }
115        return FileUtilSupport.defaultInstance;
116    }
117
118    // for use in JUnitUtil #resetFileUtilSupport
119    protected static void resetInstance() {
120        synchronized ( FileUtilSupport.class ) {
121            FileUtilSupport.defaultInstance = new FileUtilSupport();
122        }
123    }
124
125    /**
126     * Get the {@link java.io.File} that path refers to. Throws a
127     * {@link java.io.FileNotFoundException} if the file cannot be found instead
128     * of returning null (as File would). Use {@link #getURI(java.lang.String) }
129     * or {@link #getURL(java.lang.String) } instead of this method if possible.
130     *
131     * @param path the path to find
132     * @return {@link java.io.File} at path
133     * @throws java.io.FileNotFoundException if path cannot be found
134     * @see #getURI(java.lang.String)
135     * @see #getURL(java.lang.String)
136     */
137    @Nonnull
138    @CheckReturnValue
139    public File getFile(@Nonnull String path) throws FileNotFoundException {
140        return getFile(ProfileManager.getDefault().getActiveProfile(), path);
141    }
142
143    /**
144     * Get the {@link java.io.File} that path refers to. Throws a
145     * {@link java.io.FileNotFoundException} if the file cannot be found instead
146     * of returning null (as File would). Use {@link #getURI(java.lang.String) }
147     * or {@link #getURL(java.lang.String) } instead of this method if possible.
148     *
149     * @param profile the profile to use as a base
150     * @param path    the path to find
151     * @return {@link java.io.File} at path
152     * @throws java.io.FileNotFoundException if path cannot be found
153     * @see #getURI(java.lang.String)
154     * @see #getURL(java.lang.String)
155     */
156    @Nonnull
157    @CheckReturnValue
158    public File getFile(@CheckForNull Profile profile, @Nonnull String path) throws FileNotFoundException {
159        try {
160            return new File(this.pathFromPortablePath(profile, path));
161        } catch (NullPointerException ex) {
162            throw new FileNotFoundException("Cannot find file at " + path);
163        }
164    }
165
166    /**
167     * Get the {@link java.io.File} that path refers to. Throws a
168     * {@link java.io.FileNotFoundException} if the file cannot be found instead
169     * of returning null (as File would).
170     *
171     * @param path the path to find
172     * @return {@link java.io.File} at path
173     * @throws java.io.FileNotFoundException if path cannot be found
174     * @see #getFile(java.lang.String)
175     * @see #getURL(java.lang.String)
176     */
177    @Nonnull
178    @CheckReturnValue
179    public URI getURI(@Nonnull String path) throws FileNotFoundException {
180        return this.getFile(path).toURI();
181    }
182
183    /**
184     * Get the {@link java.net.URL} that path refers to. Throws a
185     * {@link java.io.FileNotFoundException} if the URL cannot be found instead
186     * of returning null.
187     *
188     * @param path the path to find
189     * @return {@link java.net.URL} at path
190     * @throws java.io.FileNotFoundException if path cannot be found
191     * @see #getFile(java.lang.String)
192     * @see #getURI(java.lang.String)
193     */
194    @Nonnull
195    @CheckReturnValue
196    public URL getURL(@Nonnull String path) throws FileNotFoundException {
197        try {
198            return this.getURI(path).toURL();
199        } catch (MalformedURLException ex) {
200            throw new FileNotFoundException("Cannot create URL for file at " + path);
201        }
202    }
203
204    /**
205     * Convenience method to get the {@link java.net.URL} from a
206     * {@link java.net.URI}. Logs errors and returns null if any exceptions are
207     * thrown by the conversion.
208     *
209     * @param uri The URI to convert.
210     * @return URL or null if any errors exist.
211     */
212    @CheckForNull
213    @CheckReturnValue
214    public URL getURL(@Nonnull URI uri) {
215        try {
216            return uri.toURL();
217        } catch (MalformedURLException | IllegalArgumentException ex) {
218            log.warn("Unable to get URL from {}", uri);
219            return null;
220        } catch (NullPointerException ex) {
221            log.warn("Unable to get URL from null object.", ex);
222            return null;
223        }
224    }
225
226    /**
227     * Find all files matching the given name under the given root directory
228     * within both the user and installed file locations.
229     *
230     * @param name the name of the file to find
231     * @param root the relative path to a directory in either or both of the
232     *             user or installed file locations; use a single period
233     *             character to refer to the root of the user or installed file
234     *             locations
235     * @return a set of found files or an empty set if no matching files were
236     *         found
237     * @throws IllegalArgumentException if the name is not a relative path, is
238     *                                  empty, or contains path separators; or
239     *                                  if the root is not a relative path, is
240     *                                  empty, or contains a parent directory
241     *                                  (..)
242     * @throws NullPointerException     if any parameter is null
243     */
244    @Nonnull
245    @CheckReturnValue
246    public Set<File> findFiles(@Nonnull String name, @Nonnull String root) throws IllegalArgumentException {
247        return this.findFiles(name, root, Location.ALL);
248    }
249
250    /**
251     * Find all files matching the given name under the given root directory
252     * within the specified location.
253     *
254     * @param name     the name of the file to find
255     * @param root     the relative path to a directory in either or both of the
256     *                 user or installed file locations; use a single period
257     *                 character to refer to the root of the location
258     * @param location the location to search within
259     * @return a set of found files or an empty set if no matching files were
260     *         found
261     * @throws IllegalArgumentException if the name is not a relative path, is
262     *                                  empty, or contains path separators; if
263     *                                  the root is not a relative path, is
264     *                                  empty, or contains a parent directory
265     *                                  (..); or if the location is
266     *                                  {@link Location#NONE}
267     * @throws NullPointerException     if any parameter is null
268     */
269    @Nonnull
270    @CheckReturnValue
271    public Set<File> findFiles(@Nonnull String name, @Nonnull String root, @Nonnull Location location) {
272        Objects.requireNonNull(name, "name must be nonnull");
273        Objects.requireNonNull(root, "root must be nonnull");
274        Objects.requireNonNull(location, "location must be nonnull");
275        if (location == Location.NONE) {
276            throw new IllegalArgumentException("location must not be NONE");
277        }
278        if (root.isEmpty() || root.contains("..") || root.startsWith("/")) {
279            throw new IllegalArgumentException("root is invalid");
280        }
281        if (name.isEmpty() || name.contains(File.pathSeparator) || name.contains("/")) {
282            throw new IllegalArgumentException("name is invalid");
283        }
284        Set<File> files = new HashSet<>();
285        if (location == Location.INSTALLED || location == Location.ALL) {
286            files.addAll(this.findFiles(name, new File(this.findURI(PROGRAM + root, Location.NONE))));
287        }
288        if (location == Location.USER || location == Location.ALL) {
289            try {
290                files.addAll(this.findFiles(name, new File(this.findURI(PREFERENCES + root, Location.NONE))));
291            } catch (NullPointerException ex) {
292                // expected if path PREFERENCES + root does not exist
293                log.trace("{} does not exist in {}", root, PREFERENCES);
294            }
295            try {
296                files.addAll(this.findFiles(name, new File(this.findURI(PROFILE + root, Location.NONE))));
297            } catch (NullPointerException ex) {
298                // expected if path PROFILE + root does not exist
299                log.trace("{} does not exist in {}", root, PROFILE);
300            }
301            try {
302                files.addAll(this.findFiles(name, new File(this.findURI(SETTINGS + root, Location.NONE))));
303            } catch (NullPointerException ex) {
304                // expected if path SETTINGS + root does not exist
305                log.trace("{} does not exist in {}", root, SETTINGS);
306            }
307        }
308        return files;
309    }
310
311    private Set<File> findFiles(String name, File root) {
312        Set<File> files = new HashSet<>();
313        if (root.isDirectory()) {
314            try {
315                Files.walkFileTree(root.toPath(), new SimpleFileVisitor<Path>() {
316                    @Override
317                    public FileVisitResult preVisitDirectory(final Path dir,
318                            final BasicFileAttributes attrs) throws IOException {
319
320                        Path fn = dir.getFileName();
321                        if (fn != null && name.equals(fn.toString())) {
322                            files.add(dir.toFile().getCanonicalFile());
323                        }
324                        return FileVisitResult.CONTINUE;
325                    }
326
327                    @Override
328                    public FileVisitResult visitFile(final Path file,
329                            final BasicFileAttributes attrs) throws IOException {
330                        // TODO: accept glob patterns
331                        Path fn = file.getFileName();
332                        if (fn != null && name.equals(fn.toString())) {
333                            files.add(file.toFile().getCanonicalFile());
334                        }
335                        return FileVisitResult.CONTINUE;
336                    }
337                });
338            } catch (IOException ex) {
339                log.warn("Exception while finding file {} in {}", name, root, ex);
340            }
341        }
342        return files;
343    }
344
345    /**
346     * Get the resource file corresponding to a name. There are five cases:
347     * <ul>
348     * <li>Starts with "resource:", treat the rest as a pathname relative to the
349     * program directory (deprecated; see "program:" below)</li>
350     * <li>Starts with "program:", treat the rest as a relative pathname below
351     * the program directory</li>
352     * <li>Starts with "preference:", treat the rest as a relative path below
353     * the user's files directory</li>
354     * <li>Starts with "settings:", treat the rest as a relative path below the
355     * JMRI system preferences directory</li>
356     * <li>Starts with "home:", treat the rest as a relative path below the
357     * user.home directory</li>
358     * <li>Starts with "file:", treat the rest as a relative path below the
359     * resource directory in the preferences directory (deprecated; see
360     * "preference:" above)</li>
361     * <li>Starts with "profile:", treat the rest as a relative path below the
362     * profile directory as specified in the
363     * active{@link jmri.profile.Profile}</li>
364     * <li>Starts with "scripts:", treat the rest as a relative path below the
365     * scripts directory</li>
366     * <li>Otherwise, treat the name as a relative path below the program
367     * directory</li>
368     * </ul>
369     * In any case, absolute pathnames will work. Uses the Profile returned by
370     * {@link ProfileManager#getActiveProfile()} as the base.
371     *
372     * @param pName the name, possibly starting with file:, home:, profile:,
373     *              program:, preference:, scripts:, settings, or resource:
374     * @return Absolute file name to use. This will include system-specific file
375     *         separators.
376     * @since 2.7.2
377     */
378    @Nonnull
379    @CheckReturnValue
380    public String getExternalFilename(@Nonnull String pName) {
381        return getExternalFilename(ProfileManager.getDefault().getActiveProfile(), pName);
382    }
383
384    /**
385     * Get the resource file corresponding to a name. There are five cases:
386     * <ul>
387     * <li>Starts with "resource:", treat the rest as a pathname relative to the
388     * program directory (deprecated; see "program:" below)</li>
389     * <li>Starts with "program:", treat the rest as a relative pathname below
390     * the program directory</li>
391     * <li>Starts with "preference:", treat the rest as a relative path below
392     * the user's files directory</li>
393     * <li>Starts with "settings:", treat the rest as a relative path below the
394     * JMRI system preferences directory</li>
395     * <li>Starts with "home:", treat the rest as a relative path below the
396     * user.home directory</li>
397     * <li>Starts with "file:", treat the rest as a relative path below the
398     * resource directory in the preferences directory (deprecated; see
399     * "preference:" above)</li>
400     * <li>Starts with "profile:", treat the rest as a relative path below the
401     * profile directory as specified in the
402     * active{@link jmri.profile.Profile}</li>
403     * <li>Starts with "scripts:", treat the rest as a relative path below the
404     * scripts directory</li>
405     * <li>Otherwise, treat the name as a relative path below the program
406     * directory</li>
407     * </ul>
408     * In any case, absolute pathnames will work.
409     *
410     * @param profile the Profile to use as a base
411     * @param pName   the name, possibly starting with file:, home:, profile:,
412     *                program:, preference:, scripts:, settings, or resource:
413     * @return Absolute file name to use. This will include system-specific file
414     *         separators.
415     * @since 4.17.3
416     */
417    @Nonnull
418    @CheckReturnValue
419    public String getExternalFilename(@CheckForNull Profile profile, @Nonnull String pName) {
420        String filename = this.pathFromPortablePath(profile, pName);
421        return (filename != null) ? filename : pName.replace(SEPARATOR, File.separatorChar);
422    }
423
424    /**
425     * Convert a portable filename into an absolute filename, using
426     * {@link jmri.profile.ProfileManager#getActiveProfile()} as the base.
427     *
428     * @param path the portable filename
429     * @return An absolute filename
430     */
431    @Nonnull
432    @CheckReturnValue
433    public String getAbsoluteFilename(@Nonnull String path) {
434        return this.getAbsoluteFilename(ProfileManager.getDefault().getActiveProfile(), path);
435    }
436
437    /**
438     * Convert a portable filename into an absolute filename.
439     *
440     * @param profile the profile to use as the base
441     * @param path    the portable filename
442     * @return An absolute filename
443     */
444    @Nonnull
445    @CheckReturnValue
446    public String getAbsoluteFilename(@CheckForNull Profile profile, @Nonnull String path) {
447        return this.pathFromPortablePath(profile, path);
448    }
449
450    /**
451     * Convert a File object's path to our preferred storage form.
452     * <p>
453     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
454     * are not created.
455     *
456     * @param file File at path to be represented
457     * @return Filename for storage in a portable manner. This will include
458     *         portable, not system-specific, file separators.
459     * @since 2.7.2
460     */
461    @Nonnull
462    @CheckReturnValue
463    public String getPortableFilename(@Nonnull File file) {
464        return this.getPortableFilename(ProfileManager.getDefault().getActiveProfile(), file, false, false);
465    }
466
467    /**
468     * Convert a File object's path to our preferred storage form.
469     * <p>
470     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
471     * are not created.
472     * <p>
473     * This method supports a specific use case concerning profiles and other
474     * portable paths that are stored within the User files directory, which
475     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
476     * path for the current profile or
477     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
478     * for the Users file directory. In most cases, the use of
479     * {@link #getPortableFilename(java.io.File)} is preferable.
480     *
481     * @param file                File at path to be represented
482     * @param ignoreUserFilesPath true if paths in the User files path should be
483     *                            stored as absolute paths, which is often not
484     *                            desirable.
485     * @param ignoreProfilePath   true if paths in the profile should be stored
486     *                            as absolute paths, which is often not
487     *                            desirable.
488     * @return Storage format representation
489     * @since 3.5.5
490     */
491    @Nonnull
492    @CheckReturnValue
493    public String getPortableFilename(@Nonnull File file, boolean ignoreUserFilesPath, boolean ignoreProfilePath) {
494        return getPortableFilename(ProfileManager.getDefault().getActiveProfile(), file, ignoreUserFilesPath, ignoreProfilePath);
495    }
496
497    /**
498     * Convert a filename string to our preferred storage form.
499     * <p>
500     * This is the inverse of {@link #getExternalFilename(String pName)}.
501     * Deprecated forms are not created.
502     *
503     * @param filename Filename to be represented
504     * @return Filename for storage in a portable manner
505     * @since 2.7.2
506     */
507    @Nonnull
508    @CheckReturnValue
509    public String getPortableFilename(@Nonnull String filename) {
510        return getPortableFilename(ProfileManager.getDefault().getActiveProfile(), filename, false, false);
511    }
512
513    /**
514     * Convert a filename string to our preferred storage form.
515     * <p>
516     * This is the inverse of {@link #getExternalFilename(String pName)}.
517     * Deprecated forms are not created.
518     * <p>
519     * This method supports a specific use case concerning profiles and other
520     * portable paths that are stored within the User files directory, which
521     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
522     * path for the current profile or
523     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
524     * for the Users file directory. In most cases, the use of
525     * {@link #getPortableFilename(java.io.File)} is preferable.
526     *
527     * @param filename            Filename to be represented
528     * @param ignoreUserFilesPath true if paths in the User files path should be
529     *                            stored as absolute paths, which is often not
530     *                            desirable.
531     * @param ignoreProfilePath   true if paths in the profile path should be
532     *                            stored as absolute paths, which is often not
533     *                            desirable.
534     * @return Storage format representation
535     * @since 3.5.5
536     */
537    @Nonnull
538    @CheckReturnValue
539    public String getPortableFilename(@Nonnull String filename, boolean ignoreUserFilesPath, boolean ignoreProfilePath) {
540        if (this.isPortableFilename(filename)) {
541            // if this already contains prefix, run through conversion to normalize
542            return getPortableFilename(getExternalFilename(filename), ignoreUserFilesPath, ignoreProfilePath);
543        } else {
544            // treat as pure filename
545            return getPortableFilename(new File(filename), ignoreUserFilesPath, ignoreProfilePath);
546        }
547    }
548
549    /**
550     * Convert a File object's path to our preferred storage form.
551     * <p>
552     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
553     * are not created.
554     *
555     * @param profile Profile to use as base
556     * @param file    File at path to be represented
557     * @return Filename for storage in a portable manner. This will include
558     *         portable, not system-specific, file separators.
559     * @since 4.17.3
560     */
561    @Nonnull
562    @CheckReturnValue
563    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull File file) {
564        return this.getPortableFilename(profile, file, false, false);
565    }
566
567    /**
568     * Convert a File object's path to our preferred storage form.
569     * <p>
570     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
571     * are not created.
572     * <p>
573     * This method supports a specific use case concerning profiles and other
574     * portable paths that are stored within the User files directory, which
575     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
576     * path for the current profile or
577     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
578     * for the Users file directory. In most cases, the use of
579     * {@link #getPortableFilename(java.io.File)} is preferable.
580     *
581     * @param profile             Profile to use as base
582     * @param file                File at path to be represented
583     * @param ignoreUserFilesPath true if paths in the User files path should be
584     *                            stored as absolute paths, which is often not
585     *                            desirable.
586     * @param ignoreProfilePath   true if paths in the profile should be stored
587     *                            as absolute paths, which is often not
588     *                            desirable.
589     * @return Storage format representation
590     * @since 3.5.5
591     */
592    @Nonnull
593    @CheckReturnValue
594    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull File file, boolean ignoreUserFilesPath, boolean ignoreProfilePath) {
595        // compare full path name to see if same as preferences
596        String filename = file.getAbsolutePath();
597
598        // append separator if file is a directory
599        if (file.isDirectory()) {
600            filename = filename + File.separator;
601        }
602
603        if (filename == null) {
604            throw new IllegalArgumentException("File \"" + file + "\" has a null absolute path which is not allowed");
605        }
606
607        // compare full path name to see if same as preferences
608        if (!ignoreUserFilesPath) {
609            if (filename.startsWith(getUserFilesPath(profile))) {
610                return PREFERENCES
611                        + filename.substring(getUserFilesPath(profile).length(), filename.length()).replace(File.separatorChar,
612                                SEPARATOR);
613            }
614        }
615
616        if (!ignoreProfilePath) {
617            // compare full path name to see if same as profile
618            if (filename.startsWith(getProfilePath(profile))) {
619                return PROFILE
620                        + filename.substring(getProfilePath(profile).length(), filename.length()).replace(File.separatorChar,
621                                SEPARATOR);
622            }
623        }
624
625        // compare full path name to see if same as settings
626        if (filename.startsWith(getPreferencesPath())) {
627            return SETTINGS
628                    + filename.substring(getPreferencesPath().length(), filename.length()).replace(File.separatorChar,
629                            SEPARATOR);
630        }
631
632        if (!ignoreUserFilesPath) {
633            /*
634             * The tests for any portatable path that could be within the
635             * UserFiles locations needs to be within this block. This prevents
636             * the UserFiles or Profile path from being set to another portable
637             * path that is user settable.
638             *
639             * Note that this test should be after the UserFiles, Profile, and
640             * Preferences tests.
641             */
642            // check for relative to scripts dir
643            if (filename.startsWith(getScriptsPath(profile)) && !filename.equals(getScriptsPath(profile))) {
644                return SCRIPTS
645                        + filename.substring(getScriptsPath(profile).length(), filename.length()).replace(File.separatorChar,
646                                SEPARATOR);
647            }
648        }
649
650        // now check for relative to program dir
651        if (filename.startsWith(getProgramPath())) {
652            return PROGRAM
653                    + filename.substring(getProgramPath().length(), filename.length()).replace(File.separatorChar,
654                            SEPARATOR);
655        }
656
657        // compare full path name to see if same as home directory
658        // do this last, in case preferences or program dir are in home directory
659        if (filename.startsWith(getHomePath())) {
660            return HOME
661                    + filename.substring(getHomePath().length(), filename.length()).replace(File.separatorChar,
662                            SEPARATOR);
663        }
664
665        return filename.replace(File.separatorChar, SEPARATOR); // absolute, and doesn't match; not really portable...
666    }
667
668    /**
669     * Convert a filename string to our preferred storage form.
670     * <p>
671     * This is the inverse of {@link #getExternalFilename(String pName)}.
672     * Deprecated forms are not created.
673     *
674     * @param profile  Profile to use as base
675     * @param filename Filename to be represented
676     * @return Filename for storage in a portable manner
677     * @since 4.17.3
678     */
679    @Nonnull
680    @CheckReturnValue
681    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull String filename) {
682        return getPortableFilename(profile, filename, false, false);
683    }
684
685    /**
686     * Convert a filename string to our preferred storage form.
687     * <p>
688     * This is the inverse of {@link #getExternalFilename(String pName)}.
689     * Deprecated forms are not created.
690     * <p>
691     * This method supports a specific use case concerning profiles and other
692     * portable paths that are stored within the User files directory, which
693     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
694     * path for the current profile or
695     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
696     * for the Users file directory. In most cases, the use of
697     * {@link #getPortableFilename(java.io.File)} is preferable.
698     *
699     * @param profile             Profile to use as base
700     * @param filename            Filename to be represented
701     * @param ignoreUserFilesPath true if paths in the User files path should be
702     *                            stored as absolute paths, which is often not
703     *                            desirable.
704     * @param ignoreProfilePath   true if paths in the profile path should be
705     *                            stored as absolute paths, which is often not
706     *                            desirable.
707     * @return Storage format representation
708     * @since 4.17.3
709     */
710    @Nonnull
711    @CheckReturnValue
712    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull String filename, boolean ignoreUserFilesPath,
713            boolean ignoreProfilePath) {
714        if (isPortableFilename(filename)) {
715            // if this already contains prefix, run through conversion to normalize
716            return getPortableFilename(profile, getExternalFilename(filename), ignoreUserFilesPath, ignoreProfilePath);
717        } else {
718            // treat as pure filename
719            return getPortableFilename(profile, new File(filename), ignoreUserFilesPath, ignoreProfilePath);
720        }
721    }
722
723    /**
724     * Test if the given filename is a portable filename.
725     * <p>
726     * Note that this method may return a false positive if the filename is a
727     * file: URL.
728     *
729     * @param filename the name to test
730     * @return true if filename is portable
731     */
732    public boolean isPortableFilename(@Nonnull String filename) {
733        return (filename.startsWith(PROGRAM)
734                || filename.startsWith(HOME)
735                || filename.startsWith(PREFERENCES)
736                || filename.startsWith(SCRIPTS)
737                || filename.startsWith(PROFILE)
738                || filename.startsWith(SETTINGS));
739    }
740
741    /**
742     * Get the user's home directory.
743     *
744     * @return User's home directory as a String
745     */
746    @Nonnull
747    @CheckReturnValue
748    public String getHomePath() {
749        return HOME_PATH;
750    }
751
752    /**
753     * Get the user's files directory. If not set by the user, this is the same
754     * as the profile path returned by
755     * {@link ProfileManager#getActiveProfile()}. Note that if the profile path
756     * has been set to null, that returns the preferences directory, see
757     * {@link #getProfilePath()}.
758     *
759     * @see #getProfilePath()
760     * @return User's files directory
761     */
762    @Nonnull
763    @CheckReturnValue
764    public String getUserFilesPath() {
765        return getUserFilesPath(ProfileManager.getDefault().getActiveProfile());
766    }
767
768    /**
769     * Get the user's files directory. If not set by the user, this is the same
770     * as the profile path. Note that if the profile path has been set to null,
771     * that returns the preferences directory, see {@link #getProfilePath()}.
772     *
773     * @param profile the profile to use
774     * @see #getProfilePath()
775     * @return User's files directory
776     */
777    @Nonnull
778    @CheckReturnValue
779    public String getUserFilesPath(@CheckForNull Profile profile) {
780        String path = userFilesPaths.get(profile);
781        return path != null ? path : getProfilePath(profile);
782    }
783
784    /**
785     * Set the user's files directory.
786     *
787     * @see #getUserFilesPath()
788     * @param profile the profile to set the user's files directory for
789     * @param path    The path to the user's files directory using
790     *                system-specific separators
791     */
792    public void setUserFilesPath(@CheckForNull Profile profile, @Nonnull String path) {
793        String old = userFilesPaths.get(profile);
794        if (!path.endsWith(File.separator)) {
795            path = path + File.separator;
796        }
797        userFilesPaths.put(profile, path);
798        if ((old != null && !old.equals(path)) || (!path.equals(old))) {
799            this.firePropertyChange(FileUtil.PREFERENCES, new Property(profile, old), new Property(profile, path));
800        }
801    }
802
803    /**
804     * Get the profile directory. If not set, provide the preferences path.
805     *
806     * @param profile the Profile to use as a base
807     * @see #getPreferencesPath()
808     * @return Profile directory using system-specific separators
809     */
810    @Nonnull
811    @CheckReturnValue
812    public String getProfilePath(@CheckForNull Profile profile) {
813        String path = profilePaths.get(profile);
814        if (path == null) {
815            File f = profile != null ? profile.getPath() : null;
816            if (f != null) {
817                path = f.getAbsolutePath();
818                if (!path.endsWith(File.separator)) {
819                    path = path + File.separator;
820                }
821                profilePaths.put(profile, path);
822            }
823        }
824        return (path != null) ? path : this.getPreferencesPath();
825    }
826
827    /**
828     * Get the profile directory. If not set, provide the preferences path. Uses
829     * the Profile returned by {@link ProfileManager#getActiveProfile()} as a
830     * base.
831     *
832     * @see #getPreferencesPath()
833     * @return Profile directory using system-specific separators
834     */
835    @Nonnull
836    @CheckReturnValue
837    public String getProfilePath() {
838        return getProfilePath(ProfileManager.getDefault().getActiveProfile());
839    }
840
841    /**
842     * Get the preferences directory. This directory is set based on the OS and
843     * is not normally settable by the user.
844     * <ul>
845     * <li>On Microsoft Windows systems, this is {@code JMRI} in the User's home
846     * directory.</li>
847     * <li>On OS X systems, this is {@code Library/Preferences/JMRI} in the
848     * User's home directory.</li>
849     * <li>On Linux, Solaris, and other UNIXes, this is {@code .jmri} in the
850     * User's home directory.</li>
851     * <li>This can be overridden with by setting the {@code jmri.prefsdir} Java
852     * property when starting JMRI.</li>
853     * </ul>
854     * Use {@link #getHomePath()} to get the User's home directory.
855     *
856     * @see #getHomePath()
857     * @return Path to the preferences directory using system-specific
858     *         separators.
859     */
860    @Nonnull
861    @CheckReturnValue
862    public String getPreferencesPath() {
863        // return jmri.prefsdir property if present
864        String jmriPrefsDir = System.getProperty("jmri.prefsdir", ""); // NOI18N
865        if (!jmriPrefsDir.isEmpty()) {
866            try {
867                return new File(jmriPrefsDir).getCanonicalPath() + File.separator;
868            } catch (IOException ex) {
869                // use System.err because logging at this point will fail
870                // since this method is called to setup logging
871                System.err.println("Unable to locate settings dir \"" + jmriPrefsDir + "\"");
872                if (!jmriPrefsDir.endsWith(File.separator)) {
873                    return jmriPrefsDir + File.separator;
874                }
875            }
876        }
877        String result;
878        switch (SystemType.getType()) {
879            case SystemType.MACOSX:
880                // Mac OS X
881                result = this.getHomePath() + "Library" + File.separator + "Preferences" + File.separator + "JMRI" + File.separator; // NOI18N
882                break;
883            case SystemType.LINUX:
884            case SystemType.UNIX:
885                // Linux, so use an invisible file
886                result = this.getHomePath() + ".jmri" + File.separator; // NOI18N
887                break;
888            case SystemType.WINDOWS:
889            default:
890                // Could be Windows, other
891                result = this.getHomePath() + "JMRI" + File.separator; // NOI18N
892                break;
893        }
894        // logging here merely throws warnings since we call this method to set up logging
895        // uncomment below to print OS default to console
896        // System.out.println("preferencesPath defined as \"" + result + "\" based on os.name=\"" + SystemType.getOSName() + "\"");
897        return result;
898    }
899
900    /**
901     * Get the JMRI cache location, ensuring its existence.
902     * <p>
903     * This is <strong>not</strong> part of the {@link jmri.util.FileUtil} API
904     * since it should generally be accessed using
905     * {@link jmri.profile.ProfileUtils#getCacheDirectory(jmri.profile.Profile, java.lang.Class)}.
906     * <p>
907     * Uses the following locations (where [version] is from
908     * {@link jmri.Version#getCanonicalVersion()}):
909     * <dl>
910     * <dt>System Property (if set)</dt><dd>value of
911     * <em>jmri_default_cachedir</em></dd>
912     * <dt>macOS</dt><dd>~/Library/Caches/JMRI/[version]</dd>
913     * <dt>Windows</dt><dd>%Local AppData%/JMRI/[version]</dd>
914     * <dt>UNIX/Linux/POSIX</dt><dd>${XDG_CACHE_HOME}/JMRI/[version] or
915     * $HOME/.cache/JMRI/[version]</dd>
916     * <dt>Fallback</dt><dd>JMRI portable path
917     * <em>setting:cache/[version]</em></dd>
918     * </dl>
919     *
920     * @return the cache directory for this version of JMRI
921     */
922    @Nonnull
923    public File getCacheDirectory() {
924        File cache;
925        String property = System.getProperty("jmri_default_cachedir");
926        if (property != null) {
927            cache = new File(property);
928        } else {
929            switch (SystemType.getType()) {
930                case SystemType.MACOSX:
931                    cache = new File(new File(this.getHomePath(), "Library/Caches/JMRI"), Version.getCanonicalVersion());
932                    break;
933                case SystemType.LINUX:
934                case SystemType.UNIX:
935                    property = System.getenv("XDG_CACHE_HOME");
936                    if (property != null) {
937                        cache = new File(new File(property, "JMRI"), Version.getCanonicalVersion());
938                    } else {
939                        cache = new File(new File(this.getHomePath(), ".cache/JMRI"), Version.getCanonicalVersion());
940                    }
941                    break;
942                case SystemType.WINDOWS:
943                    try {
944                        cache = new File(new File(Shell32Util.getKnownFolderPath(KnownFolders.FOLDERID_LocalAppData), "JMRI/cache"), Version.getCanonicalVersion());
945                    } catch (UnsatisfiedLinkError er) {
946                        // Needed only on Windows XP
947                        cache = new File(new File(Shell32Util.getFolderPath(ShlObj.CSIDL_LOCAL_APPDATA), "JMRI/cache"), Version.getCanonicalVersion());
948                    }
949                    break;
950                default:
951                    // fallback
952                    cache = new File(new File(this.getPreferencesPath(), "cache"), Version.getCanonicalVersion());
953                    break;
954            }
955        }
956        this.createDirectory(cache);
957        return cache;
958    }
959
960    /**
961     * Get the JMRI program directory.
962     * <p>
963     * If the program directory has not been
964     * previously set, first sets the program directory to the value specified
965     * in the Java System property <code>jmri.path.program</code>
966     * <p>
967     * If this property is unset, finds from jar or class files location.
968     * <p>
969     * If this fails, returns <code>.</code> .
970     *
971     * @return JMRI program directory as a String.
972     */
973    @Nonnull
974    @CheckReturnValue
975    public String getProgramPath() {
976        // As this method is called in Log4J setup, should not
977        // contain standard logging statements.
978        if (programPath == null) {
979            if (System.getProperty("jmri.path.program") == null) {
980                // find from jar or class files location
981                String path1 = this.getClass().getProtectionDomain().getCodeSource().getLocation().getPath();
982                String path2 = (new File(path1)).getParentFile().getPath();
983                path2 = path2.replaceAll("\\+", "%2B"); // convert + chars to UTF-8 to get through the decode
984                try {
985                    String loadingDir = java.net.URLDecoder.decode(path2, "UTF-8");
986                    if (loadingDir.endsWith("target")) {
987                        loadingDir = loadingDir.substring(0, loadingDir.length()-6);
988                    }
989                     this.setProgramPath(loadingDir); // NOI18N
990               } catch (java.io.UnsupportedEncodingException e) {
991                    System.err.println("Unsupported URL when trying to locate program directory: " + path2 );
992                    // best guess
993                    this.setProgramPath("."); // NOI18N
994                }
995            } else {
996                this.setProgramPath(System.getProperty("jmri.path.program", ".")); // NOI18N
997            }
998        }
999        return programPath;
1000    }
1001
1002    /**
1003     * Set the JMRI program directory.
1004     * <p>
1005     * Convenience method that calls {@link #setProgramPath(java.io.File)} with
1006     * the passed in path.
1007     *
1008     * @param path the path to the JMRI installation
1009     */
1010    public void setProgramPath(@Nonnull String path) {
1011        this.setProgramPath(new File(path));
1012    }
1013
1014    /**
1015     * Set the JMRI program directory.
1016     * <p>
1017     * If set, allows JMRI to be loaded from locations other than the directory
1018     * containing JMRI resources. This must be set very early in the process of
1019     * loading JMRI (prior to loading any other JMRI code) to be meaningfully
1020     * used.
1021     *
1022     * @param path the path to the JMRI installation
1023     */
1024    public void setProgramPath(@Nonnull File path) {
1025        String old = this.programPath;
1026        try {
1027            this.programPath = (path).getCanonicalPath() + File.separator;
1028        } catch (IOException ex) {
1029            log.error("Unable to get JMRI program directory.", ex);
1030        }
1031        if ((old != null && !old.equals(this.programPath))
1032                || (this.programPath != null && !this.programPath.equals(old))) {
1033            this.firePropertyChange(FileUtil.PROGRAM, old, this.programPath);
1034        }
1035    }
1036
1037    /**
1038     * Get the resources directory within the user's files directory.
1039     *
1040     * @return path to [user's file]/resources/ using system-specific separators
1041     */
1042    @Nonnull
1043    @CheckReturnValue
1044    public String getUserResourcePath() {
1045        return this.getUserFilesPath() + "resources" + File.separator; // NOI18N
1046    }
1047
1048    /**
1049     * Log all paths at the INFO level.
1050     */
1051    public void logFilePaths() {
1052        log.info("File path {} is {}", FileUtil.PROGRAM, this.getProgramPath());
1053        log.info("File path {} is {}", FileUtil.PREFERENCES, this.getUserFilesPath());
1054        log.info("File path {} is {}", FileUtil.PROFILE, this.getProfilePath());
1055        log.info("File path {} is {}", FileUtil.SETTINGS, this.getPreferencesPath());
1056        log.info("File path {} is {}", FileUtil.HOME, this.getHomePath());
1057        log.info("File path {} is {}", FileUtil.SCRIPTS, this.getScriptsPath());
1058    }
1059
1060    /**
1061     * Get the path to the scripts directory. If not set previously with
1062     * {@link #setScriptsPath}, this is the "jython" subdirectory in the program
1063     * directory. Uses the Profile returned by
1064     * {@link ProfileManager#getActiveProfile()} as the base.
1065     *
1066     * @return the scripts directory using system-specific separators
1067     */
1068    @Nonnull
1069    @CheckReturnValue
1070    public String getScriptsPath() {
1071        return getScriptsPath(ProfileManager.getDefault().getActiveProfile());
1072    }
1073
1074    /**
1075     * Get the path to the scripts directory. If not set previously with
1076     * {@link #setScriptsPath}, this is the "jython" subdirectory in the program
1077     * directory.
1078     *
1079     * @param profile the Profile to use as the base
1080     * @return the path to scripts directory using system-specific separators
1081     */
1082    @Nonnull
1083    @CheckReturnValue
1084    public String getScriptsPath(@CheckForNull Profile profile) {
1085        String path = scriptsPaths.get(profile);
1086        if (path != null) {
1087            return path;
1088        }
1089        // scripts directory not set by user, return default if it exists
1090        File file = new File(this.getProgramPath() + File.separator + "jython" + File.separator); // NOI18N
1091        if (file.exists() && file.isDirectory()) {
1092            return file.getPath() + File.separator;
1093        }
1094        // if default does not exist, return user's files directory
1095        return this.getUserFilesPath();
1096    }
1097
1098    /**
1099     * Set the path to python scripts.
1100     *
1101     * @param profile the profile to use as a base
1102     * @param path    the scriptsPaths to set; null resets to the default,
1103     *                defined in {@link #getScriptsPath()}
1104     */
1105    public void setScriptsPath(@CheckForNull Profile profile, @CheckForNull String path) {
1106        String old = scriptsPaths.get(profile);
1107        if (path != null && !path.endsWith(File.separator)) {
1108            path = path + File.separator;
1109        }
1110        scriptsPaths.put(profile, path);
1111        if ((old != null && !old.equals(path)) || (path != null && !path.equals(old))) {
1112            this.firePropertyChange(FileUtil.SCRIPTS, new Property(profile, old), new Property(profile, path));
1113        }
1114    }
1115
1116    /**
1117     * Get the URL of a portable filename if it can be located using
1118     * {@link #findURI(java.lang.String)}
1119     *
1120     * @param path the path to find
1121     * @return URL of portable or absolute path
1122     */
1123    @Nonnull
1124    @CheckReturnValue
1125    public URI findExternalFilename(@Nonnull String path) {
1126        log.debug("Finding external path {}", path);
1127        if (this.isPortableFilename(path)) {
1128            int index = path.indexOf(":") + 1;
1129            String location = path.substring(0, index);
1130            path = path.substring(index);
1131            log.debug("Finding {} and {}", location, path);
1132            switch (location) {
1133                case FileUtil.PROGRAM:
1134                    return this.findURI(path, FileUtil.Location.INSTALLED);
1135                case FileUtil.PREFERENCES:
1136                    return this.findURI(path, FileUtil.Location.USER);
1137                case FileUtil.PROFILE:
1138                case FileUtil.SETTINGS:
1139                case FileUtil.SCRIPTS:
1140                case FileUtil.HOME:
1141                    return this.findURI(this.getExternalFilename(location + path));
1142                default:
1143                    break;
1144            }
1145        }
1146        return this.findURI(path, Location.ALL);
1147    }
1148
1149    /**
1150     * Search for a file or JAR resource by name and return the
1151     * {@link java.io.InputStream} for that file. Search order is defined by
1152     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1153     * No limits are placed on search locations.
1154     *
1155     * @param path The relative path of the file or resource
1156     * @return InputStream or null.
1157     * @see #findInputStream(java.lang.String, java.lang.String...)
1158     * @see #findInputStream(java.lang.String, jmri.util.FileUtil.Location,
1159     * java.lang.String...)
1160     * @see #findURL(java.lang.String)
1161     * @see #findURL(java.lang.String, java.lang.String...)
1162     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1163     * java.lang.String...)
1164     */
1165    public InputStream findInputStream(@Nonnull String path) {
1166        return this.findInputStream(path, new String[]{});
1167    }
1168
1169    /**
1170     * Search for a file or JAR resource by name and return the
1171     * {@link java.io.InputStream} for that file. Search order is defined by
1172     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1173     * No limits are placed on search locations.
1174     *
1175     * @param path        The relative path of the file or resource
1176     * @param searchPaths a list of paths to search for the path in
1177     * @return InputStream or null.
1178     * @see #findInputStream(java.lang.String)
1179     * @see #findInputStream(java.lang.String, jmri.util.FileUtil.Location,
1180     * java.lang.String...)
1181     */
1182    public InputStream findInputStream(@Nonnull String path, @Nonnull String... searchPaths) {
1183        return this.findInputStream(path, Location.ALL, searchPaths);
1184    }
1185
1186    /**
1187     * Search for a file or JAR resource by name and return the
1188     * {@link java.io.InputStream} for that file. Search order is defined by
1189     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1190     *
1191     * @param path      The relative path of the file or resource
1192     * @param locations The type of locations to limit the search to
1193     * @return InputStream or null.
1194     * @see #findInputStream(java.lang.String)
1195     * @see #findInputStream(java.lang.String, jmri.util.FileUtil.Location,
1196     * java.lang.String...)
1197     */
1198    public InputStream findInputStream(@Nonnull String path, @Nonnull Location locations) {
1199        return this.findInputStream(path, locations, new String[]{});
1200    }
1201
1202    /**
1203     * Search for a file or JAR resource by name and return the
1204     * {@link java.io.InputStream} for that file. Search order is defined by
1205     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1206     *
1207     * @param path        The relative path of the file or resource
1208     * @param locations   The type of locations to limit the search to
1209     * @param searchPaths a list of paths to search for the path in
1210     * @return InputStream or null.
1211     * @see #findInputStream(java.lang.String)
1212     * @see #findInputStream(java.lang.String, java.lang.String...)
1213     */
1214    public InputStream findInputStream(@Nonnull String path, @Nonnull Location locations, @Nonnull String... searchPaths) {
1215        URL file = this.findURL(path, locations, searchPaths);
1216        if (file != null) {
1217            try {
1218                return file.openStream();
1219            } catch (IOException ex) {
1220                log.error("findInputStream IOException", ex);
1221            }
1222        }
1223        return null;
1224    }
1225
1226    /**
1227     * Search for a file or JAR resource by name and return the
1228     * {@link java.net.URI} for that file. Search order is defined by
1229     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1230     * No limits are placed on search locations.
1231     *
1232     * @param path The relative path of the file or resource.
1233     * @return The URI or null.
1234     * @see #findURI(java.lang.String, java.lang.String...)
1235     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location)
1236     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location,
1237     * java.lang.String...)
1238     */
1239    public URI findURI(@Nonnull String path) {
1240        return this.findURI(path, new String[]{});
1241    }
1242
1243    /**
1244     * Search for a file or JAR resource by name and return the
1245     * {@link java.net.URI} for that file. Search order is defined by
1246     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1247     * No limits are placed on search locations.
1248     * <p>
1249     * Note that if the file for path is not found in one of the searchPaths,
1250     * all standard locations are also be searched through to find the file. If
1251     * you need to limit the locations where the file can be found use
1252     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1253     *
1254     * @param path        The relative path of the file or resource
1255     * @param searchPaths a list of paths to search for the path in
1256     * @return The URI or null
1257     * @see #findURI(java.lang.String)
1258     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location)
1259     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location,
1260     * java.lang.String...)
1261     */
1262    public URI findURI(@Nonnull String path, @Nonnull String... searchPaths) {
1263        return this.findURI(path, Location.ALL, searchPaths);
1264    }
1265
1266    /**
1267     * Search for a file or JAR resource by name and return the
1268     * {@link java.net.URI} for that file. Search order is defined by
1269     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1270     *
1271     * @param path      The relative path of the file or resource
1272     * @param locations The types of locations to limit the search to
1273     * @return The URI or null
1274     * @see #findURI(java.lang.String)
1275     * @see #findURI(java.lang.String, java.lang.String...)
1276     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location,
1277     * java.lang.String...)
1278     */
1279    public URI findURI(@Nonnull String path, @Nonnull Location locations) {
1280        return this.findURI(path, locations, new String[]{});
1281    }
1282
1283    /**
1284     * Search for a file or JAR resource by name and return the
1285     * {@link java.net.URI} for that file.
1286     * <p>
1287     * Search order is:
1288     * <ol>
1289     * <li>For any provided searchPaths, iterate over the searchPaths by
1290     * prepending each searchPath to the path and following the following search
1291     * order:<ol>
1292     * <li>As a {@link java.io.File} in the user preferences directory</li>
1293     * <li>As a File in the current working directory (usually, but not always
1294     * the JMRI distribution directory)</li>
1295     * <li>As a File in the JMRI distribution directory</li>
1296     * <li>As a resource in jmri.jar</li>
1297     * </ol></li>
1298     * <li>If the file or resource has not been found in the searchPaths, search
1299     * in the four locations listed without prepending any path</li>
1300     * <li>As a File with an absolute path</li>
1301     * </ol>
1302     * <p>
1303     * The <code>locations</code> parameter limits the above logic by limiting
1304     * the location searched.
1305     * <ol>
1306     * <li>{@link Location#ALL} will not place any limits on the search</li>
1307     * <li>{@link Location#NONE} effectively requires that <code>path</code> be
1308     * a portable pathname</li>
1309     * <li>{@link Location#INSTALLED} limits the search to the
1310     * {@link FileUtil#PROGRAM} directory and JARs in the class path</li>
1311     * <li>{@link Location#USER} limits the search to the
1312     * {@link FileUtil#PREFERENCES}, {@link FileUtil#PROFILE}, and
1313     * {@link FileUtil#SETTINGS} directories (in that order)</li>
1314     * </ol>
1315     *
1316     * @param path        The relative path of the file or resource
1317     * @param locations   The types of locations to limit the search to
1318     * @param searchPaths a list of paths to search for the path in
1319     * @return The URI or null
1320     * @see #findURI(java.lang.String)
1321     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location)
1322     * @see #findURI(java.lang.String, java.lang.String...)
1323     */
1324    public URI findURI(@Nonnull String path, @Nonnull Location locations, @Nonnull String... searchPaths) {
1325        if (log.isDebugEnabled()) { // avoid the Arrays.toString call unless debugging
1326            log.debug("Attempting to find {} in {}", path, Arrays.toString(searchPaths));
1327        }
1328        if (this.isPortableFilename(path)) {
1329            try {
1330                return this.findExternalFilename(path);
1331            } catch (NullPointerException ex) {
1332                // do nothing
1333            }
1334        }
1335        URI resource = null;
1336        for (String searchPath : searchPaths) {
1337            resource = this.findURI(searchPath + File.separator + path);
1338            if (resource != null) {
1339                return resource;
1340            }
1341        }
1342        File file;
1343        if (locations == Location.ALL || locations == Location.USER) {
1344            // attempt to return path from preferences directory
1345            file = new File(this.getUserFilesPath(), path);
1346            if (file.exists()) {
1347                return file.toURI();
1348            }
1349            // attempt to return path from profile directory
1350            file = new File(this.getProfilePath(), path);
1351            if (file.exists()) {
1352                return file.toURI();
1353            }
1354            // attempt to return path from preferences directory
1355            file = new File(this.getPreferencesPath(), path);
1356            if (file.exists()) {
1357                return file.toURI();
1358            }
1359        }
1360        if (locations == Location.ALL || locations == Location.INSTALLED) {
1361            // attempt to return path from current working directory
1362            file = new File(path);
1363            if (file.exists()) {
1364                return file.toURI();
1365            }
1366            // attempt to return path from JMRI distribution directory
1367            file = new File(this.getProgramPath() + path);
1368            if (file.exists()) {
1369                return file.toURI();
1370            }
1371        }
1372        if (locations == Location.ALL || locations == Location.INSTALLED) {
1373            // return path if in jmri.jar or null
1374            // The ClassLoader needs paths to use /
1375            path = path.replace(File.separatorChar, '/');
1376            URL url = FileUtilSupport.class.getClassLoader().getResource(path);
1377            if (url == null) {
1378                url = FileUtilSupport.class.getResource(path);
1379                if (url == null) {
1380                    log.debug("{} not found in classpath", path);
1381                }
1382            }
1383            try {
1384                resource = (url != null) ? url.toURI() : null;
1385            } catch (URISyntaxException ex) {
1386                log.warn("Unable to get URI for {}", path, ex);
1387            }
1388        }
1389        // if a resource has not been found and path is absolute and exists
1390        // return it
1391        if (resource == null) {
1392            file = new File(path);
1393            if (file.isAbsolute() && file.exists()) {
1394                return file.toURI();
1395            }
1396        }
1397        return resource;
1398    }
1399
1400    /**
1401     * Search for a file or JAR resource by name and return the
1402     * {@link java.net.URL} for that file. Search order is defined by
1403     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1404     * No limits are placed on search locations.
1405     *
1406     * @param path The relative path of the file or resource.
1407     * @return The URL or null.
1408     * @see #findURL(java.lang.String, java.lang.String...)
1409     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location)
1410     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1411     * java.lang.String...)
1412     */
1413    public URL findURL(@Nonnull String path) {
1414        return this.findURL(path, new String[]{});
1415    }
1416
1417    /**
1418     * Search for a file or JAR resource by name and return the
1419     * {@link java.net.URL} for that file. Search order is defined by
1420     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1421     * No limits are placed on search locations.
1422     *
1423     * @param path        The relative path of the file or resource
1424     * @param searchPaths a list of paths to search for the path in
1425     * @return The URL or null
1426     * @see #findURL(java.lang.String)
1427     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location)
1428     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1429     * java.lang.String...)
1430     */
1431    public URL findURL(@Nonnull String path, @Nonnull String... searchPaths) {
1432        return this.findURL(path, Location.ALL, searchPaths);
1433    }
1434
1435    /**
1436     * Search for a file or JAR resource by name and return the
1437     * {@link java.net.URL} for that file. Search order is defined by
1438     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1439     *
1440     * @param path      The relative path of the file or resource
1441     * @param locations The types of locations to limit the search to
1442     * @return The URL or null
1443     * @see #findURL(java.lang.String)
1444     * @see #findURL(java.lang.String, java.lang.String...)
1445     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1446     * java.lang.String...)
1447     */
1448    public URL findURL(@Nonnull String path, Location locations) {
1449        return this.findURL(path, locations, new String[]{});
1450    }
1451
1452    /**
1453     * Search for a file or JAR resource by name and return the
1454     * {@link java.net.URL} for that file.
1455     * <p>
1456     * Search order is:
1457     * <ol><li>For any provided searchPaths, iterate over the searchPaths by
1458     * prepending each searchPath to the path and following the following search
1459     * order:
1460     * <ol><li>As a {@link java.io.File} in the user preferences directory</li>
1461     * <li>As a File in the current working directory (usually, but not always
1462     * the JMRI distribution directory)</li> <li>As a File in the JMRI
1463     * distribution directory</li> <li>As a resource in jmri.jar</li></ol></li>
1464     * <li>If the file or resource has not been found in the searchPaths, search
1465     * in the four locations listed without prepending any path</li></ol>
1466     * <p>
1467     * The <code>locations</code> parameter limits the above logic by limiting
1468     * the location searched.
1469     * <ol><li>{@link Location#ALL} will not place any limits on the
1470     * search</li><li>{@link Location#NONE} effectively requires that
1471     * <code>path</code> be a portable
1472     * pathname</li><li>{@link Location#INSTALLED} limits the search to the
1473     * {@link FileUtil#PROGRAM} directory and JARs in the class
1474     * path</li><li>{@link Location#USER} limits the search to the
1475     * {@link FileUtil#PROFILE} directory</li></ol>
1476     *
1477     * @param path        The relative path of the file or resource
1478     * @param locations   The types of locations to limit the search to
1479     * @param searchPaths a list of paths to search for the path in
1480     * @return The URL or null
1481     * @see #findURL(java.lang.String)
1482     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location)
1483     * @see #findURL(java.lang.String, java.lang.String...)
1484     */
1485    public URL findURL(@Nonnull String path, @Nonnull Location locations, @Nonnull String... searchPaths) {
1486        URI file = this.findURI(path, locations, searchPaths);
1487        if (file != null) {
1488            try {
1489                return file.toURL();
1490            } catch (MalformedURLException ex) {
1491                log.error("findURL MalformedURLException", ex);
1492            }
1493        }
1494        return null;
1495    }
1496
1497    /**
1498     * Return the {@link java.net.URI} for a given URL
1499     *
1500     * @param url the URL
1501     * @return a URI or null if the conversion would have caused a
1502     *         {@link java.net.URISyntaxException}
1503     */
1504    public URI urlToURI(@Nonnull URL url) {
1505        try {
1506            return url.toURI();
1507        } catch (URISyntaxException ex) {
1508            log.error("Unable to get URI from URL", ex);
1509            return null;
1510        }
1511    }
1512
1513    /**
1514     * Return the {@link java.net.URL} for a given {@link java.io.File}. This
1515     * method catches a {@link java.net.MalformedURLException} and returns null
1516     * in its place, since we really do not expect a File object to ever give a
1517     * malformed URL. This method exists solely so implementing classes do not
1518     * need to catch that exception.
1519     *
1520     * @param file The File to convert.
1521     * @return a URL or null if the conversion would have caused a
1522     *         MalformedURLException
1523     */
1524    public URL fileToURL(@Nonnull File file) {
1525        try {
1526            return file.toURI().toURL();
1527        } catch (MalformedURLException ex) {
1528            log.error("Unable to get URL from file", ex);
1529            return null;
1530        }
1531    }
1532
1533    /**
1534     * Get the JMRI distribution jar file.
1535     *
1536     * @return the JAR file containing the JMRI library or null if not running
1537     *         from a JAR file
1538     */
1539    public JarFile getJmriJarFile() {
1540        if (jarPath == null) {
1541            CodeSource sc = FileUtilSupport.class.getProtectionDomain().getCodeSource();
1542            if (sc != null) {
1543                jarPath = sc.getLocation().toString();
1544                if (jarPath.startsWith("jar:file:")) {
1545                    // 9 = length of jar:file:
1546                    jarPath = jarPath.substring(9, jarPath.lastIndexOf("!"));
1547                } else {
1548                    log.info("Running from classes not in jar file.");
1549                    jarPath = ""; // set to empty String to bypass search
1550                    return null;
1551                }
1552                log.debug("jmri.jar path is {}", jarPath);
1553            }
1554            if (jarPath == null) {
1555                log.error("Unable to locate jmri.jar");
1556                jarPath = ""; // set to empty String to bypass search
1557                return null;
1558            }
1559        }
1560        if (!jarPath.isEmpty()) {
1561            try {
1562                return new JarFile(jarPath);
1563            } catch (IOException ex) {
1564                log.error("Unable to open jmri.jar", ex);
1565                return null;
1566            }
1567        }
1568        return null;
1569    }
1570
1571    /**
1572     * Read a text file into a String.
1573     *
1574     * @param file The text file.
1575     * @return The contents of the file.
1576     * @throws java.io.IOException if the file cannot be read
1577     */
1578    public String readFile(@Nonnull File file) throws IOException {
1579        return this.readURL(this.fileToURL(file));
1580    }
1581
1582    /**
1583     * Read a text URL into a String.
1584     *
1585     * @param url The text URL.
1586     * @return The contents of the file.
1587     * @throws java.io.IOException if the URL cannot be read
1588     */
1589    public String readURL(@Nonnull URL url) throws IOException {
1590        try {
1591            try (InputStreamReader in = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8);
1592                    BufferedReader reader = new BufferedReader(in)) {
1593                return reader.lines().collect(Collectors.joining("\n")); // NOI18N
1594            }
1595        } catch (NullPointerException ex) {
1596            return null;
1597        }
1598    }
1599
1600    /**
1601     * Replaces most non-alphanumeric characters in name with an underscore.
1602     *
1603     * @param name The filename to be sanitized.
1604     * @return The sanitized filename.
1605     */
1606    @Nonnull
1607    public String sanitizeFilename(@Nonnull String name) {
1608        name = name.trim().replaceAll(" ", "_").replaceAll("[.]+", ".");
1609        StringBuilder filename = new StringBuilder();
1610        for (char c : name.toCharArray()) {
1611            if (c == '.' || Character.isJavaIdentifierPart(c)) {
1612                filename.append(c);
1613            }
1614        }
1615        return filename.toString();
1616    }
1617
1618    /**
1619     * Create a directory if required. Any parent directories will also be
1620     * created.
1621     *
1622     * @param path directory to create
1623     */
1624    public void createDirectory(@Nonnull String path) {
1625        this.createDirectory(new File(path));
1626    }
1627
1628    /**
1629     * Create a directory if required. Any parent directories will also be
1630     * created.
1631     *
1632     * @param dir directory to create
1633     */
1634    public void createDirectory(@Nonnull File dir) {
1635        if (!dir.exists()) {
1636            log.debug("Creating directory: {}", dir);
1637            if (!dir.mkdirs()) {
1638                log.error("Failed to create directory: {}", dir);
1639            }
1640        }
1641    }
1642
1643    /**
1644     * Recursively delete a path. It is recommended to use
1645     * {@link java.nio.file.Files#delete(java.nio.file.Path)} or
1646     * {@link java.nio.file.Files#deleteIfExists(java.nio.file.Path)} for files.
1647     *
1648     * @param path path to delete
1649     * @return true if path was deleted, false otherwise
1650     */
1651    public boolean delete(@Nonnull File path) {
1652        if (path.isDirectory()) {
1653            File[] files = path.listFiles();
1654            if (files != null) {
1655                for (File file : files) {
1656                    this.delete(file);
1657                }
1658            }
1659        }
1660        return path.delete();
1661    }
1662
1663    /**
1664     * Copy a file or directory. It is recommended to use
1665     * {@link java.nio.file.Files#copy(java.nio.file.Path, java.io.OutputStream)}
1666     * for files.
1667     *
1668     * @param source the file or directory to copy
1669     * @param dest   must be the file or directory, not the containing directory
1670     * @throws java.io.IOException if file cannot be copied
1671     */
1672    public void copy(@Nonnull File source, @Nonnull File dest) throws IOException {
1673        if (!source.exists()) {
1674            log.error("Attempting to copy non-existant file: {}", source);
1675            return;
1676        }
1677        if (!dest.exists()) {
1678            if (source.isDirectory()) {
1679                boolean ok = dest.mkdirs();
1680                if (!ok) {
1681                    throw new IOException("Could not use mkdirs to create destination directory");
1682                }
1683            } else {
1684                boolean ok = dest.createNewFile();
1685                if (!ok) {
1686                    throw new IOException("Could not create destination file");
1687                }
1688            }
1689        }
1690        Path srcPath = source.toPath();
1691        Path dstPath = dest.toPath();
1692        if (source.isDirectory()) {
1693            Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
1694                @Override
1695                public FileVisitResult preVisitDirectory(final Path dir,
1696                        final BasicFileAttributes attrs) throws IOException {
1697                    Files.createDirectories(dstPath.resolve(srcPath.relativize(dir)));
1698                    return FileVisitResult.CONTINUE;
1699                }
1700
1701                @Override
1702                public FileVisitResult visitFile(final Path file,
1703                        final BasicFileAttributes attrs) throws IOException {
1704                    Files.copy(file, dstPath.resolve(srcPath.relativize(file)), StandardCopyOption.REPLACE_EXISTING);
1705                    return FileVisitResult.CONTINUE;
1706                }
1707            });
1708        } else {
1709            Files.copy(source.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
1710        }
1711    }
1712
1713    /**
1714     * Simple helper method to just append a text string to the end of the given
1715     * filename. The file will be created if it does not exist.
1716     *
1717     * @param file File to append text to
1718     * @param text Text to append
1719     * @throws java.io.IOException if file cannot be written to
1720     */
1721    public void appendTextToFile(@Nonnull File file, @Nonnull String text) throws IOException {
1722        try (PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(file, true), StandardCharsets.UTF_8))) {
1723            pw.println(text);
1724        }
1725    }
1726
1727    /**
1728     * Backup a file. The backup is in the same location as the original file,
1729     * has the extension <code>.bak</code> appended to the file name, and up to
1730     * four revisions are retained. The lowest numbered revision is the most
1731     * recent.
1732     *
1733     * @param file the file to backup
1734     * @throws java.io.IOException if a backup cannot be created
1735     */
1736    public void backup(@Nonnull File file) throws IOException {
1737        this.rotate(file, 4, "bak");
1738    }
1739
1740    /**
1741     * Rotate a file and its backups, retaining only a set number of backups.
1742     *
1743     * @param file      the file to rotate
1744     * @param max       maximum number of backups to retain
1745     * @param extension The extension to use for the rotations. If null or an
1746     *                  empty string, the rotation number is used as the
1747     *                  extension.
1748     * @throws java.io.IOException      if a backup cannot be created
1749     * @throws IllegalArgumentException if max is less than one
1750     * @see #backup(java.io.File)
1751     */
1752    public void rotate(@Nonnull File file, int max, @CheckForNull String extension) throws IOException {
1753        if (max < 1) {
1754            throw new IllegalArgumentException();
1755        }
1756        String name = file.getName();
1757        if (extension != null) {
1758            if (extension.length() > 0 && !extension.startsWith(".")) {
1759                extension = "." + extension;
1760            }
1761        } else {
1762            extension = "";
1763        }
1764        File dir = file.getParentFile();
1765        File source;
1766        int i = max;
1767        while (i > 1) {
1768            source = new File(dir, name + "." + (i - 1) + extension);
1769            if (source.exists()) {
1770                this.copy(source, new File(dir, name + "." + i + extension));
1771            }
1772            i--;
1773        }
1774        this.copy(file, new File(dir, name + "." + i + extension));
1775    }
1776
1777    /**
1778     * Get the canonical path for a portable path. There are nine cases:
1779     * <ul>
1780     * <li>Starts with "resource:", treat the rest as a pathname relative to the
1781     * program directory (deprecated; see "program:" below)</li>
1782     * <li>Starts with "program:", treat the rest as a relative pathname below
1783     * the program directory</li>
1784     * <li>Starts with "preference:", treat the rest as a relative path below
1785     * the user's files directory</li>
1786     * <li>Starts with "settings:", treat the rest as a relative path below the
1787     * JMRI system preferences directory</li>
1788     * <li>Starts with "home:", treat the rest as a relative path below the
1789     * user.home directory</li>
1790     * <li>Starts with "file:", treat the rest as a relative path below the
1791     * resource directory in the preferences directory (deprecated; see
1792     * "preference:" above)</li>
1793     * <li>Starts with "profile:", treat the rest as a relative path below the
1794     * profile directory as specified in the
1795     * active{@link jmri.profile.Profile}</li>
1796     * <li>Starts with "scripts:", treat the rest as a relative path below the
1797     * scripts directory</li>
1798     * <li>Otherwise, treat the name as a relative path below the program
1799     * directory</li>
1800     * </ul>
1801     * In any case, absolute pathnames will work.
1802     *
1803     * @param path The name string, possibly starting with file:, home:,
1804     *             profile:, program:, preference:, scripts:, settings, or
1805     *             resource:
1806     * @return Canonical path to use, or null if one cannot be found.
1807     * @since 2.7.2
1808     */
1809    private String pathFromPortablePath(@CheckForNull Profile profile, @Nonnull String path) {
1810        // As this method is called in Log4J setup, should not
1811        // contain standard logging statements.
1812        if (path.startsWith(PROGRAM)) {
1813            if (new File(path.substring(PROGRAM.length())).isAbsolute()) {
1814                path = path.substring(PROGRAM.length());
1815            } else {
1816                path = path.replaceFirst(PROGRAM, Matcher.quoteReplacement(this.getProgramPath()));
1817            }
1818        } else if (path.startsWith(PREFERENCES)) {
1819            if (new File(path.substring(PREFERENCES.length())).isAbsolute()) {
1820                path = path.substring(PREFERENCES.length());
1821            } else {
1822                path = path.replaceFirst(PREFERENCES, Matcher.quoteReplacement(this.getUserFilesPath(profile)));
1823            }
1824        } else if (path.startsWith(PROFILE)) {
1825            if (new File(path.substring(PROFILE.length())).isAbsolute()) {
1826                path = path.substring(PROFILE.length());
1827            } else {
1828                path = path.replaceFirst(PROFILE, Matcher.quoteReplacement(this.getProfilePath(profile)));
1829            }
1830        } else if (path.startsWith(SCRIPTS)) {
1831            if (new File(path.substring(SCRIPTS.length())).isAbsolute()) {
1832                path = path.substring(SCRIPTS.length());
1833            } else {
1834                path = path.replaceFirst(SCRIPTS, Matcher.quoteReplacement(this.getScriptsPath(profile)));
1835            }
1836        } else if (path.startsWith(SETTINGS)) {
1837            if (new File(path.substring(SETTINGS.length())).isAbsolute()) {
1838                path = path.substring(SETTINGS.length());
1839            } else {
1840                path = path.replaceFirst(SETTINGS, Matcher.quoteReplacement(this.getPreferencesPath()));
1841            }
1842        } else if (path.startsWith(HOME)) {
1843            if (new File(path.substring(HOME.length())).isAbsolute()) {
1844                path = path.substring(HOME.length());
1845            } else {
1846                path = path.replaceFirst(HOME, Matcher.quoteReplacement(this.getHomePath()));
1847            }
1848        } else if (!new File(path).isAbsolute()) {
1849            return null;
1850        }
1851        try {
1852            // if path cannot be converted into a canonical path, return null
1853            return new File(path.replace(SEPARATOR, File.separatorChar)).getCanonicalPath();
1854        } catch (IOException ex) {
1855            System.err.println("Cannot convert " + path + " into a usable filename. " + ex.getMessage());
1856            return null;
1857        }
1858    }
1859
1860}