├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .gitmodules ├── .qgis-plugin-ci ├── CHANGELOG.md ├── LICENSE ├── README.md ├── Unfolded ├── __init__.py ├── build.py ├── core │ ├── __init__.py │ ├── config_creator.py │ ├── exceptions.py │ ├── layer_handler.py │ ├── processing │ │ ├── __init__.py │ │ ├── base_config_creator_task.py │ │ ├── csv_field_value_converter.py │ │ ├── layer2dataset.py │ │ └── layer2layer_config.py │ └── utils.py ├── definitions │ ├── __init__.py │ ├── gui.py │ ├── settings.py │ └── types.py ├── logs │ └── .gitignore ├── metadata.txt ├── model │ ├── __init__.py │ ├── conversion_utils.py │ └── map_config.py ├── plugin.py ├── resources │ ├── .gitignore │ ├── configurations │ │ └── .gitignore │ ├── i18n │ │ └── .gitignore │ ├── icons │ │ ├── .gitignore │ │ └── icon.svg │ └── ui │ │ ├── .gitignore │ │ ├── progress_dialog.ui │ │ └── unfolded_dialog.ui ├── sentry.py └── ui │ ├── __init__.py │ ├── about_panel.py │ ├── base_panel.py │ ├── dialog.py │ ├── export_panel.py │ ├── progress_dialog.py │ └── settings_panel.py ├── docs ├── development.md ├── imgs │ ├── foursquare-logo.png │ ├── main_dialog.png │ └── uf_qgis_logo.svg └── push_translations.yml └── requirements.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = Unfolded/qgis_plugin_tools/* -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Plugin: [e.g. 1.0] 28 | - QGIS [e.g. 3.14] 29 | - Python: [e.g. 3.8] 30 | - OS: [e.g. Windows 10, Fedora 32] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Expected behaviour** 11 | A clear and concise description of what you'd like to happen if you do x. 12 | 13 | **Current behaviour** 14 | A clear and concise description of the current behaviour when you do x. If completely new feature, leave empty. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. If relevant please also provide version of the plugin and information on the system you are running it on. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: published 6 | 7 | jobs: 8 | plugin_dst: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | submodules: true 15 | 16 | - name: Set up Python 3.8 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: 3.8 20 | 21 | # Needed if the plugin is using Transifex, to have the lrelease command 22 | # - name: Install Qt lrelease 23 | # run: sudo apt-get install qt5-default qttools5-dev-tools 24 | 25 | # sets up an "environment" tag for Sentry 26 | - name: Set up a Sentry environment 27 | run: | 28 | sed -i "s/PLUGIN_ENVIRONMENT='local'/PLUGIN_ENVIRONMENT='production'/" Unfolded/sentry.py 29 | 30 | - name: Install qgis-plugin-ci 31 | run: pip3 install qgis-plugin-ci 32 | 33 | # the current OSGEO_USERNAME_FSQ and OSGEO_PASSWORD_FSQ are tied to: 34 | # user: https://plugins.qgis.org/plugins/user/foursquare 35 | # email: dokanovic@foursquare.com 36 | # 37 | # When osgeo upload is wanted: --osgeo-username usrname --osgeo-password ${{ secrets.OSGEO_PASSWORD_FSQ }} 38 | # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} 39 | - name: Deploy plugin 40 | run: qgis-plugin-ci release ${GITHUB_REF/refs\/tags\//} --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update --osgeo-username ${{ secrets.OSGEO_USERNAME_FSQ }} --osgeo-password ${{ secrets.OSGEO_PASSWORD_FSQ }} --allow-uncommitted-changes 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # workflow name 2 | name: Tests 3 | 4 | # Controls when the action will run. Triggers the workflow on push or pull request 5 | # events but only for the wanted branches 6 | on: 7 | pull_request: 8 | push: 9 | branches: [master, main] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | linux_tests: 14 | # The type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | # Remove unsupported versions and add more versions. Use LTR version in the cov_tests job 19 | docker_tags: [release-3_16, release-3_18, latest] 20 | fail-fast: false 21 | 22 | # Steps represent a sequence of tasks that will be executed as part of the job 23 | steps: 24 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 25 | - uses: actions/checkout@v2 26 | with: 27 | submodules: true 28 | 29 | - name: Pull qgis 30 | run: docker pull qgis/qgis:${{ matrix.docker_tags }} 31 | 32 | # Runs all tests 33 | - name: Run tests 34 | run: > 35 | docker run --rm --net=host --volume `pwd`:/app -w=/app -e QGIS_PLUGIN_IN_CI=1 qgis/qgis:${{ matrix.docker_tags }} sh -c 36 | "pip3 install -q pytest pytest-cov && xvfb-run -s '+extension GLX -screen 0 1024x768x24' 37 | pytest -v --cov=Unfolded --cov-report=xml" 38 | # Upload coverage report. Will not work if the repo is private 39 | - name: Upload coverage to Codecov 40 | if: ${{ matrix.docker_tags == 'latest' && !github.event.repository.private }} 41 | uses: codecov/codecov-action@v1 42 | with: 43 | file: ./coverage.xml 44 | flags: unittests 45 | fail_ci_if_error: false # set to true when upload is working 46 | verbose: false 47 | 48 | windows_tests: 49 | runs-on: windows-latest 50 | strategy: 51 | matrix: 52 | # Remove unsupported versions and add more versions. Use LTR version in the cov_tests job 53 | qgis_version: [3.22.16] 54 | fail-fast: false 55 | 56 | steps: 57 | - uses: actions/checkout@v2 58 | with: 59 | submodules: true 60 | 61 | - name: Choco install qgis 62 | uses: crazy-max/ghaction-chocolatey@v1 63 | with: 64 | args: install qgis-ltr --version ${{ matrix.qgis_version }} -y 65 | 66 | - name: Run tests 67 | shell: pwsh 68 | run: | 69 | $env:PATH="C:\Program Files\QGIS ${{ matrix.qgis_version }}\bin;$env:PATH" 70 | $env:QGIS_PLUGIN_IN_CI=1 71 | python-qgis-ltr.bat -m pip install -q pytest 72 | python-qgis-ltr.bat -m pytest -v 73 | pre-release: 74 | name: "Pre Release" 75 | runs-on: "ubuntu-latest" 76 | needs: [linux_tests, windows_tests] 77 | 78 | steps: 79 | - uses: hmarr/debug-action@v2 80 | 81 | - uses: "marvinpinto/action-automatic-releases@latest" 82 | if: ${{ github.event.pull_request }} 83 | with: 84 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 85 | automatic_release_tag: "dev-pr" 86 | prerelease: true 87 | title: "Development Build made for PR #${{ github.event.number }}" 88 | 89 | - uses: "marvinpinto/action-automatic-releases@latest" 90 | if: ${{ github.event.after != github.event.before }} 91 | with: 92 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 93 | automatic_release_tag: "dev" 94 | prerelease: true 95 | title: "Development Build made for master branch" 96 | 97 | - uses: actions/checkout@v2 98 | with: 99 | submodules: true 100 | 101 | - name: Set up Python 3.8 102 | uses: actions/setup-python@v1 103 | with: 104 | python-version: 3.8 105 | 106 | # Needed if the plugin is using Transifex, to have the lrelease command 107 | # - name: Install Qt lrelease 108 | # run: sudo apt-get update && sudo apt-get install qt5-default qttools5-dev-tools 109 | 110 | - name: Install qgis-plugin-ci 111 | run: pip3 install qgis-plugin-ci 112 | 113 | # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} 114 | - name: Deploy plugin 115 | if: ${{ github.event.pull_request }} 116 | run: qgis-plugin-ci release dev-pr --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update 117 | 118 | # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} 119 | - name: Deploy plugin 120 | if: ${{ github.event.after != github.event.before }} 121 | run: qgis-plugin-ci release dev --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Unfolded/i18n 2 | /.idea/ 3 | *.gpkg-shm 4 | *.gpkg-wal 5 | .vscode 6 | .DS_Store 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Unfolded/qgis_plugin_tools"] 2 | path = Unfolded/qgis_plugin_tools 3 | url = https://github.com/GispoCoding/qgis_plugin_tools.git 4 | -------------------------------------------------------------------------------- /.qgis-plugin-ci: -------------------------------------------------------------------------------- 1 | plugin_path: Unfolded 2 | github_organization_slug: UnfoldedInc 3 | project_slug: qgis-plugin 4 | transifex_coordinator: replace-me 5 | transifex_organization: replace-me 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ### 1.0.5 - 09/06/2023 4 | 5 | * Additional bug fixes 6 | 7 | ### 1.0.4 - 29/05/2023 8 | 9 | * Support for additional symbols: Logarithmic, Pretty Breaks and Natural Breaks (Jenks) 10 | * Several bugfixes 11 | 12 | ### 1.0.3 - 03/03/2023 13 | 14 | * Update Studio map import URL 15 | 16 | ### 1.0.2 - 28/06/2021 17 | 18 | * Fixed support for QGIS 3.20 19 | * Fixed encoding issues on Windows 20 | 21 | ### 1.0.1 - 25/03/2021 22 | 23 | * Changed CSV separator to comma 24 | * Improved UI 25 | * Full Changelog 26 | 27 | ### 1.0.0 - 24/03/2021 28 | 29 | * Initial configuration export functionality 30 | * Support for points, lines and polygons 31 | * Support for single symbol styles 32 | * Support for graduated and categorized styles 33 | * Multithreading support 34 | * Full Changelog 35 | 36 | ### 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unfolded QGIS plugin 2 | 3 | ![](https://github.com/UnfoldedInc/qgis-plugin/workflows/Tests/badge.svg) 4 | [![codecov.io](https://codecov.io/github/UnfoldedInc/qgis-plugin/coverage.svg?branch=main)](https://codecov.io/github/UnfoldedInc/qgis-plugin?branch=main) 5 | ![](https://github.com/UnfoldedInc/qgis-plugin/workflows/Release/badge.svg) 6 | 7 | 8 | 9 | This plugin exports [QGIS](http://qgis.org/) vector layers into a format that can be imported into [Unfolded Studio](https://studio.unfolded.ai/) for further analysis or one-click publishing to the web, after signing up for a free [Unfolded](https://unfolded.ai/) account. 10 | 11 | # Documentation 12 | 13 | This readme contains a short overview of basic functionality of the plugin. Full documentation is available at [docs.unfolded.ai](https://docs.unfolded.ai/integrations/qgis). 14 | 15 | ## Requirements 16 | 17 | This plugin supports QGIS version 3.16.x, which is the minimum required QGIS version. 18 | 19 | ## Installation 20 | 21 | The plugin is registered in the official QGIS plugin repository and it can be installed directly inside QGIS via PluginsManage and Install Plugins... menu. 22 | 23 | A user can also install the plugin from a zip package that you can download from the releases of this repository. 24 | 25 | ## Using the plugin 26 | 27 | User can export any vector data format that 28 | is [supported in QGIS](https://docs.qgis.org/3.16/en/docs/user_manual/working_with_vector/index.html) and the data can 29 | be in any known coordinate reference system as it is automatically reprojected (to EPSG:4326) during export. 30 | 31 | Layer geometries and styles are exported in to a single ZIP configuration file, which can then be imported to Unfolded 32 | Studio. 33 | 34 | Before opening the plugin, users add their datasets to QGIS in the normal way (see 35 | e.g. [QGIS tutorials](https://www.qgistutorials.com/en/)), perform some data processing tasks if necessary and add 36 | cartographic styling for the vector layers. 37 | 38 | After the user is satisfied with their result and the plugin has been installed, the `Unfolded` plugin can now be opened 39 | under the *Web* tab in QGIS. It opens a new window, which lets the user control the map export process. 40 | 41 | ![Main plugin dialog](docs/imgs/main_dialog.png) 42 | 43 | - **Layer Selection** - If a project contains multiple layers, user can select which layers should be exported and which 44 | should be visible by default (note layers are preserved in the exported map and the user can control layer visibility 45 | in Unfolded Studio after importing the map). 46 | 47 | - **Basemap Selection** - In the main *Export* tab the user can also select which type of basemap they want to use and 48 | which Unfolded Studio functionality (e.g. brushing, geocoding) that the exported interactive map should offer. All of 49 | these values can be changed after import into Unfolded Studio. 50 | 51 | - **Interactive Features** - In the *Settings* tab user can define where they want the exported configuration file to be 52 | exported on their local disk. A user can also add their personal MapBox API key if they wish to add MapBox basemaps to 53 | their project. In this tab a user can also define the logging level mainly for development purpose. 54 | 55 | From the *About* tab a user can see the basic infomation about the version they are using and find relevant links. 56 | 57 | ### Supported styling and layer types 58 | 59 | Currently the plugin supports exporting **line**, **point** and **polygon** geometries. The cartographic capabilities in QGIS are vast and can become very complex, and currently the plugin supports only basic styles. 60 | 61 | The following QGIS styles are supported: 62 | 63 | - **Single Symbol with Simple Fill** - These are the basic QGIS styles. With these you can define a fill and a stroke styles (width and color) for a feature. 64 | - **Categorized** - With categorized styling you can visualize qualitative data. The color palettes used in QGIS visualization are automatically exported. 65 | - **Graduated** - Graduated styling can be used for sequential or diverging datasets. Currently supported classifications are *quantile*, *equal interval*, *logarithmic*, *pretty breaks*, and *natural breaks (jenks)*. 66 | 67 | If an unsupported feature is detected, the export will be stopped in its entirety. 68 | 69 | ## Development 70 | 71 | If you encounter a bug or would like to see a new feature, please open an issue. Contributions are welcome. Refer to [development](docs/development.md) for developing this QGIS3 plugin. 72 | 73 | ## License 74 | 75 | This plugin is licenced with 76 | [GNU General Public License, version 2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html). 77 | See [LICENSE](LICENSE) for more information. 78 | -------------------------------------------------------------------------------- /Unfolded/__init__.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded . 6 | # 7 | # Unfolded is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | import os 21 | 22 | from .qgis_plugin_tools.infrastructure.debugging import setup_pydevd 23 | 24 | if os.environ.get('QGIS_PLUGIN_USE_DEBUGGER') == 'pydevd': 25 | if os.environ.get('IN_TESTS', "0") != "1" and os.environ.get('QGIS_PLUGIN_IN_CI', "0") != "1": 26 | setup_pydevd() 27 | 28 | 29 | def classFactory(iface): 30 | from .plugin import Plugin 31 | return Plugin(iface) 32 | -------------------------------------------------------------------------------- /Unfolded/build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 5 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 6 | # 7 | # 8 | # This file is part of Unfolded QGIS plugin. 9 | # 10 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation, either version 2 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License 21 | # along with Unfolded QGIS plugin. If not, see . 22 | 23 | import glob 24 | 25 | from qgis_plugin_tools.infrastructure.plugin_maker import PluginMaker 26 | 27 | ''' 28 | ################################################# 29 | # Edit the following to match the plugin 30 | ################################################# 31 | ''' 32 | 33 | py_files = [fil for fil in glob.glob("**/*.py", recursive=True) if "test/" not in fil] 34 | locales = ['fi'] 35 | profile = 'default' 36 | ui_files = list(glob.glob("**/*.ui", recursive=True)) 37 | resources = list(glob.glob("**/*.qrc", recursive=True)) 38 | extra_dirs = ["resources"] 39 | compiled_resources = [] 40 | 41 | PluginMaker(py_files=py_files, ui_files=ui_files, resources=resources, extra_dirs=extra_dirs, 42 | compiled_resources=compiled_resources, locales=locales, profile=profile) 43 | -------------------------------------------------------------------------------- /Unfolded/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | -------------------------------------------------------------------------------- /Unfolded/core/config_creator.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 21 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 22 | import datetime 23 | import json 24 | import locale 25 | import logging 26 | import tempfile 27 | import time 28 | import uuid 29 | import zipfile 30 | from functools import partial 31 | from pathlib import Path 32 | from typing import Optional, Dict, List 33 | from zipfile import ZipFile 34 | 35 | from PyQt5.QtCore import pyqtSignal, QObject 36 | from PyQt5.QtGui import QColor 37 | from qgis.core import (QgsVectorLayer, QgsApplication, QgsPointXY) 38 | 39 | from .exceptions import InvalidInputException 40 | from .processing.layer2dataset import LayerToDatasets 41 | from .processing.layer2layer_config import LayerToLayerConfig 42 | from ..model.map_config import (MapConfig, MapState, MapStyle, Layer, 43 | ConfigConfig, Config, Info) 44 | from ..model.map_config import (VisState, InteractionConfig, AnimationConfig, Datasets, 45 | FieldDisplayNames, AnyDict, VisibleLayerGroups, Globe, Tooltip, FieldsToShow, Brush, 46 | Coordinate, Dataset) 47 | from ..qgis_plugin_tools.tools.custom_logging import bar_msg 48 | from ..qgis_plugin_tools.tools.i18n import tr 49 | from ..qgis_plugin_tools.tools.resources import plugin_name, resources_path 50 | 51 | ENGLISH_LOCALE = 'en_US.utf8' 52 | 53 | LOGGER = logging.getLogger(plugin_name()) 54 | 55 | 56 | class ConfigCreator(QObject): 57 | """ 58 | Create Unfolded Studio compatible configuration based on QGIS project. This class can be used in context manager in 59 | single threaded environments (such as tests). 60 | """ 61 | 62 | UNFOLDED_CONFIG_FILE_NAME = 'config.json' 63 | 64 | progress_bar_changed = pyqtSignal([int, int]) 65 | finished = pyqtSignal(dict) 66 | canceled = pyqtSignal() 67 | completed = pyqtSignal() 68 | tasks_complete = pyqtSignal() 69 | 70 | def __init__(self, title: str, description: str, output_directory: Path): 71 | """ 72 | :param title: Title of the configuration 73 | :param description: Description of the configuration 74 | """ 75 | 76 | super().__init__() 77 | self.title = title 78 | self.description = description 79 | self.output_directory = output_directory 80 | 81 | self.tasks = {} 82 | self.layers: Dict[uuid.UUID, QgsVectorLayer] = {} 83 | 84 | self.created_configuration_path: Path = Path() 85 | self.created_configuration_path: Path = (self.output_directory / f"{self.title.replace(' ', '_')}.zip") 86 | 87 | self._shown_fields: Dict[uuid.UUID, List[str]] = {} 88 | self._vis_state_values = {} 89 | self._interaction_config_values = {} 90 | self._map_state: Optional[MapState] = None 91 | self._map_style: Optional[MapStyle] = None 92 | self._temp_dir_obj = tempfile.TemporaryDirectory(dir=resources_path()) 93 | self._temp_dir = Path(self._temp_dir_obj.name) 94 | 95 | def __enter__(self, *args): 96 | return self 97 | 98 | def __exit__(self, *args): 99 | self.__cleanup() 100 | 101 | def __cleanup(self): 102 | """ Remove temporary directory """ 103 | LOGGER.debug("Cleaning up") 104 | self._temp_dir_obj.cleanup() 105 | 106 | def _validate_inputs(self): 107 | """ Validate user given input """ 108 | LOGGER.info('Validating inputs') 109 | error_message_title = '' 110 | bar_msg_ = None 111 | if not self.layers: 112 | error_message_title = tr('No layers selected') 113 | bar_msg_ = bar_msg(tr('Select at least on layer to continue export')) 114 | 115 | elif not (self.output_directory.name and self.output_directory.exists()): 116 | error_message_title = tr('Output directory "{}" does not exist', self.output_directory) 117 | bar_msg_ = bar_msg(tr('Set a correct output directory in the Settings')) 118 | 119 | elif not self.title: 120 | error_message_title = tr('Title not filled') 121 | bar_msg_ = bar_msg(tr('Please add a proper title for the map. This is used in a filename of the output')) 122 | 123 | if error_message_title: 124 | # noinspection PyUnresolvedReferences 125 | self.canceled.emit() 126 | raise InvalidInputException(error_message_title, bar_msg=bar_msg_) 127 | 128 | def set_animation_config(self, current_time: any = None, speed: int = AnimationConfig.speed): 129 | """ Set animation configuration with current time and speed """ 130 | try: 131 | self._vis_state_values['animation_config'] = AnimationConfig(current_time, speed) 132 | except Exception as e: 133 | raise InvalidInputException(tr('Check the animation configuration values'), bar_msg=bar_msg(e)) 134 | 135 | # noinspection PyDefaultArgument 136 | def set_vis_state_values(self, layer_blending: str, filters: List = list(), 137 | split_maps: List = list(), metrics: List = list(), geo_keys: List = list(), 138 | group_bys: List = list(), joins: List = list()): 139 | """ Set visualization state values """ 140 | vals = dict(**locals()) 141 | vals.pop('self') 142 | self._vis_state_values = {**self._vis_state_values, **vals} 143 | 144 | def set_interaction_config_values(self, tooltip_enabled: bool, brush_enabled: bool, 145 | geocoder_enabled: bool, 146 | coordinate_enabled: bool, brush_size: float = 0.5): 147 | """ Set interaction configuration values """ 148 | self._interaction_config_values = {"brush": Brush(brush_size, brush_enabled), 149 | "geocoder": Coordinate(geocoder_enabled), 150 | "coordinate": Coordinate(coordinate_enabled), 151 | "tooltip_enabled": tooltip_enabled} 152 | 153 | def set_map_state(self, center: QgsPointXY, zoom: float, bearing: int = 0, drag_rotate: bool = False, 154 | pitch: int = 0, is_split: bool = False, map_view_mode: str = MapState.map_view_mode): 155 | """ Set map state values """ 156 | 157 | try: 158 | self._map_state = MapState(bearing, drag_rotate, center.y(), center.x(), pitch, zoom, is_split, 159 | map_view_mode, Globe.create_default()) 160 | except Exception as e: 161 | raise InvalidInputException(tr('Check the map state configuration values'), bar_msg=bar_msg(e)) 162 | 163 | def set_map_style(self, style_type: str): 164 | """ Set map style values """ 165 | try: 166 | self._map_style = MapStyle(style_type, MapStyle.top_layer_groups, VisibleLayerGroups.create_default(), 167 | MapStyle.three_d_building_color, MapStyle.map_styles) 168 | except Exception as e: 169 | raise InvalidInputException(tr('Check the map style configuration values'), bar_msg=bar_msg(e)) 170 | 171 | def add_layer(self, layer_uuid: uuid.UUID, layer: QgsVectorLayer, layer_color: QColor, is_visible: bool): 172 | """ Add layer to the config creation """ 173 | color = (layer_color.red(), layer_color.green(), layer_color.blue()) 174 | output_dir = self._temp_dir 175 | self.layers[layer_uuid] = layer 176 | self.tasks[uuid.uuid4()] = {'task': LayerToDatasets(layer_uuid, layer, color, output_dir), 'finished': False} 177 | self.tasks[uuid.uuid4()] = {'task': LayerToLayerConfig(layer_uuid, layer, is_visible), 'finished': False} 178 | 179 | # Save information about shown fields based 180 | shown_fields = [] 181 | for column in layer.attributeTableConfig().columns(): 182 | name = column.name 183 | if name: 184 | if not column.hidden: 185 | shown_fields.append(name) 186 | self._shown_fields[str(layer_uuid)] = shown_fields 187 | 188 | def start_config_creation(self) -> None: 189 | """ Start config creation using background processing tasks """ 190 | 191 | self._validate_inputs() 192 | LOGGER.info('Started config creation') 193 | LOGGER.debug(f"Tasks are: {self.tasks}") 194 | 195 | for task_id, task_dict in self.tasks.items(): 196 | # noinspection PyArgumentList 197 | QgsApplication.taskManager().addTask(task_dict['task']) 198 | task_dict['task'].progressChanged.connect(partial(self._progress_changed, task_id)) 199 | task_dict['task'].taskCompleted.connect(partial(self._task_completed, task_id)) 200 | task_dict['task'].taskTerminated.connect(partial(self._task_terminated, task_id)) 201 | 202 | def abort(self) -> None: 203 | """ Aborts config creation manually """ 204 | for task_id, task_dict in self.tasks.items(): 205 | if not task_dict['finished'] and not task_dict['task'].isCanceled(): 206 | LOGGER.warning(f"Cancelling task {task_id}") 207 | task_dict['task'].cancel() 208 | self.__cleanup() 209 | 210 | def _progress_changed(self, task_id: uuid.UUID): 211 | """ Increments progress """ 212 | # noinspection PyUnresolvedReferences 213 | self.progress_bar_changed.emit(list(self.tasks.keys()).index(task_id), self.tasks[task_id]['task'].progress()) 214 | 215 | def _task_completed(self, task_id: uuid.UUID) -> None: 216 | """ One of the background processing tasks if finished succesfully """ 217 | LOGGER.debug(f"Task {task_id} completed!") 218 | self.tasks[task_id]['finished'] = True 219 | self.tasks[task_id]['successful'] = True 220 | at_least_one_running = False 221 | for id_, task_dict in self.tasks.items(): 222 | if id_ != task_id and not task_dict['finished']: 223 | at_least_one_running = True 224 | 225 | if not at_least_one_running: 226 | # noinspection PyUnresolvedReferences 227 | self.tasks_complete.emit() 228 | self._create_map_config() 229 | 230 | def _task_terminated(self, task_id: uuid.UUID) -> None: 231 | """ One of the background processing tasks failed """ 232 | 233 | LOGGER.warning(tr("Task {} terminated", task_id)) 234 | self.tasks[task_id]['finished'] = True 235 | at_least_one_running = False 236 | for id_, task_dict in self.tasks.items(): 237 | if id_ != task_id and not task_dict['finished'] and not task_dict['task'].isCanceled(): 238 | at_least_one_running = True 239 | task_dict['task'].cancel() 240 | 241 | if not at_least_one_running: 242 | # noinspection PyUnresolvedReferences 243 | self.canceled.emit() 244 | 245 | def _create_map_config(self): 246 | """ Generates map configuration file """ 247 | 248 | LOGGER.info(tr('Creating map config')) 249 | 250 | try: 251 | # noinspection PyTypeChecker 252 | datasets: List[Dataset] = [None] * len(self.layers) 253 | # noinspection PyTypeChecker 254 | layers: List[Layer] = [None] * len(self.layers) 255 | 256 | layer_uuids = list(self.layers.keys()) 257 | 258 | for id_, task_dict in self.tasks.items(): 259 | task = task_dict['task'] 260 | if isinstance(task, LayerToDatasets): 261 | datasets[layer_uuids.index(task.layer_uuid)] = task.result_dataset 262 | elif isinstance(task, LayerToLayerConfig): 263 | layers[layer_uuids.index(task.layer_uuid)] = task.result_layer_conf 264 | 265 | tooltip_data = {} 266 | for layer_uuid, fields in self._shown_fields.items(): 267 | field_list = [] 268 | for field_name in fields: 269 | # try to find a field in a dataset so we can get its format 270 | datasetIdx = layer_uuids.index(task.layer_uuid) 271 | dataset = datasets[datasetIdx] 272 | for dataset_field in dataset.data.fields: 273 | if dataset_field.name == field_name: 274 | field_list.append({"name": field_name, "format": dataset_field.format}) 275 | 276 | tooltip_data[layer_uuid] = field_list 277 | 278 | tooltip = Tooltip( 279 | FieldsToShow(AnyDict(tooltip_data)), 280 | Tooltip.compare_mode, 281 | Tooltip.compare_type, 282 | self._interaction_config_values["tooltip_enabled"] 283 | ) 284 | 285 | interaction_config = InteractionConfig(tooltip, self._interaction_config_values["brush"], 286 | self._interaction_config_values["geocoder"], 287 | self._interaction_config_values["coordinate"]) 288 | 289 | vis_state = VisState(layers=layers, datasets=self._extract_datasets(), 290 | interaction_config=interaction_config, **self._vis_state_values) 291 | 292 | config = Config(Config.version, ConfigConfig(vis_state, self._map_state, self._map_style)) 293 | info = self._create_config_info() 294 | 295 | map_config = MapConfig(datasets, config, info) 296 | 297 | self._write_output(map_config) 298 | 299 | LOGGER.info(tr('Configuration created successfully'), 300 | extra=bar_msg(tr('The file can be found in {}', str(self.created_configuration_path)), 301 | success=True, duration=30)) 302 | 303 | # noinspection PyUnresolvedReferences 304 | self.completed.emit() 305 | 306 | except Exception as e: 307 | LOGGER.exception('Config creation failed. Check the log for more details', extra=bar_msg(e)) 308 | # noinspection PyUnresolvedReferences 309 | self.canceled.emit() 310 | finally: 311 | self.__cleanup() 312 | 313 | def _write_output(self, map_config): 314 | """ Write the configuration as a ZIP file""" 315 | 316 | config_file = self._temp_dir / self.UNFOLDED_CONFIG_FILE_NAME 317 | with open(config_file, 'w') as f: 318 | json.dump(map_config.to_dict(), f) 319 | 320 | # Create a zip for the configuration and datasets 321 | with ZipFile(self.created_configuration_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: 322 | zip_file.write(config_file, config_file.name) 323 | # Add multiple files to the zip 324 | for dataset in map_config.datasets: 325 | zip_file.write(self._temp_dir / dataset.source, dataset.source) 326 | 327 | def _create_config_info(self): 328 | """ Create info for the configuration """ 329 | try: 330 | locale.setlocale(locale.LC_ALL, ENGLISH_LOCALE) 331 | except locale.Error: 332 | LOGGER.warning(tr("Unsupported locale {}. Using system default.", ENGLISH_LOCALE)) 333 | timestamp = datetime.datetime.now().strftime('%a %b %d %Y %H:%M:%S ') 334 | time_zone = time.strftime('%Z%z') 335 | created_at = timestamp + time_zone 336 | source = Info.source 337 | 338 | return Info(Info.app, created_at, self.title, self.description, source) 339 | 340 | def _start_config_creation(self) -> None: 341 | """ This method runs the config creation in one thread. Mainly meant for testing """ 342 | 343 | LOGGER.info(tr('Started config creation')) 344 | 345 | for id_, task_dict in self.tasks.items(): 346 | task = task_dict['task'] 347 | success = task.run() 348 | if not success: 349 | raise task.exception 350 | self._create_map_config() 351 | 352 | def _extract_datasets(self) -> Datasets: 353 | """ Exrtact datasets from QGIS layers """ 354 | # TODO: configure fields to display 355 | return Datasets(FieldDisplayNames(AnyDict({str(uuid_): {} for uuid_ in self.layers}))) 356 | -------------------------------------------------------------------------------- /Unfolded/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded . 6 | # 7 | # Unfolded is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | from ..qgis_plugin_tools.tools.exceptions import QgsPluginException 20 | 21 | 22 | class ProcessInterruptedException(QgsPluginException): 23 | pass 24 | 25 | 26 | class InvalidInputException(QgsPluginException): 27 | pass 28 | 29 | 30 | class MapboxTokenMissing(QgsPluginException): 31 | pass 32 | 33 | 34 | class ExportException(QgsPluginException): 35 | pass 36 | -------------------------------------------------------------------------------- /Unfolded/core/layer_handler.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import logging 20 | from typing import List, Dict, Optional, Tuple 21 | 22 | from qgis.core import QgsProject, QgsLayerTree, QgsLayerTreeNode, QgsMapLayer, QgsVectorLayer, QgsRasterLayer, \ 23 | QgsLayerTreeLayer 24 | 25 | from .exceptions import MapboxTokenMissing 26 | from ..definitions.settings import Settings 27 | from ..qgis_plugin_tools.tools.custom_logging import bar_msg 28 | from ..qgis_plugin_tools.tools.i18n import tr 29 | from ..qgis_plugin_tools.tools.resources import plugin_name 30 | 31 | LOGGER = logging.getLogger(plugin_name()) 32 | 33 | 34 | class LayerHandler: 35 | basemap_group = tr('Unfolded Basemaps') 36 | 37 | @staticmethod 38 | def add_unfolded_basemaps() -> List[QgsRasterLayer]: 39 | """ Add unfolded basemaps to the project """ 40 | # noinspection PyArgumentList 41 | qgs_project = QgsProject.instance() 42 | 43 | base_url = Settings.basemap_wmts_url.get() 44 | token = Settings.mapbox_api_token.get() 45 | crs = Settings.project_crs.get() 46 | if not token: 47 | raise MapboxTokenMissing(tr('Mapbox token is missing'), bar_msg=bar_msg( 48 | tr('Please add a valid Mapbox token to the settings to view the base maps'))) 49 | 50 | # Add group 51 | root: QgsLayerTree = qgs_project.layerTreeRoot() 52 | group = root.findGroup(LayerHandler.basemap_group) 53 | if not group: 54 | group = root.addGroup(LayerHandler.basemap_group) 55 | group.setIsMutuallyExclusive(True) 56 | 57 | existing_layers_in_group = [node.layer().name() for node in group.children()] 58 | 59 | default_params = {'format': Settings.basemap_wmts_default_format.get(), 'token': token, 'crs': crs} 60 | 61 | # Generate WMTS layers 62 | layers: List[QgsRasterLayer] = [] 63 | wmts_basemap_config: Dict[str, Dict[str, Dict[str, str]]] = Settings.wmts_basemaps.get() 64 | for username, wmts_layers in wmts_basemap_config.items(): 65 | for name, layer_params in wmts_layers.items(): 66 | if name not in existing_layers_in_group: 67 | params = {**default_params, **layer_params, 'username': username} 68 | url = base_url.format(**params) 69 | LOGGER.debug(f"{name}: {url.replace(token, '')}") 70 | layer = QgsRasterLayer(url, name, "wms") 71 | if layer.isValid(): 72 | layers.append(layer) 73 | else: 74 | LOGGER.warning(tr('Layer {} is not valid', name)) 75 | 76 | if not layers and not existing_layers_in_group: 77 | raise MapboxTokenMissing(tr('No valid base maps found'), 78 | bar_msg=bar_msg(tr('Please check your Mapbox token'))) 79 | 80 | # Add layers to the group 81 | for i, layer in enumerate(layers): 82 | if not group.findLayer(layer): 83 | qgs_project.addMapLayer(layer, False) 84 | layer_element: QgsLayerTreeLayer = group.addLayer(layer) 85 | layer_element.setItemVisibilityChecked(i == 0) 86 | 87 | return layers 88 | 89 | @staticmethod 90 | def get_current_basemap_name() -> Optional[str]: 91 | """ Get the name of the currently active basemap """ 92 | # noinspection PyArgumentList 93 | qgs_project = QgsProject.instance() 94 | layer_name = None 95 | root: QgsLayerTree = qgs_project.layerTreeRoot() 96 | group = root.findGroup(LayerHandler.basemap_group) 97 | if group: 98 | layers = list(filter(lambda l: l[1], LayerHandler.get_layers_and_visibility_from_node(root, group))) 99 | layer_name = layers[0][0].name() if layers else None 100 | return layer_name 101 | 102 | # noinspection PyTypeChecker 103 | @staticmethod 104 | def get_vector_layers_and_visibility() -> List[Tuple[QgsVectorLayer, bool]]: 105 | """ Get all vector layers in correct order """ 106 | # noinspection PyArgumentList 107 | root: QgsLayerTree = QgsProject.instance().layerTreeRoot() 108 | layers_with_visibility = LayerHandler.get_layers_and_visibility_from_node(root, root) 109 | return list(filter(lambda layer_and_visibility: isinstance(layer_and_visibility[0], QgsVectorLayer), 110 | layers_with_visibility)) 111 | 112 | @staticmethod 113 | def get_layers_and_visibility_from_node(root: QgsLayerTree, node: QgsLayerTreeNode) -> List[ 114 | Tuple[QgsMapLayer, bool]]: 115 | layers = [] 116 | child: QgsLayerTreeNode 117 | for child in node.children(): 118 | if root.isGroup(child): 119 | # noinspection PyTypeChecker 120 | layers += LayerHandler.get_layers_and_visibility_from_node(root, child) 121 | else: 122 | layer = child.layer() 123 | visibility = child.itemVisibilityChecked() and node.itemVisibilityChecked() 124 | if layer: 125 | layers.append((layer, visibility)) 126 | return layers 127 | -------------------------------------------------------------------------------- /Unfolded/core/processing/__init__.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | -------------------------------------------------------------------------------- /Unfolded/core/processing/base_config_creator_task.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import logging 20 | from typing import Optional 21 | 22 | from PyQt5.QtCore import QVariant 23 | from qgis.core import (QgsTask, QgsField) 24 | 25 | from ..exceptions import ProcessInterruptedException 26 | from ...model.map_config import Field 27 | from ...qgis_plugin_tools.tools.custom_logging import bar_msg 28 | from ...qgis_plugin_tools.tools.exceptions import QgsPluginException, QgsPluginNotImplementedException 29 | from ...qgis_plugin_tools.tools.i18n import tr 30 | from ...qgis_plugin_tools.tools.resources import plugin_name 31 | 32 | # This logger is safe to use inside the task 33 | LOGGER = logging.getLogger(f'{plugin_name()}_task') 34 | 35 | # Main thread logger meant to be used in finished method 36 | LOGGER_MAIN = logging.getLogger(plugin_name()) 37 | 38 | 39 | class BaseConfigCreatorTask(QgsTask): 40 | LONG_FIELD = 'longitude' 41 | LAT_FIELD = 'latitude' 42 | GEOM_FIELD = 'geometry' 43 | 44 | def __init__(self, description: str): 45 | super().__init__(description, QgsTask.CanCancel) 46 | self.exception: Optional[Exception] = None 47 | 48 | def _qgis_field_to_unfolded_field(self, field: QgsField) -> Field: 49 | """ 50 | Analyze information about the field 51 | :param field: QGIS field 52 | :return: Unfolded field 53 | """ 54 | field_name = field.name() 55 | field_type = field.type() 56 | format_ = '' 57 | if field_type in [QVariant.Int, QVariant.UInt, QVariant.LongLong, QVariant.ULongLong]: 58 | type_, analyzer_type = 'integer', 'INT' 59 | elif field_type == QVariant.Double: 60 | type_, analyzer_type = 'real', 'FLOAT' 61 | elif field_type == QVariant.String: 62 | if field_name == self.GEOM_FIELD: 63 | type_, analyzer_type = 'geojson', 'PAIR_GEOMETRY_FROM_STRING' 64 | else: 65 | type_, analyzer_type = 'string', 'STRING' 66 | elif field_type == QVariant.Bool: 67 | type_, analyzer_type = ('boolean', 'BOOLEAN') 68 | # TODO: check date time formats 69 | elif field_type == QVariant.Date: 70 | type_, analyzer_type = ('date', 'DATE') 71 | format_ = 'YYYY/M/D' 72 | elif field_type == QVariant.DateTime: 73 | type_, analyzer_type = ('timestamp', 'DATETIME') 74 | format_ = 'YYYY/M/D H:m:s' 75 | elif field_type == QVariant.Time: 76 | type_, analyzer_type = ('timestamp', 'INT') 77 | format_ = 'H:m:s' 78 | # elif field_type == QVariant.ByteArray: 79 | # type, analyzer_type = ('integer', 'INT') 80 | else: 81 | raise QgsPluginNotImplementedException(tr('Field type "{}" not implemented yet', field_type)) 82 | 83 | return Field(field_name, type_, format_, analyzer_type) 84 | 85 | def _check_if_canceled(self) -> None: 86 | """ Check if the task has been canceled """ 87 | if self.isCanceled(): 88 | raise ProcessInterruptedException() 89 | 90 | def finished(self, result: bool) -> None: 91 | """ 92 | This function is automatically called when the task has completed (successfully or not). 93 | 94 | finished is always called from the main thread, so it's safe 95 | to do GUI operations and raise Python exceptions here. 96 | 97 | :param result: the return value from self.run 98 | """ 99 | if result: 100 | pass 101 | else: 102 | if self.exception is None: 103 | LOGGER_MAIN.warning(tr('Task was not successful'), 104 | extra=bar_msg(tr('Task was probably cancelled by user'))) 105 | else: 106 | try: 107 | raise self.exception 108 | except QgsPluginException as e: 109 | LOGGER_MAIN.exception(str(e), extra=e.bar_msg) 110 | except Exception as e: 111 | LOGGER_MAIN.exception(tr('Unhandled exception occurred'), extra=bar_msg(e)) 112 | -------------------------------------------------------------------------------- /Unfolded/core/processing/csv_field_value_converter.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | from PyQt5.QtCore import QVariant 20 | from qgis.core import (QgsVectorFileWriter, QgsVectorLayer, QgsField) 21 | 22 | 23 | class CsvFieldValueConverter(QgsVectorFileWriter.FieldValueConverter): 24 | """ 25 | Converts boolean fields to string fields containing true, false or empty string. 26 | """ 27 | 28 | def __init__(self, layer: QgsVectorLayer): 29 | QgsVectorFileWriter.FieldValueConverter.__init__(self) 30 | self.layer = layer 31 | field_types = [field.type() for field in self.layer.fields()] 32 | self.bool_field_idxs = [i for i, field_type in enumerate(field_types) if field_type == QVariant.Bool] 33 | self.date_field_idxs = [i for i, field_type in enumerate(field_types) if field_type == QVariant.Date] 34 | self.datetime_field_idxs = [i for i, field_type in enumerate(field_types) if field_type == QVariant.DateTime] 35 | 36 | def convert(self, field_idx, value): 37 | if field_idx in self.bool_field_idxs: 38 | if value is None: 39 | return "" 40 | return "true" if value else "false" 41 | elif field_idx in self.date_field_idxs: 42 | return value.toPyDate().strftime("%Y/%m/%d") 43 | elif field_idx in self.datetime_field_idxs: 44 | return value.toPyDateTime().strftime("%Y/%m/%d %H:%M:%S") 45 | return value 46 | 47 | def fieldDefinition(self, field): 48 | idx = self.layer.fields().indexFromName(field.name()) 49 | 50 | if idx in self.bool_field_idxs: 51 | return QgsField(field.name(), QVariant.String) 52 | return self.layer.fields()[idx] 53 | -------------------------------------------------------------------------------- /Unfolded/core/processing/layer2dataset.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import csv 20 | import logging 21 | import tempfile 22 | import uuid 23 | from pathlib import Path 24 | from typing import Optional, List, Tuple 25 | 26 | from PyQt5.QtCore import QVariant 27 | from qgis.core import (QgsVectorLayer, QgsField, QgsVectorFileWriter, QgsProject) 28 | 29 | from .base_config_creator_task import BaseConfigCreatorTask 30 | from .csv_field_value_converter import CsvFieldValueConverter 31 | from ..exceptions import ProcessInterruptedException 32 | from ..utils import set_csv_field_size_limit 33 | from ...definitions.settings import Settings 34 | from ...model.map_config import OldDataset, Data, Field, UnfoldedDataset 35 | from ...qgis_plugin_tools.tools.custom_logging import bar_msg 36 | from ...qgis_plugin_tools.tools.exceptions import QgsPluginNotImplementedException 37 | from ...qgis_plugin_tools.tools.i18n import tr 38 | from ...qgis_plugin_tools.tools.layers import LayerType 39 | from ...qgis_plugin_tools.tools.resources import plugin_name, resources_path 40 | 41 | # This logger is safe to use inside the task 42 | LOGGER = logging.getLogger(f'{plugin_name()}_task') 43 | 44 | # Main thread logger meant to be used in finished method 45 | LOGGER_MAIN = logging.getLogger(plugin_name()) 46 | 47 | class LayerToDatasets(BaseConfigCreatorTask): 48 | 49 | def __init__(self, layer_uuid: uuid.UUID, layer: QgsVectorLayer, color: Tuple[int, int, int], 50 | output_directory: Optional[Path] = None): 51 | super().__init__('LayerToDatasets') 52 | self.layer_uuid = layer_uuid 53 | self.layer = layer 54 | self.color = color 55 | self.output_directory = output_directory 56 | self.result_dataset: Optional[OldDataset] = None 57 | 58 | def run(self) -> bool: 59 | try: 60 | self._check_if_canceled() 61 | self.result_dataset = self._convert_to_dataset() 62 | self.setProgress(100) 63 | return True 64 | except Exception as e: 65 | self.exception = e 66 | return False 67 | 68 | def _convert_to_dataset(self) -> OldDataset: 69 | self._add_geom_to_fields() 70 | try: 71 | self.setProgress(20) 72 | self._check_if_canceled() 73 | 74 | fields = self._extract_fields() 75 | 76 | self.setProgress(40) 77 | self._check_if_canceled() 78 | 79 | source, all_data = self._extract_all_data() 80 | self.setProgress(60) 81 | self._check_if_canceled() 82 | 83 | if self.output_directory: 84 | dataset = UnfoldedDataset(self.layer_uuid, self.layer.name(), list(self.color), source, fields) 85 | else: 86 | data = Data(self.layer_uuid, self.layer.name(), list(self.color), all_data, fields) 87 | dataset = OldDataset(data) 88 | 89 | self.setProgress(80) 90 | return dataset 91 | finally: 92 | self._remove_geom_from_fields() 93 | 94 | def _add_geom_to_fields(self) -> None: 95 | """ Adds geometry to the layer as virtual field(s) """ 96 | 97 | LOGGER.info(tr('Adding layer geometry to fields')) 98 | 99 | 100 | 101 | crs = self.layer.crs().authid() 102 | dest_crs = Settings.crs.get() 103 | requires_transform = crs != dest_crs 104 | layer_type = LayerType.from_layer(self.layer) 105 | if layer_type == LayerType.Point: 106 | LOGGER.debug('Point layer') 107 | 108 | expressions: Tuple[str, str] = ('$x', '$y') 109 | if requires_transform: 110 | expressions = ( 111 | f"x(transform($geometry, '{crs}', '{dest_crs}'))", 112 | f"y(transform($geometry, '{crs}', '{dest_crs}'))" 113 | ) 114 | self.layer.addExpressionField(expressions[0], QgsField(LayerToDatasets.LONG_FIELD, QVariant.Double)) 115 | self.layer.addExpressionField(expressions[1], QgsField(LayerToDatasets.LAT_FIELD, QVariant.Double)) 116 | # TODO: z coord 117 | elif layer_type in (LayerType.Polygon, LayerType.Line): 118 | LOGGER.debug('Polygon or line layer') 119 | expression: str = 'geom_to_wkt($geometry)' 120 | if requires_transform: 121 | expression = f"geom_to_wkt(transform($geometry, '{crs}', '{dest_crs}'))" 122 | self.layer.addExpressionField(expression, QgsField(LayerToDatasets.GEOM_FIELD, QVariant.String)) 123 | else: 124 | raise QgsPluginNotImplementedException( 125 | bar_msg=bar_msg(tr('Unsupported layer wkb type: {}', self.layer.wkbType()))) 126 | 127 | def _remove_geom_from_fields(self): 128 | """ Removes virtual geometry field(s) from the layer """ 129 | 130 | LOGGER.info(tr('Removing layer geometry fields')) 131 | 132 | layer_type = LayerType.from_layer(self.layer) 133 | field_count = len(self.layer.fields().toList()) 134 | if layer_type == LayerType.Point: 135 | self.layer.removeExpressionField(field_count - 1) 136 | self.layer.removeExpressionField(field_count - 2) 137 | elif layer_type in (LayerType.Polygon, LayerType.Line): 138 | self.layer.removeExpressionField(field_count - 1) 139 | else: 140 | raise QgsPluginNotImplementedException( 141 | bar_msg=bar_msg(tr('Unsupported layer wkb type: {}', self.layer.wkbType()))) 142 | 143 | def _extract_fields(self) -> List[Field]: 144 | """ Extract field information from layer """ 145 | fields: List[Field] = [] 146 | field: QgsField 147 | LOGGER.info(tr('Extracting fields')) 148 | 149 | for field in self.layer.fields(): 150 | fields.append(self._qgis_field_to_unfolded_field(field)) 151 | return fields 152 | 153 | def _extract_all_data(self) -> Tuple[Optional[str], Optional[List]]: 154 | """ Extract data either as csv file or list representing csv 155 | :returns csv file source if exists, data list if output directory for the file is not set 156 | """ 157 | 158 | LOGGER.info(tr('Extracting layer data')) 159 | 160 | source, all_data = [None] * 2 161 | if self.output_directory: 162 | output_file = self._save_layer_to_file(self.layer, self.output_directory) 163 | source = output_file.name 164 | else: 165 | all_data = [] 166 | field_types = [field.type() for field in self.layer.fields()] 167 | conversion_functions = {} 168 | for i, field_type in enumerate(field_types): 169 | if field_types[i] in [QVariant.Int, QVariant.UInt, QVariant.LongLong, 170 | QVariant.ULongLong]: 171 | conversion_functions[i] = lambda x: int(x) if x else None 172 | elif field_types[i] == QVariant.Double: 173 | conversion_functions[i] = lambda x: float(x) if x else None 174 | elif field_types[i] == QVariant.Bool: 175 | conversion_functions[i] = lambda x: None if x == "" else x == "true" 176 | else: 177 | conversion_functions[i] = lambda x: x.rstrip().strip('"') 178 | 179 | with tempfile.TemporaryDirectory(dir=resources_path()) as tmpdirname: 180 | output_file = self._save_layer_to_file(self.layer, Path(tmpdirname)) 181 | with open(output_file, newline='', encoding="utf-8") as f: 182 | set_csv_field_size_limit() 183 | data_reader = csv.reader(f, delimiter=',') 184 | # Skipping header 185 | next(data_reader) 186 | for row in data_reader: 187 | data = [] 188 | for i, value in enumerate(row): 189 | data.append(conversion_functions[i](value)) 190 | all_data.append(data) 191 | 192 | return source, all_data 193 | 194 | # noinspection PyArgumentList 195 | @staticmethod 196 | def _save_layer_to_file(layer: QgsVectorLayer, output_path: Path) -> Path: 197 | """ Save layer to file""" 198 | output_file = output_path / f'{layer.name().replace(" ", "")}.csv' 199 | LOGGER.debug(f'Saving layer to a file {output_file.name}') 200 | 201 | converter = CsvFieldValueConverter(layer) 202 | 203 | layer_type = LayerType.from_layer(layer) 204 | field_count = len(layer.fields().toList()) 205 | filtered_attribute_ids: list[int] = [] 206 | for i, field in enumerate(layer.fields()): 207 | field_name = field.name().lower() 208 | # during _add_geom_to_fields() we've added some fields, but we now 209 | # want to filter out the fields with the same name as to avoid name 210 | # colissions 211 | if layer_type == LayerType.Point: 212 | if field_name == LayerToDatasets.LONG_FIELD and i != field_count - 2: 213 | LOGGER.info(tr('Skipping attribute: {} ({})', field.name(), i)) 214 | continue 215 | if field_name == LayerToDatasets.LAT_FIELD and i != field_count - 1: 216 | LOGGER.info(tr('Skipping attribute: {} ({})', field.name(), i)) 217 | continue 218 | elif layer_type in (LayerType.Polygon, LayerType.Line): 219 | if field_name == LayerToDatasets.GEOM_FIELD and i != field_count - 1: 220 | LOGGER.info(tr('Skipping attribute: {} ({})', field.name(), i)) 221 | continue 222 | filtered_attribute_ids.append(i) 223 | 224 | options = QgsVectorFileWriter.SaveVectorOptions() 225 | options.driverName = "csv" 226 | options.fileEncoding = "utf-8" 227 | options.layerOptions = ["SEPARATOR=COMMA"] 228 | options.fieldValueConverter = converter 229 | options.attributes = filtered_attribute_ids 230 | 231 | if hasattr(QgsVectorFileWriter, "writeAsVectorFormatV3"): 232 | # noinspection PyCallByClass 233 | writer_, msg, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(layer, str(output_file), 234 | QgsProject.instance().transformContext(), 235 | options) 236 | else: 237 | writer_, msg = QgsVectorFileWriter.writeAsVectorFormatV2(layer, str(output_file), 238 | QgsProject.instance().transformContext(), options) 239 | if msg: 240 | raise ProcessInterruptedException(tr('Process ended'), 241 | bar_msg=bar_msg(tr('Exception occurred during data extraction: {}', msg))) 242 | return output_file 243 | -------------------------------------------------------------------------------- /Unfolded/core/processing/layer2layer_config.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import logging 20 | import uuid 21 | from typing import Optional, List, Tuple, Union, cast 22 | 23 | from qgis.core import (QgsVectorLayer, QgsSymbol, QgsFeatureRenderer, QgsSymbolLayer, QgsMarkerSymbol, 24 | QgsLineSymbol, QgsFillSymbol, QgsGraduatedSymbolRenderer, 25 | QgsSingleSymbolRenderer, QgsCategorizedSymbolRenderer) 26 | 27 | from .base_config_creator_task import BaseConfigCreatorTask 28 | from ..exceptions import InvalidInputException 29 | from ..utils import extract_color, rgb_to_hex 30 | from ...definitions.settings import Settings 31 | from ...definitions.types import UnfoldedLayerType, SymbolType, SymbolLayerType 32 | from ...model.map_config import Layer, LayerConfig, VisualChannels, VisConfig, Columns, TextLabel, ColorRange 33 | from ...qgis_plugin_tools.tools.custom_logging import bar_msg 34 | from ...qgis_plugin_tools.tools.exceptions import QgsPluginNotImplementedException 35 | from ...qgis_plugin_tools.tools.i18n import tr 36 | from ...qgis_plugin_tools.tools.layers import LayerType 37 | from ...qgis_plugin_tools.tools.resources import plugin_name 38 | 39 | # This logger is safe to use inside the task 40 | LOGGER = logging.getLogger(f'{plugin_name()}_task') 41 | 42 | # Main thread logger meant to be used in finished method 43 | LOGGER_MAIN = logging.getLogger(plugin_name()) 44 | 45 | 46 | class LayerToLayerConfig(BaseConfigCreatorTask): 47 | """ 48 | Creates VisState.Layer object 49 | 50 | Some of the code is inspired by QGIS plugin Spatial Data Package Export created by Gispo Ltd. 51 | https://github.com/cividi/spatial-data-package-export/blob/master/SpatialDataPackageExport/core/styles2attributes.py 52 | Licensed by GPLv3 53 | """ 54 | 55 | SUPPORTED_GRADUATED_METHODS = {"EqualInterval": "quantize", "Quantile": "quantile", 56 | "Logarithmic": "custom", "Jenks": "custom", "Pretty": "custom"} 57 | CATEGORIZED_SCALE = "ordinal" 58 | 59 | def __init__(self, layer_uuid: uuid.UUID, layer: QgsVectorLayer, is_visible: bool = True): 60 | super().__init__('LayerToLayerConfig') 61 | self.layer_uuid = layer_uuid 62 | self.layer = layer 63 | self.is_visible = is_visible 64 | self.result_layer_conf: Optional[Layer] = None 65 | self.__pixel_unit = Settings.pixel_size_unit.get() 66 | self.__millimeter_unit = Settings.millimeter_size_unit.get() 67 | self.__millimeters_to_pixels = Settings.millimeters_to_pixels.get() 68 | self.__width_pixel_factor = Settings.width_pixel_factor.get() 69 | 70 | def run(self) -> bool: 71 | try: 72 | self._check_if_canceled() 73 | self.result_layer_conf = self._extract_layer() 74 | self.setProgress(100) 75 | return True 76 | except Exception as e: 77 | self.exception = e 78 | return False 79 | 80 | def _extract_layer(self) -> Layer: 81 | """ Extract VisState.layer configuration based on layer renderer and type """ 82 | LOGGER.info(tr('Extracting layer configuration for {}', self.layer.name())) 83 | 84 | renderer: QgsFeatureRenderer = self.layer.renderer() 85 | try: 86 | symbol_type = SymbolType[renderer.type()] 87 | except Exception: 88 | raise QgsPluginNotImplementedException(tr("Symbol type {} is not supported yet", renderer.type()), 89 | bar_msg=bar_msg()) 90 | 91 | layer_type = LayerType.from_layer(self.layer) 92 | LOGGER.info(tr('Symbol type: {}', symbol_type)) 93 | 94 | self.setProgress(50) 95 | if symbol_type == SymbolType.singleSymbol: 96 | color, vis_config = self._extract_layer_style( 97 | cast(QgsSingleSymbolRenderer, renderer).symbol()) 98 | visual_channels = VisualChannels.create_single_color_channels() 99 | elif symbol_type in (SymbolType.graduatedSymbol, SymbolType.categorizedSymbol): 100 | color, vis_config, visual_channels = self._extract_advanced_layer_style(renderer, layer_type, symbol_type) 101 | else: 102 | raise QgsPluginNotImplementedException() 103 | 104 | if layer_type == LayerType.Point: 105 | layer_type_ = UnfoldedLayerType.Point 106 | columns = Columns.for_point_2d() 107 | elif layer_type in [LayerType.Line, LayerType.Polygon]: 108 | layer_type_ = UnfoldedLayerType.Geojson 109 | columns = Columns.for_geojson() 110 | visual_channels.height_scale = VisualChannels.height_scale 111 | visual_channels.radius_scale = VisualChannels.radius_scale 112 | else: 113 | raise QgsPluginNotImplementedException(tr('Layer type {} is not implemented', layer_type), 114 | bar_msg=bar_msg()) 115 | 116 | hidden = False 117 | text_label = [TextLabel.create_default()] 118 | 119 | layer_config = LayerConfig(self.layer_uuid, self.layer.name(), color, columns, self.is_visible, vis_config, 120 | hidden, text_label) 121 | 122 | id_ = str(self.layer_uuid).replace("-", "")[:7] 123 | # noinspection PyTypeChecker 124 | return Layer(id_, layer_type_.value, layer_config, visual_channels) 125 | 126 | def _extract_advanced_layer_style(self, renderer: Union[QgsCategorizedSymbolRenderer, QgsGraduatedSymbolRenderer], layer_type: LayerType, symbol_type: SymbolType) -> Tuple[ 127 | List[int], VisConfig, VisualChannels]: 128 | """ Extract layer style when layer has graduated or categorized style """ 129 | if symbol_type == SymbolType.graduatedSymbol: 130 | classification_method = renderer.classificationMethod() 131 | scale_name = self.SUPPORTED_GRADUATED_METHODS.get(classification_method.id()) 132 | 133 | if not scale_name: 134 | raise InvalidInputException(tr('Unsupported classification method "{}"', classification_method.id()), 135 | bar_msg=bar_msg(tr( 136 | 'Use Equal Count (Quantile), Equal Interval (Quantize), Natural Breaks (Jenks), Logarithmic or Pretty Breaks'))) 137 | styles = [self._extract_layer_style(symbol_range.symbol()) for symbol_range in renderer.ranges()] 138 | if not styles: 139 | raise InvalidInputException(tr('Graduated layer should have at least 1 class'), bar_msg=bar_msg()) 140 | else: 141 | scale_name = self.CATEGORIZED_SCALE 142 | styles = [self._extract_layer_style(category.symbol()) for category in renderer.categories()] 143 | if not styles: 144 | raise InvalidInputException(tr('Categorized layer should have at least 1 class'), bar_msg=bar_msg()) 145 | 146 | color = styles[0][0] 147 | vis_config = styles[0][1] 148 | fill_colors = [rgb_to_hex(style[0]) for style in styles] 149 | stroke_colors = [rgb_to_hex(style[1].stroke_color) for style in styles if style[1].stroke_color] 150 | 151 | if layer_type == LayerType.Line: 152 | # For lines, swap the color ranges 153 | tmp = [] + fill_colors 154 | fill_colors = [] + stroke_colors 155 | stroke_colors = tmp 156 | 157 | if fill_colors: 158 | vis_config.color_range = ColorRange.create_custom(fill_colors) 159 | if stroke_colors: 160 | vis_config.stroke_color_range = ColorRange.create_custom(stroke_colors) 161 | categorizing_field = self._qgis_field_to_unfolded_field( 162 | self.layer.fields()[self.layer.fields().indexOf(renderer.classAttribute())]) 163 | categorizing_field.analyzer_type = None 164 | categorizing_field.format = None 165 | color_field, stroke_field = [None] * 2 166 | if len(set(fill_colors)) > 1: 167 | color_field = categorizing_field 168 | if len(set(stroke_colors)) > 1: 169 | stroke_field = categorizing_field 170 | visual_channels = VisualChannels(color_field, scale_name if color_field else VisualChannels.color_scale, 171 | stroke_field, 172 | scale_name if stroke_field else VisualChannels.stroke_color_scale, None, 173 | VisualChannels.size_scale) 174 | 175 | # provide color map for certain graduated symbols 176 | if scale_name == 'custom': 177 | symbol_ranges = renderer.ranges() 178 | vis_config.color_range.color_map = [] 179 | for i, col in enumerate(fill_colors): 180 | upperValue = symbol_ranges[i].upperValue() 181 | vis_config.color_range.color_map.append((upperValue, col)) 182 | 183 | return color, vis_config, visual_channels 184 | 185 | def _extract_layer_style(self, symbol: QgsSymbol) -> Tuple[List[int], VisConfig]: 186 | symbol_opacity: float = symbol.opacity() 187 | symbol_layer: QgsSymbolLayer = symbol.symbolLayers()[0] 188 | if symbol_layer.subSymbol() is not None: 189 | return self._extract_layer_style(symbol_layer.subSymbol()) 190 | 191 | sym_type = SymbolLayerType[symbol_layer.layerType()] 192 | properties = symbol_layer.properties() 193 | 194 | # Default values 195 | radius = VisConfig.radius 196 | color_range = ColorRange.create_default() 197 | radius_range = VisConfig.radius_range 198 | 199 | if isinstance(symbol, QgsMarkerSymbol) or isinstance(symbol, QgsFillSymbol): 200 | fill_rgb, alpha = extract_color(properties['color']) 201 | opacity = round(symbol_opacity * alpha, 2) 202 | stroke_rgb, stroke_alpha = extract_color(properties['outline_color']) 203 | stroke_opacity = round(symbol_opacity * stroke_alpha, 2) 204 | thickness = self._convert_to_pixels(float(properties['outline_width']), properties['outline_width_unit']) 205 | outline = stroke_opacity > 0.0 and properties['outline_style'] != 'no' 206 | stroke_opacity = stroke_opacity if outline else None 207 | stroke_color = stroke_rgb if outline else None 208 | filled = opacity > 0.0 and properties.get('style', 'solid') != 'no' 209 | 210 | if isinstance(symbol, QgsMarkerSymbol): 211 | size_range, height_range, elevation_scale, stroked, enable3_d, wireframe = [None] * 6 212 | 213 | # Fixed radius seems to always be False with point types 214 | fixed_radius = False 215 | 216 | radius = self._convert_to_pixels(float(properties['size']), properties['size_unit'], radius=True) 217 | thickness = thickness if thickness > 0.0 else 1.0 # Hairline in QGIS 218 | else: 219 | size_range = VisConfig.size_range 220 | height_range = VisConfig.height_range 221 | elevation_scale = VisConfig.elevation_scale 222 | if outline: 223 | stroked = True 224 | else: 225 | stroked = False 226 | stroke_color = None 227 | wireframe, enable3_d = [False] * 2 228 | fixed_radius, outline = [None] * 2 229 | elif isinstance(symbol, QgsLineSymbol): 230 | fill_rgb, stroke_alpha = extract_color(properties['line_color']) 231 | opacity = round(symbol_opacity * stroke_alpha, 2) 232 | stroke_opacity = opacity 233 | thickness = self._convert_to_pixels(float(properties['line_width']), properties['line_width_unit']) 234 | 235 | size_range = VisConfig.size_range 236 | height_range = VisConfig.height_range 237 | elevation_scale = VisConfig.elevation_scale 238 | stroked = True 239 | wireframe, enable3_d, filled = [False] * 3 240 | stroke_color, fixed_radius, outline = [None] * 3 241 | else: 242 | raise QgsPluginNotImplementedException(tr('Symbol type {} is not supported yet', symbol.type()), 243 | bar_msg=bar_msg()) 244 | 245 | thickness = thickness if thickness > 0.0 else VisConfig.thickness 246 | 247 | vis_config = VisConfig(opacity, stroke_opacity, thickness, stroke_color, color_range, color_range, radius, 248 | size_range, radius_range, height_range, elevation_scale, stroked, filled, enable3_d, 249 | wireframe, fixed_radius, outline) 250 | 251 | return fill_rgb, vis_config 252 | 253 | def _convert_to_pixels(self, size_value: float, size_unit: str, radius: bool = False) -> Union[int, float]: 254 | """ Convert size value to pixels""" 255 | value = size_value if radius else size_value / self.__width_pixel_factor 256 | if size_unit == self.__millimeter_unit: 257 | value = value / self.__millimeters_to_pixels 258 | 259 | if size_unit in (self.__millimeter_unit, self.__pixel_unit): 260 | return int(value) if radius else round(value, 1) 261 | else: 262 | raise InvalidInputException(tr('Size unit "{}" is unsupported.', size_unit), 263 | bar_msg=bar_msg( 264 | tr('Please use {} instead', 265 | tr('or').join((self.__millimeter_unit, self.__pixel_unit))))) 266 | -------------------------------------------------------------------------------- /Unfolded/core/utils.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import csv 20 | import ctypes 21 | import math 22 | import random 23 | from typing import List, Tuple 24 | 25 | from PyQt5.QtGui import QColor 26 | from qgis.core import QgsPointXY, QgsRectangle, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject 27 | from qgis.gui import QgsMapCanvas 28 | 29 | from ..definitions.settings import Settings 30 | 31 | UNFOLDED_CRS = QgsCoordinateReferenceSystem(Settings.crs.get()) 32 | PROJECT_CRS = QgsCoordinateReferenceSystem(Settings.project_crs.get()) 33 | 34 | 35 | def extract_color(color: str) -> Tuple[List[int], float]: 36 | """ Extract rgb and aplha values from color string """ 37 | _color = list(map(int, color.split(","))) 38 | rgb_value = _color[:-1] 39 | alpha = _color[-1] / 255.0 40 | return rgb_value, alpha 41 | 42 | 43 | def rgb_to_hex(rgb_color: List[int]) -> str: 44 | """ Convert rgb color value to hex """ 45 | return '#{:02x}{:02x}{:02x}'.format(*rgb_color) 46 | 47 | 48 | def get_canvas_center(canvas: QgsMapCanvas) -> QgsPointXY: 49 | """ Get canvas center in supported spatial reference system """ 50 | extent: QgsRectangle = canvas.extent() 51 | center = extent.center() 52 | # noinspection PyArgumentList 53 | transformer = QgsCoordinateTransform(canvas.mapSettings().destinationCrs(), UNFOLDED_CRS, QgsProject.instance()) 54 | return transformer.transform(center) 55 | 56 | 57 | def set_project_crs() -> None: 58 | """ Set project crs """ 59 | # noinspection PyArgumentList 60 | QgsProject.instance().setCrs(PROJECT_CRS) 61 | 62 | 63 | def generate_zoom_level(scale: float, dpi: int) -> float: 64 | """ 65 | Generates zoom level from scale and dpi 66 | 67 | Adapted from https://gis.stackexchange.com/a/268894/123927 68 | """ 69 | zoomlevel = (29.1402 - math.log2(scale)) / 1.2 70 | return zoomlevel 71 | 72 | 73 | def random_color() -> QColor: 74 | """ Generate random color. Adapted from https://stackoverflow.com/a/28999469/10068922 """ 75 | color = [random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)] 76 | return QColor(*color) 77 | 78 | 79 | def set_csv_field_size_limit() -> None: 80 | """ Sets csv field size limit """ 81 | limit = int(ctypes.c_ulong(-1).value // 2) 82 | old_limit = csv.field_size_limit() 83 | if old_limit < limit: 84 | csv.field_size_limit(limit) 85 | -------------------------------------------------------------------------------- /Unfolded/definitions/__init__.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | -------------------------------------------------------------------------------- /Unfolded/definitions/gui.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import enum 20 | 21 | from PyQt5.QtGui import QIcon 22 | from qgis._core import QgsApplication 23 | 24 | from ..qgis_plugin_tools.tools.resources import resources_path 25 | 26 | 27 | class Panels(enum.Enum): 28 | """ 29 | Panels in the Dialog 30 | 31 | This class is adapted from https://github.com/GispoCoding/qaava-qgis-plugin licensed under GPL version 2 32 | """ 33 | Export = {'icon': '/mActionSharingExport.svg'} 34 | Settings = {'icon': '/mActionMapSettings.svg'} 35 | About = {'icon': '/mActionHelpContents.svg'} 36 | 37 | # noinspection PyCallByClass,PyArgumentList 38 | @property 39 | def icon(self) -> QIcon: 40 | _icon: str = self.value['icon'] 41 | 42 | # QGIS icons 43 | # https://github.com/qgis/QGIS/tree/master/images/themes/default 44 | if _icon.startswith("/"): 45 | return QgsApplication.getThemeIcon(_icon) 46 | else: 47 | # Internal icons 48 | return QIcon(resources_path('icons', _icon)) 49 | -------------------------------------------------------------------------------- /Unfolded/definitions/settings.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import enum 20 | import json 21 | from typing import Union, List 22 | 23 | from ..qgis_plugin_tools.tools.exceptions import QgsPluginException 24 | from ..qgis_plugin_tools.tools.i18n import tr 25 | from ..qgis_plugin_tools.tools.resources import resources_path 26 | from ..qgis_plugin_tools.tools.settings import get_setting, set_setting 27 | 28 | 29 | @enum.unique 30 | class Settings(enum.Enum): 31 | crs = 'EPSG:4326' 32 | project_crs = 'EPSG:3857' 33 | conf_output_dir = resources_path('configurations') 34 | layer_blending = 'normal' 35 | studio_url = 'https://studio.foursquare.com/workspace/maps/import' 36 | 37 | # size 38 | pixel_size_unit = 'Pixel' 39 | millimeter_size_unit = 'MM' 40 | millimeters_to_pixels = 0.28 # Taken from qgssymbollayerutils.cpp 41 | width_pixel_factor = 3.0 # Empirically determined factor 42 | 43 | # basemaps 44 | basemap = 'dark' 45 | mapbox_api_token = '' 46 | basemap_wmts_url = 'url=https://api.mapbox.com/styles/v1/{username}/{style_id}/wmts?access_token%3D{token}&contextualWMSLegend=0&crs={crs}&format={format}&layers={style_id}&dpiMode=7&featureCount=10&styles=default&tileMatrixSet=GoogleMapsCompatible' 47 | basemap_wmts_default_format = 'image/png' 48 | wmts_basemaps = { 49 | 'uberdata': { 50 | 'dark': {'style_id': 'cjoqbbf6l9k302sl96tyvka09'}, 51 | 'light': {'style_id': 'cjoqb9j339k1f2sl9t5ic5bn4'}, 52 | 'muted': {'style_id': 'cjfyl03kp1tul2smf5v2tbdd4'}, 53 | 'muted_night': {'style_id': 'cjfxhlikmaj1b2soyzevnywgs'}, 54 | }, 55 | 'mapbox': { 56 | 'satellite': {'style_id': 'satellite-v9', 'format': 'image/jpeg'} 57 | }, 58 | 'unfoldedinc': { 59 | 'satellite-street': {'style_id': 'ckcr4dmep0i511is9m4qj9az5', 'format': 'image/jpeg'}, 60 | 'streets': {'style_id': 'ckfzpk24r0thc1anudzpwnc9q'}, 61 | } 62 | } 63 | 64 | _options = {'layer_blending': ['normal', 'additive', 'substractive'], 65 | 'basemap': ['dark', 'light', 'muted', 'muted_night', 'satellite', 'satellite-street', 'streets']} 66 | 67 | def get(self, typehint: type = str) -> any: 68 | """Gets the value of the setting""" 69 | if self in (Settings.millimeters_to_pixels, Settings.width_pixel_factor): 70 | typehint = float 71 | elif self in (Settings.wmts_basemaps,): 72 | return json.loads(get_setting(self.name, json.dumps(self.value), str)) 73 | value = get_setting(self.name, self.value, typehint) 74 | 75 | return value 76 | 77 | def set(self, value: Union[str, int, float, bool]) -> bool: 78 | """Sets the value of the setting""" 79 | options = self.get_options() 80 | if options and value not in options: 81 | raise QgsPluginException(tr('Invalid option. Choose something from values {}', options)) 82 | if self in (Settings.wmts_basemaps,): 83 | value = json.dumps(value) 84 | return set_setting(self.name, value) 85 | 86 | def get_options(self) -> List[any]: 87 | """Get options for the setting""" 88 | return Settings._options.value.get(self.name, []) 89 | -------------------------------------------------------------------------------- /Unfolded/definitions/types.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | import enum 20 | 21 | 22 | @enum.unique 23 | class UnfoldedLayerType(enum.Enum): 24 | Point = 'point' 25 | Geojson = 'geojson' 26 | 27 | 28 | """ 29 | Following classes are applied from the QGIS plugin Spatial Data Package Export created by Gispo Ltd. 30 | https://github.com/cividi/spatial-data-package-export/blob/master/SpatialDataPackageExport/definitions/symbols.py 31 | Licensed by GPLv3 32 | """ 33 | 34 | 35 | @enum.unique 36 | class SymbolType(enum.Enum): 37 | categorizedSymbol = 'categorizedSymbol' 38 | graduatedSymbol = 'graduatedSymbol' 39 | singleSymbol = 'singleSymbol' 40 | 41 | 42 | @enum.unique 43 | class SymbolLayerType(enum.Enum): 44 | SimpleMarker = 'SimpleMarker' 45 | SimpleLine = 'SimpleLine' 46 | CentroidFill = 'CentroidFill' 47 | SimpleFill = 'SimpleFill' 48 | -------------------------------------------------------------------------------- /Unfolded/logs/.gitignore: -------------------------------------------------------------------------------- 1 | *.log* 2 | -------------------------------------------------------------------------------- /Unfolded/metadata.txt: -------------------------------------------------------------------------------- 1 | [general] 2 | name=Unfolded 3 | description=Export QGIS Maps to Unfolded Studio and publish them on the web. 4 | about=This plugin exports a QGIS map into a format that can be imported into Unfolded Studio for further analysis or one-click publishing to the web after signing up for a free Unfolded account. 5 | version=0.0.1 6 | qgisMinimumVersion=3.16 7 | author=Foursquare 8 | email=dokanovic@foursquare.com 9 | changelog= 10 | tags=Unfolded Studio,Unfolded,Unfolded Map SDK,Unfolded Data SDK,web,webmap,export 11 | repository=https://github.com/foursquare/qgis-plugin 12 | homepage=https://github.com/foursquare/qgis-plugin 13 | tracker=https://github.com/foursquare/qgis-plugin/issues 14 | category=Web 15 | icon=resources/icons/icon.svg 16 | experimental=False 17 | deprecated=False 18 | -------------------------------------------------------------------------------- /Unfolded/model/__init__.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | -------------------------------------------------------------------------------- /Unfolded/model/conversion_utils.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | from typing import TypeVar, Any, Callable, List, Type, cast 20 | 21 | T = TypeVar("T") 22 | 23 | 24 | def from_int(x: Any) -> int: 25 | assert isinstance(x, int) and not isinstance(x, bool) 26 | return x 27 | 28 | 29 | def from_bool(x: Any) -> bool: 30 | assert isinstance(x, bool) 31 | return x 32 | 33 | 34 | def from_float(x: Any) -> float: 35 | assert isinstance(x, (float, int)) and not isinstance(x, bool) 36 | return float(x) 37 | 38 | 39 | def to_float(x: Any) -> float: 40 | assert isinstance(x, float) 41 | return x 42 | 43 | 44 | def from_str(x: Any) -> str: 45 | assert isinstance(x, str) 46 | return x 47 | 48 | 49 | def from_list(f: Callable[[Any], T], x: Any) -> List[T]: 50 | assert isinstance(x, list) 51 | return [f(y) for y in x] 52 | 53 | 54 | def to_class(c: Type[T], x: Any) -> dict: 55 | assert isinstance(x, c) 56 | return cast(Any, x).to_dict() 57 | 58 | 59 | def from_none(x: Any) -> Any: 60 | assert x is None 61 | return x 62 | 63 | 64 | def from_union(fs, x): 65 | for f in fs: 66 | try: 67 | return f(x) 68 | except: 69 | pass 70 | assert False 71 | -------------------------------------------------------------------------------- /Unfolded/plugin.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | from typing import Callable, Optional 21 | 22 | from PyQt5.QtCore import QTranslator, QCoreApplication 23 | from PyQt5.QtGui import QIcon 24 | from PyQt5.QtWidgets import QAction, QWidget 25 | from qgis.gui import QgisInterface 26 | 27 | from .qgis_plugin_tools.tools.custom_logging import setup_logger, setup_task_logger, teardown_logger, \ 28 | use_custom_msg_bar_in_logger 29 | from .qgis_plugin_tools.tools.i18n import setup_translation, tr 30 | from .qgis_plugin_tools.tools.resources import plugin_name, resources_path 31 | from .ui.dialog import Dialog 32 | from .sentry import init_sentry 33 | 34 | class Plugin: 35 | """QGIS Plugin Implementation.""" 36 | 37 | def __init__(self, iface: QgisInterface): 38 | 39 | init_sentry() 40 | 41 | self.iface = iface 42 | 43 | setup_logger(plugin_name(), iface) 44 | setup_task_logger(plugin_name()) 45 | 46 | # initialize locale 47 | locale, file_path = setup_translation() 48 | if file_path: 49 | self.translator = QTranslator() 50 | self.translator.load(file_path) 51 | # noinspection PyCallByClass 52 | QCoreApplication.installTranslator(self.translator) 53 | else: 54 | pass 55 | 56 | self.actions = [] 57 | self.menu = tr(plugin_name()) 58 | 59 | def add_action( 60 | self, 61 | icon_path: str, 62 | text: str, 63 | callback: Callable, 64 | enabled_flag: bool = True, 65 | add_to_menu: bool = True, 66 | add_to_toolbar: bool = True, 67 | status_tip: Optional[str] = None, 68 | whats_this: Optional[str] = None, 69 | parent: Optional[QWidget] = None) -> QAction: 70 | """Add a toolbar icon to the toolbar. 71 | 72 | :param icon_path: Path to the icon for this action. Can be a resource 73 | path (e.g. ':/plugins/foo/bar.png') or a normal file system path. 74 | 75 | :param text: Text that should be shown in menu items for this action. 76 | 77 | :param callback: Function to be called when the action is triggered. 78 | 79 | :param enabled_flag: A flag indicating if the action should be enabled 80 | by default. Defaults to True. 81 | 82 | :param add_to_menu: Flag indicating whether the action should also 83 | be added to the menu. Defaults to True. 84 | 85 | :param add_to_toolbar: Flag indicating whether the action should also 86 | be added to the toolbar. Defaults to True. 87 | 88 | :param status_tip: Optional text to show in a popup when mouse pointer 89 | hovers over the action. 90 | 91 | :param parent: Parent widget for the new action. Defaults None. 92 | 93 | :param whats_this: Optional text to show in the status bar when the 94 | mouse pointer hovers over the action. 95 | 96 | :returns: The action that was created. Note that the action is also 97 | added to self.actions list. 98 | :rtype: QAction 99 | """ 100 | 101 | icon = QIcon(icon_path) 102 | action = QAction(icon, text, parent) 103 | # noinspection PyUnresolvedReferences 104 | action.triggered.connect(callback) 105 | action.setEnabled(enabled_flag) 106 | 107 | if status_tip is not None: 108 | action.setStatusTip(status_tip) 109 | 110 | if whats_this is not None: 111 | action.setWhatsThis(whats_this) 112 | 113 | if add_to_toolbar: 114 | # Adds plugin icon to Plugins toolbar 115 | self.iface.addToolBarIcon(action) 116 | 117 | if add_to_menu: 118 | self.iface.addPluginToWebMenu( 119 | self.menu, 120 | action) 121 | 122 | self.actions.append(action) 123 | 124 | return action 125 | 126 | def initGui(self): 127 | """Create the menu entries and toolbar icons inside the QGIS GUI.""" 128 | self.add_action( 129 | resources_path('icons', 'icon.svg'), 130 | text=tr('Export to Web'), 131 | callback=self.run, 132 | parent=self.iface.mainWindow(), 133 | add_to_toolbar=True 134 | ) 135 | 136 | def onClosePlugin(self): 137 | """Cleanup necessary items here when plugin dockwidget is closed""" 138 | pass 139 | 140 | def unload(self): 141 | """Removes the plugin menu item and icon from QGIS GUI.""" 142 | for action in self.actions: 143 | self.iface.removePluginWebMenu( 144 | self.menu, 145 | action) 146 | self.iface.removeToolBarIcon(action) 147 | teardown_logger(plugin_name()) 148 | 149 | def run(self): 150 | """Run method that performs all the real work""" 151 | dialog = Dialog() 152 | use_custom_msg_bar_in_logger(plugin_name(), dialog.message_bar) 153 | dialog.exec() 154 | -------------------------------------------------------------------------------- /Unfolded/resources/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/Unfolded/resources/.gitignore -------------------------------------------------------------------------------- /Unfolded/resources/configurations/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/Unfolded/resources/configurations/.gitignore -------------------------------------------------------------------------------- /Unfolded/resources/i18n/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/Unfolded/resources/i18n/.gitignore -------------------------------------------------------------------------------- /Unfolded/resources/icons/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/Unfolded/resources/icons/.gitignore -------------------------------------------------------------------------------- /Unfolded/resources/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 12 | 15 | 18 | 21 | 22 | 23 | 26 | 29 | 32 | 35 | 38 | 41 | 44 | 45 | 46 | 49 | 52 | 55 | 56 | 57 | 60 | 63 | 65 | 66 | 69 | 71 | 72 | 75 | 77 | 78 | 81 | 83 | 84 | 87 | 89 | 90 | 93 | 95 | 96 | 99 | 101 | 102 | 105 | 107 | 108 | 111 | 113 | 114 | 117 | 119 | 120 | 123 | 126 | 129 | 132 | 135 | 138 | 141 | 144 | 145 | 146 | 147 | 149 | 150 | 151 | Topi Tjukanov 152 | 153 | 154 | 155 | 156 | QGIS 3.16.2-Hannover 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | image/svg+xml 165 | 166 | 2021-02-08T15:13:32 167 | 168 | 169 | 170 | 171 | Topi Tjukanov 172 | 173 | 174 | 175 | 176 | QGIS 3.16.2-Hannover 177 | 178 | 179 | 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /Unfolded/resources/ui/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/Unfolded/resources/ui/.gitignore -------------------------------------------------------------------------------- /Unfolded/resources/ui/progress_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 500 10 | 153 11 | 12 | 13 | 14 | Processing... 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 200 24 | 60 25 | 26 | 27 | 28 | Exporting, please wait... 29 | 30 | 31 | Qt::AlignCenter 32 | 33 | 34 | 35 | 36 | 37 | 38 | 24 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | Qt::Vertical 48 | 49 | 50 | 51 | 20 52 | 0 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Qt::Horizontal 63 | 64 | 65 | 66 | 40 67 | 20 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | Abort 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /Unfolded/sentry.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import platform 3 | try: 4 | from qgis.core import Qgis 5 | except ImportError: 6 | # for QGIS version < 3.x 7 | from qgis.core import QGis as Qgis 8 | 9 | # There's no easy way to distribute a QGIS plugin with extra dependencies, and 10 | # one way is to make sure that pip is installed and then install the required deps. 11 | # see: https://gis.stackexchange.com/questions/196002/development-of-a-plugin-which-depends-on-an-external-python-library 12 | try: 13 | import pip 14 | except: 15 | r = requests.get('https://4sq-studio-public.s3.us-west-2.amazonaws.com/qgis-plugin-eng/get-pip.py', 16 | allow_redirects=False) 17 | exec(r.content) 18 | import pip 19 | # just in case the included version is old 20 | pip.main(['install', '--upgrade', 'pip']) 21 | try: 22 | import sentry_sdk 23 | except: 24 | pip.main(['install', 'sentry-sdk==1.24.0']) 25 | import sentry_sdk 26 | 27 | PLUGIN_VERSION='1.0.5' 28 | PLUGIN_ENVIRONMENT='local' 29 | 30 | def init_sentry(): 31 | sentry_sdk.init( 32 | dsn="https://2d2c8d43150e46c6a73bde4f5a039715@o305787.ingest.sentry.io/4505239708172288", 33 | traces_sample_rate=0.1, 34 | ) 35 | 36 | sentry_sdk.set_tag('environment', PLUGIN_ENVIRONMENT) 37 | sentry_sdk.set_tag('version', PLUGIN_VERSION) 38 | sentry_sdk.set_tag('platform.platform', platform.platform()) 39 | sentry_sdk.set_tag('platform.system', platform.system()) 40 | sentry_sdk.set_tag('qgis.version', Qgis.QGIS_VERSION) 41 | -------------------------------------------------------------------------------- /Unfolded/ui/__init__.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | -------------------------------------------------------------------------------- /Unfolded/ui/about_panel.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | import logging 21 | 22 | from .base_panel import BasePanel 23 | from ..definitions.gui import Panels 24 | from ..qgis_plugin_tools.tools.i18n import tr 25 | from ..qgis_plugin_tools.tools.resources import plugin_name 26 | from ..qgis_plugin_tools.tools.version import version 27 | 28 | LOGGER = logging.getLogger(plugin_name()) 29 | 30 | 31 | class AboutPanel(BasePanel): 32 | """ 33 | This file is taken from https://github.com/GispoCoding/qaava-qgis-plugin licensed under GPL version 2 34 | """ 35 | 36 | def __init__(self, dialog): 37 | super().__init__(dialog) 38 | self.panel = Panels.About 39 | 40 | def setup_panel(self): 41 | v = version() 42 | LOGGER.info(tr(u"Plugin version is {}", v)) 43 | self.dlg.label_version.setText(v) 44 | -------------------------------------------------------------------------------- /Unfolded/ui/base_panel.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | import logging 21 | from typing import Dict 22 | 23 | from PyQt5.QtWidgets import QDialog 24 | 25 | from ..definitions.gui import Panels 26 | from ..qgis_plugin_tools.tools.custom_logging import bar_msg 27 | from ..qgis_plugin_tools.tools.exceptions import QgsPluginException, QgsPluginNotImplementedException 28 | from ..qgis_plugin_tools.tools.i18n import tr 29 | from ..qgis_plugin_tools.tools.resources import plugin_name 30 | 31 | LOGGER = logging.getLogger(plugin_name()) 32 | 33 | 34 | def process(fn): 35 | """ 36 | This decoration should be used when same effect as BasePanel.run is wanted for multiple methods 37 | """ 38 | from functools import wraps 39 | 40 | @wraps(fn) 41 | def wrapper(self: BasePanel, *args, **kwargs): 42 | self._start_process() 43 | try: 44 | if args and args != (False,): 45 | if len(kwargs): 46 | fn(self, *args, **kwargs) 47 | else: 48 | fn(self, *args) 49 | elif len(kwargs): 50 | fn(self, **kwargs) 51 | else: 52 | fn(self) 53 | except QgsPluginException as e: 54 | LOGGER.exception(str(e), extra=e.bar_msg) 55 | except Exception as e: 56 | LOGGER.exception(tr('Unhandled exception occurred'), extra=bar_msg(e)) 57 | finally: 58 | self._end_process() 59 | 60 | return wrapper 61 | 62 | 63 | class BasePanel: 64 | """ 65 | Base panel for dialog. Adapted from https://github.com/GispoCoding/qaava-qgis-plugin and 66 | https://github.com/3liz/QuickOSM. Both projects are licenced under GPL version 2. 67 | """ 68 | 69 | def __init__(self, dialog: QDialog): 70 | self._panel = None 71 | self._dialog = dialog 72 | self.elem_map: Dict[int, bool] = {} 73 | 74 | @property 75 | def panel(self) -> Panels: 76 | if self._panel: 77 | return self._panel 78 | else: 79 | raise NotImplemented 80 | 81 | @panel.setter 82 | def panel(self, panel: Panels): 83 | self._panel = panel 84 | 85 | @property 86 | def dlg(self) -> QDialog: 87 | """Return the dialog. 88 | """ 89 | return self._dialog 90 | 91 | def setup_panel(self): 92 | """Setup the UI for the panel.""" 93 | raise QgsPluginNotImplementedException() 94 | 95 | def teardown_panel(self): 96 | """Teardown for the panels""" 97 | 98 | def on_update_map_layers(self): 99 | """Occurs when map layers are updated""" 100 | 101 | def is_active(self): 102 | """ Is the panel currently active (selected)""" 103 | curr_panel = list(self.dlg.panels.keys())[self.dlg.menu_widget.currentRow()] 104 | return curr_panel == self.panel 105 | 106 | def run(self, method='_run'): 107 | if not method: 108 | method = '_run' 109 | self._start_process() 110 | try: 111 | # use dispatch pattern to invoke method with same name 112 | if not hasattr(self, method): 113 | raise QgsPluginException(f'Class does not have a method {method}') 114 | getattr(self, method)() 115 | except QgsPluginException as e: 116 | msg = e.bar_msg if e.bar_msg else bar_msg(e) 117 | LOGGER.exception(str(e), extra=msg) 118 | except Exception as e: 119 | LOGGER.exception(tr('Unhandled exception occurred'), extra=bar_msg(e)) 120 | finally: 121 | self._end_process() 122 | 123 | def _run(self): 124 | raise QgsPluginNotImplementedException() 125 | 126 | def _start_process(self): 127 | """Make some stuff before launching the process.""" 128 | self.dlg.is_running = True 129 | for i, elem in enumerate(self.dlg.responsive_elements[self.panel]): 130 | self.elem_map[i] = elem.isEnabled() 131 | elem.setEnabled(False) 132 | 133 | def _end_process(self): 134 | """Make some stuff after the process.""" 135 | self.dlg.is_running = False 136 | for i, elem in enumerate(self.dlg.responsive_elements[self.panel]): 137 | # Some process could change the status to True 138 | is_enabled = elem.isEnabled() 139 | if not is_enabled: 140 | elem.setEnabled(self.elem_map.get(i, True)) 141 | -------------------------------------------------------------------------------- /Unfolded/ui/dialog.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | import logging 21 | 22 | from PyQt5 import QtGui 23 | from PyQt5.QtGui import QIcon 24 | from PyQt5.QtWidgets import QDialog, QMessageBox, QDesktopWidget 25 | 26 | from .about_panel import AboutPanel 27 | from .export_panel import ExportPanel 28 | from .settings_panel import SettingsPanel 29 | from ..core.utils import set_project_crs 30 | from ..definitions.gui import Panels 31 | from ..qgis_plugin_tools.tools.custom_logging import bar_msg 32 | from ..qgis_plugin_tools.tools.i18n import tr 33 | from ..qgis_plugin_tools.tools.resources import load_ui, plugin_name, resources_path 34 | 35 | FORM_CLASS = load_ui('unfolded_dialog.ui') 36 | LOGGER = logging.getLogger(plugin_name()) 37 | 38 | 39 | class Dialog(QDialog, FORM_CLASS): 40 | """ 41 | The structure and idea of the UI is adapted https://github.com/GispoCoding/qaava-qgis-plugin and originally 42 | from https://github.com/3liz/QuickOSM. Both projects are licenced under GPL version 2 43 | """ 44 | 45 | def __init__(self, parent=None): 46 | """Constructor.""" 47 | QDialog.__init__(self, parent) 48 | self.setupUi(self) 49 | self.setWindowIcon(QIcon(resources_path('icons', 'icon.svg'))) 50 | self.is_running = False 51 | 52 | self._set_window_location() 53 | 54 | self.panels = { 55 | Panels.Export: ExportPanel(self), 56 | Panels.Settings: SettingsPanel(self), 57 | Panels.About: AboutPanel(self) 58 | } 59 | 60 | self.responsive_elements = { 61 | Panels.Export: [self.btn_export, self.gb_, self.gb_1, self.gb_2, self.gb_3], 62 | Panels.Settings: [], 63 | Panels.About: [] 64 | } 65 | 66 | for i, panel in enumerate(self.panels): 67 | item = self.menu_widget.item(i) 68 | item.setIcon(panel.icon) 69 | self.panels[panel].panel = panel 70 | 71 | # Change panel as menu item is changed 72 | self.menu_widget.currentRowChanged['int'].connect( 73 | self.stacked_widget.setCurrentIndex) 74 | 75 | try: 76 | for panel in self.panels.values(): 77 | panel.setup_panel() 78 | except Exception as e: 79 | LOGGER.exception(tr(u'Unhandled exception occurred during UI initialization.'), bar_msg(e)) 80 | 81 | # The first panel is shown initially 82 | self.menu_widget.setCurrentRow(0) 83 | 84 | # Change crs if needed 85 | set_project_crs() 86 | 87 | def _set_window_location(self): 88 | ag = QDesktopWidget().availableGeometry() 89 | sg = QDesktopWidget().screenGeometry() 90 | 91 | widget = self.geometry() 92 | x = (ag.width() - widget.width()) / 1.5 93 | y = 2 * ag.height() - sg.height() - 1.2 * widget.height() 94 | self.move(x, y) 95 | 96 | def ask_confirmation(self, title: str, msg: str) -> bool: 97 | """ 98 | Ask confirmation via QMessageBox question 99 | :param title: title of the window 100 | :param msg: message of the window 101 | :return: Whether user wants to continue 102 | """ 103 | res = QMessageBox.information(self, title, msg, QMessageBox.Ok, QMessageBox.Cancel) 104 | return res == QMessageBox.Ok 105 | 106 | def display_window(self, title: str, msg: str) -> None: 107 | """ 108 | Display window to user 109 | :param title: title of the window 110 | :param msg: message of the window 111 | :return: 112 | """ 113 | res = QMessageBox.information(self, title, msg, QMessageBox.Ok) 114 | 115 | def closeEvent(self, evt: QtGui.QCloseEvent) -> None: 116 | LOGGER.debug('Closing dialog') 117 | try: 118 | for panel in self.panels.values(): 119 | panel.teardown_panel() 120 | except Exception as e: 121 | LOGGER.exception(tr(u'Unhandled exception occurred during UI closing.'), bar_msg(e)) 122 | -------------------------------------------------------------------------------- /Unfolded/ui/export_panel.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | import logging 21 | import uuid 22 | import webbrowser 23 | from pathlib import Path 24 | from typing import Optional, Tuple, List, cast 25 | 26 | from PyQt5.QtGui import QIcon 27 | from PyQt5.QtWidgets import QComboBox, QTableWidget, QTableWidgetItem, QCheckBox 28 | from qgis.core import QgsProject, QgsVectorLayer, QgsApplication, QgsMapLayer 29 | from qgis.gui import QgsMapCanvas 30 | from qgis.utils import iface 31 | 32 | from .base_panel import BasePanel 33 | from .progress_dialog import ProgressDialog 34 | from ..core.config_creator import ConfigCreator 35 | from ..core.exceptions import ExportException 36 | from ..core.layer_handler import LayerHandler 37 | from ..core.utils import generate_zoom_level, random_color, get_canvas_center 38 | from ..definitions.gui import Panels 39 | from ..definitions.settings import Settings 40 | from ..qgis_plugin_tools.tools.custom_logging import bar_msg 41 | from ..qgis_plugin_tools.tools.i18n import tr 42 | from ..qgis_plugin_tools.tools.resources import plugin_name, resources_path 43 | 44 | LOGGER = logging.getLogger(plugin_name()) 45 | 46 | 47 | class ExportPanel(BasePanel): 48 | """ 49 | """ 50 | 51 | def __init__(self, dialog): 52 | super().__init__(dialog) 53 | self.panel = Panels.Export 54 | self.progress_dialog: Optional[ProgressDialog] = None 55 | self.config_creator: Optional[ConfigCreator] = None 56 | 57 | # noinspection PyArgumentList 58 | def setup_panel(self): 59 | # Map configuration 60 | self.dlg.input_title.setText(QgsProject.instance().baseName()) 61 | 62 | # Visualization state 63 | cb_layer_blending: QComboBox = self.dlg.cb_layer_blending 64 | cb_layer_blending.clear() 65 | cb_layer_blending.addItems(Settings.layer_blending.get_options()) 66 | cb_layer_blending.setCurrentText(Settings.layer_blending.get()) 67 | 68 | # Map style 69 | cb_basemap: QComboBox = self.dlg.cb_basemap 70 | cb_basemap.clear() 71 | cb_basemap.addItems(Settings.basemap.get_options()) 72 | 73 | # Map interaction 74 | self.dlg.cb_tooltip.setChecked(True) 75 | self.dlg.cb_brush.setChecked(False) 76 | self.dlg.cb_geocoder.setChecked(False) 77 | self.dlg.cb_coordinate.setChecked(False) 78 | 79 | # Export button 80 | self.dlg.btn_export.clicked.connect(self.run) 81 | 82 | # Studio button 83 | self.dlg.btn_open_studio.setIcon(QIcon(resources_path('icons', 'icon.svg'))) 84 | self.dlg.btn_open_studio.clicked.connect(lambda _: webbrowser.open(Settings.studio_url.get())) 85 | 86 | # Refresh 87 | self.dlg.btn_refresh.setIcon(QgsApplication.getThemeIcon('/mActionRefresh.svg')) 88 | self.dlg.btn_refresh.clicked.connect(self.__refreshed) 89 | 90 | # Setup dynamic contents 91 | self.__refreshed() 92 | 93 | def __refreshed(self): 94 | """ Set up dynamic contents """ 95 | self.__setup_layers_to_export() 96 | current_basemap = LayerHandler.get_current_basemap_name() 97 | self.dlg.cb_basemap.setCurrentText(current_basemap if current_basemap else Settings.basemap.get()) 98 | 99 | def __setup_layers_to_export(self): 100 | """ """ 101 | # Vector layers 102 | table: QTableWidget = self.dlg.tw_layers 103 | table.setColumnCount(3) 104 | table.setRowCount(0) 105 | layers_with_visibility = LayerHandler.get_vector_layers_and_visibility() 106 | table.setRowCount(len(layers_with_visibility)) 107 | for i, layer_with_visibility in enumerate(layers_with_visibility): 108 | layer, visibility = layer_with_visibility 109 | cb_export = QCheckBox() 110 | cb_export.setChecked(visibility) 111 | cb_is_visible = QCheckBox() 112 | cb_is_visible.setChecked(True) 113 | layer_name = QTableWidgetItem(layer.name()) 114 | table.setItem(i, 0, layer_name) 115 | table.setCellWidget(i, 1, cb_export) 116 | table.setCellWidget(i, 2, cb_is_visible) 117 | 118 | def __get_layers_to_export(self) -> List[Tuple[QgsVectorLayer, bool]]: 119 | """ 120 | 121 | :return: List of Tuples with (layer, is_hidden) 122 | """ 123 | layers_with_visibility = [] 124 | # noinspection PyArgumentList 125 | qgs_project = QgsProject.instance() 126 | table: QTableWidget = self.dlg.tw_layers 127 | for row in range(table.rowCount()): 128 | cb_export = table.cellWidget(row, 1) 129 | if cb_export.isChecked(): 130 | layer_name = table.item(row, 0).text() 131 | is_visible = table.cellWidget(row, 2).isChecked() 132 | layers = qgs_project.mapLayersByName(layer_name) 133 | if len(layers) > 1: 134 | raise ExportException( 135 | tr('Multiple layers found with name {}.', layer_name), 136 | bar_msg=bar_msg(tr('Please use unique layer names.'))) 137 | if not layers: 138 | raise ExportException(tr('No layers found with name {}!', layer_name), 139 | bar_msg=bar_msg(tr('Open the dialog again to refresh the layers'))) 140 | 141 | if layers[0].type() != QgsMapLayer.VectorLayer: 142 | LOGGER.warning(tr('Skipping layer {} because it is not a vector layer', layers[0].name())) 143 | continue 144 | 145 | layer = cast(QgsVectorLayer, layers[0]) 146 | if layer.featureCount() == 0: 147 | LOGGER.warning(tr('Skipping layer {} because it is empty', layer.name())) 148 | continue 149 | 150 | layers_with_visibility.append((layer, is_visible)) 151 | if not layers_with_visibility: 152 | raise ExportException(tr('No layers selected'), 153 | bar_msg=bar_msg(tr('Select at least on layer to continue export'))) 154 | 155 | return layers_with_visibility 156 | 157 | def _run(self): 158 | """ Exports map to configuration """ 159 | title = self.dlg.input_title.text() 160 | description = self.dlg.input_description.toPlainText() 161 | output_dir = Path(self.dlg.f_conf_output.filePath()) 162 | basemap = self.dlg.cb_basemap.currentText() 163 | 164 | layers_with_visibility = self.__get_layers_to_export() 165 | 166 | # Map state 167 | canvas: QgsMapCanvas = iface.mapCanvas() 168 | center = get_canvas_center(canvas) 169 | # noinspection PyTypeChecker 170 | zoom = generate_zoom_level(canvas.scale(), iface.mainWindow().physicalDpiX()) 171 | 172 | # Interaction 173 | tooltip_enabled = self.dlg.cb_tooltip.isChecked() 174 | brush_enabled = self.dlg.cb_brush.isChecked() 175 | geocoder_enabled = self.dlg.cb_geocoder.isChecked() 176 | coordinate_enabled = self.dlg.cb_coordinate.isChecked() 177 | 178 | # Vis state 179 | layer_blending = self.dlg.cb_layer_blending.currentText() 180 | 181 | self.progress_dialog = ProgressDialog(len(layers_with_visibility) * 2, self.dlg) 182 | self.progress_dialog.show() 183 | self.progress_dialog.aborted.connect(self.__aborted) 184 | 185 | self.config_creator = ConfigCreator(title, description, output_dir) 186 | self.config_creator.completed.connect(self.__completed) 187 | self.config_creator.canceled.connect(self.__aborted) 188 | self.config_creator.tasks_complete.connect( 189 | lambda: self.progress_dialog.status_label.setText(tr("Writing config file to the disk..."))) 190 | self.config_creator.progress_bar_changed.connect(self.__progress_bar_changed) 191 | self.config_creator.set_map_style(basemap) 192 | self.config_creator.set_map_state(center, zoom) 193 | self.config_creator.set_animation_config(None, 1) 194 | self.config_creator.set_vis_state_values(layer_blending) 195 | self.config_creator.set_interaction_config_values(tooltip_enabled, brush_enabled, geocoder_enabled, 196 | coordinate_enabled) 197 | 198 | for layer_info in layers_with_visibility: 199 | layer, is_visible = layer_info 200 | self.config_creator.add_layer(uuid.uuid4(), layer, random_color(), is_visible) 201 | 202 | self.config_creator.start_config_creation() 203 | 204 | def __progress_bar_changed(self, i: int, progress: int): 205 | if self.progress_dialog: 206 | self.progress_dialog.update_progress_bar(i, progress) 207 | 208 | def __aborted(self): 209 | if self.config_creator: 210 | self.config_creator.abort() 211 | if self.progress_dialog: 212 | self.progress_dialog.close() 213 | self.progress_dialog = None 214 | self.config_creator = None 215 | 216 | def __completed(self): 217 | if self.progress_dialog: 218 | self.progress_dialog.close() 219 | self.progress_dialog = None 220 | self.config_creator = None 221 | -------------------------------------------------------------------------------- /Unfolded/ui/progress_dialog.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | 21 | import logging 22 | 23 | from PyQt5.QtCore import pyqtSignal 24 | from PyQt5.QtWidgets import QDialog, QProgressBar, QLabel 25 | 26 | from ..qgis_plugin_tools.tools.custom_logging import bar_msg 27 | from ..qgis_plugin_tools.tools.i18n import tr 28 | from ..qgis_plugin_tools.tools.resources import load_ui, plugin_name 29 | 30 | FORM_CLASS = load_ui('progress_dialog.ui') 31 | LOGGER = logging.getLogger(plugin_name()) 32 | 33 | 34 | class ProgressDialog(QDialog, FORM_CLASS): 35 | aborted = pyqtSignal() 36 | 37 | def __init__(self, number_of_tasks: int, parent=None): 38 | QDialog.__init__(self, parent) 39 | self.setupUi(self) 40 | self.progress_per_tasks = [0] * number_of_tasks 41 | self.progress_bar: QProgressBar = self.progress_bar 42 | self.status_label: QLabel = self.status_label 43 | 44 | def closeEvent(self, evt) -> None: 45 | LOGGER.debug('Closing progress dialog') 46 | # noinspection PyUnresolvedReferences 47 | self.aborted.emit() 48 | 49 | def update_progress_bar(self, task_number: int, progress: int): 50 | """ Update progress bar with progress of a task """ 51 | self.progress_per_tasks[task_number] = progress 52 | self._update_progress_bar() 53 | 54 | def _update_progress_bar(self): 55 | self.progress_bar.setValue(min(97, int(sum(self.progress_per_tasks) / len(self.progress_per_tasks)))) 56 | 57 | def __aborted(self): 58 | LOGGER.warning(tr("Export aborted"), extra=bar_msg(tr("Export aborted by user"))) 59 | self.status_label.setText(tr("Aborting...")) 60 | # noinspection PyUnresolvedReferences 61 | self.aborted.emit() 62 | -------------------------------------------------------------------------------- /Unfolded/ui/settings_panel.py: -------------------------------------------------------------------------------- 1 | # Gispo Ltd., hereby disclaims all copyright interest in the program Unfolded QGIS plugin 2 | # Copyright (C) 2021 Gispo Ltd (https://www.gispo.fi/). 3 | # 4 | # 5 | # This file is part of Unfolded QGIS plugin. 6 | # 7 | # Unfolded QGIS plugin is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Unfolded QGIS plugin is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Unfolded QGIS plugin. If not, see . 19 | 20 | import logging 21 | import webbrowser 22 | 23 | from PyQt5.QtWidgets import QLineEdit 24 | from qgis.gui import QgsFileWidget 25 | 26 | from .base_panel import BasePanel 27 | from ..core.exceptions import MapboxTokenMissing 28 | from ..core.layer_handler import LayerHandler 29 | from ..definitions.gui import Panels 30 | from ..definitions.settings import Settings 31 | from ..qgis_plugin_tools.tools.custom_logging import get_log_level_key, LogTarget, get_log_level_name 32 | from ..qgis_plugin_tools.tools.resources import plugin_name, plugin_path 33 | from ..qgis_plugin_tools.tools.settings import set_setting 34 | 35 | LOGGER = logging.getLogger(plugin_name()) 36 | 37 | LOGGING_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 38 | 39 | 40 | # noinspection PyMethodMayBeStatic 41 | class SettingsPanel(BasePanel): 42 | """ 43 | This file is originally adapted from https://github.com/GispoCoding/qaava-qgis-plugin licensed under GPL version 2 44 | """ 45 | 46 | def __init__(self, dialog): 47 | super().__init__(dialog) 48 | self.panel = Panels.Settings 49 | 50 | # noinspection PyUnresolvedReferences 51 | def setup_panel(self): 52 | # Mapbox token 53 | line_edit_token: QLineEdit = self.dlg.le_mapbox_token 54 | line_edit_token.setText(Settings.mapbox_api_token.get()) 55 | line_edit_token.textChanged.connect(self.__mapbox_token_changed) 56 | self.dlg.btn_add_basemaps.clicked.connect(self.__add_basemaps_to_the_project) 57 | 58 | # Configuration output 59 | f_conf_output: QgsFileWidget = self.dlg.f_conf_output 60 | f_conf_output.setFilePath(Settings.conf_output_dir.get()) 61 | f_conf_output.fileChanged.connect(self.__conf_output_dir_changed) 62 | 63 | # Logging 64 | self.dlg.combo_box_log_level_file.clear() 65 | self.dlg.combo_box_log_level_console.clear() 66 | 67 | self.dlg.combo_box_log_level_file.addItems(LOGGING_LEVELS) 68 | self.dlg.combo_box_log_level_console.addItems(LOGGING_LEVELS) 69 | self.dlg.combo_box_log_level_file.setCurrentText(get_log_level_name(LogTarget.FILE)) 70 | self.dlg.combo_box_log_level_console.setCurrentText(get_log_level_name(LogTarget.STREAM)) 71 | 72 | self.dlg.combo_box_log_level_file.currentTextChanged.connect( 73 | lambda level: set_setting(get_log_level_key(LogTarget.FILE), level)) 74 | 75 | self.dlg.combo_box_log_level_console.currentTextChanged.connect( 76 | lambda level: set_setting(get_log_level_key(LogTarget.STREAM), level)) 77 | 78 | self.dlg.btn_open_log.clicked.connect(lambda _: webbrowser.open(plugin_path("logs", f"{plugin_name()}.log"))) 79 | 80 | def __add_basemaps_to_the_project(self): 81 | try: 82 | LayerHandler.add_unfolded_basemaps() 83 | except MapboxTokenMissing as e: 84 | LOGGER.warning(e, extra=e.bar_msg) 85 | 86 | def __conf_output_dir_changed(self, new_dir: str): 87 | if new_dir: 88 | Settings.conf_output_dir.set(new_dir) 89 | 90 | def __mapbox_token_changed(self, new_token: str): 91 | if new_token: 92 | Settings.mapbox_api_token.set(new_token) 93 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | Plugin development 2 | ================== 3 | 4 | ## Setup 5 | 6 | Instructions are aimed at developers using MacOS, but similar steps should work on different platforms as well. 7 | 8 | Instructions were confirmed to be working well with a combination of: Python 3.9.5, QGIS 3.30+, and PyQT 5. 9 | 10 | 1. Install QGIS app from https://qgis.org/en/site/forusers/download.html 11 | 2. We rely on [qgis_plugin_tools](https://github.com/GispoCoding/qgis_plugin_tools), so when cloning the repo, make sure to clone it recursively, with submodules: 12 | 13 | ```bash 14 | git clone --recurse-submodules https://github.com/UnfoldedInc/qgis-plugin.git 15 | ``` 16 | 17 | 3. Set up tools: 18 | 19 | ```bash 20 | python3 --version # make sure that you're using Python 3.9.5 21 | pip3 install --upgrade pip # upgrade pip to latest 22 | pip3 install --upgrade setuptools # upgrade setuptools to latest 23 | ``` 24 | 25 | 4. Install Qt and PyQT: 26 | 27 | ```bash 28 | brew install qt@5 # our plugin relies on v5, so we make sure it's that version 29 | export PATH="/opt/homebrew/opt/qt5/bin:$PATH" # makes sure that qmake is in your PATH 30 | pip3 install pyqt5-sip 31 | pip3 install pyqt5 --config-settings --confirm-license= --verbose # in some cases, the install script gets stuck on license step and this way we just automatically confirm it 32 | ``` 33 | 34 | 5. Install dependencies: 35 | 36 | ```bash 37 | cd qgis-plugin 38 | pip install -r requirements.txt 39 | 40 | export PYTHONPATH=/Applications/Qgis.app/Contents/Resources/python # this makes sure that the version of python with bundled `qgis` module can be found 41 | ``` 42 | 43 | 6. The build script: 44 | 45 | If you're on Mac, you want to comment out the lines #70 and #71 in `qgis-plugin/Unfolded/qgis_plugin_tools/infrastructure/plugin_maker.py`. This is because Apple returns `"darwin"` as a OS identifier, so this OS check mistakenly thinks it's a Windows machine, and instead, we just let it fall through to the actual case for Mac. 46 | 47 | Now you can run the build script and deploy it to the QGIS' plugins folder: 48 | 49 | ```bash 50 | cd qgis-plugin/Unfolded 51 | python3 build.py deploy 52 | ``` 53 | 54 | This should be the end of your setup and if you manage to run `build.py` script without any errors, that's a confirmation that everything is set up correctly. 55 | 56 | ## Development workflow 57 | 58 | - make changes to the plugin inside `/Unfolded` folder 59 | - run `python3 build.py deploy`, this packages the plugin and copies it to the QGIS' plugins folder (usually `/Users//Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins`; or see [plugin's dir location](https://gis.stackexchange.com/questions/274311/qgis-3-plugin-folder-location)) 60 | - this does not publish the plugin to the official plugin registry, just installs it locally! (for releasing it to the remote registry, see [Creating a release](#creating-a-release) section) 61 | - additionally, you can set up a filesystem watcher to monitor entire folder and automatically execute the deploy command so you don't have to do it manually every time 62 | - to use the freshly "deployed" plugin inside QGIS you can, either: 63 | - restart QGIS app, and it will reload all plugins; or 64 | - go to "Installed Plugins" and deselect and then again select your plugin in the list, effectively reloading it; or 65 | - use [plugin-reloader](https://plugins.qgis.org/plugins/plugin_reloader/) plugin (← this has the best DX and is recommended) 66 | 67 | For debugging, use: 68 | - dev log (via ViewPanelsLog Messages) 69 | - this gives you multiple output windows for all the different plugins and internal QGIS python interpreter, and is basically the main debugging tool you'll be using 70 | - REPL Python console (via PluginsPython Console) 71 | - `qgis` module is available to all the plugins, and is automatically bound to them when executing plugins and is not available as a general dependency that you can freely import and use in normal Python scripts, so this is the only way you have access to it in any Python environment other than within QGIS plugin runtime 72 | - it's recommended to set up some typechecker and Python language server in your IDE to get a good DX and minimize the risk of errors 73 | - for VS Code: 74 | - [`mypy`](https://marketplace.visualstudio.com/items?itemName=matangover.mypy) typechecker 75 | - [`pylance`](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) language server 76 | - [`Qt for Python`](https://marketplace.visualstudio.com/items?itemName=seanwu.vscode-qt-for-python) for PyQt5 support 77 | - also consider adding this config line to your `.vscode/settings.json` (this makes sure it can find `qgis` module as well): 78 | ```json 79 | { 80 | "python.analysis.extraPaths": ["./kepler", "./keplergl", "./Unfolded", "/Applications/Qgis.app/Contents/Resources/python", "/Applications/Qgis.app/Contents/Resources", "${userHome}/.pyenv/versions/3.9.5/lib/python3.9/site-packages", "${userHome}/.pyenv/shims/pytest"] 81 | } 82 | ``` 83 | 84 | Another useful thing is to have both versions of the plugin installed - the current, officially available version and your development version: 85 | - install the regular version from the registry 86 | - before running `python3 build.py deploy` script, update `name` in `metadata.txt` to something like `name=Unfolded-dev` 87 | - now when you run the script, a new plugin with `Unfolded-dev` name will appear along side the regular one in the QGIS plugins directory and plugins listing 88 | - ❗️ don't commit these changes to `metadata.txt` when doing a release (unless that's your actual intention ofc; this is just for development), just keep them in git's unstaged changes e.g. 89 | - you can also update icon and naming in other places to help differentiate it 90 | 91 | | ![image](https://github.com/foursquare/qgis-plugin/assets/1355455/d8c20647-4618-489a-b10b-5c8c50efa7f5) | 92 | |:--:| 93 | | example: both versions of the plugin active (official and dev), with different art | 94 | 95 | ## Adding or editing source files 96 | If you create or edit source files make sure that: 97 | 98 | * they contain relative imports 99 | ```python 100 | 101 | from ..utils.exceptions import TestException # Good 102 | 103 | from Unfolded.utils.exceptions import TestException # Bad 104 | ``` 105 | * they will be found by [build.py](../Unfolded/build.py) script (`py_files` and `ui_files` values) 106 | * you consider adding test files for the new functionality 107 | 108 | ## QGIS documentation and help 109 | 110 | - QGIS docs: https://docs.qgis.org/3.28/en/docs/user_manual/ 111 | - make sure you're viewing the docs of the right SDK version 112 | - GIS stachexchange is your friend: https://gis.stackexchange.com/ 113 | - when googling, adding "PyQGIS" keyword helps narrow down search results quite a lot 114 | 115 | ## Testing 116 | Install Docker, docker-compose and python packages listed in [requirements.txt](../requirements.txt) 117 | to run tests with: 118 | 119 | ```shell script 120 | python build.py test 121 | ``` 122 | ## Translating 123 | 124 | #### Translating with transifex 125 | 126 | Fill in `transifex_coordinator` (Transifex username) and `transifex_organization` 127 | in [.qgis-plugin-ci](../.qgis-plugin-ci) to use Transifex translation. 128 | 129 | 130 | ##### Pushing / creating new translations 131 | 132 | * First install [Transifex CLI](https://docs.transifex.com/client/installing-the-client) and 133 | [qgis-plugin-ci](https://github.com/opengisch/qgis-plugin-ci) 134 | * Make sure command `pylupdate5` works. Otherwise install it with `pip install pyqt5` 135 | * Run `qgis-plugin-ci push-translation ` 136 | * Go to your Transifex site, add some languages and start translating 137 | * Copy [push_translations.yml](push_translations.yml) file to [workflows](../.github/workflows) folder to enable 138 | automatic pushing after commits to master 139 | * Add this badge ![](https://github.com/UnfoldedInc/qgis-plugin/workflows/Translations/badge.svg) to 140 | the [README](../README.md) 141 | 142 | ##### Pulling 143 | There is no need to pull if you configure `--transifex-token` into your 144 | [release](../.github/workflows/release.yml) workflow (remember to use Github Secrets). 145 | Remember to uncomment the lrelease section as well. 146 | You can however pull manually to test the process. 147 | * Run `qgis-plugin-ci pull-translation --compile ` 148 | 149 | #### Translating with QT Linguistic (if Transifex not available) 150 | 151 | The translation files are in [i18n](../Unfolded/resources/i18n) folder. Translatable content in python files is code 152 | such as `tr(u"Hello World")`. 153 | 154 | To update language *.ts* files to contain newest lines to translate, run 155 | ```shell script 156 | python build.py transup 157 | ``` 158 | 159 | You can then open the *.ts* files you wish to translate with Qt Linguist and make the changes. 160 | 161 | Compile the translations to *.qm* files with: 162 | ```shell script 163 | python build.py transcompile 164 | ``` 165 | 166 | 167 | ## Creating a release 168 | Follow these steps to create a release 169 | * Add changelog information to [CHANGELOG.md](../CHANGELOG.md) using this 170 | [format](https://raw.githubusercontent.com/opengisch/qgis-plugin-ci/master/CHANGELOG.md) 171 | * Update `PLUGIN_VERSION` variable in `sentry.py` 172 | * Make a new commit. (`git add -A && git commit -m "Release v0.1.0"`) 173 | * Create new tag for it (`git tag -a v0.1.0 -m "Version v0.1.0"`) 174 | * Push tag to Github using `git push --follow-tags` 175 | * Create Github release 176 | * [qgis-plugin-ci](https://github.com/opengisch/qgis-plugin-ci) adds release zip automatically as an asset 177 | -------------------------------------------------------------------------------- /docs/imgs/foursquare-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/docs/imgs/foursquare-logo.png -------------------------------------------------------------------------------- /docs/imgs/main_dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foursquare/qgis-plugin/a74a9aee94fe281e49914a84ab9c2f3384151903/docs/imgs/main_dialog.png -------------------------------------------------------------------------------- /docs/imgs/uf_qgis_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Topi Tjukanov 55 | 56 | 57 | 58 | 59 | QGIS 3.16.2-Hannover 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | image/svg+xml 68 | 69 | 2021-02-08T15:13:32 70 | 71 | 72 | 73 | 74 | Topi Tjukanov 75 | 76 | 77 | 78 | 79 | QGIS 3.16.2-Hannover 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /docs/push_translations.yml: -------------------------------------------------------------------------------- 1 | name: Translations 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | push_translations: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | submodules: true 16 | 17 | - name: Set up Python 3.8 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: 3.8 21 | 22 | - name: Install qgis-plugin-ci 23 | run: pip3 install qgis-plugin-ci 24 | 25 | - name: Push translations 26 | run: qgis-plugin-ci push-translation ${{ secrets.TRANSIFEX_TOKEN }} 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Only for development purposes 2 | pytest~=6.0.1 3 | qgis_plugin_ci~=1.8.4 4 | --------------------------------------------------------------------------------