├── .gitignore ├── Discovery ├── LICENSE ├── __init__.py ├── config_dialog.py ├── config_dialog.ui ├── dbutils.py ├── discovery_logo.png ├── discovery_logo_64.png ├── discovery_ts.pro ├── discovery_ts.pro.qtds ├── discoveryplugin.py ├── gpkg_utils.py ├── i18n │ ├── discovery_ja.qm │ └── discovery_ja.ts ├── locator_filter.py ├── metadata.txt ├── mssql_utils.py ├── oracle_utils.py └── utils.py ├── LICENSE ├── README.md ├── install_plugin_dev_win.bat └── package.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .idea 4 | 5 | *.zip 6 | 7 | -------------------------------------------------------------------------------- /Discovery/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 | -------------------------------------------------------------------------------- /Discovery/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Discovery Plugin 4 | # 5 | # Copyright (C) 2015 Lutra Consulting 6 | # info@lutraconsulting.co.uk 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | 13 | 14 | def classFactory(iface): 15 | from .discoveryplugin import DiscoveryPlugin 16 | 17 | return DiscoveryPlugin(iface) 18 | -------------------------------------------------------------------------------- /Discovery/config_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Discovery Plugin 4 | # 5 | # Copyright (C) 2015 Lutra Consulting 6 | # info@lutraconsulting.co.uk 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | 13 | import os 14 | 15 | from PyQt5 import uic 16 | from PyQt5.QtCore import QSettings, Qt, QUrl 17 | from PyQt5.QtGui import QColor, QDesktopServices 18 | from PyQt5.QtWidgets import QApplication, QDialog, QDialogButtonBox, QFileDialog, QMessageBox 19 | from qgis.core import QgsSettings 20 | 21 | from . import dbutils, discoveryplugin, gpkg_utils, mssql_utils, oracle_utils 22 | 23 | plugin_dir = os.path.dirname(__file__) 24 | 25 | uiConfigDialog, qtBaseClass = uic.loadUiType(os.path.join(plugin_dir, "config_dialog.ui")) 26 | 27 | 28 | class ConfigDialog(qtBaseClass, uiConfigDialog): 29 | 30 | def __init__(self, parent=None): 31 | qtBaseClass.__init__(self, parent) 32 | self.setupUi(self) 33 | 34 | self.conn = None 35 | self.key = "" # currently selected config key 36 | 37 | # signals 38 | self.buttonBox.button(QDialogButtonBox.Help).clicked.connect(self.show_help) 39 | self.addButton.clicked.connect(self.add_config) 40 | self.deleteButton.clicked.connect(self.delete_config) 41 | self.configOptions.currentIndexChanged.connect(self.config_selection_changed) 42 | self.cboName.textChanged.connect(self.validate_nameField) 43 | self.cboDataSource.currentIndexChanged.connect(self.data_type_changed) 44 | self.fileButton.clicked.connect(self.browse_file_db) 45 | self.cboFile.currentIndexChanged.connect(self.populate_tables) 46 | self.cboSchema.currentIndexChanged.connect(self.populate_tables) 47 | self.cboTable.currentIndexChanged.connect(self.populate_columns) 48 | 49 | settings = QgsSettings() 50 | settings.beginGroup("/Discovery") 51 | 52 | # init config list 53 | if not settings.value("config_list"): 54 | settings.setValue("config_list", []) 55 | config_list = settings.value("config_list") 56 | 57 | # prev version compatibility settings 58 | if self.prev_version_config_available(): 59 | config_list.append("") 60 | settings.setValue("config_list", config_list) 61 | 62 | # if empty, add config 63 | if not config_list: 64 | config_list.append("New config") 65 | settings.setValue("config_list", config_list) 66 | 67 | self.init_cbo_data_source() 68 | self.cboConnection.currentIndexChanged.connect(self.connect_db) 69 | self.cboConnection.addItem("") 70 | self.populate_connections() 71 | 72 | for key in config_list: 73 | self.configOptions.addItem(key) 74 | 75 | if self.configOptions.count(): 76 | self.configOptions.setCurrentIndex(0) 77 | 78 | self.key = self.configOptions.currentText() if self.configOptions.currentIndex() >= 0 else "" 79 | 80 | if not self.configOptions.count(): 81 | self.enable_form(False) 82 | 83 | self.chkMarkerTime.stateChanged.connect(self.time_checkbox_changed) 84 | self.chkBarInfoTime.stateChanged.connect(self.bar_info_checkbox_changed) 85 | 86 | def init_cbo_data_source(self): 87 | self.cboDataSource.addItem("PostgreSQL", "postgres") 88 | self.cboDataSource.addItem("MS SQL Server", "mssql") 89 | self.cboDataSource.addItem("Oracle", "oracle") 90 | self.cboDataSource.addItem("GeoPackage", "gpkg") 91 | self.cboDataSource.setCurrentIndex(0) 92 | 93 | def prev_version_config_available(self): 94 | settings = QSettings() 95 | settings.beginGroup("/Discovery") 96 | 97 | conn = settings.value("connection") 98 | if conn: 99 | return True 100 | return False 101 | 102 | def validate_nameField(self): 103 | settings = QSettings() 104 | settings.beginGroup("/Discovery") 105 | config_list = settings.value("config_list") 106 | key = self.cboName.text() 107 | 108 | if self.validate_key(key, config_list): 109 | self.cboName.setStyleSheet("") 110 | self.lblMessage.setText("") 111 | else: 112 | self.lblMessage.setText("Connection name is too short or already exists!") 113 | self.cboName.setStyleSheet("QLineEdit {background-color: pink;}") 114 | 115 | # connected to buttonBox.accepted() 116 | def validate_and_accept(self): 117 | settings = QSettings() 118 | settings.beginGroup("/Discovery") 119 | config_list = settings.value("config_list") 120 | key = self.cboName.text() 121 | 122 | if self.validate_key(key, config_list): 123 | self.accept() 124 | else: 125 | self.cboName.setStyleSheet("QLineEdit {background-color: pink;}") 126 | 127 | def reset_form_fields(self): 128 | self.cboName.setText("") 129 | self.cboDataSource.setCurrentIndex(0) 130 | self.enable_fields_for_data_type() 131 | self.init_conn_schema_cbos([], "") 132 | self.cboTable.setCurrentIndex(0) 133 | self.populate_columns() 134 | 135 | for cbo in [ 136 | self.cboSearchColumn, 137 | self.cboGeomColumn, 138 | self.cboDisplayColumn1, 139 | self.cboDisplayColumn2, 140 | self.cboDisplayColumn3, 141 | self.cboDisplayColumn4, 142 | self.cboDisplayColumn5, 143 | ]: 144 | cbo.setCurrentIndex(0) 145 | 146 | def set_form_fields(self, key): 147 | QApplication.setOverrideCursor(Qt.WaitCursor) 148 | settings = QSettings() 149 | settings.beginGroup("/Discovery") 150 | 151 | if key: 152 | self.cboName.setText(key) 153 | else: 154 | self.cboName.setText("") 155 | 156 | data_type = settings.value(key + "data_type", "postgres") 157 | data_type_idx = self.cboDataSource.findData(data_type) 158 | self.cboDataSource.blockSignals(True) 159 | self.cboDataSource.setCurrentIndex(data_type_idx) 160 | self.cboDataSource.blockSignals(False) 161 | 162 | self.populate_connections() 163 | 164 | # tables 165 | self.init_combo_from_settings(self.cboTable, key + "table") 166 | self.populate_columns() 167 | 168 | # columns 169 | self.init_combo_from_settings(self.cboSearchColumn, key + "search_column") 170 | if data_type in ("postgres", "mssql", "oracle"): 171 | self.label_3.setText("Table") 172 | self.cboGeomColumn.setEnabled(True) 173 | self.init_combo_from_settings(self.cboGeomColumn, key + "geom_column") 174 | elif data_type == "gpkg": 175 | self.label_3.setText("Layer") 176 | self.cboGeomColumn.clear() 177 | self.cboGeomColumn.addItem("") 178 | self.cboGeomColumn.setEnabled(False) 179 | 180 | self.enable_fields_for_data_type() 181 | 182 | escape_spec_chars = settings.value(key + "escape_spec_chars", False, type=bool) 183 | self.cbEscapeSpecChars.setCheckState(Qt.Checked if escape_spec_chars else Qt.Unchecked) 184 | echo_search_col = settings.value(key + "echo_search_column", True, type=bool) 185 | self.cbEchoSearchColumn.setCheckState(Qt.Checked if echo_search_col else Qt.Unchecked) 186 | 187 | columns = settings.value(key + "display_columns", "", type=str) 188 | if len(columns) != 0: 189 | lst = columns.split(",") 190 | self.set_combo_current_text(self.cboDisplayColumn1, lst[0]) 191 | if len(lst) > 1: 192 | self.set_combo_current_text(self.cboDisplayColumn2, lst[1]) 193 | if len(lst) > 2: 194 | self.set_combo_current_text(self.cboDisplayColumn3, lst[2]) 195 | if len(lst) > 3: 196 | self.set_combo_current_text(self.cboDisplayColumn4, lst[3]) 197 | if len(lst) > 4: 198 | self.set_combo_current_text(self.cboDisplayColumn5, lst[4]) 199 | 200 | self.editScaleExpr.setText(settings.value(key + "scale_expr", "", type=str)) 201 | self.editBboxExpr.setText(settings.value(key + "bbox_expr", "", type=str)) 202 | h_color = QColor() 203 | h_color.setNamedColor(settings.value(key + "highlight_color", "#FF0000", type=str)) 204 | self.color_picker.setColor(h_color) 205 | self.chkMarkerTime.setChecked(settings.value("marker_time_enabled", True, type=bool)) 206 | self.spinMarkerTime.setValue(settings.value("marker_time", 5000, type=int) // 1000) 207 | self.chkBarInfoTime.setChecked(settings.value("bar_info_time_enabled", True, type=bool)) 208 | self.spinBarInfoTime.setValue(settings.value("bar_info_time", 30, type=int)) 209 | self.spinLimitResults.setValue(settings.value(key + "limit_results", 1000, type=int)) 210 | self.time_checkbox_changed() 211 | self.bar_info_checkbox_changed() 212 | self.chkInfoToClipboard.setChecked(settings.value("info_to_clipboard", True, type=bool)) 213 | 214 | QApplication.restoreOverrideCursor() 215 | 216 | def init_conn_schema_cbos(self, current_connections, key): 217 | all_cons = [self.cboConnection.itemText(i) for i in range(self.cboConnection.count())] 218 | self.cboConnection.clear() 219 | for conn in current_connections: 220 | if conn not in all_cons: 221 | self.cboConnection.addItem(conn) 222 | self.init_combo_from_settings(self.cboConnection, key + "connection") 223 | self.connect_db() 224 | # schemas 225 | self.init_combo_from_settings(self.cboSchema, key + "schema") 226 | self.populate_tables() 227 | 228 | def init_combo_from_settings(self, cbo, settings_key): 229 | settings = QSettings() 230 | settings.beginGroup("/Discovery") 231 | name = settings.value(settings_key, "", type=str) 232 | self.set_combo_current_text(cbo, name) 233 | 234 | def set_combo_current_text(self, cbo, name): 235 | idx = cbo.findText(name) 236 | cbo.setCurrentIndex(idx) if idx != -1 else cbo.setEditText(name) 237 | 238 | def connect_db(self): 239 | name = self.cboConnection.currentText() 240 | if name == "": 241 | return 242 | try: 243 | data_type = self.cboDataSource.itemData(self.cboDataSource.currentIndex()) 244 | if data_type == "postgres": 245 | self.conn = dbutils.get_connection(dbutils.get_postgres_conn_info(name)) 246 | elif data_type == "mssql": 247 | self.conn = mssql_utils.get_mssql_conn(mssql_utils.get_mssql_conn_info(name)) 248 | elif data_type == "oracle": 249 | self.conn = oracle_utils.get_oracle_conn(oracle_utils.get_oracle_conn_info(name)) 250 | self.lblMessage.setText("") 251 | except Exception as e: 252 | self.conn = None 253 | self.lblMessage.setText("" + str(e) + "") 254 | self.populate_schemas() 255 | 256 | def populate_connections(self): 257 | key = self.cboName.text() 258 | data_type = self.cboDataSource.itemData(self.cboDataSource.currentIndex()) 259 | if data_type == "postgres": 260 | current_connections = dbutils.get_postgres_connections() 261 | self.init_conn_schema_cbos(current_connections, key) 262 | elif data_type == "mssql": 263 | current_connections = mssql_utils.get_mssql_connections() 264 | self.init_conn_schema_cbos(current_connections, key) 265 | elif data_type == "oracle": 266 | current_connections = oracle_utils.get_oracle_connections() 267 | self.init_conn_schema_cbos(current_connections, key) 268 | elif data_type == "gpkg": 269 | self.init_combo_from_settings(self.cboFile, key + "file") 270 | self.populate_tables() 271 | 272 | def populate_schemas(self): 273 | self.cboSchema.clear() 274 | self.cboSchema.addItem("") 275 | if self.conn is None: 276 | return 277 | 278 | data_type = self.cboDataSource.itemData(self.cboDataSource.currentIndex()) 279 | if data_type == "postgres": 280 | schemas = dbutils.list_schemas(self.conn.cursor()) 281 | elif data_type == "mssql": 282 | schemas = mssql_utils.list_schemas(self.conn) 283 | elif data_type == "oracle": 284 | schemas = oracle_utils.list_schemas(self.conn) 285 | else: 286 | schemas = [] 287 | for schema in schemas: 288 | self.cboSchema.addItem(schema) 289 | 290 | def populate_tables(self): 291 | self.cboTable.clear() 292 | self.cboTable.addItem("") 293 | data_type = self.cboDataSource.itemData(self.cboDataSource.currentIndex()) 294 | if data_type == "postgres": 295 | if self.conn is None: 296 | return 297 | tables = dbutils.list_tables(self.conn.cursor(), self.cboSchema.currentText()) 298 | elif data_type == "mssql": 299 | if self.conn is None: 300 | return 301 | tables = mssql_utils.list_tables(self.conn) # TODO: filter by schema 302 | elif data_type == "oracle": 303 | if self.conn is None: 304 | return 305 | tables = oracle_utils.list_tables(self.conn, self.cboSchema.currentText()) 306 | elif data_type == "gpkg": 307 | tables = gpkg_utils.list_gpkg_layers(self.cboFile.currentText()) 308 | else: 309 | return # current index == -1 310 | 311 | for table in tables: 312 | self.cboTable.addItem(table) 313 | 314 | def populate_columns(self): 315 | cbos = [ 316 | self.cboSearchColumn, 317 | self.cboGeomColumn, 318 | self.cboDisplayColumn1, 319 | self.cboDisplayColumn2, 320 | self.cboDisplayColumn3, 321 | self.cboDisplayColumn4, 322 | self.cboDisplayColumn5, 323 | ] 324 | for cbo in cbos: 325 | cbo.clear() 326 | cbo.addItem("") 327 | 328 | data_type = self.cboDataSource.itemData(self.cboDataSource.currentIndex()) 329 | if data_type == "postgres": 330 | if self.conn is None: 331 | return 332 | columns = dbutils.list_columns( 333 | self.conn.cursor(), self.cboSchema.currentText(), self.cboTable.currentText() 334 | ) 335 | elif data_type == "mssql": 336 | if self.conn is None: 337 | return 338 | columns = mssql_utils.list_columns(self.conn, self.cboSchema.currentText(), self.cboTable.currentText()) 339 | elif data_type == "oracle": 340 | if self.conn is None: 341 | return 342 | columns = oracle_utils.list_columns(self.conn, self.cboSchema.currentText(), self.cboTable.currentText()) 343 | elif data_type == "gpkg": 344 | columns = gpkg_utils.list_gpkg_fields(self.cboFile.currentText(), self.cboTable.currentText()) 345 | else: 346 | return # current index == -1 347 | 348 | for cbo in cbos: 349 | for column in columns: 350 | cbo.addItem(column) 351 | 352 | def enable_fields_for_data_type(self): 353 | data_type = self.cboDataSource.itemData(self.cboDataSource.currentIndex()) 354 | is_db = data_type in ("mssql", "oracle", "postgres") 355 | 356 | for w in [self.cboConnection, self.cboSchema, self.label, self.label_2]: 357 | w.setEnabled(is_db) 358 | w.setVisible(is_db) 359 | for w in [self.file_grid_layout, self.cboFile, self.label_10, self.fileButton]: 360 | w.setEnabled(not is_db) 361 | w.setVisible(not is_db) 362 | 363 | def validate_key(self, key, config_list): 364 | if not key: 365 | return False 366 | if self.key != key and key in config_list: 367 | return False 368 | 369 | return True 370 | 371 | def write_config(self): 372 | 373 | settings = QSettings() 374 | settings.beginGroup("/Discovery") 375 | 376 | config_list = settings.value("config_list") 377 | if not config_list: 378 | config_list = [] 379 | 380 | key = self.cboName.text() 381 | if self.key != key: 382 | 383 | if not self.validate_key(key, config_list): 384 | return 385 | 386 | if self.key in config_list: 387 | config_list.remove(self.key) 388 | discoveryplugin.delete_config_from_settings(self.key, settings) 389 | self.key = key 390 | 391 | if key not in config_list: 392 | config_list.append(key) 393 | settings.setValue("config_list", config_list) 394 | 395 | settings.setValue(key + "data_type", self.cboDataSource.itemData(self.cboDataSource.currentIndex())) 396 | settings.setValue(key + "file", self.cboFile.currentText()) 397 | settings.setValue(key + "connection", self.cboConnection.currentText()) 398 | settings.setValue(key + "schema", self.cboSchema.currentText()) 399 | settings.setValue(key + "table", self.cboTable.currentText()) 400 | settings.setValue(key + "search_column", self.cboSearchColumn.currentText()) 401 | settings.setValue(key + "escape_spec_chars", self.cbEscapeSpecChars.isChecked()) 402 | settings.setValue(key + "echo_search_column", self.cbEchoSearchColumn.isChecked()) 403 | settings.setValue(key + "display_columns", self.display_columns()) 404 | settings.setValue(key + "geom_column", self.cboGeomColumn.currentText()) 405 | settings.setValue(key + "scale_expr", self.editScaleExpr.text()) 406 | settings.setValue(key + "bbox_expr", self.editBboxExpr.text()) 407 | settings.setValue(key + "highlight_color", self.color_picker.color().name()) 408 | 409 | settings.setValue("marker_time_enabled", self.chkMarkerTime.isChecked()) 410 | settings.setValue("marker_time", self.spinMarkerTime.value() * 1000) 411 | settings.setValue("bar_info_time_enabled", self.chkBarInfoTime.isChecked()) 412 | settings.setValue("bar_info_time", self.spinBarInfoTime.value()) 413 | settings.setValue(key + "limit_results", self.spinLimitResults.value()) 414 | settings.setValue("info_to_clipboard", self.chkInfoToClipboard.isChecked()) 415 | 416 | self.configOptions.clear() 417 | for k in config_list: 418 | self.configOptions.addItem(k) 419 | 420 | index = self.configOptions.findText(key) 421 | if index != -1: 422 | self.configOptions.setCurrentIndex(index) 423 | 424 | def time_checkbox_changed(self): 425 | self.spinMarkerTime.setEnabled(self.chkMarkerTime.isChecked()) 426 | 427 | def bar_info_checkbox_changed(self): 428 | self.spinBarInfoTime.setEnabled(self.chkBarInfoTime.isChecked()) 429 | 430 | def display_columns(self): 431 | """Make a string out of display columns, e.g. "column1,column2" or just "column1" """ 432 | lst = [] 433 | for cbo in [ 434 | self.cboDisplayColumn1, 435 | self.cboDisplayColumn2, 436 | self.cboDisplayColumn3, 437 | self.cboDisplayColumn4, 438 | self.cboDisplayColumn5, 439 | ]: 440 | txt = cbo.currentText() 441 | if len(txt) > 0: 442 | lst.append(txt) 443 | return ",".join(lst) 444 | 445 | def enable_form(self, enable=True): 446 | self.datasource_lout.setEnabled(enable) 447 | 448 | def add_config(self): 449 | txt = "" 450 | self.configOptions.addItem(txt) 451 | self.configOptions.setCurrentIndex(self.configOptions.count() - 1) 452 | 453 | settings = QSettings() 454 | settings.beginGroup("/Discovery") 455 | config_list = settings.value("config_list") 456 | if not (config_list): 457 | config_list = [] 458 | self.enable_form() 459 | config_list.append(txt) 460 | settings.setValue("config_list", config_list) 461 | 462 | # reset fields 463 | self.reset_form_fields() 464 | self.cboName.setText(txt) 465 | self.cboDataSource.setCurrentIndex(0) 466 | self.populate_connections() 467 | self.key = txt 468 | 469 | def delete_config(self): 470 | if self.configOptions.currentIndex() < 0: 471 | return 472 | 473 | msgBox = QMessageBox() 474 | msgBox.setWindowTitle("Delete configuration") 475 | msgBox.setText("Do you want to delete selected configuration?") 476 | msgBox.setStandardButtons(QMessageBox.Yes) 477 | msgBox.addButton(QMessageBox.No) 478 | msgBox.setDefaultButton(QMessageBox.No) 479 | if msgBox.exec_() == QMessageBox.No: 480 | return 481 | 482 | self.delete_config_without_confirm() 483 | 484 | def delete_config_without_confirm(self): 485 | item_text = self.configOptions.currentText() 486 | self.configOptions.removeItem(self.configOptions.currentIndex()) 487 | settings = QSettings() 488 | settings.beginGroup("/Discovery") 489 | config_list = settings.value("config_list") 490 | config_list.remove(item_text) 491 | settings.setValue("config_list", config_list) 492 | if self.configOptions.count(): 493 | self.configOptions.setCurrentIndex(0) 494 | else: 495 | self.reset_form_fields() 496 | self.enable_form(False) 497 | self.key = "" 498 | 499 | def config_selection_changed(self): 500 | if not self.configOptions.count(): 501 | return 502 | if self.configOptions.currentIndex() < 0: 503 | return 504 | 505 | self.key = self.configOptions.currentText() 506 | self.set_form_fields(self.key) 507 | 508 | def data_type_changed(self): 509 | self.conn = None 510 | self.populate_connections() 511 | self.enable_fields_for_data_type() 512 | 513 | def browse_file_db(self): 514 | dialog = QFileDialog(self) 515 | dialog.setWindowTitle("Open GeoPackage database") 516 | dialog.setNameFilters(["*.gpkg"]) 517 | dialog.setFileMode(QFileDialog.ExistingFile) 518 | if dialog.exec_() == QDialog.Accepted: 519 | filename = dialog.selectedFiles()[0] 520 | if self.cboFile.findText(filename) < 0: 521 | self.cboFile.addItem(filename) 522 | self.cboFile.setCurrentIndex(self.cboFile.findText(filename)) 523 | 524 | def show_help(self): 525 | QDesktopServices.openUrl(QUrl("http://www.lutraconsulting.co.uk/products/discovery/")) 526 | -------------------------------------------------------------------------------- /Discovery/config_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ConfigDialog 4 | 5 | 6 | true 7 | 8 | 9 | 10 | 0 11 | 0 12 | 453 13 | 693 14 | 15 | 16 | 17 | 18 | 0 19 | 0 20 | 21 | 22 | 23 | 24 | 0 25 | 0 26 | 27 | 28 | 29 | Configuration 30 | 31 | 32 | 33 | 34 | 35 | 36 | 0 37 | 0 38 | 39 | 40 | 41 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop 42 | 43 | 44 | true 45 | 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 53 | 54 | 55 | 56 | border-bottom: 1px solid black 57 | 58 | 59 | 60 | 1 61 | 62 | 63 | 0 64 | 65 | 66 | 0 67 | 68 | 69 | 0 70 | 71 | 72 | 73 | 74 | 0 75 | 76 | 77 | 0 78 | 79 | 80 | 81 | 82 | 83 | 0 84 | 0 85 | 86 | 87 | 88 | true 89 | 90 | 91 | QComboBox::NoInsert 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 0 100 | 0 101 | 102 | 103 | 104 | true 105 | 106 | 107 | QComboBox::NoInsert 108 | 109 | 110 | 111 | 112 | 113 | 114 | border-bottom:0px 115 | 116 | 117 | Echo search column in results 118 | 119 | 120 | true 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 0 129 | 0 130 | 131 | 132 | 133 | true 134 | 135 | 136 | QComboBox::NoInsert 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 0 145 | 0 146 | 147 | 148 | 149 | true 150 | 151 | 152 | QComboBox::NoInsert 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 0 161 | 0 162 | 163 | 164 | 165 | true 166 | 167 | 168 | QComboBox::NoInsert 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 0 180 | 0 181 | 182 | 183 | 184 | true 185 | 186 | 187 | QComboBox::NoInsert 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 0 196 | 0 197 | 198 | 199 | 200 | 201 | 16777215 202 | 21 203 | 204 | 205 | 206 | File 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 0 215 | 0 216 | 217 | 218 | 219 | true 220 | 221 | 222 | QComboBox::NoInsert 223 | 224 | 225 | 226 | 227 | 228 | 229 | Search column 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 0 238 | 0 239 | 240 | 241 | 242 | true 243 | 244 | 245 | QComboBox::NoInsert 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | BBOX expression 256 | 257 | 258 | 259 | 260 | 261 | 262 | Scale expression 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 0 271 | 0 272 | 273 | 274 | 275 | true 276 | 277 | 278 | QComboBox::NoInsert 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | Schema 289 | 290 | 291 | 292 | 293 | 294 | 295 | border-bottom:0px 296 | 297 | 298 | Escape special characters in search text 299 | 300 | 301 | 302 | 303 | 304 | 305 | Table 306 | 307 | 308 | 309 | 310 | 311 | 312 | Connection 313 | 314 | 315 | 316 | 317 | 318 | 319 | Geometry column 320 | 321 | 322 | 323 | 324 | 325 | 326 | Display columns 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 0 335 | 0 336 | 337 | 338 | 339 | true 340 | 341 | 342 | QComboBox::NoInsert 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 0 351 | 0 352 | 353 | 354 | 355 | 356 | 0 357 | 0 358 | 359 | 360 | 361 | 362 | 16777215 363 | 16777215 364 | 365 | 366 | 367 | border-bottom:0px; 368 | 369 | 370 | 371 | 372 | QLayout::SetNoConstraint 373 | 374 | 375 | 0 376 | 377 | 378 | 0 379 | 380 | 381 | 0 382 | 383 | 384 | 0 385 | 386 | 387 | 388 | 389 | true 390 | 391 | 392 | 393 | 4 394 | 0 395 | 396 | 397 | 398 | 399 | 400 | 401 | true 402 | 403 | 404 | QComboBox::NoInsert 405 | 406 | 407 | true 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 0 416 | 0 417 | 418 | 419 | 420 | 421 | 0 422 | 0 423 | 424 | 425 | 426 | 427 | 16777215 428 | 16777215 429 | 430 | 431 | 432 | 433 | 434 | 435 | Browse... 436 | 437 | 438 | 439 | 16 440 | 16 441 | 442 | 443 | 444 | true 445 | 446 | 447 | false 448 | 449 | 450 | false 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | Name 461 | 462 | 463 | 464 | 465 | 466 | 467 | Highlight colour 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 0 485 | 486 | 487 | 488 | 489 | Show bar info and hide it after 490 | 491 | 492 | 493 | 494 | 495 | 496 | seconds 497 | 498 | 499 | 1 500 | 501 | 502 | 9999 503 | 504 | 505 | 30 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | Auto-hide marker after 517 | 518 | 519 | 520 | 521 | 522 | 523 | seconds 524 | 525 | 526 | 1 527 | 528 | 529 | 5 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | Copy selected item info to clipboard 539 | 540 | 541 | 542 | 543 | 544 | 545 | 0 546 | 547 | 548 | 549 | 550 | Limit fetched results number to 551 | 552 | 553 | 554 | 555 | 556 | 557 | 1 558 | 559 | 560 | 9999999 561 | 562 | 563 | 1000 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | Qt::Horizontal 577 | 578 | 579 | QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | + 594 | 595 | 596 | 597 | 598 | 599 | 600 | - 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 0 612 | 613 | 614 | 615 | 616 | Data source type 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 0 625 | 0 626 | 627 | 628 | 629 | false 630 | 631 | 632 | QComboBox::NoInsert 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | Qt::Vertical 642 | 643 | 644 | 645 | 20 646 | 20 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | QgsColorButton 656 | QToolButton 657 |
qgscolorbutton.h
658 |
659 |
660 | 661 | buttonBox 662 | 663 | 664 | 665 | 666 | buttonBox 667 | accepted() 668 | ConfigDialog 669 | validate_and_accept() 670 | 671 | 672 | 257 673 | 732 674 | 675 | 676 | 157 677 | 274 678 | 679 | 680 | 681 | 682 | buttonBox 683 | rejected() 684 | ConfigDialog 685 | reject() 686 | 687 | 688 | 325 689 | 732 690 | 691 | 692 | 286 693 | 274 694 | 695 | 696 | 697 | 698 | 699 | validate_and_accept() 700 | clear_and_reject() 701 | 702 |
703 | -------------------------------------------------------------------------------- /Discovery/dbutils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Discovery Plugin 4 | # 5 | # Copyright (C) 2015 Lutra Consulting 6 | # info@lutraconsulting.co.uk 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | 13 | import re 14 | 15 | import psycopg2 16 | from qgis.core import QgsApplication, QgsAuthMethodConfig, QgsSettings 17 | 18 | from .utils import is_number 19 | 20 | 21 | def get_connection(conn_info): 22 | """Connect to the database using conn_info dict: 23 | { 'host': ..., 'port': ..., 'database': ..., 'username': ..., 'password': ... } 24 | """ 25 | conn = psycopg2.connect(**conn_info) 26 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 27 | return conn 28 | 29 | 30 | def get_postgres_connections(): 31 | """Read PostgreSQL connection names from QgsSettings stored by QGIS""" 32 | settings = QgsSettings() 33 | settings.beginGroup("/PostgreSQL/connections/") 34 | return settings.childGroups() 35 | 36 | 37 | """ 38 | def current_postgres_connection(): 39 | settings = QgsSettings() 40 | settings.beginGroup("/Discovery") 41 | return settings.value("connection", "", type=str) 42 | """ 43 | 44 | 45 | def get_postgres_conn_info(selected): 46 | """Read PostgreSQL connection details from QgsSettings stored by QGIS""" 47 | settings = QgsSettings() 48 | settings.beginGroup("/PostgreSQL/connections/" + selected) 49 | if not settings.contains("database"): # non-existent entry? 50 | return {} 51 | 52 | conn_info = dict() 53 | 54 | # Check if a service is provided 55 | service = settings.value("service", "", type=str) 56 | hasService = len(service) > 0 57 | if hasService: 58 | conn_info["service"] = service 59 | 60 | # password and username 61 | username = "" 62 | password = "" 63 | authconf = settings.value("authcfg", "") 64 | if authconf: 65 | # password encrypted in AuthManager 66 | auth_manager = QgsApplication.authManager() 67 | conf = QgsAuthMethodConfig() 68 | auth_manager.loadAuthenticationConfig(authconf, conf, True) 69 | if conf.id(): 70 | username = conf.config("username", "") 71 | password = conf.config("password", "") 72 | else: 73 | # basic (plain-text) settings 74 | username = settings.value("username", "", type=str) 75 | password = settings.value("password", "", type=str) 76 | 77 | # password and username could be stored in environment variables 78 | # if not present in AuthManager or plain-text settings, do not 79 | # add it to conn_info at all 80 | if len(username) > 0: 81 | conn_info["user"] = username 82 | if len(password) > 0: 83 | conn_info["password"] = password 84 | 85 | host = settings.value("host", "", type=str) 86 | database = settings.value("database", "", type=str) 87 | port = settings.value("port", "", type=str) 88 | 89 | # Prevent setting host, port or database to empty string or default value 90 | # It may by set in a provided service and would overload it 91 | if len(host) > 0: 92 | conn_info["host"] = host 93 | if len(database) > 0: 94 | conn_info["database"] = database 95 | if len(port) > 0: 96 | conn_info["port"] = int(port) 97 | 98 | return conn_info 99 | 100 | 101 | def _quote(identifier): 102 | """quote identifier""" 103 | return '"%s"' % identifier.replace('"', '""') 104 | 105 | 106 | def _quote_str(txt): 107 | """make the string safe - replace ' with ''""" 108 | return txt.replace("'", "''") 109 | 110 | 111 | def list_schemas(cursor): 112 | """Get list of schema names""" 113 | sql = "SELECT nspname FROM pg_namespace WHERE nspname !~ '^pg_' AND nspname != 'information_schema'" 114 | cursor.execute(sql) 115 | 116 | names = map(lambda row: row[0], cursor.fetchall()) 117 | return sorted(names) 118 | 119 | 120 | def list_tables(cursor, schema): 121 | sql = """SELECT pg_class.relname 122 | FROM pg_class 123 | JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace 124 | WHERE pg_class.relkind IN ('v', 'r', 'm') AND nspname = '%s' 125 | ORDER BY nspname, relname""" % _quote_str( 126 | schema 127 | ) 128 | cursor.execute(sql) 129 | names = map(lambda row: row[0], cursor.fetchall()) 130 | return sorted(names) 131 | 132 | 133 | def list_columns(cursor, schema, table): 134 | sql = """SELECT a.attname AS column_name 135 | FROM pg_class c 136 | JOIN pg_attribute a ON a.attrelid = c.oid 137 | JOIN pg_namespace nsp ON c.relnamespace = nsp.oid 138 | WHERE c.relname = '%s' AND nspname='%s' AND a.attnum > 0 139 | ORDER BY a.attnum""" % ( 140 | _quote_str(table), 141 | _quote_str(schema), 142 | ) 143 | cursor.execute(sql) 144 | names = map(lambda row: row[0], cursor.fetchall()) 145 | return sorted(names) 146 | 147 | 148 | def get_search_sql( 149 | search_text, 150 | geom_column, 151 | search_column, 152 | echo_search_column, 153 | display_columns, 154 | extra_expr_columns, 155 | schema, 156 | table, 157 | escape_spec_chars, 158 | limit, 159 | ): 160 | """Returns a tuple: (SQL query text, dictionary with values to replace variables with).""" 161 | 162 | """ 163 | Spaces in queries 164 | A query with spaces is executed as follows: 165 | 'my query' 166 | ILIKE '%my%query%' 167 | 168 | A note on spaces in postcodes 169 | Postcodes must be stored in the DB without spaces: 170 | 'DL10 4DQ' becomes 'DL104DQ' 171 | This allows users to query with or without spaces 172 | As wildcards are inserted at spaces, it doesn't matter whether the query is: 173 | 'dl10 4dq'; or 174 | 'dl104dq' 175 | """ 176 | 177 | # escape search text to allow \ backslash characters in search string 178 | # i.e. 1\TP => 1\\TP 179 | if escape_spec_chars: 180 | search_text = re.escape(search_text) 181 | 182 | wildcarded_search_string = "" 183 | for part in search_text.split(): 184 | wildcarded_search_string += "%" + part 185 | wildcarded_search_string += "%" 186 | query_dict = {"search_text": wildcarded_search_string} 187 | 188 | query_text = """ SELECT 189 | ST_AsText("%s") AS geom, 190 | ST_SRID("%s") AS epsg, 191 | """ % ( 192 | geom_column, 193 | geom_column, 194 | ) 195 | if echo_search_column: 196 | query_column_selection_text = ( 197 | """"%s" 198 | """ 199 | % search_column 200 | ) 201 | suggestion_string_seperator = ", " 202 | else: 203 | query_column_selection_text = """''""" 204 | suggestion_string_seperator = "" 205 | if len(display_columns) > 0: 206 | for display_column in display_columns.split(","): 207 | query_column_selection_text += """ || CASE WHEN "%s" IS NOT NULL THEN 208 | '%s' || "%s" 209 | ELSE 210 | '' 211 | END 212 | """ % ( 213 | display_column, 214 | suggestion_string_seperator, 215 | display_column, 216 | ) 217 | suggestion_string_seperator = ", " 218 | query_column_selection_text += """ AS suggestion_string """ 219 | if query_column_selection_text.startswith("'', "): 220 | query_column_selection_text = query_column_selection_text[4:] 221 | query_text += query_column_selection_text 222 | for extra_column in extra_expr_columns: 223 | query_text += ', "%s"' % extra_column 224 | query_text += """ 225 | FROM 226 | "%s"."%s" 227 | WHERE 228 | "%s"::text ILIKE 229 | """ % ( 230 | schema, 231 | table, 232 | search_column, 233 | ) 234 | query_text += """ %(search_text)s 235 | """ 236 | 237 | limit = "{}".format(int(limit)) if is_number(limit) else "1000" 238 | query_text += """ORDER BY 239 | "%s" 240 | LIMIT %s 241 | """ % ( 242 | search_column, 243 | limit, 244 | ) 245 | 246 | return query_text, query_dict 247 | -------------------------------------------------------------------------------- /Discovery/discovery_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lutraconsulting/qgis-discovery-plugin/47791e154df609aa9a1f9d473f256a9c6b964dca/Discovery/discovery_logo.png -------------------------------------------------------------------------------- /Discovery/discovery_logo_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lutraconsulting/qgis-discovery-plugin/47791e154df609aa9a1f9d473f256a9c6b964dca/Discovery/discovery_logo_64.png -------------------------------------------------------------------------------- /Discovery/discovery_ts.pro: -------------------------------------------------------------------------------- 1 | SOURCES = config_dialog.py\ 2 | discoveryplugin.py \ 3 | gpkg_utils.py \ 4 | locator_filter.py \ 5 | mssql_utils.py \ 6 | utils.py 7 | 8 | FORMS = config_dialog.ui 9 | 10 | TRANSLATIONS = i18n/discovery_ja.ts 11 | -------------------------------------------------------------------------------- /Discovery/discovery_ts.pro.qtds: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | EnvironmentId 7 | {f8846bc4-233b-4f0d-99a5-65c8e21a01d0} 8 | 9 | 10 | ProjectExplorer.Project.ActiveTarget 11 | 1 12 | 13 | 14 | ProjectExplorer.Project.EditorSettings 15 | 16 | true 17 | false 18 | true 19 | 20 | Cpp 21 | 22 | CppGlobal 23 | 24 | 25 | 26 | QmlJS 27 | 28 | QmlJSGlobal 29 | 30 | 31 | 2 32 | UTF-8 33 | false 34 | 4 35 | false 36 | 80 37 | true 38 | true 39 | 1 40 | false 41 | true 42 | false 43 | 0 44 | true 45 | true 46 | 0 47 | 8 48 | true 49 | false 50 | 1 51 | true 52 | true 53 | true 54 | *.md, *.MD, Makefile 55 | false 56 | true 57 | 58 | 59 | 60 | ProjectExplorer.Project.Target.0 61 | 62 | Desktop 63 | Desktop Qt 5.15.5 64 | Desktop Qt 5.15.5 65 | {8994bd34-5ed9-4c45-8c0a-94c8f33eca4a} 66 | 0 67 | 0 68 | 0 69 | 70 | 0 71 | C:\Users\ryu\Documents\GitHub\qgis-discovery-plugin\build-discovery_ts-Desktop_Qt_5_15_5-Debug 72 | C:/Users/ryu/Documents/GitHub/qgis-discovery-plugin/build-discovery_ts-Desktop_Qt_5_15_5-Debug 73 | 74 | 75 | true 76 | QtProjectManager.QMakeBuildStep 77 | false 78 | 79 | 80 | 81 | true 82 | Qt4ProjectManager.MakeStep 83 | 84 | 2 85 | Build 86 | Build 87 | ProjectExplorer.BuildSteps.Build 88 | 89 | 90 | 91 | true 92 | Qt4ProjectManager.MakeStep 93 | clean 94 | 95 | 1 96 | Clean 97 | Clean 98 | ProjectExplorer.BuildSteps.Clean 99 | 100 | 2 101 | false 102 | 103 | false 104 | 105 | Debug 106 | Qt4ProjectManager.Qt4BuildConfiguration 107 | 2 108 | 109 | 110 | C:\Users\ryu\Documents\GitHub\qgis-discovery-plugin\build-discovery_ts-Desktop_Qt_5_15_5-Release 111 | C:/Users/ryu/Documents/GitHub/qgis-discovery-plugin/build-discovery_ts-Desktop_Qt_5_15_5-Release 112 | 113 | 114 | true 115 | QtProjectManager.QMakeBuildStep 116 | false 117 | 118 | 119 | 120 | true 121 | Qt4ProjectManager.MakeStep 122 | 123 | 2 124 | Build 125 | Build 126 | ProjectExplorer.BuildSteps.Build 127 | 128 | 129 | 130 | true 131 | Qt4ProjectManager.MakeStep 132 | clean 133 | 134 | 1 135 | Clean 136 | Clean 137 | ProjectExplorer.BuildSteps.Clean 138 | 139 | 2 140 | false 141 | 142 | false 143 | 144 | Release 145 | Qt4ProjectManager.Qt4BuildConfiguration 146 | 0 147 | 0 148 | 149 | 150 | 0 151 | C:\Users\ryu\Documents\GitHub\qgis-discovery-plugin\build-discovery_ts-Desktop_Qt_5_15_5-Profile 152 | C:/Users/ryu/Documents/GitHub/qgis-discovery-plugin/build-discovery_ts-Desktop_Qt_5_15_5-Profile 153 | 154 | 155 | true 156 | QtProjectManager.QMakeBuildStep 157 | false 158 | 159 | 160 | 161 | true 162 | Qt4ProjectManager.MakeStep 163 | 164 | 2 165 | Build 166 | Build 167 | ProjectExplorer.BuildSteps.Build 168 | 169 | 170 | 171 | true 172 | Qt4ProjectManager.MakeStep 173 | clean 174 | 175 | 1 176 | Clean 177 | Clean 178 | ProjectExplorer.BuildSteps.Clean 179 | 180 | 2 181 | false 182 | 183 | false 184 | 185 | Profile 186 | Qt4ProjectManager.Qt4BuildConfiguration 187 | 0 188 | 0 189 | 0 190 | 191 | 3 192 | 193 | 194 | 0 195 | Deploy 196 | Deploy 197 | ProjectExplorer.BuildSteps.Deploy 198 | 199 | 1 200 | 201 | false 202 | ProjectExplorer.DefaultDeployConfiguration 203 | 204 | 1 205 | 206 | true 207 | 208 | 2 209 | 210 | ProjectExplorer.CustomExecutableRunConfiguration 211 | 212 | false 213 | true 214 | false 215 | true 216 | 217 | 1 218 | 219 | 220 | 221 | ProjectExplorer.Project.Target.1 222 | 223 | Desktop 224 | Desktop Qt 6.3.1 225 | Desktop Qt 6.3.1 226 | {63f87550-2541-4163-9631-08b7fea781da} 227 | 0 228 | 0 229 | 0 230 | 231 | 0 232 | C:\Users\ryu\Documents\GitHub\qgis-discovery-plugin\build-discovery_ts-Desktop_Qt_6_3_1-Debug 233 | C:/Users/ryu/Documents/GitHub/qgis-discovery-plugin/build-discovery_ts-Desktop_Qt_6_3_1-Debug 234 | 235 | 236 | true 237 | QtProjectManager.QMakeBuildStep 238 | false 239 | 240 | 241 | 242 | true 243 | Qt4ProjectManager.MakeStep 244 | 245 | 2 246 | Build 247 | Build 248 | ProjectExplorer.BuildSteps.Build 249 | 250 | 251 | 252 | true 253 | Qt4ProjectManager.MakeStep 254 | clean 255 | 256 | 1 257 | Clean 258 | Clean 259 | ProjectExplorer.BuildSteps.Clean 260 | 261 | 2 262 | false 263 | 264 | false 265 | 266 | Debug 267 | Qt4ProjectManager.Qt4BuildConfiguration 268 | 2 269 | 270 | 271 | C:\Users\ryu\Documents\GitHub\qgis-discovery-plugin\build-discovery_ts-Desktop_Qt_6_3_1-Release 272 | C:/Users/ryu/Documents/GitHub/qgis-discovery-plugin/build-discovery_ts-Desktop_Qt_6_3_1-Release 273 | 274 | 275 | true 276 | QtProjectManager.QMakeBuildStep 277 | false 278 | 279 | 280 | 281 | true 282 | Qt4ProjectManager.MakeStep 283 | 284 | 2 285 | Build 286 | Build 287 | ProjectExplorer.BuildSteps.Build 288 | 289 | 290 | 291 | true 292 | Qt4ProjectManager.MakeStep 293 | clean 294 | 295 | 1 296 | Clean 297 | Clean 298 | ProjectExplorer.BuildSteps.Clean 299 | 300 | 2 301 | false 302 | 303 | false 304 | 305 | Release 306 | Qt4ProjectManager.Qt4BuildConfiguration 307 | 0 308 | 0 309 | 310 | 311 | 0 312 | C:\Users\ryu\Documents\GitHub\qgis-discovery-plugin\build-discovery_ts-Desktop_Qt_6_3_1-Profile 313 | C:/Users/ryu/Documents/GitHub/qgis-discovery-plugin/build-discovery_ts-Desktop_Qt_6_3_1-Profile 314 | 315 | 316 | true 317 | QtProjectManager.QMakeBuildStep 318 | false 319 | 320 | 321 | 322 | true 323 | Qt4ProjectManager.MakeStep 324 | 325 | 2 326 | Build 327 | Build 328 | ProjectExplorer.BuildSteps.Build 329 | 330 | 331 | 332 | true 333 | Qt4ProjectManager.MakeStep 334 | clean 335 | 336 | 1 337 | Clean 338 | Clean 339 | ProjectExplorer.BuildSteps.Clean 340 | 341 | 2 342 | false 343 | 344 | false 345 | 346 | Profile 347 | Qt4ProjectManager.Qt4BuildConfiguration 348 | 0 349 | 0 350 | 0 351 | 352 | 3 353 | 354 | 355 | 0 356 | Deploy 357 | Deploy 358 | ProjectExplorer.BuildSteps.Deploy 359 | 360 | 1 361 | 362 | false 363 | ProjectExplorer.DefaultDeployConfiguration 364 | 365 | 1 366 | 367 | true 368 | 369 | 2 370 | 371 | ProjectExplorer.CustomExecutableRunConfiguration 372 | 373 | false 374 | true 375 | false 376 | true 377 | 378 | 1 379 | 380 | 381 | 382 | ProjectExplorer.Project.TargetCount 383 | 2 384 | 385 | 386 | ProjectExplorer.Project.Updater.FileVersion 387 | 22 388 | 389 | 390 | Version 391 | 22 392 | 393 | 394 | -------------------------------------------------------------------------------- /Discovery/discoveryplugin.py: -------------------------------------------------------------------------------- 1 | # Discovery Plugin 2 | # 3 | # Copyright (C) 2020 Lutra Consulting 4 | # info@lutraconsulting.co.uk 5 | # 6 | # Thanks to Tim Martin of Ordnance Survey for his original PostGIS Search 7 | # plugin which inspired and formed the foundation of Discovery. 8 | # 9 | # This program is free software; you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 2 of the License, or 12 | # (at your option) any later version. 13 | 14 | import os.path 15 | import time 16 | 17 | import psycopg2 18 | from PyQt5.QtCore import QCoreApplication, QModelIndex, QSettings, Qt, QTimer, QTranslator, QVariant 19 | from PyQt5.QtGui import QColor, QIcon 20 | from PyQt5.QtWidgets import QAction, QApplication, QComboBox, QCompleter, QMessageBox 21 | from qgis.core import ( 22 | Qgis, 23 | QgsCoordinateReferenceSystem, 24 | QgsCoordinateTransform, 25 | QgsExpression, 26 | QgsExpressionContext, 27 | QgsFeature, 28 | QgsField, 29 | QgsFields, 30 | QgsGeometry, 31 | QgsRectangle, 32 | QgsSettings, 33 | QgsVectorLayer, 34 | QgsWkbTypes, 35 | ) 36 | from qgis.gui import QgsFilterLineEdit, QgsRubberBand, QgsVertexMarker 37 | from qgis.utils import iface 38 | 39 | from Discovery import gpkg_utils, mssql_utils, oracle_utils 40 | 41 | from . import config_dialog, dbutils, locator_filter 42 | 43 | 44 | def eval_expression(expr_text, extra_data, default=None): 45 | """Helper method to evaluate an expression. E.g. 46 | eval_expression("1+a", {"a": 2}) will return 3 47 | """ 48 | if expr_text is None or len(expr_text) == 0: 49 | return default 50 | 51 | flds = QgsFields() 52 | for extra_col, extra_value in extra_data.items(): 53 | if isinstance(extra_value, int): 54 | t = QVariant.Int 55 | elif isinstance(extra_value, float): 56 | t = QVariant.Double 57 | else: 58 | t = QVariant.String 59 | flds.append(QgsField(extra_col, t)) 60 | f = QgsFeature(flds) 61 | for extra_col, extra_value in extra_data.items(): 62 | f[extra_col] = extra_value 63 | expr = QgsExpression(expr_text) 64 | ctx = QgsExpressionContext() 65 | ctx.setFeature(f) 66 | res = expr.evaluate(ctx) 67 | return default if expr.hasEvalError() else res 68 | 69 | 70 | def bbox_str_to_rectangle(bbox_str): 71 | """Helper method to convert "xmin,ymin,xmax,ymax" to QgsRectangle - or return None on error""" 72 | if bbox_str is None or len(bbox_str) == 0: 73 | return None 74 | 75 | coords = bbox_str.split(",") 76 | if len(coords) != 4: 77 | return None 78 | 79 | try: 80 | xmin = float(coords[0]) 81 | ymin = float(coords[1]) 82 | xmax = float(coords[2]) 83 | ymax = float(coords[3]) 84 | return QgsRectangle(xmin, ymin, xmax, ymax) 85 | except ValueError: 86 | return None 87 | 88 | 89 | def delete_config_from_settings(key, settings): 90 | settings.remove(key + "data_type") 91 | settings.remove(key + "file") 92 | settings.remove(key + "connection") 93 | settings.remove(key + "schema") 94 | settings.remove(key + "table") 95 | settings.remove(key + "search_column") 96 | settings.remove(key + "escape_spec_chars") 97 | settings.remove(key + "echo_search_column") 98 | settings.remove(key + "display_columns") 99 | settings.remove(key + "geom_column") 100 | settings.remove(key + "scale_expr") 101 | settings.remove(key + "bbox_expr") 102 | 103 | 104 | class DiscoveryPlugin: 105 | 106 | def __init__(self, _iface): 107 | # Save reference to the QGIS interface 108 | self.iface = _iface 109 | # initialize plugin directory 110 | self.plugin_dir = os.path.dirname(__file__) 111 | 112 | # Localize 113 | locale = QSettings().value("locale/userLocale")[0:2] 114 | localePath = os.path.join(self.plugin_dir, "i18n", "discovery_{}.qm".format(locale)) 115 | if os.path.exists(localePath): 116 | self.translator = QTranslator() 117 | self.translator.load(localePath) 118 | QCoreApplication.installTranslator(self.translator) 119 | 120 | # Variables to facilitate delayed queries and database connection management 121 | self.db_timer = QTimer() 122 | self.line_edit_timer = QTimer() 123 | self.line_edit_timer.setSingleShot(True) 124 | self.line_edit_timer.timeout.connect(self.reset_line_edit_after_move) 125 | self.next_query_time = None 126 | self.last_query_time = time.time() 127 | self.db_conn = None 128 | self.search_delay = 0.5 # s 129 | self.query_sql = "" 130 | self.query_text = "" 131 | self.query_dict = {} 132 | self.db_idle_time = 60.0 # s 133 | self.display_time = 5000 # ms 134 | self.bar_info_time = 30 # s 135 | 136 | self.search_results = [] 137 | self.limit_results = 1000 138 | self.tool_bar = None 139 | self.search_line_edit = None 140 | self.completer = None 141 | self.conn_info = {} 142 | 143 | self.marker = QgsVertexMarker(iface.mapCanvas()) 144 | self.marker.setIconSize(15) 145 | self.marker.setPenWidth(2) 146 | self.marker.setColor(QColor(226, 27, 28)) # 51,160,44)) 147 | self.marker.setZValue(11) 148 | self.marker.setVisible(False) 149 | self.marker2 = QgsVertexMarker(iface.mapCanvas()) 150 | self.marker2.setIconSize(16) 151 | self.marker2.setPenWidth(4) 152 | self.marker2.setColor(QColor(255, 255, 255, 200)) 153 | self.marker2.setZValue(10) 154 | self.marker2.setVisible(False) 155 | self.is_displayed = False 156 | 157 | self.rubber_band = QgsRubberBand(iface.mapCanvas(), QgsWkbTypes.PolygonGeometry) 158 | self.rubber_band.setVisible(False) 159 | self.rubber_band.setWidth(3) 160 | self.rubber_band.setStrokeColor(QColor(226, 27, 28)) 161 | self.rubber_band.setFillColor(QColor(226, 27, 28, 63)) 162 | 163 | def initGui(self): 164 | 165 | # Create a new toolbar 166 | self.tool_bar = self.iface.addToolBar("Discovery") 167 | self.tool_bar.setObjectName("Discovery_Plugin") 168 | 169 | # Create action that will start plugin configuration 170 | self.action_config = QAction( 171 | QIcon(os.path.join(self.plugin_dir, "discovery_logo.png")), "Configure Discovery", self.tool_bar 172 | ) 173 | self.action_config.triggered.connect(self.show_config_dialog) 174 | self.tool_bar.addAction(self.action_config) 175 | 176 | # Add combobox for configs 177 | self.config_combo = QComboBox() 178 | settings = QgsSettings() 179 | settings.beginGroup("/Discovery") 180 | config_list = settings.value("config_list") 181 | 182 | if config_list: 183 | for conf in config_list: 184 | self.config_combo.addItem(conf) 185 | elif settings.childGroups(): 186 | # support for prev version 187 | key = "Config1" 188 | config_list = [] 189 | config_list.append(key) 190 | settings.setValue("config_list", config_list) 191 | self.config_combo.addItem(key) 192 | 193 | settings.setValue(key + "data_type", settings.value("data_type")) 194 | settings.setValue(key + "file", settings.value("file")) 195 | settings.setValue(key + "connection", settings.value("connection")) 196 | settings.setValue(key + "schema", settings.value("schema")) 197 | settings.setValue(key + "table", settings.value("table")) 198 | settings.setValue(key + "search_column", settings.value("search_column")) 199 | settings.setValue(key + "escape_spec_chars", settings.value("escape_spec_chars")) 200 | settings.setValue(key + "echo_search_column", settings.value("echo_search_column")) 201 | settings.setValue(key + "display_columns", settings.value("display_columns")) 202 | settings.setValue(key + "geom_column", settings.value("geom_column")) 203 | settings.setValue(key + "scale_expr", settings.value("scale_expr")) 204 | settings.setValue(key + "bbox_expr", settings.value("bbox_expr")) 205 | 206 | delete_config_from_settings("", settings) 207 | self.tool_bar.addWidget(self.config_combo) 208 | 209 | # Add search edit box 210 | self.search_line_edit = QgsFilterLineEdit() 211 | self.search_line_edit.setPlaceholderText("Search for...") 212 | self.search_line_edit.setMaximumWidth(768) 213 | self.tool_bar.addWidget(self.search_line_edit) 214 | 215 | self.config_combo.currentIndexChanged.connect(self.change_configuration) 216 | 217 | # Set up the completer 218 | self.completer = QCompleter([]) # Initialise with en empty list 219 | self.completer.setCaseSensitivity(Qt.CaseInsensitive) 220 | self.completer.setMaxVisibleItems(1000) 221 | self.completer.setModelSorting(QCompleter.UnsortedModel) # Sorting done in PostGIS 222 | self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # Show all fetched possibilities 223 | self.completer.activated[QModelIndex].connect(self.on_result_selected) 224 | self.completer.highlighted[QModelIndex].connect(self.on_result_highlighted) 225 | self.search_line_edit.setCompleter(self.completer) 226 | 227 | # Connect any signals 228 | self.search_line_edit.textEdited.connect(self.on_search_text_changed) 229 | 230 | # Search results 231 | self.search_results = [] 232 | 233 | # Set up a timer to periodically perform db queries as required 234 | self.db_timer.timeout.connect(self.do_db_operations) 235 | self.db_timer.start(100) 236 | 237 | # Read config 238 | self.read_config(config_list[0] if config_list else "") 239 | 240 | self.locator_filter = locator_filter.DiscoveryLocatorFilter(self) 241 | self.iface.registerLocatorFilter(self.locator_filter) 242 | 243 | # Debug 244 | # import pydevd; pydevd.settrace('localhost', port=5678) 245 | 246 | def unload(self): 247 | # Stop timer 248 | self.db_timer.stop() 249 | # Disconnect any signals 250 | self.db_timer.timeout.disconnect(self.do_db_operations) 251 | self.completer.highlighted[QModelIndex].disconnect(self.on_result_highlighted) 252 | self.completer.activated[QModelIndex].disconnect(self.on_result_selected) 253 | self.search_line_edit.textEdited.disconnect(self.on_search_text_changed) 254 | # Remove the new toolbar 255 | self.tool_bar.clear() # Clear all actions 256 | self.iface.mainWindow().removeToolBar(self.tool_bar) 257 | 258 | self.iface.deregisterLocatorFilter(self.locator_filter) 259 | self.locator_filter = None 260 | 261 | def clear_suggestions(self): 262 | model = self.completer.model() 263 | model.setStringList([]) 264 | 265 | def on_search_text_changed(self, new_search_text): 266 | """ 267 | This function is called whenever the user modified the search text 268 | 269 | 1. Open a database connection 270 | 2. Make the query 271 | 3. Update the QStringListModel with these results 272 | 4. Store the other details in self.search_results 273 | """ 274 | 275 | self.query_text = new_search_text 276 | 277 | if len(new_search_text) < 3: 278 | # Clear any previous suggestions in case the user is 'backspacing' 279 | self.clear_suggestions() 280 | return 281 | 282 | if self.data_type == "postgres": 283 | query_text, query_dict = dbutils.get_search_sql( 284 | new_search_text, 285 | self.postgisgeomcolumn, 286 | self.postgissearchcolumn, 287 | self.echosearchcolumn, 288 | self.postgisdisplaycolumn, 289 | self.extra_expr_columns, 290 | self.postgisschema, 291 | self.postgistable, 292 | self.escapespecchars, 293 | self.limit_results, 294 | ) 295 | self.schedule_search(query_text, query_dict) 296 | 297 | elif self.data_type == "gpkg": 298 | query_text = ( 299 | new_search_text, 300 | self.postgissearchcolumn, 301 | self.echosearchcolumn, 302 | self.postgisdisplaycolumn.split(","), 303 | self.extra_expr_columns, 304 | self.layer, 305 | self.limit_results, 306 | ) 307 | self.schedule_search(query_text, None) 308 | 309 | elif self.data_type == "mssql": 310 | query_text = mssql_utils.get_search_sql( 311 | new_search_text, 312 | self.postgisgeomcolumn, 313 | self.postgissearchcolumn, 314 | self.echosearchcolumn, 315 | self.postgisdisplaycolumn, 316 | self.extra_expr_columns, 317 | self.postgisschema, 318 | self.postgistable, 319 | self.limit_results, 320 | ) 321 | self.schedule_search(query_text, None) 322 | 323 | elif self.data_type == "oracle": 324 | query_text = oracle_utils.get_search_sql( 325 | new_search_text, 326 | self.postgisgeomcolumn, 327 | self.postgissearchcolumn, 328 | self.echosearchcolumn, 329 | self.postgisdisplaycolumn, 330 | self.extra_expr_columns, 331 | self.postgisschema, 332 | self.postgistable, 333 | self.limit_results, 334 | ) 335 | self.schedule_search(query_text, None) 336 | 337 | def do_db_operations(self): 338 | if self.next_query_time is not None and self.next_query_time < time.time(): 339 | # It's time to run a query 340 | self.next_query_time = None # Prevent this query from being repeated 341 | self.last_query_time = time.time() 342 | self.perform_search() 343 | else: 344 | # We're not performing a query, close the db connection if it's been open for > 60s 345 | if time.time() > self.last_query_time + self.db_idle_time: 346 | self.db_conn = None 347 | 348 | def perform_search(self): 349 | db = self.get_db() 350 | if db is None and self.data_type != "gpkg": 351 | return 352 | 353 | self.search_results = [] 354 | suggestions = [] 355 | if self.data_type == "postgres": 356 | cur = db.cursor() 357 | try: 358 | cur.execute(self.query_sql, self.query_dict) 359 | except psycopg2.Error as e: 360 | err_info = "Failed to execute the search query. Please, check your settings. Error message:\n\n" 361 | err_info += "{}".format(e.pgerror) 362 | QMessageBox.critical(None, "Discovery", err_info) 363 | return 364 | result_set = cur.fetchall() 365 | elif self.data_type == "mssql": 366 | result_set = mssql_utils.execute(db, self.query_sql) 367 | elif self.data_type == "oracle": 368 | result_set = oracle_utils.execute(db, self.query_sql) 369 | elif self.data_type == "gpkg": 370 | result_set = gpkg_utils.search_gpkg(*self.query_sql) 371 | 372 | for row in result_set: 373 | geom, epsg, suggestion_text = row[0], row[1], row[2] 374 | extra_data = {} 375 | for idx, extra_col in enumerate(self.extra_expr_columns): 376 | extra_data[extra_col] = row[3 + idx] 377 | self.search_results.append((geom, epsg, suggestion_text, extra_data)) 378 | suggestions.append(suggestion_text) 379 | model = self.completer.model() 380 | model.setStringList(suggestions) 381 | self.completer.complete() 382 | 383 | def schedule_search(self, query_text, query_dict): 384 | # Update the search text and the time after which the query should be executed 385 | self.query_sql = query_text 386 | self.query_dict = query_dict 387 | self.next_query_time = time.time() + self.search_delay 388 | 389 | def show_bar_info(self, info_text): 390 | """Optional show info bar message with selected result information""" 391 | self.iface.messageBar().clearWidgets() 392 | if self.bar_info_time: 393 | self.iface.messageBar().pushMessage("Discovery", info_text, level=Qgis.Info, duration=self.bar_info_time) 394 | 395 | def on_result_selected(self, result_index): 396 | # What to do when the user makes a selection 397 | self.select_result(self.search_results[result_index.row()]) 398 | 399 | def select_result(self, result_data): 400 | geometry_text, src_epsg, suggestion_text, extra_data = result_data 401 | location_geom = QgsGeometry.fromWkt(geometry_text) 402 | location_geom_type = location_geom.type() 403 | if location_geom_type in {QgsWkbTypes.UnknownGeometry, QgsWkbTypes.NullGeometry}: 404 | # Unknown geometry or no geometry at all 405 | pass 406 | else: 407 | canvas = self.iface.mapCanvas() 408 | dst_srid = canvas.mapSettings().destinationCrs().authid() 409 | transform = QgsCoordinateTransform( 410 | QgsCoordinateReferenceSystem.fromEpsgId(int(src_epsg)), 411 | QgsCoordinateReferenceSystem(dst_srid), 412 | canvas.mapSettings().transformContext(), 413 | ) 414 | # Ensure the geometry from the DB is reprojected to the same SRID as the map canvas 415 | location_geom.transform(transform) 416 | location_centroid = location_geom.centroid().asPoint() 417 | 418 | # show temporary marker 419 | if location_geom_type == QgsWkbTypes.PointGeometry: 420 | self.show_marker(location_centroid) 421 | elif location_geom_type == QgsWkbTypes.LineGeometry or location_geom_type == QgsWkbTypes.PolygonGeometry: 422 | self.show_line_rubber_band(location_geom) 423 | else: 424 | # unsupported geometry type 425 | pass 426 | 427 | # Adjust map canvas extent 428 | zoom_method = "Move and Zoom" 429 | if zoom_method == "Move and Zoom": 430 | # with higher priority try to use exact bounding box to zoom to features (if provided) 431 | bbox_str = eval_expression(self.bbox_expr, extra_data) 432 | rect = bbox_str_to_rectangle(bbox_str) 433 | if rect is not None: 434 | # transform the rectangle in case of OTF projection 435 | rect = transform.transformBoundingBox(rect) 436 | else: 437 | # bbox is not available - so let's just use defined scale 438 | # compute target scale. If the result is 2000 this means the target scale is 1:2000 439 | rect = location_geom.boundingBox() 440 | if rect.isEmpty(): 441 | scale_denom = eval_expression(self.scale_expr, extra_data, default=2000.0) 442 | rect = canvas.mapSettings().extent() 443 | rect.scale(scale_denom / canvas.scale(), location_centroid) 444 | else: 445 | # enlarge geom bbox to have some margin 446 | rect.scale(1.2) 447 | canvas.setExtent(rect) 448 | elif zoom_method == "Move": 449 | current_extent = QgsGeometry.fromRect(self.iface.mapCanvas().extent()) 450 | dx = location_centroid.x() - location_centroid.x() 451 | dy = location_centroid.y() - location_centroid.y() 452 | current_extent.translate(dx, dy) 453 | canvas.setExtent(current_extent.boundingBox()) 454 | canvas.refresh() 455 | self.line_edit_timer.start(0) 456 | if self.info_to_clipboard: 457 | QApplication.clipboard().setText(suggestion_text) 458 | suggestion_text += " (copied to clipboard)" 459 | self.show_bar_info(suggestion_text) 460 | 461 | def on_result_highlighted(self, result_idx): 462 | self.line_edit_timer.start(0) 463 | 464 | def reset_line_edit_after_move(self): 465 | self.search_line_edit.setText(self.query_text) 466 | 467 | def get_db(self): 468 | # Create a new connection if required 469 | QApplication.setOverrideCursor(Qt.WaitCursor) 470 | if self.db_conn is None: 471 | if self.data_type == "postgres": 472 | try: 473 | self.db_conn = dbutils.get_connection(self.conn_info) 474 | except psycopg2.Error as e: 475 | err_info = "Failed to connect to the server. Error message:\n\n" 476 | err_info += f"{e.pgerror} - {e}" 477 | QMessageBox.critical(None, "Discovery", err_info) 478 | QApplication.restoreOverrideCursor() 479 | return 480 | elif self.data_type == "mssql": 481 | self.db_conn = mssql_utils.get_mssql_conn(self.conn_info) 482 | elif self.data_type == "oracle": 483 | self.db_conn = oracle_utils.get_oracle_conn(self.conn_info) 484 | QApplication.restoreOverrideCursor() 485 | return self.db_conn 486 | 487 | def change_configuration(self): 488 | self.search_line_edit.setText("") 489 | self.line_edit_timer.start(0) 490 | self.read_config(self.config_combo.currentText()) 491 | 492 | def read_config(self, key=""): 493 | # the following code reads the configuration file which setups the plugin to search in the correct database, 494 | # table and method 495 | 496 | settings = QgsSettings() 497 | settings.beginGroup("/Discovery") 498 | 499 | connection = settings.value(key + "connection", "", type=str) 500 | self.data_type = settings.value(key + "data_type", "", type=str) 501 | self.file = settings.value(key + "file", "", type=str) 502 | self.postgisschema = settings.value(key + "schema", "", type=str) 503 | self.postgistable = settings.value(key + "table", "", type=str) 504 | self.postgissearchcolumn = settings.value(key + "search_column", "", type=str) 505 | self.escapespecchars = settings.value(key + "escape_spec_chars", False, type=bool) 506 | self.echosearchcolumn = settings.value(key + "echo_search_column", True, type=bool) 507 | self.postgisdisplaycolumn = settings.value(key + "display_columns", "", type=str) 508 | self.postgisgeomcolumn = settings.value(key + "geom_column", "", type=str) 509 | if settings.value("marker_time_enabled", True, type=bool): 510 | self.display_time = settings.value("marker_time", 5000, type=int) 511 | else: 512 | self.display_time = -1 513 | if settings.value("bar_info_time_enabled", True, type=bool): 514 | self.bar_info_time = settings.value("bar_info_time", 30, type=int) 515 | else: 516 | self.bar_info_time = 0 517 | self.limit_results = settings.value(key + "limit_results", 1000, type=int) 518 | self.info_to_clipboard = settings.value("info_to_clipboard", True, type=bool) 519 | 520 | scale_expr = settings.value(key + "scale_expr", "", type=str) 521 | bbox_expr = settings.value(key + "bbox_expr", "", type=str) 522 | 523 | m_color = QColor() 524 | m_color_name = settings.value(key + "highlight_color", "#e21b1c", type=str) 525 | m_color.setNamedColor(m_color_name) 526 | self.marker.setColor(m_color) 527 | self.rubber_band.setStrokeColor(m_color) 528 | f_color = m_color 529 | f_color.setAlpha(63) 530 | self.rubber_band.setFillColor(f_color) 531 | 532 | if self.is_displayed: 533 | self.hide_marker() 534 | self.hide_rubber_band() 535 | self.is_displayed = False 536 | 537 | self.make_enabled(False) # assume the config is invalid first 538 | 539 | self.db_conn = None 540 | if self.data_type == "postgres": 541 | self.conn_info = dbutils.get_postgres_conn_info(connection) 542 | self.layer = None 543 | 544 | if ( 545 | len(connection) == 0 546 | or len(self.postgisschema) == 0 547 | or len(self.postgistable) == 0 548 | or len(self.postgissearchcolumn) == 0 549 | or len(self.postgisgeomcolumn) == 0 550 | ): 551 | return 552 | 553 | if len(self.conn_info) == 0: 554 | iface.messageBar().pushMessage( 555 | "Discovery", "The database connection '%s' does not exist!" % connection, level=Qgis.Critical 556 | ) 557 | return 558 | elif self.data_type == "mssql": 559 | self.conn_info = mssql_utils.get_mssql_conn_info(connection) 560 | self.layer = None 561 | 562 | if ( 563 | len(connection) == 0 564 | or len(self.postgisschema) == 0 565 | or len(self.postgistable) == 0 566 | or len(self.postgissearchcolumn) == 0 567 | or len(self.postgisgeomcolumn) == 0 568 | ): 569 | return 570 | 571 | if len(self.conn_info) == 0: 572 | iface.messageBar().pushMessage( 573 | "Discovery", "The database connection '%s' does not exist!" % connection, level=Qgis.Critical 574 | ) 575 | return 576 | elif self.data_type == "oracle": 577 | self.conn_info = oracle_utils.get_oracle_conn_info(connection) 578 | self.layer = None 579 | 580 | if ( 581 | len(connection) == 0 582 | or len(self.postgisschema) == 0 583 | or len(self.postgistable) == 0 584 | or len(self.postgissearchcolumn) == 0 585 | or len(self.postgisgeomcolumn) == 0 586 | ): 587 | return 588 | 589 | if len(self.conn_info) == 0: 590 | iface.messageBar().pushMessage( 591 | "Discovery", "The database connection '%s' does not exist!" % connection, level=Qgis.Critical 592 | ) 593 | return 594 | elif self.data_type == "gpkg": 595 | self.layer = QgsVectorLayer(self.file + "|layername=" + self.postgistable, self.postgistable, "ogr") 596 | self.conn_info = None 597 | self.extra_expr_columns = [] 598 | self.scale_expr = None 599 | self.bbox_expr = None 600 | 601 | self.make_enabled(True) 602 | 603 | # optional scale expression when zooming in to results 604 | if len(scale_expr) != 0: 605 | expr = QgsExpression(scale_expr) 606 | if expr.hasParserError(): 607 | iface.messageBar().pushMessage( 608 | "Discovery", "Invalid scale expression: " + expr.parserErrorString(), level=Qgis.Warning 609 | ) 610 | else: 611 | self.scale_expr = scale_expr 612 | self.extra_expr_columns += expr.referencedColumns() 613 | 614 | # optional bbox expression when zooming in to results 615 | if len(bbox_expr) != 0: 616 | expr = QgsExpression(bbox_expr) 617 | if expr.hasParserError(): 618 | iface.messageBar().pushMessage( 619 | "Discovery", "Invalid bbox expression: " + expr.parserErrorString(), level=Qgis.Warning 620 | ) 621 | else: 622 | self.bbox_expr = bbox_expr 623 | self.extra_expr_columns += expr.referencedColumns() 624 | 625 | def show_config_dialog(self): 626 | dlg = config_dialog.ConfigDialog() 627 | if self.config_combo.currentIndex() >= 0: 628 | dlg.configOptions.setCurrentIndex(self.config_combo.currentIndex()) 629 | 630 | if dlg.exec_(): 631 | dlg.write_config() 632 | self.config_combo.clear() 633 | for key in [dlg.configOptions.itemText(i) for i in range(dlg.configOptions.count())]: 634 | self.config_combo.addItem(key) 635 | 636 | self.config_combo.setCurrentIndex(dlg.configOptions.currentIndex()) 637 | self.change_configuration() 638 | 639 | def make_enabled(self, enabled): 640 | self.search_line_edit.setEnabled(enabled) 641 | self.search_line_edit.setPlaceholderText("Search for..." if enabled else "Search disabled: check configuration") 642 | 643 | def show_marker(self, point): 644 | for m in [self.marker, self.marker2]: 645 | m.setCenter(point) 646 | m.setOpacity(1.0) 647 | m.setVisible(True) 648 | if self.display_time == -1: 649 | self.is_displayed = True 650 | else: 651 | QTimer.singleShot(self.display_time, self.hide_marker) 652 | 653 | def hide_marker(self): 654 | opacity = self.marker.opacity() 655 | if opacity > 0.0: 656 | # produce a fade out effect 657 | opacity -= 0.1 658 | self.marker.setOpacity(opacity) 659 | self.marker2.setOpacity(opacity) 660 | QTimer.singleShot(100, self.hide_marker) 661 | else: 662 | self.marker.setVisible(False) 663 | self.marker2.setVisible(False) 664 | 665 | def show_line_rubber_band(self, geom): 666 | self.rubber_band.reset(geom.type()) 667 | self.rubber_band.setToGeometry(geom, None) 668 | self.rubber_band.setVisible(True) 669 | self.rubber_band.setOpacity(1.0) 670 | self.rubber_band.show() 671 | if self.display_time == -1: 672 | self.is_displayed = True 673 | else: 674 | QTimer.singleShot(self.display_time, self.hide_rubber_band) 675 | pass 676 | 677 | def hide_rubber_band(self): 678 | opacity = self.rubber_band.opacity() 679 | if opacity > 0.0: 680 | # produce a fade out effect 681 | opacity -= 0.1 682 | self.rubber_band.setOpacity(opacity) 683 | QTimer.singleShot(100, self.hide_rubber_band) 684 | else: 685 | self.rubber_band.setVisible(False) 686 | self.rubber_band.hide() 687 | -------------------------------------------------------------------------------- /Discovery/gpkg_utils.py: -------------------------------------------------------------------------------- 1 | from osgeo import gdal, ogr 2 | from qgis.core import QgsExpression, QgsFeatureRequest, QgsMessageLog, QgsVectorLayer 3 | 4 | from .utils import is_number 5 | 6 | 7 | def list_gpkg_layers(pckg_path): 8 | if not pckg_path: 9 | return [] 10 | 11 | layer_names = [] 12 | ds = gdal.OpenEx(pckg_path) 13 | lyr_count = ds.GetLayerCount() 14 | for i in range(lyr_count): 15 | lyr = ds.GetLayer(i) 16 | if lyr.GetGeomType() == ogr.wkbNone: 17 | continue 18 | lyr_name = lyr.GetName() 19 | layer_names.append(lyr_name) 20 | ds = None 21 | return layer_names 22 | 23 | 24 | def list_gpkg_fields(gpkg_path, name, bar_warning=None): 25 | try: 26 | layer = QgsVectorLayer(gpkg_path + "|layername=" + name, name, "ogr") 27 | fields = layer.fields() 28 | columns = [] 29 | for f in fields: 30 | columns.append(f.name()) 31 | return columns 32 | except RuntimeError as e: 33 | if bar_warning: 34 | bar_warning("Cannot read GeoPackage layer!") 35 | return [] 36 | 37 | 38 | def search_gpkg(search_text, search_field, echo_search_column, display_fields, extra_expr_columns, layer, limit): 39 | wildcarded_search_string = "" 40 | for part in search_text.split(): 41 | wildcarded_search_string += "%" + part 42 | wildcarded_search_string += "%" 43 | expr_str = "{0} ILIKE '{1}'".format(search_field, wildcarded_search_string) 44 | expr = QgsExpression(expr_str) 45 | req = QgsFeatureRequest(expr) 46 | limit = limit if is_number(limit) else None 47 | if limit: 48 | req.setLimit(int(limit)) 49 | it = layer.getFeatures(req) 50 | result = [] 51 | 52 | for f in it: 53 | feature_info = [] 54 | geom = f.geometry().asWkt() 55 | 56 | crs_auth_id = layer.crs().authid() 57 | try: 58 | # only the plain integer code is wanted later on 59 | epsg = int(crs_auth_id.lstrip("EPSG:")) 60 | except ValueError: 61 | QgsMessageLog.logMessage(f"{crs_auth_id} is not an EPSG code.", "Discovery") 62 | return [] 63 | 64 | feature_info.append(geom) 65 | feature_info.append(epsg) 66 | available_fields = [field.name() for field in f.fields()] 67 | 68 | display_info = [] 69 | if echo_search_column: 70 | display_info.append(str(f[search_field])) 71 | for field_name in display_fields: 72 | if f[field_name]: 73 | display_info.append(str(f[field_name])) 74 | feature_info.append(", ".join(display_info)) 75 | 76 | for field_name in extra_expr_columns: 77 | if field_name in available_fields: 78 | feature_info.append(f[field_name]) 79 | else: 80 | feature_info.append("") 81 | result.append(feature_info) 82 | return result 83 | -------------------------------------------------------------------------------- /Discovery/i18n/discovery_ja.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lutraconsulting/qgis-discovery-plugin/47791e154df609aa9a1f9d473f256a9c6b964dca/Discovery/i18n/discovery_ja.qm -------------------------------------------------------------------------------- /Discovery/i18n/discovery_ja.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ConfigDialog 6 | 7 | 8 | Configuration 9 | 設定 10 | 11 | 12 | 13 | Echo search column in results 14 | 検索結果に検索カラムを表示する 15 | 16 | 17 | 18 | File 19 | ファイル 20 | 21 | 22 | 23 | Search column 24 | 検索カラム 25 | 26 | 27 | 28 | BBOX expression 29 | BBOXの表現 30 | 31 | 32 | 33 | Scale expression 34 | スケール感 35 | 36 | 37 | 38 | Schema 39 | スキーマ 40 | 41 | 42 | 43 | Escape special characters in search text 44 | 検索テキストに含まれる特殊文字のエスケープ 45 | 46 | 47 | 48 | Table 49 | テーブル 50 | 51 | 52 | 53 | Connection 54 | 接続方法 55 | 56 | 57 | 58 | Geometry column 59 | ジオメトリ 列 60 | 61 | 62 | 63 | Display columns 64 | 表示列 65 | 66 | 67 | 68 | Browse... 69 | 閲覧する... 70 | 71 | 72 | 73 | Name 74 | 名前 75 | 76 | 77 | 78 | Highlight colour 79 | ハイライト色 80 | 81 | 82 | 83 | Show bar info and hide it after 84 | バー情報を表示し、その後非表示にする 85 | 86 | 87 | 88 | 89 | seconds 90 | 91 | 92 | 93 | 94 | Auto-hide marker after 95 | マーカーを自動で隠す 96 | 97 | 98 | 99 | Copy selected item info to clipboard 100 | 選択した項目の情報をクリップボードにコピーする 101 | 102 | 103 | 104 | Limit fetched results number to 105 | 結果番号 106 | 107 | 108 | 109 | + 110 | 111 | 112 | 113 | 114 | - 115 | 116 | 117 | 118 | 119 | Data source type 120 | データソースの種類 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /Discovery/locator_filter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Discovery Plugin 4 | # 5 | # Copyright (C) 2017 Lutra Consulting 6 | # info@lutraconsulting.co.uk 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | 13 | 14 | from qgis.core import QgsLocatorFilter, QgsLocatorResult, QgsMessageLog 15 | 16 | from . import config_dialog, dbutils 17 | 18 | 19 | class DiscoveryLocatorFilter(QgsLocatorFilter): 20 | def __init__(self, plugin): 21 | QgsLocatorFilter.__init__(self, None) 22 | self.plugin = plugin 23 | 24 | def clone(self): 25 | return DiscoveryLocatorFilter(self.plugin) 26 | 27 | def name(self): 28 | return "discovery" 29 | 30 | def displayName(self): 31 | return "Discovery - search in PostGIS tables" 32 | 33 | def prefix(self): 34 | return "dis" 35 | 36 | def fetchResults(self, text, context, feedback): 37 | 38 | if len(text) < 3: 39 | return 40 | 41 | query_text, query_dict = dbutils.get_search_sql( 42 | text, 43 | self.plugin.postgisgeomcolumn, 44 | self.plugin.postgissearchcolumn, 45 | self.plugin.echosearchcolumn, 46 | self.plugin.postgisdisplaycolumn, 47 | self.plugin.extra_expr_columns, 48 | self.plugin.postgisschema, 49 | self.plugin.postgistable, 50 | self.plugin.escapespecchars, 51 | self.plugin.limit_results, 52 | ) 53 | 54 | conn = self.plugin.get_db() 55 | if not conn: 56 | QgsMessageLog.logMessage("The Locator Bar filter is currently only supported on PostGIS", "Discovery") 57 | return 58 | cursor = conn.cursor() 59 | cursor.execute(query_text, query_dict) 60 | 61 | for row in cursor.fetchall(): 62 | 63 | if feedback.isCanceled(): 64 | return 65 | 66 | geom, epsg, suggestion_text = row[0], row[1], row[2] 67 | extra_data = {} 68 | for idx, extra_col in enumerate(self.plugin.extra_expr_columns): 69 | extra_data[extra_col] = row[3 + idx] 70 | 71 | res = QgsLocatorResult(self, suggestion_text, (geom, epsg, suggestion_text, extra_data)) 72 | self.resultFetched.emit(res) 73 | 74 | def triggerResult(self, result): 75 | self.plugin.select_result(result.userData) 76 | 77 | def hasConfigWidget(self): 78 | return True 79 | 80 | def openConfigWidget(self, parent): 81 | dlg = config_dialog.ConfigDialog(parent) 82 | if dlg.exec_(): 83 | dlg.write_config() 84 | self.plugin.read_config() 85 | -------------------------------------------------------------------------------- /Discovery/metadata.txt: -------------------------------------------------------------------------------- 1 | [general] 2 | name=Discovery 3 | qgisMinimumVersion=3.0 4 | qgisMaximumVersion=3.99 5 | description=Provides search / gazetteer functionality in QGIS using PostGIS, MSSQL and Geopackage databases 6 | version=2.6.0 7 | author=Lutra Consulting 8 | email=info@lutraconsulting.co.uk 9 | homepage=http://www.lutraconsulting.co.uk/products/discovery/ 10 | tracker=https://github.com/lutraconsulting/qgis-discovery-plugin/issues 11 | repository=https://github.com/lutraconsulting/qgis-discovery-plugin 12 | icon=discovery_logo.png 13 | experimental=True 14 | about=The Discovery plugin adds search capability to QGIS. Its key features are: 15 | - Connects directly to PostgreSQL / PostGIS / Geopackage / MSSQL (no reliance on web services) 16 | - Auto-completion of results 17 | - Flexible expression-based support for scales 18 | - Can use multiple fields to display result context 19 | - Simple GUI-based configuration 20 | 21 | changelog=2.6.0 22 | - fixed issues: #95, #84 23 | - fixed handling features without geometry or with an unknown geometry type 24 | - added support for Oracle databases 25 | - added translations 26 |

