├── .github
└── ISSUE_TEMPLATE
│ ├── pyspy-bug-report.md
│ └── pyspy-feature-request.md
├── .gitignore
├── .travis.yml
├── LICENSE.txt
├── README.md
├── VERSION
├── __main__.py
├── __main__.spec
├── aboutdialog.py
├── analyze.py
├── apis.py
├── assets
├── cov_cyno_64.png
├── hic_64.png
├── norm_cyno_64.png
├── pyspy.icns
├── pyspy.ico
├── pyspy.png
├── pyspy_mid.png
├── pyspy_small.png
├── v0.2_screenshot.png
├── v0.3_dark_screenshot.png
├── v0.3_light_screenshot.png
├── v0.4_dark_screenshot.png
└── v0.4_light_screenshot.png
├── chkversion.py
├── config.py
├── db.py
├── gui.py
├── highlightdialog.py
├── ignoredialog.py
├── optstore.py
├── reportstats.py
├── requirements.txt
├── sortarray.py
└── statusmsg.py
/.github/ISSUE_TEMPLATE/pyspy-bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: PySpy Bug report
3 | about: Create a report to help us improve
4 |
5 | ---
6 |
7 | **Describe the bug**
8 | A clear and concise description of what the bug is.
9 |
10 | **To Reproduce**
11 | Steps to reproduce the behavior:
12 | 1. Go to '...'
13 | 2. Click on '....'
14 | 3. Scroll down to '....'
15 | 4. See error
16 |
17 | **Expected behavior**
18 | A clear and concise description of what you expected to happen.
19 |
20 | **Screenshots**
21 | If applicable, add screenshots to help explain your problem.
22 |
23 | **System Environment:**
24 | - OS: [e.g. macOS, Windows]
25 | - OS Version: [e.g. High Sierra, 10]
26 | - PySpy Version [e.g. 0.1]
27 |
28 | **Additional context**
29 | Add any other context about the problem here.
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/pyspy-feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: PySpy Feature request
3 | about: Suggest improvements to PySpy
4 |
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Describe alternatives you've considered**
14 | A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Custom folders
2 | other/
3 | tmp/
4 | download/
5 | sql/
6 | stats_server/
7 | dll/
8 |
9 | # macOS stuff
10 | .DS_Store
11 |
12 | # Byte-compiled / optimized / DLL files
13 | __pycache__/
14 | *.py[cod]
15 | *$py.class
16 |
17 | # Distribution / packaging
18 | .Python
19 | build/
20 | develop-eggs/
21 | dist/
22 | downloads/
23 | eggs/
24 | .eggs/
25 | lib/
26 | lib64/
27 | parts/
28 | sdist/
29 | var/
30 | wheels/
31 | *.egg-info/
32 | .installed.cfg
33 | *.egg
34 | MANIFEST
35 |
36 | # PyInstaller
37 | # Usually these files are written by a python script from a template
38 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
39 | *.manifest
40 | # *.spec
41 | build/
42 |
43 | # Jetbrains IDE stuff
44 | .idea/
45 |
46 | # venv
47 | venv/
48 |
49 | *.pdf
50 | *.db*
51 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # Discussed at https://travis-ci.community/t/python-support-on-windows/241/18
2 | language: python # this works for Linux but is an error on macOS or Windows
3 | matrix:
4 | include:
5 | - name: "Python 3.7.2 on macOS"
6 | os: osx
7 | osx_image: xcode11 # Python 3.7.2 running on macOS 10.14.3
8 | language: shell # 'language: python' is an error on Travis CI macOS
9 | # python: 3.7 # 'python:' is ignored on Travis CI macOS
10 | before_install: python3 --version ; pip3 --version ; sw_vers
11 | - name: "Python 3.7.4 on Windows"
12 | os: windows # Windows 10.0.17134 N/A Build 17134
13 | language: shell # 'language: python' is an error on Travis CI Windows
14 | # python: 3.7 # 'python:' is ignored on Travis CI Windows
15 | before_install:
16 | - choco install python3 --version=3.7.4 # this install takes at least 1 min 30 sec
17 | - python -m pip install --upgrade pip
18 | env: PATH=/c/Python37:/c/Python37/Scripts:$PATH
19 | install:
20 | - pip3 install --upgrade pip # all three OSes agree about 'pip3'
21 | - pip3 install pathlib2
22 | - pip3 install -r requirements.txt
23 | - pip3 install pyinstaller==3.5
24 | script: pyinstaller __main__.spec
25 | notifications:
26 | email: false
27 |
28 | before_deploy:
29 | - ls dist/
30 | - wget "http://s000.tinyupload.com/download.php?file_id=57916265111510766893&t=5791626511151076689386010" -O dist/README.pdf
31 | - if [ "$TRAVIS_OS_NAME" = "windows" ]; then choco install zip ; fi
32 | - if [ "$TRAVIS_OS_NAME" = "osx" ]; then rm dist/PySpy ; fi
33 | - (cd dist; zip -r "../PySpy-$TRAVIS_OS_NAME-$TRAVIS_TAG.zip" .)
34 | - ls
35 | - ls dist/
36 | deploy:
37 | provider: releases
38 | api_key: $GITHUB_TOKEN
39 | file_glob: true
40 | file:
41 | - "PySpy-$TRAVIS_OS_NAME-$TRAVIS_TAG.zip"
42 | skip_cleanup: true
43 | on:
44 | tags: true
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 - White Russsian (https://github.com/WhiteRusssian)
4 |
5 | All rights reserved.
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining a copy
8 | of this software and associated documentation files (the "Software"), to deal
9 | in the Software without restriction, including without limitation the rights
10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in all
15 | copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # !! This Tool is unmaintained and in a not working state. !!
4 |
5 | # PySpy - A simple EVE Online character intel tool using CCP's ESI API
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | **Download the latest release [here](https://github.com/Eve-PySpy/PySpy/releases/latest).**
20 |
21 | ## Background
22 |
23 | PySpy is a fast and simple character intel tool for [EVE Online](https://www.eveonline.com/). Within seconds, PySpy gathers useful information on character names from the in-game *local chat* window.
24 |
25 | PySpy connects to [CCP's ESI API](https://esi.evetech.net/ui/) and the
26 | [zKillboard API](https://github.com/zKillboard/zKillboard/wiki) and is available on Windows, macOS and Linux.
27 |
28 | In addition, PySpy uses a proprietary database which creates summary statistics for approximately 2.4 million EVE Online pilots, based on some 50 million killmails dating back to December 2007. This database is updated daily, shortly after CCP server downtime.
29 |
30 | If you enjoy using PySpy and would like to show your appreciation, please feel free to send ISK in-game to White Russsian (with 3 's'). Thank you.
31 |
32 | ## How to use PySpy
33 |
34 | 1. Open PySpy.
35 | 2. In your EVE client, select a list of characters and copy them to the clipboard (`CTRL+C` on Windows *or* `⌘+C` on macOS).
36 | 3. Wait until PySpy is done and inspect the results.
37 | 4. Double-click a name to open the respective zKillboard in your browser. zKillboard will open to the relevant page based on the column you have clicked on. (Or to the character page if you turn off advanced zkillboard linking)
38 |
39 | **Note**: PySpy will save its window location, size, column sizes, sorting order and transparency (slider on bottom right) and any other settings automatically and restore them the next time you launch it (settings will be reset whenever you update to a new version). If selected in the _View Menu_, PySpy will stay on top of the EVE client so long as the game runs in *window mode*.
40 |
41 | ## Information Provided by PySpy
42 |
43 | ### New Dark Mode
44 |
45 |
46 |
47 |
48 | ### Traditional Normal Mode
49 |
50 |
51 |
52 |
53 | * **Warning**: Displays reasons why a character might be highlighted
54 | * **Character**: Character name.
55 | * **Security**: Concord security status.
56 | * **Corporation**: Corporation of character.
57 | * **Alliance**: Alliance of character's Corporation, if any.
58 | * **Faction**: Faction of character, if any.
59 | * **Kills**: Total number of kills.
60 | * **Losses**: Total number of losses.
61 | * **Last Wk**: Number of kills over past 7 days.
62 | * **Solo**: Ratio of solo kills over total kills.
63 | * **BLOPS**: Number of Black Ops Battleships (BLOPS) killed.
64 | * **HICs**: Number of lost Heavy Interdiction Cruisers (HIC).
65 | * **Last Loss**: Days since last loss.
66 | * **Last Kill**: Days since last kill.
67 | * **Avg. Attackers**: Average number of attackers per kill.
68 | * **Covert Cyno**: Ratio of losses where a covert cyno was fitted to total losses.
69 | * **Regular Cyno**: Ratio of losses where a regular cyno was fitted to total losses.
70 | * **Last Covert Cyno**: Ship type of most recent loss where covert cyno was fitted.
71 | * **Last Regular Cyno**: Ship type of most recent loss where regular cyno was fitted.
72 | * **Abyssal Losses**: Number of ship losses in Abyssal space.
73 |
74 | **Current Limitations**: To avoid undue strain on zKillboard's API, PySpy will run the *Kills*, *Losses*, *Last Wk*, *Solo*, *BLOPS* and *HICs* analyses only for the first 100 characters in the list.
75 |
76 | ## Ignore Certain Entities
77 |
78 | PySpy allows you to specify a list of ignored characters, corporations and alliances. To add entities to that list, right click on a search result. You can remove entities from this list under _Options_->_Review Ignored Entities_.
79 |
80 | ## Ignore all Members of your NPSI Fleet
81 |
82 | For anyone using PySpy in not-purple-shoot-it (NPSI) fleets, you can tell PySpy to temporarily ignore your fleet mates by first running PySpy on all characters in your fleet chat and then selecting _Options_->_Set NPSI Ignore List_. Once the fleet is over, you can clear the list under _Options_->_Clear NPSI List_. Your custom ignore list described above will not be affected by this action.
83 |
84 | ## Highlighting
85 |
86 | PySpy allows you to specify a list of highlighted characters, corporations and alliances.
87 | These entities will be highlighted in a different color from the others.
88 | To add and remove entities to that list, right click on a search result.
89 | You can also review and remove entities from this list under _Options_->_Review Highlighted Entities_.
90 |
91 | Furthermore PySpy can also highlight a character if he uses Black Ops and Heavy Interdiction Cruisers or frequently has a cyno fitted.
92 |
93 | ## Installation
94 |
95 | You can download the latest release for your operating system [here](https://github.com/Eve-PySpy/PySpy/releases/latest).
96 |
97 | PySpy comes as a single-file executable both in Windows and macOS. On both platforms, you can run PySpy from any folder location you like.
98 |
99 | On Linux, you can run PySpy like any other Python3 script. PySpy was developed on Python 3.6.5 but should run on any other Python3 version, so long as you install the required packages listed in [requirements.txt](https://github.com/Eve-PySpy/PySpy/blob/master/requirements.txt).
100 |
101 | If you want to build PySpy into an executable yourself, then the pyinstaller spec file is provided, you will likely need to provide the api-ms-core dlls that python requires. details of this can be found [here](https://github.com/pyinstaller/pyinstaller/issues/4047#issuecomment-460869714). You will know you need them if pyinstaller complains about missing them when run.
102 |
103 | **Note**: PySpy automatically checks for updates on launch and will notify you if a new version is available.
104 |
105 | ## Uninstalling PySpy
106 |
107 | Delete the PySpy executable and remove the following files manually:
108 |
109 | * **Windows**: PySpy saves preference and log files in a folder called `PySpy` located at `%LocalAppData%`.
110 | * **macOS**: PySpy creates `pyspy.log` under `~/Library/Logs` and `pyspy.cfg` as well as `pyspy.pickle` under `~/Library/Preferences`.
111 | * **Linux**: PySpy creates `pyspy.log` under `~/Library/Logs` and `pyspy.cfg` as well as `pyspy.pickle` under `~/.config/pyspy`.
112 |
113 | ## Future Features
114 |
115 | Below is a non-exhaustive list of additional features I plan to add to PySpy as and when the ESI and zKillboard APIs support them:
116 |
117 | * **Standings**: Only show characters that are non-blue, i.e. neutral or hostile.
118 | * **Highlight New Pilots**: Highlight any pilots that have entered system since last PySpy run.
119 | * **Improved GUI**: The current GUI is very basic and while it works, I do appreciate that it is not ideal for people who cannot use it on a second screen but actually have to overlay it on-top of their EVE client.
120 |
121 | Please feel free to add a [feature request](https://github.com/Eve-PySpy/PySpy/issues/new?template=pyspy-feature-request.md) for any improvements you would like to see in future releases.
122 |
123 | ## Bug Reporting
124 |
125 | Despite extensive testing, you may encounter the odd bug. If so, please check if an existing [issue](https://github.com/WhiteRusssian/PySpy/issues) already describes your bug. If not, feel free to [create a new issue](https://github.com/WhiteRusssian/PySpy/issues/new?template=pyspy-bug-report.md) for your bug.
126 |
127 | ## Dependencies & Acknowledgements
128 |
129 | * PySpy is written in [Python 3](https://www.python.org/), licensed under [PSF's License Agreement](https://docs.python.org/3/license.html#psf-license-agreement-for-python-release).
130 | * For API connectivity, PySpy relies on [Requests](http://docs.python-requests.org/) (v2.19.1), licensed under the [Apache License, Version 2.0](http://docs.python-requests.org/en/master/user/intro/#requests-license).
131 | * Clipboard monitoring is implemented with the help of [pyperclip](https://github.com/asweigart/pyperclip) (v1.6.2), licensed under the [3-Clause BSD License](https://github.com/asweigart/pyperclip/blob/master/LICENSE.txt).
132 | * The GUI is powered by [wxPython](https://www.wxpython.org/) (v4.0.3), licensed under the [wxWindows Library Licence](https://wxpython.org/pages/license/index.html).
133 | * The Windows and macOS executables are built using [pyinstaller](https://www.pyinstaller.org/), licensed under [its own modified GPL license](https://raw.githubusercontent.com/pyinstaller/pyinstaller/develop/COPYING.txt).
134 | * PySpy's icon was created by Jojo Mendoza and is licensed under [Creative Commons (Attribution-Noncommercial 3.0 Unported)](https://creativecommons.org/licenses/by-nc/3.0/). It is available on [IconFinder](https://www.iconfinder.com/icons/1218719/cyber_hat_spy_undercover_user_icon).
135 |
136 | ## License
137 |
138 | PySpy is licensed under the [MIT](LICENSE.txt) License.
139 |
140 | ## CCP Copyright Notice
141 |
142 | EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide. All other trademarks are the property of their respective owners. EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf. All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the intellectual property relating to these trademarks are likewise the intellectual property of CCP hf. CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for any damage arising from the use of this website.
143 |
144 | ## Collection of Usage Statistics
145 |
146 | To help improve PySpy further, PySpy reports usage statistics comprising certain anonymous information such as the number of characters analysed, duration of each analysis, operating system, version of PySpy, and any active GUI features. For full disclosure, a randomly generated identifier is being sent with each data set to allow me to track how many people are actually using PySpy over any given period. If you would like to see for yourself what is being collected, have a look at the source code of module `reportstats.py`.
147 |
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
1 | v0.5.2
--------------------------------------------------------------------------------
/__main__.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' This is the primary module responsible for launching a background
6 | thread that watches for changes in the clipboard and if it detects a
7 | list of strings that could be EVE Online character strings, sends them
8 | to the analyze.py module to gather specific information from CCP's ESI
9 | API and zKIllboard's API. This information then gets sent to the GUI for
10 | output.
11 | '''
12 | # **********************************************************************
13 | import logging
14 | import re
15 | import threading
16 | import time
17 |
18 | import wx
19 | import pyperclip
20 |
21 | import analyze
22 | import chkversion
23 | import config
24 | import gui
25 | import reportstats
26 | import statusmsg
27 | import db
28 | # cSpell Checker - Correct Words****************************************
29 | # // cSpell:words russsian, ccp's, pyperclip, chkversion, clpbd, gui
30 | # **********************************************************************
31 | Logger = logging.getLogger(__name__)
32 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
33 |
34 |
35 | def watch_clpbd():
36 | valid = False
37 | recent_value = None
38 | while True:
39 | clipboard = pyperclip.paste()
40 | if clipboard != recent_value:
41 | char_names = clipboard.splitlines()
42 | for name in char_names:
43 | valid = check_name_validity(name)
44 | if valid is False:
45 | break
46 | if valid:
47 | statusmsg.push_status("Clipboard change detected...")
48 | recent_value = clipboard
49 | analyze_chars(clipboard.splitlines())
50 | time.sleep(0.5) # Short sleep between loops to reduce CPU load
51 |
52 |
53 | def check_name_validity(char_name):
54 | if len(char_name) < 3:
55 | return False
56 | regex = r"[^ 'a-zA-Z0-9-]" # Valid EVE Online character names
57 | if re.search(regex, char_name):
58 | return False
59 | return True
60 |
61 |
62 | def analyze_chars(char_names):
63 | conn_mem, cur_mem = db.connect_memory_db()
64 | conn_dsk, cur_dsk = db.connect_persistent_db()
65 | start_time = time.time()
66 | wx.CallAfter(app.PySpy.grid.ClearGrid)
67 | try:
68 | outlist = analyze.main(char_names, conn_mem, cur_mem, conn_dsk, cur_dsk)
69 | duration = round(time.time() - start_time, 1)
70 | reportstats.ReportStats(outlist, duration).start()
71 | if outlist is not None:
72 | # Need to use keyword args as sortOutlist can also get called
73 | # by event handler which would pass event object as first argument.
74 | wx.CallAfter(
75 | app.PySpy.sortOutlist,
76 | outlist=outlist,
77 | duration=duration
78 | )
79 | else:
80 | statusmsg.push_status(
81 | "No valid character names found. Please try again..."
82 | )
83 | except Exception:
84 | Logger.error(
85 | "Failed to collect character information. Clipboard "
86 | "content was: " + str(char_names), exc_info=True
87 | )
88 |
89 |
90 | app = gui.App(0) # Has to be defined before background thread starts.
91 |
92 | background_thread = threading.Thread(
93 | target=watch_clpbd,
94 | daemon=True
95 | )
96 | background_thread.start()
97 |
98 | update_checker = threading.Thread(
99 | target=chkversion.chk_github_update,
100 | daemon=True
101 | )
102 | update_checker.start()
103 |
104 | app.MainLoop()
105 |
--------------------------------------------------------------------------------
/__main__.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python -*-
2 | '''Cross-platform (MacOSX and Windows 10) Spec file for pyinstaller.
3 | Be sure to not use python 3.7 until pyinstaller supports it.'''
4 | # cSpell Checker - Correct Words****************************************
5 | # // cSpell:words pyinstaller, pyspy, posix, icns, getcwd, datas,
6 | # // cSpell:words tkinter, noconsole
7 | # **********************************************************************
8 | import os
9 |
10 | if os.name == "nt":
11 | ICON_FILE = os.path.join("assets", "pyspy.ico")
12 | elif os.name == "posix":
13 | ICON_FILE = os.path.join("assets", "pyspy.png")
14 |
15 | MAC_ICON = os.path.join("assets", "pyspy.icns")
16 | ABOUT_ICON = os.path.join("assets", "pyspy_mid.png")
17 |
18 | block_cipher = None
19 |
20 | a = Analysis(
21 | ["__main__.py"],
22 | pathex=[os.getcwd()],
23 | binaries=[],
24 | datas=[
25 | (ICON_FILE, "."),
26 | (ABOUT_ICON, "."),
27 | ("VERSION", "."),
28 | ("LICENSE.txt", "."),
29 | ],
30 | hiddenimports=[],
31 | hookspath=[],
32 | runtime_hooks=[],
33 | excludes=["Tkinter"],
34 | win_no_prefer_redirects=False,
35 | win_private_assemblies=False,
36 | cipher=block_cipher
37 | )
38 |
39 | pyz = PYZ(
40 | a.pure,
41 | a.zipped_data,
42 | cipher=block_cipher
43 | )
44 |
45 | exe = EXE(
46 | pyz,
47 | a.scripts,
48 | a.binaries,
49 | a.zipfiles,
50 | a.datas,
51 | name="PySpy",
52 | icon=ICON_FILE,
53 | debug=False,
54 | strip=False,
55 | upx=True,
56 | runtime_tmpdir=None,
57 | console=False,
58 | noconsole=True,
59 | onefile=True,
60 | windowed=True
61 | )
62 |
63 | app = BUNDLE(
64 | exe,
65 | name="PySpy.app",
66 | icon=MAC_ICON,
67 | bundle_identifier=None
68 | )
69 |
--------------------------------------------------------------------------------
/aboutdialog.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | '''The About Dialog for PySpy's GUI. OnAboutBox() gets called by the GUI
6 | module.'''
7 | # **********************************************************************
8 | import logging
9 | import time
10 |
11 | import wx
12 | import wx.adv
13 |
14 | import __main__
15 | import config
16 | # cSpell Checker - Correct Words****************************************
17 | # // cSpell:words russsian, wxpython, ccp's
18 | # // cSpell:words
19 | # **********************************************************************
20 | Logger = logging.getLogger(__name__)
21 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
22 |
23 |
24 | def showAboutBox(parent, event=None):
25 | # __main__.app.PySpy.ToggleWindowStyle(wx.STAY_ON_TOP)
26 |
27 | description = """
28 | PySpy is an EVE Online character intel tool
29 | using CCP's ESI API and a daily updated proprietary
30 | database containing key statistics on approximately
31 | 2.4 million pilots.
32 |
33 | If you enjoy PySpy and want to show your appreciation
34 | to its author, you are welcome to send an ISK donation
35 | in-game to White Russsian (with 3 "s").
36 |
37 | Thank you."""
38 |
39 | try:
40 | with open(config.resource_path('LICENSE.txt'), 'r') as lic_file:
41 | license = lic_file.read()
42 | except:
43 | license = "PySpy is licensed under the MIT License."
44 |
45 | info = wx.adv.AboutDialogInfo()
46 |
47 | info.SetIcon(wx.Icon(config.ABOUT_ICON, wx.BITMAP_TYPE_PNG))
48 | info.SetName("PySpy")
49 | info.SetVersion(config.CURRENT_VER)
50 | info.SetDescription(description)
51 | info.SetCopyright('(C) 2018 White Russsian')
52 | info.SetWebSite('https://github.com/Eve-PySpy/PySpy')
53 | info.SetLicence(license)
54 |
55 | wx.adv.AboutBox(info)
56 |
--------------------------------------------------------------------------------
/analyze.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' The primary function is main(), which takes a set of EVE Online
6 | character names and gathers useful information from CCP's ESI API and
7 | zKIllboard's API, to be stored in a temporary in-memory SQLite3 database.
8 | '''
9 | # **********************************************************************
10 | import logging
11 | import json
12 | import threading
13 | import queue
14 | import time
15 | import datetime
16 |
17 | import apis
18 | import config
19 | import db
20 | import statusmsg
21 | # cSpell Checker - Correct Words****************************************
22 | # // cSpell:words affil, zkill, blops, qsize, numid, russsian, ccp's
23 | # // cSpell:words records_added
24 | # **********************************************************************
25 | Logger = logging.getLogger(__name__)
26 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
27 |
28 |
29 | def main(char_names, conn_mem, cur_mem, conn_dsk, cur_dsk):
30 | chars_found = get_char_ids(conn_mem, cur_mem, char_names)
31 | if chars_found > 0:
32 | # Run Pyspy remote database query in seprate thread
33 | tp = threading.Thread(
34 | target=get_character_intel(conn_mem, cur_mem),
35 | daemon=True
36 | )
37 | tp.start()
38 |
39 | # Run zKill query in seprate thread
40 | char_ids_mem = cur_mem.execute(
41 | "SELECT char_id, last_update FROM characters ORDER BY char_name"
42 | ).fetchall()
43 |
44 | cache_max_age = datetime.datetime.now() - datetime.timedelta(seconds=config.CACHE_TIME)
45 |
46 | char_ids_dsk = cur_dsk.execute(
47 | "SELECT char_id, last_update FROM characters WHERE last_update > ? ORDER BY char_name", (cache_max_age,)
48 | ).fetchall()
49 |
50 | char_ids_mem_d = dict(char_ids_mem)
51 | char_ids_dsk_d = dict(char_ids_dsk)
52 |
53 | ids_mem = set(char_ids_mem_d.keys())
54 | ids_dsk = set(char_ids_dsk_d.keys())
55 |
56 | cache_hits = ids_mem & ids_dsk # Intersection of what we want and what we already have
57 | cache_miss = ids_mem - cache_hits
58 |
59 | Logger.debug("Cache Hits - {}".format(len(cache_hits)))
60 | Logger.debug("Cache Miss - {}".format(len(cache_miss)))
61 |
62 | zkill_req = [r for r in char_ids_mem if r[0] in cache_miss]
63 |
64 | q_main = queue.Queue()
65 | tz = zKillStats(zkill_req, q_main)
66 | tz.start()
67 |
68 | get_char_affiliations(conn_mem, cur_mem)
69 | get_affil_names(conn_mem, cur_mem)
70 |
71 | # Join zKill thread
72 | tz.join()
73 | update_datetime = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
74 | zkill_stats = q_main.get()
75 | for entry in zkill_stats:
76 | entry.insert(-1, update_datetime)
77 | query_string = (
78 | '''UPDATE characters SET kills=?, blops_kills=?, hic_losses=?,
79 | week_kills=?, losses=?, solo_ratio=?, sec_status=?, last_update=?
80 | WHERE char_id=?'''
81 | )
82 |
83 | cache_stats = []
84 | for char_id in cache_hits:
85 | # kills, blops_kills, hic_losses, week_kills, losses, solo_ratio, sec_status, id
86 | cache_query = '''SELECT kills, blops_kills, hic_losses, week_kills, losses, solo_ratio,
87 | sec_status, last_update, char_id FROM characters WHERE char_id = ?'''
88 | stat = tuple(cur_dsk.execute(cache_query, (char_id,)).fetchone()) #SHOULD ONLY BE ONE ENTRY!!!
89 | cache_stats.append(stat)
90 |
91 | cache_char_query_string = (
92 | '''INSERT OR REPLACE INTO characters (char_id, char_name) VALUES (?, ?)'''
93 | )
94 |
95 | db.write_many_to_db(conn_dsk, cur_dsk, cache_char_query_string, zkill_req)
96 | db.write_many_to_db(conn_dsk, cur_dsk, query_string, zkill_stats)
97 |
98 | db.write_many_to_db(conn_mem, cur_mem, query_string, zkill_stats)
99 | db.write_many_to_db(conn_mem, cur_mem, query_string, cache_stats)
100 |
101 | # Join Pyspy remote database thread
102 | tp.join()
103 | output = output_list(cur_mem)
104 | conn_mem.close()
105 | return output
106 | else:
107 | return
108 |
109 |
110 | def get_char_ids(conn, cur, char_names):
111 | char_names = json.dumps(char_names[0:config.MAX_NAMES]) # apis max char is 1000
112 | statusmsg.push_status("Resolving character names to IDs...")
113 | try:
114 | characters = apis.post_req_ccp("universe/ids/", char_names)
115 | characters = characters['characters']
116 | except:
117 | return 0
118 | records = ()
119 | for r in characters:
120 | records = records + ((r["id"], r["name"]),)
121 | query_string = (
122 | '''INSERT OR REPLACE INTO characters (char_id, char_name) VALUES (?, ?)'''
123 | )
124 | records_added = db.write_many_to_db(conn, cur, query_string, records)
125 | return records_added
126 |
127 |
128 | def get_char_affiliations(conn, cur):
129 | char_ids = cur.execute("SELECT char_id FROM characters").fetchall()
130 | char_ids = json.dumps(tuple([r[0] for r in char_ids]))
131 | statusmsg.push_status("Retrieving character affiliation IDs...")
132 | try:
133 | affiliations = apis.post_req_ccp("characters/affiliation/", char_ids)
134 | except:
135 | Logger.info("Failed to obtain character affiliations.", exc_info=True)
136 | raise Exception
137 |
138 | records = ()
139 | for r in affiliations:
140 | corp_id = r["corporation_id"]
141 | alliance_id = r["alliance_id"] if "alliance_id" in r else 0
142 | faction_id = r["faction_id"] if "faction_id" in r else 0
143 | char_id = r["character_id"]
144 | records = records + ((corp_id, alliance_id, faction_id, char_id), )
145 |
146 | query_string = (
147 | '''UPDATE characters SET corp_id=?, alliance_id=?, faction_id=?
148 | WHERE char_id=?'''
149 | )
150 | db.write_many_to_db(conn, cur, query_string, records)
151 |
152 |
153 | def get_affil_names(conn, cur):
154 | # use select distinct to get corp and alliance ids and reslove them
155 | alliance_ids = cur.execute(
156 | '''SELECT DISTINCT alliance_id FROM characters
157 | WHERE alliance_id IS NOT 0'''
158 | ).fetchall()
159 | corp_ids = cur.execute(
160 | "SELECT DISTINCT corp_id FROM characters WHERE corp_id IS NOT 0"
161 | ).fetchall()
162 |
163 | ids = alliance_ids + corp_ids
164 | ids = json.dumps(tuple([r[0] for r in ids]))
165 |
166 | statusmsg.push_status("Obtaining corporation and alliance names and zKillboard data...")
167 | try:
168 | names = apis.post_req_ccp("universe/names/", ids)
169 | except:
170 | Logger.info("Failed request corporation and alliance names.",
171 | exc_info=True)
172 | raise Exception
173 |
174 | alliances, corporations = (), ()
175 | for r in names:
176 | if r["category"] == "alliance":
177 | alliances = alliances + ((r["id"], r["name"]),)
178 | elif r["category"] == "corporation":
179 | corporations = corporations + ((r["id"], r["name"]),)
180 | if alliances:
181 | query_string = ('''INSERT INTO alliances (id, name) VALUES (?, ?)''')
182 | db.write_many_to_db(conn, cur, query_string, alliances)
183 | if corporations:
184 | query_string = ('''INSERT INTO corporations (id, name) VALUES (?, ?)''')
185 | db.write_many_to_db(conn, cur, query_string, corporations)
186 |
187 |
188 | class zKillStats(threading.Thread):
189 |
190 | def __init__(self, char_ids, q_main):
191 | super(zKillStats, self).__init__()
192 | self.daemon = True
193 | self._char_ids = char_ids
194 | self._q_main = q_main
195 |
196 | def run(self):
197 | count = 0
198 | max = config.ZKILL_CALLS
199 | threads = []
200 | q_sub = queue.Queue()
201 | for id in self._char_ids:
202 | t = apis.Query_zKill(id[0], q_sub)
203 | threads.append(t)
204 | t.start()
205 | count += 1
206 | time.sleep(config.ZKILL_DELAY)
207 | if count >= max:
208 | break
209 | for t in threads:
210 | t.join(5)
211 | zkill_stats = []
212 | while q_sub.qsize():
213 | # Run through each queue item and prepare response list.
214 | s = q_sub.get()
215 | kills = str(s[0])
216 | blops_kills = str(s[1])
217 | hic_losses = str(s[2])
218 | week_kills = str(s[3])
219 | losses = str(s[4])
220 | solo_ratio = str(s[5])
221 | sec_status = str(s[6])
222 | id = str(s[7])
223 | zkill_stats.append([
224 | kills, blops_kills, hic_losses, week_kills, losses, solo_ratio,
225 | sec_status, id
226 | ])
227 | self._q_main.put(zkill_stats)
228 | return
229 |
230 |
231 | def get_character_intel(conn, cur):
232 | '''
233 | Adds certain character killboard statistics derived from PySpy's
234 | proprietary database to the local SQLite3 database.
235 |
236 | :param `conn`: SQLite3 connection object.
237 | :param `cur`: SQLite3 cursor object.
238 | '''
239 | char_ids = cur.execute("SELECT char_id FROM characters").fetchall()
240 | char_intel = apis.post_proprietary_db(char_ids)
241 | records = ()
242 | for r in char_intel:
243 | char_id = r["character_id"]
244 | last_loss_date = r["last_loss_date"] if r["last_loss_date"] is not None else 0
245 | last_kill_date = r["last_kill_date"] if r["last_kill_date"] is not None else 0
246 | avg_attackers = r["avg_attackers"] if r["avg_attackers"] is not None else 0
247 | covert_prob = r["covert_prob"] if r["covert_prob"] is not None else 0
248 | normal_prob = r["normal_prob"] if r["normal_prob"] is not None else 0
249 | last_cov_ship = r["last_cov_ship"] if r["last_cov_ship"] is not None else 0
250 | last_norm_ship = r["last_norm_ship"] if r["last_norm_ship"] is not None else 0
251 | abyssal_losses = r["abyssal_losses"] if r["abyssal_losses"] is not None else 0
252 |
253 | records = records + ((
254 | last_loss_date, last_kill_date, avg_attackers, covert_prob,
255 | normal_prob, last_cov_ship, last_norm_ship, abyssal_losses, char_id
256 | ), )
257 |
258 | query_string = (
259 | '''UPDATE characters SET last_loss_date=?, last_kill_date=?,
260 | avg_attackers=?, covert_prob=?, normal_prob=?,
261 | last_cov_ship=?, last_norm_ship=?, abyssal_losses=?
262 | WHERE char_id=?'''
263 | )
264 | db.write_many_to_db(conn, cur, query_string, records)
265 |
266 |
267 | def output_list(cur):
268 | query_string = (
269 | '''SELECT
270 | ch.char_id, ch.faction_id, ch.char_name, co.id, co.name, al.id,
271 | al.name, fa.name, ac.numid, ch.week_kills, ch.kills, ch.blops_kills,
272 | ch.hic_losses, ch.losses, ch.solo_ratio, ch.sec_status,
273 | ch.last_loss_date, ch.last_kill_date,
274 | ch.avg_attackers, ch.covert_prob, ch.normal_prob,
275 | IFNULL(cs.name,'-'), IFNULL(ns.name,'-'), ch.abyssal_losses
276 | FROM characters AS ch
277 | LEFT JOIN alliances AS al ON ch.alliance_id = al.id
278 | LEFT JOIN corporations AS co ON ch.corp_id = co.id
279 | LEFT JOIN factions AS fa ON ch.faction_id = fa.id
280 | LEFT JOIN (SELECT alliance_id, COUNT(alliance_id) AS numid FROM characters GROUP BY alliance_id)
281 | AS ac ON ch.alliance_id = ac.alliance_id
282 | LEFT JOIN ships AS cs ON ch.last_cov_ship = cs.id
283 | LEFT JOIN ships AS ns ON ch.last_norm_ship = ns.id
284 | ORDER BY ch.char_name'''
285 | )
286 | return cur.execute(query_string).fetchall()
287 |
--------------------------------------------------------------------------------
/apis.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' This module provides connectivity to CCP's ESI API, to zKillboard's
6 | API and to PySpy's own proprietary RESTful API.
7 | '''
8 | # **********************************************************************
9 | import json
10 | import logging
11 | import threading
12 | import time
13 |
14 | import requests
15 |
16 | import config
17 | import statusmsg
18 | # cSpell Checker - Correct Words****************************************
19 | # // cSpell:words wrusssian, ZKILL, gmail, blops, toon, HICs, russsian,
20 | # // cSpell:words ccp's, activepvp
21 | # **********************************************************************
22 | Logger = logging.getLogger(__name__)
23 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
24 |
25 |
26 | # ESI Status
27 | # https://esi.evetech.net/ui/?version=meta#/Meta/get_status
28 |
29 | def post_req_ccp(esi_path, json_data):
30 | url = "https://esi.evetech.net/latest/" + esi_path + \
31 | "?datasource=tranquility"
32 | try:
33 | r = requests.post(url, json_data)
34 | except requests.exceptions.ConnectionError:
35 | Logger.info("No network connection.", exc_info=True)
36 | statusmsg.push_status(
37 | "NETWORK ERROR: Check your internet connection and firewall settings."
38 | )
39 | time.sleep(5)
40 | return "network_error"
41 | if r.status_code != 200:
42 | server_msg = json.loads(r.text)["error"]
43 | Logger.info(
44 | "CCP Servers at (" + esi_path + ") returned error code: " +
45 | str(r.status_code) + ", saying: " + server_msg, exc_info=True
46 | )
47 | statusmsg.push_status(
48 | "CCP SERVER ERROR: " + str(r.status_code) + " (" + server_msg + ")"
49 | )
50 | return "server_error"
51 | return r.json()
52 |
53 |
54 | class Query_zKill(threading.Thread):
55 | # Run in a separate thread to query certain kill board statistics
56 | # from zKillboard's API and return the values via a queue object.
57 | def __init__(self, char_id, q):
58 | super(Query_zKill, self).__init__()
59 | self.daemon = True
60 | self._char_id = char_id
61 | self._queue = q
62 |
63 | def run(self):
64 | url = (
65 | "https://zkillboard.com/api/stats/characterID/" +
66 | str(self._char_id) + "/"
67 | )
68 | headers = {
69 | "Accept-Encoding": "gzip",
70 | "User-Agent": "PySpy, Author: White Russsian, https://github.com/WhiteRusssian/PySpy"
71 | }
72 | try:
73 | r = requests.get(url, headers=headers)
74 | except requests.exceptions.ConnectionError:
75 | Logger.info("No network connection.", exc_info=True)
76 | statusmsg.push_status(
77 | '''NETWORK ERROR: Check your internet connection
78 | and firewall settings.'''
79 | )
80 | time.sleep(5)
81 | return "network error"
82 | if r.status_code != 200:
83 | server_msg = "N/A"
84 | try:
85 | server_msg = json.loads(r.text)["error"]
86 | except:
87 | pass
88 | Logger.info(
89 | "zKillboard server returned error for character ID " +
90 | str(self._char_id) + ". Error code: " + str(r.status_code),
91 | exc_info=True
92 | )
93 | statusmsg.push_status(
94 | "ZKILL SERVER ERROR: " + str(r.status_code) + " (" + server_msg + ")"
95 | )
96 | return "server error"
97 | try:
98 | r = r.json()
99 | except AttributeError:
100 | kills = 0
101 | blops_kills = 0
102 | hic_losses = 0
103 | self._queue.put([kills, blops_kills, self._char_id])
104 | return
105 |
106 | try:
107 | # Number of total kills
108 | kills = r["shipsDestroyed"]
109 | except (KeyError, TypeError):
110 | kills = 0
111 |
112 | try:
113 | # Number of BLOPS killed
114 | blops_kills = r["groups"]["898"]["shipsDestroyed"]
115 | except (KeyError, TypeError):
116 | blops_kills = 0
117 |
118 | try:
119 | # Number of HICs lost
120 | hic_losses = r["groups"]["894"]["shipsLost"]
121 | except (KeyError, TypeError):
122 | hic_losses = 0
123 |
124 | try:
125 | # Kills over past 7 days
126 | week_kills = r["activepvp"]["kills"]["count"]
127 | except (KeyError, TypeError):
128 | week_kills = 0
129 |
130 | try:
131 | # Number of total losses
132 | losses = r["shipsLost"]
133 | except (KeyError, TypeError):
134 | losses = 0
135 |
136 | try:
137 | # Ratio of solo kills to total kills
138 | solo_ratio = int(r["soloKills"]) / int(r["shipsDestroyed"])
139 | except (KeyError, TypeError):
140 | solo_ratio = 0
141 |
142 | try:
143 | # Security status
144 | sec_status = r["info"]["secStatus"]
145 | except (KeyError, TypeError):
146 | sec_status = 0
147 |
148 | self._queue.put(
149 | [kills, blops_kills, hic_losses, week_kills, losses, solo_ratio,
150 | sec_status, self._char_id]
151 | )
152 | return
153 |
154 |
155 | def post_proprietary_db(character_ids):
156 | '''
157 | Query PySpy's proprietary kill database for the character ids
158 | provided as a list or tuple of integers. Returns a JSON containing
159 | one line per character id.
160 |
161 | :param `character_ids`: List or tuple of character ids as integers.
162 | :return: JSON dictionary containing certain statistics for each id.
163 | '''
164 | url = "http://pyspy.pythonanywhere.com" + "/character_intel/" + "v1/"
165 | headers = {
166 | "Accept-Encoding": "gzip",
167 | "User-Agent": "PySpy, Author: White Russsian, https://github.com/WhiteRusssian/PySpy"
168 | }
169 | # Character_ids is a list of tuples, which needs to be converted to dict
170 | # with list as value.
171 | character_ids = {"character_ids": character_ids}
172 | try:
173 | r = requests.post(url, headers=headers, json=character_ids)
174 | except requests.exceptions.ConnectionError:
175 | Logger.info("No network connection.", exc_info=True)
176 | statusmsg.push_status(
177 | "NETWORK ERROR: Check your internet connection and firewall settings."
178 | )
179 | time.sleep(5)
180 | return "network_error"
181 | if r.status_code != 200:
182 | server_msg = json.loads(r.text)["error"]
183 | Logger.info(
184 | "PySpy server returned error code: " +
185 | str(r.status_code) + ", saying: " + server_msg, exc_info=True
186 | )
187 | statusmsg.push_status(
188 | "PYSPY SERVER ERROR: " + str(r.status_code) + " (" + server_msg + ")"
189 | )
190 | return "server_error"
191 | return r.json()
192 |
193 |
194 | def get_ship_data():
195 | '''
196 | Produces a list of ship id and ship name pairs for each ship in EVE
197 | Online, using ESI's universe/names endpoint.
198 |
199 | :return: List of lists containing ship ids and related ship names.
200 | '''
201 | all_ship_ids = get_all_ship_ids()
202 | if not isinstance(all_ship_ids, (list, tuple)) or len(all_ship_ids) < 1:
203 | Logger.error("[get_ship_data] No valid ship ids provided.", exc_info=True)
204 | return
205 |
206 | url = "https://esi.evetech.net/v2/universe/names/?datasource=tranquility"
207 | json_data = json.dumps(all_ship_ids)
208 | try:
209 | r = requests.post(url, json_data)
210 | except requests.exceptions.ConnectionError:
211 | Logger.error("[get_ship_data] No network connection.", exc_info=True)
212 | return "network_error"
213 | if r.status_code != 200:
214 | server_msg = json.loads(r.text)["error"]
215 | Logger.error(
216 | "[get_ship_data] CCP Servers returned error code: " +
217 | str(r.status_code) + ", saying: " + server_msg, exc_info=True
218 | )
219 | return "server_error"
220 | ship_data = list(map(lambda r: [r['id'], r['name']], r.json()))
221 | return ship_data
222 |
223 |
224 | def get_all_ship_ids():
225 | '''
226 | Uses ESI's insurance/prices endpoint to get all available ship ids.
227 |
228 | :return: List of ship ids as integers.
229 | '''
230 | url = "https://esi.evetech.net/v1/insurance/prices/?datasource=tranquility"
231 |
232 | try:
233 | r = requests.get(url)
234 | except requests.exceptions.ConnectionError:
235 | Logger.error("[get_ship_ids] No network connection.", exc_info=True)
236 | return "network_error"
237 | if r.status_code != 200:
238 | server_msg = json.loads(r.text)["error"]
239 | Logger.error(
240 | "[get_ship_ids] CCP Servers at returned error code: " +
241 | str(r.status_code) + ", saying: " + server_msg, exc_info=True
242 | )
243 | return "server_error"
244 |
245 | ship_ids = list(map(lambda r: str(r['type_id']), r.json()))
246 | Logger.info("[get_ship_ids] Number of ship ids found: " + str(len(ship_ids)))
247 | return ship_ids
--------------------------------------------------------------------------------
/assets/cov_cyno_64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/cov_cyno_64.png
--------------------------------------------------------------------------------
/assets/hic_64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/hic_64.png
--------------------------------------------------------------------------------
/assets/norm_cyno_64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/norm_cyno_64.png
--------------------------------------------------------------------------------
/assets/pyspy.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy.icns
--------------------------------------------------------------------------------
/assets/pyspy.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy.ico
--------------------------------------------------------------------------------
/assets/pyspy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy.png
--------------------------------------------------------------------------------
/assets/pyspy_mid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy_mid.png
--------------------------------------------------------------------------------
/assets/pyspy_small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy_small.png
--------------------------------------------------------------------------------
/assets/v0.2_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.2_screenshot.png
--------------------------------------------------------------------------------
/assets/v0.3_dark_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.3_dark_screenshot.png
--------------------------------------------------------------------------------
/assets/v0.3_light_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.3_light_screenshot.png
--------------------------------------------------------------------------------
/assets/v0.4_dark_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.4_dark_screenshot.png
--------------------------------------------------------------------------------
/assets/v0.4_light_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.4_light_screenshot.png
--------------------------------------------------------------------------------
/chkversion.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' Checks if there is a later version of PySpy available on GitHub.'''
6 | # **********************************************************************
7 | import logging.config
8 | import logging
9 | import os
10 | import sys
11 | import datetime
12 |
13 | import requests
14 | import wx
15 |
16 | import __main__
17 | import config
18 | # cSpell Checker - Correct Words****************************************
19 | # // cSpell:words russsian
20 | # **********************************************************************
21 | Logger = logging.getLogger(__name__)
22 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
23 | CURRENT_VER = config.CURRENT_VER
24 |
25 |
26 | def chk_github_update():
27 | last_check = config.OPTIONS_OBJECT.Get("last_update_check", 0)
28 | if last_check == 0 or last_check < datetime.date.today():
29 | # Get latest version available on GitHub
30 | GIT_URL = "https://api.github.com/repos/Eve-PySpy/PySpy/releases/latest"
31 | try:
32 | # verify=False to avoid certificate errors. This is not critical.
33 | latest_ver = requests.get(GIT_URL, verify=False).json()["tag_name"]
34 | Logger.info(
35 | "You are running " + CURRENT_VER + " and " +
36 | latest_ver + " is the latest version available on GitHub."
37 | )
38 | config.OPTIONS_OBJECT.Set("last_update_check", datetime.date.today())
39 | if latest_ver != CURRENT_VER:
40 | wx.CallAfter(__main__.app.PySpy.updateAlert, latest_ver, CURRENT_VER)
41 | except:
42 | Logger.info("Could not check GitHub for potential available updates.")
43 |
44 |
45 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' Define a few paths and constants used throughout other modules.'''
6 | # **********************************************************************
7 | import logging.config
8 | import logging
9 | import os
10 | import platform
11 | import sys
12 | import uuid
13 |
14 | import requests
15 | import wx # required for colour codes in DARK_MODE
16 |
17 | import optstore
18 | # cSpell Checker - Correct Words****************************************
19 | # // cSpell:words MEIPASS, datefmt, russsian, pyinstaller, posix, pyspy
20 | # // cSpell:words zkill, amarr, caldari, gallente, minmatar, isfile
21 | # **********************************************************************
22 | Logger = logging.getLogger(__name__)
23 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
24 |
25 |
26 | # Location of packaged resource files when running pyinstaller --onefile
27 | def resource_path(relative_path):
28 | """ Get absolute path to resource, works for dev and for PyInstaller """
29 | try:
30 | # PyInstaller creates a temp folder and stores path in _MEIPASS
31 | base_path = sys._MEIPASS
32 | except Exception:
33 | base_path = os.path.dirname(__file__)
34 |
35 | return os.path.join(base_path, relative_path)
36 |
37 |
38 | # If application is frozen executable
39 | if getattr(sys, 'frozen', False):
40 | ABOUT_ICON = resource_path("pyspy_mid.png")
41 | application_path = os.path.dirname(sys.executable)
42 | if os.name == "posix":
43 | PREF_PATH = os.path.expanduser("~/Library/Preferences")
44 | LOG_PATH = os.path.expanduser("~/Library/Logs")
45 | ICON_FILE = resource_path("pyspy.png")
46 |
47 | elif os.name == "nt":
48 | local_path = os.path.join(os.path.expandvars("%LocalAppData%"), "PySpy")
49 | if not os.path.exists(local_path):
50 | os.makedirs(local_path)
51 | PREF_PATH = local_path
52 | LOG_PATH = local_path
53 | ICON_FILE = resource_path("pyspy.ico")
54 | # If application is run as script
55 | elif __file__:
56 | ABOUT_ICON = resource_path("assets/pyspy_mid.png")
57 | application_path = os.path.dirname(__file__)
58 | if platform.system() == "Linux":
59 | PREF_PATH = os.path.expanduser("~/.config/pyspy")
60 | else:
61 | PREF_PATH = os.path.join(application_path, "tmp")
62 | if not os.path.exists(PREF_PATH):
63 | os.makedirs(PREF_PATH)
64 | LOG_PATH = PREF_PATH
65 | if os.name == "posix":
66 | ICON_FILE = resource_path("assets/pyspy.png")
67 | elif os.name == "nt":
68 | ICON_FILE = resource_path("assets/pyspy.ico")
69 |
70 | LOG_FILE = os.path.join(LOG_PATH, "pyspy.log")
71 | GUI_CFG_FILE = os.path.join(PREF_PATH, "pyspy.cfg")
72 | OPTIONS_FILE = os.path.join(PREF_PATH, "pyspy.pickle")
73 | DB_FILE = os.path.join(PREF_PATH, "pyspy.sqlite3")
74 |
75 | # Persisten options object
76 | OPTIONS_OBJECT = optstore.PersistentOptions(OPTIONS_FILE)
77 |
78 | # Read current version from VERSION file
79 | with open(resource_path('VERSION'), 'r') as ver_file:
80 | CURRENT_VER = ver_file.read().replace('\n', '')
81 |
82 | # Clean up old GUI_CFG_FILES and OPTIONS_OBJECT keys
83 | if os.path.isfile(GUI_CFG_FILE) and not os.path.isfile(OPTIONS_FILE):
84 | try:
85 | os.remove(GUI_CFG_FILE)
86 | except:
87 | pass
88 | if OPTIONS_OBJECT.Get("version", 0) != CURRENT_VER:
89 | print("Config file erased.")
90 | try:
91 | os.remove(GUI_CFG_FILE)
92 | except:
93 | pass
94 | for key in OPTIONS_OBJECT.ListKeys():
95 | if key != "uuid":
96 | OPTIONS_OBJECT.Del(key)
97 |
98 | # Unique identifier for usage statistics reporting
99 | if OPTIONS_OBJECT.Get("uuid", "not set") == "not set":
100 | OPTIONS_OBJECT.Set("uuid", str(uuid.uuid4()))
101 |
102 | # Store version information
103 | OPTIONS_OBJECT.Set("version", CURRENT_VER)
104 |
105 | # Various constants
106 | MAX_NAMES = 500 # The max number of char names to be processed
107 | ZKILL_DELAY = 1 # API rate limit is 10/second, pushing it a little...
108 | ZKILL_CALLS = 100
109 | GUI_TITLE = "PySpy " + CURRENT_VER
110 | FONT_SCALE_MIN = 7 # 7 equates to 70%
111 | FONT_SCALE_MAX = 13
112 | MAX_SHIP_DATA_AGE = 7 # The maximum age of ship data (used in db.prepare_ship_data)
113 | CYNO_HL_PERCENTAGE = 0.05 # The minimum cover / normal cyno probability for highlighting
114 | CACHE_TIME = 43200 # Seconds for which zkill requests are cached
115 |
116 | # Colour Scheme
117 |
118 | DARK_MODE = {
119 | "BG": wx.Colour(0, 0, 0),
120 | "TXT": wx.Colour(247, 160, 55), # Yellow
121 | "LNE": wx.Colour(15, 15, 15),
122 | "LBL": wx.Colour(160, 160, 160),
123 | "HL1": wx.Colour(237, 72, 59), # Red
124 | "HL2": wx.Colour(62, 157, 250), # Blue
125 | "HL3": wx.Colour(237, 47, 218) # Pink
126 | }
127 |
128 | NORMAL_MODE = {
129 | "BG": wx.Colour(-1, -1, -1),
130 | "TXT": wx.Colour(45, 45, 45),
131 | "LNE": wx.Colour(240, 240, 240),
132 | "LBL": wx.Colour(32, 32, 32),
133 | "HL1": wx.Colour(187, 55, 46),
134 | "HL2": wx.Colour(38, 104, 166),
135 | "HL3": wx.Colour(237, 47, 218)
136 | }
137 |
138 | # Note, Amarr and Caldari are allied and have IDs ending on uneven integers.
139 | # Likewise, Gallente and Minmatar, also allied, have even IDs.
140 | # We will use this to block certain faction alliances.
141 | FACTION_IDS = (
142 | (("500001", "Caldari"), ) +
143 | (("500002", "Minmatar"), ) +
144 | (("500003", "Amarr"), ) +
145 | (("500004", "Gallente"), )
146 | )
147 | IGNORED_FACTIONS = OPTIONS_OBJECT.Get("IgnoredFactions", 0)
148 |
149 | # Logging setup
150 | ''' For each module that requires logging, make sure to import modules
151 | logging and this config. Then get a new logger at the beginning
152 | of the module like this: "Logger = logging.getLogger(__name__)" and
153 | produce log messages like this: "Logger.error("text", exc_info=True)"
154 | '''
155 | LOG_DETAIL = 'DEBUG'
156 |
157 | LOG_DICT = {
158 | 'version': 1,
159 | 'disable_existing_loggers': True,
160 | 'formatters': {
161 | 'standard': {
162 | 'format': '%(asctime)s [%(levelname)s] (%(name)s): %(message)s',
163 | 'datefmt': '%d-%b-%Y %I:%M:%S %p'
164 | },
165 | },
166 | 'handlers': {
167 | 'stream_handler': {
168 | 'level': 'DEBUG',
169 | 'formatter': 'standard',
170 | 'class': 'logging.StreamHandler',
171 | },
172 | 'file_handler': {
173 | 'level': 'DEBUG',
174 | 'filename': LOG_FILE,
175 | 'class': 'logging.FileHandler',
176 | 'formatter': 'standard'
177 | },
178 | 'timed_rotating_file_handler': {
179 | 'level': 'DEBUG',
180 | 'filename': LOG_FILE,
181 | 'class': 'logging.handlers.TimedRotatingFileHandler',
182 | 'when': 'D',
183 | 'interval': 7, # Log file rolling over every week
184 | 'backupCount': 1,
185 | 'formatter': 'standard'
186 | },
187 | },
188 | 'loggers': {
189 | '': {
190 | 'handlers': ['timed_rotating_file_handler', 'stream_handler'],
191 | 'level': 'DEBUG',
192 | 'propagate': True
193 | },
194 | }
195 | }
196 | logging.config.dictConfig(LOG_DICT)
197 |
--------------------------------------------------------------------------------
/db.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | '''Establishes an in memory SQLite3 database and creates a few tables as
6 | well as provides a function to write records to the database.'''
7 | # **********************************************************************
8 | import datetime
9 | import logging
10 | import sqlite3
11 |
12 | import config
13 | import apis
14 | # cSpell Checker - Correct Words****************************************
15 | # // cSpell:words wrusssian, sqlite, blops, russsian
16 | # **********************************************************************
17 | Logger = logging.getLogger(__name__)
18 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
19 |
20 |
21 | def connect_memory_db():
22 | '''
23 | Create in memory database
24 |
25 | @returns: connection and cursor objects as conn and cur
26 | '''
27 | conn = sqlite3.connect(":memory:")
28 | conn.isolation_level = None
29 | cur = conn.cursor()
30 | cur.execute("PRAGMA journal_mode = TRUNCATE")
31 | prepare_tables(conn, cur)
32 | prepare_ship_data(conn, cur)
33 | return conn, cur
34 |
35 | def connect_persistent_db():
36 | '''
37 | Create on disk database
38 |
39 | @returns: connection and cursor objects as conn and cur
40 | '''
41 | Logger.info("Connecting to persistent DB - {}".format(config.DB_FILE))
42 | conn = sqlite3.connect(config.DB_FILE)
43 | cur = conn.cursor()
44 | cur.execute("PRAGMA journal_mode = TRUNCATE")
45 | prepare_tables(conn, cur)
46 | prepare_ship_data(conn, cur)
47 | return conn, cur
48 |
49 | def prepare_tables(conn, cur):
50 | '''
51 | Create a few tables, unless they already exist. Do not close the
52 | connection as it will continue to be used by the calling
53 | function.
54 | '''
55 | cur.execute(
56 | '''CREATE TABLE IF NOT EXISTS characters (char_name TEXT UNIQUE , char_id INT PRIMARY KEY ,
57 | corp_id INT, alliance_id INT, faction_id INT, kills INT,
58 | blops_kills INT, hic_losses INT, week_kills INT, losses INT,
59 | solo_ratio NUMERIC, sec_status NUMERIC, last_loss_date INT,
60 | last_kill_date INT, avg_attackers NUMERIC, covert_prob NUMERIC,
61 | normal_prob NUMERIC, last_cov_ship INT, last_norm_ship INT,
62 | abyssal_losses INT, last_update TEXT)'''
63 | )
64 | cur.execute(
65 | '''CREATE TABLE IF NOT EXISTS corporations (id INT PRIMARY KEY, name TEXT)'''
66 | )
67 | cur.execute(
68 | '''CREATE TABLE IF NOT EXISTS alliances (id INT PRIMARY KEY, name TEXT)'''
69 | )
70 | cur.execute(
71 | '''CREATE TABLE IF NOT EXISTS factions (id INT PRIMARY KEY, name TEXT)'''
72 | )
73 | cur.execute(
74 | '''CREATE TABLE IF NOT EXISTS ships (id INT PRIMARY KEY, name TEXT)'''
75 | )
76 | # Populate this table with the 4 faction warfare factions
77 | cur.executemany(
78 | '''INSERT OR REPLACE INTO factions (id, name) VALUES (?, ?)''',
79 | config.FACTION_IDS
80 | )
81 | conn.commit()
82 |
83 |
84 | def prepare_ship_data(conn, cur):
85 | '''
86 | Download all ship ids and names from ESI and save in OPTIONS_OBJECT.
87 | '''
88 | ship_data_date = config.OPTIONS_OBJECT.Get("ship_data_date", 0)
89 | max_age = config.MAX_SHIP_DATA_AGE
90 | max_date = datetime.date.today() - datetime.timedelta(days=max_age)
91 | if ship_data_date == 0 or ship_data_date < max_date:
92 | config.OPTIONS_OBJECT.Set("ship_data", apis.get_ship_data())
93 | config.OPTIONS_OBJECT.Set("ship_data_date", datetime.date.today())
94 | # Populate ships table with ids and names for all ships in game
95 | cur.executemany(
96 | '''INSERT OR REPLACE INTO ships (id, name) VALUES (?, ?)''',
97 | config.OPTIONS_OBJECT.Get("ship_data", 0)
98 | )
99 | conn.commit()
100 |
101 |
102 | def write_many_to_db(conn, cur, query_string, records, keepalive=True):
103 | '''
104 | Take a database connection and write records to it. Afterwards,
105 | leave the connection alive, unless keepalive=False and return the
106 | number of records added to the database.
107 |
108 | @returns: records_added
109 | '''
110 | try:
111 | cur.executemany(query_string, records)
112 | conn.commit()
113 | except Exception as e:
114 | Logger.error("Failed to write orders to database. {}".format(e), exc_info=True)
115 | raise Exception
116 | records_added = conn.total_changes
117 | if not keepalive:
118 | cur.execute("PRAGMA optimize")
119 | conn.close()
120 | return records_added
121 |
--------------------------------------------------------------------------------
/gui.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | '''Simple wxpython GUI with 1 frame, using persistent properties.'''
6 | # **********************************************************************
7 | import datetime
8 | import logging
9 | import os
10 | import webbrowser
11 |
12 | import wx
13 | import wx.grid as WXG
14 | import wx.lib.agw.persist as pm
15 |
16 | import config
17 |
18 | import db
19 |
20 | import aboutdialog
21 | import highlightdialog
22 | import ignoredialog
23 | import sortarray
24 | import statusmsg
25 | # cSpell Checker - Correct Words****************************************
26 | # // cSpell:words wrusssian, wxpython, HRULES, VRULES, ELLIPSIZE, zkill,
27 | # // cSpell:words blops, Unregister, russsian, chkversion, posix,
28 | # // cSpell:words Gallente, Minmatar, Amarr, Caldari, ontop, hics, npsi
29 | # **********************************************************************
30 | Logger = logging.getLogger(__name__)
31 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
32 |
33 |
34 | class Frame(wx.Frame):
35 | def __init__(self, *args, **kwds):
36 |
37 | # Persistent Options
38 | self.options = config.OPTIONS_OBJECT
39 |
40 | kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE # wx.RESIZE_BORDER
41 | wx.Frame.__init__(self, *args, **kwds)
42 | self.SetName("Main Window")
43 |
44 | self.Font = self.Font.Scaled(self.options.Get("FontScale", 1))
45 |
46 | # Set stay on-top unless user deactivated it
47 | if self.options.Get("StayOnTop", True):
48 | self.ToggleWindowStyle(wx.STAY_ON_TOP)
49 |
50 | # Set parameters for columns
51 | self.columns = (
52 | # Index, Heading, Format, Default Width, Can Toggle, Default Show, Menu Name, Outlist Column
53 | [0, "ID", wx.ALIGN_LEFT, 0, False, False, "", 0],
54 | [1, "Warning", wx.ALIGN_LEFT, 80, True, True, "Warning\tCTRL+ALT+X"],
55 | [2, "Faction ID", wx.ALIGN_LEFT, 0, False, False, "", 1],
56 | [3, "Character", wx.ALIGN_LEFT, 100, False, True, "", 2],
57 | [4, "Security", wx.ALIGN_RIGHT, 50, True, False, "&Security\tCTRL+ALT+S", 15],
58 | [5, "CorpID", wx.ALIGN_LEFT, 0, False, False, "", 3],
59 | [6, "Corporation", wx.ALIGN_LEFT, 100, True, True, "Cor&poration\tCTRL+ALT+P", 4],
60 | [7, "AllianceID", wx.ALIGN_LEFT, 0, False, False, "-", 5],
61 | [8, "Alliance", wx.ALIGN_LEFT, 150, True, True, "All&iance\tCTRL+ALT+I", 6],
62 | [9, "Faction", wx.ALIGN_LEFT, 50, True, False, "&Faction\tCTRL+ALT+F", 7],
63 | [10, "Kills", wx.ALIGN_RIGHT, 50, True, True, "&Kills\tCTRL+ALT+K", 10],
64 | [11, "Losses", wx.ALIGN_RIGHT, 50, True, True, "&Losses\tCTRL+ALT+L", 13],
65 | [12, "Last Wk", wx.ALIGN_RIGHT, 50, True, True, "Last &Wk\tCTRL+ALT+W", 9],
66 | [13, "Solo", wx.ALIGN_RIGHT, 50, True, False, "S&olo\tCTRL+ALT+O", 14],
67 | [14, "BLOPS", wx.ALIGN_RIGHT, 50, True, False, "&BLOPS\tCTRL+ALT+B", 11],
68 | [15, "HICs", wx.ALIGN_RIGHT, 50, True, False, "&HICs\tCTRL+ALT+H", 12],
69 | [16, "Last Loss", wx.ALIGN_RIGHT, 60, True, True, "Days since last Loss\tCTRL+ALT+[", 16],
70 | [17, "Last Kill", wx.ALIGN_RIGHT, 60, True, True, "Days since last Kill\tCTRL+ALT+]", 17],
71 | [18, "Avg. Attackers", wx.ALIGN_RIGHT, 100, True, True, "&Average Attackers\tCTRL+ALT+A", 18],
72 | [19, "Covert Cyno", wx.ALIGN_RIGHT, 100, True, True, "&Covert Cyno Probability\tCTRL+ALT+C", 19],
73 | [20, "Regular Cyno", wx.ALIGN_RIGHT, 100, True, True, "&Regular Cyno Probability\tCTRL+ALT+R", 20],
74 | [21, "Last Covert Cyno", wx.ALIGN_RIGHT, 100, True, True, "&Last Covert Cyno Ship Loss\tCTRL+ALT+<", 21],
75 | [22, "Last Regular Cyno", wx.ALIGN_RIGHT, 110, True, True, "&Last Regular Cyno Ship Loss\tCTRL+ALT+>", 22],
76 | [23, "Abyssal Losses", wx.ALIGN_RIGHT, 100, True, False, "&Abyssal Losses\tCTRL+ALT+Y", 23],
77 | [24, "", None, 1, False, True, ""], # Need for _stretchLastCol()
78 | )
79 |
80 | # Define the menu bar and menu items
81 | self.menubar = wx.MenuBar()
82 | self.menubar.SetName("Menubar")
83 | if os.name == "nt": # For Windows
84 | self.file_menu = wx.Menu()
85 | self.file_about = self.file_menu.Append(wx.ID_ANY, '&About\tCTRL+A')
86 | self.file_menu.Bind(wx.EVT_MENU, self._openAboutDialog, self.file_about)
87 | self.file_quit = self.file_menu.Append(wx.ID_ANY, 'Quit PySpy')
88 | self.file_menu.Bind(wx.EVT_MENU, self.OnQuit, self.file_quit)
89 | self.menubar.Append(self.file_menu, 'File')
90 | if os.name == "posix": # For macOS
91 | self.help_menu = wx.Menu()
92 | self.help_about = self.help_menu.Append(wx.ID_ANY, '&About\tCTRL+A')
93 | self.help_menu.Bind(wx.EVT_MENU, self._openAboutDialog, self.help_about)
94 | self.menubar.Append(self.help_menu, 'Help')
95 |
96 | # View menu is platform independent
97 | self.view_menu = wx.Menu()
98 |
99 | self._createShowColMenuItems()
100 |
101 | self.view_menu.AppendSeparator()
102 |
103 | # Ignore Factions submenu for view menu
104 | self.factions_sub = wx.Menu()
105 | self.view_menu.Append(wx.ID_ANY, "Ignore Factions", self.factions_sub)
106 |
107 | self.ignore_galmin = self.factions_sub.AppendRadioItem(wx.ID_ANY, "Gallente / Minmatar")
108 | self.factions_sub.Bind(wx.EVT_MENU, self._toggleIgnoreFactions, self.ignore_galmin)
109 | self.ignore_galmin.Check(self.options.Get("IgnoreGalMin", False))
110 |
111 | self.ignore_amacal = self.factions_sub.AppendRadioItem(wx.ID_ANY, "Amarr / Caldari")
112 | self.factions_sub.Bind(wx.EVT_MENU, self._toggleIgnoreFactions, self.ignore_amacal)
113 | self.ignore_amacal.Check(self.options.Get("IgnoreAmaCal", False))
114 |
115 | self.ignore_none = self.factions_sub.AppendRadioItem(wx.ID_ANY, "None")
116 | self.factions_sub.Bind(wx.EVT_MENU, self._toggleIgnoreFactions, self.ignore_none)
117 | self.ignore_none.Check(self.options.Get("IgnoreNone", True))
118 |
119 | # Higlighting submenu for view menu
120 | self.hl_sub = wx.Menu()
121 | self.view_menu.Append(wx.ID_ANY, "Highlighting", self.hl_sub)
122 |
123 | self.hl_blops = self.hl_sub.AppendCheckItem(wx.ID_ANY, "&BLOPS Kills\t(red)")
124 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_blops)
125 | self.hl_blops.Check(self.options.Get("HlBlops", True))
126 |
127 | self.hl_hic = self.hl_sub.AppendCheckItem(wx.ID_ANY, "&HIC Losses\t(red)")
128 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_hic)
129 | self.hl_hic.Check(self.options.Get("HlHic", True))
130 |
131 | self.hl_cyno = self.hl_sub.AppendCheckItem(
132 | wx.ID_ANY,
133 | "Cyno Characters (>" +
134 | "{:.0%}".format(config.CYNO_HL_PERCENTAGE) +
135 | " cyno losses)\t(blue)"
136 | )
137 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_cyno)
138 | self.hl_cyno.Check(self.options.Get("HlCyno", True))
139 |
140 | self.hl_list = self.hl_sub.AppendCheckItem(wx.ID_ANY, "&Highlighted Entities List\t(pink)")
141 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_list)
142 | self.hl_list.Check(self.options.Get("HlList", True))
143 |
144 | # Font submenu for font scale
145 | self.font_sub = wx.Menu()
146 | self.view_menu.Append(wx.ID_ANY, "Font Scale", self.font_sub)
147 |
148 | self._fontScaleMenu(config.FONT_SCALE_MIN, config.FONT_SCALE_MAX)
149 |
150 | self.view_menu.AppendSeparator()
151 |
152 | # Toggle Stay on-top
153 | self.stay_ontop = self.view_menu.AppendCheckItem(
154 | wx.ID_ANY, 'Stay on-&top\tCTRL+T'
155 | )
156 | self.view_menu.Bind(wx.EVT_MENU, self._toggleStayOnTop, self.stay_ontop)
157 | self.stay_ontop.Check(self.options.Get("StayOnTop", True))
158 |
159 | # Toggle Dark-Mode
160 | self.dark_mode = self.view_menu.AppendCheckItem(
161 | wx.ID_ANY, '&Dark Mode\tCTRL+D'
162 | )
163 | self.dark_mode.Check(self.options.Get("DarkMode", False))
164 | self.view_menu.Bind(wx.EVT_MENU, self._toggleDarkMode, self.dark_mode)
165 | self.use_dm = self.dark_mode.IsChecked()
166 |
167 | self.menubar.Append(self.view_menu, 'View ') # Added space to avoid autogenerated menu items on Mac
168 |
169 | # Options Menubar
170 | self.opt_menu = wx.Menu()
171 |
172 | self.review_ignore = self.opt_menu.Append(wx.ID_ANY, "&Review Ignored Entities\tCTRL+R")
173 | self.opt_menu.Bind(
174 | wx.EVT_MENU,
175 | self._openIgnoreDialog,
176 | self.review_ignore
177 | )
178 |
179 | self.review_highlight = self.opt_menu.Append(wx.ID_ANY, "&Review Highlighted Entities\tCTRL+H")
180 | self.opt_menu.Bind(
181 | wx.EVT_MENU,
182 | self._openHightlightDialog,
183 | self.review_highlight
184 | )
185 |
186 | self.opt_menu.AppendSeparator()
187 |
188 | self.ignore_all = self.opt_menu.Append(wx.ID_ANY, "&Set NPSI Ignore List\tCTRL+SHIFT+S")
189 | self.opt_menu.Bind(
190 | wx.EVT_MENU,
191 | self._showNpsiDialog,
192 | self.ignore_all
193 | )
194 |
195 | self.clear_ignore = self.opt_menu.Append(wx.ID_ANY, "&Clear NPSI Ignore List\tCTRL+SHIFT+C")
196 | self.opt_menu.Bind(
197 | wx.EVT_MENU,
198 | self._clearNpsiList,
199 | self.clear_ignore
200 | )
201 |
202 | self.opt_menu.AppendSeparator()
203 |
204 | # Toggle zKillboard linking mode
205 | self.zkill_mode = self.opt_menu.AppendCheckItem(
206 | wx.ID_ANY, '&zKillboard Advanced Linking\tCTRL+ALT+Z'
207 | )
208 | self.zkill_mode.Check(self.options.Get("ZkillMode", False))
209 | self.opt_menu.Bind(wx.EVT_MENU, self._toggleZkillMode, self.zkill_mode)
210 | self.use_adv_zkill = self.zkill_mode.IsChecked()
211 |
212 | self.opt_menu.AppendSeparator()
213 |
214 | self.clear_cache = self.opt_menu.Append(wx.ID_ANY, '&Clear Character Cache')
215 | self.opt_menu.Bind(wx.EVT_MENU, self.clear_character_cache, self.clear_cache)
216 | # self.file_about = self.file_menu.Append(wx.ID_ANY, '&About\tCTRL+A')
217 | # self.file_menu.Bind(wx.EVT_MENU, self._openAboutDialog, self.file_about)
218 |
219 | self.menubar.Append(self.opt_menu, 'Options')
220 |
221 | # Set the grid object
222 | self.grid = wx.grid.Grid(self, wx.ID_ANY)
223 | self.grid.CreateGrid(0, 0)
224 | self.grid.SetName("Output List")
225 |
226 | # Allow to change window transparency using this slider.
227 | self.alpha_slider = wx.Slider(self, wx.ID_ANY, 250, 50, 255)
228 | self.alpha_slider.SetName("Transparency_Slider")
229 | self.Bind(wx.EVT_SLIDER, self._setTransparency)
230 |
231 | # The status label shows various info and error messages.
232 | self.status_label = wx.StaticText(
233 | self,
234 | wx.ID_ANY,
235 | "Please copy some EVE character names to clipboard...",
236 | style=wx.ALIGN_LEFT | wx.ST_ELLIPSIZE_END
237 | )
238 | self.status_label.SetName("Status_Bar")
239 |
240 | # First set default properties, then restore persistence if any
241 | self.__set_properties()
242 |
243 | # Set up Persistence Manager
244 | self._persistMgr = pm.PersistenceManager.Get()
245 | self._persistMgr.SetPersistenceFile(config.GUI_CFG_FILE)
246 | self._persistMgr.RegisterAndRestoreAll(self)
247 |
248 | # Column resize to trigger last column stretch to fill blank canvas.
249 | self.Bind(wx.grid.EVT_GRID_COL_SIZE, self._stretchLastCol, self.grid)
250 |
251 | # Window resize to trigger last column stretch to fill blank canvas.
252 | self.Bind(wx.EVT_SIZE, self._stretchLastCol, self)
253 |
254 | # Ensure that Persistence Manager saves window location on close
255 | self.Bind(wx.EVT_CLOSE, self.OnClose)
256 |
257 | # Bind double click on list item to zKill link.
258 | self.Bind(wx.grid.EVT_GRID_CELL_LEFT_DCLICK, self._goToZKill, self.grid)
259 |
260 | # Bind right click on list item to ignore character.
261 | self.Bind(wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self._showContextMenu, self.grid)
262 |
263 | # Bind left click on column label to sorting
264 | self.Bind(wx.grid.EVT_GRID_COL_SORT, self.sortOutlist, self.grid)
265 |
266 | # Set transparency based off restored slider
267 | self._setTransparency()
268 | self.__do_layout()
269 |
270 | def __set_properties(self, dark_toggle=None):
271 | '''
272 | Set the initial properties for the various widgets.
273 |
274 | :param `dark_toggle`: Boolean indicating if only the properties
275 | related to the colour scheme should be set or everything.
276 | '''
277 | # Colour Scheme Dictionaries
278 | self.dark_dict = config.DARK_MODE
279 | self.normal_dict = config.NORMAL_MODE
280 |
281 | # Colour Scheme
282 | if not self.options.Get("DarkMode", False):
283 | self.bg_colour = self.normal_dict["BG"]
284 | self.txt_colour = self.normal_dict["TXT"]
285 | self.lne_colour = self.normal_dict["LNE"]
286 | self.lbl_colour = self.normal_dict["LBL"]
287 | self.hl1_colour = self.normal_dict["HL1"]
288 | self.hl2_colour = self.normal_dict["HL2"]
289 | self.hl3_colour = self.normal_dict["HL3"]
290 | else:
291 | self.bg_colour = self.dark_dict["BG"]
292 | self.txt_colour = self.dark_dict["TXT"]
293 | self.lne_colour = self.dark_dict["LNE"]
294 | self.lbl_colour = self.dark_dict["LBL"]
295 | self.hl1_colour = self.dark_dict["HL1"]
296 | self.hl2_colour = self.dark_dict["HL2"]
297 | self.hl3_colour = self.dark_dict["HL3"]
298 |
299 | # Set default colors
300 | self.SetBackgroundColour(self.bg_colour)
301 | self.SetForegroundColour(self.txt_colour)
302 | self.grid.SetDefaultCellBackgroundColour(self.bg_colour)
303 | self.grid.SetDefaultCellTextColour(self.txt_colour)
304 | self.grid.SetGridLineColour(self.lne_colour)
305 | self.grid.SetLabelBackgroundColour(self.bg_colour)
306 | self.grid.SetLabelTextColour(self.lbl_colour)
307 | self.status_label.SetForegroundColour(self.lbl_colour)
308 |
309 | # Do not reset window size etc. if only changing colour scheme.
310 | if dark_toggle:
311 | return
312 |
313 | self.SetTitle(config.GUI_TITLE)
314 | self.SetSize((720, 400))
315 | self.SetMenuBar(self.menubar)
316 | # Insert columns based on parameters provided in col_def
317 |
318 | # self.grid.CreateGrid(0, 0)
319 | if self.grid.GetNumberCols() < len(self.columns):
320 | self.grid.AppendCols(len(self.columns))
321 | self.grid.SetColLabelSize(self.grid.GetDefaultRowSize() + 2)
322 | self.grid.SetRowLabelSize(0)
323 | self.grid.EnableEditing(0)
324 | self.grid.DisableCellEditControl()
325 | self.grid.EnableDragRowSize(0)
326 | self.grid.EnableDragGridSize(0)
327 | self.grid.SetSelectionMode(wx.grid.Grid.SelectRows)
328 | self.grid.SetColLabelAlignment(wx.ALIGN_CENTRE, wx.ALIGN_BOTTOM)
329 | self.grid.ClipHorzGridLines(False)
330 | # self.grid.ClipVertGridLines(False)
331 | # Disable visual highlighting of selected cell to look more like listctrl
332 | self.grid.SetCellHighlightPenWidth(0)
333 | colidx = 0
334 | for col in self.columns:
335 | self.grid.SetColLabelValue(
336 | col[0], # Index
337 | col[1], # Heading
338 | )
339 | # self.grid.SetColSize(colidx, col[3])
340 | colidx += 1
341 | # Transparency slider
342 | self.alpha_slider.SetMinSize((100, 20))
343 | # Window icon
344 | icon = wx.Icon()
345 | icon.CopyFromBitmap(wx.Bitmap(config.ICON_FILE, wx.BITMAP_TYPE_ANY))
346 | self.SetIcon(icon)
347 |
348 | def __do_layout(self):
349 | '''
350 | Assigns the various widgets to sizers and calls a number of helper
351 | functions.
352 | '''
353 | sizer_main = wx.BoxSizer(wx.VERTICAL)
354 | sizer_bottom = wx.BoxSizer(wx.HORIZONTAL)
355 | sizer_main.Add(self.grid, 1, wx.EXPAND, 0)
356 | sizer_bottom.Add(self.status_label, 1, wx.ALIGN_CENTER_VERTICAL, 0)
357 | static_line = wx.StaticLine(self, wx.ID_ANY, style=wx.LI_VERTICAL)
358 | sizer_bottom.Add(static_line, 0, wx.EXPAND, 0)
359 | sizer_bottom.Add(self.alpha_slider, 0, wx.ALIGN_RIGHT, 0)
360 | sizer_main.Add(sizer_bottom, 0, wx.ALIGN_BOTTOM | wx.ALL | wx.EXPAND, 1)
361 | self.SetSizer(sizer_main)
362 | self.Layout()
363 | self._restoreColWidth()
364 | self._stretchLastCol()
365 |
366 | def _fontScaleMenu(self, lower, upper):
367 | '''
368 | Populates the font scale sub menu with scale percentages
369 | based on input bounds.
370 |
371 | :param `lower`: The minimum scale represented as a decimal, e.g. 0.6
372 |
373 | :param `upper`: The maximum scale represented as a decimal, e.g. 1.4
374 | '''
375 | for scale in range(lower, upper):
376 | scale = scale / 10
377 | self.font_sub.AppendRadioItem(wx.ID_ANY, "{:.0%}".format(scale))
378 | self.font_sub.Bind(
379 | wx.EVT_MENU,
380 | lambda evt, scale=scale: self._setFontScale(scale, evt),
381 | self.font_sub.MenuItems[-1]
382 | )
383 | if scale == self.options.Get("FontScale", 1):
384 | self.font_sub.MenuItems[-1].Check(True)
385 |
386 | def _setFontScale(self, scale, evt=None):
387 | '''
388 | Changes the font scaling and saves it in the pickle container.
389 |
390 | :param `scale`: Float representing the font scale.
391 | '''
392 | self.Font = self.Font.Scaled(scale)
393 | self.options.Set("FontScale", scale)
394 |
395 | def _createShowColMenuItems(self):
396 | '''
397 | Populates the View menu with show column toggle menu items for
398 | each column that is toggleable. It uses the information provided
399 | in self.columns.
400 | '''
401 | # For each column, create show / hide menu items, if hideable
402 | self.col_menu_items = [[] for i in self.columns]
403 | for col in self.columns:
404 | if not col[4]: # Do not add menu item if column not hideable
405 | continue
406 | index = col[0]
407 | options_key = "Show" + col[1]
408 | menu_name = "Show " + col[6]
409 | self.col_menu_items[index] = self.view_menu.AppendCheckItem(
410 | wx.ID_ANY,
411 | menu_name
412 | )
413 | # Column show / hide, depending on user settings, if any
414 | checked = self.options.Get(
415 | options_key,
416 | self.columns[index][5]
417 | )
418 | self.col_menu_items[index].Check(
419 | self.options.Get(options_key, checked)
420 | )
421 | # Bind new menu item to toggleColumn method
422 | self.view_menu.Bind(
423 | wx.EVT_MENU,
424 | lambda evt, index=index: self._toggleColumn(index, evt),
425 | self.col_menu_items[index]
426 | )
427 |
428 | def _toggleColumn(self, index, event=None):
429 | '''
430 | Depending on the respective menu item state, either reveals or
431 | hides a column. If it hides a column, it first stores the old
432 | column width in self.options to allow for subsequent restore.
433 |
434 | :param `index`: Integer representing the index of the column
435 | which is to shown / hidden.
436 | '''
437 | try:
438 | checked = self.col_menu_items[index].IsChecked()
439 | except:
440 | checked = False
441 | col_name = self.columns[index][1]
442 | if checked:
443 | default_width = self.columns[index][3]
444 | col_width = self.options.Get(col_name, default_width)
445 | if col_width > 0:
446 | self.grid.SetColSize(index, col_width)
447 | else:
448 | self.grid.SetColSize(index, default_width)
449 | else:
450 | col_width = self.grid.GetColSize(index)
451 | # Only save column status if column is actually hideable
452 | if self.columns[index][4]:
453 | self.options.Set(col_name, col_width)
454 | self.grid.HideCol(index)
455 | self._stretchLastCol()
456 |
457 | def _stretchLastCol(self, event=None):
458 | '''
459 | Makes sure the last column fills any blank space of the
460 | grid. For this reason, the last list item of self.columns should
461 | describe an empty column.
462 | '''
463 | grid_width = self.grid.Size.GetWidth()
464 | cols_width = 0
465 | for index in range(self.columns[-1][0] + 1):
466 | cols_width += self.grid.GetColSize(index)
467 | stretch_width = grid_width - cols_width
468 | last_col_width = max(
469 | self.grid.GetColSize(index) + stretch_width,
470 | self.columns[index][3]
471 | )
472 | self.grid.SetColSize(index, last_col_width)
473 | self.Layout()
474 | if event is not None:
475 | event.Skip(True)
476 |
477 | def appendString(self, org, app):
478 | """
479 | Appends a String to another string with a "+" if the org string is not "".
480 |
481 | :param org: Original String
482 | :param app: String which is to be appended to the org string
483 | :return:
484 | """
485 | if org == "-":
486 | return app
487 | else:
488 | return org + " + " + app
489 |
490 | def updateList(self, outlist, duration=None):
491 | '''
492 | `updateList()` takes the output of `output_list()` in `analyze.py` (via
493 | `sortOutlist()`) or a copy thereof stored in self.option, and uses it
494 | to populate the grid widget. Before it does so, it checks each
495 | item in outlist against a list of ignored characters, corporations
496 | and alliances. Finally, it highlights certain characters and
497 | updates the statusbar message.
498 |
499 | :param `outlist`: A list of rows with character data.
500 |
501 | :param `duration`: Time in seconds taken to query all relevant
502 | databases for each character.
503 | '''
504 | # If updateList() gets called before outlist has been provided, do nothing
505 | if outlist is None:
506 | return
507 | # Clean up grid
508 | if self.grid.GetNumberRows() > 0:
509 | self.grid.DeleteRows(numRows=self.grid.GetNumberRows())
510 | self.grid.AppendRows(len(outlist))
511 | # Add any NPSI fleet related characters to ignored_list
512 | npsi_list = self.options.Get("NPSIList", default=[])
513 | ignored_list = self.options.Get("ignoredList", default=[])
514 | highlighted_list = self.options.Get("highlightedList", default=[])
515 | hl_blops = self.options.Get("HlBlops", True)
516 | hl_hic = self.options.Get("HlHic", True)
517 | hl_cyno = self.options.Get("HlCyno", True)
518 | hl_list = self.options.Get("HlList", True)
519 | hl_cyno_prob = config.CYNO_HL_PERCENTAGE
520 | ignore_count = 0
521 | rowidx = 0
522 | for r in outlist:
523 |
524 | ignore = False
525 | for rec in ignored_list:
526 | if r[0] == rec[0] or r[3] == rec[0] or r[5] == rec[0]:
527 | ignore = True
528 | for rec in npsi_list:
529 | if r[0] == rec[0]:
530 | ignore = True
531 | if ignore:
532 | self.grid.HideRow(rowidx)
533 | ignore_count += 1
534 |
535 | # Schema depending on output_list() in analyze.py
536 | id = r[0] # Hidden, used for zKillboard link
537 | faction_id = r[1] # Hidden, used for faction ignoring
538 | name = r[2]
539 | corp_id = r[3]
540 | corp_name = r[4]
541 | alliance_id = r[5]
542 | alliance_name = r[6]
543 | faction = r[7] if r[7] is not None else "-"
544 | allies = "{:,}".format(int(r[8]))
545 |
546 | # Add number of allies to alliance name
547 | if alliance_name is not None:
548 | alliance_name = alliance_name + " (" + allies + ")"
549 | else:
550 | alliance_name = "-"
551 |
552 | # zKillboard data is "n.a." unless available
553 | week_kills = kills = blops_kills = hic_losses = "n.a."
554 | losses = solo_ratio = sec_status = "n.a."
555 |
556 | if r[13] is not None:
557 | week_kills = "{:,}".format(int(r[9])) if int(r[9]) >0 else "-"
558 | kills = "{:,}".format(int(r[10]))
559 | blops_kills = "{:,}".format(int(r[11])) if int(r[11]) >0 else "-"
560 | hic_losses = "{:,}".format(int(r[12])) if int(r[12]) >0 else "-"
561 | losses = "{:,}".format(int(r[13]))
562 | solo_ratio = "{:.0%}".format(float(r[14]))
563 | sec_status = "{:.1f}".format(float(r[15]))
564 |
565 | # PySpy proprietary data is "n.a." unless available
566 | last_loss = last_kill = covert_ship = normal_ship = "n.a."
567 | avg_attackers = covert_prob = normal_prob = abyssal_losses = "n.a."
568 | cov_prob_float = norm_prob_float = 0
569 | if r[16] is not None:
570 |
571 | if int(r[16]) > 0:
572 | last_loss = str((
573 | datetime.date.today() -
574 | datetime.datetime.strptime(str(r[16]),'%Y%m%d').date()
575 | ).days) + "d"
576 | else:
577 | last_loss = "n.a."
578 |
579 | if int(r[17]) > 0:
580 | last_kill = str((
581 | datetime.date.today() -
582 | datetime.datetime.strptime(str(r[17]),'%Y%m%d').date()
583 | ).days) + "d"
584 | else:
585 | last_kill = "n.a."
586 |
587 | avg_attackers = "{:.1f}".format(float(r[18]))
588 | cov_prob_float = r[19]
589 | covert_prob = "{:.0%}".format(cov_prob_float) if cov_prob_float >0 else "-"
590 | norm_prob_float = r[20]
591 | normal_prob = "{:.0%}".format(norm_prob_float) if norm_prob_float >0 else "-"
592 | covert_ship = r[21]
593 | normal_ship = r[22]
594 | abyssal_losses = r[23] if int(r[23]) >0 else "-"
595 |
596 | out = [
597 | id,
598 | "-",
599 | faction_id,
600 | name,
601 | sec_status,
602 | corp_id,
603 | corp_name,
604 | alliance_id,
605 | alliance_name,
606 | faction,
607 | kills,
608 | losses,
609 | week_kills,
610 | solo_ratio,
611 | blops_kills,
612 | hic_losses,
613 | last_loss,
614 | last_kill,
615 | avg_attackers,
616 | covert_prob,
617 | normal_prob,
618 | covert_ship,
619 | normal_ship,
620 | abyssal_losses
621 | ]
622 |
623 | # Check if character belongs to a faction that should be ignored
624 | if faction_id != 0:
625 | if config.IGNORED_FACTIONS == 2 and faction_id % 2 == 0:
626 | self.grid.HideRow(rowidx)
627 | if config.IGNORED_FACTIONS == 1 and faction_id % 2 != 0:
628 | self.grid.HideRow(rowidx)
629 | colidx = 0
630 |
631 | if hl_blops and r[9] is not None and r[11] > 0: # Add BLOPS to Warning Column
632 | out[1] = self.appendString(out[1], "BLOPS")
633 | if hl_hic and r[9] is not None and r[12] > 0:
634 | out[1] = self.appendString(out[1], "HIC") # Add HIC to Warning Column
635 | if hl_cyno and (
636 | cov_prob_float >= hl_cyno_prob or norm_prob_float >= hl_cyno_prob): # Add CYNO to Warnin Column
637 | out[1] = self.appendString(out[1], "CYNO")
638 |
639 | # Cell text formatting
640 | for value in out:
641 | color = False
642 | self.grid.SetCellValue(rowidx, colidx, str(value))
643 | self.grid.SetCellAlignment(self.columns[colidx][2], rowidx, colidx)
644 | if hl_blops and r[9] is not None and r[11] > 0: # Highlight BLOPS chars
645 | self.grid.SetCellTextColour(rowidx, colidx, self.hl1_colour)
646 | color = True
647 | if hl_hic and r[9] is not None and r[12] > 0: # Highlight HIC chars
648 | self.grid.SetCellTextColour(rowidx, colidx, self.hl1_colour)
649 | color = True
650 | if hl_cyno and (
651 | cov_prob_float >= hl_cyno_prob or norm_prob_float >= hl_cyno_prob): # Highlight CYNO chars
652 | self.grid.SetCellTextColour(rowidx, colidx, self.hl2_colour)
653 | color = True
654 |
655 | for entry in highlighted_list: # Highlight chars from highlight list
656 | if hl_list and (entry[1] == out[3] or entry[1] == out[6]or entry[1] == out[8][:-4]):
657 | self.grid.SetCellTextColour(rowidx, colidx, self.hl3_colour)
658 | color = True
659 |
660 | if not color:
661 | self.grid.SetCellTextColour(rowidx, colidx, self.txt_colour)
662 | colidx += 1
663 | rowidx += 1
664 |
665 | if duration is not None:
666 | statusmsg.push_status(
667 | str(len(outlist) - ignore_count) +
668 | " characters analysed, in " + str(duration) +
669 | " seconds (" + str(ignore_count) + " ignored). Double click " +
670 | "character to go to zKillboard."
671 | )
672 | else:
673 | statusmsg.push_status(
674 | str(len(outlist) - ignore_count) + " characters analysed (" +
675 | str(ignore_count) + " ignored). Double click character to go " +
676 | " to zKillboard."
677 | )
678 |
679 | def updateStatusbar(self, msg):
680 | '''Gets called by push_status() in statusmsg.py.'''
681 | if isinstance(msg, str):
682 | self.status_label.SetLabel(msg)
683 | self.Layout()
684 |
685 | def _goToZKill(self, event):
686 | rowidx = event.GetRow()
687 |
688 | url = "https://zkillboard.com/"
689 |
690 | # If we want zkillboard advanced linking then just link to the character sheet
691 | if self.options.Get("ZkillMode", False):
692 | colidx = event.GetCol()
693 |
694 | # Corporation was clicked on, link to that killboard
695 | if colidx == 6:
696 | corporation_id = self.options.Get("outlist")[rowidx][3]
697 | url = url + "corporation/" + str(corporation_id) + "/"
698 |
699 | # Alliance was clicked on, link to that killboard if alliance exists
700 | elif colidx == 8:
701 | alliance_id = self.options.Get("outlist")[rowidx][5]
702 | if alliance_id != None:
703 | url = url + "alliance/" + str(alliance_id) + "/"
704 |
705 | # Faction was clicked on, link to that killboard if faction exists
706 | elif colidx == 9:
707 | faction_id = self.options.Get("outlist")[rowidx][1]
708 | if faction_id != None:
709 | url = url + "faction/" + str(faction_id) + "/"
710 |
711 | # Something other than character was clicked on but we want to look at the character with modifiers
712 | elif colidx != 3:
713 | # Set up the character base url
714 | character_id = self.options.Get("outlist")[rowidx][0]
715 | url = url + "character/" + str(character_id) + "/"
716 |
717 | # Kills modifier
718 | if colidx == 10:
719 | url = url + "kills/"
720 | # Losses modifier
721 | elif colidx == 11:
722 | url = url + "losses/"
723 | # Solo Modifier
724 | elif colidx == 13:
725 | url = url + "solo/"
726 | # BLOPS Modifer
727 | elif colidx == 14:
728 | url = url + "group/898/"
729 | # HIC Modifier
730 | elif colidx == 15:
731 | url = url + "group/894/"
732 | # Abyssal Modifier
733 | elif colidx == 23:
734 | url = url + "abyssal/"
735 |
736 | # This is a catch all if the url wasnt set or if a column other than a special one was clicked.
737 | # This is in a seperate flow to the above in case any of the above need to fall through
738 | if url == "https://zkillboard.com/":
739 | character_id = self.options.Get("outlist")[rowidx][0]
740 | url = url + "character/" + str(character_id) + "/"
741 |
742 | webbrowser.open_new_tab(url)
743 |
744 | def _showContextMenu(self, event):
745 | '''
746 | Gets invoked by right click on any list item and produces a
747 | context menu that allows the user to add the selected character/corp/alliance
748 | to PySpy's list of "ignored characters" which will no longer be
749 | shown in search results and add the selected character/corp/alliance
750 | to PySpy's list of "highlighted characters" which will hihglight them in the grid.
751 | '''
752 | def OnIgnore(id, name, type, e=None):
753 | ignored_list = self.options.Get("ignoredList", default=[])
754 | ignored_list.append([id, name, type])
755 | self.options.Set("ignoredList", ignored_list)
756 | self.updateList(self.options.Get("outlist", None))
757 |
758 | def OnHighlight(id, name, type, e=None):
759 | highlighted_list = self.options.Get("highlightedList", default=[])
760 | if [id, name, type] not in highlighted_list:
761 | highlighted_list.append([id, name, type])
762 | self.options.Set("highlightedList", highlighted_list)
763 | self.updateList(self.options.Get("outlist", None))
764 |
765 | def OnDeHighlight(id, name, type, e=None):
766 | highlighted_list = self.options.Get("highlightedList", default=[])
767 | highlighted_list.remove([id, name, type])
768 | self.options.Set("highlightedList", highlighted_list)
769 | self.updateList(self.options.Get("outlist", None))
770 |
771 | highlighted_list = self.options.Get("highlightedList", default=[])
772 | rowidx = event.GetRow()
773 | character_id = str(self.options.Get("outlist")[rowidx][0])
774 | # Only open context menu character item right clicked, not empty line.
775 | if len(character_id) > 0:
776 | outlist = self.options.Get("outlist")
777 | for r in outlist:
778 | if str(r[0]) == character_id:
779 | character_id = r[0]
780 | character_name = r[2]
781 | corp_id = r[3]
782 | corp_name = r[4]
783 | alliance_id = r[5]
784 | alliance_name = r[6]
785 | break
786 | self.menu = wx.Menu()
787 | # Context menu to ignore characters, corporations and alliances.
788 | item_ig_char = self.menu.Append(
789 | wx.ID_ANY, "Ignore character '" + character_name + "'"
790 | )
791 | self.menu.Bind(
792 | wx.EVT_MENU,
793 | lambda evt, id=character_id, name=character_name: OnIgnore(id, name, "Character", evt),
794 | item_ig_char
795 | )
796 |
797 | item_ig_corp = self.menu.Append(
798 | wx.ID_ANY, "Ignore corporation: '" + corp_name + "'"
799 | )
800 | self.menu.Bind(
801 | wx.EVT_MENU,
802 | lambda evt, id=corp_id, name=corp_name: OnIgnore(id, name, "Corporation", evt),
803 | item_ig_corp
804 | )
805 |
806 | if alliance_name is not None:
807 | item_ig_alliance = self.menu.Append(
808 | wx.ID_ANY, "Ignore alliance: '" + alliance_name + "'"
809 | )
810 | self.menu.Bind(
811 | wx.EVT_MENU,
812 | lambda evt, id=alliance_id, name=alliance_name: OnIgnore(id, name, "Alliance", evt),
813 | item_ig_alliance
814 | )
815 |
816 | self.menu.AppendSeparator()
817 |
818 | hl_char = False
819 | hl_corp = False
820 | hl_alliance = False
821 |
822 | for entry in highlighted_list:
823 | if entry[1] == self.options.Get("outlist")[rowidx][2]:
824 | hl_char = True
825 | if entry[1] == self.options.Get("outlist")[rowidx][4]:
826 | hl_corp = True
827 | if alliance_name is not None:
828 | if entry[1] == self.options.Get("outlist")[rowidx][6]:
829 | hl_alliance = True
830 |
831 | # Context menu to highlight characters, corporations and alliances
832 | if not hl_char:
833 | item_hl_char = self.menu.Append(
834 | wx.ID_ANY, "Highlight character '" + character_name + "'"
835 | )
836 | self.menu.Bind(
837 | wx.EVT_MENU,
838 | lambda evt, id=character_id, name=character_name: OnHighlight(id, name, "Character", evt),
839 | item_hl_char
840 | )
841 | else:
842 | item_hl_char = self.menu.Append(
843 | wx.ID_ANY, "Stop highlighting character '" + character_name + "'"
844 | )
845 | self.menu.Bind(
846 | wx.EVT_MENU,
847 | lambda evt, id=character_id, name=character_name: OnDeHighlight(id, name, "Character", evt),
848 | item_hl_char
849 | )
850 |
851 | if not hl_corp:
852 | item_hl_corp = self.menu.Append(
853 | wx.ID_ANY, "Highlight corporation '" + corp_name + "'"
854 | )
855 | self.menu.Bind(
856 | wx.EVT_MENU,
857 | lambda evt, id=corp_id, name=corp_name: OnHighlight(id, name, "Corporation", evt),
858 | item_hl_corp
859 | )
860 | else:
861 | item_hl_corp = self.menu.Append(
862 | wx.ID_ANY, "Stop highlighting corporation '" + corp_name + "'"
863 | )
864 | self.menu.Bind(
865 | wx.EVT_MENU,
866 | lambda evt, id=corp_id, name=corp_name: OnDeHighlight(id, name, "Corporation", evt),
867 | item_hl_corp
868 | )
869 |
870 | if alliance_name is not None:
871 | if not hl_alliance:
872 | item_hl_alliance = self.menu.Append(
873 | wx.ID_ANY, "Highlight alliance: '" + alliance_name + "'"
874 | )
875 | self.menu.Bind(
876 | wx.EVT_MENU,
877 | lambda evt, id=alliance_id, name=alliance_name: OnHighlight(id, name, "Alliance", evt),
878 | item_hl_alliance
879 | )
880 | else:
881 | item_hl_alliance = self.menu.Append(
882 | wx.ID_ANY, "Stop highlighting alliance: '" + alliance_name + "'"
883 | )
884 | self.menu.Bind(
885 | wx.EVT_MENU,
886 | lambda evt, id=alliance_id, name=alliance_name: OnDeHighlight(id, name, "Alliance", evt),
887 | item_hl_alliance
888 | )
889 |
890 | self.PopupMenu(self.menu, event.GetPosition())
891 | self.menu.Destroy()
892 |
893 | def sortOutlist(self, event=None, outlist=None, duration=None):
894 | '''
895 | If called by event handle, i.e. user
896 | '''
897 | if event is None:
898 | # Default sort by character name ascending.
899 | colidx = self.options.Get("SortColumn", self.columns[3][7])
900 | sort_desc = self.options.Get("SortDesc", False)
901 | else:
902 | colidx = event.GetCol()
903 | if self.options.Get("SortColumn", -1) == colidx:
904 | sort_desc = not self.options.Get("SortDesc")
905 | else:
906 | sort_desc = True
907 |
908 | # Use unicode characters for sort indicators
909 | arrow = u"\u2193" if sort_desc else u"\u2191"
910 |
911 | # Reset all labels
912 | for col in self.columns:
913 | self.grid.SetColLabelValue(col[0], col[1])
914 |
915 | # Assign sort indicator to sort column
916 | self.grid.SetColLabelValue(
917 | colidx,
918 | self.columns[colidx][1] + " " + arrow
919 | )
920 | self.options.Set("SortColumn", colidx)
921 | self.options.Set("SortDesc", sort_desc)
922 | event = None
923 | # Sort outlist. Note: outlist columns are not the same as
924 | # self.grid columns!!!
925 | if outlist is None:
926 | outlist = self.options.Get("outlist", False)
927 |
928 | if outlist and colidx != 1:
929 | outlist = sortarray.sort_array(
930 | outlist,
931 | self.columns[colidx][7],
932 | sec_col=self.columns[2][7], # Secondary sort by name
933 | prim_desc=sort_desc,
934 | sec_desc=False, # Secondary sort by name always ascending
935 | case_sensitive=False
936 | )
937 | self.options.Set("outlist", outlist)
938 | self.updateList(outlist, duration=duration)
939 |
940 | def _setTransparency(self, event=None):
941 | '''
942 | Sets window transparency based off slider setting and stores
943 | value in self.options.
944 | '''
945 | alpha = self.alpha_slider.GetValue()
946 | self.SetTransparent(alpha)
947 | self.options.Set("GuiAlpha", alpha)
948 | self.Layout()
949 |
950 | def updateAlert(self, latest_ver, cur_ver):
951 | '''
952 | Simple dialog box to notify user when new version of PySpy is
953 | available for download. Gets called by chk_github_update()
954 | in chkversion.py.
955 | '''
956 | self.ToggleWindowStyle(wx.STAY_ON_TOP)
957 | msgbox = wx.MessageBox(
958 | "PySpy " + str(latest_ver) + " is now available. You are running " +
959 | str(cur_ver) + ". Would you like to update now?",
960 | 'Update Available',
961 | wx.YES_NO | wx.YES_DEFAULT | wx.ICON_INFORMATION | wx.STAY_ON_TOP
962 | )
963 | if msgbox == wx.YES:
964 | webbrowser.open_new_tab(
965 | "https://github.com/Eve-PySpy/PySpy/releases/latest"
966 | )
967 | self.ToggleWindowStyle(wx.STAY_ON_TOP)
968 |
969 | def _toggleIgnoreFactions(self, e):
970 | ig_galmin = self.ignore_galmin.IsChecked()
971 | ig_amacal = self.ignore_amacal.IsChecked()
972 | ig_none = self.ignore_none.IsChecked()
973 | if ig_galmin:
974 | config.IGNORED_FACTIONS = 2 # Gallente & Minmatar have even ids
975 | self.options.Set("IgnoredFactions", 2)
976 | if ig_amacal:
977 | config.IGNORED_FACTIONS = 1 # Amarr & Caldari have uneven ids
978 | self.options.Set("IgnoredFactions", 1)
979 | if ig_none:
980 | config.IGNORED_FACTIONS = None # Amarr & Caldari have uneven ids
981 | self.options.Set("IgnoredFactions", 0)
982 | self.updateList(self.options.Get("outlist", None))
983 |
984 | def _toggleHighlighting(self, e):
985 | self.options.Set("HlBlops", self.hl_blops.IsChecked())
986 | self.options.Set("HlCyno", self.hl_cyno.IsChecked())
987 | self.options.Set("HlHic", self.hl_hic.IsChecked())
988 | self.options.Set("HlList", self.hl_list.IsChecked())
989 | self.updateList(self.options.Get("outlist", None))
990 |
991 | def _toggleStayOnTop(self, evt=None):
992 | self.options.Set("StayOnTop", self.stay_ontop.IsChecked())
993 | self.ToggleWindowStyle(wx.STAY_ON_TOP)
994 |
995 | def _toggleDarkMode(self, evt=None):
996 | self.options.Set("DarkMode", self.dark_mode.IsChecked())
997 | self.use_dm = self.dark_mode.IsChecked()
998 | self.__set_properties(dark_toggle=True)
999 | self.Refresh()
1000 | self.Update()
1001 | self.updateList(self.options.Get("outlist"))
1002 |
1003 | def _openAboutDialog(self, evt=None):
1004 | '''
1005 | Checks if AboutDialog is already open. If not, opens the dialog
1006 | window, otherwise brings the existing dialog window to the front.
1007 | '''
1008 | for c in self.GetChildren():
1009 | if c.GetName() == "AboutDialog": # Needs to match name in aboutdialog.py
1010 | c.Raise()
1011 | return
1012 | aboutdialog.showAboutBox(self)
1013 |
1014 | def _openIgnoreDialog(self, evt=None):
1015 | '''
1016 | Checks if IgnoreDialog is already open. If not, opens the dialog
1017 | window, otherwise brings the existing dialog window to the front.
1018 | '''
1019 | for c in self.GetChildren():
1020 | if c.GetName() == "IgnoreDialog": # Needs to match name in ignoredialog.py
1021 | c.Raise()
1022 | return
1023 | ignoredialog.showIgnoreDialog(self)
1024 |
1025 | def _openHightlightDialog(self, evt=None):
1026 | '''
1027 | Checks if HightlightDialog is already open. If not, opens the dialog
1028 | window, otherwise brings the existing dialog window to the front.
1029 | '''
1030 | for c in self.GetChildren():
1031 | if c.GetName() == "HighlightDialog": # Needs to match name in highlightdialog.py
1032 | c.Raise()
1033 | return
1034 | highlightdialog.showHighlightDialog(self)
1035 |
1036 | def _showNpsiDialog(self, evt=None):
1037 | dialog = wx.MessageBox(
1038 | "Do you want to ignore all currently shown characters? " +
1039 | "You can undo this under `Options > Clear NPSI Ignore List`.",
1040 | "NPSI Ignore List",
1041 | wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION
1042 | )
1043 | if dialog == 2: # Yes
1044 | npsi_list = []
1045 | outlist = self.options.Get("outlist", None)
1046 | if outlist is None:
1047 | return
1048 | for r in outlist:
1049 | character_id = [r[0]] # Needs to be list to append to ignored_list
1050 | npsi_list.append(character_id)
1051 | self.options.Set("NPSIList", npsi_list)
1052 | self.updateList(outlist)
1053 |
1054 | def _clearNpsiList(self, evt=None):
1055 | dialog = wx.MessageBox(
1056 | "Would you like to clear the current NPSI fleet ignore list?",
1057 | "NPSI Ignore List",
1058 | wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION
1059 | )
1060 | if dialog == 2: # Yes
1061 | self.options.Set("NPSIList", [])
1062 | self.updateList(self.options.Get("outlist", None))
1063 |
1064 | def _toggleZkillMode(self, evt=None):
1065 | self.options.Set("ZkillMode", self.zkill_mode.IsChecked())
1066 | self.use_adv_zkill = self.zkill_mode.IsChecked()
1067 | # This just prevents all settings from being updated.
1068 | self.__set_properties(dark_toggle=True)
1069 | self.Refresh()
1070 | self.Update()
1071 | self.updateList(self.options.Get("outlist"))
1072 |
1073 | def _restoreColWidth(self):
1074 | '''
1075 | Restores column width either to default or value stored from
1076 | previous session.
1077 | '''
1078 | for col in self.columns:
1079 | header = col[1]
1080 | # Column width is also set in _toggleColumn()
1081 | width = self.options.Get(header, col[3])
1082 | menu_item = self.col_menu_items[col[0]]
1083 | if menu_item == [] or menu_item.IsChecked():
1084 | self.grid.SetColSize(col[0], width)
1085 | else:
1086 | self.grid.SetColSize(col[0], 0)
1087 | pass
1088 |
1089 | def _saveColumns(self):
1090 | '''
1091 | Saves custom column widths, since wxpython's Persistence Manager
1092 | is unable to do so for Grid widgets.
1093 | '''
1094 | for col in self.columns:
1095 | is_hideable = col[4]
1096 | default_show = col[5]
1097 | header = col[1]
1098 | options_key = "Show" + header
1099 | width = self.grid.GetColSize(col[0])
1100 | try:
1101 | menu_item_chk = self.col_menu_items[col[0]].IsChecked()
1102 | except:
1103 | menu_item_chk = False
1104 | # Only save column width for columns that are not hidden or
1105 | # not hideable and shown by default.
1106 | if menu_item_chk or (not is_hideable and default_show):
1107 | self.options.Set(header, width)
1108 | # Do not add menu item if column not hideable
1109 | if col[4]:
1110 | self.options.Set(options_key, menu_item_chk)
1111 | pass
1112 |
1113 | def OnClose(self, event=None):
1114 | '''
1115 | Run a few clean-up tasks on close and save persistent properties.
1116 | '''
1117 | self._persistMgr.SaveAndUnregister()
1118 |
1119 | # Save column toggle menu state and column width in pickle container
1120 | self._saveColumns()
1121 |
1122 | # Store check-box values in pickle container
1123 | self.options.Set("HlBlops", self.hl_blops.IsChecked())
1124 | self.options.Set("HlCyno", self.hl_cyno.IsChecked())
1125 | self.options.Set("IgnoreGalMin", self.ignore_galmin.IsChecked())
1126 | self.options.Set("IgnoreAmaCal", self.ignore_amacal.IsChecked())
1127 | self.options.Set("IgnoreNone", self.ignore_none.IsChecked())
1128 | self.options.Set("StayOnTop", self.stay_ontop.IsChecked())
1129 | self.options.Set("DarkMode", self.dark_mode.IsChecked())
1130 | # Delete last outlist and NPSIList
1131 | self.options.Set("outlist", None)
1132 | self.options.Set("NPSIList", [])
1133 | # Write pickle container to disk
1134 | self.options.Save()
1135 | event.Skip() if event else False
1136 |
1137 | def OnQuit(self, e):
1138 | self.Close()
1139 |
1140 | def clear_character_cache(self, e):
1141 | conn, cur = db.connect_persistent_db()
1142 | query_statement = '''DELETE FROM characters'''
1143 | cur.execute(query_statement)
1144 | conn.commit()
1145 | conn.close()
1146 | statusmsg.push_status("Cleared character cache")
1147 |
1148 | class App(wx.App):
1149 | def OnInit(self):
1150 | self.PySpy = Frame(None, wx.ID_ANY, "")
1151 | self.SetTopWindow(self.PySpy)
1152 | self.PySpy.Show()
1153 | return True
1154 |
--------------------------------------------------------------------------------
/highlightdialog.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' Dialog to view and remove entities from PySpy's list of highlighted
6 | characters, corporations and alliances.
7 | '''
8 | # **********************************************************************
9 | import logging
10 |
11 | import wx
12 | from wx.lib.mixins.listctrl import CheckListCtrlMixin, ListCtrlAutoWidthMixin
13 |
14 | import config
15 | import sortarray
16 | import statusmsg
17 | # cSpell Checker - Correct Words****************************************
18 | # // cSpell:words russsian, ccp's, pyperclip, chkversion, clpbd, gui
19 | # **********************************************************************
20 | Logger = logging.getLogger(__name__)
21 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
22 |
23 |
24 | class CheckListCtrl(wx.ListCtrl, CheckListCtrlMixin, ListCtrlAutoWidthMixin):
25 |
26 | def __init__(self, parent):
27 | wx.ListCtrl.__init__(self, parent, wx.ID_ANY, style=wx.LC_REPORT |
28 | wx.SUNKEN_BORDER)
29 | CheckListCtrlMixin.__init__(self)
30 | ListCtrlAutoWidthMixin.__init__(self)
31 |
32 |
33 | class HighlightDialog(wx.Frame):
34 | def __init__(self, parent, *args, **kwds):
35 | kwds["style"] = (kwds.get("style", 0) | wx.CAPTION | wx.CLIP_CHILDREN |
36 | wx.CLOSE_BOX | wx.FRAME_FLOAT_ON_PARENT | wx.RESIZE_BORDER)
37 | wx.Frame.__init__(self, parent, *args, **kwds)
38 |
39 | self.Font = self.Font.Scaled(config.OPTIONS_OBJECT.Get("FontScale", 1))
40 | self.SetName("HighlightDialog")
41 | self.SetSize((400, 300))
42 |
43 | self.highlightList = CheckListCtrl(self)
44 | self.highlightList.InsertColumn(0, 'Name', width=180)
45 | self.highlightList.InsertColumn(1, 'ID', width=0)
46 | self.highlightList.InsertColumn(2, 'Type')
47 | self.buttonPanel = wx.Panel(self, wx.ID_ANY)
48 | self.appBtn = wx.Button(self.buttonPanel, wx.ID_OK, "Delete Selected Entries")
49 | self.cnclBtn = wx.Button(self.buttonPanel, wx.ID_CANCEL, "Cancel Changes")
50 |
51 | self.Bind(wx.EVT_BUTTON, self.OnApply, id=self.appBtn.GetId())
52 | self.Bind(wx.EVT_BUTTON, self.OnCancel, id=self.cnclBtn.GetId())
53 | self.Bind(wx.EVT_CHAR_HOOK, self.OnHook)
54 |
55 | self.highlighted_entities = config.OPTIONS_OBJECT.Get("highlightedList", default=[])
56 | self._populateList()
57 |
58 | self.__set_properties()
59 | self.__do_layout()
60 |
61 | if config.OPTIONS_OBJECT.Get("StayOnTop", True):
62 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP)
63 | self.ToggleWindowStyle(wx.STAY_ON_TOP)
64 |
65 | def __set_properties(self):
66 | self.SetTitle("Review Highlighted Entities")
67 | # Colour Scheme Dictionaries
68 | self.dark_dict = config.DARK_MODE
69 | self.normal_dict = config.NORMAL_MODE
70 |
71 | # Colour Scheme
72 | if not config.OPTIONS_OBJECT.Get("DarkMode", False):
73 | self.bg_colour = self.normal_dict["BG"]
74 | self.txt_colour = self.normal_dict["TXT"]
75 | self.lne_colour = self.normal_dict["LNE"]
76 | self.hl1_colour = self.normal_dict["HL1"]
77 | else:
78 | self.bg_colour = self.dark_dict["BG"]
79 | self.txt_colour = self.dark_dict["TXT"]
80 | self.lne_colour = self.dark_dict["LNE"]
81 | self.hl1_colour = self.dark_dict["HL1"]
82 |
83 | # Set default colors
84 | self.SetBackgroundColour(self.bg_colour)
85 | self.SetForegroundColour(self.txt_colour)
86 | self.highlightList.SetBackgroundColour(self.bg_colour)
87 | self.highlightList.SetForegroundColour(self.txt_colour)
88 |
89 | # Window icon
90 | icon = wx.Icon()
91 | icon.CopyFromBitmap(wx.Bitmap(config.ICON_FILE, wx.BITMAP_TYPE_ANY))
92 | self.SetIcon(icon)
93 |
94 | def __do_layout(self):
95 | main = wx.BoxSizer(wx.VERTICAL)
96 | buttonSizer = wx.BoxSizer(wx.HORIZONTAL)
97 | instrLbl = wx.StaticText(self, wx.ID_ANY, "Select entities to be removed from highlight list:", style=wx.ALIGN_LEFT)
98 | main.Add(instrLbl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 10)
99 | main.Add(self.highlightList, 1, wx.ALL | wx.EXPAND, 10)
100 | buttonSizer.Add(self.appBtn, 1, wx.RIGHT, 5)
101 | buttonSizer.Add(self.cnclBtn, 1, wx.LEFT, 5)
102 | self.buttonPanel.SetSizer(buttonSizer)
103 | main.Add(self.buttonPanel, 0, wx.ALIGN_BOTTOM | wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 10)
104 | self.SetSizer(main)
105 | self.Layout()
106 | self.Centre()
107 |
108 | def _populateList(self):
109 | idx = 0
110 | if self.highlighted_entities == []:
111 | return
112 | if len(self.highlighted_entities) > 1:
113 | self.highlighted_entities = sortarray.sort_array(self.highlighted_entities, 2, 1)
114 | for i in self.highlighted_entities:
115 | index = self.highlightList.InsertItem(idx, i[1])
116 | self.highlightList.SetItem(index, 1, str(i[0]))
117 | self.highlightList.SetItem(index, 2, i[2])
118 | idx += 1
119 |
120 | def OnHook(self, event):
121 | if event.GetKeyCode() == wx.WXK_ESCAPE:
122 | self.OnCancel(event)
123 | if event.GetKeyCode() == wx.WXK_RETURN:
124 | self.OnApply(event)
125 | else:
126 | event.Skip()
127 |
128 | def OnApply(self, event):
129 | num = self.highlightList.GetItemCount()
130 | for i in range(num):
131 | if self.highlightList.IsChecked(i):
132 | id = int(self.highlightList.GetItemText(i, 1))
133 | n = 0
134 | for r in self.highlighted_entities:
135 | if r[0] == id:
136 | del self.highlighted_entities[n]
137 | n += 1
138 | config.OPTIONS_OBJECT.Set("highlightedList", self.highlighted_entities)
139 | self.Parent.updateList(config.OPTIONS_OBJECT.Get("outlist"))
140 | self.Close()
141 |
142 | def OnCancel(self, event):
143 | if config.OPTIONS_OBJECT.Get("StayOnTop", True):
144 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP)
145 | self.Close()
146 |
147 |
148 | def showHighlightDialog(parent, evt=None):
149 | app = wx.App(False)
150 | frame = HighlightDialog(parent=parent)
151 | app.SetTopWindow(frame)
152 | frame.Show()
153 | app.MainLoop()
154 |
--------------------------------------------------------------------------------
/ignoredialog.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' Dialog to view and remove entities from PySpy's list of ignored
6 | characters, corporations and alliances.
7 | '''
8 | # **********************************************************************
9 | import logging
10 |
11 | import wx
12 | from wx.lib.mixins.listctrl import CheckListCtrlMixin, ListCtrlAutoWidthMixin
13 |
14 | import config
15 | import sortarray
16 | import statusmsg
17 | # cSpell Checker - Correct Words****************************************
18 | # // cSpell:words russsian, ccp's, pyperclip, chkversion, clpbd, gui
19 | # **********************************************************************
20 | Logger = logging.getLogger(__name__)
21 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
22 |
23 |
24 | class CheckListCtrl(wx.ListCtrl, CheckListCtrlMixin, ListCtrlAutoWidthMixin):
25 |
26 | def __init__(self, parent):
27 | wx.ListCtrl.__init__(self, parent, wx.ID_ANY, style=wx.LC_REPORT |
28 | wx.SUNKEN_BORDER)
29 | CheckListCtrlMixin.__init__(self)
30 | ListCtrlAutoWidthMixin.__init__(self)
31 |
32 |
33 | class IgnoreDialog(wx.Frame):
34 | def __init__(self, parent, *args, **kwds):
35 | kwds["style"] = (kwds.get("style", 0) | wx.CAPTION | wx.CLIP_CHILDREN |
36 | wx.CLOSE_BOX | wx.FRAME_FLOAT_ON_PARENT | wx.RESIZE_BORDER)
37 | wx.Frame.__init__(self, parent, *args, **kwds)
38 |
39 | self.Font = self.Font.Scaled(config.OPTIONS_OBJECT.Get("FontScale", 1))
40 | self.SetName("IgnoreDialog")
41 | self.SetSize((400, 300))
42 |
43 | self.ignoredList = CheckListCtrl(self)
44 | self.ignoredList.InsertColumn(0, 'Name', width=180)
45 | self.ignoredList.InsertColumn(1, 'ID', width=0)
46 | self.ignoredList.InsertColumn(2, 'Type')
47 | self.buttonPanel = wx.Panel(self, wx.ID_ANY)
48 | self.appBtn = wx.Button(self.buttonPanel, wx.ID_OK, "Delete Selected Entries")
49 | self.cnclBtn = wx.Button(self.buttonPanel, wx.ID_CANCEL, "Cancel Changes")
50 |
51 | self.Bind(wx.EVT_BUTTON, self.OnApply, id=self.appBtn.GetId())
52 | self.Bind(wx.EVT_BUTTON, self.OnCancel, id=self.cnclBtn.GetId())
53 | self.Bind(wx.EVT_CHAR_HOOK, self.OnHook)
54 |
55 | self.ignored_entities = config.OPTIONS_OBJECT.Get("ignoredList", default=[])
56 | self._populateList()
57 |
58 | self.__set_properties()
59 | self.__do_layout()
60 |
61 | if config.OPTIONS_OBJECT.Get("StayOnTop", True):
62 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP)
63 | self.ToggleWindowStyle(wx.STAY_ON_TOP)
64 |
65 | def __set_properties(self):
66 | self.SetTitle("Review Ignored Entities")
67 | # Colour Scheme Dictionaries
68 | self.dark_dict = config.DARK_MODE
69 | self.normal_dict = config.NORMAL_MODE
70 |
71 | # Colour Scheme
72 | if not config.OPTIONS_OBJECT.Get("DarkMode", False):
73 | self.bg_colour = self.normal_dict["BG"]
74 | self.txt_colour = self.normal_dict["TXT"]
75 | self.lne_colour = self.normal_dict["LNE"]
76 | self.hl1_colour = self.normal_dict["HL1"]
77 | else:
78 | self.bg_colour = self.dark_dict["BG"]
79 | self.txt_colour = self.dark_dict["TXT"]
80 | self.lne_colour = self.dark_dict["LNE"]
81 | self.hl1_colour = self.dark_dict["HL1"]
82 |
83 | # Set default colors
84 | self.SetBackgroundColour(self.bg_colour)
85 | self.SetForegroundColour(self.txt_colour)
86 | self.ignoredList.SetBackgroundColour(self.bg_colour)
87 | self.ignoredList.SetForegroundColour(self.txt_colour)
88 |
89 | # Window icon
90 | icon = wx.Icon()
91 | icon.CopyFromBitmap(wx.Bitmap(config.ICON_FILE, wx.BITMAP_TYPE_ANY))
92 | self.SetIcon(icon)
93 |
94 | def __do_layout(self):
95 | main = wx.BoxSizer(wx.VERTICAL)
96 | buttonSizer = wx.BoxSizer(wx.HORIZONTAL)
97 | instrLbl = wx.StaticText(self, wx.ID_ANY, "Select entities to be removed from ignore list:", style=wx.ALIGN_LEFT)
98 | main.Add(instrLbl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 10)
99 | main.Add(self.ignoredList, 1, wx.ALL | wx.EXPAND, 10)
100 | buttonSizer.Add(self.appBtn, 1, wx.RIGHT, 5)
101 | buttonSizer.Add(self.cnclBtn, 1, wx.LEFT, 5)
102 | self.buttonPanel.SetSizer(buttonSizer)
103 | main.Add(self.buttonPanel, 0, wx.ALIGN_BOTTOM | wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 10)
104 | self.SetSizer(main)
105 | self.Layout()
106 | self.Centre()
107 |
108 | def _populateList(self):
109 | idx = 0
110 | if self.ignored_entities == []:
111 | return
112 | if len(self.ignored_entities) > 1:
113 | self.ignored_entities = sortarray.sort_array(self.ignored_entities, 2, 1)
114 | for i in self.ignored_entities:
115 | index = self.ignoredList.InsertItem(idx, i[1])
116 | self.ignoredList.SetItem(index, 1, str(i[0]))
117 | self.ignoredList.SetItem(index, 2, i[2])
118 | idx += 1
119 |
120 | def OnHook(self, event):
121 | if event.GetKeyCode() == wx.WXK_ESCAPE:
122 | self.OnCancel(event)
123 | if event.GetKeyCode() == wx.WXK_RETURN:
124 | self.OnApply(event)
125 | else:
126 | event.Skip()
127 |
128 | def OnApply(self, event):
129 | num = self.ignoredList.GetItemCount()
130 | for i in range(num):
131 | if self.ignoredList.IsChecked(i):
132 | id = int(self.ignoredList.GetItemText(i, 1))
133 | n = 0
134 | for r in self.ignored_entities:
135 | if r[0] == id:
136 | del self.ignored_entities[n]
137 | n += 1
138 | config.OPTIONS_OBJECT.Set("ignoredList", self.ignored_entities)
139 | self.Parent.updateList(config.OPTIONS_OBJECT.Get("outlist"))
140 | self.Close()
141 |
142 | def OnCancel(self, event):
143 | if config.OPTIONS_OBJECT.Get("StayOnTop", True):
144 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP)
145 | self.Close()
146 |
147 |
148 | def showIgnoreDialog(parent, evt=None):
149 | app = wx.App(False)
150 | frame = IgnoreDialog(parent=parent)
151 | app.SetTopWindow(frame)
152 | frame.Show()
153 | app.MainLoop()
154 |
--------------------------------------------------------------------------------
/optstore.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | '''
6 | PresistentOptions can be used to create an object that stores values
7 | in a dictionary and save them in a pickle file for use in subsequent
8 | sessions.
9 | '''
10 | # **********************************************************************
11 | import logging
12 | import os
13 | import pickle
14 |
15 | import config
16 | # cSpell Checker - Correct Words****************************************
17 | # // cSpell:words russsian
18 | # **********************************************************************
19 | Logger = logging.getLogger(__name__)
20 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
21 |
22 |
23 | class PersistentOptions():
24 | '''
25 | :class:`PersistentOptions`: Store variables between sessions.
26 |
27 | Creates a dictionary object to store various variables in a
28 | pickle file between sessions.
29 | '''
30 | def __init__(self, options_file):
31 | '''
32 | :param `options_file`: the relative or absolute path to the pickle file;
33 | '''
34 | self._pickle_file = options_file
35 | self._options = self._restore()
36 |
37 | def ListKeys(self):
38 | '''
39 | Returns list of all keys available in class object.
40 | '''
41 | keys = []
42 | for key in self._options:
43 | keys.append(key)
44 | return keys
45 |
46 | def Get(self, key, default=None):
47 | '''
48 | Returns the value of the specified key in the dictionary object.
49 |
50 | :param `key`: a valid key of the dictionary object;
51 | :param `default`: the value that should be returned if key is invalid;
52 | '''
53 | try:
54 | return self._options[key]
55 | except KeyError:
56 | if default is not None:
57 | return default
58 | else:
59 | raise Exception("ERROR: no such key: " + str(key))
60 |
61 | def Set(self, key, value):
62 | '''
63 | Stores value under the specified key in the dictionary object.
64 |
65 | :param `key`: a new or existing key of the dictionary object;
66 | :param `value`: any python object;
67 | '''
68 | self._options[key] = value
69 |
70 | def Del(self, key):
71 | '''
72 | Deletes specified key in the dictionary object.
73 |
74 | :param `key`: existing key of the dictionary object;
75 | '''
76 | try:
77 | del self._options[key]
78 | except:
79 | raise Exception("ERROR: no such key: " + str(key))
80 |
81 | def Save(self):
82 | '''
83 | Saves the dictionary object in a pickle file under the file name
84 | provided at instantiation.
85 | '''
86 | self._storePickle(self._pickle_file, self._options)
87 | return
88 |
89 | def _restore(self):
90 | '''
91 | Restores the dictionary object from the pickle file saved under
92 | the file name provided at instantiation.
93 | '''
94 | return self._getPickle(self._pickle_file)
95 |
96 | def _storePickle(self, pickle_file, data):
97 | '''
98 | Save binary pickle to disk.
99 |
100 | :param `pickle_file`: absolute or relative path to pickle file;
101 | :param `data`: any python object;
102 | '''
103 | try:
104 | pickle_dir = os.path.dirname(pickle_file)
105 | except:
106 | pickle_dir = "./"
107 | pickle_data = data
108 | try:
109 | if not os.path.exists(pickle_dir):
110 | os.makedirs(pickle_dir)
111 | with open(pickle_file, 'wb') as file:
112 | pickle.dump(pickle_data, file, pickle.HIGHEST_PROTOCOL)
113 | except Exception:
114 | Logger.warn("Failed to create / store pickle file.", exc_info=True)
115 | finally:
116 | return
117 |
118 | def _getPickle(self, pickle_file):
119 | '''
120 | Returns data contained in pickle file or if pickle_file is not a
121 | valid pickle, return empty dictionary.
122 |
123 | :param `pickle_file`: absolute or relative path to pickle file;
124 | '''
125 | pickle_data = {}
126 | try:
127 | with open(pickle_file, 'rb') as file:
128 | pickle_data = pickle.load(file)
129 | return pickle_data
130 | except FileNotFoundError:
131 | Logger.warn("Could not find pickle file.", exc_info=True)
132 | finally:
133 | return pickle_data
134 |
--------------------------------------------------------------------------------
/reportstats.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' Provides functionality to report basic usage statistics to a
6 | webserver.
7 | '''
8 | # **********************************************************************
9 | import json
10 | import logging
11 | import platform
12 | import threading
13 | import time
14 |
15 | import requests
16 |
17 | import config
18 | import statusmsg
19 | # cSpell Checker - Correct Words****************************************
20 | # // cSpell:words russsian, blops
21 | # **********************************************************************
22 | Logger = logging.getLogger(__name__)
23 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
24 |
25 |
26 | class ReportStats(threading.Thread):
27 | '''
28 | Collects a number of properties and selected user options
29 | and uploads those each time the user performs a character analysis,
30 | together with the number of characters searched and the search duration.
31 | Instances of ReportStats run in their own separate thread and do not
32 | return any values, regardless of successful upload or failure.
33 | '''
34 | # Report stats in separate thread. Do not complain if error encountered.
35 | def __init__(self, outlist, duration):
36 | super(ReportStats, self).__init__()
37 | self.daemon = True
38 | self._uuid = config.OPTIONS_OBJECT.Get("uuid")
39 | self._version = config.CURRENT_VER
40 | self._platform = platform.system()
41 | self._chars = str(len(outlist))
42 | self._duration = str(duration)
43 | self._sh_faction = str(config.OPTIONS_OBJECT.Get("ShowFaction", True))
44 | self._hl_blops = str(config.OPTIONS_OBJECT.Get("HlBlops", True))
45 | self._ig_factions = str(not config.OPTIONS_OBJECT.Get("HlNone", True))
46 | self._gui_alpha = config.OPTIONS_OBJECT.Get("GuiAlpha", 250)
47 |
48 | def run(self):
49 | url = "http://pyspy.pythonanywhere.com/add_record/"
50 | headers = {
51 | "Accept-Encoding": "gzip",
52 | "User-Agent": "PySpy, Author: White Russsian, https://github.com/WhiteRusssian/PySpy"
53 | }
54 | payload = {
55 | "uuid": self._uuid,
56 | "version": self._version,
57 | "platform": self._platform,
58 | "chars": self._chars,
59 | "duration": self._duration,
60 | "sh_faction": self._sh_faction,
61 | "hl_blops": self._hl_blops,
62 | "ig_factions": self._ig_factions,
63 | "gui_alpha": self._gui_alpha
64 | }
65 |
66 | try:
67 | r = requests.get(url, headers=headers, params=payload)
68 | except requests.exceptions.ConnectionError:
69 | Logger.info("No network connection.", exc_info=True)
70 | statusmsg.push_status(
71 | '''NETWORK ERROR: Check your internet connection
72 | and firewall settings.'''
73 | )
74 | time.sleep(5)
75 | return
76 |
77 | if r.status_code != 200:
78 | status_code = r.status_code
79 | reason = r.reason
80 | Logger.info(
81 | "Could not upload usage statistics. Server message: '" +
82 | reason + "'. Code: " + str(status_code) + " [URL: " + r.url + "]",
83 | exc_info=True
84 | )
85 | return
86 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | wxPython>=4.0.3
2 | requests>=2.19.1
3 | pyperclip>=1.6.2
4 |
--------------------------------------------------------------------------------
/sortarray.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | '''SortArray() provides functionality to sort an array (list of lists
6 | or tuples) containing string or numeric values (or a mix thereof).
7 | Ability to sort by two "columns", to break a tie in the primary sort
8 | column. Can be case sensitive.
9 | '''
10 | # cSpell Checker - Correct Words****************************************
11 | # // cSpell:words russsian
12 | # **********************************************************************
13 |
14 |
15 | class SortArrayError(Exception):
16 | '''
17 | Base exception for any exceptions raised by SortArray.
18 | '''
19 | def __init__(self, msg=None):
20 | if msg is None:
21 | # Set some default useful error message
22 | msg = "SortArray encountered a problem and stopped."
23 | super(SortArrayError, self).__init__(msg)
24 |
25 |
26 | class OutOfBoundError(SortArrayError):
27 | '''
28 | Gets raised when `prim_col` is greater than the last column in `array`.
29 | '''
30 | def __init__(self, prim_col, sort_array):
31 | prim_col = prim_col
32 | array_columns = len(sort_array[0])
33 | msg = (
34 | "Sort column index (%s) exceeds number of columns in the unsorted input array (%s)." % (prim_col, array_columns)
35 | )
36 | super(SortArrayError, self).__init__(msg)
37 |
38 |
39 | def _determineApproach(array, sort_col):
40 | '''
41 | Takes an array (list of lists/tuples) and determines whether values
42 | in a given column should be sorted as numbers or strings. Returns
43 | a boolean which is True if column should be sorted as string and
44 | False if to be sorted as float.
45 |
46 | :param `array`: A list of list or tuples.
47 |
48 | :param `sort_col`: The number of the sort column as integer.
49 |
50 | :return: Boolean indicating if sort type is string (True) or float
51 | (False).
52 | '''
53 |
54 | if sort_col > len(array[0]):
55 | raise OutOfBoundError(sort_col, array)
56 |
57 | # Check type of each element to be sorted_list
58 | is_num = is_str = is_other = is_none = 0
59 | for r in array:
60 | if isinstance(r[sort_col], (int, float)):
61 | is_num += 1
62 | elif isinstance(r[sort_col], str):
63 | try:
64 | float(r[sort_col])
65 | is_num += 1
66 | except ValueError:
67 | is_str += 1
68 | elif r[sort_col] is None:
69 | is_none += 1
70 | else:
71 | is_other += 1
72 |
73 | # Make sure all elements are sortable, if not return TypeError
74 | if is_other > 0:
75 | raise TypeError
76 |
77 | # If any element is not or cannot be converted to a number,
78 | # treat all elements as string.
79 | elif is_str > 0:
80 | sort_as_str = True
81 | else:
82 | sort_as_str = False
83 |
84 | return sort_as_str
85 |
86 |
87 | def sort_array(array, prim_col, sec_col=None, prim_desc=False, sec_desc=False, case_sensitive=False):
88 | '''
89 | Take an array (list of lists/tuples) with numeric or text values (or
90 | mix of both) and sort it by a primary column and optionally by a
91 | secondary column. It is tollerant to None values.
92 |
93 | :param `array`: List of lists or tuples containing strings
94 | or numbers.
95 |
96 | :param `prim_col`: Index (integer) of primary sort column.
97 |
98 | :param `sec_col`: Index (integer) of secondary sort column.
99 |
100 | :param `prim_desc`: Boolean indicating sort order of primary sort
101 | column(`True` for descending `False` for ascending)
102 |
103 | :param `sec_desc`: Boolean indicating sort order of secondary sort
104 | column(`True` for descending `False` for ascending)
105 |
106 | :param `case_sensitive`: Boolean set to True if cases matters.
107 |
108 | :return: The sorted array as list of tuples.
109 | '''
110 |
111 | # Determine if we need to convert values to str or float
112 | prim_is_str = _determineApproach(array, prim_col)
113 | if sec_col is not None and sec_col != prim_col:
114 | sec_is_str = _determineApproach(array, sec_col)
115 | else:
116 | sec_col = None
117 |
118 | # We mke use of the fact that sorted() is stable and first sort by
119 | # the secondary sort column, if any, and then by the primary.
120 |
121 | # Secondary Sort --------------------------------------------------
122 | if sec_col is not None:
123 | if sec_is_str and case_sensitive:
124 | sorted_array = sorted(
125 | array,
126 | key=lambda r: str(r[sec_col]) if r[sec_col] is not None else "",
127 | reverse=sec_desc
128 | )
129 | elif sec_is_str and not case_sensitive:
130 | sorted_array = sorted(
131 | array,
132 | key=lambda r: str(r[sec_col]).lower() if r[sec_col] is not None else "",
133 | reverse=sec_desc
134 | )
135 | else:
136 | sorted_array = sorted(
137 | array,
138 | key=lambda r: float(r[sec_col]) if r[sec_col] is not None else float("-inf"),
139 | reverse=sec_desc
140 | )
141 | array = sorted_array
142 |
143 | # Primary Sort -----------------------------------------------------
144 | if prim_is_str and case_sensitive:
145 | sorted_array = sorted(
146 | array,
147 | key=lambda r: str(r[prim_col]) if r[prim_col] is not None else "",
148 | reverse=prim_desc
149 | )
150 | elif prim_is_str and not case_sensitive:
151 | sorted_array = sorted(
152 | array,
153 | key=lambda r: str(r[prim_col]).lower() if r[prim_col] is not None else "",
154 | reverse=prim_desc
155 | )
156 | else:
157 | sorted_array = sorted(
158 | array,
159 | key=lambda r: float(r[prim_col]) if r[prim_col] is not None else float("-inf"),
160 | reverse=prim_desc
161 | )
162 |
163 | return sorted_array
164 |
--------------------------------------------------------------------------------
/statusmsg.py:
--------------------------------------------------------------------------------
1 | # !/usr/local/bin/python3.6
2 | # MIT licensed
3 | # Copyright (c) 2018 White Russsian
4 | # Github: **********************
5 | ''' Provides functionality to update the status message in the
6 | wxPython GUi.
7 | '''
8 | # **********************************************************************
9 | import logging
10 | import time
11 |
12 | import wx
13 |
14 | import __main__
15 | import config
16 | # cSpell Checker - Correct Words****************************************
17 | # // cSpell:words russsian
18 | # **********************************************************************
19 | Logger = logging.getLogger(__name__)
20 | # Example call: Logger.info("Something badhappened", exc_info=True) ****
21 |
22 |
23 | def push_status(msg):
24 | wx.CallAfter(__main__.app.PySpy.updateStatusbar, msg)
25 |
--------------------------------------------------------------------------------