33import os
44import tempfile
55from collections import defaultdict
6+ from datetime import datetime
67from itertools import groupby
78
89from mergin import ClientError
910from mergin .merginproject import MerginProject , pygeodiff
1011from mergin .utils import int_version
1112
13+ try :
14+ from qgis .core import QgsGeometry , QgsDistanceArea , QgsCoordinateReferenceSystem , QgsCoordinateTransformContext , QgsWkbTypes
15+ has_qgis = True
16+ except ImportError :
17+ has_qgis = False
18+
1219
1320# inspired by C++ implementation https://github.com/lutraconsulting/geodiff/blob/master/geodiff/src/drivers/sqliteutils.cpp
1421# in geodiff lib (MIT licence)
@@ -39,76 +46,78 @@ def parse_gpkgb_header_size(gpkg_wkb):
3946 return no_envelope_header_size + envelope_size
4047
4148
49+ def qgs_geom_from_wkb (geom ):
50+ if not has_qgis :
51+ raise NotImplementedError
52+ g = QgsGeometry ()
53+ wkb_header_length = parse_gpkgb_header_size (geom )
54+ wkb_geom = geom [wkb_header_length :]
55+ g .fromWkb (wkb_geom )
56+ return g
57+
58+
4259class ChangesetReportEntry :
4360 """ Derivative of geodiff ChangesetEntry suitable for further processing/reporting """
4461 def __init__ (self , changeset_entry , geom_idx , geom ):
4562 self .table = changeset_entry .table .name
4663 self .geom_type = geom ["type" ]
47- self .crs = geom ["srs_id" ]
64+ self .crs = "EPSG:" + geom ["srs_id" ]
65+ self .length = None
66+ self .area = None
4867
4968 if changeset_entry .operation == changeset_entry .OP_DELETE :
5069 self .operation = "delete"
51- self .old_geom = changeset_entry .old_values [geom_idx ]
52- self .new_geom = None
5370 elif changeset_entry .operation == changeset_entry .OP_UPDATE :
5471 self .operation = "update"
55- self .old_geom = changeset_entry .old_values [geom_idx ]
56- self .new_geom = changeset_entry .new_values [geom_idx ]
5772 elif changeset_entry .operation == changeset_entry .OP_INSERT :
5873 self .operation = "insert"
59- self .old_geom = None
60- self .new_geom = changeset_entry .new_values [geom_idx ]
61-
62- self .count = None
63- self .length = None
64- self .area = None
65-
66- if self .geom_type == "LINESTRING" :
67- # we calculate change in length, for attributes changes only we set to 0
68- self .metric = "length"
69- if self .operation == "delete" :
70- self .length = self .measure (self .old_geom )
71- elif self .operation == "update" :
72- self .length = self .measure (self .new_geom ) - self .measure (self .old_geom )
73- elif self .operation == "insert" :
74- self .length = self .measure (self .new_geom )
75- elif self .geom_type == "POLYGON" :
76- # we calculate change in area, for attributes changes only we set to 0
77- self .metric = "area"
78- if self .operation == "delete" :
79- self .area = self .measure (self .old_geom )
80- elif self .operation == "update" :
81- self .area = self .measure (self .new_geom ) - self .measure (self .old_geom )
82- elif self .operation == "insert" :
83- self .area = self .measure (self .new_geom )
8474 else :
85- # regardless of geometry change count as 1
86- self .metric = "count"
87- self .count = 1
75+ self .operation = "unknown"
8876
89- def measure (self , geom ):
90- """ Return length or area of geometry based on type """
91- # calculate geom length/area only if QGIS API is available
92- try :
93- from qgis .core import QgsGeometry , QgsDistanceArea , QgsCoordinateReferenceSystem , QgsCoordinateTransformContext
94- except ImportError :
95- return - 1
77+ # only calculate geom properties when qgis api is available
78+ if not has_qgis :
79+ return
9680
9781 d = QgsDistanceArea ()
9882 d .setEllipsoid ('WGS84' )
9983 crs = QgsCoordinateReferenceSystem ()
10084 crs .createFromString (self .crs )
10185 d .setSourceCrs (crs , QgsCoordinateTransformContext ())
102- g = QgsGeometry ()
103- wkb_header_length = parse_gpkgb_header_size (geom )
104- wkb_geom = geom [wkb_header_length :]
105- g .fromWkb (wkb_geom )
106- if self .metric == "length" :
107- return d .measureLength (g )
108- elif self .metric == "area" :
109- return d .measureArea (g )
86+
87+ if hasattr (changeset_entry , "old_values" ):
88+ old_wkb = changeset_entry .old_values [geom_idx ]
89+ else :
90+ old_wkb = None
91+ if hasattr (changeset_entry , "new_values" ):
92+ new_wkb = changeset_entry .new_values [geom_idx ]
11093 else :
111- return 1
94+ new_wkb = None
95+
96+ # no geometry at all
97+ if old_wkb is None and new_wkb is None :
98+ return
99+
100+ updated_qgs_geom = None
101+ if self .operation == "delete" :
102+ qgs_geom = qgs_geom_from_wkb (old_wkb )
103+ elif self .operation == "update" :
104+ qgs_geom = qgs_geom_from_wkb (old_wkb )
105+ # get new geom if it was updated, there can be updates also without change of geom
106+ updated_qgs_geom = qgs_geom_from_wkb (new_wkb ) if new_wkb else qgs_geom
107+ elif self .operation == "insert" :
108+ qgs_geom = qgs_geom_from_wkb (new_wkb )
109+
110+ dim = QgsWkbTypes .wkbDimensions (qgs_geom .wkbType ())
111+ if dim == 1 :
112+ self .length = d .measureLength (qgs_geom )
113+ if updated_qgs_geom :
114+ self .length = d .measureLength (updated_qgs_geom ) - self .length
115+ elif dim == 2 :
116+ self .length = d .measurePerimeter (qgs_geom )
117+ self .area = d .measureArea (qgs_geom )
118+ if updated_qgs_geom :
119+ self .length = d .measurePerimeter (updated_qgs_geom ) - self .length
120+ self .area = d .measureArea (updated_qgs_geom ) - self .area
112121
113122
114123class ChangesetReport :
@@ -139,15 +148,17 @@ def report(self):
139148 tables [obj .table ].append (obj )
140149
141150 for table , entries in tables .items ():
142- items = groupby (entries , lambda i : (i .operation , i .metric ))
151+ items = groupby (entries , lambda i : (i .operation , i .geom_type ))
143152 for k , v in items :
144- quantity_type = "_" .join (k )
145153 values = list (v )
146- quantity = sum ([getattr (entry , k [1 ]) for entry in values ])
154+ area = sum ([entry .area for entry in values if entry .area ]) if has_qgis else None
155+ length = sum ([entry .length for entry in values if entry .length ]) if has_qgis else None
147156 records .append ({
148157 "table" : table ,
149- "quantity_type" : quantity_type ,
150- "quantity" : quantity
158+ "operation" : k [0 ],
159+ "length" : length ,
160+ "area" : area ,
161+ "count" : len (values )
151162 })
152163 return records
153164
@@ -166,7 +177,7 @@ def create_report(mc, directory, project, since, to, out_dir=tempfile.gettempdir
166177 mp = MerginProject (directory )
167178 mp .log .info (f"--- Creating changesets report for { project } from { since } to { to } versions ----" )
168179 versions_map = {v ["name" ]: v for v in mc .project_versions (project , since , to )}
169- headers = ["file" , "table" , "author" , "timestamp " , "version" , "quantity_type " , "quantity " ]
180+ headers = ["file" , "table" , "author" , "date " , "time" , " version" , "operation " , "length" , "area" , "count " ]
170181 records = []
171182 info = mc .project_info (project , since = since )
172183 num_since = int_version (since )
@@ -203,6 +214,9 @@ def create_report(mc, directory, project, since, to, out_dir=tempfile.gettempdir
203214
204215 # add records for every version (diff) and all tables within geopackage
205216 for version in history_keys :
217+ if "diff" not in f ['history' ][version ]:
218+ continue
219+
206220 v_diff_file = os .path .join (mp .meta_dir , '.cache' ,
207221 version + "-" + f ['history' ][version ]['diff' ]['path' ])
208222
@@ -211,10 +225,12 @@ def create_report(mc, directory, project, since, to, out_dir=tempfile.gettempdir
211225 rep = ChangesetReport (cr , schema )
212226 report = rep .report ()
213227 # append version info to changeset info
228+ dt = datetime .fromisoformat (version_data ["created" ].rstrip ("Z" ))
214229 version_fields = {
215230 "file" : f ["path" ],
216231 "author" : version_data ["author" ],
217- "timestamp" : version_data ["created" ],
232+ "date" : dt .date ().isoformat (),
233+ "time" : dt .time ().isoformat (),
218234 "version" : version_data ["name" ]
219235 }
220236 for row in report :
0 commit comments