├── .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 | 
4 | [](https://codecov.io/github/UnfoldedInc/qgis-plugin?branch=main)
5 | 
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 Plugins → Manage 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 | 
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 |
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 View → Panels → Log 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 Plugins → Python 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 | |  |
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  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 |
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 |
--------------------------------------------------------------------------------