OSDN Git Service

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