cs2tracker 2.1.16__tar.gz → 2.1.18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of cs2tracker might be problematic. Click here for more details.
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/.gitignore +3 -1
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/PKG-INFO +28 -7
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/README.md +27 -6
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/_version.py +2 -2
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/app/app.py +48 -15
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/app/editor_frame.py +99 -45
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/config.py +61 -28
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/constants.py +86 -25
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/data/config.ini +14 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/data/convert_inventory.js +50 -19
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/data/get_inventory.js +53 -24
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker.egg-info/PKG-INFO +28 -7
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/.flake8 +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/.isort.cfg +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/.pre-commit-config.yaml +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/.pylintrc +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/LICENSE +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/MANIFEST.in +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/assets/demo.gif +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/assets/icon.png +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/__init__.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/__main__.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/app/__init__.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/app/history_frame.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/app/scraper_frame.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/data/output.csv +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/logs.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/main.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/scraper/__init__.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/scraper/background_task.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/scraper/discord_notifier.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/scraper/parser.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/scraper/scraper.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/util/__init__.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/util/currency_conversion.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/util/padded_console.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker/util/tkinter.py +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker.egg-info/SOURCES.txt +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker.egg-info/dependency_links.txt +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker.egg-info/entry_points.txt +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker.egg-info/requires.txt +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/cs2tracker.egg-info/top_level.txt +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/pyproject.toml +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/requirements.txt +0 -0
- {cs2tracker-2.1.16 → cs2tracker-2.1.18}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cs2tracker
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.18
|
|
4
4
|
Summary: Tracking the steam market prices of CS2 items
|
|
5
5
|
Home-page: https://github.com/ashiven/cs2tracker
|
|
6
6
|
Author: Jannik Novak
|
|
@@ -54,6 +54,7 @@ Dynamic: license-file
|
|
|
54
54
|
- [Usage](#usage)
|
|
55
55
|
- [Configuration](#configuration)
|
|
56
56
|
- [Advanced Features](#advanced-features)
|
|
57
|
+
- [FAQ](#faq)
|
|
57
58
|
- [Contributing](#contributing)
|
|
58
59
|
- [License](#license)
|
|
59
60
|
|
|
@@ -74,8 +75,9 @@ Dynamic: license-file
|
|
|
74
75
|
#### Method 1: Executable
|
|
75
76
|
|
|
76
77
|
Simply download the program and run it:
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
- [Windows](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-windows.zip)
|
|
80
|
+
- [Linux](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-linux.zip)
|
|
79
81
|
|
|
80
82
|
#### Method 2: Install via Pip
|
|
81
83
|
|
|
@@ -90,6 +92,7 @@ Simply download the program and run it:
|
|
|
90
92
|
```bash
|
|
91
93
|
cs2tracker
|
|
92
94
|
```
|
|
95
|
+
|
|
93
96
|
### Additional Setup
|
|
94
97
|
|
|
95
98
|
- Register for the [Crawlbase Smart Proxy API](https://crawlbase.com/) and retrieve your API key. (Optional)
|
|
@@ -117,18 +120,36 @@ This will open the config editor where you can change any setting by double clic
|
|
|
117
120
|
|
|
118
121
|
- Enable **Daily Background Calculations** to automatically run a daily calculation of your investment in the background.
|
|
119
122
|
- Use **Receive Discord Notifications** to receive a notification on your Discord server whenever the program has finished calculating your investment.
|
|
120
|
-
- You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook
|
|
123
|
+
- You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook URL into the `discord_webhook_url` field in the config `User Settings`.
|
|
121
124
|
- Enable **Proxy Requests** to prevent your requests from being rate limited by the steamcommunity server.
|
|
122
125
|
- You need to register for a free API key on [Crawlbase](crawlbase.com) and enter it into the `proxy_api_key` field in the config `User Settings`.
|
|
123
126
|
|
|
127
|
+
## FAQ
|
|
128
|
+
|
|
129
|
+
- **Q: Is it safe to login with my Steam account?**
|
|
130
|
+
- **A:** Yes, the program uses the [SteamUser](https://github.com/DoctorMcKay/node-steam-user?tab=readme-ov-file#methods-) and [Globaloffensive](https://github.com/DoctorMcKay/node-globaloffensive) libraries to sign in and import your Storage Units (the same method is used by [casemove](https://github.com/nombersDev/casemove)) and all of the login-related code is transparently available in [this file](cs2tracker/data/get_inventory.js).
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
- **Q: Do I have to login with my Steam account?**
|
|
134
|
+
- **A:** No, you can also manually specify the number of items you own in the config editor.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
- **Q: Can I get VAC-banned for using this program?**
|
|
138
|
+
- **A:** No, this program does not interact with the game in any way and only reads your Storage Units.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
- **Q: Why does Windows Defender flag this program as potentially harmful?**
|
|
142
|
+
- **A:** This is because the program is not signed with a [Code Signing Certificate](https://www.globalsign.com/en/code-signing-certificate/what-is-code-signing-certificate), which Windows uses to verify the identity of publishers. These certificates are very expensive and not something I am willing to invest in for a free and open source project like this.
|
|
143
|
+
|
|
124
144
|
## Contributing
|
|
125
145
|
|
|
126
|
-
Please feel free to submit a pull request
|
|
146
|
+
Please feel free to submit a [pull request](https://github.com/ashiven/cs2tracker/pulls) or open an [issue](https://github.com/ashiven/cs2tracker/issues).
|
|
127
147
|
|
|
128
148
|
1. Fork the repository
|
|
129
|
-
2. Create a new branch
|
|
149
|
+
2. Create a new branch: `git checkout -b feature-name`.
|
|
130
150
|
3. Make your changes
|
|
131
|
-
4.
|
|
151
|
+
4. Push your branch: `git push origin feature-name`.
|
|
152
|
+
5. Submit a PR
|
|
132
153
|
|
|
133
154
|
## License
|
|
134
155
|
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
- [Usage](#usage)
|
|
27
27
|
- [Configuration](#configuration)
|
|
28
28
|
- [Advanced Features](#advanced-features)
|
|
29
|
+
- [FAQ](#faq)
|
|
29
30
|
- [Contributing](#contributing)
|
|
30
31
|
- [License](#license)
|
|
31
32
|
|
|
@@ -46,8 +47,9 @@
|
|
|
46
47
|
#### Method 1: Executable
|
|
47
48
|
|
|
48
49
|
Simply download the program and run it:
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
|
|
51
|
+
- [Windows](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-windows.zip)
|
|
52
|
+
- [Linux](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-linux.zip)
|
|
51
53
|
|
|
52
54
|
#### Method 2: Install via Pip
|
|
53
55
|
|
|
@@ -62,6 +64,7 @@ Simply download the program and run it:
|
|
|
62
64
|
```bash
|
|
63
65
|
cs2tracker
|
|
64
66
|
```
|
|
67
|
+
|
|
65
68
|
### Additional Setup
|
|
66
69
|
|
|
67
70
|
- Register for the [Crawlbase Smart Proxy API](https://crawlbase.com/) and retrieve your API key. (Optional)
|
|
@@ -89,18 +92,36 @@ This will open the config editor where you can change any setting by double clic
|
|
|
89
92
|
|
|
90
93
|
- Enable **Daily Background Calculations** to automatically run a daily calculation of your investment in the background.
|
|
91
94
|
- Use **Receive Discord Notifications** to receive a notification on your Discord server whenever the program has finished calculating your investment.
|
|
92
|
-
- You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook
|
|
95
|
+
- You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook URL into the `discord_webhook_url` field in the config `User Settings`.
|
|
93
96
|
- Enable **Proxy Requests** to prevent your requests from being rate limited by the steamcommunity server.
|
|
94
97
|
- You need to register for a free API key on [Crawlbase](crawlbase.com) and enter it into the `proxy_api_key` field in the config `User Settings`.
|
|
95
98
|
|
|
99
|
+
## FAQ
|
|
100
|
+
|
|
101
|
+
- **Q: Is it safe to login with my Steam account?**
|
|
102
|
+
- **A:** Yes, the program uses the [SteamUser](https://github.com/DoctorMcKay/node-steam-user?tab=readme-ov-file#methods-) and [Globaloffensive](https://github.com/DoctorMcKay/node-globaloffensive) libraries to sign in and import your Storage Units (the same method is used by [casemove](https://github.com/nombersDev/casemove)) and all of the login-related code is transparently available in [this file](cs2tracker/data/get_inventory.js).
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
- **Q: Do I have to login with my Steam account?**
|
|
106
|
+
- **A:** No, you can also manually specify the number of items you own in the config editor.
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
- **Q: Can I get VAC-banned for using this program?**
|
|
110
|
+
- **A:** No, this program does not interact with the game in any way and only reads your Storage Units.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
- **Q: Why does Windows Defender flag this program as potentially harmful?**
|
|
114
|
+
- **A:** This is because the program is not signed with a [Code Signing Certificate](https://www.globalsign.com/en/code-signing-certificate/what-is-code-signing-certificate), which Windows uses to verify the identity of publishers. These certificates are very expensive and not something I am willing to invest in for a free and open source project like this.
|
|
115
|
+
|
|
96
116
|
## Contributing
|
|
97
117
|
|
|
98
|
-
Please feel free to submit a pull request
|
|
118
|
+
Please feel free to submit a [pull request](https://github.com/ashiven/cs2tracker/pulls) or open an [issue](https://github.com/ashiven/cs2tracker/issues).
|
|
99
119
|
|
|
100
120
|
1. Fork the repository
|
|
101
|
-
2. Create a new branch
|
|
121
|
+
2. Create a new branch: `git checkout -b feature-name`.
|
|
102
122
|
3. Make your changes
|
|
103
|
-
4.
|
|
123
|
+
4. Push your branch: `git push origin feature-name`.
|
|
124
|
+
5. Submit a PR
|
|
104
125
|
|
|
105
126
|
## License
|
|
106
127
|
|
|
@@ -15,6 +15,7 @@ from cs2tracker.logs import PriceLogs
|
|
|
15
15
|
from cs2tracker.scraper.background_task import BackgroundTask
|
|
16
16
|
from cs2tracker.scraper.scraper import Scraper
|
|
17
17
|
from cs2tracker.util.currency_conversion import CURRENCY_SYMBOLS
|
|
18
|
+
from cs2tracker.util.padded_console import get_console
|
|
18
19
|
from cs2tracker.util.tkinter import centered, fix_sv_ttk, size_info
|
|
19
20
|
|
|
20
21
|
APPLICATION_NAME = "CS2Tracker"
|
|
@@ -31,6 +32,7 @@ PRICE_HISTORY_TITLE = "Price History"
|
|
|
31
32
|
PRICE_HISTORY_SIZE = "900x700"
|
|
32
33
|
|
|
33
34
|
config = get_config()
|
|
35
|
+
console = get_console()
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
class Application:
|
|
@@ -48,6 +50,7 @@ class Application:
|
|
|
48
50
|
|
|
49
51
|
fix_sv_ttk(ttk.Style())
|
|
50
52
|
|
|
53
|
+
window.focus_force()
|
|
51
54
|
window.mainloop()
|
|
52
55
|
|
|
53
56
|
def _configure_window(self):
|
|
@@ -76,6 +79,10 @@ class MainFrame(ttk.Frame):
|
|
|
76
79
|
super().__init__(parent, padding=15)
|
|
77
80
|
self.parent = parent
|
|
78
81
|
self.scraper = scraper
|
|
82
|
+
|
|
83
|
+
self.scraper_window = None
|
|
84
|
+
self.config_editor_window = None
|
|
85
|
+
self.price_history_window = None
|
|
79
86
|
self._add_widgets()
|
|
80
87
|
|
|
81
88
|
def _add_widgets(self):
|
|
@@ -181,13 +188,21 @@ class MainFrame(ttk.Frame):
|
|
|
181
188
|
"""Scrape prices from the configured sources, print the total, and save the
|
|
182
189
|
results to a file.
|
|
183
190
|
"""
|
|
184
|
-
scraper_window
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
if self.scraper_window is None or not self.scraper_window.winfo_exists():
|
|
192
|
+
self._open_scraper_window()
|
|
193
|
+
else:
|
|
194
|
+
self.scraper_window.lift()
|
|
195
|
+
self.scraper_window.focus_set()
|
|
196
|
+
|
|
197
|
+
def _open_scraper_window(self):
|
|
198
|
+
"""Open a new window with the scraper GUI."""
|
|
199
|
+
self.scraper_window = tk.Toplevel(self.parent)
|
|
200
|
+
self.scraper_window.geometry(centered(self.scraper_window, SCRAPER_WINDOW_SIZE))
|
|
201
|
+
self.scraper_window.minsize(*size_info(SCRAPER_WINDOW_SIZE))
|
|
202
|
+
self.scraper_window.title(SCRAPER_WINDOW_TITLE)
|
|
188
203
|
|
|
189
204
|
run_frame = ScraperFrame(
|
|
190
|
-
scraper_window,
|
|
205
|
+
self.scraper_window,
|
|
191
206
|
self.scraper,
|
|
192
207
|
sheet_size=SCRAPER_WINDOW_SIZE,
|
|
193
208
|
dark_theme=self.dark_theme_checkbox_value.get(),
|
|
@@ -196,26 +211,42 @@ class MainFrame(ttk.Frame):
|
|
|
196
211
|
run_frame.start()
|
|
197
212
|
|
|
198
213
|
def _edit_config(self):
|
|
214
|
+
"""Open a new window with a config editor GUI or lift the existing one."""
|
|
215
|
+
if self.config_editor_window is None or not self.config_editor_window.winfo_exists():
|
|
216
|
+
self._open_config_editor()
|
|
217
|
+
else:
|
|
218
|
+
self.config_editor_window.lift()
|
|
219
|
+
self.config_editor_window.focus_set()
|
|
220
|
+
|
|
221
|
+
def _open_config_editor(self):
|
|
199
222
|
"""Open a new window with a config editor GUI."""
|
|
200
|
-
config_editor_window = tk.Toplevel(self.parent)
|
|
201
|
-
config_editor_window.geometry(centered(config_editor_window, CONFIG_EDITOR_SIZE))
|
|
202
|
-
config_editor_window.minsize(*size_info(CONFIG_EDITOR_SIZE))
|
|
203
|
-
config_editor_window.title(CONFIG_EDITOR_TITLE)
|
|
223
|
+
self.config_editor_window = tk.Toplevel(self.parent)
|
|
224
|
+
self.config_editor_window.geometry(centered(self.config_editor_window, CONFIG_EDITOR_SIZE))
|
|
225
|
+
self.config_editor_window.minsize(*size_info(CONFIG_EDITOR_SIZE))
|
|
226
|
+
self.config_editor_window.title(CONFIG_EDITOR_TITLE)
|
|
204
227
|
|
|
205
|
-
editor_frame = ConfigEditorFrame(config_editor_window)
|
|
228
|
+
editor_frame = ConfigEditorFrame(self.config_editor_window)
|
|
206
229
|
editor_frame.pack(expand=True, fill="both")
|
|
207
230
|
|
|
208
231
|
def _show_history(self):
|
|
209
232
|
"""Show a chart consisting of past calculations."""
|
|
233
|
+
if self.price_history_window is None or not self.price_history_window.winfo_exists():
|
|
234
|
+
self._open_history_window()
|
|
235
|
+
else:
|
|
236
|
+
self.price_history_window.lift()
|
|
237
|
+
self.price_history_window.focus_set()
|
|
238
|
+
|
|
239
|
+
def _open_history_window(self):
|
|
240
|
+
"""Open a new window with a price history GUI."""
|
|
210
241
|
if PriceLogs.empty():
|
|
211
242
|
return
|
|
212
243
|
|
|
213
|
-
price_history_window = tk.Toplevel(self.parent)
|
|
214
|
-
price_history_window.geometry(centered(price_history_window, PRICE_HISTORY_SIZE))
|
|
215
|
-
price_history_window.minsize(*size_info(PRICE_HISTORY_SIZE))
|
|
216
|
-
price_history_window.title(PRICE_HISTORY_TITLE)
|
|
244
|
+
self.price_history_window = tk.Toplevel(self.parent)
|
|
245
|
+
self.price_history_window.geometry(centered(self.price_history_window, PRICE_HISTORY_SIZE))
|
|
246
|
+
self.price_history_window.minsize(*size_info(PRICE_HISTORY_SIZE))
|
|
247
|
+
self.price_history_window.title(PRICE_HISTORY_TITLE)
|
|
217
248
|
|
|
218
|
-
history_frame = PriceHistoryFrame(price_history_window)
|
|
249
|
+
history_frame = PriceHistoryFrame(self.price_history_window)
|
|
219
250
|
history_frame.pack(expand=True, fill="both")
|
|
220
251
|
|
|
221
252
|
def _export_log_file(self):
|
|
@@ -239,8 +270,10 @@ class MainFrame(ttk.Frame):
|
|
|
239
270
|
filetypes=[("CSV files", "*.csv")],
|
|
240
271
|
)
|
|
241
272
|
if not PriceLogs.validate_file(import_path):
|
|
273
|
+
console.error("Invalid log file format.")
|
|
242
274
|
return
|
|
243
275
|
copy(import_path, OUTPUT_FILE)
|
|
276
|
+
console.info("Log file imported successfully.")
|
|
244
277
|
|
|
245
278
|
def _toggle_background_task(self, enabled: bool):
|
|
246
279
|
"""Toggle whether a daily price calculation should run in the background."""
|
|
@@ -8,7 +8,7 @@ from tkinter import messagebox, ttk
|
|
|
8
8
|
from nodejs import node
|
|
9
9
|
from ttk_text import ThemedText
|
|
10
10
|
|
|
11
|
-
from cs2tracker.config import get_config
|
|
11
|
+
from cs2tracker.config import CUSTOM_SECTIONS, get_config
|
|
12
12
|
from cs2tracker.constants import (
|
|
13
13
|
CONFIG_FILE,
|
|
14
14
|
CONFIG_FILE_BACKUP,
|
|
@@ -131,7 +131,7 @@ class ConfigEditorFrame(ttk.Frame):
|
|
|
131
131
|
if selected:
|
|
132
132
|
item = selected[0]
|
|
133
133
|
section_name = self.tree.parent(item)
|
|
134
|
-
if section_name in
|
|
134
|
+
if section_name in CUSTOM_SECTIONS:
|
|
135
135
|
next_option = self.tree.next(item)
|
|
136
136
|
self.tree.delete(item)
|
|
137
137
|
self.save_config()
|
|
@@ -169,31 +169,19 @@ class ConfigEditorFrame(ttk.Frame):
|
|
|
169
169
|
continue
|
|
170
170
|
|
|
171
171
|
section_level = self.tree.insert("", "end", iid=section, text=section)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
section_items = config.items(section)
|
|
175
|
-
if section in ("Stickers", "Cases", "Skins"):
|
|
176
|
-
section_items = sorted(section_items)
|
|
177
|
-
|
|
178
|
-
for config_option, value in section_items:
|
|
172
|
+
sorted_section_items = sorted(config.items(section))
|
|
173
|
+
for config_option, value in sorted_section_items:
|
|
179
174
|
if section not in ("User Settings", "App Settings"):
|
|
180
175
|
option_name = config.option_to_name(config_option, href=True)
|
|
181
|
-
self.tree.insert(
|
|
182
|
-
section_level,
|
|
183
|
-
"end",
|
|
184
|
-
iid=f"{section}-{option_name}",
|
|
185
|
-
text=option_name,
|
|
186
|
-
values=[value],
|
|
187
|
-
)
|
|
188
176
|
else:
|
|
189
177
|
option_name = config.option_to_name(config_option)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
178
|
+
self.tree.insert(
|
|
179
|
+
section_level,
|
|
180
|
+
"end",
|
|
181
|
+
iid=f"{section}-{option_name}",
|
|
182
|
+
text=option_name,
|
|
183
|
+
values=[value],
|
|
184
|
+
)
|
|
197
185
|
|
|
198
186
|
self.tree.focus("User Settings")
|
|
199
187
|
self.tree.selection_set("User Settings")
|
|
@@ -253,6 +241,8 @@ class ConfigEditorButtonFrame(ttk.Frame):
|
|
|
253
241
|
self.editor_frame = editor_frame
|
|
254
242
|
self.custom_item_dialog = None
|
|
255
243
|
|
|
244
|
+
self.custom_item_window = None
|
|
245
|
+
self.steam_inventory_window = None
|
|
256
246
|
self._add_widgets()
|
|
257
247
|
|
|
258
248
|
def _add_widgets(self):
|
|
@@ -265,6 +255,25 @@ class ConfigEditorButtonFrame(ttk.Frame):
|
|
|
265
255
|
custom_item_button = ttk.Button(self, text="Add Item", command=self._add_custom_item)
|
|
266
256
|
custom_item_button.pack(side="left", expand=True, padx=5)
|
|
267
257
|
|
|
258
|
+
def open_custom_enter(_):
|
|
259
|
+
selected = self.editor_frame.tree.selection()
|
|
260
|
+
if selected and not self.editor_frame.tree.parent(selected[0]):
|
|
261
|
+
selected_section = self.editor_frame.tree.item(selected[0], "text")
|
|
262
|
+
if selected_section in CUSTOM_SECTIONS and not self.editor_frame.tree.get_children(
|
|
263
|
+
selected_section
|
|
264
|
+
):
|
|
265
|
+
custom_item_button.invoke()
|
|
266
|
+
|
|
267
|
+
def open_custom_click(event):
|
|
268
|
+
selected_section = self.editor_frame.tree.identify_row(event.y)
|
|
269
|
+
if selected_section in CUSTOM_SECTIONS and not self.editor_frame.tree.get_children(
|
|
270
|
+
selected_section
|
|
271
|
+
):
|
|
272
|
+
custom_item_button.invoke()
|
|
273
|
+
|
|
274
|
+
self.editor_frame.tree.bind("<Return>", open_custom_enter, add="+")
|
|
275
|
+
self.editor_frame.tree.bind("<Double-1>", open_custom_click, add="+")
|
|
276
|
+
|
|
268
277
|
import_inventory_button = ttk.Button(
|
|
269
278
|
self, text="Import Steam Inventory", command=self._import_steam_inventory
|
|
270
279
|
)
|
|
@@ -285,37 +294,59 @@ class ConfigEditorButtonFrame(ttk.Frame):
|
|
|
285
294
|
self.editor_frame.tree.focus_set()
|
|
286
295
|
|
|
287
296
|
def _add_custom_item(self):
|
|
297
|
+
"""Open a window to add a new custom item or lift the existing one if it is
|
|
298
|
+
already open.
|
|
299
|
+
"""
|
|
300
|
+
if self.custom_item_window is None or not self.custom_item_window.winfo_exists():
|
|
301
|
+
self._open_custom_item_window()
|
|
302
|
+
else:
|
|
303
|
+
self.custom_item_window.lift()
|
|
304
|
+
self.custom_item_window.focus_set()
|
|
305
|
+
|
|
306
|
+
def _open_custom_item_window(self):
|
|
288
307
|
"""Open a window to add a new custom item."""
|
|
289
|
-
custom_item_window = tk.Toplevel(self.editor_frame)
|
|
290
|
-
custom_item_window.title(ADD_CUSTOM_ITEM_TITLE)
|
|
291
|
-
custom_item_window.geometry(centered(custom_item_window, ADD_CUSTOM_ITEM_SIZE))
|
|
292
|
-
custom_item_window.minsize(*size_info(ADD_CUSTOM_ITEM_SIZE))
|
|
293
|
-
custom_item_window.focus_set()
|
|
308
|
+
self.custom_item_window = tk.Toplevel(self.editor_frame)
|
|
309
|
+
self.custom_item_window.title(ADD_CUSTOM_ITEM_TITLE)
|
|
310
|
+
self.custom_item_window.geometry(centered(self.custom_item_window, ADD_CUSTOM_ITEM_SIZE))
|
|
311
|
+
self.custom_item_window.minsize(*size_info(ADD_CUSTOM_ITEM_SIZE))
|
|
312
|
+
self.custom_item_window.focus_set()
|
|
294
313
|
|
|
295
314
|
def on_close():
|
|
296
|
-
custom_item_window.destroy()
|
|
315
|
+
self.custom_item_window.destroy() # type: ignore
|
|
297
316
|
self.editor_frame.tree.focus_set()
|
|
298
317
|
|
|
299
|
-
custom_item_window.protocol("WM_DELETE_WINDOW", on_close)
|
|
318
|
+
self.custom_item_window.protocol("WM_DELETE_WINDOW", on_close)
|
|
300
319
|
|
|
301
|
-
custom_item_frame = CustomItemFrame(custom_item_window, self.editor_frame)
|
|
320
|
+
custom_item_frame = CustomItemFrame(self.custom_item_window, self.editor_frame)
|
|
302
321
|
custom_item_frame.pack(expand=True, fill="both", padx=15, pady=15)
|
|
303
322
|
|
|
304
323
|
def _import_steam_inventory(self):
|
|
324
|
+
"""Open a window to import the user's Steam inventory or lift the existing one
|
|
325
|
+
if it is already open.
|
|
326
|
+
"""
|
|
327
|
+
if self.steam_inventory_window is None or not self.steam_inventory_window.winfo_exists():
|
|
328
|
+
self._open_steam_inventory_window()
|
|
329
|
+
else:
|
|
330
|
+
self.steam_inventory_window.lift()
|
|
331
|
+
self.steam_inventory_window.focus_set()
|
|
332
|
+
|
|
333
|
+
def _open_steam_inventory_window(self):
|
|
305
334
|
"""Open a window to import the user's Steam inventory."""
|
|
306
|
-
steam_inventory_window = tk.Toplevel(self.editor_frame)
|
|
307
|
-
steam_inventory_window.title(IMPORT_INVENTORY_TITLE)
|
|
308
|
-
steam_inventory_window.geometry(
|
|
309
|
-
|
|
310
|
-
|
|
335
|
+
self.steam_inventory_window = tk.Toplevel(self.editor_frame)
|
|
336
|
+
self.steam_inventory_window.title(IMPORT_INVENTORY_TITLE)
|
|
337
|
+
self.steam_inventory_window.geometry(
|
|
338
|
+
centered(self.steam_inventory_window, IMPORT_INVENTORY_SIZE)
|
|
339
|
+
)
|
|
340
|
+
self.steam_inventory_window.minsize(*size_info(IMPORT_INVENTORY_SIZE))
|
|
341
|
+
self.steam_inventory_window.focus_set()
|
|
311
342
|
|
|
312
343
|
def on_close():
|
|
313
|
-
steam_inventory_window.destroy()
|
|
344
|
+
self.steam_inventory_window.destroy() # type: ignore
|
|
314
345
|
self.editor_frame.tree.focus_set()
|
|
315
346
|
|
|
316
|
-
steam_inventory_window.protocol("WM_DELETE_WINDOW", on_close)
|
|
347
|
+
self.steam_inventory_window.protocol("WM_DELETE_WINDOW", on_close)
|
|
317
348
|
|
|
318
|
-
steam_inventory_frame = InventoryImportFrame(steam_inventory_window, self.editor_frame)
|
|
349
|
+
steam_inventory_frame = InventoryImportFrame(self.steam_inventory_window, self.editor_frame)
|
|
319
350
|
steam_inventory_frame.pack(expand=True, fill="both", padx=15, pady=15)
|
|
320
351
|
|
|
321
352
|
|
|
@@ -373,6 +404,30 @@ class CustomItemFrame(ttk.Frame):
|
|
|
373
404
|
break
|
|
374
405
|
return insert_index
|
|
375
406
|
|
|
407
|
+
def _identify_custom_section(self, item_name):
|
|
408
|
+
# pylint: disable=too-many-return-statements
|
|
409
|
+
"""Given an item name, identify the custom section it belongs to."""
|
|
410
|
+
if "Patch Pack" in item_name or "Patch Collection" in item_name:
|
|
411
|
+
return "Patch Packs"
|
|
412
|
+
elif "Patch |" in item_name:
|
|
413
|
+
return "Patches"
|
|
414
|
+
elif "Sticker |" in item_name:
|
|
415
|
+
return "Stickers"
|
|
416
|
+
elif "Charm |" in item_name:
|
|
417
|
+
return "Charms"
|
|
418
|
+
elif "Souvenir" in item_name and "|" not in item_name:
|
|
419
|
+
return "Souvenirs"
|
|
420
|
+
elif "★ " in item_name:
|
|
421
|
+
return "Special Items"
|
|
422
|
+
elif " | " in item_name and "(" in item_name and ")" in item_name:
|
|
423
|
+
return "Skins"
|
|
424
|
+
elif "Music Kit |" in item_name:
|
|
425
|
+
return "Others"
|
|
426
|
+
elif " | " in item_name:
|
|
427
|
+
return "Agents"
|
|
428
|
+
else:
|
|
429
|
+
return "Others"
|
|
430
|
+
|
|
376
431
|
def _add_custom_item(self, item_href, item_owned):
|
|
377
432
|
"""Add a custom item to the configuration."""
|
|
378
433
|
if not item_href or not item_owned:
|
|
@@ -380,7 +435,7 @@ class CustomItemFrame(ttk.Frame):
|
|
|
380
435
|
"Input Error", "All fields must be filled out.", parent=self.window
|
|
381
436
|
)
|
|
382
437
|
return
|
|
383
|
-
if config.option_exists(item_href, exclude_sections=
|
|
438
|
+
if config.option_exists(item_href, exclude_sections=CUSTOM_SECTIONS):
|
|
384
439
|
messagebox.showerror(
|
|
385
440
|
"Item Exists", "This item already exists in another section.", parent=self.window
|
|
386
441
|
)
|
|
@@ -392,12 +447,11 @@ class CustomItemFrame(ttk.Frame):
|
|
|
392
447
|
messagebox.showerror("Invalid URL", str(error), parent=self.window)
|
|
393
448
|
return
|
|
394
449
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return
|
|
450
|
+
for section in CUSTOM_SECTIONS:
|
|
451
|
+
if self._update_existing(section, item_name, item_owned):
|
|
452
|
+
return
|
|
399
453
|
|
|
400
|
-
section =
|
|
454
|
+
section = self._identify_custom_section(item_name)
|
|
401
455
|
insert_index = self._get_insert_index(item_name, section)
|
|
402
456
|
self.editor_frame.tree.insert(
|
|
403
457
|
section,
|
|
@@ -3,12 +3,53 @@ import re
|
|
|
3
3
|
from configparser import ConfigParser, ParsingError
|
|
4
4
|
from urllib.parse import quote, unquote
|
|
5
5
|
|
|
6
|
-
from cs2tracker.constants import
|
|
6
|
+
from cs2tracker.constants import (
|
|
7
|
+
CONFIG_FILE,
|
|
8
|
+
INVENTORY_IMPORT_FILE,
|
|
9
|
+
)
|
|
7
10
|
from cs2tracker.util.padded_console import get_console
|
|
8
11
|
|
|
9
12
|
STEAM_MARKET_LISTING_BASEURL_CS2 = "https://steamcommunity.com/market/listings/730/"
|
|
10
13
|
STEAM_MARKET_LISTING_REGEX = r"^https://steamcommunity.com/market/listings/\d+/.+$"
|
|
11
14
|
|
|
15
|
+
CUSTOM_SECTIONS = [
|
|
16
|
+
"Skins",
|
|
17
|
+
"Special Items",
|
|
18
|
+
"Agents",
|
|
19
|
+
"Charms",
|
|
20
|
+
"Patches",
|
|
21
|
+
"Patch Packs",
|
|
22
|
+
"Stickers",
|
|
23
|
+
"Souvenirs",
|
|
24
|
+
"Others",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
PREEXISTING_SECTIONS = [
|
|
28
|
+
"Cases",
|
|
29
|
+
"Katowice 2014 Sticker Capsule",
|
|
30
|
+
"Cologne 2014 Sticker Capsule",
|
|
31
|
+
"DreamHack 2014 Sticker Capsule",
|
|
32
|
+
"Katowice 2015 Sticker Capsule",
|
|
33
|
+
"Cologne 2015 Sticker Capsule",
|
|
34
|
+
"Cluj-Napoca 2015 Sticker Capsule",
|
|
35
|
+
"Columbus 2016 Sticker Capsule",
|
|
36
|
+
"Cologne 2016 Sticker Capsule",
|
|
37
|
+
"Atlanta 2017 Sticker Capsule",
|
|
38
|
+
"Krakow 2017 Sticker Capsule",
|
|
39
|
+
"Boston 2018 Sticker Capsule",
|
|
40
|
+
"London 2018 Sticker Capsule",
|
|
41
|
+
"Katowice 2019 Sticker Capsule",
|
|
42
|
+
"Berlin 2019 Sticker Capsule",
|
|
43
|
+
"2020 RMR Sticker Capsule",
|
|
44
|
+
"Stockholm 2021 Sticker Capsule",
|
|
45
|
+
"Antwerp 2022 Sticker Capsule",
|
|
46
|
+
"Rio 2022 Sticker Capsule",
|
|
47
|
+
"Paris 2023 Sticker Capsule",
|
|
48
|
+
"Copenhagen 2024 Sticker Capsule",
|
|
49
|
+
"Shanghai 2024 Sticker Capsule",
|
|
50
|
+
"Austin 2025 Sticker Capsule",
|
|
51
|
+
]
|
|
52
|
+
|
|
12
53
|
console = get_console()
|
|
13
54
|
|
|
14
55
|
|
|
@@ -46,19 +87,12 @@ class ValidatedConfig(ConfigParser):
|
|
|
46
87
|
|
|
47
88
|
def _validate_config_sections(self):
|
|
48
89
|
"""Validate that the configuration file has all required sections."""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if not self.has_section("Cases"):
|
|
56
|
-
raise ValueError("Missing 'Cases' section in the configuration file.")
|
|
57
|
-
if not self.has_section("Skins"):
|
|
58
|
-
raise ValueError("Missing 'Skins' section in the configuration file.")
|
|
59
|
-
for capsule_section in CAPSULE_PAGES:
|
|
60
|
-
if not self.has_section(capsule_section):
|
|
61
|
-
raise ValueError(f"Missing '{capsule_section}' section in the configuration file.")
|
|
90
|
+
for section in CUSTOM_SECTIONS:
|
|
91
|
+
if not self.has_section(section):
|
|
92
|
+
raise ValueError(f"Missing '{section}' section in the configuration file.")
|
|
93
|
+
for section in PREEXISTING_SECTIONS:
|
|
94
|
+
if not self.has_section(section):
|
|
95
|
+
raise ValueError(f"Missing '{section}' section in the configuration file.")
|
|
62
96
|
|
|
63
97
|
def _validate_config_values(self):
|
|
64
98
|
# pylint: disable=too-many-branches
|
|
@@ -136,23 +170,22 @@ class ValidatedConfig(ConfigParser):
|
|
|
136
170
|
try:
|
|
137
171
|
with open(INVENTORY_IMPORT_FILE, "r", encoding="utf-8") as inventory_file:
|
|
138
172
|
inventory_data = json.load(inventory_file)
|
|
139
|
-
sorted_inventory_data = dict(sorted(inventory_data.items()))
|
|
140
173
|
|
|
141
174
|
added_to_config = set()
|
|
142
|
-
for
|
|
143
|
-
|
|
144
|
-
for section in self.sections():
|
|
145
|
-
if option in self.options(section):
|
|
146
|
-
self.set(section, option, str(item_owned))
|
|
147
|
-
added_to_config.add(item_name)
|
|
148
|
-
|
|
149
|
-
for item_name, item_owned in sorted_inventory_data.items():
|
|
150
|
-
if item_name not in added_to_config:
|
|
175
|
+
for _, item_infos in inventory_data.items():
|
|
176
|
+
for item_name, item_owned in item_infos.items():
|
|
151
177
|
option = self.name_to_option(item_name, href=True)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
178
|
+
for section in self.sections():
|
|
179
|
+
if option in self.options(section):
|
|
180
|
+
self.set(section, option, str(item_owned))
|
|
181
|
+
added_to_config.add(item_name)
|
|
182
|
+
|
|
183
|
+
for section, item_infos in inventory_data.items():
|
|
184
|
+
sorted_item_infos = dict(sorted(item_infos.items()))
|
|
185
|
+
for item_name, item_owned in sorted_item_infos.items():
|
|
186
|
+
if item_name not in added_to_config:
|
|
187
|
+
option = self.name_to_option(item_name, href=True)
|
|
188
|
+
self.set(section, option, str(item_owned))
|
|
156
189
|
|
|
157
190
|
self.write_to_file()
|
|
158
191
|
except (FileNotFoundError, json.JSONDecodeError) as error:
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import ctypes
|
|
1
2
|
import enum
|
|
2
3
|
import os
|
|
3
4
|
import sys
|
|
5
|
+
import tkinter as tk
|
|
4
6
|
from datetime import datetime
|
|
5
|
-
from shutil import copy
|
|
7
|
+
from shutil import copy, copytree
|
|
6
8
|
from subprocess import DEVNULL
|
|
9
|
+
from tkinter import ttk
|
|
7
10
|
|
|
11
|
+
import sv_ttk
|
|
8
12
|
from nodejs import npm
|
|
9
13
|
|
|
14
|
+
from cs2tracker.util.tkinter import centered
|
|
15
|
+
|
|
10
16
|
try:
|
|
11
17
|
from cs2tracker._version import version # pylint: disable=E0611
|
|
12
18
|
|
|
@@ -48,33 +54,86 @@ if RUNNING_IN_EXE:
|
|
|
48
54
|
else:
|
|
49
55
|
raise NotImplementedError(f"Unsupported OS: {OS}")
|
|
50
56
|
|
|
51
|
-
DATA_DIR = os.path.join(APP_DATA_DIR, "cs2tracker", "data")
|
|
52
|
-
os.makedirs(DATA_DIR, exist_ok=True)
|
|
53
|
-
|
|
54
57
|
CONFIG_FILE_SOURCE = os.path.join(MODULE_DIR, "data", "config.ini")
|
|
55
58
|
OUTPUT_FILE_SOURCE = os.path.join(MODULE_DIR, "data", "output.csv")
|
|
56
|
-
|
|
59
|
+
INVENTORY_CONVERT_SCRIPT_SOURCE = os.path.join(MODULE_DIR, "data", "convert_inventory.js")
|
|
57
60
|
INVENTORY_IMPORT_SCRIPT_SOURCE = os.path.join(MODULE_DIR, "data", "get_inventory.js")
|
|
61
|
+
NODE_MODULES_SOURCE = os.path.join(MODULE_DIR, "data", "node_modules")
|
|
58
62
|
|
|
63
|
+
DATA_DIR = os.path.join(APP_DATA_DIR, "cs2tracker", "data")
|
|
59
64
|
CONFIG_FILE = os.path.join(DATA_DIR, "config.ini")
|
|
60
65
|
CONFIG_FILE_BACKUP = os.path.join(DATA_DIR, "config.ini.bak")
|
|
61
66
|
OUTPUT_FILE = os.path.join(DATA_DIR, "output.csv")
|
|
62
|
-
|
|
67
|
+
INVENTORY_CONVERT_SCRIPT = os.path.join(DATA_DIR, "convert_inventory.js")
|
|
63
68
|
INVENTORY_IMPORT_SCRIPT = os.path.join(DATA_DIR, "get_inventory.js")
|
|
69
|
+
NODE_MODULES = os.path.join(DATA_DIR, "node_modules")
|
|
70
|
+
|
|
71
|
+
ICON_FILE = os.path.join(PROJECT_DIR, "assets", "icon.png")
|
|
72
|
+
BATCH_FILE = os.path.join(DATA_DIR, "cs2tracker_scraper.bat")
|
|
73
|
+
INVENTORY_IMPORT_FILE = os.path.join(DATA_DIR, "inventory.json")
|
|
74
|
+
INVENTORY_IMPORT_SCRIPT_DEPENDENCIES = [
|
|
75
|
+
"steam-user",
|
|
76
|
+
"globaloffensive",
|
|
77
|
+
"@node-steam/vdf",
|
|
78
|
+
"axios",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def show_temp_popup():
|
|
82
|
+
"""Show a temporary popup window while copying initial files."""
|
|
83
|
+
popup = tk.Tk()
|
|
84
|
+
popup.title("Please wait")
|
|
85
|
+
popup.geometry(centered(popup, "300x80"))
|
|
86
|
+
popup.resizable(False, False)
|
|
87
|
+
if OS == OSType.WINDOWS:
|
|
88
|
+
app_id = "cs2tracker.unique.id"
|
|
89
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
|
90
|
+
icon = tk.PhotoImage(file=ICON_FILE)
|
|
91
|
+
popup.wm_iconphoto(True, icon)
|
|
92
|
+
|
|
93
|
+
label = ttk.Label(popup, text="Setting up the application. Please wait...")
|
|
94
|
+
label.pack(pady=20)
|
|
95
|
+
|
|
96
|
+
sv_ttk.use_dark_theme()
|
|
97
|
+
|
|
98
|
+
popup.update()
|
|
99
|
+
return popup
|
|
100
|
+
|
|
101
|
+
def copy_initial_files_with_popup():
|
|
102
|
+
"""Copy initial files to the user data directory with a temporary popup."""
|
|
103
|
+
popup = show_temp_popup()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
if not os.path.exists(DATA_DIR):
|
|
107
|
+
os.makedirs(DATA_DIR)
|
|
108
|
+
if not os.path.exists(OUTPUT_FILE):
|
|
109
|
+
copy(OUTPUT_FILE_SOURCE, OUTPUT_FILE)
|
|
110
|
+
if not os.path.exists(CONFIG_FILE):
|
|
111
|
+
copy(CONFIG_FILE_SOURCE, CONFIG_FILE)
|
|
112
|
+
if not os.path.exists(INVENTORY_CONVERT_SCRIPT):
|
|
113
|
+
copy(INVENTORY_CONVERT_SCRIPT_SOURCE, INVENTORY_CONVERT_SCRIPT)
|
|
114
|
+
if not os.path.exists(INVENTORY_IMPORT_SCRIPT):
|
|
115
|
+
copy(INVENTORY_IMPORT_SCRIPT_SOURCE, INVENTORY_IMPORT_SCRIPT)
|
|
116
|
+
if not os.path.exists(NODE_MODULES):
|
|
117
|
+
copytree(NODE_MODULES_SOURCE, NODE_MODULES)
|
|
118
|
+
finally:
|
|
119
|
+
popup.destroy()
|
|
120
|
+
|
|
121
|
+
# pylint: disable=too-many-boolean-expressions
|
|
122
|
+
if (
|
|
123
|
+
not os.path.exists(DATA_DIR)
|
|
124
|
+
or not os.path.exists(OUTPUT_FILE)
|
|
125
|
+
or not os.path.exists(CONFIG_FILE)
|
|
126
|
+
or not os.path.exists(INVENTORY_CONVERT_SCRIPT)
|
|
127
|
+
or not os.path.exists(INVENTORY_IMPORT_SCRIPT)
|
|
128
|
+
or not os.path.exists(NODE_MODULES)
|
|
129
|
+
):
|
|
130
|
+
copy_initial_files_with_popup()
|
|
64
131
|
|
|
65
132
|
# Always copy the source config into the user data directory as a backup
|
|
66
133
|
# and overwrite the existing backup if it exists
|
|
67
134
|
# (This is to ensure that no outdated config backup remains in the user data directory)
|
|
68
135
|
copy(CONFIG_FILE_SOURCE, CONFIG_FILE_BACKUP)
|
|
69
136
|
|
|
70
|
-
if not os.path.exists(OUTPUT_FILE):
|
|
71
|
-
copy(OUTPUT_FILE_SOURCE, OUTPUT_FILE)
|
|
72
|
-
if not os.path.exists(CONFIG_FILE):
|
|
73
|
-
copy(CONFIG_FILE_SOURCE, CONFIG_FILE)
|
|
74
|
-
if not os.path.exists(IVENTORY_CONVERT_SCRIPT):
|
|
75
|
-
copy(IVENTORY_CONVERT_SCRIPT_SOURCE, IVENTORY_CONVERT_SCRIPT)
|
|
76
|
-
if not os.path.exists(INVENTORY_IMPORT_SCRIPT):
|
|
77
|
-
copy(INVENTORY_IMPORT_SCRIPT_SOURCE, INVENTORY_IMPORT_SCRIPT)
|
|
78
137
|
|
|
79
138
|
else:
|
|
80
139
|
MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -86,24 +145,25 @@ else:
|
|
|
86
145
|
OUTPUT_FILE = os.path.join(DATA_DIR, "output.csv")
|
|
87
146
|
INVENTORY_CONVERT_SCRIPT = os.path.join(DATA_DIR, "convert_inventory.js")
|
|
88
147
|
INVENTORY_IMPORT_SCRIPT = os.path.join(DATA_DIR, "get_inventory.js")
|
|
148
|
+
NODE_MODULES = os.path.join(DATA_DIR, "node_modules")
|
|
149
|
+
|
|
150
|
+
ICON_FILE = os.path.join(PROJECT_DIR, "assets", "icon.png")
|
|
151
|
+
BATCH_FILE = os.path.join(DATA_DIR, "cs2tracker_scraper.bat")
|
|
152
|
+
INVENTORY_IMPORT_FILE = os.path.join(DATA_DIR, "inventory.json")
|
|
153
|
+
INVENTORY_IMPORT_SCRIPT_DEPENDENCIES = [
|
|
154
|
+
"steam-user",
|
|
155
|
+
"globaloffensive",
|
|
156
|
+
"@node-steam/vdf",
|
|
157
|
+
"axios",
|
|
158
|
+
]
|
|
89
159
|
|
|
90
160
|
if not os.path.exists(CONFIG_FILE_BACKUP):
|
|
91
161
|
copy(CONFIG_FILE, CONFIG_FILE_BACKUP)
|
|
92
162
|
|
|
93
163
|
|
|
94
|
-
ICON_FILE = os.path.join(PROJECT_DIR, "assets", "icon.png")
|
|
95
|
-
BATCH_FILE = os.path.join(DATA_DIR, "cs2tracker_scraper.bat")
|
|
96
|
-
INVENTORY_IMPORT_FILE = os.path.join(DATA_DIR, "inventory.json")
|
|
97
|
-
INVENTORY_IMPORT_SCRIPT_DEPENDENCIES = [
|
|
98
|
-
"steam-user",
|
|
99
|
-
"globaloffensive",
|
|
100
|
-
"@node-steam/vdf",
|
|
101
|
-
"axios",
|
|
102
|
-
]
|
|
103
|
-
|
|
104
164
|
# Ensures that the necessary node modules are installed if a user wants
|
|
105
165
|
# to import their steam inventory via the cs2tracker/data/get_inventory.js Node.js script.
|
|
106
|
-
if not os.path.exists(
|
|
166
|
+
if not os.path.exists(NODE_MODULES):
|
|
107
167
|
npm.Popen(
|
|
108
168
|
["install", "-g", "--prefix", DATA_DIR] + INVENTORY_IMPORT_SCRIPT_DEPENDENCIES,
|
|
109
169
|
stdout=DEVNULL,
|
|
@@ -127,6 +187,7 @@ AUTHOR_STRING = (
|
|
|
127
187
|
f"Version: {VERSION} - {datetime.today().strftime('%Y/%m/%d')} - Jannik Novak @ashiven\n"
|
|
128
188
|
)
|
|
129
189
|
|
|
190
|
+
|
|
130
191
|
CAPSULE_PAGES = {
|
|
131
192
|
"Katowice 2014 Sticker Capsule": "https://steamcommunity.com/market/search?q=katowice+2014+legends+challengers",
|
|
132
193
|
"Cologne 2014 Sticker Capsule": "https://steamcommunity.com/market/search?q=cologne+2014+legends+challengers",
|
|
@@ -9,8 +9,22 @@ discord_webhook_url ~
|
|
|
9
9
|
|
|
10
10
|
[Skins]
|
|
11
11
|
|
|
12
|
+
[Special Items]
|
|
13
|
+
|
|
14
|
+
[Agents]
|
|
15
|
+
|
|
16
|
+
[Charms]
|
|
17
|
+
|
|
18
|
+
[Patches]
|
|
19
|
+
|
|
20
|
+
[Patch Packs]
|
|
21
|
+
|
|
12
22
|
[Stickers]
|
|
13
23
|
|
|
24
|
+
[Souvenirs]
|
|
25
|
+
|
|
26
|
+
[Others]
|
|
27
|
+
|
|
14
28
|
[Cases]
|
|
15
29
|
https://steamcommunity.com/market/listings/730/CS%3AGO%20Weapon%20Case ~ 0
|
|
16
30
|
https://steamcommunity.com/market/listings/730/CS%3AGO%20Weapon%20Case%202 ~ 0
|
|
@@ -11,6 +11,9 @@ const itemsGameLink =
|
|
|
11
11
|
const translationsCacheFile = path.join(__dirname, "translations.json");
|
|
12
12
|
const itemsCacheFile = path.join(__dirname, "items.json");
|
|
13
13
|
|
|
14
|
+
const translationRegex = /"(.+?)"\s+"(.+?)"/;
|
|
15
|
+
const souvenirRegex = /^csgo_crate_[a-z0-9]+_promo.*/;
|
|
16
|
+
|
|
14
17
|
class ItemNameConverter {
|
|
15
18
|
constructor() {
|
|
16
19
|
this.translations = {};
|
|
@@ -38,7 +41,7 @@ class ItemNameConverter {
|
|
|
38
41
|
const res = await axios.get(translationsLink);
|
|
39
42
|
const lines = res.data.split(/\n/);
|
|
40
43
|
for (const line of lines) {
|
|
41
|
-
const match = line.match(
|
|
44
|
+
const match = line.match(translationRegex);
|
|
42
45
|
if (match) {
|
|
43
46
|
this.translations[match[1].toLowerCase()] = match[2];
|
|
44
47
|
}
|
|
@@ -174,7 +177,7 @@ class ItemNameConverter {
|
|
|
174
177
|
|
|
175
178
|
getItemType(item) {
|
|
176
179
|
const def = this.items[item.def_index];
|
|
177
|
-
if (def === undefined) return "
|
|
180
|
+
if (def === undefined) return "Unknown";
|
|
178
181
|
|
|
179
182
|
if (def.item_name !== undefined) {
|
|
180
183
|
let translatedName =
|
|
@@ -183,17 +186,48 @@ class ItemNameConverter {
|
|
|
183
186
|
translatedName.startsWith("csgo_crate_sticker_pack") ||
|
|
184
187
|
translatedName.startsWith("csgo_crate_signature_pack")
|
|
185
188
|
) {
|
|
186
|
-
return "
|
|
187
|
-
} else if (translatedName.startsWith("
|
|
188
|
-
return "
|
|
189
|
+
return "Sticker Capsules";
|
|
190
|
+
} else if (translatedName.startsWith("csgo_crate_patch_pack")) {
|
|
191
|
+
return "Patch Packs";
|
|
192
|
+
} else if (translatedName.match(souvenirRegex)) {
|
|
193
|
+
return "Souvenirs";
|
|
194
|
+
} else if (
|
|
195
|
+
translatedName.startsWith("csgo_crate_community") ||
|
|
196
|
+
translatedName.startsWith("csgo_crate_gamma") ||
|
|
197
|
+
translatedName.startsWith("csgo_crate_valve") ||
|
|
198
|
+
translatedName.startsWith("csgo_crate_esports") ||
|
|
199
|
+
translatedName.startsWith("csgo_crate_operation")
|
|
200
|
+
) {
|
|
201
|
+
return "Cases";
|
|
189
202
|
} else if (translatedName.startsWith("csgo_tool_spray")) {
|
|
190
|
-
return "
|
|
203
|
+
return "Graffitis";
|
|
191
204
|
} else if (translatedName.startsWith("csgo_tool_sticker")) {
|
|
192
|
-
return "
|
|
205
|
+
return "Stickers";
|
|
206
|
+
} else if (translatedName.startsWith("csgo_tool_patch")) {
|
|
207
|
+
return "Patches";
|
|
208
|
+
} else if (translatedName.startsWith("csgo_tool_keychain")) {
|
|
209
|
+
return "Charms";
|
|
210
|
+
} else if (translatedName.startsWith("csgo_customplayer")) {
|
|
211
|
+
return "Agents";
|
|
193
212
|
}
|
|
194
213
|
}
|
|
195
214
|
|
|
196
|
-
|
|
215
|
+
if (item.quality === 3) {
|
|
216
|
+
return "Special Items";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (def.prefab !== undefined) {
|
|
220
|
+
let prefab = this.prefabs[def.prefab];
|
|
221
|
+
if (
|
|
222
|
+
prefab !== undefined &&
|
|
223
|
+
prefab.image_inventory !== undefined &&
|
|
224
|
+
prefab.image_inventory.startsWith("econ/weapons/base_weapons")
|
|
225
|
+
) {
|
|
226
|
+
return "Skins";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return "Others";
|
|
197
231
|
}
|
|
198
232
|
|
|
199
233
|
getItemTradable(item) {
|
|
@@ -204,11 +238,13 @@ class ItemNameConverter {
|
|
|
204
238
|
let translatedName =
|
|
205
239
|
def.item_name.replace("#", "").toLowerCase() || def.item_name;
|
|
206
240
|
if (
|
|
207
|
-
translatedName.startsWith("csgo_collectible")
|
|
241
|
+
(translatedName.startsWith("csgo_collectible") &&
|
|
242
|
+
!translatedName.startsWith("csgo_collectible_pin")) ||
|
|
208
243
|
translatedName.startsWith("csgo_tournamentpass") ||
|
|
209
244
|
translatedName.startsWith("csgo_tournamentjournal") ||
|
|
210
245
|
translatedName.startsWith("csgo_ticket") ||
|
|
211
|
-
translatedName.startsWith("csgo_tool_casket_tag")
|
|
246
|
+
translatedName.startsWith("csgo_tool_casket_tag") ||
|
|
247
|
+
translatedName.startsWith("sfui_wpnhud_c4")
|
|
212
248
|
) {
|
|
213
249
|
return false;
|
|
214
250
|
}
|
|
@@ -221,14 +257,6 @@ class ItemNameConverter {
|
|
|
221
257
|
return false;
|
|
222
258
|
}
|
|
223
259
|
|
|
224
|
-
// Base weapons with stickers/name tags
|
|
225
|
-
if (
|
|
226
|
-
def.image_inventory !== undefined &&
|
|
227
|
-
def.image_inventory.startsWith("econ/weapons/base_weapons")
|
|
228
|
-
) {
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
260
|
// Base weapons with stickers/name tags
|
|
233
261
|
if (
|
|
234
262
|
item.paint_index === undefined &&
|
|
@@ -239,7 +267,10 @@ class ItemNameConverter {
|
|
|
239
267
|
if (
|
|
240
268
|
prefab !== undefined &&
|
|
241
269
|
prefab.image_inventory !== undefined &&
|
|
242
|
-
prefab.image_inventory.startsWith("econ/weapons/base_weapons")
|
|
270
|
+
prefab.image_inventory.startsWith("econ/weapons/base_weapons") &&
|
|
271
|
+
!prefab.image_inventory.startsWith(
|
|
272
|
+
"econ/weapons/base_weapons/weapon_knife",
|
|
273
|
+
)
|
|
243
274
|
) {
|
|
244
275
|
return false;
|
|
245
276
|
}
|
|
@@ -91,16 +91,12 @@ console.error = (...args) => {
|
|
|
91
91
|
|
|
92
92
|
if (importInventory) {
|
|
93
93
|
const inventoryItemCounts = await processInventory();
|
|
94
|
-
|
|
95
|
-
finalItemCounts[itemName] = (finalItemCounts[itemName] || 0) + count;
|
|
96
|
-
}
|
|
94
|
+
mergeItemCounts(finalItemCounts, inventoryItemCounts);
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
if (importStorageUnits) {
|
|
100
98
|
const storageUnitItemCounts = await processStorageUnits();
|
|
101
|
-
|
|
102
|
-
finalItemCounts[itemName] = (finalItemCounts[itemName] || 0) + count;
|
|
103
|
-
}
|
|
99
|
+
mergeItemCounts(finalItemCounts, storageUnitItemCounts);
|
|
104
100
|
}
|
|
105
101
|
|
|
106
102
|
paddedLog("Saving config...");
|
|
@@ -127,7 +123,7 @@ console.error = (...args) => {
|
|
|
127
123
|
const convertedItems =
|
|
128
124
|
nameConverter.convertInventory(prefilteredInventory);
|
|
129
125
|
const filteredItems = filterItems(convertedItems);
|
|
130
|
-
const itemCounts =
|
|
126
|
+
const itemCounts = groupAndCountItems(filteredItems);
|
|
131
127
|
paddedLog(`${filteredItems.length} items found in inventory`);
|
|
132
128
|
console.log(itemCounts);
|
|
133
129
|
return itemCounts;
|
|
@@ -145,10 +141,8 @@ console.error = (...args) => {
|
|
|
145
141
|
const items = await getCasketContentsAsync(cs2, unitId);
|
|
146
142
|
const convertedItems = nameConverter.convertInventory(items);
|
|
147
143
|
const filteredItems = filterItems(convertedItems);
|
|
148
|
-
const itemCounts =
|
|
149
|
-
|
|
150
|
-
finalItemCounts[itemName] = (finalItemCounts[itemName] || 0) + count;
|
|
151
|
-
}
|
|
144
|
+
const itemCounts = groupAndCountItems(filteredItems);
|
|
145
|
+
mergeItemCounts(finalItemCounts, itemCounts);
|
|
152
146
|
paddedLog(
|
|
153
147
|
`${filteredItems.length} items found in storage unit: ${unitIndex + 1}/${storageUnitIds.length}`,
|
|
154
148
|
);
|
|
@@ -181,16 +175,27 @@ console.error = (...args) => {
|
|
|
181
175
|
}
|
|
182
176
|
|
|
183
177
|
function filterItems(items) {
|
|
178
|
+
const otherItemTypes = [
|
|
179
|
+
"Skins",
|
|
180
|
+
"Special Items",
|
|
181
|
+
"Agents",
|
|
182
|
+
"Charms",
|
|
183
|
+
"Patches",
|
|
184
|
+
"Patch Packs",
|
|
185
|
+
"Souvenirs",
|
|
186
|
+
"Others",
|
|
187
|
+
];
|
|
184
188
|
let filteredItems = [];
|
|
189
|
+
|
|
185
190
|
items.forEach((item) => {
|
|
186
191
|
if (!item.item_tradable) {
|
|
187
192
|
return;
|
|
188
193
|
}
|
|
189
194
|
if (
|
|
190
|
-
(item.item_type === "
|
|
191
|
-
(item.item_type === "
|
|
192
|
-
(item.item_type === "
|
|
193
|
-
(item.item_type
|
|
195
|
+
(item.item_type === "Cases" && importCases) ||
|
|
196
|
+
(item.item_type === "Sticker Capsules" && importStickerCapsules) ||
|
|
197
|
+
(item.item_type === "Stickers" && importStickers) ||
|
|
198
|
+
(otherItemTypes.includes(item.item_type) && importOthers)
|
|
194
199
|
) {
|
|
195
200
|
filteredItems.push(item);
|
|
196
201
|
}
|
|
@@ -198,15 +203,39 @@ console.error = (...args) => {
|
|
|
198
203
|
return filteredItems;
|
|
199
204
|
}
|
|
200
205
|
|
|
201
|
-
function
|
|
202
|
-
let
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
itemCounts[item.item_name] = 1;
|
|
206
|
+
function groupAndCountItems(items) {
|
|
207
|
+
let groupedItems = items.reduce((acc, item) => {
|
|
208
|
+
const { item_name, item_type } = item;
|
|
209
|
+
|
|
210
|
+
if (!acc[item_type]) {
|
|
211
|
+
acc[item_type] = {};
|
|
208
212
|
}
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
|
|
214
|
+
if (!acc[item_type][item_name]) {
|
|
215
|
+
acc[item_type][item_name] = 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
acc[item_type][item_name]++;
|
|
219
|
+
return acc;
|
|
220
|
+
}, {});
|
|
221
|
+
|
|
222
|
+
return groupedItems;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function mergeItemCounts(finalItemCounts, currentItemCounts) {
|
|
226
|
+
for (const item_type in currentItemCounts) {
|
|
227
|
+
if (!finalItemCounts[item_type]) {
|
|
228
|
+
finalItemCounts[item_type] = {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const item_name in currentItemCounts[item_type]) {
|
|
232
|
+
if (!finalItemCounts[item_type][item_name]) {
|
|
233
|
+
finalItemCounts[item_type][item_name] = 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
finalItemCounts[item_type][item_name] +=
|
|
237
|
+
currentItemCounts[item_type][item_name];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
211
240
|
}
|
|
212
241
|
})();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cs2tracker
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.18
|
|
4
4
|
Summary: Tracking the steam market prices of CS2 items
|
|
5
5
|
Home-page: https://github.com/ashiven/cs2tracker
|
|
6
6
|
Author: Jannik Novak
|
|
@@ -54,6 +54,7 @@ Dynamic: license-file
|
|
|
54
54
|
- [Usage](#usage)
|
|
55
55
|
- [Configuration](#configuration)
|
|
56
56
|
- [Advanced Features](#advanced-features)
|
|
57
|
+
- [FAQ](#faq)
|
|
57
58
|
- [Contributing](#contributing)
|
|
58
59
|
- [License](#license)
|
|
59
60
|
|
|
@@ -74,8 +75,9 @@ Dynamic: license-file
|
|
|
74
75
|
#### Method 1: Executable
|
|
75
76
|
|
|
76
77
|
Simply download the program and run it:
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
- [Windows](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-windows.zip)
|
|
80
|
+
- [Linux](https://github.com/ashiven/cs2tracker/releases/latest/download/cs2tracker-linux.zip)
|
|
79
81
|
|
|
80
82
|
#### Method 2: Install via Pip
|
|
81
83
|
|
|
@@ -90,6 +92,7 @@ Simply download the program and run it:
|
|
|
90
92
|
```bash
|
|
91
93
|
cs2tracker
|
|
92
94
|
```
|
|
95
|
+
|
|
93
96
|
### Additional Setup
|
|
94
97
|
|
|
95
98
|
- Register for the [Crawlbase Smart Proxy API](https://crawlbase.com/) and retrieve your API key. (Optional)
|
|
@@ -117,18 +120,36 @@ This will open the config editor where you can change any setting by double clic
|
|
|
117
120
|
|
|
118
121
|
- Enable **Daily Background Calculations** to automatically run a daily calculation of your investment in the background.
|
|
119
122
|
- Use **Receive Discord Notifications** to receive a notification on your Discord server whenever the program has finished calculating your investment.
|
|
120
|
-
- You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook
|
|
123
|
+
- You need to set up a [webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) in your Discord server and enter the webhook URL into the `discord_webhook_url` field in the config `User Settings`.
|
|
121
124
|
- Enable **Proxy Requests** to prevent your requests from being rate limited by the steamcommunity server.
|
|
122
125
|
- You need to register for a free API key on [Crawlbase](crawlbase.com) and enter it into the `proxy_api_key` field in the config `User Settings`.
|
|
123
126
|
|
|
127
|
+
## FAQ
|
|
128
|
+
|
|
129
|
+
- **Q: Is it safe to login with my Steam account?**
|
|
130
|
+
- **A:** Yes, the program uses the [SteamUser](https://github.com/DoctorMcKay/node-steam-user?tab=readme-ov-file#methods-) and [Globaloffensive](https://github.com/DoctorMcKay/node-globaloffensive) libraries to sign in and import your Storage Units (the same method is used by [casemove](https://github.com/nombersDev/casemove)) and all of the login-related code is transparently available in [this file](cs2tracker/data/get_inventory.js).
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
- **Q: Do I have to login with my Steam account?**
|
|
134
|
+
- **A:** No, you can also manually specify the number of items you own in the config editor.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
- **Q: Can I get VAC-banned for using this program?**
|
|
138
|
+
- **A:** No, this program does not interact with the game in any way and only reads your Storage Units.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
- **Q: Why does Windows Defender flag this program as potentially harmful?**
|
|
142
|
+
- **A:** This is because the program is not signed with a [Code Signing Certificate](https://www.globalsign.com/en/code-signing-certificate/what-is-code-signing-certificate), which Windows uses to verify the identity of publishers. These certificates are very expensive and not something I am willing to invest in for a free and open source project like this.
|
|
143
|
+
|
|
124
144
|
## Contributing
|
|
125
145
|
|
|
126
|
-
Please feel free to submit a pull request
|
|
146
|
+
Please feel free to submit a [pull request](https://github.com/ashiven/cs2tracker/pulls) or open an [issue](https://github.com/ashiven/cs2tracker/issues).
|
|
127
147
|
|
|
128
148
|
1. Fork the repository
|
|
129
|
-
2. Create a new branch
|
|
149
|
+
2. Create a new branch: `git checkout -b feature-name`.
|
|
130
150
|
3. Make your changes
|
|
131
|
-
4.
|
|
151
|
+
4. Push your branch: `git push origin feature-name`.
|
|
152
|
+
5. Submit a PR
|
|
132
153
|
|
|
133
154
|
## License
|
|
134
155
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|