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 +
qgis.gui
+ 1 +
+ + QgsAttributeTableView + QTableView +
qgsattributetableview.h
+
+
+ + +
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