├── .flake8 ├── .gitmodules ├── .pre-commit-config.yaml ├── .qgis-plugin-ci ├── LICENSE ├── README.md ├── qfieldsync ├── __init__.py ├── core │ ├── __init__.py │ ├── cloud_api.py │ ├── cloud_converter.py │ ├── cloud_project.py │ ├── cloud_transferrer.py │ ├── message_bus.py │ └── preferences.py ├── gui │ ├── __init__.py │ ├── attachment_naming_widget.py │ ├── checker_feedback_table.py │ ├── cloud_browser_tree.py │ ├── cloud_create_project_widget.py │ ├── cloud_login_dialog.py │ ├── cloud_projects_dialog.py │ ├── cloud_transfer_dialog.py │ ├── dirs_to_copy_widget.py │ ├── layers_config_widget.py │ ├── map_layer_config_widget.py │ ├── mapthemes_config_widget.py │ ├── package_dialog.py │ ├── preferences_widget.py │ ├── project_configuration_dialog.py │ ├── project_configuration_widget.py │ ├── relationship_configuration_widget.py │ ├── synchronize_dialog.py │ └── utils.py ├── metadata.txt ├── qfield_sync.py ├── resources.qrc ├── resources │ ├── add.svg │ ├── arrow_back-green.svg │ ├── arrow_back-orange.svg │ ├── arrow_back-red.svg │ ├── arrow_back.svg │ ├── arrow_forward-green.svg │ ├── arrow_forward-orange.svg │ ├── arrow_forward-red.svg │ ├── arrow_forward.svg │ ├── cloud.svg │ ├── cloud_create.svg │ ├── cloud_download.svg │ ├── cloud_off.svg │ ├── cloud_project.svg │ ├── cloud_project_remote.svg │ ├── cloud_synchronize.svg │ ├── cloud_upload.svg │ ├── computer.svg │ ├── delete-red.svg │ ├── delete.svg │ ├── edit.svg │ ├── file.svg │ ├── file_add-green.svg │ ├── file_refresh-orange.svg │ ├── icon.png │ ├── idea.svg │ ├── launch.svg │ ├── missing.svg │ ├── package.svg │ ├── project_properties.svg │ ├── qfield_logo.svg │ ├── qfieldcloud_logo.png │ ├── refresh-reverse.png │ ├── refresh.png │ ├── refresh.svg │ ├── sync.svg │ ├── sync_disabled.svg │ ├── synchronize.svg │ ├── visibility.svg │ └── warning.png ├── ui │ ├── cloud_create_project_widget.ui │ ├── cloud_login_dialog.ui │ ├── cloud_projects_dialog.ui │ ├── cloud_transfer_dialog.ui │ ├── dirs_to_copy_widget.ui │ ├── layers_config_widget.ui │ ├── map_layer_config_widget.ui │ ├── package_dialog.ui │ ├── preferences_widget.ui │ ├── project_configuration_widget.ui │ └── synchronize_dialog.ui └── utils │ ├── __init__.py │ ├── cloud_utils.py │ ├── file_utils.py │ ├── permissions.py │ ├── qgis_utils.py │ └── qt_utils.py ├── requirements.txt ├── scripts └── prepare-commit.sh └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, F401, W503 3 | max-line-length = 120 4 | max-complexity = 30 5 | exclude = **/.git/, **/qfieldsync/libqfieldsync/ 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "qfieldsync/setting_manager"] 2 | path = qfieldsync/setting_manager 3 | url = https://github.com/opengisch/qgissettingmanager.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # Fix end of files 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.4.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: mixed-line-ending 9 | args: 10 | - '--fix=lf' 11 | 12 | # Lint and format 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | # Ruff version. 15 | rev: v0.1.4 16 | hooks: 17 | # Run the linter. 18 | - id: ruff 19 | args: [ --fix ] 20 | 21 | # Run the formatter. 22 | - id: ruff-format 23 | 24 | # Sort imports alphabetically 25 | - repo: https://github.com/PyCQA/isort 26 | rev: 6.0.0 27 | hooks: 28 | - id: isort 29 | args: ["--profile", "black"] 30 | -------------------------------------------------------------------------------- /.qgis-plugin-ci: -------------------------------------------------------------------------------- 1 | plugin_path: qfieldsync 2 | github_organization_slug: opengisch 3 | project_slug: qfieldsync 4 | transifex_resource: QFieldSync 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Read the documentation](https://img.shields.io/badge/Read-the%20docs-green.svg)](https://docs.qfield.org/get-started/) 2 | [![Release](https://img.shields.io/github/release/opengisch/QFieldSync.svg)](https://github.com/opengisch/QFieldSync/releases) 3 | [![Build Status](https://travis-ci.org/opengisch/qfieldsync.svg?branch=master)](https://travis-ci.org/opengisch/qfieldsync) 4 | 5 | # QFieldSync 6 | 7 | This plugin facilitates packaging and synchronizing QGIS projects for use with [QField](http://www.qfield.org). 8 | 9 | It analyses the QGIS project and suggests and performs actions needed to make the project working on QField. 10 | 11 | More information can be found in the [QField documentation](https://docs.qfield.org/get-started/). 12 | 13 | The plugin can be download on the [QGIS plugin repository](https://plugins.qgis.org/plugins/qfieldsync/). 14 | 15 | 16 | ## Contribute 17 | 18 | QFieldSync is an open source project, licensed under the terms of the GPLv3 or later. 19 | This means that it is free to use and modify and will stay like that. 20 | 21 | We are very happy if this app helps you to get your job done or in whatever creative way you may use it. 22 | 23 | If you found it useful, we will be even happier if you could give something back. 24 | A couple of things you can do are: 25 | 26 | - Rate the plugin at [plugins.qgis.org](https://plugins.qgis.org/plugins/qfieldsync/) ★★★★★ 27 | - Write about your experience (please let us know!). 28 | - [Help with the documentation](https://github.com/opengisch/QField-docs/). 29 | - Translate [the QFieldSync QGIS plugin](https://app.transifex.com/opengisch/qfieldsync/dashboard/), [the QField app](https://app.transifex.com/opengisch/qfield-for-qgis/dashboard/) or [the documentation](https://app.transifex.com/opengisch/qfield-documentation/dashboard/). 30 | - [Sponsor a feature](https://docs.qfield.org/get-started/sponsor/) 31 | - And just drop by to say thank you or have a beer with us next time you meet [OPENGIS.ch](https://opengis.ch) at a conference. 32 | - [Develop a new feature or fix a bug](#development). 33 | 34 | 35 | ## Development 36 | 37 | ### Getting the source code 38 | 39 | 1) Checkout [`qfieldsync`](https://github.com/opengisch/qfieldsync/) locally: 40 | 41 | ```shell 42 | git clone --recurse-submodules git@github.com:opengisch/qfieldsync.git 43 | ``` 44 | 45 | 2) Make a link of the QFieldSync checkout to `qfieldsync` directory in your current QGIS profile: 46 | 47 | ```shell 48 | ln -s ${PWD}/qfieldsync/qfieldsync ${HOME}/.local/share/QGIS/QGIS3/profiles/default/python/plugins 49 | ``` 50 | 51 | 3) Checkout [`libqfieldsync`](https://github.com/opengisch/libqfieldsync/) locally: 52 | 53 | ```shell 54 | git clone git@github.com:opengisch/libqfieldsync.git 55 | ``` 56 | 57 | 4) Install your local `libqfieldsync` as editable dependency (assuming you are in the same directory as step 3): 58 | 59 | ```shell 60 | pip install -e libqfieldsync 61 | ``` 62 | 63 | > [!NOTE] 64 | > On more recent Linux distributions you might get an error `error: externally-managed-environment` and you have to pass additional `--break-system-packages`. 65 | > Despite the name, we promise this is not going to break system packages. 66 | 67 | ### Opening a PR 68 | 69 | Make sure each new feature or bug fix are in a separate PR. 70 | 71 | QFieldSync stores the respective `libqfieldsync` commit SHA in the bottom of [`requirements.txt`](https://github.com/opengisch/qfieldsync/blob/master/requirements.txt#L9-L10). 72 | Sometimes changes in QFieldSync require modifications in [`libqfieldsync`](https://github.com/opengisch/libqfieldsync/). 73 | In these cases please update the commit sha of `libqfieldsync` to point to the respective commit on `libqfieldsync`'s master branch. 74 | -------------------------------------------------------------------------------- /qfieldsync/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSync 5 | A QGIS plugin 6 | Sync your projects to QField on android 7 | ------------------- 8 | begin : 2015-05-20 9 | copyright : (C) 2015 by OPENGIS.ch 10 | email : info@opengis.ch 11 | git sha : $Format:%H$ 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | This script initializes the plugin, making it known to QGIS. 23 | """ 24 | 25 | from __future__ import absolute_import 26 | 27 | import importlib 28 | import pathlib 29 | import re 30 | import sys 31 | 32 | src_dir = pathlib.Path(__file__).parent.resolve() 33 | 34 | # remove previously loaded `libqfieldsync.whl` from the python import path 35 | for python_path in sys.path: 36 | if re.search(r"libqfieldsync.*\.whl$", python_path): 37 | sys.path.remove(python_path) 38 | 39 | # add the new `libqfieldsync.whl` file to the python import path 40 | for libqfieldsync_whl in src_dir.glob("libqfieldsync*.whl"): 41 | sys.path.append(str(libqfieldsync_whl)) 42 | 43 | # force reload all the `libqfieldsync` modules from the new path 44 | module_names = list(sys.modules.keys()) 45 | for module_name in module_names: 46 | if module_name.startswith("libqfieldsync"): 47 | importlib.reload(sys.modules[module_name]) 48 | 49 | 50 | # noinspection PyPep8Naming 51 | def classFactory(iface): # pylint: disable=invalid-name 52 | """Load QFieldSync class from file QFieldSync. 53 | 54 | :param iface: A QGIS interface instance. 55 | :type iface: QgsInterface 56 | """ 57 | 58 | from qfieldsync.qfield_sync import QFieldSync 59 | 60 | return QFieldSync(iface) 61 | -------------------------------------------------------------------------------- /qfieldsync/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .cloud_converter import CloudConverter # NOQA 2 | from .cloud_project import CloudProject # NOQA 3 | from .preferences import Preferences # NOQA 4 | -------------------------------------------------------------------------------- /qfieldsync/core/cloud_converter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSync 5 | ------------------- 6 | begin : 2021-07-20 7 | git sha : $Format:%H$ 8 | copyright : (C) 2021 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | 22 | from pathlib import Path 23 | 24 | from libqfieldsync.layer import LayerSource 25 | from libqfieldsync.utils.file_utils import copy_attachments 26 | from libqfieldsync.utils.qgis import get_qgis_files_within_dir, make_temp_qgis_file 27 | from qgis.core import QgsMapLayer, QgsProject, QgsVirtualLayerDefinition 28 | from qgis.PyQt.QtCore import QCoreApplication, QObject, QUrl, pyqtSignal 29 | from qgis.utils import iface 30 | 31 | from qfieldsync.core.preferences import Preferences 32 | from qfieldsync.utils.qgis_utils import open_project 33 | 34 | 35 | class CloudConverter(QObject): 36 | progressStopped = pyqtSignal() 37 | warning = pyqtSignal(str, str) 38 | total_progress_updated = pyqtSignal(int, int, str) 39 | 40 | def __init__( 41 | self, 42 | project: QgsProject, 43 | export_dirname: str, 44 | ): 45 | super(CloudConverter, self).__init__(parent=None) 46 | self.project = project 47 | self.__layers = list() 48 | 49 | # elipsis workaround 50 | self.trUtf8 = self.tr 51 | 52 | self.export_dirname = Path(export_dirname) 53 | 54 | def convert(self) -> None: # noqa: C901 55 | """ 56 | Convert the project to a cloud project. 57 | """ 58 | 59 | original_project_path = self.project.fileName() 60 | project_path = self.export_dirname.joinpath( 61 | f"{self.project.baseName()}_cloud.qgs" 62 | ) 63 | backup_project_path = make_temp_qgis_file(self.project) 64 | is_converted = False 65 | 66 | try: 67 | if not self.export_dirname.exists(): 68 | self.export_dirname.mkdir(parents=True, exist_ok=True) 69 | 70 | if get_qgis_files_within_dir(self.export_dirname): 71 | raise Exception( 72 | self.tr("The destination folder already contains a project file") 73 | ) 74 | 75 | self.total_progress_updated.emit(0, 100, self.trUtf8("Converting project…")) 76 | self.__layers = list(self.project.mapLayers().values()) 77 | 78 | # Loop through all layers and copy them to the destination folder 79 | for current_layer_index, layer in enumerate(self.__layers): 80 | self.total_progress_updated.emit( 81 | current_layer_index, 82 | len(self.__layers), 83 | self.trUtf8("Copying layers…"), 84 | ) 85 | 86 | layer_source = LayerSource(layer) 87 | if not layer_source.is_supported: 88 | self.project.removeMapLayer(layer) 89 | continue 90 | 91 | if layer.dataProvider() is not None: 92 | # layer stored in localized data path, skip 93 | if layer_source.is_localized_path: 94 | continue 95 | 96 | if layer.type() == QgsMapLayer.VectorLayer: 97 | if ( 98 | layer.dataProvider() 99 | and layer.dataProvider().name() == "virtual" 100 | ): 101 | url = QUrl.fromEncoded(layer.source().encode("ascii")) 102 | valid = url.isValid() 103 | if valid: 104 | definition = QgsVirtualLayerDefinition.fromUrl(url) 105 | for source in definition.sourceLayers(): 106 | if not source.isReferenced(): 107 | valid = False 108 | break 109 | if not valid: 110 | # virtual layers with non-referenced sources are not supported 111 | self.warning.emit( 112 | self.tr("Cloud Converter"), 113 | self.tr( 114 | "The virtual layer '{}' is not valid or contains non-referenced source(s) and could not be converted and was therefore removed from the cloud project." 115 | ).format(layer.name()), 116 | ) 117 | self.project.removeMapLayer(layer) 118 | continue 119 | else: 120 | if not layer_source.convert_to_gpkg(self.export_dirname): 121 | # something went wrong, remove layer and inform the user that layer will be missing 122 | self.warning.emit( 123 | self.tr("Cloud Converter"), 124 | self.tr( 125 | "The layer '{}' could not be converted and was therefore removed from the cloud project." 126 | ).format(layer.name()), 127 | ) 128 | self.project.removeMapLayer(layer) 129 | continue 130 | else: 131 | layer_source.copy(self.export_dirname, list()) 132 | layer.setCustomProperty( 133 | "QFieldSync/cloud_action", layer_source.default_cloud_action 134 | ) 135 | 136 | # save the offline project twice so that the offline plugin can "know" that it's a relative path 137 | if not self.project.write(str(project_path)): 138 | raise Exception( 139 | self.tr('Failed to save project to "{}".').format(project_path) 140 | ) 141 | 142 | # export the DCIM folder 143 | for attachment_dir in Preferences().value("attachmentDirs"): 144 | copy_attachments( 145 | Path(original_project_path).parent, 146 | project_path.parent, 147 | attachment_dir, 148 | ) 149 | 150 | title = self.project.title() 151 | title_suffix = self.tr("(QFieldCloud)") 152 | if not title.endswith(title_suffix): 153 | self.project.setTitle("{} {}".format(title, title_suffix)) 154 | # Now we have a project state which can be saved as cloud project 155 | self.project.write(str(project_path)) 156 | is_converted = True 157 | finally: 158 | # We need to let the app handle events before loading the next project or QGIS will crash with rasters 159 | QCoreApplication.processEvents() 160 | self.project.clear() 161 | QCoreApplication.processEvents() 162 | 163 | # TODO whatcha gonna do if QgsProject::read()/write() fails 164 | if is_converted: 165 | iface.addProject(str(project_path)) 166 | else: 167 | open_project(original_project_path, backup_project_path) 168 | 169 | self.total_progress_updated.emit(100, 100, self.tr("Finished")) 170 | -------------------------------------------------------------------------------- /qfieldsync/core/message_bus.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSync 5 | ------------------- 6 | begin : 2022-08-09 7 | git sha : $Format:%H$ 8 | copyright : (C) 2022 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | 22 | 23 | from qgis.PyQt.QtCore import QObject, pyqtSignal 24 | 25 | 26 | class MessageBus(QObject): 27 | """Super minimal implementation of a message bus. 28 | 29 | Allows communication between unrelated parts of the plugin. 30 | """ 31 | 32 | """The signal that passes the message.""" 33 | messaged = pyqtSignal(str) 34 | 35 | 36 | # Modules are evaluated only once, therefore it works as a poor man version of singleton. 37 | message_bus = MessageBus() 38 | -------------------------------------------------------------------------------- /qfieldsync/core/preferences.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from qfieldsync.setting_manager import ( 4 | Bool, 5 | Dictionary, 6 | Scope, 7 | SettingManager, 8 | String, 9 | Stringlist, 10 | ) 11 | 12 | pluginName = "QFieldSync" 13 | 14 | 15 | class Preferences(SettingManager): 16 | def __init__(self): 17 | SettingManager.__init__(self, pluginName, False) 18 | home = Path.home() 19 | self.add_setting( 20 | String("exportDirectory", Scope.Global, str(home.joinpath("QField/export"))) 21 | ) 22 | self.add_setting(String("exportDirectoryProject", Scope.Project, None)) 23 | self.add_setting( 24 | String("importDirectory", Scope.Global, str(home.joinpath("QField/import"))) 25 | ) 26 | self.add_setting(Bool("showPackagingActions", Scope.Global, True)) 27 | self.add_setting(String("importDirectoryProject", Scope.Project, None)) 28 | self.add_setting(Dictionary("dirsToCopy", Scope.Project, {})) 29 | self.add_setting(Stringlist("attachmentDirs", Scope.Project, ["DCIM"])) 30 | self.add_setting(Dictionary("qfieldCloudProjectLocalDirs", Scope.Global, {})) 31 | self.add_setting(Dictionary("qfieldCloudLastProjectFiles", Scope.Global, {})) 32 | self.add_setting(String("qfieldCloudServerUrl", Scope.Global, "")) 33 | self.add_setting(String("qfieldCloudAuthcfg", Scope.Global, "")) 34 | self.add_setting(Bool("qfieldCloudRememberMe", Scope.Global, False)) 35 | self.add_setting( 36 | String("cloudDirectory", Scope.Global, str(home.joinpath("QField/cloud"))) 37 | ) 38 | self.add_setting(Bool("firstRun", Scope.Global, True)) 39 | -------------------------------------------------------------------------------- /qfieldsync/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/gui/__init__.py -------------------------------------------------------------------------------- /qfieldsync/gui/attachment_naming_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | AttachmentNamingTableWidget 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2020-06-15 9 | git sha : $Format:%H$ 10 | copyright : (C) 2020 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from qgis.core import QgsMapLayer 25 | from qgis.gui import QgsFieldExpressionWidget 26 | from qgis.PyQt.QtCore import Qt 27 | from qgis.PyQt.QtWidgets import QTableWidget, QTableWidgetItem 28 | 29 | 30 | class AttachmentNamingTableWidget(QTableWidget): 31 | def __init__(self): 32 | super(AttachmentNamingTableWidget, self).__init__() 33 | 34 | self.setColumnCount(3) 35 | self.setHorizontalHeaderLabels( 36 | [self.tr("Layer"), self.tr("Field"), self.tr("Naming Expression")] 37 | ) 38 | self.horizontalHeaderItem(2).setToolTip( 39 | self.tr("Enter expression for a file path with the extension .jpg") 40 | ) 41 | self.horizontalHeader().setStretchLastSection(True) 42 | self.setRowCount(0) 43 | self.resizeColumnsToContents() 44 | self.setMinimumHeight(100) 45 | 46 | def addLayerFields(self, layer_source): 47 | layer = layer_source.layer 48 | 49 | if layer.type() != QgsMapLayer.VectorLayer: 50 | return 51 | 52 | for field_name in layer_source.get_attachment_fields().keys(): 53 | row = self.rowCount() 54 | 55 | self.insertRow(row) 56 | item = QTableWidgetItem(layer.name()) 57 | item.setData(Qt.UserRole, layer_source) 58 | item.setFlags(Qt.ItemIsEnabled) 59 | self.setItem(row, 0, item) 60 | item = QTableWidgetItem(field_name) 61 | item.setFlags(Qt.ItemIsEnabled) 62 | self.setItem(row, 1, item) 63 | ew = QgsFieldExpressionWidget() 64 | ew.setLayer(layer) 65 | ew.setExpression(layer_source.attachment_naming(field_name)) 66 | self.setCellWidget(row, 2, ew) 67 | 68 | self.resizeColumnsToContents() 69 | 70 | def setLayerColumnHidden(self, is_hidden): 71 | self.setColumnHidden(0, is_hidden) 72 | 73 | def syncLayerSourceValues(self, should_apply=False): 74 | for i in range(self.rowCount()): 75 | layer_source = self.item(i, 0).data(Qt.UserRole) 76 | field_name = self.item(i, 1).text() 77 | new_expression = self.cellWidget(i, 2).currentText() 78 | layer_source.set_attachment_naming(field_name, new_expression) 79 | 80 | if should_apply: 81 | layer_source.apply() 82 | -------------------------------------------------------------------------------- /qfieldsync/gui/checker_feedback_table.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSync 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2023-04-11 9 | git sha : $Format:%H$ 10 | copyright : (C) 2015 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from libqfieldsync.project_checker import Feedback, ProjectCheckerFeedback 25 | from qgis.core import QgsApplication 26 | from qgis.PyQt.QtCore import Qt 27 | from qgis.PyQt.QtWidgets import QLabel, QSizePolicy, QTableWidget, QTableWidgetItem 28 | 29 | from ..utils.qt_utils import make_icon 30 | 31 | 32 | class CheckerFeedbackTable(QTableWidget): 33 | def __init__(self, checker_feedback: ProjectCheckerFeedback, *args, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | 36 | self.setColumnCount(2) 37 | self.setHorizontalHeaderLabels(["", self.tr("Message")]) 38 | self.horizontalHeader().setStretchLastSection(True) 39 | self.setRowCount(0) 40 | self.setMinimumHeight(100) 41 | self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) 42 | 43 | for layer_id in checker_feedback.feedbacks.keys(): 44 | for feedback in checker_feedback.feedbacks[layer_id]: 45 | row = self.rowCount() 46 | 47 | self.insertRow(row) 48 | 49 | # first column 50 | if feedback.level == Feedback.Level.WARNING: 51 | level_icon = make_icon("idea.svg") 52 | level_text = self.tr("Warning") 53 | else: 54 | level_icon = QgsApplication.getThemeIcon("/mIconWarning.svg") 55 | level_text = self.tr("Error") 56 | 57 | item = QTableWidgetItem(level_icon, "") 58 | item.setFlags(Qt.ItemIsEnabled) 59 | item.setToolTip(level_text) 60 | self.setItem(row, 0, item) 61 | 62 | # second column 63 | if feedback.layer_id: 64 | source = self.tr('Layer "{}"').format(feedback.layer_name) 65 | else: 66 | source = self.tr("Project") 67 | 68 | item = QTableWidgetItem() 69 | item.setFlags(Qt.ItemIsEnabled) 70 | item.setToolTip(level_text) 71 | self.setItem(row, 1, item) 72 | 73 | # we do not escape the values on purpose to support Markdown/HTML 74 | label = QLabel("**{}**\n\n{}".format(source, feedback.message)) 75 | label.setWordWrap(True) 76 | label.setMargin(5) 77 | label.setTextFormat(Qt.MarkdownText) 78 | label.setTextInteractionFlags( 79 | Qt.TextSelectableByMouse 80 | | Qt.TextSelectableByKeyboard 81 | | Qt.LinksAccessibleByMouse 82 | | Qt.LinksAccessibleByKeyboard 83 | ) 84 | label.setOpenExternalLinks(True) 85 | self.setCellWidget(row, 1, label) 86 | 87 | self.verticalHeader().hide() 88 | self.resizeColumnsToContents() 89 | self.resizeRowsToContents() 90 | self.horizontalHeader().sectionResized.connect(self.resizeRowsToContents) 91 | self.setWordWrap(True) 92 | -------------------------------------------------------------------------------- /qfieldsync/gui/cloud_browser_tree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldCloud 5 | ------------------- 6 | begin : 2020-07-13 7 | git sha : $Format:%H$ 8 | copyright : (C) 2020 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | 22 | import os 23 | from pathlib import Path 24 | from typing import List 25 | 26 | from libqfieldsync.utils.qgis import get_qgis_files_within_dir 27 | from qgis.core import ( 28 | QgsDataCollectionItem, 29 | QgsDataItem, 30 | QgsDataItemProvider, 31 | QgsDataProvider, 32 | QgsErrorItem, 33 | ) 34 | from qgis.gui import QgsDataItemGuiProvider 35 | from qgis.PyQt.QtCore import QObject 36 | from qgis.PyQt.QtGui import QIcon 37 | from qgis.utils import iface 38 | 39 | from qfieldsync.core.cloud_api import CloudNetworkAccessManager 40 | from qfieldsync.core.cloud_project import CloudProject 41 | from qfieldsync.gui.cloud_login_dialog import CloudLoginDialog 42 | from qfieldsync.gui.cloud_projects_dialog import CloudProjectsDialog 43 | from qfieldsync.gui.cloud_transfer_dialog import CloudTransferDialog 44 | 45 | 46 | class QFieldCloudItemProvider(QgsDataItemProvider): 47 | def __init__(self, network_manager: CloudNetworkAccessManager): 48 | QgsDataItemProvider.__init__(self) 49 | self.network_manager = network_manager 50 | 51 | def name(self): 52 | return "QFieldCloudItemProvider" 53 | 54 | def capabilities(self): 55 | return QgsDataProvider.Net 56 | 57 | def createDataItem(self, path, parentItem): 58 | if not parentItem: 59 | root_item = QFieldCloudRootItem(self.network_manager) 60 | return root_item 61 | else: 62 | return 63 | 64 | 65 | class QFieldCloudRootItem(QgsDataCollectionItem): 66 | """QFieldCloud root""" 67 | 68 | def __init__(self, network_manager: CloudNetworkAccessManager): 69 | QgsDataCollectionItem.__init__( 70 | self, None, "QFieldCloud", "/QFieldCloud", "QFieldCloudProvider" 71 | ) 72 | self.setIcon( 73 | QIcon(os.path.join(os.path.dirname(__file__), "../resources/cloud_off.svg")) 74 | ) 75 | self.network_manager = network_manager 76 | self.error = None 77 | 78 | self.network_manager.login_finished.connect(lambda: self.update_icon()) 79 | self.network_manager.token_changed.connect(lambda: self.update_icon()) 80 | self.network_manager.projects_cache.projects_updated.connect( 81 | lambda: self.refreshing_cloud_projects() 82 | ) 83 | 84 | def capabilities2(self): 85 | return QgsDataItem.Fast 86 | 87 | def createChildren(self): 88 | items = [] 89 | 90 | if not self.network_manager.has_token(): 91 | CloudLoginDialog.show_auth_dialog(self.network_manager) 92 | self.setState(QgsDataItem.Populating) 93 | return [] 94 | 95 | self.setState(QgsDataItem.Populated) 96 | 97 | if self.error: 98 | error_item = QgsErrorItem(self, self.error, "/QFieldCloud/error") 99 | error_item.setIcon( 100 | QIcon(os.path.join(os.path.dirname(__file__), "../resources/cloud.svg")) 101 | ) 102 | return [error_item] 103 | 104 | my_projects = QFieldCloudGroupItem( 105 | self, "My projects", "private", "../resources/cloud.svg", 1 106 | ) 107 | items.append(my_projects) 108 | 109 | # TODO uncomment when public projects are ready 110 | # public_projects = QFieldCloudGroupItem( 111 | # self, "Community", "public", "../resources/cloud.svg", 2 112 | # ) 113 | # items.append(public_projects) 114 | 115 | return items 116 | 117 | def refreshing_cloud_projects(self): 118 | self.depopulate() 119 | self.refresh() 120 | 121 | def update_icon(self): 122 | if self.network_manager.has_token(): 123 | self.setIcon( 124 | QIcon(os.path.join(os.path.dirname(__file__), "../resources/cloud.svg")) 125 | ) 126 | else: 127 | self.setIcon( 128 | QIcon( 129 | os.path.join( 130 | os.path.dirname(__file__), "../resources/cloud_off.svg" 131 | ) 132 | ) 133 | ) 134 | 135 | 136 | class QFieldCloudGroupItem(QgsDataCollectionItem): 137 | """QFieldCloud group data item.""" 138 | 139 | def __init__(self, parent, name, project_type, icon, order): 140 | super(QFieldCloudGroupItem, self).__init__(parent, name, "/QFieldCloud/" + name) 141 | 142 | self.network_manager = parent.network_manager 143 | self.project_type = project_type 144 | self.setIcon(QIcon(os.path.join(os.path.dirname(__file__), icon))) 145 | self.setSortKey(order) 146 | 147 | def createChildren(self): 148 | items = [] 149 | 150 | projects: List[CloudProject] = self.network_manager.projects_cache.projects 151 | 152 | if projects is None: 153 | try: 154 | # TODO try to be make it Fast Fertile 155 | self.network_manager.projects_cache.refresh_not_async() 156 | except Exception: 157 | return [] 158 | 159 | projects = self.network_manager.projects_cache.projects 160 | 161 | for project in self.network_manager.projects_cache.projects: 162 | if (self.project_type == "public" and not project.is_private) or ( 163 | self.project_type == "private" and project.is_private 164 | ): 165 | item = QFieldCloudProjectItem(self, project) 166 | item.setState(QgsDataItem.Populated) 167 | items.append(item) 168 | 169 | return items 170 | 171 | 172 | class QFieldCloudProjectItem(QgsDataItem): 173 | """QFieldCloud project item.""" 174 | 175 | def __init__(self, parent, project): 176 | super(QFieldCloudProjectItem, self).__init__( 177 | QgsDataItem.Collection, 178 | parent, 179 | project.name, 180 | "/QFieldCloud/project/" + project.id, 181 | ) 182 | self.project_id = project.id 183 | project = parent.network_manager.projects_cache.find_project(self.project_id) 184 | self.setIcon( 185 | QIcon( 186 | str( 187 | Path(__file__).parent.joinpath( 188 | "../resources/cloud_project.svg" 189 | if project.local_dir 190 | else "../resources/cloud_project_remote.svg" 191 | ) 192 | ) 193 | ) 194 | ) 195 | 196 | 197 | class QFieldCloudItemGuiProvider(QgsDataItemGuiProvider): 198 | def __init__(self, network_manager: CloudNetworkAccessManager): 199 | QgsDataItemGuiProvider.__init__(self) 200 | self.network_manager = network_manager 201 | 202 | def name(self): 203 | return "QFieldCloudItemGuiProvider" 204 | 205 | def populateContextMenu(self, item, menu, selectedItems, context): 206 | if type(item) is QFieldCloudProjectItem: 207 | project = self.network_manager.projects_cache.find_project(item.project_id) 208 | if project and project.local_dir: 209 | open_action = menu.addAction(QObject().tr("Open Project")) 210 | open_action.triggered.connect(lambda: self.open_project(item)) 211 | 212 | sync_action = menu.addAction( 213 | QIcon(os.path.join(os.path.dirname(__file__), "../resources/sync.svg")), 214 | QObject().tr("Synchronize Project"), 215 | ) 216 | sync_action.triggered.connect( 217 | lambda: self.show_cloud_synchronize_dialog(item) 218 | ) 219 | 220 | properties_action = menu.addAction(QObject().tr("Project Properties")) 221 | properties_action.triggered.connect( 222 | lambda: self._create_projects_dialog(item).show_project_form() 223 | ) 224 | elif type(item) is QFieldCloudGroupItem: 225 | create_action = menu.addAction( 226 | QIcon(os.path.join(os.path.dirname(__file__), "../resources/edit.svg")), 227 | QObject().tr("Create New Project"), 228 | ) 229 | create_action.triggered.connect( 230 | lambda: CloudProjectsDialog( 231 | self.network_manager, iface.mainWindow() 232 | ).show_create_project() 233 | ) 234 | elif type(item) is QFieldCloudRootItem: 235 | projects_overview_action = menu.addAction( 236 | QIcon( 237 | os.path.join(os.path.dirname(__file__), "../resources/cloud.svg") 238 | ), 239 | QObject().tr("Projects Overview"), 240 | ) 241 | projects_overview_action.triggered.connect( 242 | lambda: CloudProjectsDialog( 243 | self.network_manager, iface.mainWindow() 244 | ).show() 245 | ) 246 | 247 | refresh_action = menu.addAction( 248 | QIcon( 249 | os.path.join(os.path.dirname(__file__), "../resources/refresh.png") 250 | ), 251 | QObject().tr("Refresh Projects"), 252 | ) 253 | refresh_action.triggered.connect(lambda: self.refresh_cloud_projects()) 254 | 255 | def handleDoubleClick(self, item, context): 256 | if type(item) is QFieldCloudProjectItem: 257 | if not self.open_project(item): 258 | self.show_cloud_synchronize_dialog(item) 259 | return True 260 | return False 261 | 262 | def _create_projects_dialog(self, item) -> CloudProjectsDialog: 263 | # it is important to get the project like this, because if the project list is refreshed, here we will store an old reference 264 | project = self.network_manager.projects_cache.find_project(item.project_id) 265 | return CloudProjectsDialog( 266 | self.network_manager, iface.mainWindow(), project=project 267 | ) 268 | 269 | def show_cloud_synchronize_dialog(self, item): 270 | project = self.network_manager.projects_cache.find_project(item.project_id) 271 | CloudTransferDialog.show_transfer_dialog( 272 | self.network_manager, project, None, None, iface.mainWindow() 273 | ) 274 | 275 | def open_project(self, item) -> bool: 276 | project = self.network_manager.projects_cache.find_project(item.project_id) 277 | if project and project.local_dir: 278 | project_file_name = get_qgis_files_within_dir(Path(project.local_dir)) 279 | if project_file_name: 280 | iface.addProject(os.path.join(project.local_dir, project_file_name[0])) 281 | return True 282 | 283 | return False 284 | 285 | def refresh_cloud_projects(self): 286 | if not self.network_manager.has_token(): 287 | CloudLoginDialog.show_auth_dialog( 288 | self.network_manager, lambda: self.refresh_cloud_projects() 289 | ) 290 | return 291 | 292 | if self.network_manager.is_login_active: 293 | return 294 | 295 | self.network_manager.projects_cache.refresh() 296 | -------------------------------------------------------------------------------- /qfieldsync/gui/cloud_login_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldCloudDialog 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2020-08-01 9 | git sha : $Format:%H$ 10 | copyright : (C) 2020 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | import os 24 | from typing import Callable 25 | 26 | from qgis.PyQt.QtCore import Qt 27 | from qgis.PyQt.QtGui import QPixmap 28 | from qgis.PyQt.QtWidgets import ( 29 | QApplication, 30 | QDialog, 31 | QDialogButtonBox, 32 | QMainWindow, 33 | QWidget, 34 | ) 35 | from qgis.PyQt.uic import loadUiType 36 | 37 | from qfieldsync.core import Preferences 38 | from qfieldsync.core.cloud_api import CloudNetworkAccessManager 39 | 40 | CloudLoginDialogUi, _ = loadUiType( 41 | os.path.join(os.path.dirname(__file__), "../ui/cloud_login_dialog.ui") 42 | ) 43 | 44 | 45 | class CloudLoginDialog(QDialog, CloudLoginDialogUi): 46 | instance = None 47 | 48 | @staticmethod 49 | def show_auth_dialog( 50 | network_manager: CloudNetworkAccessManager, 51 | accepted_cb: Callable = None, 52 | rejected_cb: Callable = None, 53 | parent: QWidget = None, 54 | ): 55 | if CloudLoginDialog.instance: 56 | CloudLoginDialog.instance.show() 57 | CloudLoginDialog.instance.raise_() 58 | CloudLoginDialog.instance.activateWindow() 59 | return CloudLoginDialog.instance 60 | 61 | CloudLoginDialog.instance = CloudLoginDialog(network_manager, parent) 62 | CloudLoginDialog.instance.authenticate() 63 | 64 | if accepted_cb: 65 | CloudLoginDialog.instance.accepted.connect(accepted_cb) 66 | if rejected_cb: 67 | CloudLoginDialog.instance.rejected.connect(rejected_cb) 68 | 69 | def on_finished(result): 70 | CloudLoginDialog.instance = None 71 | 72 | CloudLoginDialog.instance.finished.connect(on_finished) 73 | 74 | return CloudLoginDialog.instance 75 | 76 | def __init__( 77 | self, network_manager: CloudNetworkAccessManager, parent: QWidget = None 78 | ) -> None: 79 | """Constructor.""" 80 | super(CloudLoginDialog, self).__init__(parent=parent) 81 | self.setupUi(self) 82 | self.preferences = Preferences() 83 | self.network_manager = network_manager 84 | 85 | self.buttonBox.button(QDialogButtonBox.Ok).setText(self.tr("Sign In")) 86 | self.buttonBox.button(QDialogButtonBox.Ok).clicked.connect( 87 | self.on_login_button_clicked 88 | ) 89 | self.buttonBox.button(QDialogButtonBox.Cancel).clicked.connect( 90 | self.on_cancel_button_clicked 91 | ) 92 | 93 | self.serverUrlLabel.setVisible(False) 94 | self.serverUrlCmb.setVisible(False) 95 | for server_url in self.network_manager.server_urls(): 96 | self.serverUrlCmb.addItem(server_url) 97 | 98 | cfg = self.network_manager.auth() 99 | remember_me = self.preferences.value("qfieldCloudRememberMe") 100 | 101 | self.serverUrlCmb.setCurrentText(cfg.uri() or self.network_manager.url) 102 | self.usernameLineEdit.setText(cfg.config("username")) 103 | self.passwordLineEdit.setText(cfg.config("password")) 104 | self.rememberMeCheckBox.setChecked(remember_me) 105 | 106 | self.network_manager.login_finished.connect(self.on_login_finished) 107 | 108 | self.qfieldCloudIcon.setAlignment(Qt.AlignHCenter) 109 | self.qfieldCloudIcon.setPixmap( 110 | QPixmap( 111 | os.path.join( 112 | os.path.dirname(__file__), "../resources/qfieldcloud_logo.png" 113 | ) 114 | ) 115 | ) 116 | self.qfieldCloudIcon.setMinimumSize(175, 180) 117 | self.qfieldCloudIcon.mouseDoubleClickEvent = ( 118 | lambda event: self.toggle_server_url_visibility() 119 | ) 120 | self.rejected.connect(self.on_rejected) 121 | self.hide() 122 | 123 | def on_rejected(self) -> None: 124 | QApplication.restoreOverrideCursor() 125 | if self.parent(): 126 | self.parent().setEnabled(True) 127 | self.setEnabled(True) 128 | 129 | def toggle_server_url_visibility(self) -> None: 130 | self.serverUrlLabel.setVisible(not self.serverUrlLabel.isVisible()) 131 | self.serverUrlCmb.setVisible(not self.serverUrlCmb.isVisible()) 132 | 133 | def authenticate(self) -> None: 134 | self.usernameLineEdit.setEnabled(True) 135 | self.passwordLineEdit.setEnabled(True) 136 | self.rememberMeCheckBox.setEnabled(True) 137 | self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True) 138 | 139 | if self.parent() and not isinstance(self.parent(), QMainWindow): 140 | self.parent().setEnabled(False) 141 | self.setEnabled(True) 142 | 143 | cfg = self.network_manager.auth() 144 | 145 | if cfg.config("token"): 146 | self.usernameLineEdit.setEnabled(False) 147 | self.passwordLineEdit.setEnabled(False) 148 | self.rememberMeCheckBox.setEnabled(False) 149 | self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) 150 | 151 | self.network_manager.set_url(cfg.uri()) 152 | self.network_manager.set_auth(self.network_manager.url, token="") 153 | # don't trust the password, just login once again 154 | self.network_manager.login(cfg.config("username"), cfg.config("password")) 155 | 156 | if not cfg.config("token") or not self.parent(): 157 | self.show() 158 | self.raise_() 159 | self.activateWindow() 160 | 161 | def on_login_button_clicked(self) -> None: 162 | QApplication.setOverrideCursor(Qt.WaitCursor) 163 | 164 | self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) 165 | self.rememberMeCheckBox.setEnabled(False) 166 | 167 | server_url = self.serverUrlCmb.currentText() 168 | username = self.usernameLineEdit.text() 169 | password = self.passwordLineEdit.text() 170 | remember_me = self.rememberMeCheckBox.isChecked() 171 | 172 | self.network_manager.set_auth(server_url, username=username, password=password) 173 | self.network_manager.set_url(server_url) 174 | self.network_manager.login(username, password) 175 | 176 | self.preferences.set_value("qfieldCloudRememberMe", remember_me) 177 | 178 | def on_login_finished(self) -> None: 179 | QApplication.restoreOverrideCursor() 180 | if self.parent(): 181 | self.parent().setEnabled(True) 182 | self.setEnabled(True) 183 | 184 | if not self.network_manager.has_token(): 185 | self.loginFeedbackLabel.setText(self.network_manager.get_last_login_error()) 186 | self.loginFeedbackLabel.setVisible(True) 187 | self.usernameLineEdit.setEnabled(True) 188 | self.passwordLineEdit.setEnabled(True) 189 | self.rememberMeCheckBox.setEnabled(True) 190 | self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True) 191 | return 192 | 193 | self.usernameLineEdit.setEnabled(False) 194 | self.passwordLineEdit.setEnabled(False) 195 | self.rememberMeCheckBox.setEnabled(False) 196 | self.done(QDialog.Accepted) 197 | 198 | def on_cancel_button_clicked(self): 199 | self.reject() 200 | -------------------------------------------------------------------------------- /qfieldsync/gui/dirs_to_copy_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | AttachmentNamingTableWidget 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2023-02-26 9 | git sha : $Format:%H$ 10 | copyright : (C) 2023 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | import os 25 | from pathlib import Path 26 | from typing import Dict, Optional 27 | 28 | from qgis.PyQt.QtCore import Qt 29 | from qgis.PyQt.QtWidgets import QTreeWidgetItem, QWidget 30 | from qgis.PyQt.uic import loadUiType 31 | 32 | from qfieldsync.core.preferences import Preferences 33 | from qfieldsync.utils.file_utils import ( 34 | DirectoryTreeDict, 35 | DirectoryTreeType, 36 | path_to_dict, 37 | ) 38 | from qfieldsync.utils.qt_utils import build_file_tree_widget_from_dict 39 | 40 | LayersConfigWidgetUi, _ = loadUiType( 41 | os.path.join(os.path.dirname(__file__), "../ui/dirs_to_copy_widget.ui") 42 | ) 43 | 44 | DirsToCopySettings = Dict[str, bool] 45 | 46 | 47 | class DirsToCopyWidget(QWidget, LayersConfigWidgetUi): 48 | def __init__(self, parent=None): 49 | super().__init__(parent=parent) 50 | self.setupUi(self) 51 | 52 | self.path: Optional[Path] = None 53 | self.preferences: Preferences = Preferences() 54 | self.refreshButton.clicked.connect(lambda: self.refresh_tree()) 55 | self.selectAllButton.clicked.connect( 56 | lambda: self._set_checked_state_recursively(True) 57 | ) 58 | self.deselectAllButton.clicked.connect( 59 | lambda: self._set_checked_state_recursively(False) 60 | ) 61 | 62 | def refresh_tree(self): 63 | if self.path is None: 64 | return 65 | 66 | if not self.path.is_dir(): 67 | return 68 | 69 | dirs_to_copy = self.load_settings() 70 | 71 | self.dirsTreeWidget.clear() 72 | 73 | def build_item_cb(item: QTreeWidgetItem, node: DirectoryTreeDict): 74 | relative_path = node["path"].relative_to(self.path) 75 | str_path = str(relative_path) 76 | 77 | # TODO decide whether or not to take into account the attachment_dirs into account 78 | # attachment_dirs = self.preferences.value("attachmentDirs") # TODO move this in the outer scope 79 | # matches = False 80 | 81 | # for attachment_dir in attachment_dirs: 82 | # if str_path.startswith(attachment_dir): 83 | # matches = True 84 | 85 | # if not matches: 86 | # return False 87 | 88 | check_state = ( 89 | Qt.Checked if dirs_to_copy.get(str_path, True) else Qt.Unchecked 90 | ) 91 | 92 | item.setCheckState(0, check_state) 93 | item.setExpanded(True) 94 | item.setData(0, Qt.UserRole, str_path) 95 | item.setToolTip(0, str(node["path"].absolute())) 96 | item.setFlags( 97 | item.flags() 98 | | Qt.ItemIsUserCheckable 99 | | Qt.ItemIsSelectable 100 | | Qt.ItemIsAutoTristate 101 | ) 102 | 103 | root_item = self.dirsTreeWidget.invisibleRootItem() 104 | dict_paths = path_to_dict(self.path, dirs_only=True) 105 | 106 | if dict_paths["type"] == DirectoryTreeType.DIRECTORY: 107 | for subnode in dict_paths["content"]: 108 | build_file_tree_widget_from_dict( 109 | root_item, 110 | subnode, 111 | build_item_cb, 112 | ) 113 | 114 | def dirs_to_copy(self) -> DirsToCopySettings: 115 | def extract_dirs_data(root_item: QTreeWidgetItem) -> Dict[str, bool]: 116 | data = {} 117 | for i in range(root_item.childCount()): 118 | item = root_item.child(i) 119 | relative_path = item.data(0, Qt.UserRole) 120 | is_checked = item.checkState(0) == Qt.Checked 121 | data[relative_path] = is_checked 122 | 123 | if item.childCount() > 0: 124 | data.update(extract_dirs_data(item)) 125 | 126 | return data 127 | 128 | dirs_to_copy = extract_dirs_data(self.dirsTreeWidget.invisibleRootItem()) 129 | 130 | return dirs_to_copy 131 | 132 | def set_path(self, path: str) -> None: 133 | if path.strip(): 134 | self.path = Path(path) 135 | else: 136 | self.path = None 137 | 138 | is_enabled = bool(self.path and self.path.is_dir()) 139 | self.refreshButton.setEnabled(is_enabled) 140 | 141 | def load_settings(self) -> DirsToCopySettings: 142 | return self.preferences.value("dirsToCopy") 143 | 144 | def save_settings(self) -> None: 145 | self.preferences.set_value("dirsToCopy", self.dirs_to_copy()) 146 | 147 | def _set_checked_state_recursively(self, checked: bool) -> None: 148 | def set_checked_state(item: QTreeWidgetItem) -> None: 149 | checked_state = Qt.Checked if checked else Qt.Unchecked 150 | item.setCheckState(0, checked_state) 151 | 152 | for i in range(item.childCount()): 153 | set_checked_state(item.child(i)) 154 | 155 | set_checked_state(self.dirsTreeWidget.invisibleRootItem()) 156 | -------------------------------------------------------------------------------- /qfieldsync/gui/layers_config_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSyncDialog 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2020-10-10 9 | git sha : $Format:%H$ 10 | copyright : (C) 2020 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | import os 24 | from typing import Callable 25 | 26 | from libqfieldsync.layer import LayerSource, SyncAction 27 | from PyQt5.QtWidgets import QPushButton 28 | from qgis.core import Qgis, QgsMapLayerModel, QgsProject 29 | from qgis.PyQt.QtCore import Qt 30 | from qgis.PyQt.QtGui import QIcon 31 | from qgis.PyQt.QtWidgets import ( 32 | QAction, 33 | QComboBox, 34 | QMenu, 35 | QTableWidgetItem, 36 | QToolButton, 37 | QWidget, 38 | ) 39 | from qgis.PyQt.uic import loadUiType 40 | from qgis.utils import iface 41 | 42 | from qfieldsync.core.message_bus import message_bus 43 | from qfieldsync.gui.utils import set_available_actions 44 | 45 | LayersConfigWidgetUi, _ = loadUiType( 46 | os.path.join(os.path.dirname(__file__), "../ui/layers_config_widget.ui") 47 | ) 48 | 49 | 50 | class LayersConfigWidget(QWidget, LayersConfigWidgetUi): 51 | def __init__(self, project, use_cloud_actions, layer_sources, parent=None): 52 | """Constructor.""" 53 | super(LayersConfigWidget, self).__init__(parent=parent) 54 | self.setupUi(self) 55 | 56 | self.project = project 57 | self.use_cloud_actions = use_cloud_actions 58 | self.layer_sources = layer_sources 59 | 60 | self.multipleToggleButton.setIcon( 61 | QIcon( 62 | os.path.join(os.path.dirname(__file__), "../resources/visibility.svg") 63 | ) 64 | ) 65 | self.toggleMenu = QMenu(self) 66 | self.removeAllAction = QAction(self.tr("Remove All Layers"), self.toggleMenu) 67 | self.toggleMenu.addAction(self.removeAllAction) 68 | self.removeHiddenAction = QAction( 69 | self.tr("Remove Hidden Layers"), self.toggleMenu 70 | ) 71 | self.toggleMenu.addAction(self.removeHiddenAction) 72 | self.addAllCopyAction = QAction(self.tr("Add All Layers"), self.toggleMenu) 73 | self.toggleMenu.addAction(self.addAllCopyAction) 74 | self.addVisibleCopyAction = QAction( 75 | self.tr("Add Visible Layers"), self.toggleMenu 76 | ) 77 | self.toggleMenu.addAction(self.addVisibleCopyAction) 78 | self.addAllOfflineAction = QAction( 79 | self.tr("Add All Vector Layers as Offline"), self.toggleMenu 80 | ) 81 | self.toggleMenu.addAction(self.addAllOfflineAction) 82 | self.addVisibleOfflineAction = QAction( 83 | self.tr("Add Visible Vector Layers as Offline"), self.toggleMenu 84 | ) 85 | self.toggleMenu.addAction(self.addVisibleOfflineAction) 86 | self.multipleToggleButton.setMenu(self.toggleMenu) 87 | self.multipleToggleButton.setAutoRaise(True) 88 | self.multipleToggleButton.setPopupMode(QToolButton.InstantPopup) 89 | self.toggleMenu.triggered.connect(self.toggleMenu_triggered) 90 | self.unsupportedLayersList = list() 91 | 92 | self._on_message_bus_messaged_wrapper = ( 93 | lambda msg: self._on_message_bus_messaged(msg) 94 | ) 95 | message_bus.messaged.connect(self._on_message_bus_messaged_wrapper) 96 | 97 | self.showVisibleLayersOnlyCheckbox.stateChanged.connect(self.reloadProject) 98 | self.textFilterBox.textChanged.connect(self.reloadProject) 99 | 100 | self.reloadProject() 101 | 102 | def get_available_actions(self, layer_source): 103 | if self.use_cloud_actions: 104 | return layer_source.available_cloud_actions 105 | else: 106 | return layer_source.available_actions 107 | 108 | def get_layer_action(self, layer_source): 109 | if self.use_cloud_actions: 110 | return layer_source.cloud_action 111 | else: 112 | return layer_source.action 113 | 114 | def set_layer_action(self, layer_source, action): 115 | if self.use_cloud_actions: 116 | layer_source.cloud_action = action 117 | else: 118 | layer_source.action = action 119 | 120 | def get_default_action(self, layer_source): 121 | if self.use_cloud_actions: 122 | return layer_source.default_cloud_action 123 | else: 124 | return layer_source.default_action 125 | 126 | def reloadProject(self): 127 | """ 128 | Load all layers from the map layer registry into the table. 129 | """ 130 | self.unsupportedLayersList = list() 131 | 132 | self.layersTable.setRowCount(0) 133 | self.layersTable.setSortingEnabled(False) 134 | 135 | # Get filtered layers 136 | show_visible_only = self.showVisibleLayersOnlyCheckbox.isChecked() 137 | filter_text = self.textFilterBox.text().lower() 138 | 139 | layers = [] 140 | if show_visible_only: 141 | for layer_source in self.layer_sources: 142 | if ( 143 | QgsProject.instance() 144 | .layerTreeRoot() 145 | .findLayer(layer_source.layer.id()) 146 | .isVisible() 147 | ): 148 | layers.append(layer_source) 149 | else: 150 | layers = self.layer_sources 151 | 152 | for layer_source in layers: 153 | layer_name = layer_source.layer.name().lower() 154 | 155 | if filter_text and filter_text not in layer_name: 156 | continue 157 | 158 | count = self.layersTable.rowCount() 159 | self.layersTable.insertRow(count) 160 | item = QTableWidgetItem(layer_source.layer.name()) 161 | item.setData(Qt.UserRole, layer_source) 162 | item.setData(Qt.EditRole, layer_source.layer.name()) 163 | item.setIcon(QgsMapLayerModel.iconForLayer(layer_source.layer)) 164 | self.layersTable.setItem(count, 0, item) 165 | 166 | cmb = QComboBox() 167 | available_actions = self.get_available_actions(layer_source) 168 | set_available_actions( 169 | cmb, available_actions, self.get_layer_action(layer_source) 170 | ) 171 | 172 | properties_btn = QPushButton() 173 | properties_btn.setText(self.tr("Properties")) 174 | properties_btn.clicked.connect(self.propertiesBtn_clicked(layer_source)) 175 | 176 | self.layersTable.setCellWidget(count, 1, cmb) 177 | self.layersTable.setCellWidget(count, 2, properties_btn) 178 | 179 | if not layer_source.is_supported: 180 | self.unsupportedLayersList.append(layer_source) 181 | self.layersTable.item(count, 0).setFlags(Qt.NoItemFlags) 182 | self.layersTable.cellWidget(count, 1).setEnabled(False) 183 | cmb.setCurrentIndex(cmb.findData(SyncAction.REMOVE)) 184 | 185 | self.layersTable.resizeColumnsToContents() 186 | self.layersTable.sortByColumn(0, Qt.AscendingOrder) 187 | self.layersTable.setSortingEnabled(True) 188 | 189 | if self.unsupportedLayersList: 190 | self.unsupportedLayersLabel.setVisible(True) 191 | 192 | unsupported_layers_text = "{}: ".format(self.tr("Warning")) 193 | unsupported_layers_text += self.tr( 194 | "There are unsupported layers in your project which will not be available in QField." 195 | ) 196 | unsupported_layers_text += self.tr( 197 | " If needed, you can create a Base Map to include those layers in your packaged project." 198 | ) 199 | self.unsupportedLayersLabel.setText(unsupported_layers_text) 200 | 201 | def propertiesBtn_clicked(self, layer_source: LayerSource) -> Callable: 202 | def clicked_callback() -> None: 203 | if Qgis.QGIS_VERSION_INT >= 31900: 204 | iface.showLayerProperties(layer_source.layer, "QFieldLayerSettingsPage") 205 | else: 206 | iface.showLayerProperties(layer_source.layer) 207 | 208 | return clicked_callback 209 | 210 | def toggleMenu_triggered(self, action): 211 | """ 212 | Toggles usage of layers 213 | :param action: the menu action that triggered this 214 | """ 215 | sync_action = None 216 | if action in (self.removeHiddenAction, self.removeAllAction): 217 | sync_action = SyncAction.REMOVE 218 | elif action in (self.addAllOfflineAction, self.addVisibleOfflineAction): 219 | sync_action = SyncAction.OFFLINE 220 | 221 | is_project_dirty = False 222 | # all layers 223 | if action in ( 224 | self.removeAllAction, 225 | self.addAllCopyAction, 226 | self.addAllOfflineAction, 227 | ): 228 | for i in range(self.layersTable.rowCount()): 229 | item = self.layersTable.item(i, 0) 230 | layer_source = item.data(Qt.UserRole) 231 | old_action = self.get_layer_action(layer_source) 232 | available_actions, _ = zip(*self.get_available_actions(layer_source)) 233 | layer_sync_action = ( 234 | self.get_default_action(layer_source) 235 | if sync_action is None 236 | else sync_action 237 | ) 238 | if layer_sync_action in available_actions: 239 | self.set_layer_action(layer_source, layer_sync_action) 240 | if self.get_layer_action(layer_source) != old_action: 241 | self.project.setDirty(True) 242 | layer_source.apply() 243 | is_project_dirty |= layer_source.apply() 244 | # based on visibility 245 | elif action in ( 246 | self.removeHiddenAction, 247 | self.addVisibleCopyAction, 248 | self.addVisibleOfflineAction, 249 | ): 250 | visible = action != self.removeHiddenAction 251 | root = QgsProject.instance().layerTreeRoot() 252 | for layer in QgsProject.instance().mapLayers().values(): 253 | node = root.findLayer(layer.id()) 254 | if node and node.isVisible() == visible: 255 | layer_source = LayerSource(layer) 256 | old_action = self.get_layer_action(layer_source) 257 | available_actions, _ = zip( 258 | *self.get_available_actions(layer_source) 259 | ) 260 | layer_sync_action = ( 261 | self.get_default_action(layer_source) 262 | if sync_action is None 263 | else sync_action 264 | ) 265 | if layer_sync_action in available_actions: 266 | self.set_layer_action(layer_source, layer_sync_action) 267 | if self.get_layer_action(layer_source) != old_action: 268 | self.project.setDirty(True) 269 | layer_source.apply() 270 | is_project_dirty |= layer_source.apply() 271 | 272 | if is_project_dirty: 273 | self.project.setDirty(True) 274 | 275 | self.reloadProject() 276 | 277 | def apply(self): 278 | is_project_dirty = False 279 | 280 | for i in range(self.layersTable.rowCount()): 281 | item = self.layersTable.item(i, 0) 282 | layer_source = item.data(Qt.UserRole) 283 | cmb = self.layersTable.cellWidget(i, 1) 284 | 285 | self.set_layer_action(layer_source, cmb.itemData(cmb.currentIndex())) 286 | 287 | is_project_dirty |= layer_source.apply() 288 | 289 | if is_project_dirty: 290 | self.project.setDirty(True) 291 | 292 | def _on_message_bus_messaged(self, msg: str) -> None: 293 | # when MapLayerConfigWidget.apply() detects changes in layer settings, 294 | # the event is emitted with `layer_config_saved` as a message. 295 | # check ./gui/map_layer_config_widget.py 296 | if msg != "layer_config_saved": 297 | return 298 | 299 | for layer_source in self.layer_sources.copy(): 300 | try: 301 | layer_source.read_layer() 302 | except RuntimeError: 303 | self.layer_sources.remove(layer_source) 304 | 305 | # quite ugly workaround, but this method sometimes operates on deleted objects, 306 | # so we need to make sure we don't get exceptions 307 | try: 308 | self.reloadProject() 309 | except Exception: 310 | message_bus.messaged.disconnect(self._on_message_bus_messaged_wrapper) 311 | -------------------------------------------------------------------------------- /qfieldsync/gui/map_layer_config_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSyncDialog 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2020-06-15 9 | git sha : $Format:%H$ 10 | copyright : (C) 2020 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | import os 24 | 25 | from libqfieldsync.layer import LayerSource 26 | from qgis.core import QgsMapLayer, QgsProject, QgsProperty, QgsPropertyDefinition 27 | from qgis.gui import QgsMapLayerConfigWidget, QgsMapLayerConfigWidgetFactory, QgsSpinBox 28 | from qgis.PyQt.QtWidgets import QLabel 29 | from qgis.PyQt.uic import loadUiType 30 | 31 | from qfieldsync.core.message_bus import message_bus 32 | from qfieldsync.gui.attachment_naming_widget import AttachmentNamingTableWidget 33 | from qfieldsync.gui.relationship_configuration_widget import ( 34 | RelationshipConfigurationTableWidget, 35 | ) 36 | from qfieldsync.gui.utils import set_available_actions 37 | 38 | WidgetUi, _ = loadUiType( 39 | os.path.join(os.path.dirname(__file__), "../ui/map_layer_config_widget.ui") 40 | ) 41 | 42 | 43 | class MapLayerConfigWidgetFactory(QgsMapLayerConfigWidgetFactory): 44 | def __init__(self, title, icon): 45 | super(MapLayerConfigWidgetFactory, self).__init__(title, icon) 46 | 47 | def createWidget(self, layer, canvas, dock_widget, parent): 48 | return MapLayerConfigWidget(layer, canvas, parent) 49 | 50 | def supportsLayer(self, layer): 51 | return LayerSource(layer).is_supported 52 | 53 | def supportLayerPropertiesDialog(self): 54 | return True 55 | 56 | 57 | class MapLayerConfigWidget(QgsMapLayerConfigWidget, WidgetUi): 58 | PROPERTY_GEOMETRY_LOCKED = 1 59 | 60 | def __init__(self, layer, canvas, parent): 61 | super(MapLayerConfigWidget, self).__init__(layer, canvas, parent) 62 | self.setupUi(self) 63 | self.layer_source = LayerSource(layer) 64 | self.project = QgsProject.instance() 65 | 66 | set_available_actions( 67 | self.cloudLayerActionComboBox, 68 | self.layer_source.available_cloud_actions, 69 | self.layer_source.cloud_action, 70 | ) 71 | set_available_actions( 72 | self.cableLayerActionComboBox, 73 | self.layer_source.available_actions, 74 | self.layer_source.action, 75 | ) 76 | 77 | self.attachmentNamingTable = AttachmentNamingTableWidget() 78 | self.attachmentNamingTable.addLayerFields(self.layer_source) 79 | self.attachmentNamingTable.setLayerColumnHidden(True) 80 | 81 | self.relationshipConfigurationTable = RelationshipConfigurationTableWidget() 82 | self.relationshipConfigurationTable.addLayerFields(self.layer_source) 83 | self.relationshipConfigurationTable.setLayerColumnHidden(True) 84 | 85 | self.valueMapButtonInterfaceSpinBox.setClearValueMode( 86 | QgsSpinBox.CustomValue, self.tr("Disabled") 87 | ) 88 | 89 | self.measurementTypeComboBox.addItem( 90 | "Elapsed time (seconds since start of tracking)" 91 | ) 92 | self.measurementTypeComboBox.addItem( 93 | self.tr("Timestamp (milliseconds since epoch)") 94 | ) 95 | self.measurementTypeComboBox.addItem(self.tr("Ground speed")) 96 | self.measurementTypeComboBox.addItem(self.tr("Bearing")) 97 | self.measurementTypeComboBox.addItem(self.tr("Horizontal accuracy")) 98 | self.measurementTypeComboBox.addItem(self.tr("Vertical accuracy")) 99 | self.measurementTypeComboBox.addItem(self.tr("PDOP")) 100 | self.measurementTypeComboBox.addItem(self.tr("HDOP")) 101 | self.measurementTypeComboBox.addItem(self.tr("VDOP")) 102 | 103 | if layer.type() == QgsMapLayer.VectorLayer: 104 | prop = QgsProperty.fromExpression( 105 | self.layer_source.geometry_locked_expression 106 | ) 107 | prop.setActive(self.layer_source.is_geometry_locked_expression_active) 108 | prop_definition = QgsPropertyDefinition( 109 | "is_geometry_locked", 110 | QgsPropertyDefinition.DataType.DataTypeBoolean, 111 | "Geometry Locked", 112 | "", 113 | ) 114 | self.isGeometryLockedDDButton.init( 115 | MapLayerConfigWidget.PROPERTY_GEOMETRY_LOCKED, 116 | prop, 117 | prop_definition, 118 | None, 119 | False, 120 | ) 121 | self.isGeometryLockedDDButton.setVectorLayer(layer) 122 | 123 | self.isGeometryLockedCheckBox.setEnabled( 124 | self.layer_source.can_lock_geometry 125 | ) 126 | self.isGeometryLockedCheckBox.setChecked( 127 | self.layer_source.is_geometry_locked 128 | ) 129 | 130 | self.valueMapButtonInterfaceSpinBox.setValue( 131 | self.layer_source.value_map_button_interface_threshold 132 | ) 133 | self.valueMapButtonInterfaceSpinBox.setVisible(True) 134 | 135 | # append the attachment naming table to the layout 136 | self.attachmentsRelationsLayout.insertRow( 137 | -1, self.tr("Attachment\nnaming"), self.attachmentNamingTable 138 | ) 139 | tip = QLabel( 140 | self.tr( 141 | "In your expressions, use {filename} and {extension} tags to refer to attachment filenames and extensions." 142 | ) 143 | ) 144 | tip.setWordWrap(True) 145 | self.attachmentsRelationsLayout.insertRow(-1, "", tip) 146 | self.attachmentNamingTable.setEnabled( 147 | self.attachmentNamingTable.rowCount() > 0 148 | ) 149 | 150 | # append the relationship configuration table to the layout 151 | self.attachmentsRelationsLayout.insertRow( 152 | -1, 153 | self.tr("Relationship\nconfiguration"), 154 | self.relationshipConfigurationTable, 155 | ) 156 | self.relationshipConfigurationTable.setEnabled( 157 | self.relationshipConfigurationTable.rowCount() > 0 158 | ) 159 | 160 | self.trackingSessionGroupBox.setChecked( 161 | self.layer_source.tracking_session_active 162 | ) 163 | self.timeRequirementCheckBox.setChecked( 164 | self.layer_source.tracking_time_requirement_active 165 | ) 166 | self.timeRequirementIntervalSecondsSpinBox.setValue( 167 | self.layer_source.tracking_time_requirement_interval_seconds 168 | ) 169 | self.distanceRequirementCheckBox.setChecked( 170 | self.layer_source.tracking_distance_requirement_active 171 | ) 172 | self.distanceRequirementMinimumMetersSpinBox.setValue( 173 | self.layer_source.tracking_distance_requirement_minimum_meters 174 | ) 175 | self.sensorDataRequirementCheckBox.setChecked( 176 | self.layer_source.tracking_sensor_data_requirement_active 177 | ) 178 | self.allRequirementsCheckBox.setChecked( 179 | self.layer_source.tracking_all_requirements_active 180 | ) 181 | self.erroneousDistanceSafeguardCheckBox.setChecked( 182 | self.layer_source.tracking_erroneous_distance_safeguard_active 183 | ) 184 | self.erroneousDistanceSafeguardMaximumMetersSpinBox.setValue( 185 | self.layer_source.tracking_erroneous_distance_safeguard_maximum_meters 186 | ) 187 | self.measurementTypeComboBox.setCurrentIndex( 188 | self.layer_source.tracking_measurement_type 189 | ) 190 | else: 191 | self.isGeometryLockedDDButton.setVisible(False) 192 | self.isGeometryLockedCheckBox.setVisible(False) 193 | 194 | self.valueMapButtonInterfaceSpinBox.setVisible(False) 195 | self.attachmentsRelationsGroupBox.setVisible(False) 196 | self.trackingSessionGroupBox.setVisible(False) 197 | 198 | def apply(self): 199 | self.layer_source.action 200 | self.layer_source.cloud_action 201 | self.layer_source.is_geometry_locked 202 | 203 | self.layer_source.cloud_action = self.cloudLayerActionComboBox.itemData( 204 | self.cloudLayerActionComboBox.currentIndex() 205 | ) 206 | self.layer_source.action = self.cableLayerActionComboBox.itemData( 207 | self.cableLayerActionComboBox.currentIndex() 208 | ) 209 | self.layer_source.is_geometry_locked = self.isGeometryLockedCheckBox.isChecked() 210 | prop = self.isGeometryLockedDDButton.toProperty() 211 | self.layer_source.is_geometry_locked_expression_active = prop.isActive() 212 | self.layer_source.geometry_locked_expression = prop.asExpression() 213 | print(self.valueMapButtonInterfaceSpinBox.value()) 214 | print(self.valueMapButtonInterfaceSpinBox.value()) 215 | self.layer_source.value_map_button_interface_threshold = ( 216 | self.valueMapButtonInterfaceSpinBox.value() 217 | ) 218 | self.attachmentNamingTable.syncLayerSourceValues() 219 | self.relationshipConfigurationTable.syncLayerSourceValues() 220 | 221 | self.layer_source.tracking_session_active = ( 222 | self.trackingSessionGroupBox.isChecked() 223 | ) 224 | self.layer_source.tracking_time_requirement_active = ( 225 | self.timeRequirementCheckBox.isChecked() 226 | ) 227 | self.layer_source.tracking_time_requirement_interval_seconds = ( 228 | self.timeRequirementIntervalSecondsSpinBox.value() 229 | ) 230 | self.layer_source.tracking_distance_requirement_active = ( 231 | self.distanceRequirementCheckBox.isChecked() 232 | ) 233 | self.layer_source.tracking_distance_requirement_minimum_meters = ( 234 | self.distanceRequirementMinimumMetersSpinBox.value() 235 | ) 236 | self.layer_source.tracking_sensor_data_requirement_active = ( 237 | self.sensorDataRequirementCheckBox.isChecked() 238 | ) 239 | self.layer_source.tracking_all_requirements_active = ( 240 | self.allRequirementsCheckBox.isChecked() 241 | ) 242 | self.layer_source.tracking_erroneous_distance_safeguard_active = ( 243 | self.erroneousDistanceSafeguardCheckBox.isChecked() 244 | ) 245 | self.layer_source.tracking_erroneous_distance_safeguard_maximum_meters = ( 246 | self.erroneousDistanceSafeguardMaximumMetersSpinBox.value() 247 | ) 248 | self.layer_source.tracking_measurement_type = ( 249 | self.measurementTypeComboBox.currentIndex() 250 | ) 251 | 252 | if self.layer_source.apply(): 253 | self.project.setDirty(True) 254 | message_bus.messaged.emit("layer_config_saved") 255 | -------------------------------------------------------------------------------- /qfieldsync/gui/mapthemes_config_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | MapThemesConfigWidget 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2024-07-22 9 | git sha : $Format:%H$ 10 | copyright : (C) 2024 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from qgis.core import Qgis, QgsMapLayerProxyModel 25 | from qgis.gui import QgsMapLayerComboBox 26 | from qgis.PyQt.QtCore import Qt 27 | from qgis.PyQt.QtWidgets import QTableWidget, QTableWidgetItem 28 | 29 | 30 | class MapThemesConfigWidget(QTableWidget): 31 | def __init__(self, project, configuration, parent=None): 32 | """Constructor.""" 33 | super(QTableWidget, self).__init__(parent=parent) 34 | 35 | self.project = project 36 | 37 | self.setMinimumHeight(200) 38 | self.setColumnCount(2) 39 | self.setHorizontalHeaderLabels( 40 | [self.tr("Map Theme"), self.tr("Default Active Layer")] 41 | ) 42 | 43 | self.reload(configuration) 44 | 45 | def reload(self, configuration): 46 | """ 47 | Load map themes into table. 48 | """ 49 | 50 | self.setRowCount(0) 51 | self.setSortingEnabled(False) 52 | map_themes = self.project.mapThemeCollection().mapThemes() 53 | for map_theme in map_themes: 54 | count = self.rowCount() 55 | self.insertRow(count) 56 | item = QTableWidgetItem(map_theme) 57 | item.setData(Qt.EditRole, map_theme) 58 | self.setItem(count, 0, item) 59 | 60 | cmb = QgsMapLayerComboBox() 61 | cmb.setAllowEmptyLayer(True) 62 | if Qgis.QGIS_VERSION_INT >= 32400: 63 | cmb.setProject(self.project) 64 | cmb.setFilters(QgsMapLayerProxyModel.VectorLayer) 65 | if map_theme in configuration: 66 | cmb.setLayer(self.project.mapLayer(configuration[map_theme])) 67 | self.setCellWidget(count, 1, cmb) 68 | 69 | self.setColumnWidth(0, int(self.width() * 0.2)) 70 | self.setColumnWidth(1, int(self.width() * 0.75)) 71 | self.sortByColumn(0, Qt.AscendingOrder) 72 | self.setSortingEnabled(True) 73 | 74 | def createConfiguration(self): 75 | configuration = {} 76 | for i in range(self.rowCount()): 77 | item = self.item(i, 0) 78 | map_theme = item.data(Qt.EditRole) 79 | cmb = self.cellWidget(i, 1) 80 | layer_id = cmb.currentLayer().id() if cmb.currentLayer() else "" 81 | configuration[map_theme] = layer_id 82 | 83 | return configuration 84 | -------------------------------------------------------------------------------- /qfieldsync/gui/preferences_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSyncDialog 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2015-05-20 9 | git sha : $Format:%H$ 10 | copyright : (C) 2015 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | import os 25 | 26 | from qgis.gui import QgsFileWidget, QgsOptionsPageWidget 27 | from qgis.PyQt.uic import loadUiType 28 | 29 | from qfieldsync.core.preferences import Preferences 30 | from qfieldsync.setting_manager import SettingDialog 31 | 32 | WidgetUi, _ = loadUiType( 33 | os.path.join(os.path.dirname(__file__), "../ui/preferences_widget.ui") 34 | ) 35 | 36 | 37 | class PreferencesWidget(WidgetUi, QgsOptionsPageWidget, SettingDialog): 38 | def __init__(self, qfieldSync, parent=None): 39 | self.qfieldSync = qfieldSync 40 | preferences = Preferences() 41 | SettingDialog.__init__(self, setting_manager=preferences) 42 | super().__init__(parent, setting_manager=preferences) 43 | self.setupUi(self) 44 | self.init_widgets() 45 | 46 | self.setting_widget("importDirectory").widget.setStorageMode( 47 | QgsFileWidget.GetDirectory 48 | ) 49 | self.setting_widget("exportDirectory").widget.setStorageMode( 50 | QgsFileWidget.GetDirectory 51 | ) 52 | self.setting_widget("cloudDirectory").widget.setStorageMode( 53 | QgsFileWidget.GetDirectory 54 | ) 55 | 56 | def apply(self): 57 | self.set_values_from_widgets() 58 | self.qfieldSync.update_button_visibility() 59 | -------------------------------------------------------------------------------- /qfieldsync/gui/project_configuration_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | ------------------- 5 | begin : 21.11.2016 6 | git sha : :%H$ 7 | copyright : (C) 2016 by OPENGIS.ch 8 | email : info@opengis.ch 9 | ***************************************************************************/ 10 | 11 | /*************************************************************************** 12 | * * 13 | * This program is free software; you can redistribute it and/or modify * 14 | * it under the terms of the GNU General Public License as published by * 15 | * the Free Software Foundation; either version 2 of the License, or * 16 | * (at your option) any later version. * 17 | * * 18 | ***************************************************************************/ 19 | """ 20 | 21 | from qgis.gui import QgsGui 22 | from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout 23 | 24 | from qfieldsync.gui.project_configuration_widget import ProjectConfigurationWidget 25 | 26 | 27 | class ProjectConfigurationDialog(QDialog): 28 | """ 29 | Configuration dialog for QFieldSync on a particular project. 30 | """ 31 | 32 | def __init__(self, parent=None): 33 | """Constructor.""" 34 | super(ProjectConfigurationDialog, self).__init__(parent=parent) 35 | 36 | self.setMinimumWidth(500) 37 | QgsGui.instance().enableAutoGeometryRestore(self) 38 | 39 | self.setWindowTitle("QFieldSync Project Properties") 40 | 41 | self.projectConfigurationWidget = ProjectConfigurationWidget(self) 42 | 43 | self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 44 | self.buttonBox.accepted.connect(lambda: self.onAccepted()) 45 | self.buttonBox.rejected.connect(self.reject) 46 | 47 | self.layout = QVBoxLayout() 48 | self.layout.addWidget(self.projectConfigurationWidget) 49 | self.layout.addWidget(self.buttonBox) 50 | self.setLayout(self.layout) 51 | 52 | def onAccepted(self): 53 | self.projectConfigurationWidget.apply() 54 | self.close() 55 | -------------------------------------------------------------------------------- /qfieldsync/gui/relationship_configuration_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | RelationshipConfigurationWidget 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2020-06-15 9 | git sha : $Format:%H$ 10 | copyright : (C) 2020 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from qgis.core import QgsMapLayer, QgsProject 25 | from qgis.gui import QgsSpinBox 26 | from qgis.PyQt.QtCore import Qt 27 | from qgis.PyQt.QtWidgets import QTableWidget, QTableWidgetItem 28 | 29 | 30 | class RelationshipConfigurationTableWidget(QTableWidget): 31 | def __init__(self): 32 | super(RelationshipConfigurationTableWidget, self).__init__() 33 | 34 | self.setColumnCount(4) 35 | self.setHorizontalHeaderLabels( 36 | [ 37 | self.tr("Layer"), 38 | "", 39 | self.tr("Relationship"), 40 | self.tr("Maximum number of items visible"), 41 | ] 42 | ) 43 | self.horizontalHeader().setStretchLastSection(True) 44 | self.setRowCount(0) 45 | self.resizeColumnsToContents() 46 | self.setMinimumHeight(100) 47 | 48 | def addLayerFields(self, layer_source): 49 | layer = layer_source.layer 50 | 51 | if layer.type() != QgsMapLayer.VectorLayer: 52 | return 53 | 54 | for relation in ( 55 | QgsProject.instance().relationManager().referencedRelations(layer) 56 | ): 57 | row = self.rowCount() 58 | self.insertRow(row) 59 | layer_name_item = QTableWidgetItem(layer.name()) 60 | layer_name_item.setData(Qt.UserRole, layer_source) 61 | layer_name_item.setFlags(Qt.ItemIsEnabled) 62 | self.setItem(row, 0, layer_name_item) 63 | relation_id_item = QTableWidgetItem(relation.id()) 64 | relation_id_item.setFlags(Qt.ItemIsEnabled) 65 | self.setItem(row, 1, relation_id_item) 66 | relation_name_item = QTableWidgetItem(relation.name()) 67 | relation_name_item.setFlags(Qt.ItemIsEnabled) 68 | self.setItem(row, 2, relation_name_item) 69 | spin_item = QgsSpinBox() 70 | spin_item.setMinimum(0) 71 | spin_item.setMaximum(100) 72 | spin_item.setSingleStep(1) 73 | spin_item.setClearValue(0, self.tr("unlimited")) 74 | spin_item.setShowClearButton(True) 75 | spin_item.setValue(layer_source.relationship_maximum_visible(relation.id())) 76 | self.setCellWidget(row, 3, spin_item) 77 | self.setColumnHidden(1, True) 78 | 79 | self.resizeColumnsToContents() 80 | 81 | def setLayerColumnHidden(self, is_hidden): 82 | self.setColumnHidden(0, is_hidden) 83 | 84 | def syncLayerSourceValues(self, should_apply=False): 85 | for i in range(self.rowCount()): 86 | layer_source = self.item(i, 0).data(Qt.UserRole) 87 | relation_id = self.item(i, 1).text() 88 | relationship_maximum_visible = self.cellWidget(i, 3).value() 89 | layer_source.set_relationship_maximum_visible( 90 | relation_id, relationship_maximum_visible 91 | ) 92 | 93 | if should_apply: 94 | layer_source.apply() 95 | -------------------------------------------------------------------------------- /qfieldsync/gui/synchronize_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSyncDialog 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2015-05-20 9 | git sha : $Format:%H$ 10 | copyright : (C) 2015 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | import os 24 | from pathlib import Path 25 | 26 | from libqfieldsync.project import ProjectConfiguration 27 | from libqfieldsync.utils.exceptions import NoProjectFoundError 28 | from libqfieldsync.utils.file_utils import ( 29 | copy_attachments, 30 | get_project_in_folder, 31 | import_file_checksum, 32 | ) 33 | from libqfieldsync.utils.qgis import make_temp_qgis_file, open_project 34 | from qgis.core import QgsProject 35 | from qgis.PyQt.QtCore import QDir 36 | from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QMessageBox 37 | from qgis.PyQt.uic import loadUiType 38 | 39 | from qfieldsync.core.preferences import Preferences 40 | from qfieldsync.gui.dirs_to_copy_widget import DirsToCopyWidget 41 | from qfieldsync.utils.qgis_utils import import_checksums_of_project 42 | from qfieldsync.utils.qt_utils import make_folder_selector 43 | 44 | DialogUi, _ = loadUiType( 45 | os.path.join(os.path.dirname(__file__), "../ui/synchronize_dialog.ui") 46 | ) 47 | 48 | 49 | class SynchronizeDialog(QDialog, DialogUi): 50 | def __init__(self, iface, offline_editing, parent=None): 51 | """Constructor.""" 52 | super(SynchronizeDialog, self).__init__(parent=parent) 53 | self.setupUi(self) 54 | self.iface = iface 55 | self.preferences = Preferences() 56 | self.offline_editing = offline_editing 57 | self.dirsToCopyWidget = DirsToCopyWidget() 58 | 59 | self.advancedOptionsGroupBox.layout().addWidget(self.dirsToCopyWidget) 60 | 61 | self.button_box.button(QDialogButtonBox.Save).setText(self.tr("Synchronize")) 62 | self.button_box.button(QDialogButtonBox.Save).clicked.connect( 63 | lambda: self.start_synchronization() 64 | ) 65 | import_dir = self.preferences.value("importDirectoryProject") 66 | if not import_dir: 67 | import_dir = self.preferences.value("importDirectory") 68 | 69 | self.qfieldDir.setText(QDir.toNativeSeparators(import_dir)) 70 | self.qfieldDir.textChanged.connect(lambda: self._on_qfield_dir_text_changed()) 71 | 72 | self.offline_editing_done = False 73 | 74 | self.qfieldDir_button.clicked.connect(lambda: self._on_qfield_dir_chosen()) 75 | 76 | self.dirsToCopyWidget.set_path(self.qfieldDir.text()) 77 | self.dirsToCopyWidget.refresh_tree() 78 | 79 | def start_synchronization(self): 80 | self.button_box.button(QDialogButtonBox.Save).setEnabled(False) 81 | project = QgsProject.instance() 82 | current_path = Path(project.fileName()) 83 | qfield_project_str_path = self.qfieldDir.text() 84 | qfield_path = Path(qfield_project_str_path) 85 | self.preferences.set_value("importDirectoryProject", str(qfield_path)) 86 | self.dirsToCopyWidget.save_settings() 87 | backup_project_path = make_temp_qgis_file(project) 88 | 89 | try: 90 | current_import_file_checksum = import_file_checksum(str(qfield_path)) 91 | imported_files_checksums = import_checksums_of_project(qfield_path) 92 | 93 | if ( 94 | imported_files_checksums 95 | and current_import_file_checksum 96 | and current_import_file_checksum in imported_files_checksums 97 | ): 98 | message = self.tr( 99 | "Data from this file are already synchronized with the original project." 100 | ) 101 | raise NoProjectFoundError(message) 102 | 103 | open_project(get_project_in_folder(str(qfield_path))) 104 | 105 | self.offline_editing.progressStopped.connect(self.update_done) 106 | self.offline_editing.layerProgressUpdated.connect(self.update_total) 107 | self.offline_editing.progressModeSet.connect(self.update_mode) 108 | self.offline_editing.progressUpdated.connect(self.update_value) 109 | 110 | try: 111 | self.offline_editing.synchronize(True) 112 | except Exception: 113 | self.offline_editing.synchronize() 114 | 115 | project_config = ProjectConfiguration(QgsProject.instance()) 116 | original_path = Path(project_config.original_project_path or "") 117 | 118 | if not original_path.exists(): 119 | answer = QMessageBox.warning( 120 | self, 121 | self.tr("Original project not found"), 122 | self.tr( 123 | 'The original project path at "{}" is not found. Would you like to use the currently opened project path at "{}" instead?' 124 | ).format( 125 | original_path, 126 | current_path, 127 | ), 128 | QMessageBox.Yes | QMessageBox.No, 129 | ) 130 | 131 | if answer == QMessageBox.Ok: 132 | project_config.original_project_path = str(current_path) 133 | original_path = current_path 134 | else: 135 | self.iface.messageBar().pushInfo( 136 | "QFieldSync", 137 | self.tr('No original project path found at "{}"').format( 138 | original_path 139 | ), 140 | ) 141 | 142 | if not self.offline_editing_done: 143 | raise NoProjectFoundError( 144 | self.tr( 145 | "The project you imported does not seem to be an offline project" 146 | ) 147 | ) 148 | 149 | if original_path.exists() and open_project( 150 | str(original_path), backup_project_path 151 | ): 152 | import_dirs_to_copy = self.dirsToCopyWidget.load_settings() 153 | 154 | # use the import dirs to copy selection if available, otherwise keep the old behavior 155 | if import_dirs_to_copy: 156 | for path, should_copy in import_dirs_to_copy.items(): 157 | if not should_copy: 158 | continue 159 | 160 | copy_attachments( 161 | qfield_path, 162 | original_path.parent, 163 | path, 164 | ) 165 | else: 166 | for attachment_dir in self.preferences.value("attachmentDirs"): 167 | copy_attachments( 168 | qfield_path, 169 | original_path.parent, 170 | attachment_dir, 171 | ) 172 | 173 | # save the data_file_checksum to the project and save it 174 | imported_files_checksums.append(import_file_checksum(str(qfield_path))) 175 | ProjectConfiguration( 176 | QgsProject.instance() 177 | ).imported_files_checksums = imported_files_checksums 178 | QgsProject.instance().write() 179 | self.iface.messageBar().pushInfo( 180 | "QFieldSync", 181 | self.tr("Opened original project {}".format(original_path)), 182 | ) 183 | else: 184 | self.iface.messageBar().pushInfo( 185 | "QFieldSync", 186 | self.tr( 187 | "The data has been synchronized successfully but the original project ({}) could not be opened".format( 188 | original_path 189 | ) 190 | ), 191 | ) 192 | self.close() 193 | except NoProjectFoundError as e: 194 | self.iface.messageBar().pushWarning("QFieldSync", str(e)) 195 | 196 | def update_total(self, current, layer_count): 197 | self.totalProgressBar.setMaximum(layer_count) 198 | self.totalProgressBar.setValue(current) 199 | 200 | def update_value(self, progress): 201 | self.layerProgressBar.setValue(progress) 202 | 203 | def update_mode(self, _, mode_count): 204 | self.layerProgressBar.setMaximum(mode_count) 205 | self.layerProgressBar.setValue(0) 206 | 207 | def update_done(self): 208 | self.offline_editing.progressStopped.disconnect(self.update_done) 209 | self.offline_editing.layerProgressUpdated.disconnect(self.update_total) 210 | self.offline_editing.progressModeSet.disconnect(self.update_mode) 211 | self.offline_editing.progressUpdated.disconnect(self.update_value) 212 | self.offline_editing_done = True 213 | 214 | def _on_qfield_dir_text_changed(self): 215 | self.dirsToCopyWidget.set_path(self.qfieldDir.text()) 216 | 217 | def _on_qfield_dir_chosen(self): 218 | make_folder_selector(self.qfieldDir)() 219 | self.dirsToCopyWidget.set_path(self.qfieldDir.text()) 220 | -------------------------------------------------------------------------------- /qfieldsync/gui/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSyncDialog 5 | A QGIS plugin 6 | Sync your projects to QField 7 | ------------------- 8 | begin : 2020-06-15 9 | git sha : $Format:%H$ 10 | copyright : (C) 2020 by OPENGIS.ch 11 | email : info@opengis.ch 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | 25 | def set_available_actions(combobox, actions, default_action): 26 | """Sets available actions on a checkbox and selects the current one. 27 | 28 | Args: 29 | combobox (QComboBox): target combobox 30 | layer_source (LayerSource): target layer 31 | """ 32 | for action, description in actions: 33 | combobox.addItem(description) 34 | combobox.setItemData(combobox.count() - 1, action) 35 | 36 | if action == default_action: 37 | combobox.setCurrentIndex(combobox.count() - 1) 38 | -------------------------------------------------------------------------------- /qfieldsync/metadata.txt: -------------------------------------------------------------------------------- 1 | # This file contains metadata for your plugin. Since 2 | # version 2.0 of QGIS this is the proper way to supply 3 | # information about a plugin. The old method of 4 | # embedding metadata in __init__.py 5 | # is no longer supported since version 2.0. 6 | 7 | # Mandatory items: 8 | 9 | [general] 10 | name=QField Sync 11 | qgisMinimumVersion=3.22 12 | description=Sync your projects to QField 13 | version=dev 14 | author=OPENGIS.ch 15 | email=info@opengis.ch 16 | 17 | # End of mandatory metadata 18 | 19 | # Recommended items: 20 | 21 | changelog= 22 | We've been busy improving QFieldSync, enjoy this new release. 23 | 24 | Check out the complete changelog on: https://github.com/opengisch/qfieldsync/releases 25 | 26 | # Tags are comma separated with spaces allowed 27 | tags=QField, Android, iOS, Windows, Linux, mobile, smartphone, tablet, QFieldCloud, cloud, field, fieldwork, GNSS, GPS, sensors, synchronization, offline, qfieldsync, simple, collaborative 28 | 29 | homepage=https://docs.qfield.org/get-started/ 30 | tracker=https://github.com/opengisch/QFieldSync/issues 31 | repository=https://github.com/opengisch/QFieldSync 32 | category=Plugins 33 | icon=resources/icon.png 34 | # experimental flag 35 | experimental=False 36 | 37 | # deprecated flag (applies to the whole plugin, not just a single version) 38 | deprecated=False 39 | 40 | about= 41 | This plugin facilitates packaging QGIS projects for QField.
42 | It analyses the current project and suggests (and performs) actions needed to make the project working on QField. 43 | -------------------------------------------------------------------------------- /qfieldsync/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | resources/icon-refresh-128.png 4 | resources/icon-refresh-128-reverse.png 5 | resources/icon.png 6 | resources/visibility.svg 7 | 8 | 9 | -------------------------------------------------------------------------------- /qfieldsync/resources/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_back-green.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_back-orange.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_back-red.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_forward-green.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_forward-orange.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_forward-red.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/arrow_forward.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_create.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 31 | 35 | 38 | 46 | 49 | 52 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_download.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_project_remote.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_synchronize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/cloud_upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /qfieldsync/resources/computer.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/delete-red.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/file.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/file_add-green.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/file_refresh-orange.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/resources/icon.png -------------------------------------------------------------------------------- /qfieldsync/resources/idea.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /qfieldsync/resources/launch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/missing.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/package.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 28 | 30 | 35 | 36 | 57 | 60 | 61 | 63 | 67 | 71 | 77 | 82 | 85 | 91 | 96 | 97 | 98 | 101 | 105 | 109 | 114 | 118 | 121 | 122 | 123 | 124 | 129 | 133 | 139 | 140 | -------------------------------------------------------------------------------- /qfieldsync/resources/project_properties.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /qfieldsync/resources/qfield_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 48 | 53 | 54 | 63 | 68 | 73 | 74 | -------------------------------------------------------------------------------- /qfieldsync/resources/qfieldcloud_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/resources/qfieldcloud_logo.png -------------------------------------------------------------------------------- /qfieldsync/resources/refresh-reverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/resources/refresh-reverse.png -------------------------------------------------------------------------------- /qfieldsync/resources/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/resources/refresh.png -------------------------------------------------------------------------------- /qfieldsync/resources/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /qfieldsync/resources/sync.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /qfieldsync/resources/sync_disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /qfieldsync/resources/synchronize.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 27 | 28 | 29 | 52 | 57 | 58 | 107 | -------------------------------------------------------------------------------- /qfieldsync/resources/visibility.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /qfieldsync/resources/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/resources/warning.png -------------------------------------------------------------------------------- /qfieldsync/ui/cloud_login_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QFieldCloudLoginDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 380 10 | 390 11 | 12 | 13 | 14 | QFieldCloud Sign In 15 | 16 | 17 | 18 | 19 | 20 | 21 | 1 22 | 0 23 | 24 | 25 | 26 | Double-click me to show or hide advanced options 27 | 28 | 29 | false 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 1 38 | 0 39 | 40 | 41 | 42 | The easiest way to transfer your project from QGIS to your devices! <a href="https://qfield.cloud/">Learn more about QFieldCloud</a>. 43 | 44 | 45 | true 46 | 47 | 48 | true 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 0 57 | 58 | 59 | 0 60 | 61 | 62 | 63 | 64 | Server URL 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 0 73 | 0 74 | 75 | 76 | 77 | true 78 | 79 | 80 | 81 | 82 | 83 | 84 | Username 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | Password 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | true 116 | 117 | 118 | color: red 119 | 120 | 121 | 122 | 123 | 124 | true 125 | 126 | 127 | true 128 | 129 | 130 | Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse 131 | 132 | 133 | 134 | 135 | 136 | 137 | Store QFieldCloud credentials and automatically sign in on QGIS startup. 138 | 139 | 140 | Stay signed in 141 | 142 | 143 | 144 | 145 | 146 | 147 | <html><head/><body><p>New user? <a href="https://app.qfield.cloud/accounts/signup/"><span>Register an account</span></a>.</p></body></html> 148 | 149 | 150 | Qt::RichText 151 | 152 | 153 | true 154 | 155 | 156 | Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse 157 | 158 | 159 | 160 | 161 | 162 | 163 | Qt::Vertical 164 | 165 | 166 | 167 | 40 168 | 20 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | QgsPasswordLineEdit 185 | QLineEdit 186 |
qgspasswordlineedit.h
187 |
188 |
189 | 190 | 191 |
192 | -------------------------------------------------------------------------------- /qfieldsync/ui/dirs_to_copy_widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 234 10 | 146 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 0 19 | 20 | 21 | 0 22 | 23 | 24 | 0 25 | 26 | 27 | 0 28 | 29 | 30 | 31 | 32 | Directories to be copied 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Directory 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | false 51 | 52 | 53 | Refresh directories 54 | 55 | 56 | ... 57 | 58 | 59 | 60 | ../resources/refresh.svg../resources/refresh.svg 61 | 62 | 63 | 64 | 65 | 66 | 67 | Qt::Horizontal 68 | 69 | 70 | 71 | 40 72 | 20 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | Select All 81 | 82 | 83 | 84 | 85 | 86 | 87 | Deselect All 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /qfieldsync/ui/layers_config_widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 343 10 | 204 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 0 19 | 20 | 21 | 0 22 | 23 | 24 | 0 25 | 26 | 27 | 0 28 | 29 | 30 | 31 | 32 | false 33 | 34 | 35 | 36 | 37 | 38 | Qt::RichText 39 | 40 | 41 | true 42 | 43 | 44 | 45 | 46 | 47 | 48 | true 49 | 50 | 51 | 52 | Layer 53 | 54 | 55 | 56 | 57 | Action 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Filter layers... 73 | 74 | 75 | true 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | Show Visible Layers Only 86 | 87 | 88 | 89 | 90 | 91 | 92 | Toggle layers 93 | 94 | 95 | 96 | 97 | 98 | 99 | ../resources/visibility.svg../resources/visibility.svg 100 | 101 | 102 | QToolButton::InstantPopup 103 | 104 | 105 | true 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | QgsFilterLineEdit 116 | QLineEdit 117 |
qgsfilterlineedit.h
118 |
119 |
120 | 121 | 122 |
123 | -------------------------------------------------------------------------------- /qfieldsync/ui/map_layer_config_widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QFieldLayerSettingsPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 500 10 | 250 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | General Settings 21 | 22 | 23 | 24 | 25 | 26 | 27 | 0 28 | 0 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Cloud layer action 37 | 38 | 39 | 40 | 41 | 42 | 43 | Cable layer action 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | When enabled, this option disables adding and deleting features, as well as modifying the geometries of existing features. 53 | 54 | 55 | Lock geometries 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Qt::Horizontal 70 | 71 | 72 | 73 | 20 74 | 20 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | Feature Form, Attachments and Relationships Settings 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 1 100 | 0 101 | 102 | 103 | 104 | Item threshold under which value map editor widgets will use a toggle buttons interface 105 | 106 | 107 | true 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 0 116 | 0 117 | 118 | 119 | 120 | item(s) 121 | 122 | 123 | 0 124 | 125 | 126 | 9999999 127 | 128 | 129 | 0 130 | 131 | 132 | true 133 | 134 | 135 | false 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | Tracking Session 148 | 149 | 150 | true 151 | 152 | 153 | false 154 | 155 | 156 | true 157 | 158 | 159 | 160 | 161 | 162 | When enabled, QField will automatically start a tracking session upon successfully loading the project. 163 | 164 | 165 | true 166 | 167 | 168 | 169 | 170 | 171 | 172 | Requirement Settings 173 | 174 | 175 | 176 | 177 | 178 | Enable time requirement 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 1 187 | 0 188 | 189 | 190 | 191 | seconds 192 | 193 | 194 | 1 195 | 196 | 197 | 600 198 | 199 | 200 | 30 201 | 202 | 203 | true 204 | 205 | 206 | 30 207 | 208 | 209 | 210 | 211 | 212 | 213 | Enable distance requirement 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 1 222 | 0 223 | 224 | 225 | 226 | meters 227 | 228 | 229 | 1 230 | 231 | 232 | 1000 233 | 234 | 235 | 30 236 | 237 | 238 | true 239 | 240 | 241 | 30 242 | 243 | 244 | 245 | 246 | 247 | 248 | Enable sensor data requirement 249 | 250 | 251 | 252 | 253 | 254 | 255 | Wait for all active requirements 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | General Settings 266 | 267 | 268 | 269 | 270 | 271 | Enable erroneous distance safeguard 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 1 280 | 0 281 | 282 | 283 | 284 | meters 285 | 286 | 287 | 1 288 | 289 | 290 | 1000 291 | 292 | 293 | 100 294 | 295 | 296 | true 297 | 298 | 299 | 100 300 | 301 | 302 | 303 | 304 | 305 | 306 | Measure (M) value attached to vertices 307 | 308 | 309 | true 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 1 318 | 0 319 | 320 | 321 | 322 | QComboBox::AdjustToMinimumContentsLengthWithIcon 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | QgsCollapsibleGroupBox 337 | QGroupBox 338 |
qgscollapsiblegroupbox.h
339 | 1 340 |
341 | 342 | QgsPropertyOverrideButton 343 | QToolButton 344 |
qgspropertyoverridebutton.h
345 |
346 | 347 | QgsSpinBox 348 | QSpinBox 349 |
qgsspinbox.h
350 |
351 |
352 | 353 | 354 |
355 | -------------------------------------------------------------------------------- /qfieldsync/ui/package_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QFieldPackageDialogBase 4 | 5 | 6 | 7 | 0 8 | 0 9 | 599 10 | 700 11 | 12 | 13 | 14 | Package Project for QField 15 | 16 | 17 | 18 | 19 | 20 | 0 21 | 22 | 23 | 24 | 25 | 0 26 | 27 | 28 | 0 29 | 30 | 31 | 0 32 | 33 | 34 | 0 35 | 36 | 37 | 38 | 39 | 40 | 0 41 | 0 42 | 43 | 44 | 45 | The project checks provides automatic feedback for some common issues when preparing projects for QField and QFieldCloud. Find below a list of improvement suggestions and errors that must be fixed before proceeding. 46 | 47 | 48 | true 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 0 61 | 62 | 63 | 0 64 | 65 | 66 | 0 67 | 68 | 69 | 0 70 | 71 | 72 | 73 | 74 | Packaged Project Title 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Packaged Project Filename 87 | 88 | 89 | 90 | 91 | 92 | QgsFileWidget::SaveFile 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | Advanced 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | Progress 118 | 119 | 120 | 121 | 122 | 123 | Total 124 | 125 | 126 | 127 | 128 | 129 | 130 | 0 131 | 132 | 133 | 134 | 135 | 136 | 137 | Layer 138 | 139 | 140 | 141 | 142 | 143 | 144 | 0 145 | 146 | 147 | 148 | 149 | 150 | 151 | Qt::Vertical 152 | 153 | 154 | QSizePolicy::Expanding 155 | 156 | 157 | 158 | 16 159 | 16 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | Information 168 | 169 | 170 | 171 | 172 | 173 | 174 | 0 175 | 0 176 | 177 | 178 | 179 | The current project relies on datasets stored in localized data paths, make sure to copy the relevant datasets into the localized data path of devices running QField. On most devices, the path is <u>/QField/basemaps.</u> 180 | 181 | 182 | true 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 0 191 | 0 192 | 193 | 194 | 195 | 196 | 197 | 198 | true 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 0 216 | 217 | 218 | 219 | 220 | Qt::Horizontal 221 | 222 | 223 | 224 | 40 225 | 20 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | Next 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | Qt::Horizontal 243 | 244 | 245 | QDialogButtonBox::Close|QDialogButtonBox::Reset|QDialogButtonBox::Save 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | QgsCollapsibleGroupBox 254 | QGroupBox 255 |
qgscollapsiblegroupbox.h
256 | 1 257 |
258 | 259 | QgsFileWidget 260 | QWidget 261 |
qgsfilewidget.h
262 |
263 |
264 | 265 | 266 | 267 | button_box 268 | rejected() 269 | QFieldPackageDialogBase 270 | reject() 271 | 272 | 273 | 20 274 | 20 275 | 276 | 277 | 20 278 | 20 279 | 280 | 281 | 282 | 283 |
284 | -------------------------------------------------------------------------------- /qfieldsync/ui/preferences_widget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QFieldPreferences 4 | 5 | 6 | 7 | 0 8 | 0 9 | 378 10 | 342 11 | 12 | 13 | 14 | QFieldSync Preferences 15 | 16 | 17 | 18 | 19 | 20 | QFieldCloud Settings 21 | 22 | 23 | 24 | 25 | 26 | Default cloud directory 27 | 28 | 29 | 30 | 31 | 32 | 33 | <span style=" font-style:italic;"><a href="https://qfield.cloud/">QFieldCloud</a> allows your team to focus on what's important, making sure you efficiently get quality field data. Thanks to its integration with QField, you and your team will be able to start surveying and digitising data collaboratively in no time.</span> 34 | 35 | 36 | true 37 | 38 | 39 | true 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Stay signed in 50 | 51 | 52 | Store QFieldCloud credentials and automatically sign in on QGIS startup. 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Packaging Settings 63 | 64 | 65 | 66 | 67 | 68 | <span style=" font-style:italic;">QField continues to give users full control by allowing projects to be packaged and synchronized via USB cable as well as through networks.</span> 69 | 70 | 71 | true 72 | 73 | 74 | true 75 | 76 | 77 | 78 | 79 | 80 | 81 | Default packaging import directory 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Default packaging export directory 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | Show packaging actions in the toolbar 102 | 103 | 104 | Store QFieldCloud credentials and automatically sign in on QGIS startup. 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | Qt::Vertical 115 | 116 | 117 | 118 | 20 119 | 56 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | QgsFileWidget 129 | QWidget 130 |
qgsfilewidget.h
131 |
132 |
133 | 134 | 135 |
136 | -------------------------------------------------------------------------------- /qfieldsync/ui/synchronize_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QFieldSynchronizeBase 4 | 5 | 6 | 7 | 0 8 | 0 9 | 413 10 | 435 11 | 12 | 13 | 14 | Synchronize Project 15 | 16 | 17 | 18 | 19 | 20 | <html><head/><body><p>Select the QField Project Folder</p></body></html> 21 | 22 | 23 | Qt::RichText 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ... 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | Advanced 45 | 46 | 47 | true 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Progress 56 | 57 | 58 | 59 | 60 | 61 | Total 62 | 63 | 64 | 65 | 66 | 67 | 68 | 0 69 | 70 | 71 | 72 | 73 | 74 | 75 | Layer 76 | 77 | 78 | 79 | 80 | 81 | 82 | 0 83 | 84 | 85 | 86 | 87 | 88 | 89 | Qt::Vertical 90 | 91 | 92 | QSizePolicy::Expanding 93 | 94 | 95 | 96 | 16 97 | 16 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | Qt::Horizontal 109 | 110 | 111 | QDialogButtonBox::Close|QDialogButtonBox::Save 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | QgsCollapsibleGroupBox 120 | QGroupBox 121 |
qgscollapsiblegroupbox.h
122 | 1 123 |
124 |
125 | 126 | 127 | 128 | button_box 129 | accepted() 130 | QFieldSynchronizeBase 131 | accept() 132 | 133 | 134 | 20 135 | 20 136 | 137 | 138 | 20 139 | 20 140 | 141 | 142 | 143 | 144 | button_box 145 | rejected() 146 | QFieldSynchronizeBase 147 | reject() 148 | 149 | 150 | 20 151 | 20 152 | 153 | 154 | 20 155 | 20 156 | 157 | 158 | 159 | 160 |
161 | -------------------------------------------------------------------------------- /qfieldsync/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengisch/qfieldsync/561b20451d9d4b152fb6e845bc8e5951340f51f9/qfieldsync/utils/__init__.py -------------------------------------------------------------------------------- /qfieldsync/utils/cloud_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QFieldSync 5 | ------------------- 6 | begin : 2020-07-13 7 | git sha : $Format:%H$ 8 | copyright : (C) 2020 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | 22 | 23 | import re 24 | from enum import Enum 25 | from pathlib import Path 26 | from typing import Tuple 27 | 28 | from libqfieldsync.utils.qgis import get_qgis_files_within_dir 29 | from qgis.PyQt.QtCore import QObject 30 | 31 | 32 | class LocalDirFeedback(Enum): 33 | Error = "error" 34 | Warning = "warning" 35 | Success = "success" 36 | 37 | 38 | def to_cloud_title(title): 39 | return re.sub("[^A-Za-z0-9-_]", "_", title) 40 | 41 | 42 | def closure(cb): 43 | def wrapper(*closure_args, **closure_kwargs): 44 | def call(*args, **kwargs): 45 | return cb(*closure_args, *args, **closure_kwargs, **kwargs) 46 | 47 | return call 48 | 49 | return wrapper 50 | 51 | 52 | def local_dir_feedback( 53 | local_dir: str, 54 | no_path_status: LocalDirFeedback = LocalDirFeedback.Error, 55 | not_dir_status: LocalDirFeedback = LocalDirFeedback.Error, 56 | not_existing_status: LocalDirFeedback = LocalDirFeedback.Warning, 57 | no_project_status: LocalDirFeedback = LocalDirFeedback.Success, 58 | single_project_status: LocalDirFeedback = LocalDirFeedback.Success, 59 | multiple_projects_status: LocalDirFeedback = LocalDirFeedback.Error, 60 | relative_status: LocalDirFeedback = LocalDirFeedback.Error, 61 | ) -> Tuple[LocalDirFeedback, str]: 62 | dummy = QObject() 63 | if not local_dir: 64 | return no_path_status, dummy.tr( 65 | "Please select local directory where the project to be stored." 66 | ) 67 | elif not Path(local_dir).is_absolute(): 68 | return relative_status, dummy.tr( 69 | "The entered path is a relative path. Please enter an absolute directory path." 70 | ) 71 | elif Path(local_dir).exists() and not Path(local_dir).is_dir(): 72 | return not_dir_status, dummy.tr( 73 | "The entered path is not an directory. Please enter a valid directory path." 74 | ) 75 | elif not Path(local_dir).exists(): 76 | return not_existing_status, dummy.tr( 77 | "The entered path is not an existing directory. It will be created after you submit this form." 78 | ) 79 | elif len(get_qgis_files_within_dir(Path(local_dir))) == 0: 80 | message = dummy.tr("The entered path does not contain a QGIS project file yet.") 81 | status = no_project_status 82 | 83 | if single_project_status == LocalDirFeedback.Success: 84 | message += " " 85 | message += dummy.tr("You can always add one later.") 86 | 87 | return status, message 88 | elif len(get_qgis_files_within_dir(Path(local_dir))) == 1: 89 | message = dummy.tr("The entered path contains one QGIS project file.") 90 | status = single_project_status 91 | 92 | if single_project_status == LocalDirFeedback.Success: 93 | message += " " 94 | message += dummy.tr("Exactly as it should be.") 95 | 96 | return status, message 97 | else: 98 | return multiple_projects_status, dummy.tr( 99 | "Multiple project files have been found in the directory. Please leave exactly one QGIS project in the root directory." 100 | ) 101 | -------------------------------------------------------------------------------- /qfieldsync/utils/file_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | /*************************************************************************** 5 | QFieldSync 6 | ------------------- 7 | begin : 2016 8 | copyright : (C) 2016 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | from enum import Enum 22 | from pathlib import Path 23 | from typing import List, TypedDict, Union 24 | 25 | PathLike = Union[Path, str] 26 | 27 | 28 | class DirectoryTreeType(str, Enum): 29 | FILE = "file" 30 | DIRECTORY = "directory" 31 | 32 | 33 | class DirectoryTreeDict(TypedDict): 34 | type: DirectoryTreeType 35 | path: Path 36 | content: List["DirectoryTreeDict"] 37 | 38 | 39 | def path_to_dict(path: PathLike, dirs_only: bool = False) -> DirectoryTreeDict: 40 | path = Path(path) 41 | node: DirectoryTreeDict = { 42 | "path": path, 43 | "content": [], 44 | } 45 | 46 | if path.is_dir(): 47 | node["type"] = DirectoryTreeType.DIRECTORY 48 | 49 | glob_pattern = "*/" if dirs_only else "*" 50 | for subpath in path.glob(glob_pattern): 51 | if dirs_only and not subpath.is_dir(): 52 | continue 53 | 54 | if ".qfieldsync" in str(subpath): 55 | continue 56 | 57 | node["content"].append(path_to_dict(subpath, dirs_only=dirs_only)) 58 | elif not dirs_only: 59 | node["type"] = DirectoryTreeType.FILE 60 | 61 | node["content"].sort(key=lambda node: node["path"].name) 62 | 63 | return node 64 | -------------------------------------------------------------------------------- /qfieldsync/utils/permissions.py: -------------------------------------------------------------------------------- 1 | from qfieldsync.core.cloud_project import CloudProject 2 | 3 | 4 | def can_change_project_owner(project: CloudProject) -> bool: 5 | if project.user_role == "admin": 6 | return True 7 | 8 | return False 9 | 10 | 11 | def can_delete_project(project: CloudProject) -> bool: 12 | if project.user_role == "admin": 13 | return True 14 | 15 | return False 16 | -------------------------------------------------------------------------------- /qfieldsync/utils/qgis_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | /*************************************************************************** 5 | QFieldSync 6 | ------------------- 7 | begin : 2016 8 | copyright : (C) 2016 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | 22 | from typing import List 23 | 24 | from libqfieldsync.project import ProjectConfiguration 25 | from libqfieldsync.utils.file_utils import get_project_in_folder 26 | from libqfieldsync.utils.qgis import open_project 27 | from qgis.core import QgsProject 28 | 29 | 30 | def import_checksums_of_project(dirname: str) -> List[str]: 31 | project = QgsProject.instance() 32 | qgs_file = get_project_in_folder(dirname) 33 | open_project(qgs_file) 34 | original_project_path = ProjectConfiguration(project).original_project_path 35 | open_project(original_project_path) 36 | return ProjectConfiguration(project).imported_files_checksums 37 | -------------------------------------------------------------------------------- /qfieldsync/utils/qt_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | /*************************************************************************** 5 | QFieldSync 6 | ------------------- 7 | begin : 2016 8 | copyright : (C) 2016 by OPENGIS.ch 9 | email : info@opengis.ch 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | import os 22 | from functools import partial 23 | from typing import Callable, Optional 24 | 25 | from qgis.PyQt import QtWidgets 26 | from qgis.PyQt.QtCore import QRectF, QSize, Qt 27 | from qgis.PyQt.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QTextDocument 28 | from qgis.PyQt.QtSvg import QSvgRenderer 29 | from qgis.PyQt.QtWidgets import QTreeWidgetItem 30 | 31 | from .file_utils import DirectoryTreeDict, DirectoryTreeType 32 | 33 | 34 | def selectFolder(line_edit_widget): 35 | line_edit_widget.setText( 36 | QtWidgets.QFileDialog.getExistingDirectory(directory=line_edit_widget.text()) 37 | ) 38 | 39 | 40 | def make_folder_selector(widget): 41 | return partial(selectFolder, line_edit_widget=widget) 42 | 43 | 44 | def make_icon(icon_name): 45 | return QIcon(os.path.join(os.path.dirname(__file__), "..", "resources", icon_name)) 46 | 47 | 48 | def make_pixmap(icon_name): 49 | return QPixmap( 50 | os.path.join(os.path.dirname(__file__), "..", "resources", icon_name) 51 | ) 52 | 53 | 54 | def rounded_pixmap(img_path: str, diameter: int) -> QPixmap: 55 | width, height = diameter, diameter 56 | size = QSize(height, width) 57 | 58 | target_pixmap = QPixmap(size) 59 | target_pixmap.fill(Qt.transparent) 60 | 61 | painter = QPainter(target_pixmap) 62 | painter.setRenderHint(QPainter.Antialiasing, True) 63 | painter.setRenderHint(QPainter.HighQualityAntialiasing, True) 64 | painter.setRenderHint(QPainter.SmoothPixmapTransform, True) 65 | 66 | path = QPainterPath() 67 | path.addRoundedRect( 68 | 0, 69 | 0, 70 | width, 71 | height, 72 | width / 2, 73 | height / 2, 74 | ) 75 | 76 | painter.setClipPath(path) 77 | 78 | if img_path.endswith(".svg"): 79 | renderer = QSvgRenderer(img_path) 80 | renderer.render(painter, QRectF(0, 0, width, height)) 81 | else: 82 | pixmap = QPixmap() 83 | pixmap = QPixmap(img_path) 84 | 85 | pixmap = pixmap.scaled( 86 | width, 87 | height, 88 | Qt.KeepAspectRatioByExpanding, 89 | Qt.SmoothTransformation, 90 | ) 91 | 92 | painter.drawPixmap(0, 0, pixmap) 93 | 94 | return target_pixmap 95 | 96 | 97 | def strip_html(text: str) -> str: 98 | # strip HTML tags 99 | doc = QTextDocument() 100 | doc.setHtml(text) 101 | text = doc.toPlainText() 102 | 103 | return text 104 | 105 | 106 | def build_file_tree_widget_from_dict( 107 | parent_item: QTreeWidgetItem, 108 | node: DirectoryTreeDict, 109 | build_item_cb: Optional[Callable] = None, 110 | ): 111 | item = QTreeWidgetItem() 112 | 113 | if node["type"] == DirectoryTreeType.DIRECTORY: 114 | for subnode in node["content"]: 115 | build_file_tree_widget_from_dict(item, subnode, build_item_cb) 116 | 117 | item.setText(0, node["path"].name) 118 | 119 | should_add = None 120 | if build_item_cb: 121 | should_add = build_item_cb(item, node) 122 | 123 | if should_add is None or should_add: 124 | parent_item.addChild(item) 125 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==2.5.5 2 | pep8-naming 3 | flake8-respect-noqa 4 | nose2 5 | pytest 6 | future 7 | transifex-client 8 | 9 | # NOTE `libqfielsync` version should be defined in the `*.tar.gz` format, not `git+https://` to make `wheel` happy 10 | libqfieldsync @ https://github.com/opengisch/libqfieldsync/archive/9320bb884ffbf3a6e09f3788fb268ee65b52878b.tar.gz 11 | -------------------------------------------------------------------------------- /scripts/prepare-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT_DIR=$(git rev-parse --show-toplevel) 4 | 5 | autopep8 -r --in-place --ignore=E261,E265,E402,E501 $ROOT_DIR/qfieldsync/core 6 | autopep8 -r --in-place --ignore=E261,E265,E402,E501 $ROOT_DIR/qfieldsync/gui 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = N802,E501 3 | exclude= 4 | .git, 5 | qfieldsync/ui, 6 | qfieldsync/resources_*, 7 | qfieldsync/test*, 8 | --------------------------------------------------------------------------------