2.5.10 27 | - allow searching for numbers in PostgreSQL dbs by casting searched column to text 28 |

2.5.9 29 | - fix for Qt deprecation warnings 30 | - fix for result geometry transformation 31 |

2.5.8 32 | - fix marker creation error when loading the plugin 33 | - handle PostgreSQL db connection timeouts 34 |

2.5.7 35 | - Made highlight marker colour configurable at the config-level 36 |

2.5.6 37 | - Limit for search results is now per config parameter, i.e. users can set different limits for their configs 38 |

2.5.5 39 | - Configurable limit of fetched results number for all providers 40 |

2.5.4 41 | - Escaping special characters (i.e. backslash) is now optional - by default there is NO escaping. 42 | - Tidied-up strings in config dialog 43 |

2.5.3 44 | - Limited number of results returned by SQL Server provider to 1000 45 |

2.5.2 46 | - Fixed issue with backslashes in search string safe for postgres 47 |

2.5.1 - Minor bug fix 48 | - Fixed issue with global settings 49 |

2.5.0 - New features 50 | - Add postgresql service support 51 | - Added optional bar info about selected search result 52 |

2.4.3 - Minor bugfixes 53 | - Fixed issue with plain text username and password 54 | - Fixed issue with missing dsn 55 | - Fixed issue with switching connection types 56 | - Fixed empty connections combo 57 |

