Revision 47656

View differences:

trunk/org.gvsig.desktop/org.gvsig.desktop.compat.cdc/org.gvsig.fmap.dal/org.gvsig.fmap.dal.file/org.gvsig.fmap.dal.file.csv/src/main/java/org/gvsig/fmap/dal/store/csv/simplereaders/CSVReaderSuperCSV.java
112 112
        try {
113 113
            CSVStoreParameters params = getParameters();
114 114
            File data_file = CSVStoreParameters.getFile(params);
115
            if( data_file.length()< 10*1024*1024 ) {
116
                return null;
117
            }
115
//            if( data_file.length()< 10*1024*1024 ) {
116
//                return null;
117
//            }
118 118
            String charset = CSVStoreParameters.getCharset(params);
119 119
            File index_file = getIndexFile(data_file);
120 120
            
trunk/org.gvsig.desktop/org.gvsig.desktop.compat.cdc/org.gvsig.fmap.dal/org.gvsig.fmap.dal.file/org.gvsig.fmap.dal.file.csv/src/main/java/org/gvsig/fmap/dal/store/csv/CSVStoreProvider.java
119 119
                        if (virtualrows == null) {
120 120
                            List<FeatureProvider> newdata = new ArrayList<>();
121 121
                            FeatureType ftype = getStoreServices().getDefaultFeatureType();
122
                            writer.initialize(getCSVParameters(), file, ftype, getCSVPreferences());
122
                            writer.initialize(getCSVParameters(), file, ftype, getCSVPreferences(), spatialIndexes);
123 123
                            writer.begin();
124 124
                            it = features.fastIterator();
125 125
                            taskStatus.setRangeOfValues(0, features.getSize());
......
136 136
                                newdata.add(feature);
137 137
                            }
138 138
                            data = newdata;
139
                            if (writer.getEnvelope() != null) {
140
                                envelope = writer.getEnvelope();
139
                            if (writer.getEnvelopes() != null) {
140
                                envelopes = writer.getEnvelopes();
141 141
                            } else {
142
                                envelope = null;
142
                                envelopes = null;
143 143
                            }
144 144
                            resource.notifyChanges();
145 145
                            writer.end();
146
                            bboxFileSave(envelope);
146
                            bboxFileSave(envelopes);
147 147
                        } else {
148 148

  
149 149
                            CSVStoreParameters csvParams = getCSVParameters();
......
154 154
                            tmpParams.setFile(tmpFile);
155 155

  
156 156
                            FeatureType ftype = getStoreServices().getDefaultFeatureType();
157
                            writer.initialize(tmpParams, tmpFile, ftype, getCSVPreferences(),spatialIndex);
157
                            writer.initialize(tmpParams, tmpFile, ftype, getCSVPreferences(),spatialIndexes);
158 158
                            writer.begin();
159 159
                            it = features.fastIterator();
160 160
                            taskStatus.setIndeterminate();
......
174 174
                                    feature.setOID(createNewOID());
175 175
                                }
176 176
                            }
177
                            if (writer.getEnvelope() != null) {
178
                                envelope = writer.getEnvelope();
177
                            if (writer.getEnvelopes() != null) {
178
                                envelopes = writer.getEnvelopes();
179 179
                            } else {
180
                                envelope = null;
180
                                envelopes = null;
181 181
                            }
182 182
                            resource.notifyChanges();
183 183
                            writer.end();
......
194 194
                                FileUtils.moveFile(tmpParams.getFile(), csvFile);
195 195
                                FileUtils.delete(new File(FilenameUtils.removeExtension(csvFile.getAbsolutePath())+".idx"));
196 196

  
197
                                bboxFileSave(envelope);
197
                                bboxFileSave(envelopes);
198 198

  
199 199
                                loadFeatures();
200 200
                            }
