001package jmri.jmrit.vsdecoder;
002
003import java.io.BufferedOutputStream;
004import java.io.File;
005import java.io.FileOutputStream;
006import java.io.IOException;
007import java.io.InputStream;
008import java.io.OutputStream;
009import java.util.Enumeration;
010import java.util.Iterator;
011import java.util.List;
012import java.util.zip.ZipEntry;
013import java.util.zip.ZipException;
014import java.util.zip.ZipFile;
015import jmri.jmrit.XmlFile;
016import org.jdom2.Element;
017
018/**
019 * Open a VSD file and validate the configuration part.
020 *
021 * <hr>
022 * This file is part of JMRI.
023 * <p>
024 * JMRI is free software; you can redistribute it and/or modify it under
025 * the terms of version 2 of the GNU General Public License as published
026 * by the Free Software Foundation. See the "COPYING" file for a copy
027 * of this license.
028 * <p>
029 * JMRI is distributed in the hope that it will be useful, but WITHOUT
030 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
031 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
032 * for more details.
033 *
034 * @author Mark Underwood Copyright (C) 2011
035 * @author Klaus Killinger Copyright (C) 2025
036 */
037public class VSDFile extends ZipFile {
038
039    private static final String VSDXmlFileName = "config.xml"; // NOI18N
040
041    // Dummy class just used to instantiate
042    private static class VSDXmlFile extends XmlFile {
043    }
044
045    protected Element root;
046    private boolean initialized;
047    private String _statusMsg = Bundle.getMessage("ButtonOK"); // File Status = OK
048    private String missedFileName;
049    private int num_cylinders;
050
051    public VSDFile(String name) throws ZipException, IOException {
052        super(name);
053        initialized = false;
054    }
055
056    public String getStatusMessage() {
057        return _statusMsg;
058    }
059
060    @edu.umd.cs.findbugs.annotations.SuppressFBWarnings( value="SLF4J_FORMAT_SHOULD_BE_CONST",
061            justification="error text in _statusMsg kept for later use")
062    final boolean isInitialized() {
063        VSDXmlFile xmlfile = new VSDXmlFile();
064        initialized = false;
065
066        try {
067            // Debug: List all the top-level contents in the file
068            Enumeration<?> entries = this.entries();
069            while (entries.hasMoreElements()) {
070                ZipEntry z = (ZipEntry) entries.nextElement();
071                log.debug("Entry: {}", z.getName());
072            }
073
074            ZipEntry config = this.getEntry(VSDXmlFileName);
075            if (config == null) {
076                _statusMsg = "File does not contain " + VSDXmlFileName;
077                log.error(_statusMsg);
078                return false;
079            }
080            File f2 = new File(this.getURL(VSDXmlFileName));
081            root = xmlfile.rootFromFile(f2);
082            ValidateStatus rv = this.validate(root);
083            if (!rv.getValid()) {
084                _statusMsg = rv.getMessage();
085            }
086            initialized = rv.getValid();
087            return initialized;
088
089        } catch (java.io.IOException ioe) {
090            _statusMsg = "IO Error auto-loading VSD File: " + VSDXmlFileName + " " + ioe;
091            log.error(_statusMsg);
092            return false;
093        } catch (org.jdom2.JDOMException ex) {
094            _statusMsg = "JDOM Exception loading VSDecoder from path " + VSDXmlFileName + " " + ex;
095            log.error(_statusMsg);
096            return false;
097        }
098    }
099
100    public Element getRoot() {
101        return root;
102    }
103
104    public java.io.InputStream getInputStream(String name) {
105        java.io.InputStream rv;
106        try {
107            ZipEntry e = this.getEntry(name);
108            if (e == null) {
109                e = this.getEntry(name.toLowerCase());
110                if (e == null) {
111                    e = this.getEntry(name.toUpperCase());
112                    if (e == null) {
113                        // I give up.  Return null
114                        return null;
115                    }
116                }
117            }
118            rv = getInputStream(this.getEntry(name));
119        } catch (IOException e) {
120            log.error("IOException caught", e);
121            rv = null;
122        }
123        return rv;
124    }
125
126    public java.io.File getFile(String name) {
127        ZipEntry e = this.getEntry(name);
128        if (e == null) {
129            return null;
130        } else {
131            File f = new File(e.getName());
132            return f;
133        }
134    }
135
136    public String getURL(String name) {
137        try {
138            // Grab the entry from the Zip file, and create a tempfile to dump it into
139            ZipEntry e = this.getEntry(name);
140            File t = File.createTempFile(name, ".wav.tmp");
141            t.deleteOnExit();
142
143            // Dump the file from the Zip into the tempfile
144            copyInputStream(this.getInputStream(e), new BufferedOutputStream(new FileOutputStream(t)));
145
146            // return the name of the tempfile
147            return t.getPath();
148
149        } catch (IOException e) {
150            log.error("IO exception", e);
151            return null;
152        }
153    }
154
155    private static final void copyInputStream(InputStream in, OutputStream out)
156            throws IOException {
157        byte[] buffer = new byte[1024];
158        int len;
159
160        while ((len = in.read(buffer)) >= 0) {
161            out.write(buffer, 0, len);
162        }
163
164        in.close();
165        out.close();
166    }
167
168    static class ValidateStatus {
169        String msg = "";
170        Boolean valid = false;
171
172        public ValidateStatus() {
173            this(false, "");
174        }
175
176        public ValidateStatus(Boolean v, String m) {
177            valid = v;
178            msg = m;
179        }
180
181        public void setValid(Boolean v) {
182            valid = v;
183        }
184
185        public void setMessage(String m) {
186            msg = m;
187        }
188
189        public Boolean getValid() {
190            return valid;
191        }
192
193        public String getMessage() {
194            return msg;
195        }
196    }
197
198    public ValidateStatus validate(Element xmlroot) {
199        Element e, el;
200        // Iterate through all the profiles in the file
201        // Would like to get rid of this suppression, but I think it's fairly safe to assume a list of children
202        // returned from an Element is going to be a list of Elements
203        Iterator<Element> i = xmlroot.getChildren("profile").iterator();
204        // If no Profiles, file is invalid
205        if (!i.hasNext()) {
206            log.error("No Profile(s)");
207            return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusNoProfiles"));
208        }
209
210        // Iterate through Profiles
211        while (i.hasNext()) {
212            e = i.next(); // e points to a profile
213            log.debug("Validate: Profile {}", e.getAttributeValue("name"));
214            if (e.getAttributeValue("name") == null || e.getAttributeValue("name").isEmpty()) {
215                log.error("Missing Profile name");
216                return new ValidateStatus(false, "Missing Profile name");
217            }
218
219            // Get the "Sound" children ... these are the ones that should have files
220            // Would like to get rid of this suppression, but I think it's fairly safe to assume a list of children
221            // returned from an Element is going to be a list of Elements
222            Iterator<Element> i2 = (e.getChildren("sound")).iterator();
223            if (!i2.hasNext()) {
224                log.error("Profile {} has no Sounds", e.getAttributeValue("name"));
225                return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusNoSounds") + ": " + e.getAttributeValue("name"));
226            }
227
228            // Iterate through Sounds
229            while (i2.hasNext()) {
230                el = i2.next();
231                log.debug("Element: {}", el);
232                if (el.getAttribute("name") == null) {
233                    log.error("Sound element without a name in profile {}", e.getAttributeValue("name"));
234                    return new ValidateStatus(false, "Sound-Element without a name"); //Bundle.getMessage("VSDFileStatusNoName")
235                }
236                String type = el.getAttributeValue("type");
237                log.debug("  Name: {}", el.getAttributeValue("name"));
238                log.debug("   type: {}", type);
239                if (type.equals("configurable")) {
240                    // Validate a Configurable Sound
241                    // All these elements are optional, so if the element is missing,
242                    // that's OK.  But if there is an element, and the FILE is missing,
243                    // that's bad
244                    if (!validateOptionalFile(el, "start-file")) {
245                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <start-file>: " + missedFileName);
246                    }
247                    if (!validateOptionalFile(el, "mid-file")) {
248                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <mid-file>: " + missedFileName);
249                    }
250                    if (!validateOptionalFile(el, "end-file")) {
251                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <end-file>: " + missedFileName);
252                    }
253                    if (!validateOptionalFile(el, "short-file")) {
254                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <short-file>: " + missedFileName);
255                    }
256                } else if (type.equals("diesel")) {
257                    // Validate a diesel sound
258                    String[] file_elements = {"file"};
259                    if (!validateOptionalFile(el, "start-file")) {
260                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <start-file>: " + missedFileName);
261                    }
262                    if (!validateOptionalFile(el, "shutdown-file")) {
263                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <shutdown-file>: " + missedFileName);
264                    }
265                    if (!validateFiles(el, "notch-sound", file_elements)) {
266                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <notch-sound>: " + missedFileName);
267                    }
268                    if (!validateFiles(el, "notch-transition", file_elements, false)) {
269                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <notch-transition>: " + missedFileName);
270                    }
271                } else if (type.equals("diesel3")) {
272                    // Validate a diesel3 sound
273                    String[] file_elements = {"file", "accel-file", "decel-file"};
274                    if (!validateOptionalFile(el, "start-file")) {
275                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <start-file>: " + missedFileName);
276                    }
277                    if (!validateOptionalFile(el, "shutdown-file")) {
278                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <shutdown-file>: " + missedFileName);
279                    }
280                    if (!validateFiles(el, "notch-sound", file_elements)) {
281                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <notch-sound>: " + missedFileName);
282                    }
283                } else if (type.equals("steam")) {
284                    // Validate a steam sound
285                    String[] file_elements = {"file"};
286                    if (!validateRequiredElement(el, "top-speed")) {
287                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <top-speed>");
288                    }
289                    if (!validateRequiredElement(el, "driver-diameter")) {
290                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <driver-diameter>");
291                    }
292                    if (!validateRequiredElement(el, "cylinders")) {
293                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <cylinders>");
294                    } else {
295                        // Found element <cylinders> - is number valid?
296                        if (!validateRequiredElementRange(el, "cylinders", 1, 4)) {
297                            return new ValidateStatus(false, "Number of cylinders must be 1, 2, 3 or 4");
298                        }
299                    }
300                    if (!validateFiles(el, "rpm-step", file_elements)) {
301                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <rpm-step>: " + missedFileName);
302                    }
303                } else if (type.equals("steam1")) {
304                    // Validate a steam1 sound
305                    if (!validateRequiredElement(el, "top-speed")) {
306                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <top-speed>");
307                    }
308                    if (!validateRequiredElement(el, "driver-diameter-float")) {
309                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <driver-diameter-float>");
310                    }
311                    if (!validateRequiredElement(el, "cylinders")) {
312                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <cylinders>");
313                    } else {
314                        // Found element <cylinders> - is number valid?
315                        if (!validateRequiredElementRange(el, "cylinders", 1, 4)) {
316                            return new ValidateStatus(false, "Number of cylinders must be 1, 2, 3 or 4");
317                        }
318                        // Found element <cylinders> - #cylinders * 2 must correspond to #files
319                        String[] file_elements = {"notch-file", "coast-file"};
320                        if (!validateFilesNumbers(el, "s1notch-sound", file_elements, true)) {
321                            return new ValidateStatus(false, getStatusMessage());
322                        }
323                    }
324                    if (!validateRequiredElement(el, "s1notch-sound")) {
325                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingElement") + ": <s1notch-sound>");
326                    }
327                    if (!validateRequiredNotchElement(el, "s1notch-sound", "min-rpm")) {
328                        return new ValidateStatus(false, "Element min-rpm for Element s1notch-sound missing");
329                    }
330                    if (!validateRequiredNotchElement(el, "s1notch-sound", "max-rpm")) {
331                        return new ValidateStatus(false, "Element max-rpm for Element s1notch-sound missing");
332                    }
333                    String[] file_elements = {"notch-file", "notchfiller-file", "coast-file", "coastfiller-file"};
334                    if (!validateFiles(el, "s1notch-sound", file_elements)) {
335                        return new ValidateStatus(false, Bundle.getMessage("VSDFileStatusMissingSoundFile") + " <s1notch-sound>: " + missedFileName);
336                    }
337                } else {
338                    return new ValidateStatus(false, "Unsupported sound type: " + type);
339                }
340            }
341        }
342        log.debug("File Validation Successful.");
343        return new ValidateStatus(true, Bundle.getMessage("ButtonOK")); // File Status = OK
344    }
345
346    protected boolean validateRequiredElement(Element el, String name) {
347        if (el.getChild(name) == null || el.getChildText(name).isEmpty()) {
348            log.error("Element {} for Element {} missing", name, el.getAttributeValue("name"));
349            return false;
350        }
351        return true;
352    }
353
354    protected boolean validateRequiredElementRange(Element el, String name, int val_from, int val_to) {
355        int val = Integer.parseInt(el.getChildText(name));
356        log.debug(" <{}> found: {} ({} to {})", name, val, val_from, val_to);
357        if (val >= val_from && val <= val_to) {
358            if (name.equals("cylinders")) {
359                num_cylinders = val; // save #cylinder for the #files check
360            }
361            return true;
362        } else {
363            log.error("Value of {} is invalid", name);
364            return false;
365        }
366    }
367
368    protected boolean validateRequiredNotchElement(Element el, String name1, String name2) {
369        // Get all notches
370        List<Element> elist = el.getChildren(name1);
371        Iterator<Element> ns_i = elist.iterator();
372        while (ns_i.hasNext()) {
373            Element ns_e = ns_i.next();
374            if (ns_e.getChild(name2) == null || ns_e.getChildText(name2).isEmpty()) {
375                log.error("Element {} for Element {} missing", name2, name1);
376                return false;
377            }
378        }
379        return true;
380    }
381
382    protected boolean validateOptionalFile(Element el, String name) {
383        return validateOptionalFile(el, name, true);
384    }
385
386    protected boolean validateOptionalFile(Element el, String name, Boolean required) {
387        String s = el.getChildText(name);
388        if ((s != null) && (getFile(s) == null)) {
389            missedFileName = s;
390            log.error("File {} for Element {} not found {}", s, name, el.getAttributeValue("name"));
391            return false;
392        }
393        return true;
394    }
395
396    protected boolean validateFiles(Element el, String name, String[] fnames) {
397        return validateFiles(el, name, fnames, true);
398    }
399
400    protected boolean validateFiles(Element el, String name, String[] fnames, Boolean required) {
401        List<Element> elist = el.getChildren(name);
402        String s;
403
404        // First, check to see if any elements of this <name> exist
405        if (elist.isEmpty() && required) {
406            // Only fail if this type of element is required
407            log.error("No elements of name {}", name);
408            return false;
409        }
410
411        // Now, if the elements exist, make sure the files they point to exist
412        // Would like to get rid of this suppression, but I think it's fairly safe to assume a list of children
413        // returned from an Element is going to be a list of Elements
414        log.debug("{}(s): {}", name, elist.size());
415        Iterator<Element> ns_i = elist.iterator();
416        while (ns_i.hasNext()) {
417            Element ns_e = ns_i.next();
418            for (String fn : fnames) {
419                List<Element> elistf = ns_e.getChildren(fn); // Handle more than one child
420                log.debug(" {}(s): {}", fn, elistf.size());
421                Iterator<Element> ns_if = elistf.iterator();
422                while (ns_if.hasNext()) {
423                    Element ns_ef = ns_if.next();
424                    s = ns_ef.getText();
425                    log.debug("  getText: {}", s);
426                    if ((s == null) || (getFile(s) == null)) {
427                        log.error("File {} for Element {} in Element {} not found", s, fn, name);
428                        missedFileName = s; // Pass missing file name to global variable
429                        return false;
430                    }
431                }
432            }
433        }
434        // Made it this far, all is well
435        return true;
436    }
437
438    @edu.umd.cs.findbugs.annotations.SuppressFBWarnings( value="SLF4J_FORMAT_SHOULD_BE_CONST",
439            justification="error text in _statusMsg kept for later use")
440    protected boolean validateFilesNumbers(Element el, String name, String[] fnames, Boolean required) {
441        List<Element> elist = el.getChildren(name);
442
443        // First, check to see if any elements of this <name> exist
444        if (elist.isEmpty() && required) {
445            // Only fail if this type of element is required
446            log.error("No elements of name {}", name);
447            return false;
448        }
449
450        // Would like to get rid of this suppression, but I think it's fairly safe to assume a list of children
451        // returned from an Element is going to be a list of Elements
452        log.debug("{}(s): {}", name, elist.size());
453        int nn = 1; // notch number
454        Iterator<Element> ns_i = elist.iterator();
455        while (ns_i.hasNext()) {
456            Element ns_e = ns_i.next();
457            log.debug(" nse: {}", ns_e);
458            for (String fn : fnames) {
459                List<Element> elistf = ns_e.getChildren(fn); // get all files of type <fn>
460                // #notch-files must be equal num_cylinders * 2
461                if (fn.equals("notch-file") && (elistf.size() != num_cylinders * 2)) {
462                    _statusMsg = "Invalid number of notch files: " + elistf.size() + ", but should be "
463                            + (num_cylinders * 2) + " (for " + num_cylinders + " cylinders) in notch " + nn;
464                    log.error(_statusMsg);
465                    return false;
466                }
467                // #coast files are allowed on notch1 only, but are optional. If exist, must be equal num_cylinders * 2
468                if (fn.equals("coast-file") && nn == 1 && !((elistf.size() == num_cylinders * 2) || elistf.size() == 0)) {
469                    _statusMsg = "Invalid number of coast files: " + elistf.size() + ", but should be "
470                            + (num_cylinders * 2) + " (for " + num_cylinders  + " cylinders) in notch 1";
471                    log.error(_statusMsg);
472                    return false;
473                }
474                // Coast files are not allowed on notches > 1
475                if (fn.equals("coast-file") && nn > 1 && (elistf.size() != 0)) {
476                    _statusMsg = "Invalid number of coast files: " + elistf.size() + ", but should be 0 in notch " + nn;
477                    log.error(_statusMsg);
478                    return false;
479                }
480                // Note: no check for a notchfiller-file or a coastfiller-file
481            }
482            nn++;
483        }
484        // Made it this far, all is well
485        return true;
486    }
487
488    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(VSDFile.class);
489
490}