2.4.2 - Minor bugfixes 58 | - Fixed QGIS authentication configuration what username/password is in environment variables 59 | - Fixed empty connections combo 60 |

2.4.1 - Minor bugfixes 61 | - Fixed MS SQL query when there are just one or zero display columns 62 |

2.4.0 - New features 63 | - Added support for using MSSQL and Geopackage databases 64 |

2.3.0 - New features 65 | - Added support for using QGIS authentication configurations 66 |

2.2.1 - Minor bugfixes 67 | - Use semi-transparent rubber band for polygons 68 |

2.2.0 - New features 69 | - Support for linestring and polygon geometries (#34) 70 | - Support for materialized views (#29) 71 |

2.1.2 - Minor bugfixes 72 | - #27 and #28 73 |

2.1.1 - Minor bugfixes 74 | - Fixed missing import (#26) 75 |

2.1 - New features 76 | - finished port to QGIS 3 and ported #18 77 |

2.0 - New features 78 | - initial port to QGIS 3 79 |

1.4.0 - New features 80 | - Support for multiple search configurations (#18) 81 |

1.3.0 - New features 82 | - Configurable time for map marker display 83 | - Option to keep marker always visible 84 |

1.2.3 - Minor bugfixes 85 | - Fixed regression from 1.2.2 with Python error if BBOX expression was not used 86 |

1.2.2 - Minor bugfixes 87 | - Fixed issue (incorrect map location) seen when using BBOX expression with OTF projection 88 |

1.2.1 - Minor bugfixes 89 |

1.2.0 - Added configuration entries for additional display columns 90 | - Increased max results to 1000 (scrollbar now activates) 91 | - Increased width to 768px 92 |

1.1.0 - Inclusion of search column in results now optional 93 |

1.0.2 - Replaced icon with a new one 94 |

1.0.1 - Fixed error with hardcoded geometry column (#1) 95 |

1.0 - Initial release 96 | 97 | tags=PostGIS, search, gazetteer 98 | -------------------------------------------------------------------------------- /Discovery/mssql_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from PyQt5.QtSql import QSqlDatabase, QSqlQuery 4 | from qgis.core import QgsMessageLog, QgsSettings 5 | 6 | from . import dbutils 7 | from .utils import is_number 8 | 9 | 10 | def get_mssql_connections(): 11 | """Read PostgreSQL connection names from QgsSettings stored by QGIS""" 12 | settings = QgsSettings() 13 | settings.beginGroup("/MSSQL/connections/") 14 | return settings.childGroups() 15 | 16 | 17 | def get_mssql_conn_info(connection): 18 | return connection 19 | 20 | 21 | def get_connection(conn_name, service, host, database, username, password): 22 | # inspired by creation of connection string from QGIS MS SQL provider 23 | db = QSqlDatabase.addDatabase("QODBC", "discovery_" + conn_name) 24 | db.setHostName(host) 25 | if service: 26 | connection_string = service 27 | else: 28 | if sys.platform.startswith("win"): 29 | connection_string = "driver={SQL Server}" 30 | else: 31 | connection_string = "driver={FreeTDS};port=1433" 32 | if host: 33 | connection_string += ";server=" + host 34 | 35 | if database: 36 | connection_string += ";database=" + database 37 | if not password: 38 | connection_string += ";trusted_connection=yes" 39 | else: 40 | connection_string += ";uid=" + username + ";pwd=" + password 41 | connection_string += ";TDS_Version=8.0;ClientCharset=UTF-8" 42 | 43 | if username: 44 | db.setUserName(username) 45 | 46 | if password: 47 | db.setPassword(password) 48 | db.setDatabaseName(connection_string) 49 | 50 | if not db.open(): 51 | raise Exception(db.lastError().text()) 52 | return db 53 | 54 | 55 | def get_mssql_conn(connection): 56 | settings = QgsSettings() 57 | settings.beginGroup("/MSSQL/connections/" + connection) 58 | service = settings.value("/service", "") 59 | host = settings.value("/host", "") 60 | database = settings.value("/database", "") 61 | username = settings.value("/username", "") 62 | password = settings.value("/password", "") 63 | return get_connection(connection, service, host, database, username, password) 64 | 65 | 66 | def list_schemas(db): 67 | """Get list of schema names""" 68 | query = QSqlQuery(db) 69 | query_text = """SELECT schema_name 70 | FROM information_schema.schemata 71 | WHERE schema_owner = 'dbo';""" # TODO: better way to filter out system schemas 72 | query.exec(query_text) 73 | names = [] 74 | while query.next(): 75 | names.append(query.value(0)) 76 | return sorted(names) 77 | 78 | 79 | def list_tables(db): 80 | query_text = "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE'" 81 | query = QSqlQuery(db) 82 | query.exec(query_text) 83 | names = [] 84 | while query.next(): 85 | names.append(query.value(0)) 86 | return names 87 | 88 | 89 | def list_columns(db, schema, table): 90 | query_text = """SELECT COLUMN_NAME 91 | FROM INFORMATION_SCHEMA.COLUMNS 92 | WHERE TABLE_NAME = '%s' AND TABLE_SCHEMA ='%s';""" % ( 93 | dbutils._quote_str(table), 94 | dbutils._quote_str(schema), 95 | ) 96 | query = QSqlQuery(db) 97 | query.exec(query_text) 98 | names = [] 99 | while query.next(): 100 | names.append(query.value(0)) 101 | return names 102 | 103 | 104 | def _quote_brackets(identifier): 105 | """quote identifier as []""" 106 | return "[%s]" % identifier.replace('"', '""') 107 | 108 | 109 | def get_search_sql( 110 | search_text, 111 | geom_column, 112 | search_column, 113 | echo_search_column, 114 | display_columns, 115 | extra_expr_columns, 116 | schema, 117 | table, 118 | limit, 119 | ): 120 | wildcarded_search_string = "" 121 | for part in search_text.split(): 122 | wildcarded_search_string += "%" + part 123 | wildcarded_search_string += "%" 124 | limit = "{}".format(int(limit)) if is_number(limit) else "1000" 125 | query_text = """ SELECT TOP %s 126 | [%s].STAsText() AS geom, 127 | [%s].STSrid AS epsg, 128 | """ % ( 129 | limit, 130 | geom_column, 131 | geom_column, 132 | ) 133 | 134 | info_columns = [] 135 | if echo_search_column: 136 | info_columns.append(_quote_brackets(search_column)) 137 | if len(display_columns) > 0: 138 | for display_column in display_columns.split(","): 139 | info_columns.append(_quote_brackets(display_column)) 140 | 141 | if len(info_columns) == 0: 142 | query_text += "'' AS suggestion_string" 143 | elif len(info_columns) == 1: 144 | query_text += "CAST (%s AS nvarchar) AS suggestion_string" % info_columns[0] 145 | else: 146 | joined_info_columns = ", ', ' COLLATE Latin1_General_CI_AS, ".join(info_columns) 147 | query_text += "CONCAT( %s ) AS suggestion_string" % joined_info_columns 148 | 149 | for extra_column in extra_expr_columns: 150 | query_text += ", [%s]" % extra_column 151 | query_text += """ 152 | FROM 153 | [%s].[%s] 154 | WHERE [%s] LIKE 155 | """ % ( 156 | schema, 157 | table, 158 | search_column, 159 | ) 160 | query_text += ( 161 | """ '%s' 162 | """ 163 | % wildcarded_search_string 164 | ) 165 | query_text += ( 166 | """ORDER BY 167 | [%s] 168 | """ 169 | % search_column 170 | ) 171 | 172 | return query_text 173 | 174 | 175 | def execute(db, query_text): 176 | query = QSqlQuery(db) 177 | if not query.exec(query_text): 178 | QgsMessageLog.logMessage(query.lastError().text() + "\n\nQuery:\n" + query_text, "Discovery") 179 | return [] 180 | 181 | record = query.record() 182 | result_set = [] 183 | while query.next(): 184 | row = [] 185 | for i in range(record.count()): 186 | row.append(query.value(i)) 187 | result_set.append(row) 188 | return result_set 189 | -------------------------------------------------------------------------------- /Discovery/oracle_utils.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtSql import QSqlDatabase, QSqlQuery 2 | from qgis.core import QgsMessageLog, QgsSettings 3 | 4 | from . import dbutils 5 | from .utils import is_number 6 | 7 | 8 | def get_oracle_connections(): 9 | """Read Oracle connection names from QgsSettings stored by QGIS""" 10 | settings = QgsSettings() 11 | settings.beginGroup("/Oracle/connections/") 12 | return settings.childGroups() 13 | 14 | 15 | def get_oracle_conn_info(connection): 16 | return connection 17 | 18 | 19 | def get_connection(conn_name, host, database, port, username, password): 20 | """Connect to the database using conn_info dict: 21 | { 'host': ..., 'port': ..., 'database': ..., 'username': ..., 'password': ... } 22 | """ 23 | db = QSqlDatabase.addDatabase("QOCISPATIAL", "discovery_" + conn_name) 24 | connection_string = "" 25 | 26 | if host: 27 | connection_string = host 28 | 29 | if port not in ("1521",): 30 | connection_string = connection_string + ":" + port 31 | 32 | if database: 33 | connection_string = connection_string + "/" + database 34 | 35 | db.setDatabaseName(connection_string) 36 | db.setUserName(username) 37 | db.setPassword(password) 38 | 39 | if not db.open(): 40 | raise Exception(db.lastError().text()) 41 | return db 42 | 43 | 44 | def get_oracle_conn(connection): 45 | settings = QgsSettings() 46 | settings.beginGroup("/Oracle/connections/" + connection) 47 | host = settings.value("/host", "") 48 | database = settings.value("/database", "") 49 | port = settings.value("/port", "") 50 | username = settings.value("/username", "") 51 | password = settings.value("/password", "") 52 | return get_connection(connection, host, database, port, username, password) 53 | 54 | 55 | def list_schemas(db): 56 | """Get list of schema names""" 57 | query = QSqlQuery(db) 58 | query_text = """SELECT u.username 59 | FROM all_users u 60 | WHERE EXISTS (SELECT null FROM all_tables t WHERE t.owner = u.username) 61 | ORDER BY u.username""" # TODO: better way to filter out system schemas 62 | query.exec(query_text) 63 | names = [] 64 | while query.next(): 65 | names.append(query.value(0)) 66 | return names 67 | 68 | 69 | def list_tables(db, schema): 70 | query_text = """SELECT object_name 71 | FROM all_objects 72 | WHERE object_type IN ('TABLE', 'VIEW') 73 | AND NOT REGEXP_LIKE (object_name, '^DR\\$|^MDRT_|^MDXT_') 74 | AND owner = '%s' 75 | ORDER BY object_name""" % dbutils._quote_str( 76 | schema 77 | ) 78 | query = QSqlQuery(db) 79 | query.exec(query_text) 80 | names = [] 81 | while query.next(): 82 | names.append(query.value(0)) 83 | return names 84 | 85 | 86 | def list_columns(db, schema, table): 87 | query_text = """SELECT column_name 88 | FROM all_tab_columns 89 | WHERE table_name = '%s' AND owner ='%s' 90 | ORDER BY column_id""" % ( 91 | dbutils._quote_str(table), 92 | dbutils._quote_str(schema), 93 | ) 94 | query = QSqlQuery(db) 95 | query.exec(query_text) 96 | names = [] 97 | while query.next(): 98 | names.append(query.value(0)) 99 | return names 100 | 101 | 102 | def _quote(identifier): 103 | """quote identifier""" 104 | return '"%s"' % identifier.replace('"', '""') 105 | 106 | 107 | def get_search_sql( 108 | search_text, 109 | geom_column, 110 | search_column, 111 | echo_search_column, 112 | display_columns, 113 | extra_expr_columns, 114 | schema, 115 | table, 116 | limit, 117 | ): 118 | """Returns a tuple: (SQL query text, dictionary with values to replace variables with).""" 119 | 120 | """ 121 | Spaces in queries 122 | A query with spaces is executed as follows: 123 | 'my query' 124 | LIKE '%my%query%' 125 | 126 | A note on spaces in postcodes 127 | Postcodes must be stored in the DB without spaces: 128 | 'DL10 4DQ' becomes 'DL104DQ' 129 | This allows users to query with or without spaces 130 | As wildcards are inserted at spaces, it doesn't matter whether the query is: 131 | 'dl10 4dq'; or 132 | 'dl104dq' 133 | """ 134 | wildcarded_search_string = "" 135 | for part in search_text.split(): 136 | wildcarded_search_string += "%" + part 137 | wildcarded_search_string += "%" 138 | query_dict = {"search_text": wildcarded_search_string} 139 | query_text = """ SELECT 140 | SDO_UTIL.TO_WKTGEOMETRY("%s") AS geom, 141 | S."%s"."SDO_SRID" AS epsg, 142 | """ % ( 143 | geom_column, 144 | geom_column, 145 | ) 146 | 147 | if echo_search_column: 148 | query_column_selection_text = ( 149 | """"%s" 150 | """ 151 | % search_column 152 | ) 153 | suggestion_string_seperator = ", " 154 | else: 155 | query_column_selection_text = """''""" 156 | suggestion_string_seperator = "" 157 | if len(display_columns) > 0: 158 | for display_column in display_columns.split(","): 159 | query_column_selection_text += """ || CASE WHEN "%s" IS NOT NULL THEN 160 | '%s' || "%s" 161 | ELSE 162 | '' 163 | END 164 | """ % ( 165 | display_column, 166 | suggestion_string_seperator, 167 | display_column, 168 | ) 169 | suggestion_string_seperator = ", " 170 | query_column_selection_text += """ AS suggestion_string """ 171 | if query_column_selection_text.startswith("'', "): 172 | query_column_selection_text = query_column_selection_text[4:] 173 | query_text += query_column_selection_text 174 | for extra_column in extra_expr_columns: 175 | query_text += ', "%s"' % extra_column 176 | query_text += """ 177 | FROM 178 | "%s"."%s" S 179 | WHERE 180 | LOWER("%s") LIKE 181 | """ % ( 182 | schema, 183 | table, 184 | search_column, 185 | ) 186 | query_text += """ LOWER('%s') 187 | """ % ( 188 | wildcarded_search_string 189 | ) 190 | 191 | limit = "{}".format(int(limit)) if is_number(limit) else "1000" 192 | query_text += """AND ROWNUM <= %s""" % (limit) 193 | query_text += ( 194 | """ORDER BY 195 | "%s" 196 | """ 197 | % search_column 198 | ) 199 | return query_text 200 | 201 | 202 | def execute(db, query_text): 203 | query = QSqlQuery(db) 204 | if not query.exec(query_text): 205 | QgsMessageLog.logMessage(query.lastError().text() + "\n\nQuery:\n" + query_text, "Discovery") 206 | return [] 207 | 208 | record = query.record() 209 | result_set = [] 210 | while query.next(): 211 | row = [] 212 | for i in range(record.count()): 213 | row.append(query.value(i)) 214 | result_set.append(row) 215 | return result_set 216 | -------------------------------------------------------------------------------- /Discovery/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Discovery Plugin 4 | # 5 | # Copyright (C) 2020 Lutra Consulting 6 | # info@lutraconsulting.co.uk 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 2 of the License, or 11 | # (at your option) any later version. 12 | 13 | 14 | def is_number(s): 15 | """Return True if s is a number""" 16 | try: 17 | float(s) 18 | return True 19 | except ValueError: 20 | return False 21 | except TypeError: 22 | return False 23 | -------------------------------------------------------------------------------- /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 | 2 | Discovery (QGIS plugin) 3 | ======================= 4 | 5 | Logo 6 | 7 | The Discovery plugin adds search capability to QGIS. Its key features are: 8 | 9 | - Connects directly to PostgreSQL / PostGIS (no reliance on web services) 10 | - Auto-completion of results 11 | - Flexible expression-based support for scales 12 | - Can use multiple fields to display result context 13 | - Simple GUI-based configuration 14 | 15 | We’d like to thank Tim Martin of Ordnance Survey for his original PostGIS Search plugin which gave us inspiration and formed the foundation of Discovery. 16 | 17 | ### Using Discovery 18 | 19 | To learn how to use this plugin, see: 20 | http://www.lutraconsulting.co.uk/products/discovery/ 21 | -------------------------------------------------------------------------------- /install_plugin_dev_win.bat: -------------------------------------------------------------------------------- 1 | 2 | SET DIR=%cd% 3 | SET QGIS_PROFILE=default 4 | 5 | SET PLUGIN=Discovery 6 | SET SRC=%DIR%\%PLUGIN% 7 | 8 | SET DEST=%UserProfile%\AppData\Roaming\QGIS\QGIS3\profiles\%QGIS_PROFILE%\python\plugins 9 | 10 | SET DEST_PLUGIN=%DEST%\%PLUGIN% 11 | 12 | rd %DEST_PLUGIN% /s /q 13 | 14 | xcopy %SRC% %DEST_PLUGIN% /s/i/h/e/k/f/c 15 | 16 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | rm -f Discovery.zip && cd Discovery && git archive --prefix=Discovery/ -o ../Discovery.zip HEAD 2 | --------------------------------------------------------------------------------