001/*
002 * Shredzone Commons - pdb
003 *
004 * Copyright (C) 2009 Richard "Shred" Körber
005 *   http://commons.shredzone.org
006 *
007 * This program is free software: you can redistribute it and/or modify
008 * it under the terms of the GNU Library General Public License as
009 * published by the Free Software Foundation, either version 3 of the
010 * License, or (at your option) any later version.
011 *
012 * This program is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
015 * GNU General Public License for more details.
016 *
017 * You should have received a copy of the GNU Library General Public License
018 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
019 */
020package org.shredzone.commons.pdb;
021
022import java.io.ByteArrayOutputStream;
023import java.io.File;
024import java.io.FileNotFoundException;
025import java.io.IOException;
026import java.io.RandomAccessFile;
027import java.util.Calendar;
028
029import org.shredzone.commons.pdb.appinfo.AppInfo;
030import org.shredzone.commons.pdb.appinfo.CategoryAppInfo;
031import org.shredzone.commons.pdb.appinfo.CategoryAppInfo.Category;
032import org.shredzone.commons.pdb.converter.Converter;
033import org.shredzone.commons.pdb.record.Record;
034
035/**
036 * Opens a PDB file and gives access to its contents.
037 *
038 * @see <a href="http://membres.multimania.fr/microfirst/palm/pdb.html">The Pilot Record Database Format</a>
039 */
040public class PdbFile extends RandomAccessFile {
041
042    private static final String CHARSET = "iso-8859-1";
043    private static final int NUM_CATEGORIES = 16;
044
045    private CalendarFactory cf = CalendarFactory.getInstance();
046
047    /**
048     * Creates a new {@link PdbFile} for the given {@link File}.
049     *
050     * @param file
051     *            {@link File} to be opened
052     */
053    public PdbFile(File file) throws FileNotFoundException {
054        super(file, "r");
055    }
056
057    /**
058     * Reads the entire database file and returns a {@link PdbDatabase}. You usually want
059     * to invoke this method, as the other methods are just helpers.
060     *
061     * @param <T>
062     *            {@link Record} subclass the database shall consist of
063     * @param converter
064     *            {@link Converter} that converts the raw database entries into
065     *            {@link Record} objects
066     * @return {@link PdbDatabase} containing the file contents
067     * @throws IOException
068     *             The file could not be read. This can have various reasons, for example
069     *             if the file was no valid PDB file or the converter was not able to
070     *             convert the file's contents.
071     */
072    public <T extends Record, U extends AppInfo> PdbDatabase<T, U> readDatabase(Converter<T, U> converter)
073    throws IOException {
074        PdbDatabase<T, U> result = new PdbDatabase<>();
075
076        // Read the database header
077        seek(0);
078        result.setName(readTerminatedFixedString(32));
079        result.setAttributes(readShort());
080        result.setVersion(readShort());
081        result.setCreationTime(readDate());
082        result.setModificationTime(readDate());
083        result.setBackupTime(readDate());
084        result.setModificationNumber(readInt());
085        int appInfoPos = readInt();
086        int sortInfoPos = readInt();
087        result.setType(readFixedString(4));
088        result.setCreator(readFixedString(4));
089        readInt();                              // Unique ID seed
090        readInt();                              // Next index
091        int records = readShort();
092
093        // Read the entire record list
094        int[] offsets = new int[records];
095        int[] attributes = new int[records];
096        for (int ix = 0; ix < records; ix++) {
097            offsets[ix] = readInt();
098            attributes[ix] = readUnsignedByte();
099            readByte();
100            readShort();
101        }
102
103        // Ask converter if it accepts the content
104        if (!converter.isAcceptable(result)) {
105            throw new IOException("Wrong database format");
106        }
107
108        // Read appInfo if available
109        if (appInfoPos > 0) {
110            int endPos = (records > 0) ? offsets[0] : (int) length();
111            if (sortInfoPos > appInfoPos && sortInfoPos < endPos) {
112                endPos = sortInfoPos;
113            }
114            int size = endPos - appInfoPos;
115
116            seek(appInfoPos);
117            result.setAppInfo(converter.convertAppInfo(this, size, result));
118        }
119
120        // Read each record
121        for (int ix = 0; ix < records; ix++) {
122            if (offsets[ix] >= length()) {
123                continue;
124            }
125
126            int size;
127            if (ix < records - 1) {
128                size = offsets[ix + 1] - offsets[ix];
129            } else {
130                size = ((int) length()) - offsets[ix];
131            }
132
133            seek(offsets[ix]);
134            T entry = converter.convert(this, ix, size, attributes[ix], result);
135            if (entry != null) {
136                result.getRecords().add(entry);
137            }
138        }
139
140        return result;
141    }
142
143    /**
144     * Reads a string of a fixed length, not null terminated.
145     *
146     * @param length
147     *            The length of the string
148     * @return String that was read
149     */
150    public String readFixedString(int length) throws IOException {
151        byte[] data = new byte[length];
152        readFully(data);
153        return convertSpecialChars(new String(data, CHARSET));
154    }
155
156    /**
157     * Reads a string of a fixed length that is null terminated. The given number of bytes
158     * are always read, but the everything including and after the terminator character is
159     * ignored.
160     *
161     * @param length
162     *            The length of the string
163     * @return String that was read
164     */
165    public String readTerminatedFixedString(int length) throws IOException {
166        byte[] data = new byte[length];
167        readFully(data);
168        int pos = 0;
169        while (pos < length) {
170            if (data[pos] == 0) {
171                return new String(data, 0, pos, CHARSET);
172            }
173            pos++;
174        }
175        return convertSpecialChars(new String(data, CHARSET));
176    }
177
178    /**
179     * Reads a string of a variable length that is null terminated.
180     *
181     * @return String that was read
182     */
183    public String readTerminatedString() throws IOException {
184        ByteArrayOutputStream baos = new ByteArrayOutputStream();
185
186        while(true) {
187            int ch = readByte();
188            if (ch == 0) break;
189            baos.write(ch);
190        }
191
192        return convertSpecialChars(baos.toString(CHARSET));
193    }
194
195    /**
196     * Reads an unsigned integer.
197     *
198     * @return Unsigned integer that was read
199     */
200    public long readUnsignedInt() throws IOException {
201        int msb = readUnsignedShort();
202        int lsb = readUnsignedShort();
203        return ((long) msb) << 16 | lsb;
204    }
205
206    /**
207     * Reads a PalmOS date.
208     *
209     * @return Calendar that was read. May be {@code null} if no date was set.
210     */
211    public Calendar readDate() throws IOException {
212        long date = readUnsignedInt();
213        if (date > 0) {
214            Calendar result = cf.createPalmEpoch();
215            result.setTimeInMillis(result.getTimeInMillis() + (date * 1000L));
216            return result;
217        } else {
218            return null;
219        }
220    }
221
222    /**
223     * Reads a packed PalmOS date.
224     *
225     * @return Calendar containing the date read. May be {@code null} if no date
226     * was set. The time part is always midnight local time.
227     */
228    public Calendar readPackedDate() throws IOException {
229        int packed = readUnsignedShort();
230
231        if (packed == 0xFFFF) {
232            return null;
233        }
234
235        int year  = ((packed >> 9) & 0x007F) + CalendarFactory.EPOCH_YEAR;
236        int month = ((packed >> 5) & 0x000F);
237        int day   = ((packed     ) & 0x001F);
238
239        Calendar cal = cf.create();
240        cal.clear();
241        cal.set(year, month - 1, day);
242        return cal;
243    }
244
245    /**
246     * Reads a PalmOS date and time that is stored in seven words.
247     *
248     * @return Calendar that was read
249     */
250    public Calendar readDateTimeWords() throws IOException {
251        int second = readUnsignedShort();
252        int minute = readUnsignedShort();
253        int hour   = readUnsignedShort();
254        int day    = readUnsignedShort();
255        int month  = readUnsignedShort();   // 1..12
256        int year   = readUnsignedShort();   // 4 digits
257        readUnsignedShort();                // day of week, to be ignored...
258
259        Calendar cal = cf.create();
260        cal.clear();
261        cal.set(year, month - 1, day, hour, minute, second);
262        return cal;
263    }
264
265    /**
266     * Reads the categories from a standard appinfo area and fills them into a
267     * {@link CategoryAppInfo} object. After invocation, the file pointer points after the
268     * category part, where further application information may be stored.
269     *
270     * @param appInfo
271     *            {@link CategoryAppInfo} where the categories are stored.
272     * @return Bytes that were actually read from the appinfo area. The file pointer is
273     *         located at the beginning of the appinfo area plus the result of this
274     *         method.
275     */
276    public int readCategories(CategoryAppInfo appInfo)
277    throws IOException {
278        long startPos = getFilePointer();
279
280        // Read the rename flags
281        int renamed = readShort();
282
283        // Read the category names
284        String[] catNames = new String[NUM_CATEGORIES];
285        for (int ix = 0; ix < NUM_CATEGORIES; ix++) {
286            String catName = readTerminatedFixedString(16);
287            if (catName.length() > 0) {
288                catNames[ix] = catName;
289            }
290        }
291
292        // Read the category keys
293        for (int ix = 0; ix < NUM_CATEGORIES; ix++) {
294            int key = readByte();
295
296            if (catNames[ix] != null) {
297                appInfo.getCategories().add(new Category(
298                    catNames[ix],
299                    key,
300                    (renamed & (1 << ix)) != 0
301                ));
302            } else {
303                appInfo.getCategories().add(null);
304            }
305        }
306
307        readByte();     // last unique ID
308        readByte();     // padding
309
310        long endPos = getFilePointer();
311
312        return (int) (endPos - startPos);
313    }
314
315    /**
316     * Converts special PalmOS characters into their unicode equivalents. The string
317     * methods of {@link PdbFile} will invoke this method by itself, so you usually do not
318     * need to invoke it. Note that some very special PalmOS characters cannot be
319     * converted (as there are no unicode equivalents) and will be kept unchanged.
320     *
321     * @param str
322     *            String to be converted
323     * @return Converted string
324     */
325    public static String convertSpecialChars(String str) {
326        return str
327                .replace('\u0018', '\u2026') // Ellipsis
328                .replace('\u0019', '\u2007') // Numeric Space
329                .replace('\u0080', '\u20AC') // Euro
330                .replace('\u0082', '\u201A') // Single Low Quotation Mark
331                .replace('\u0083', '\u0192') // Small F with Hook
332                .replace('\u0084', '\u201E') // Double Low Quotation Mark
333                .replace('\u0085', '\u2026') // Ellipsis
334                .replace('\u0086', '\u2020') // Dagger
335                .replace('\u0087', '\u2021') // Double Dagger
336                .replace('\u0088', '\u0302') // Combining Circumflex Accent
337                .replace('\u0089', '\u2030') // Per Mille
338                .replace('\u008A', '\u0160') // Capital S with Caron
339                .replace('\u008B', '\u2039') // Single Left-pointing Angle Quotation Mark
340                .replace('\u008C', '\u0152') // Capital Ligature OE
341                .replace('\u008D', '\u2662') // Diamond
342                .replace('\u008E', '\u2663') // Club
343                .replace('\u008F', '\u2661') // Heart
344                .replace('\u0090', '\u2660') // Spade
345                .replace('\u0091', '\u2018') // Left Single Quotation Mark
346                .replace('\u0092', '\u2019') // Right Single Quotation Mark
347                .replace('\u0093', '\u201C') // Left Double Quotation Mark
348                .replace('\u0094', '\u201D') // Right Double Quotation Mark
349                .replace('\u0095', '\u2219') // Bullet
350                .replace('\u0096', '\u2011') // Non-breaking Hyphen
351                .replace('\u0097', '\u2012') // Figure Dash
352                .replace('\u0098', '\u0303') // Combining Tilde
353                .replace('\u0099', '\u2122') // Trademark
354                .replace('\u009A', '\u0161') // Small S with Caron
355                .replace('\u009B', '\u203A') // Single Right-pointing Angle Quotation Mark
356                .replace('\u009C', '\u0153') // Small Ligature OE
357                .replace('\u009F', '\u0178') // Capital Y with Diaeresis
358                ;
359    }
360
361}