OSDN Git Service

Merge commit '2458eff3aea04f67893bc824b5cf896fbb767332'
[jindolf/Jindolf.git] / src / main / java / jp / sourceforge / jindolf / CsvExporter.java
diff --git a/src/main/java/jp/sourceforge/jindolf/CsvExporter.java b/src/main/java/jp/sourceforge/jindolf/CsvExporter.java
new file mode 100644 (file)
index 0000000..4481181
--- /dev/null
@@ -0,0 +1,552 @@
+/*\r
+ * CSV file exporter\r
+ *\r
+ * Copyright(c) 2009 olyutorskii\r
+ * $Id: CsvExporter.java 953 2009-12-06 16:42:14Z olyutorskii $\r
+ */\r
+\r
+package jp.sourceforge.jindolf;\r
+\r
+import java.awt.Component;\r
+import java.awt.GridBagConstraints;\r
+import java.awt.GridBagLayout;\r
+import java.awt.Insets;\r
+import java.io.BufferedOutputStream;\r
+import java.io.File;\r
+import java.io.FileNotFoundException;\r
+import java.io.FileOutputStream;\r
+import java.io.IOException;\r
+import java.io.OutputStream;\r
+import java.io.OutputStreamWriter;\r
+import java.io.Writer;\r
+import java.nio.charset.Charset;\r
+import java.nio.charset.CharsetEncoder;\r
+import java.util.LinkedList;\r
+import java.util.List;\r
+import javax.swing.BorderFactory;\r
+import javax.swing.JComboBox;\r
+import javax.swing.JComponent;\r
+import javax.swing.JFileChooser;\r
+import javax.swing.JOptionPane;\r
+import javax.swing.JPanel;\r
+import javax.swing.border.Border;\r
+import javax.swing.filechooser.FileFilter;\r
+import jp.sourceforge.jindolf.corelib.TalkType;\r
+\r
+/**\r
+ * 任意のPeriodの発言内容をCSVファイルへエクスポートする。\r
+ * according to RFC4180 (text/csv)\r
+ * @see <a href="http://www.ietf.org/rfc/rfc4180.txt">RFC4180</a>\r
+ */\r
+public final class CsvExporter{\r
+\r
+    private static final String[] ENCNAMES = {\r
+        "UTF-8",\r
+\r
+        "ISO-2022-JP",\r
+        "ISO-2022-JP-2",\r
+        "ISO-2022-JP-3",\r
+        "ISO-2022-JP-2004",\r
+\r
+        "EUC-JP",\r
+        "x-euc-jp-linux",\r
+        "x-eucJP-Open",\r
+\r
+        "Shift_JIS",\r
+        "windows-31j",\r
+        "x-MS932_0213",\r
+        "x-SJIS_0213",\r
+        "x-PCK",\r
+    };\r
+    private static final String JPCHECK =\r
+          "[]09AZ"\r
+        + "あんアンアンゐゑヵヶヴヰヱヮ"\r
+        + "亜瑤凜熙壷壺尭堯"\r
+        + "峠"\r
+        + "〒╋";\r
+    private static final String CSVEXT = ".csv";\r
+    private static final char CR = '\r';\r
+    private static final char LF = '\n';\r
+    private static final String CRLF = CR +""+ LF;\r
+    private static final int BUFSIZ = 1024;\r
+\r
+    private static final List<Charset> CHARSET_LIST = buildCharsetList();\r
+    private static final FileFilter CSV_FILTER = new CsvFileFilter();\r
+    private static final JComboBox encodeBox = new JComboBox();\r
+    private static final JFileChooser chooser = buildChooser();\r
+    // TODO staticなGUIパーツってどうなんだ…\r
+\r
+    /**\r
+     * Charsetが日本語エンコーダを持っているか確認する。\r
+     * @param cs Charset\r
+     * @return 日本語エンコーダを持っていればtrue\r
+     */\r
+    private static boolean hasJPencoder(Charset cs){\r
+        if( ! cs.canEncode() ) return false;\r
+        CharsetEncoder encoder = cs.newEncoder();\r
+        try{\r
+            if(encoder.canEncode(JPCHECK)) return true;\r
+        }catch(Exception e){\r
+            return false;\r
+            // 一部JRE1.5系の「x-euc-jp-linux」エンコーディング実装には\r
+            // canEncode()が例外を投げるバグがあるので、その対処。\r
+        }\r
+        return false;\r
+    }\r
+\r
+    /**\r
+     * 日本語Charset一覧を生成する。\r
+     * @return 日本語Charset一覧\r
+     */\r
+    private static List<Charset> buildCharsetList(){\r
+        List<Charset> csList = new LinkedList<Charset>();\r
+        for(String name : ENCNAMES){\r
+            if( ! Charset.isSupported(name) ) continue;\r
+            Charset cs = Charset.forName(name);\r
+\r
+            if(csList.contains(cs)) continue;\r
+\r
+            if( ! hasJPencoder(cs) ) continue;\r
+\r
+            csList.add(cs);\r
+        }\r
+\r
+        Charset defcs = Charset.defaultCharset();\r
+        if(   defcs.name().equals("windows-31j")\r
+           && Charset.isSupported("Shift_JIS") ){\r
+            defcs = Charset.forName("Shift_JIS");\r
+        }\r
+\r
+        if( hasJPencoder(defcs) || csList.size() <= 0 ){\r
+            if(csList.contains(defcs)){\r
+               csList.remove(defcs);\r
+            }\r
+            csList.add(0, defcs);\r
+        }\r
+\r
+        return csList;\r
+    }\r
+\r
+    /**\r
+     * チューザーをビルドする。\r
+     * @return チューザー\r
+     */\r
+    private static JFileChooser buildChooser(){\r
+        JFileChooser result = new JFileChooser();\r
+\r
+        result.setFileSelectionMode(JFileChooser.FILES_ONLY);\r
+        result.setMultiSelectionEnabled(false);\r
+        result.setFileHidingEnabled(true);\r
+\r
+        result.setAcceptAllFileFilterUsed(true);\r
+\r
+        result.setFileFilter(CSV_FILTER);\r
+\r
+        JComponent accessory = buildAccessory();\r
+        result.setAccessory(accessory);\r
+\r
+        return result;\r
+    }\r
+\r
+    /**\r
+     * チューザのアクセサリを生成する。\r
+     * エンコード指定のコンボボックス。\r
+     * @return アクセサリ\r
+     */\r
+    private static JComponent buildAccessory(){\r
+        for(Charset cs : CHARSET_LIST){\r
+            encodeBox.addItem(cs);\r
+        }\r
+\r
+        Border border = BorderFactory.createTitledBorder("出力エンコード");\r
+        encodeBox.setBorder(border);\r
+\r
+        JPanel accessory = new JPanel();\r
+        GridBagLayout layout = new GridBagLayout();\r
+        GridBagConstraints constraints = new GridBagConstraints();\r
+        accessory.setLayout(layout);\r
+\r
+        constraints.insets = new Insets(3, 3, 3, 3);\r
+        constraints.gridwidth = GridBagConstraints.REMAINDER;\r
+        constraints.fill = GridBagConstraints.NONE;\r
+        constraints.weightx = 0.0;\r
+        constraints.weighty = 0.0;\r
+        constraints.anchor = GridBagConstraints.NORTHWEST;\r
+\r
+        accessory.add(encodeBox, constraints);\r
+\r
+        constraints.fill = GridBagConstraints.BOTH;\r
+        constraints.weightx = 1.0;\r
+        constraints.weighty = 1.0;\r
+\r
+        accessory.add(new JPanel(), constraints);  // dummy\r
+\r
+        return accessory;\r
+    }\r
+\r
+    /**\r
+     * ファイルに書き込めない/作れないエラー用のダイアログを表示する。\r
+     * @param file 書き込もうとしたファイル。\r
+     */\r
+    private static void writeError(File file){\r
+        Component parent = null;\r
+        String title = "ファイル書き込みエラー";\r
+        String message = "ファイル「" + file.toString() + "」\n"\r
+                        +"に書き込むことができません。";\r
+\r
+        JOptionPane.showMessageDialog(parent, message, title,\r
+                                      JOptionPane.ERROR_MESSAGE );\r
+\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * ファイル上書き確認ダイアログを表示する。\r
+     * @param file 上書き対象ファイル\r
+     * @return 上書きOKが指示されたらtrue\r
+     */\r
+    private static boolean confirmOverwrite(File file){\r
+        Component parent = null;\r
+        String title = "上書き確認";\r
+        String message = "既存のファイル「" + file.toString() + "」\n"\r
+                        +"を上書きしようとしています。続けますか?";\r
+\r
+        int confirm = JOptionPane.showConfirmDialog(\r
+                parent, message, title,\r
+                JOptionPane.WARNING_MESSAGE,\r
+                JOptionPane.OK_CANCEL_OPTION );\r
+\r
+        if(confirm == JOptionPane.OK_OPTION) return true;\r
+\r
+        return false;\r
+    }\r
+\r
+    /**\r
+     * チューザーのタイトルを設定する。\r
+     * @param period エクスポート対象の日\r
+     */\r
+    private static void setTitle(Period period){\r
+        Village village = period.getVillage();\r
+        String villageName = village.getVillageName();\r
+        String title = villageName + "村 " + period.getCaption();\r
+        title += "の発言をCSVファイルへエクスポートします";\r
+        chooser.setDialogTitle(title);\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * エクスポート先ファイルの名前を生成する。\r
+     * @param period エクスポート対象の日\r
+     * @return エクスポートファイル名\r
+     */\r
+    private static String createUniqueFileName(Period period){\r
+        Village village = period.getVillage();\r
+        String villageName = village.getVillageName();\r
+\r
+        String base = "JIN_" + villageName;\r
+\r
+        switch(period.getType()){\r
+        case PROLOGUE:\r
+            base += "_Prologue";\r
+            break;\r
+        case EPILOGUE:\r
+            base += "_Epilogue";\r
+            break;\r
+        case PROGRESS:\r
+            base += "_Day";\r
+            base += period.getDay();\r
+            break;\r
+        default:\r
+            assert false;\r
+            break;\r
+        }\r
+\r
+        File saveFile;\r
+        String csvName;\r
+        int serial = 1;\r
+        do{\r
+            csvName = base;\r
+            if(serial > 1){\r
+                csvName += "("+ serial +")";\r
+            }\r
+            serial++;\r
+            csvName += CSVEXT;\r
+\r
+            File current = chooser.getCurrentDirectory();\r
+            saveFile = new File(current, csvName);\r
+        }while(saveFile.exists());\r
+\r
+        return csvName;\r
+    }\r
+\r
+    /**\r
+     * Period情報をダンプする。\r
+     * @param out 格納先\r
+     * @param period ダンプ対象Period\r
+     * @param topicFilter 発言フィルタ\r
+     * @throws java.io.IOException 出力エラー\r
+     */\r
+    private static void dumpPeriod(Appendable out,\r
+                                    Period period,\r
+                                    TopicFilter topicFilter)\r
+            throws IOException{\r
+        String day = String.valueOf(period.getDay());\r
+\r
+        List<Topic> topicList = period.getTopicList();\r
+        for(Topic topic : topicList){\r
+            if( ! (topic instanceof Talk) ) continue;\r
+            Talk talk = (Talk) topic;\r
+            if(talk.getTalkCount() <= 0) continue;\r
+\r
+            if(topicFilter.isFiltered(talk)) continue;\r
+\r
+            Avatar avatar = talk.getAvatar();\r
+\r
+            String name = avatar.getName();\r
+            int hour   = talk.getHour();\r
+            int minute = talk.getMinute();\r
+            TalkType type = talk.getTalkType();\r
+            CharSequence dialog = talk.getDialog();\r
+\r
+            out.append(name).append(',');\r
+\r
+            out.append(day).append(',');\r
+\r
+            out.append(Character.forDigit(hour / 10, 10));\r
+            out.append(Character.forDigit(hour % 10, 10));\r
+            out.append(':');\r
+            out.append(Character.forDigit(minute / 10, 10));\r
+            out.append(Character.forDigit(minute % 10, 10));\r
+            out.append(',');\r
+\r
+            switch(type){\r
+            case PUBLIC:   out.append("say");     break;\r
+            case PRIVATE:  out.append("think");   break;\r
+            case WOLFONLY: out.append("whisper"); break;\r
+            case GRAVE:    out.append("groan");   break;\r
+            default: assert false;                break;\r
+            }\r
+            out.append(',');\r
+\r
+            escapeCSV(out, dialog);\r
+            out.append(CRLF);\r
+        }\r
+\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * ダイアログ操作に従いPeriodをエクスポートする。\r
+     * @param period エクスポート対象のPeriod\r
+     * @param topicFilter 発言フィルタ\r
+     * @return エクスポートしたファイル\r
+     */\r
+    public static File exportPeriod(Period period, TopicFilter topicFilter){\r
+        setTitle(period);\r
+\r
+        String uniqName = createUniqueFileName(period);\r
+        File uniqFile = new File(uniqName);\r
+        chooser.setSelectedFile(uniqFile);\r
+\r
+        int result = chooser.showSaveDialog(null);\r
+\r
+        if(result != JFileChooser.APPROVE_OPTION) return null;\r
+\r
+        File selected = chooser.getSelectedFile();\r
+\r
+        if( ! hasExtent(selected.getName()) ){\r
+            FileFilter filter = chooser.getFileFilter();\r
+            if(filter == CSV_FILTER){\r
+                String path = selected.getPath();\r
+                path += CSVEXT;\r
+                selected = new File(path);\r
+            }\r
+        }\r
+\r
+        if(selected.exists()){\r
+            if( ! selected.isFile() || ! selected.canWrite() ){\r
+                writeError(selected);\r
+                return null;\r
+            }\r
+            boolean confirmed = confirmOverwrite(selected);\r
+            if( ! confirmed ) return null;\r
+        }else{\r
+            boolean created;\r
+            try{\r
+                created = selected.createNewFile();\r
+            }catch(IOException e){\r
+                writeError(selected);\r
+                return null;\r
+            }\r
+\r
+            if( ! created ){\r
+                boolean confirmed = confirmOverwrite(selected);\r
+                if( ! confirmed ) return null;\r
+            }\r
+        }\r
+\r
+        OutputStream os;\r
+        try{\r
+            os = new FileOutputStream(selected);\r
+        }catch(FileNotFoundException e){\r
+            writeError(selected);\r
+            return null;\r
+        }\r
+        os = new BufferedOutputStream(os, BUFSIZ);\r
+\r
+        Charset cs = (Charset)( encodeBox.getSelectedItem() );\r
+\r
+        boolean hasIOError = false;\r
+        Writer writer = new OutputStreamWriter(os, cs);\r
+        try{\r
+            dumpPeriod(writer, period, topicFilter);\r
+        }catch(IOException e){\r
+            hasIOError = true;\r
+        }finally{\r
+            try{\r
+                writer.close();\r
+            }catch(IOException e){\r
+                hasIOError = true;\r
+            }\r
+        }\r
+        if(hasIOError) writeError(selected);\r
+\r
+        return selected;\r
+    }\r
+\r
+    /**\r
+     * CSV用のエスケープシーケンス処理を行う。\r
+     * RFC4180準拠。\r
+     * @param app 格納先\r
+     * @param seq エスケープシーケンス対象\r
+     * @return appと同じもの\r
+     * @throws java.io.IOException 出力エラー\r
+     */\r
+    public static Appendable escapeCSV(Appendable app, CharSequence seq)\r
+            throws IOException{\r
+        app.append('"');\r
+\r
+        int length = seq.length();\r
+\r
+        for(int pos = 0; pos < length; pos++){\r
+            char ch = seq.charAt(pos);\r
+            switch(ch){\r
+            case '"':\r
+                app.append("\"\"");\r
+                continue;\r
+            case '\n':\r
+                app.append(CRLF);\r
+                continue;\r
+            default:\r
+                app.append(ch);\r
+                break;\r
+            }\r
+        }\r
+\r
+        app.append('"');\r
+\r
+        return app;\r
+    }\r
+\r
+    /**\r
+     * ファイル名が任意の拡張子を持つか判定する。\r
+     * 英字大小は同一視される。\r
+     * 拡張子の前は必ず一文字以上何かがなければならない。\r
+     * @param filename ファイル名\r
+     * @param extent '.'で始まる拡張子文字列\r
+     * @return 指定された拡張子を持つならtrue\r
+     */\r
+    public static boolean hasExtent(CharSequence filename,\r
+                                     CharSequence extent ){\r
+        int flength = filename.length();\r
+        int elength = extent  .length();\r
+        if(elength < 2) return false;\r
+        if(flength <= elength) return false;\r
+\r
+        if(filename.charAt(0) == '.') return false;\r
+\r
+        int offset = flength - elength;\r
+        assert offset > 0;\r
+\r
+        for(int pos = 0; pos < elength; pos++){\r
+            char ech = Character.toLowerCase(extent  .charAt(pos         ));\r
+            char fch = Character.toLowerCase(filename.charAt(pos + offset));\r
+            if(fch != ech) return false;\r
+        }\r
+\r
+        return true;\r
+    }\r
+\r
+    /**\r
+     * パス名抜きのファイル名が拡張子を持つか判定する。\r
+     * 先頭が.で始まるファイル名は拡張子を持たない。\r
+     * 末尾が.で終わるファイル名は拡張子を持たない。\r
+     * それ以外の.を含むファイル名は拡張子を持つとみなす。\r
+     * @param filename パス名抜きのファイル名\r
+     * @return 拡張子を持っていればtrue\r
+     */\r
+    public static boolean hasExtent(CharSequence filename){\r
+        int length = filename.length();\r
+        if(length < 3) return false;\r
+\r
+        if(filename.charAt(0) == '.') return false;\r
+        int lastPos = length - 1;\r
+        if(filename.charAt(lastPos) == '.') return false;\r
+\r
+        for(int pos = 1; pos <= lastPos - 1; pos++){\r
+            char ch = filename.charAt(pos);\r
+            if(ch == '.') return true;\r
+        }\r
+\r
+        return false;\r
+    }\r
+\r
+    /**\r
+     * 隠しコンストラクタ。\r
+     */\r
+    private CsvExporter(){\r
+        assert false;\r
+        throw new AssertionError();\r
+    }\r
+\r
+    /**\r
+     * CSVファイル表示用フィルタ。\r
+     * 名前が「*.csv」の通常ファイルとディレクトリのみ表示させる。\r
+     * ※ 表示の可否を問うものであって、選択の可否を問うものではない。\r
+     */\r
+    private static class CsvFileFilter extends FileFilter{\r
+\r
+        /**\r
+         * コンストラクタ。\r
+         */\r
+        public CsvFileFilter(){\r
+            super();\r
+            return;\r
+        }\r
+\r
+        /**\r
+         * {@inheritDoc}\r
+         * @param file {@inheritDoc}\r
+         * @return {@inheritDoc}\r
+         */\r
+        public boolean accept(File file){\r
+            if(file.isDirectory()) return true;\r
+            if( ! file.isFile() ) return false;\r
+\r
+            if( ! hasExtent(file.getName(), CSVEXT) ) return false;\r
+\r
+            return true;\r
+        }\r
+\r
+        /**\r
+         * {@inheritDoc}\r
+         * @return {@inheritDoc}\r
+         */\r
+        public String getDescription(){\r
+            return "CSVファイル (*.csv)";\r
+        }\r
+    }\r
+\r
+    // TODO SecurityExceptionの捕捉\r
+    // 書き込み中のファイルロック\r
+}\r