001package jmri.jmrix.loconet.spjfile;
002
003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
004import java.io.File;
005import java.io.FileOutputStream;
006import java.io.IOException;
007import java.io.InputStream;
008import java.io.OutputStream;
009import java.util.Arrays;
010import jmri.jmrix.loconet.sdf.SdfBuffer;
011
012/**
013 * Provide tools for reading, writing and accessing Digitrax SPJ files.
014 * <p>
015 * Four-byte quantities in SPJ files are little-endian.
016 *
017 * @author Bob Jacobsen Copyright (C) 2006, 2009
018 */
019public class SpjFile {
020
021    public SpjFile(File file) {
022        this.file = file;
023    }
024
025    /**
026     * Number of headers present in the file.
027     *
028     * @return -1 if error
029     */
030    public int numHeaders() {
031        if (headers != null && h0 != null) {
032            return h0.numHeaders();
033        } else {
034            return -1;
035        }
036    }
037
038    public String getComment() {
039        return h0.getComment();
040    }
041
042    public Header getHeader(int index) {
043        return headers[index];
044    }
045
046    public Header findSdfHeader() {
047        int n = numHeaders();
048        for (int i = 1; i < n; i++) {
049            if (headers[i].isSDF()) {
050                return headers[i];
051            }
052        }
053        return null;
054    }
055
056    /**
057     * Find the map entry (character string) that corresponds to a particular
058     * handle number.
059     * @param i handle index.
060     * @return string of map entry.
061     */
062    public String getMapEntry(int i) {
063        log.debug("getMapEntry({})", i);
064        loadMapCache();
065        String wanted = "" + i + " ";
066        for (int j = 0; j < mapCache.length; j++) {
067            if (mapCache[j].startsWith(wanted)) {
068                return mapCache[j].substring(wanted.length());
069            }
070        }
071        return null;
072    }
073
074    String[] mapCache = null;
075
076    void loadMapCache() {
077        if (mapCache != null) {
078            return;
079        }
080
081        // find the map entries
082        log.debug("loading map cache");
083        int map;
084        for (map = 1; map < numHeaders(); map++) {
085            if (headers[map].isMap()) {
086                break;
087            }
088        }
089        // map holds the map index, hopefully
090        if (map > numHeaders()) {
091            log.error("Did not find map data");
092            return;
093        }
094
095        // here found it, count lines
096        byte[] buffer = headers[map].getByteArray();
097        log.debug("map buffer length {}", buffer.length);
098        int count = 0;
099        for (int i = 0; i < buffer.length; i++) {
100            if (buffer[i] == 0x0D) {
101                count++;
102            }
103        }
104
105        mapCache = new String[count];
106
107        log.debug("found {} map entries", count);
108
109        int start = 0;
110        int end = 0;
111        int index = 0;
112
113        // loop through the string, look for each line
114        log.debug("start loop over map with buffer length = {}", buffer.length);
115        while ((++end) < buffer.length) {
116            if (buffer[end] == 0x0D || buffer[end] == 0x0A) {
117                // sound end; make string
118                String next = new String(buffer, start, end - start);
119                // increment pointers
120                start = ++end;
121                log.debug("new start value is {}", start);
122                log.debug("new end value is   {}", end);
123
124                // if another linefeed or newline is present, skip it too
125                if ((buffer[end - 1] == 0x0D || ((end < buffer.length) && buffer[end] == 0x0A))
126                        || (buffer[end - 1] == 0x0A || ((end < buffer.length) && buffer[end] == 0x0D))) {
127                    start++;
128                    end++;
129                }
130                // store entry
131                log.debug(" store entry {}", index);
132                mapCache[index++] = next;
133            }
134        }
135    }
136
137    /**
138     * Save this file.
139     * <p>
140     * It lays the file out again, changing the record start
141     * addresses into a sequential series.
142     *
143     * @param name file name.
144     * @throws java.io.IOException if anything goes wrong
145     */
146    public void save(String name) throws java.io.IOException {
147        if (name == null) {
148            throw new java.io.IOException("Null name during write"); // NOI18N
149        }
150        try (OutputStream s = new java.io.BufferedOutputStream(
151                new java.io.FileOutputStream(new java.io.File(name)))) {
152
153            // find size of output file
154            int length = Header.HEADERSIZE * h0.numHeaders();  // allow header space at start
155            for (int i = 1; i < h0.numHeaders(); i++) {
156                length += headers[i].getRecordLength();
157            }
158            byte[] buffer = new byte[length];
159            for (int i = 0; i < length; i++) {
160                buffer[i] = 0;
161            }
162
163            // start with first header
164            int index = 0;
165            index = h0.store(buffer, index);
166
167            if (index != Header.HEADERSIZE) {
168                log.error("Unexpected 1st header length: {}", index);
169            }
170
171            int datastart = index * h0.numHeaders(); //index is the length of the 1st header
172
173            // rest of the headers
174            for (int i = 1; i < h0.numHeaders(); i++) {  // header 0 already done
175                // Update header pointers.
176                headers[i].updateStart(datastart);
177                datastart += headers[i].getRecordLength();
178
179                // copy contents into output buffer
180                index = headers[i].store(buffer, index);
181            }
182
183            // copy the chunks; skip the first header, with no data
184            for (int i = 1; i < h0.numHeaders(); i++) {
185                int start = headers[i].getRecordStart();
186                int count = headers[i].getRecordLength();  // stored one long
187
188                byte[] content = headers[i].getByteArray();
189                if (count != content.length) {
190                    log.error("header count {} != content length {}", count, content.length);
191                }
192                for (int j = 0; j < count; j++) {
193                    buffer[start + j] = content[j];
194                }
195            }
196
197            // write out the buffer
198            s.write(buffer);
199
200            // purge buffers
201            s.close();
202        }
203    }
204
205    /**
206     * Read the file whose name was provided earlier.
207     * @throws java.io.IOException on file error.
208     */
209    public void read() throws java.io.IOException {
210        if (file == null) {
211            throw new java.io.IOException("Null file during read"); // NOI18N
212        }
213        int n;
214        try (InputStream s = new java.io.BufferedInputStream(new java.io.FileInputStream(file))) {
215
216            // get first header record
217            h0 = new FirstHeader();
218            h0.load(s);
219            log.debug("FirstHeader: {}", h0);
220            n = h0.numHeaders();
221            headers = new Header[n];
222            headers[0] = h0;
223
224            for (int i = 1; i < n; i++) {  // header 0 already read
225                headers[i] = new Header();
226                headers[i].load(s);
227                log.debug("Header {} {}", i, headers[i].toString());
228            }
229
230            // now read the rest of the file, loading bytes
231            // first, scan for things we can't handle
232            for (int i = 1; i < n; i++) {
233                if (log.isDebugEnabled()) {
234                    log.debug("Header {}  length {} type {}", i, headers[i].getDataLength(), headers[i].getType()); // NOI18N
235                }
236                if (headers[i].getDataLength() > headers[i].getRecordLength()) {
237                    log.error("header {} has data length {} greater than record length {}",
238                            i, headers[i].getDataLength(), headers[i].getRecordLength()); // NOI18N
239                }
240
241                for (int j = 1; j < i; j++) {
242                    if (headers[i].getHandle() == headers[j].getHandle()
243                            && headers[i].getType() == 1
244                            && headers[j].getType() == 1) {
245                        log.error("Duplicate handle number in records {}({}) and {}({})", i, headers[i].getHandle(), j, headers[j].getHandle());
246                    }
247                }
248                if (headers[i].getType() > 6) {
249                    log.error("Type field unexpected value: {}", headers[i].getType());
250                }
251                if (headers[i].getType() == 0) {
252                    log.error("Type field unexpected value: {}", headers[i].getType());
253                }
254                if (headers[i].getType() < -1) {
255                    log.error("Type field unexpected value: {}", headers[i].getType());
256                }
257            }
258
259            // find end of last part
260            int length = 0;
261            for (int i = 1; i < n; i++) {
262                if (length < headers[i].getRecordStart() + headers[i].getRecordLength()) {
263                    length = headers[i].getRecordStart() + headers[i].getRecordLength();
264                }
265            }
266
267            log.debug("Last byte at {}", length);
268            s.close();
269        }
270        
271        // inefficient way to read, hecause of all the skips (instead
272        // of seeks)  But it handles non-consecutive and overlapping definitions.
273        for (int i = 1; i < n; i++) {
274            try (InputStream s = new java.io.BufferedInputStream(new java.io.FileInputStream(file))) {
275                long count = s.skip(headers[i].getRecordStart());
276                if (count != headers[i].getRecordStart()) {
277                    log.warn("Only skipped {} characters, should have skipped {}", count, headers[i].getRecordStart());
278                }
279                byte[] array = new byte[headers[i].getRecordLength()];
280                int read = s.read(array);
281                if (read != headers[i].getRecordLength()) {
282                    log.error("header {} read {}, expected {}", i, read, headers[i].getRecordLength());
283                }
284
285                headers[i].setByteArray(array);
286                s.close();
287            }
288        }
289    }
290
291    /**
292     * Write data from headers into separate files.
293     * <p>
294     * Normally, we just work with the data within this file.
295     * This method allows us to extract the contents of the file for external use.
296     * @throws java.io.IOException on file error.
297     */
298    public void writeSubFiles() throws IOException {
299        // write data from WAV headers into separate files
300        int n = numHeaders();
301        for (int i = 1; i < n; i++) {
302            if (headers[i].isWAV()) {
303                writeSubFile(i, "" + i + ".wav"); // NOI18N
304            } else if (headers[i].isSDF()) {
305                writeSubFile(i, "" + i + ".sdf"); // NOI18N
306            } else if (headers[i].getType() == 3) {
307                writeSubFile(i, "" + i + ".cv"); // NOI18N
308            } else if (headers[i].getType() == 4) {
309                writeSubFile(i, "" + i + ".txt"); // NOI18N
310            } else if (headers[i].isMap()) {
311                writeSubFile(i, "" + i + ".map"); // NOI18N
312            } else if (headers[i].getType() == 6) {
313                writeSubFile(i, "" + i + ".uwav"); // NOI18N
314            }
315        }
316    }
317
318    /**
319     * Write the content from a specific header as a new "subfile".
320     *
321     * @param i    index of the specific header
322     * @param name filename
323     * @throws IOException based on underlying activity
324     */
325    void writeSubFile(int i, String name) throws IOException {
326        File outfile = new File(name);
327        OutputStream ostream = new FileOutputStream(outfile);
328        try {
329            ostream.write(headers[i].getByteArray());
330        } finally {
331            ostream.close();
332        }
333    }
334
335    public void dispose() {
336    }
337
338    File file;
339    FirstHeader h0;
340    Header[] headers;
341
342    /**
343     * Class representing a header record.
344     */
345    public class Header {
346
347        final static int HEADERSIZE = 128; // bytes
348
349        int type;
350        int handle;
351
352        // Offset in overall buffer where the complete record
353        // associated with this header is found
354        int recordStart;
355
356        // Offset in overall buffer where the data part of the
357        // record associated with this header is found
358        int dataStart;
359
360        // Length of the data in the associated record
361        int dataLength;
362        // Length of the associated record
363        int recordLength;
364
365        int time;
366
367        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
368        int spare1;
369
370        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
371        int spare2;
372
373        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
374        int spare3;
375
376        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
377        int spare4;
378
379        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
380        int spare5;
381
382        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
383        int spare6;
384
385        @SuppressFBWarnings(value = "URF_UNREAD_FIELD") // we maintain this, but don't use it for anything yet
386        int spare7;
387
388        String filename = "";
389
390        public int getType() {
391            return type;
392        }
393
394        public int getHandle() {
395            return handle;
396        }
397
398        public int getDataStart() {
399            return dataStart;
400        }
401
402        public void setDataStart(int i) {
403            dataStart = i;
404        }
405
406        public int getDataLength() {
407            return dataLength;
408        }
409
410        private void setDataLength(int i) {
411            dataLength = i;
412        }
413
414        public int getRecordStart() {
415            return recordStart;
416        }
417
418        public void setRecordStart(int i) {
419            recordStart = i;
420        }
421
422        /**
423         * Get Record Length.
424         * <p>
425         * This method, in addition to returning the needed record size, will
426         * also pull a SdfBuffer back into the record if one exists.
427         * @return record length.
428         */
429        public int getRecordLength() {
430            if (sdfBuffer != null) {
431                sdfBuffer.loadByteArray();
432                byte[] a = sdfBuffer.getByteArray();
433                setByteArray(a);
434                dataLength = bytes.length;
435                recordLength = bytes.length;
436            }
437            return recordLength;
438        }
439
440        public void setRecordLength(int i) {
441            recordLength = i;
442        }
443
444        public String getName() {
445            return filename;
446        }
447
448        public void setName(String name) {
449            if (name.length() > 72) {
450                log.error("new filename \"{}\" too long: {}", name, name.length());
451            }
452            filename = name;
453        }
454
455        byte[] bytes;
456
457        /**
458         * Copy new data into the local byte array.
459         */
460        private void setByteArray(byte[] a) {
461            bytes = new byte[a.length];
462            for (int i = 0; i < a.length; i++) {
463                bytes[i] = a[i];
464            }
465        }
466
467        public byte[] getByteArray() {
468            return Arrays.copyOf(bytes, bytes.length);
469        }
470
471        /**
472         * Get as a SDF buffer.
473         * This buffer then becomes associated, and a later write will use 
474         * the buffer's contents.
475         * @return the byte array as SDF buffer.
476         */
477        public SdfBuffer getSdfBuffer() {
478            sdfBuffer = new SdfBuffer(getByteArray());
479            return sdfBuffer;
480        }
481
482        SdfBuffer sdfBuffer = null;
483
484        /**
485         * Data record associated with this header is being being repositioned.
486         * @param newRecordStart identify the new start record
487         */
488        void updateStart(int newRecordStart) {
489            //int oldRecordStart = getRecordStart();
490            int dataStartOffset = getDataStart() - getRecordStart();
491            setRecordStart(newRecordStart);
492            setDataStart(newRecordStart + dataStartOffset);
493        }
494
495        /**
496         * Provide new content. The data start and data length values are
497         * computed from the arguments, and stored relative to the length.
498         *
499         * @param array  New byte array; copied into header
500         * @param start  data start location within array
501         * @param length data length in bytes (not record length)
502         */
503        public void setContent(byte[] array, int start, int length) {
504            log.debug("setContent length = 0x{}", Integer.toHexString(length));
505            setByteArray(array);
506            setDataStart(getRecordStart() + start);
507            setDataLength(length);
508            setRecordLength(array.length);
509        }
510
511        int store(byte[] buffer, int index) {
512            index = copyInt4(buffer, index, type);
513            index = copyInt4(buffer, index, handle);
514            index = copyInt4(buffer, index, recordStart);
515            index = copyInt4(buffer, index, dataStart);
516            index = copyInt4(buffer, index, dataLength);
517            index = copyInt4(buffer, index, recordLength);
518            index = copyInt4(buffer, index, time);
519
520            index = copyInt4(buffer, index, 0); // spare 1
521            index = copyInt4(buffer, index, 0); // spare 2
522            index = copyInt4(buffer, index, 0); // spare 3
523            index = copyInt4(buffer, index, 0); // spare 4
524            index = copyInt4(buffer, index, 0); // spare 5
525            index = copyInt4(buffer, index, 0); // spare 6
526            index = copyInt4(buffer, index, 0); // spare 7
527
528            // name is written in zero-filled array
529            byte[] name = filename.getBytes();
530            if (name.length > 72) {
531                log.error("Name too long: {}", name.length);
532            }
533            for (int i = 0; i < name.length; i++) {
534                buffer[index + i] = name[i];
535            }
536
537            return index + 72;
538        }
539
540        void store(OutputStream s) throws java.io.IOException {
541            writeInt4(s, type);
542            writeInt4(s, handle);
543            writeInt4(s, recordStart);
544            writeInt4(s, dataStart);
545            writeInt4(s, dataLength);
546            writeInt4(s, recordLength);
547            writeInt4(s, time);
548
549            writeInt4(s, 0);  // spare 1
550            writeInt4(s, 0);  // spare 2
551            writeInt4(s, 0);  // spare 3
552            writeInt4(s, 0);  // spare 4
553            writeInt4(s, 0);  // spare 5
554            writeInt4(s, 0);  // spare 6
555            writeInt4(s, 0);  // spare 7
556
557            // name is written in zero-filled array
558            byte[] name = filename.getBytes();
559            if (name.length > 72) {
560                log.error("Name too long: {}", name.length);
561            }
562            byte[] buffer = new byte[72];
563            for (int i = 0; i < 72; i++) {
564                buffer[i] = 0;
565            }
566            for (int i = 0; i < name.length; i++) {
567                buffer[i] = name[i];
568            }
569            s.write(buffer);
570        }
571
572        void load(InputStream s) throws java.io.IOException {
573            type = readInt4(s);
574            handle = readInt4(s);
575            recordStart = readInt4(s);
576            dataStart = readInt4(s);
577            dataLength = readInt4(s);
578            recordLength = readInt4(s);
579            time = readInt4(s);
580
581            spare1 = readInt4(s);
582            spare2 = readInt4(s);
583            spare3 = readInt4(s);
584            spare4 = readInt4(s);
585            spare5 = readInt4(s);
586            spare6 = readInt4(s);
587            spare7 = readInt4(s);
588
589            byte[] name = new byte[72];
590            int readLength = s.read(name);
591            // name is zero-terminated, so we have to truncate that array
592            int len = 0;
593            for (len = 0; len < readLength; len++) {
594                if (name[len] == 0) {
595                    break;
596                }
597            }
598            byte[] shortname = new byte[len];
599            for (int i = 0; i < len; i++) {
600                shortname[i] = name[i];
601            }
602            filename = new String(shortname);
603        }
604
605        @Override
606        public String toString() {
607            return "type= " + typeAsString() + ", handle= " + handle + ", rs= " + recordStart + ", ds= " + dataStart // NOI18N
608                    + ", ds-rs = " + (dataStart - recordStart) // NOI18N
609                    + ", dl = " + dataLength + ", rl= " + recordLength // NOI18N
610                    + ", rl-dl = " + (recordLength - dataLength) // NOI18N
611                    + ", filename= " + filename; // NOI18N
612        }
613
614        public boolean isWAV() {
615            return (getType() == 1);
616        }
617
618        public boolean isSDF() {
619            return (getType() == 2);
620        }
621
622        public boolean isMap() {
623            return (getType() == 5);
624        }
625
626        public boolean isTxt() {
627            return (getType() == 4);
628        }
629
630        /**
631         * Read a 4-byte integer, handling endian-ness of SPJ files.
632         */
633        private int readInt4(InputStream s) throws java.io.IOException {
634            int i1 = s.read() & 0xFF;
635            int i2 = s.read() & 0xFF;
636            int i3 = s.read() & 0xFF;
637            int i4 = s.read() & 0xFF;
638            return i1 + (i2 << 8) + (i3 << 16) + (i4 << 24);
639        }
640
641        /**
642         * Write a 4-byte integer, handling endian-ness of SPJ files.
643         */
644        private void writeInt4(OutputStream s, int i) throws java.io.IOException {
645            byte i1 = (byte) (i & 0xFF);
646            byte i2 = (byte) ((i >> 8) & 0xFF);
647            byte i3 = (byte) ((i >> 16) & 0xFF);
648            byte i4 = (byte) ((i >> 24) & 0xFF);
649
650            s.write(i1);
651            s.write(i2);
652            s.write(i3);
653            s.write(i4);
654        }
655
656        /**
657         * Copy a 4-byte integer to byte buffer, handling little-endian-ness of
658         * SPJ files.
659         */
660        private int copyInt4(byte[] buffer, int index, int i) {
661            buffer[index++] = (byte) (i & 0xFF);
662            buffer[index++] = (byte) ((i >> 8) & 0xFF);
663            buffer[index++] = (byte) ((i >> 16) & 0xFF);
664            buffer[index++] = (byte) ((i >> 24) & 0xFF);
665            return index;
666        }
667
668        public String typeAsString() {
669            if (type == -1) {
670                return " initial "; // NOI18N
671            }
672            if ((type >= 0) && (type < 7)) {
673                String[] names = {"(unused) ", // 0 // NOI18N
674                    "WAV      ", // 1 // NOI18N
675                    "SDF      ", // 2 // NOI18N
676                    " CV data ", // 3 // NOI18N
677                    " comment ", // 4 // NOI18N
678                    ".map file", // 5 // NOI18N
679                    "WAV (mty)"}; // 6 // NOI18N
680                return names[type];
681            }
682            // unexpected answer
683            log.warn("Unexpected type = {}", type); // NOI18N
684            return "Unknown " + type; // NOI18N
685        }
686    }
687
688    /**
689     * Class representing first header
690     */
691    class FirstHeader extends Header {
692
693        /**
694         * @return number of headers, including the initial system header.
695         */
696        int numHeaders() {
697            return (dataStart / 128);
698        }
699
700        float version() {
701            return recordStart / 100.f;
702        }
703
704        String getComment() {
705            return filename;
706        }
707
708        @Override
709        public String toString() {
710            return "initial record, version=" + version() + " num headers = " + numHeaders() // NOI18N
711                    + ", comment= " + filename; // NOI18N
712        }
713    }
714
715    private final static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SpjFile.class);
716
717}