diff --git a/Mergin/diff.py b/Mergin/diff.py
index 5f33b1d6..7f531c68 100644
--- a/Mergin/diff.py
+++ b/Mergin/diff.py
@@ -3,12 +3,14 @@
import base64
import sqlite3
import tempfile
+import glob
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor
from qgis.core import (
+ QgsApplication,
QgsVectorLayer,
QgsFeature,
QgsGeometry,
@@ -107,6 +109,18 @@ def parse_db_schema(db_file):
return tables
+def db_schema_from_json(schema_json):
+ """Create map of tables from the schema JSON file"""
+ tables = {} # key: name, value: TableSchema
+ for tbl in schema_json:
+ columns = []
+ for col in tbl["columns"]:
+ columns.append(ColumnSchema(col["name"], col["type"], "primary_key" in col and col["primary_key"]))
+
+ tables[tbl["table"]] = TableSchema(tbl["table"], columns)
+ return tables
+
+
def parse_diff(geodiff, diff_file):
"""
Parse binary GeoDiff changeset and return map of changes per table
@@ -311,6 +325,80 @@ def make_local_changes_layer(mp, layer):
return vl, ""
+def make_version_changes_layers(project_path, version):
+ geodiff = pygeodiff.GeoDiff()
+
+ layers = []
+ version_dir = os.path.join(project_path, ".mergin", ".cache", f"v{version}")
+ for f in glob.iglob(f"{version_dir}/*.gpkg"):
+ gpkg_file = os.path.join(version_dir, f)
+ schema_file = gpkg_file + "-schema.json"
+ if not os.path.exists(schema_file):
+ geodiff.schema("sqlite", "", gpkg_file, schema_file)
+
+ changeset_file = find_changeset_file(f, version_dir)
+ if changeset_file is None:
+ continue
+
+ with open(schema_file, encoding="utf-8") as fl:
+ data = fl.read()
+ schema_json = json.loads(data.replace("\n", "")).get("geodiff_schema")
+
+ db_schema = db_schema_from_json(schema_json)
+ diff = parse_diff(geodiff, changeset_file)
+
+ for table_name in diff.keys():
+ if table_name.startswith("gpkg_"):
+ # db schema reported by geodiff exclude layer named "gpkg_*"
+ # We skip it in the layer displayed to the user
+ continue
+ fields, cols_to_fields = create_field_list(db_schema[table_name])
+ geom_type, geom_crs = get_layer_geometry_info(schema_json, table_name)
+
+ db_conn = None # no ref. db
+ db_conn = sqlite3.connect(gpkg_file)
+
+ features = diff_table_to_features(diff[table_name], db_schema[table_name], fields, cols_to_fields, db_conn)
+
+ # create diff layer
+ if geom_type is None:
+ continue
+
+ uri = f"{geom_type}?crs=epsg:{geom_crs}" if geom_crs else geom_type
+ vl = QgsVectorLayer(uri, table_name, "memory")
+ if not vl.isValid():
+ continue
+
+ vl.dataProvider().addAttributes(fields)
+ vl.updateFields()
+ vl.dataProvider().addFeatures(features)
+
+ style_diff_layer(vl, db_schema[table_name])
+ layers.append(vl)
+
+ return layers
+
+
+def find_changeset_file(file_name, version_dir):
+ """Returns path to the diff file for the given version file"""
+ for f in glob.iglob(f"{version_dir}/*.gpkg-diff*"):
+ if f.startswith(file_name):
+ return os.path.join(version_dir, f)
+ return None
+
+
+def get_layer_geometry_info(schema_json, table_name):
+ """Returns geometry type and CRS for a given table"""
+ for tbl in schema_json:
+ if tbl["table"] == table_name:
+ for col in tbl["columns"]:
+ if col["type"] == "geometry":
+ return col["geometry"]["type"], col["geometry"]["srs_id"]
+ return "NoGeometry", ""
+
+ return None, None
+
+
def style_diff_layer(layer, schema_table):
"""Apply conditional styling and symbology to diff layer"""
### setup conditional styles!
diff --git a/Mergin/diff_dialog.py b/Mergin/diff_dialog.py
index 654ca19a..91014b2a 100644
--- a/Mergin/diff_dialog.py
+++ b/Mergin/diff_dialog.py
@@ -18,14 +18,14 @@
from qgis.utils import iface, OverrideCursor
from .mergin.merginproject import MerginProject
-from .diff import make_local_changes_layer
-from .utils import icon_path
+from .diff import make_local_changes_layer, make_version_changes_layers
+from .utils import icon_path, icon_for_layer
ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_diff_viewer_dialog.ui")
class DiffViewerDialog(QDialog):
- def __init__(self, parent=None):
+ def __init__(self, version=None, parent=None):
QDialog.__init__(self, parent)
self.ui = uic.loadUi(ui_file, self)
@@ -91,6 +91,8 @@ def __init__(self, parent=None):
self.diff_layers = []
self.filter_model = None
+ self.version = version
+
self.create_tabs()
def reject(self):
@@ -106,6 +108,12 @@ def save_splitter_state(self):
settings.setValue("Mergin/changesViewerSplitterSize", self.splitter.saveState())
def create_tabs(self):
+ if self.version is None:
+ self.show_local_changes()
+ else:
+ self.show_version_changes()
+
+ def show_local_changes(self):
mp = MerginProject(QgsProject.instance().homePath())
project_layers = QgsProject.instance().mapLayers()
for layer in project_layers.values():
@@ -122,7 +130,14 @@ def create_tabs(self):
continue
self.diff_layers.append(vl)
- self.tab_bar.addTab(self.icon_for_layer(vl), f"{layer.name()} ({vl.featureCount()})")
+ self.tab_bar.addTab(icon_for_layer(vl), f"{layer.name()} ({vl.featureCount()})")
+ self.tab_bar.setCurrentIndex(0)
+
+ def show_version_changes(self):
+ layers = make_version_changes_layers(QgsProject.instance().homePath(), self.version)
+ for vl in layers:
+ self.diff_layers.append(vl)
+ self.tab_bar.addTab(icon_for_layer(vl), f"{vl.name()} ({vl.featureCount()})")
self.tab_bar.setCurrentIndex(0)
def toggle_project_layers(self, checked):
@@ -199,19 +214,6 @@ def zoom_selected(self):
self.map_canvas.zoomToSelected([self.current_diff])
self.map_canvas.refresh()
- def icon_for_layer(self, layer):
- geom_type = layer.geometryType()
- if geom_type == QgsWkbTypes.PointGeometry:
- return QgsApplication.getThemeIcon("/mIconPointLayer.svg")
- elif geom_type == QgsWkbTypes.LineGeometry:
- return QgsApplication.getThemeIcon("/mIconLineLayer.svg")
- elif geom_type == QgsWkbTypes.PolygonGeometry:
- return QgsApplication.getThemeIcon("/mIconPolygonLayer.svg")
- elif geom_type == QgsWkbTypes.UnknownGeometry:
- return QgsApplication.getThemeIcon("/mIconGeometryCollectionLayer.svg")
- else:
- return QgsApplication.getThemeIcon("/mIconTableLayer.svg")
-
def show_unsaved_changes_warning(self):
self.ui.messageBar.pushMessage(
"Mergin",
diff --git a/Mergin/images/default/tabler_icons/file-description.svg b/Mergin/images/default/tabler_icons/file-description.svg
new file mode 100644
index 00000000..74d7b5dc
--- /dev/null
+++ b/Mergin/images/default/tabler_icons/file-description.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Mergin/images/default/tabler_icons/history.svg b/Mergin/images/default/tabler_icons/history.svg
new file mode 100644
index 00000000..2721ea7f
--- /dev/null
+++ b/Mergin/images/default/tabler_icons/history.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/Mergin/images/white/tabler_icons/file-description.svg b/Mergin/images/white/tabler_icons/file-description.svg
new file mode 100644
index 00000000..80d71103
--- /dev/null
+++ b/Mergin/images/white/tabler_icons/file-description.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Mergin/images/white/tabler_icons/history.svg b/Mergin/images/white/tabler_icons/history.svg
new file mode 100644
index 00000000..a72e8071
--- /dev/null
+++ b/Mergin/images/white/tabler_icons/history.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/Mergin/plugin.py b/Mergin/plugin.py
index b0587713..3ed185e9 100644
--- a/Mergin/plugin.py
+++ b/Mergin/plugin.py
@@ -42,10 +42,12 @@
from .sync_dialog import SyncDialog
from .configure_sync_wizard import DbSyncConfigWizard
from .remove_project_dialog import RemoveProjectDialog
+from .version_viewer_dialog import VersionViewerDialog
from .utils import (
ServerType,
ClientError,
LoginError,
+ InvalidProject,
check_mergin_subdirs,
create_mergin_client,
find_qgis_files,
@@ -62,7 +64,7 @@
unsaved_project_check,
UnsavedChangesStrategy,
)
-
+from .mergin.utils import int_version, is_versioned_file
from .mergin.merginproject import MerginProject
from .processing.provider import MerginProvider
import processing
@@ -92,6 +94,7 @@ def __init__(self, iface):
self.toolbar.setObjectName("MerginMapsToolbar")
self.iface.projectRead.connect(self.on_qgis_project_changed)
+ self.on_qgis_project_changed()
self.iface.newProjectCreated.connect(self.on_qgis_project_changed)
settings = QSettings()
@@ -155,6 +158,15 @@ def initGui(self):
add_to_menu=True,
add_to_toolbar=None,
)
+ self.history_dock_action = self.add_action(
+ "history.svg",
+ text="Project History",
+ callback=self.open_project_history_window,
+ add_to_menu=False,
+ add_to_toolbar=self.toolbar,
+ enabled=False,
+ always_on=False,
+ )
self.enable_toolbar_actions()
@@ -324,6 +336,10 @@ def configure_db_sync(self):
if not wizard.exec():
return
+ def open_project_history_window(self):
+ dlg = VersionViewerDialog(self.mc)
+ dlg.exec()
+
def show_no_workspaces_dialog(self):
msg = (
"Workspace is a place to store your projects and share them with your colleagues. "
diff --git a/Mergin/ui/ui_project_history_dock.ui b/Mergin/ui/ui_project_history_dock.ui
new file mode 100644
index 00000000..10f775c5
--- /dev/null
+++ b/Mergin/ui/ui_project_history_dock.ui
@@ -0,0 +1,83 @@
+
+
+ ProjectHistoryDockWidget
+
+
+
+ 0
+ 0
+ 370
+ 450
+
+
+
+ Qt::LeftDockWidgetArea|Qt::RightDockWidgetArea
+
+
+ Mergin Maps history
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ 1
+
+
+
+
-
+
+
+ Current project is not a Mergin project. Project history is not available.
+
+
+ true
+
+
+
+
+
+
+
+ -
+
+
+ Qt::CustomContextMenu
+
+
+ false
+
+
+
+ -
+
+
+ View changes
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Mergin/ui/ui_versions_viewer.ui b/Mergin/ui/ui_versions_viewer.ui
new file mode 100644
index 00000000..461965bd
--- /dev/null
+++ b/Mergin/ui/ui_versions_viewer.ui
@@ -0,0 +1,375 @@
+
+
+ Dialog
+
+
+
+ 0
+ 0
+ 1295
+ 736
+
+
+
+ Changes Viewer
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 1
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+
+ 24
+ 24
+
+
+
+ Navigate to first feature
+
+
+
+
+
+
+ :/images/themes/default/mActionDoubleArrowLeft.svg:/images/themes/default/mActionDoubleArrowLeft.svg
+
+
+ true
+
+
+
+ -
+
+
+
+ 24
+ 24
+
+
+
+ Navigate to previous feature
+
+
+
+
+
+
+ :/images/themes/default/mActionArrowLeft.svg:/images/themes/default/mActionArrowLeft.svg
+
+
+ true
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 24
+ 24
+
+
+
+ Navigate to next feature
+
+
+
+
+
+
+ :/images/themes/default/mActionArrowRight.svg:/images/themes/default/mActionArrowRight.svg
+
+
+ true
+
+
+
+ -
+
+
+
+ 11
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Expanding
+
+
+
+ 20
+ 20
+
+
+
+
+ -
+
+
+ -
+
+
+ Automatically zoom to the current feature
+
+
+
+
+
+
+ :/images/themes/default/mActionZoomTo.svg:/images/themes/default/mActionZoomTo.svg
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 16
+ 16
+
+
+
+ false
+
+
+
+ -
+
+
+ 0
+
+
+
+
-
+
+
+ Qt::Vertical
+
+
+
+
+
+
+
+
+
+
+
+ 110
+ 300
+ 231
+ 81
+
+
+
+
+ 13
+
+
+
+ Loading version info…
+
+
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Wed, 11 Sep 2024 10:36:41 GMT
+
+
+ true
+
+
+
+ -
+
+
+ Created with:
+
+
+
+ -
+
+
+ Input/2024.3.1 (android/13.0)
+
+
+ true
+
+
+
+ -
+
+
+ Project size
+
+
+
+ -
+
+
+ 4.3 MB
+
+
+ true
+
+
+
+ -
+
+
+ Created at
+
+
+
+
+
+ -
+
+
+ false
+
+
+ QTabWidget::South
+
+
+ QTabWidget::Rounded
+
+
+ 0
+
+
+ false
+
+
+ false
+
+
+
+ Updated layers
+
+
+
-
+
+
+
+
+
+
+ Updated files
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ QgsMapCanvas
+ QGraphicsView
+
+ 1
+
+
+ QgsAttributeTableView
+ QTableView
+
+
+
+
+
+
diff --git a/Mergin/utils.py b/Mergin/utils.py
index 8e20d18c..4299e55c 100644
--- a/Mergin/utils.py
+++ b/Mergin/utils.py
@@ -1,5 +1,5 @@
import shutil
-from datetime import datetime, timezone
+from datetime import datetime, timezone, tzinfo
from enum import Enum
from urllib.error import URLError, HTTPError
import configparser
@@ -16,7 +16,7 @@
from qgis.PyQt.QtCore import QSettings, QVariant
from qgis.PyQt.QtWidgets import QMessageBox, QFileDialog
-from qgis.PyQt.QtGui import QPalette, QColor
+from qgis.PyQt.QtGui import QPalette, QColor, QIcon
from qgis.core import (
NULL,
Qgis,
@@ -1507,3 +1507,84 @@ def get_layer_by_path(path):
safe_file_path = layer_path.split("|")
if safe_file_path[0] == path:
return layer
+
+
+def check_mergin_subdirs(directory):
+ """Check if the directory has a Mergin Maps project subdir (.mergin)."""
+ for root, dirs, files in os.walk(directory):
+ for name in dirs:
+ if name == ".mergin":
+ return os.path.join(root, name)
+ return False
+
+
+def contextual_date(date_string):
+ """Converts datetime string returned by the server into contextual duration string, e.g.
+ 'N hours/days/month ago'
+ """
+ dt = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%SZ")
+ dt = dt.replace(tzinfo=timezone.utc)
+ now = datetime.now(timezone.utc)
+ delta = now - dt
+ if delta.days > 365:
+ # return the date value for version older than one year
+ return dt.strftime("%Y-%m-%d")
+ elif delta.days > 31:
+ months = int(delta.days // 30.436875)
+ return f"{months} {'months' if months > 1 else 'month'} ago"
+ elif delta.days > 6:
+ weeks = int(delta.days // 7)
+ return f"{weeks} {'weeks' if weeks > 1 else 'week'} ago"
+
+ if delta.days < 1:
+ hours = delta.seconds // 3600
+ if hours < 1:
+ minutes = (delta.seconds // 60) % 60
+ if minutes <= 0:
+ return "just now"
+ return f"{minutes} {'minutes' if minutes > 1 else 'minute'} ago"
+
+ return f"{hours} {'hours' if hours > 1 else 'hour'} ago"
+
+ return f"{delta.days} {'days' if delta.days > 1 else 'day'} ago"
+
+
+def format_datetime(date_string):
+ """Formats datetime string returned by the server into human-readable format"""
+ dt = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%SZ")
+ return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
+
+
+def parse_user_agent(user_agent: str) -> str:
+ browsers = ["Chrome", "Firefox", "Mozilla", "Opera", "Safari", "Webkit"]
+ if any([browser in user_agent for browser in browsers]):
+ return "Web dashboard"
+ elif "Input" in user_agent:
+ return "Mobile app"
+ elif "Plugin" in user_agent:
+ return "QGIS plugin"
+ elif "DB-sync" in user_agent:
+ return "Synced from db-sync"
+ elif "work-packages" in user_agent:
+ return "Synced from Work Packages"
+ elif "media-sync" in user_agent:
+ return "Synced from Media Sync"
+ elif "Python-client" in user_agent:
+ return "Mergin Maps Python Client"
+ else: # For uncommon user agent we display user agent as is
+ return user_agent
+
+
+def icon_for_layer(layer) -> QIcon:
+ # Used in diff viewer and history viewer
+ geom_type = layer.geometryType()
+ if geom_type == QgsWkbTypes.PointGeometry:
+ return QgsApplication.getThemeIcon("/mIconPointLayer.svg")
+ elif geom_type == QgsWkbTypes.LineGeometry:
+ return QgsApplication.getThemeIcon("/mIconLineLayer.svg")
+ elif geom_type == QgsWkbTypes.PolygonGeometry:
+ return QgsApplication.getThemeIcon("/mIconPolygonLayer.svg")
+ elif geom_type == QgsWkbTypes.UnknownGeometry:
+ return QgsApplication.getThemeIcon("/mIconGeometryCollectionLayer.svg")
+ else:
+ return QgsApplication.getThemeIcon("/mIconTableLayer.svg")
diff --git a/Mergin/version_viewer_dialog.py b/Mergin/version_viewer_dialog.py
new file mode 100644
index 00000000..6894ec69
--- /dev/null
+++ b/Mergin/version_viewer_dialog.py
@@ -0,0 +1,639 @@
+# GPLv3 license
+# Copyright Lutra Consulting Limited
+
+from collections import deque
+import os
+import math
+
+from qgis.PyQt import uic, QtCore
+from qgis.PyQt.QtWidgets import (
+ QDialog,
+ QAction,
+ QListWidgetItem,
+ QPushButton,
+ QMenu,
+ QMessageBox,
+ QAbstractItemView,
+ QToolButton,
+)
+from qgis.PyQt.QtGui import QStandardItem, QStandardItemModel, QIcon, QFont, QColor
+from qgis.PyQt.QtCore import (
+ QStringListModel,
+ Qt,
+ QSettings,
+ QModelIndex,
+ QAbstractTableModel,
+ QThread,
+ pyqtSignal,
+ QItemSelectionModel,
+)
+
+from qgis.utils import iface
+from qgis.core import (
+ QgsProject,
+ QgsMessageLog,
+ QgsApplication,
+ QgsFeatureRequest,
+ QgsVectorLayerCache,
+ # Used to filter background map
+ QgsRasterLayer,
+ QgsTiledSceneLayer,
+ QgsVectorTileLayer,
+)
+from qgis.gui import QgsMapToolPan, QgsAttributeTableModel, QgsAttributeTableFilterModel
+
+
+from .utils import (
+ ClientError,
+ icon_path,
+ mergin_project_local_path,
+ PROJS_PER_PAGE,
+ contextual_date,
+ is_versioned_file,
+ icon_path,
+ format_datetime,
+ parse_user_agent,
+ icon_for_layer,
+)
+
+from .mergin.merginproject import MerginProject
+from .mergin.utils import bytes_to_human_size, int_version
+
+from .mergin import MerginClient
+from .diff import make_version_changes_layers
+
+
+ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_versions_viewer.ui")
+
+
+class VersionsTableModel(QAbstractTableModel):
+ VERSION = Qt.UserRole + 1
+ VERSION_NAME = Qt.UserRole + 2
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ # Keep ordered
+ self.versions = deque()
+
+ self.oldest = None
+ self.latest = None
+
+ self.headers = ["Version", "Author", "Created"]
+
+ self.current_version = None
+
+ def latest_version(self):
+ if len(self.versions) == 0:
+ return None
+ return int_version(self.versions[0]["name"])
+
+ def oldest_version(self):
+ if len(self.versions) == 0:
+ return None
+ return int_version(self.versions[-1]["name"])
+
+ def rowCount(self, parent: QModelIndex):
+ return len(self.versions)
+
+ def columnCount(self, parent: QModelIndex) -> int:
+ return len(self.headers)
+
+ def headerData(self, section, orientation, role):
+ if orientation == Qt.Horizontal and role == Qt.DisplayRole:
+ return self.headers[section]
+ return None
+
+ def data(self, index, role=Qt.DisplayRole):
+ if not index.isValid():
+ return None
+
+ idx = index.row()
+ if role == Qt.DisplayRole:
+ if index.column() == 0:
+ if self.versions[idx]["name"] == self.current_version:
+ return f'{self.versions[idx]["name"]} (local)'
+ return self.versions[idx]["name"]
+ if index.column() == 1:
+ return self.versions[idx]["author"]
+ if index.column() == 2:
+ return contextual_date(self.versions[idx]["created"])
+ elif role == Qt.FontRole:
+ if self.versions[idx]["name"] == self.current_version:
+ font = QFont()
+ font.setBold(True)
+ return font
+ elif role == Qt.ToolTipRole:
+ if index.column() == 2:
+ return format_datetime(self.versions[idx]["created"])
+ elif role == VersionsTableModel.VERSION:
+ return int_version(self.versions[idx]["name"])
+ elif role == VersionsTableModel.VERSION_NAME:
+ return self.versions[idx]["name"]
+ else:
+ return None
+
+ def insertRows(self, row, count, parent=QModelIndex()):
+ self.beginInsertRows(parent, row, row + count - 1)
+ self.endInsertRows()
+
+ def clear(self):
+ self.beginResetModel()
+ self.versions.clear()
+ self.endResetModel()
+
+ def add_versions(self, versions):
+ self.insertRows(len(self.versions) - 1, len(versions))
+ self.versions.extend(versions)
+ self.layoutChanged.emit()
+
+ def prepend_versions(self, versions):
+ self.insertRows(0, len(versions))
+ self.versions.extendleft(versions)
+ self.layoutChanged.emit()
+
+ def item_from_index(self, index):
+ return self.versions[index.row()]
+
+
+class ChangesetsDownloader(QThread):
+ """
+ Class to download version changesets in background worker thread
+ """
+
+ finished = pyqtSignal(str)
+
+ def __init__(self, mc, mp, version):
+ """
+ ChangesetsDownloader constructor
+
+ :param mc: MerginClient instance
+ :param mp: MerginProject instance
+ :param version: project version to download
+ """
+ super(ChangesetsDownloader, self).__init__()
+ self.mc = mc
+ self.mp = mp
+ self.version = version
+
+ def run(self):
+ version_info = self.mc.project_version_info(self.mp.project_id(), version=f"v{self.version}")
+
+ files_updated = version_info["changes"]["updated"]
+
+ # if file not in project_version_info # skip as well
+ if not version_info["changesets"]:
+ self.finished.emit("This version does not contain changes in the project layers.")
+ return
+
+ files_updated = [f for f in files_updated if is_versioned_file(f["path"])]
+
+ if not files_updated:
+ self.finished.emit("This version does not contain changes in the project layers.")
+ return
+
+ has_history = any("diff" in f for f in files_updated)
+ if not has_history:
+ self.finished.emit("This version does not contain changes in the project layers.")
+ return
+
+ for f in files_updated:
+ if self.isInterruptionRequested():
+ return
+
+ if "diff" not in f:
+ continue
+ file_diffs = self.mc.download_file_diffs(self.mp.dir, f["path"], [f"v{self.version}"])
+ full_gpkg = self.mp.fpath_cache(f["path"], version=f"v{self.version}")
+ if not os.path.exists(full_gpkg):
+ self.mc.download_file(self.mp.dir, f["path"], full_gpkg, f"v{self.version}")
+
+ if self.isInterruptionRequested():
+ self.quit()
+ return
+
+ self.finished.emit("")
+
+
+class VersionsFetcher(QThread):
+
+ finished = pyqtSignal(list)
+
+ def __init__(self, mc: MerginClient, project_path, model: VersionsTableModel):
+ super(VersionsFetcher, self).__init__()
+ self.mc = mc
+ self.project_path = project_path
+ self.model = model
+
+ self.current_page = 1
+ self.per_page = 50
+
+ version_count = self.mc.project_versions_count(self.project_path)
+ self.nb_page = math.ceil(version_count / self.per_page)
+
+ def run(self):
+ self.fetch_another_page()
+
+ def has_more_page(self):
+ return self.current_page <= self.nb_page
+
+ def fetch_another_page(self):
+ if self.has_more_page() == False:
+ return
+ versions = self.mc.project_versions_page(
+ self.project_path, self.current_page, per_page=self.per_page, descending=True
+ )
+ self.model.add_versions(versions)
+
+ self.current_page += 1
+
+
+class VersionViewerDialog(QDialog):
+ """
+ The class is constructed in a way that the flow of the code follow the flow the UI
+ The UI is read from left to right and each splitter is read from top to bottom
+
+ The __init__ method follow this pattern after varaible initiatlization
+ the methods of the class also follow this pattern
+ """
+
+ def __init__(self, mc, parent=None):
+
+ QDialog.__init__(self, parent)
+ self.ui = uic.loadUi(ui_file, self)
+
+ self.mc = mc
+
+ self.project_path = mergin_project_local_path()
+ self.mp = MerginProject(self.project_path)
+
+ self.set_splitters_state()
+
+ self.versionModel = VersionsTableModel()
+ self.history_treeview.setModel(self.versionModel)
+ self.history_treeview.verticalScrollBar().valueChanged.connect(self.on_scrollbar_changed)
+
+ self.selectionModel: QItemSelectionModel = self.history_treeview.selectionModel()
+ self.selectionModel.currentChanged.connect(self.current_version_changed)
+
+ self.has_selected_latest = False
+
+ self.fetcher = VersionsFetcher(self.mc, self.mp.project_full_name(), self.versionModel)
+ self.diff_downloader = None
+
+ self.fetcher.fetch_another_page()
+
+ height = 30
+ self.toolbar.setMinimumHeight(height)
+
+ self.history_control.setMinimumHeight(height)
+ self.history_control.setVisible(False)
+
+ self.toggle_layers_action = QAction(
+ QgsApplication.getThemeIcon("/mActionAddLayer.svg"), "Hide background layers", self
+ )
+ self.toggle_layers_action.setCheckable(True)
+ self.toggle_layers_action.setChecked(True)
+ self.toggle_layers_action.toggled.connect(self.toggle_project_layers)
+
+ # We use a ToolButton instead of simple action to dislay both icon AND text
+ self.toggle_layers_button = QToolButton()
+ self.toggle_layers_button.setDefaultAction(self.toggle_layers_action)
+ self.toggle_layers_button.setText("Show background layers")
+ self.toggle_layers_button.setToolTip(
+ "Toggle the display of background layer(Raster and tiles) in the current project"
+ )
+ self.toggle_layers_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.toolbar.addWidget(self.toggle_layers_button)
+
+ self.toolbar.addSeparator()
+
+ self.zoom_full_action = QAction(QgsApplication.getThemeIcon("/mActionZoomFullExtent.svg"), "Zoom Full", self)
+ self.zoom_full_action.triggered.connect(self.zoom_full)
+
+ self.toolbar.addAction(self.zoom_full_action)
+
+ self.zoom_selected_action = QAction(
+ QgsApplication.getThemeIcon("/mActionZoomToSelected.svg"), "Zoom To Selection", self
+ )
+ self.zoom_selected_action.triggered.connect(self.zoom_selected)
+
+ self.toolbar.addAction(self.zoom_selected_action)
+
+ btn_add_changes = QPushButton("Add to project")
+ btn_add_changes.setIcon(QgsApplication.getThemeIcon("/mActionAdd.svg"))
+ menu = QMenu()
+ add_current_action = menu.addAction(QIcon(icon_path("file-plus.svg")), "Add current changes layer to project")
+ add_current_action.triggered.connect(self.add_current_to_project)
+ add_all_action = menu.addAction(QIcon(icon_path("folder-plus.svg")), "Add all changes layers to project")
+ add_all_action.triggered.connect(self.add_all_to_project)
+ btn_add_changes.setMenu(menu)
+
+ self.toolbar.addWidget(btn_add_changes)
+ self.toolbar.setIconSize(iface.iconSize())
+
+ self.map_canvas.enableAntiAliasing(True)
+ self.map_canvas.setSelectionColor(QColor(Qt.cyan))
+ self.pan_tool = QgsMapToolPan(self.map_canvas)
+ self.map_canvas.setMapTool(self.pan_tool)
+
+ self.current_diff = None
+ self.diff_layers = []
+ self.filter_model = None
+ self.layer_list.currentRowChanged.connect(self.diff_layer_changed)
+
+ self.icons = {
+ "added": "plus.svg",
+ "removed": "trash.svg",
+ "updated": "pencil.svg",
+ "renamed": "pencil.svg",
+ "table": "table.svg",
+ }
+ self.model_detail = QStandardItemModel()
+ self.model_detail.setHorizontalHeaderLabels(["Details"])
+
+ self.details_treeview.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.details_treeview.setModel(self.model_detail)
+
+ self.versionModel.current_version = self.mp.version()
+
+ def exec(self):
+
+ try:
+ ws_id = self.mp.workspace_id()
+ except ClientError as e:
+ QMessageBox.warning(None, "Client Error", str(e))
+ return
+
+ # check if user has permissions
+ try:
+ usage = self.mc.workspace_usage(ws_id)
+ if not usage["view_history"]["allowed"]:
+ QMessageBox.warning(
+ None, "Upgrade required", "To view the project history, please upgrade your subscription plan."
+ )
+ return
+ except ClientError:
+ # Some versions e.g CE, EE edition doesn't have
+ pass
+ super().exec()
+
+ def closeEvent(self, event):
+ self.save_splitters_state()
+ QDialog.closeEvent(self, event)
+
+ def save_splitters_state(self):
+ settings = QSettings()
+ settings.setValue("Mergin/VersionViewerSplitterSize", self.splitter_map_table.saveState())
+ settings.setValue("Mergin/VersionViewerSplitterVericalSize", self.splitter_vertical.saveState())
+
+ def set_splitters_state(self):
+ settings = QSettings()
+ state_vertical = settings.value("Mergin/VersionViewerSplitterVericalSize")
+ if state_vertical:
+ self.splitter_vertical.restoreState(state_vertical)
+ else:
+ self.splitter_vertical.setSizes([120, 200, 40])
+
+ do_calc_height = True
+ state = settings.value("Mergin/VersionViewerSplitterSize")
+ if state:
+ self.splitter_map_table.restoreState(state)
+
+ if self.splitter_map_table.sizes()[0] != 0:
+ do_calc_height = False
+
+ if do_calc_height:
+ height = max([self.map_canvas.minimumSizeHint().height(), self.attribute_table.minimumSizeHint().height()])
+ self.splitter_map_table.setSizes([height, height])
+
+ def fetch_from_server(self):
+
+ if self.fetcher and self.fetcher.isRunning():
+ # Only fetching when previous is finshed
+ return
+ else:
+ self.fetcher.start()
+
+ def on_scrollbar_changed(self, value):
+
+ if self.ui.history_treeview.verticalScrollBar().maximum() <= value:
+ self.fetch_from_server()
+
+ def current_version_changed(self, current_index, previous_index):
+ # Update the ui when the selected version change
+ item = self.versionModel.item_from_index(current_index)
+ version_name = item["name"]
+ version = int_version(item["name"])
+
+ self.setWindowTitle(f"Changes Viewer | {version_name}")
+
+ self.version_details = self.mc.project_version_info(self.mp.project_id(), version_name)
+ self.populate_details()
+ self.details_treeview.expandAll()
+
+ # Reset layer list
+ self.layer_list.clear()
+
+ if not os.path.exists(os.path.join(self.project_path, ".mergin", ".cache", f"v{version}")):
+
+ self.stackedWidget.setCurrentIndex(1)
+ self.label_info.setText("Loading version info…")
+
+ if self.diff_downloader and self.diff_downloader.isRunning():
+ self.diff_downloader.requestInterruption()
+
+ self.diff_downloader = ChangesetsDownloader(self.mc, self.mp, version)
+ self.diff_downloader.finished.connect(lambda msg: self.show_version_changes(version))
+ self.diff_downloader.start()
+ else:
+ self.show_version_changes(version)
+
+ def populate_details(self):
+ self.edit_project_size.setText(bytes_to_human_size(self.version_details["project_size"]))
+ self.edit_created.setText(format_datetime(self.version_details["created"]))
+ self.edit_user_agent.setText(parse_user_agent(self.version_details["user_agent"]))
+ self.edit_user_agent.setToolTip(self.version_details["user_agent"])
+
+ self.model_detail.clear()
+ root_item = QStandardItem(f"Changes in version {self.version_details['name']}")
+ self.model_detail.appendRow(root_item)
+ for category in self.version_details["changes"]:
+ for item in self.version_details["changes"][category]:
+ path = item["path"]
+ item = self._get_icon_item(category, path)
+ if is_versioned_file(path):
+ if path in self.version_details["changesets"]:
+ for sub_item in self._versioned_file_summary_items(
+ self.version_details["changesets"][path]["summary"]
+ ):
+ item.appendRow(sub_item)
+ root_item.appendRow(item)
+
+ def _get_icon_item(self, key, text):
+ path = icon_path(self.icons[key])
+ item = QStandardItem(text)
+ item.setIcon(QIcon(path))
+ return item
+
+ def _versioned_file_summary_items(self, summary):
+ items = []
+ for s in summary:
+ table_name_item = self._get_icon_item("table", s["table"])
+ for row in self._table_summary_items(s):
+ table_name_item.appendRow(row)
+ items.append(table_name_item)
+
+ return items
+
+ def _table_summary_items(self, summary):
+ return [QStandardItem("{}: {}".format(k, summary[k])) for k in summary if k != "table"]
+
+ def toggle_project_layers(self, checked):
+ if checked:
+ self.toggle_layers_button.setText("Hide background layers")
+ else:
+ self.toggle_layers_button.setText("Show background layers")
+
+ layers = self.collect_layers(checked)
+ self.update_canvas_layers(layers)
+
+ def update_canvas(self, layers):
+
+ if self.current_diff.isSpatial() == False:
+ self.map_canvas.setEnabled(False)
+ self.save_splitters_state()
+ self.splitter_map_table.setSizes([0, 1])
+ else:
+ self.map_canvas.setEnabled(True)
+ self.set_splitters_state()
+
+ self.update_canvas_layers(layers)
+ self.update_canvas_extend(layers)
+
+ def update_canvas_layers(self, layers):
+ self.map_canvas.setLayers(layers)
+ self.map_canvas.refresh()
+
+ def update_canvas_extend(self, layers):
+ self.map_canvas.setDestinationCrs(QgsProject.instance().crs())
+
+ if layers:
+ extent = layers[0].extent()
+ d = min(extent.width(), extent.height())
+ if d == 0:
+ d = 1
+ extent = extent.buffered(d * 0.07)
+
+ extent = self.map_canvas.mapSettings().layerExtentToOutputExtent(layers[0], extent)
+
+ self.map_canvas.setExtent(extent)
+ self.map_canvas.refresh()
+
+ def show_version_changes(self, version):
+ self.diff_layers.clear()
+
+ layers = make_version_changes_layers(QgsProject.instance().homePath(), version)
+ for vl in layers:
+ self.diff_layers.append(vl)
+ icon = icon_for_layer(vl)
+
+ summary = self.find_changeset_summary_for_layer(vl.name(), self.version_details["changesets"])
+ additional_info = []
+ if summary["insert"]:
+ additional_info.append(f"{summary['insert']} added")
+ if summary["update"]:
+ additional_info.append(f"{summary['update']} updated")
+ if summary["delete"]:
+ additional_info.append(f"{summary['delete']} deleted")
+
+ additional_summary = "\n" + ",".join(additional_info)
+
+ self.layer_list.addItem(QListWidgetItem(icon, vl.name() + additional_summary))
+
+ if len(self.diff_layers) >= 1:
+ self.toolbar.setEnabled(True)
+ self.layer_list.setCurrentRow(0)
+ self.stackedWidget.setCurrentIndex(0)
+ self.tabWidget.setCurrentIndex(0)
+ self.tabWidget.setTabEnabled(0, True)
+ layers = self.collect_layers(self.toggle_layers_action.isChecked())
+ self.update_canvas(layers)
+ else:
+ self.toolbar.setEnabled(False)
+ self.stackedWidget.setCurrentIndex(1)
+ self.label_info.setText("No visual changes")
+ self.tabWidget.setCurrentIndex(1)
+ self.tabWidget.setTabEnabled(0, False)
+
+ def collect_layers(self, checked: bool):
+ if checked:
+ layers = iface.mapCanvas().layers()
+
+ # Filter only "Background" type
+ whitelist_backgound_layer_types = [QgsRasterLayer, QgsVectorTileLayer, QgsTiledSceneLayer]
+ layers = [layer for layer in layers if type(layer) in whitelist_backgound_layer_types]
+ else:
+ layers = []
+
+ if self.current_diff:
+ layers.insert(0, self.current_diff)
+
+ return layers
+
+ def diff_layer_changed(self, index: int):
+ if index > len(self.diff_layers) or index < 0:
+ return
+
+ self.map_canvas.setLayers([])
+ self.attribute_table.clearSelection()
+
+ self.current_diff = self.diff_layers[index]
+
+ self.layer_cache = QgsVectorLayerCache(self.current_diff, 1000)
+ self.layer_cache.setCacheGeometry(False)
+
+ self.table_model = QgsAttributeTableModel(self.layer_cache)
+ self.table_model.setRequest(QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry).setLimit(100))
+
+ self.filter_model = QgsAttributeTableFilterModel(self.map_canvas, self.table_model)
+
+ self.layer_cache.setParent(self.table_model)
+
+ self.attribute_table.setModel(self.filter_model)
+ self.table_model.loadLayer()
+
+ config = self.current_diff.attributeTableConfig()
+ self.filter_model.setAttributeTableConfig(config)
+ self.attribute_table.setAttributeTableConfig(config)
+
+ layers = self.collect_layers(self.toggle_layers_action.isChecked())
+ self.update_canvas(layers)
+
+ def add_current_to_project(self):
+ if self.current_diff:
+ QgsProject.instance().addMapLayer(self.current_diff)
+
+ def add_all_to_project(self):
+ for layer in self.diff_layers:
+ QgsProject.instance().addMapLayer(layer)
+
+ def zoom_full(self):
+ if self.current_diff:
+ layerExtent = self.current_diff.extent()
+ # transform extent
+ layerExtent = self.map_canvas.mapSettings().layerExtentToOutputExtent(self.current_diff, layerExtent)
+
+ self.map_canvas.setExtent(layerExtent)
+ self.map_canvas.refresh()
+
+ def zoom_selected(self):
+ if self.current_diff:
+ self.map_canvas.zoomToSelected([self.current_diff])
+ self.map_canvas.refresh()
+
+ def find_changeset_summary_for_layer(self, layer_name: str, changesets: dict):
+ for gpkg_changes in changesets.values():
+ for summary in gpkg_changes["summary"]:
+ if summary["table"] == layer_name:
+ return summary