......
248 248
        getResource().execute(new ResourceAction() {
249 249
            @Override
250 250
            public Object run() throws Exception {
251
                writer.initialize(
252
                        getCSVParameters(),
251
                writer.initialize(getCSVParameters(),
253 252
                        getCSVParameters().getFile(),
254 253
                        getFeatureStore().getDefaultFeatureType(),
255
                        getCSVPreferences()
254
                        getCSVPreferences(),
255
                        spatialIndexes
256 256
                );
257 257
                writer.beginAppend();
258 258
                return null;
......
274 274
                    return null;
275 275
                }
276 276
            });
277
            if (writer.getEnvelope() != null) {
278
                envelope = writer.getEnvelope();
277
            if (writer.getEnvelopes() != null) {
278
                envelopes = writer.getEnvelopes();
279 279
            } else {
280
                envelope = null;
280
                envelopes = null;
281 281
            }
282 282
            writer.end();
283 283
            this.close();
284
            bboxFileSave(envelope);            
284
            bboxFileSave(envelopes);            
285 285
            
286 286
        } catch (Exception ex) {
287 287
            LOGGER.warn("Not been able to end append '" + this.getFullName() + "'.", ex);
trunk/org.gvsig.desktop/org.gvsig.desktop.compat.cdc/org.gvsig.fmap.dal/org.gvsig.fmap.dal.file/org.gvsig.fmap.dal.file.csv/src/main/java/org/gvsig/fmap/dal/store/csv/CSVFeatureWriter.java
11 11
import java.io.OutputStreamWriter;
12 12
import java.io.Writer;
13 13
import java.nio.file.Files;
14
import java.util.HashMap;
14 15
import java.util.Locale;
16
import java.util.Map;
15 17
import org.apache.commons.lang3.ArrayUtils;
16 18
import org.apache.commons.lang3.StringUtils;
17 19
import org.apache.commons.text.StringEscapeUtils;
......
29 31
import org.gvsig.fmap.geom.operation.GeometryOperationNotSupportedException;
30 32
import org.gvsig.fmap.geom.primitive.Envelope;
31 33
import org.gvsig.tools.ToolsLocator;
34
import org.gvsig.tools.dataTypes.Coercion;
32 35
import org.gvsig.tools.dataTypes.CoercionException;
33
import org.gvsig.tools.dataTypes.Coercion;
34 36
import org.gvsig.tools.dynobject.Tags;
35 37
import org.slf4j.Logger;
36 38
import org.slf4j.LoggerFactory;
......
46 48

  
47 49
  private static final Logger LOGGER = LoggerFactory.getLogger(CSVFeatureWriter.class);
48 50

  
49
  private Envelope envelope = null;
51
  private Map<String,Envelope> envelopes = null;
50 52
  private boolean calculate_envelope = false;
51 53
  private CsvListWriter listWriter = null;
52 54
  private CsvPreference csvpreferences = null;
......
64 66
  private boolean includeMetadataInHeader;
65 67
    private Locale locale;
66 68
    private GeometryManager geometryManager;
67
    private SpatialIndex spatialIndex;
69
    private Map<String,SpatialIndex> spatialIndexes;
68 70

  
69 71
  public void initialize(
70 72
          CSVStoreParameters storeParameters, 
......
80 82
          File file, 
81 83
          FeatureType ftype, 
82 84
          CsvPreference csvpreferences,
83
          SpatialIndex spatialIndex
85
          Map<String,SpatialIndex> spatialIndex
84 86
    ) {
85 87
    this.file = file;
86 88
    this.ftype = ftype;
......
104 106
    this.includeMetadataInHeader = CSVStoreParameters.getIncludeMetadataInHeader(storeParameters);
105 107
    this.locale = CSVStoreParameters.getLocale(storeParameters);
106 108
    this.geometryManager = GeometryLocator.getGeometryManager();
107
    this.spatialIndex = spatialIndex;
109
    this.spatialIndexes = spatialIndex;
108 110
    if( spatialIndex != null ) {
109
        spatialIndex.removeAll();
111
        for (SpatialIndex index : spatialIndex.values()) {
112
            index.removeAll();
113
        }
110 114
    }
111

  
112 115
  }
113 116

  
114 117
  public void beginAppend() {
......
198 201

  
199 202
  public void add(FeatureProvider feature) {
200 203
    if (this.calculate_envelope) {
201
      Geometry geom = feature.getDefaultGeometry();
202
      if (geom != null) {
203
        if (envelope == null) {
204
          try {
205
            envelope = (Envelope) geom.getEnvelope().clone();
206
          } catch (CloneNotSupportedException e) {
207
            LOGGER.warn("Este error no deberia pasar, siempre se puede hacer un clone de un envelope.", e);
208
          }
209
        } else {
210
          envelope.add(geom.getEnvelope());
204
        if (envelopes == null) {
205
            envelopes = new HashMap<>();
211 206
        }
212
      }
207
        for (FeatureAttributeDescriptor featureAttributeDescriptor : ftype) {
208
            if(featureAttributeDescriptor.getType()==DataTypes.GEOMETRY){
209
                String geomName = featureAttributeDescriptor.getName();
210
                Geometry geom = (Geometry) feature.get(geomName);
211
                Envelope env = envelopes.get(geomName);
212
                if (env == null) {
213
                    if (geom == null) {
214
                        envelopes.put(geomName, null);
215
                    } else {
216
                        try {
217
                            envelopes.put(geomName, (Envelope) geom.getEnvelope().clone());
218
                        } catch (CloneNotSupportedException e) {
219
                            LOGGER.warn("Este error no deberia pasar, siempre se puede hacer un clone de un envelope.", e);
220
                        }
221
                    }
222
                } else {
223
                    if (geom != null){
224
                        env.add(geom.getEnvelope());
225
                    }
226
                }
227
            }
228
        }
213 229
    }
214
    if( spatialIndex!=null ) {
215
        Geometry geom = feature.getDefaultGeometry();
216
        this.spatialIndex.insert(geom, feature.getOID());
230

  
231
    if(spatialIndexes == null){
232
        spatialIndexes = new HashMap<>();
217 233
    }
218 234

  
219 235
    for (int i = 0; i < descriptors.length; i++) {
......
222 238
        Object value = feature.get(i);
223 239
        try {
224 240
          if( descriptor.getType()==DataTypes.GEOMETRY ) {
225
              if(value != null){
241
              String geomName = descriptor.getName();
242
              Geometry geom = (Geometry) value;
243
              if(geom != null){
244
                SpatialIndex index = spatialIndexes.get(geomName);
245
                if(index != null){
246
                    index.insert(geom, feature.getOID());
247
                }
226 248
                if( StringUtils.equalsIgnoreCase("WKT", CSVStoreParameters.getGeometryFormat(storeParameters))) {
227
                  values[i] = ((Geometry)value).convertToWKT();
249
                  values[i] = geom.convertToWKT();
228 250
                } else {
229
                  values[i] = ((Geometry)value).convertToHexWKB();
251
                  values[i] = geom.convertToHexWKB();
230 252
                }
231 253
              }
232 254
          } else {
......
292 314
    } catch (Exception ex){
293 315
        LOGGER.warn("Can't delete index file '"+(indexFile == null?"null":indexFile.getAbsolutePath())+"'", ex);
294 316
    }
295
    if( spatialIndex!=null ) {
296
        spatialIndex.flush();
317
    if( spatialIndexes!=null ) {
318
        for (SpatialIndex index : spatialIndexes.values()) {
319
            index.flush();
320
        }
297 321
    }
298 322
  }
299 323

  
300
  public Envelope getEnvelope() {
301
    return this.envelope;
324
  public Map<String,Envelope> getEnvelopes() {
325
    return this.envelopes;
302 326
  }
303 327
}
trunk/org.gvsig.desktop/org.gvsig.desktop.compat.cdc/org.gvsig.fmap.dal/org.gvsig.fmap.dal.file/org.gvsig.fmap.dal.file.lib/src/main/java/org/gvsig/fmap/dal/store/simplereader/SimpleReaderStoreProvider.java
34 34
import java.util.HashMap;
35 35
import java.util.Iterator;
36 36
import java.util.List;
37
import java.util.Map;
37 38
import java.util.Objects;
38 39
import org.apache.commons.io.FileUtils;
39 40
import org.apache.commons.io.FilenameUtils;
40 41
import org.apache.commons.io.IOUtils;
42
import org.apache.commons.lang3.StringUtils;
41 43
import org.cresques.cts.IProjection;
42 44
import org.gvsig.fmap.dal.DALLocator;
43 45
import org.gvsig.fmap.dal.DataManager;
......
116 118
    protected final ResourceProvider resource;
117 119

  
118 120
    protected long counterNewsOIDs = 0;
119
    protected Envelope envelope;
121
    protected Map<String,Envelope> envelopes;
120 122
    protected boolean need_calculate_envelope = false;
121 123
    protected final SimpleTaskStatus taskStatus;
122 124
    protected FeatureType featureType;
123 125
    protected GetItemWithSize64<List<String>> virtualrows;
124 126
    protected RowToFeatureTranslator rowToFeatureTranslator;
125
    protected SpatialIndex spatialIndex;
127
    protected Map<String,SpatialIndex> spatialIndexes;
126 128
    
127 129
    @SuppressWarnings({"OverridableMethodCallInConstructor", "LeakingThisInConstructor"})
128 130
    public SimpleReaderStoreProvider(
......
139 141
        this.taskStatus = manager.createDefaultSimpleTaskStatus("CSV");
140 142
        this.taskStatus.setAutoremove(true);
141 143

  
144
        this.envelopes = new HashMap<>();
142 145
        counterNewsOIDs = 0;
143 146

  
144 147
        File file = getSimpleReaderParameters().getFile();
......
273 276
    @SuppressWarnings("Convert2Lambda")
274 277
    public Envelope getEnvelope() throws DataException {
275 278
        this.open();
276
        if (this.envelope != null) {
277
            return this.envelope;
279
        FeatureAttributeDescriptor geomdesc = this.featureType.getDefaultGeometryAttribute();
280
        String geomName = geomdesc.getName();
281
        Envelope env = this.envelopes.get(geomName);
282
        if (env != null) {
283
            return env;
278 284
        }
279
        this.envelope = bboxFileLoad();
280
        if (this.envelope != null) {
281
            return this.envelope;
285
        env = bboxFileLoad("_"+geomName);
286
        if (env != null) {
287
            this.envelopes.put(geomName, env);
288
            return env;
282 289
        }
283 290
        if (!this.need_calculate_envelope) {
284 291
            return null;
......
291 298
            FeatureType ft = fs.getDefaultFeatureType();
292 299
            FeatureAttributeDescriptor fad = ft.getAttributeDescriptor(ft.getDefaultGeometryAttributeIndex());
293 300
            this.taskStatus.setRangeOfValues(0, fs.getFeatureCount());
294
            this.envelope = GeometryLocator.getGeometryManager().createEnvelope(fad.getGeomType().getSubType());
301
            env = GeometryLocator.getGeometryManager().createEnvelope(fad.getGeomType().getSubType());
295 302
            fs.accept(new Visitor() {
296 303
                @Override
297 304
                public void visit(Object obj) throws VisitCanceledException, BaseException {
......
305 312
                    if (geom != null) {
306 313
                        try {
307 314
                            Envelope env = geom.getEnvelope();
308
                            envelope.add(env);
315
                            env.add(env);
309 316
                        } catch(Exception ex) {
310 317
                            LOGGER.warn("Can't calculate envelop of geometry in feature '"+Objects.toString(f.getReference())+"'.",ex);
311 318
                        }
312 319
                    }
313 320
                }
314 321
            });
315
            bboxFileSave(envelope);
322
            bboxFileSave("_"+geomName,env);
316 323
            taskStatus.terminate();
317 324
        } catch (VisitCanceledException e) {
318 325
            return null;
319 326
        } catch (BaseException e) {
320 327
            taskStatus.abort();
321
            LOGGER.warn("Can't calculate the envelope of CSV file '" + this.getFullName() + "'.", e);
328
            LOGGER.warn("Can't calculate the envelope of file '" + this.getFullName() + "'.", e);
322 329
            return null;
323 330
        }
324 331

  
325 332
        this.need_calculate_envelope = false;
326
        return this.envelope;
333
        return env;
327 334
    }
328 335

  
329 336
    @Override
......
771 778
        if(this.virtualrows != null && this.virtualrows instanceof Closeable){
772 779
            IOUtils.closeQuietly((Closeable) this.virtualrows);
773 780
            this.virtualrows = null;
774
            this.envelope = null;
775
            this.spatialIndex = null;
781
            this.envelopes = null;
782
            this.spatialIndexes = null;
776 783
        }
777 784
        
778 785
    }
......
782 789
        FeatureSetProvider set = null;
783 790
        DisposableIterator<FeatureProvider> it = null;
784 791
        try {
785
            if( this.virtualrows == null ) {
792
            if (this.virtualrows == null) {
786 793
                return;
787 794
            }
788
            FeatureAttributeDescriptor geomdesc = this.featureType.getDefaultGeometryAttribute();
789
            if( geomdesc == null ) {
790
                return;
791
            }
792
//            String indexTypeName = "MVRTree";
793
//            String extname = "mvtree";
794
            String indexTypeName = GeometryManager.SPATIALINDEX_DEFAULT_QUADTREE;
795
            String extname = "qtree";
796 795
            
797
            this.envelope = bboxFileLoad();
798
            File indexfile = this.getAuxFile(extname); 
799
            boolean createIndex = !indexfile.exists();
796
            this.spatialIndexes = new HashMap<>();
800 797

  
801
            GeometryManager geomManager = GeometryLocator.getGeometryManager();
802
            SpatialIndexFactory indexfactory = geomManager.getSpatialIndexFactory(indexTypeName);
803
            DynObject params = indexfactory.createParameters();
804
            params.setDynValue("file", indexfile);
805
            SpatialIndex index = geomManager.createSpatialIndex(indexTypeName, params);
806
            if( createIndex ) { 
807
                I18nManager i18n = ToolsLocator.getI18nManager();
808
                this.taskStatus.add();
809
                taskStatus.message(i18n.getTranslation("_Creating_spatial_index"));
810
                taskStatus.setRangeOfValues(0, this.virtualrows.size64());
811
                taskStatus.setCurValue(0);
812
                Envelope theEnvelope = geomManager.createEnvelope(Geometry.SUBTYPES.GEOM2D);
813
                set = this.createSet(null, featureType);
814
                it = set.fastIterator();
815
                while( it.hasNext() ) {
816
                    taskStatus.incrementCurrentValue();
817
                    if( taskStatus.isCancellationRequested() ) {
818
                        taskStatus.cancel();
819
                        LOGGER.info("Spatial index creation cancelled ("+getFullFileName()+")");
820
                        break;
798
            for (FeatureAttributeDescriptor geomdesc : this.featureType) {
799
                if (geomdesc.getType() != DataTypes.GEOMETRY) {
800
                    continue;
801
                }
802
                String indexTypeName = GeometryManager.SPATIALINDEX_DEFAULT_QUADTREE;
803
                String extname = "qtree";
804
                String geomName = geomdesc.getName();
805
                Envelope env = bboxFileLoad("_"+geomName);
806
                File indexfile = this.getAuxFile("_"+geomName, extname);
807
                boolean createIndex = !indexfile.exists();
808

  
809
                GeometryManager geomManager = GeometryLocator.getGeometryManager();
810
                SpatialIndexFactory indexfactory = geomManager.getSpatialIndexFactory(indexTypeName);
811
                DynObject params = indexfactory.createParameters();
812
                params.setDynValue("file", indexfile);
813
                SpatialIndex index = geomManager.createSpatialIndex(indexTypeName, params);
814
                if (createIndex) {
815
                    I18nManager i18n = ToolsLocator.getI18nManager();
816
                    this.taskStatus.add();
817
                    taskStatus.message(i18n.getTranslation("_Creating_spatial_index"));
818
                    taskStatus.setRangeOfValues(0, this.virtualrows.size64());
819
                    taskStatus.setCurValue(0);
820
                    Envelope theEnvelope = geomManager.createEnvelope(Geometry.SUBTYPES.GEOM2D);
821
                    set = this.createSet(null, featureType);
822
                    it = set.fastIterator();
823
                    while (it.hasNext()) {
824
                        taskStatus.incrementCurrentValue();
825
                        if (taskStatus.isCancellationRequested()) {
826
                            taskStatus.cancel();
827
                            LOGGER.info("Spatial index creation cancelled (" + getFullFileName() + ")");
828
                            break;
829
                        }
830
                        FeatureProvider f = it.next();
831
                        if (f == null) {
832
                            continue;
833
                        }
834
                        Object oid = null;
835
                        try {
836
                            oid = f.getOID();
837
                            Geometry geom = (Geometry) f.get(geomdesc.getName());
838
                            if (geom != null) {
839
                                index.insert(geom, oid);
840
                                theEnvelope.add(geom);
841
                            }
842
                        } catch (Throwable ex) {
843
                            LOGGER.debug("Can't insert feature '" + Objects.toString(oid) + "' in spatial index.", ex);
844
                        }
821 845
                    }
822
                    FeatureProvider f = it.next();
823
                    if( f == null ) {
824
                        continue;
846
                    taskStatus.message(i18n.getTranslation("_Saving_spatial_index"));
847
                    taskStatus.setIndeterminate();
848
                    index.flush();
849
                    if (!theEnvelope.isEmpty()) {
850
                        bboxFileSave("_"+geomName, theEnvelope);
825 851
                    }
826
                    Object oid = null;
827
                    try {
828
                        oid = f.getOID();
829
                        Geometry geom = (Geometry) f.get(geomdesc.getName());
830
                        if(geom!= null){
831
                            index.insert(geom, oid);
832
                            theEnvelope.add(geom);
833
                        }
834
                    } catch(Throwable ex) {
835
                        LOGGER.debug("Can't insert feature '"+Objects.toString(oid)+"' in spatial index.",ex);
836
                    }
852
                    this.envelopes.put(geomName,theEnvelope);
853
                } else {
854
                    this.envelopes.put(geomName,env);
837 855
                }
838
                taskStatus.message(i18n.getTranslation("_Saving_spatial_index"));
839
                taskStatus.setIndeterminate();
840
                index.flush();
841
                if(!theEnvelope.isEmpty()){
842
                    bboxFileSave(theEnvelope);
843
                }
844
                taskStatus.terminate();
845
                this.envelope = theEnvelope;
856
                this.spatialIndexes.put(geomdesc.getName(), index);
846 857
            }
847
            this.spatialIndex = index;
858
            taskStatus.terminate();
848 859
        } catch (Exception ex) {
849 860
            taskStatus.abort();
850
            LOGGER.warn("Can't create spatial index.",ex);
861
            LOGGER.warn("Can't create spatial index.", ex);
851 862
        } finally {
852 863
            DisposeUtils.disposeQuietly(it);
853 864
            DisposeUtils.disposeQuietly(set);
......
856 867
    }
857 868

  
858 869
    public File getAuxFile(String extension) {
870
        return getAuxFile(null, extension);
871
    }
872
    
873
    public File getAuxFile(String suffix, String extension) {
859 874
        File data_file = SimpleReaderStoreParameters.getFile(this.getSimpleReaderParameters());
860 875
        if (data_file == null){
861 876
            return null;
862 877
        }
863
        File index_file = new File(FilenameUtils.removeExtension(data_file.getAbsolutePath()) + "." + extension);
878
        File index_file;
879
        if(StringUtils.isBlank(suffix)){
880
            index_file = new File(FilenameUtils.removeExtension(data_file.getAbsolutePath()) + "." + extension);
881
        } else {
882
            index_file = new File(FilenameUtils.removeExtension(data_file.getAbsolutePath()) + suffix + "." + extension);
883
        }
864 884
        return index_file;
865 885
    }
866 886

  
867 887
    public SpatialIndex getSpatialIndex() {
868
        return spatialIndex;
888
        if(spatialIndexes == null){
889
            return null;
890
        }
891
        FeatureAttributeDescriptor geomdesc = this.featureType.getDefaultGeometryAttribute();
892
        if(geomdesc == null){
893
            return null;
894
        }
895
        return spatialIndexes.get(geomdesc.getName());
869 896
    }
870 897

  
898
    protected void bboxFileSave(Map<String,Envelope> envelopes) {
899
        for (Map.Entry<String, Envelope> entry : envelopes.entrySet()) {
900
            String key = entry.getKey();
901
            Envelope val = entry.getValue();
902
            bboxFileSave("_"+key, val);
903
        }
904
    }
905
    
871 906
    protected void bboxFileSave(Envelope envelope) {
872
        File bboxfile = this.getAuxFile("bbox");
907
        bboxFileSave((String)null, envelope);
908
    }
909
    
910
    protected void bboxFileSave(String suffix, Envelope envelope) {
911
        File bboxfile = this.getAuxFile(suffix,"bbox");
873 912
        bboxFileSave(bboxfile, envelope);
874 913
    }
875 914
    
......
889 928
        }
890 929
    }
891 930
    
892
    protected Envelope bboxFileLoad() {
893
        File bboxfile = this.getAuxFile("bbox");
931
    protected Envelope bboxFileLoad(String suffix) {
932
        File bboxfile = this.getAuxFile(suffix, "bbox");
894 933
        return bboxFileLoad(bboxfile);
895 934
    }
896 935
    

Also available in: Unified diff