drunken-chunk 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/bin/index.js +49 -0
- package/drunken_chunk/__init__.py +2 -0
- package/drunken_chunk/analytics.py +172 -0
- package/drunken_chunk/cli.py +834 -0
- package/drunken_chunk/db.py +245 -0
- package/drunken_chunk/profile.py +232 -0
- package/drunken_chunk/reminder.py +386 -0
- package/drunken_chunk/shortcut.py +43 -0
- package/drunken_chunk/utils.py +122 -0
- package/package.json +33 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import subprocess
|
|
5
|
+
import random
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from .db import (
|
|
9
|
+
get_profile, get_today_logs, get_daemon_state, set_daemon_state, init_db
|
|
10
|
+
)
|
|
11
|
+
from .profile import calculate_recommended_intake
|
|
12
|
+
|
|
13
|
+
PACKAGE_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
|
|
15
|
+
# List of rich, motivational, and sometimes humorous notifications
|
|
16
|
+
REMINDER_MESSAGES = [
|
|
17
|
+
"Hydrate or diedrate! Drink up!",
|
|
18
|
+
"A hydrated brain writes fewer bugs. Go grab a cup!",
|
|
19
|
+
"Drunken Chunk alert: Time to wash down those keystrokes!",
|
|
20
|
+
"Your cells are crying out for water! Give them a gulp!",
|
|
21
|
+
"Hydration check! Put down your mouse and pick up your flask.",
|
|
22
|
+
"Drink water now. Future hydrated you will thank you!",
|
|
23
|
+
"Water: 0 calories, 100% vital. Pour yourself a fresh glass!",
|
|
24
|
+
"Time to level up your hydration status. Sip sip hooray!"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def send_windows_toast(title, message):
|
|
28
|
+
"""Sends a Windows native toast notification via a headless PowerShell script."""
|
|
29
|
+
escaped_title = title.replace('"', '`"').replace("'", "`'")
|
|
30
|
+
escaped_message = message.replace('"', '`"').replace("'", "`'")
|
|
31
|
+
|
|
32
|
+
ps_code = f'''
|
|
33
|
+
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
34
|
+
$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
35
|
+
$Xml = [xml]$Template.GetXml()
|
|
36
|
+
$Xml.toast.visual.binding.text[0].AppendChild($Xml.CreateTextNode("{escaped_title}")) | Out-Null
|
|
37
|
+
$Xml.toast.visual.binding.text[1].AppendChild($Xml.CreateTextNode("{escaped_message}")) | Out-Null
|
|
38
|
+
$XmlDocument = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
39
|
+
$XmlDocument.LoadXml($Xml.OuterXml)
|
|
40
|
+
$Toast = [Windows.UI.Notifications.ToastNotification]::new($XmlDocument)
|
|
41
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("DrunkenChunk.Reminder").Show($Toast)
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
# Hide window completely using creationflags=0x08000000 on Windows
|
|
45
|
+
creation_flags = 0x08000000 if os.name == 'nt' else 0
|
|
46
|
+
try:
|
|
47
|
+
subprocess.run(
|
|
48
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_code],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
creationflags=creation_flags
|
|
52
|
+
)
|
|
53
|
+
except Exception:
|
|
54
|
+
# Fallback if Powershell fails
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def send_macos_notification(title, message):
|
|
58
|
+
"""Sends a macOS native desktop notification via AppleScript."""
|
|
59
|
+
escaped_title = title.replace('\\', '\\\\').replace('"', '\\"')
|
|
60
|
+
escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
|
|
61
|
+
cmd = ["osascript", "-e", f'display notification "{escaped_message}" with title "{escaped_title}"']
|
|
62
|
+
try:
|
|
63
|
+
subprocess.run(cmd, capture_output=True, check=False)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def send_linux_notification(title, message):
|
|
68
|
+
"""Sends a Linux desktop notification via notify-send."""
|
|
69
|
+
try:
|
|
70
|
+
subprocess.run(["notify-send", title, message], capture_output=True, check=False)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def send_notification(title, message):
|
|
75
|
+
"""Dispatches native desktop notifications based on the current platform."""
|
|
76
|
+
if sys.platform == 'win32':
|
|
77
|
+
send_windows_toast(title, message)
|
|
78
|
+
elif sys.platform == 'darwin':
|
|
79
|
+
send_macos_notification(title, message)
|
|
80
|
+
else:
|
|
81
|
+
send_linux_notification(title, message)
|
|
82
|
+
|
|
83
|
+
def is_waking_hours(current_time_str, wake_time_str, sleep_time_str):
|
|
84
|
+
"""
|
|
85
|
+
Checks if current time (HH:MM) is within the waking hours range.
|
|
86
|
+
Supports shift workers (e.g., wake at 22:00, sleep at 06:00).
|
|
87
|
+
"""
|
|
88
|
+
curr_h, curr_m = map(int, current_time_str.split(':'))
|
|
89
|
+
wake_h, wake_m = map(int, wake_time_str.split(':'))
|
|
90
|
+
sleep_h, sleep_m = map(int, sleep_time_str.split(':'))
|
|
91
|
+
|
|
92
|
+
curr = curr_h * 60 + curr_m
|
|
93
|
+
wake = wake_h * 60 + wake_m
|
|
94
|
+
sleep = sleep_h * 60 + sleep_m
|
|
95
|
+
|
|
96
|
+
if wake <= sleep:
|
|
97
|
+
# Day shift (e.g. 07:00 to 22:00)
|
|
98
|
+
return wake <= curr <= sleep
|
|
99
|
+
else:
|
|
100
|
+
# Night shift (e.g. 22:00 to 06:00 next day)
|
|
101
|
+
return curr >= wake or curr <= sleep
|
|
102
|
+
|
|
103
|
+
def check_and_notify():
|
|
104
|
+
"""Runs the core reminder algorithm logic."""
|
|
105
|
+
# Check if currently snoozed
|
|
106
|
+
snooze_until_str = get_daemon_state("snooze_until", "")
|
|
107
|
+
if snooze_until_str:
|
|
108
|
+
try:
|
|
109
|
+
snooze_until = datetime.strptime(snooze_until_str, "%Y-%m-%d %H:%M:%S")
|
|
110
|
+
if datetime.now() < snooze_until:
|
|
111
|
+
return # In snooze mode, skip alert
|
|
112
|
+
except ValueError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
profile = get_profile()
|
|
116
|
+
if not profile:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
now = datetime.now()
|
|
120
|
+
current_time_str = now.strftime("%H:%M")
|
|
121
|
+
|
|
122
|
+
# Check if we are inside waking hours
|
|
123
|
+
if not is_waking_hours(current_time_str, profile['wake_time'], profile['sleep_time']):
|
|
124
|
+
return # Quiet hours, do not disturb
|
|
125
|
+
|
|
126
|
+
today_logs = get_today_logs()
|
|
127
|
+
|
|
128
|
+
# Calculate target and check if completed
|
|
129
|
+
target = profile['custom_target'] if profile['custom_target'] else calculate_recommended_intake(profile['weight'], profile['height'])
|
|
130
|
+
total_today = sum(log['amount'] for log in today_logs)
|
|
131
|
+
if total_today >= target:
|
|
132
|
+
return # Target already hit, don't nag the user
|
|
133
|
+
|
|
134
|
+
# Get last drink log timestamp
|
|
135
|
+
if today_logs:
|
|
136
|
+
last_drink_time = datetime.strptime(today_logs[-1]['timestamp'], "%Y-%m-%d %H:%M:%S")
|
|
137
|
+
else:
|
|
138
|
+
# If no drinks today, baseline is today at wake_time
|
|
139
|
+
wake_h, wake_m = map(int, profile['wake_time'].split(':'))
|
|
140
|
+
last_drink_time = now.replace(hour=wake_h, minute=wake_m, second=0, microsecond=0)
|
|
141
|
+
# If wake_time was technically in the future for a night shift, adjust back 1 day
|
|
142
|
+
if last_drink_time > now:
|
|
143
|
+
last_drink_time -= timedelta(days=1)
|
|
144
|
+
|
|
145
|
+
# Get last notified time
|
|
146
|
+
last_notified_str = get_daemon_state("last_notified", "")
|
|
147
|
+
if last_notified_str:
|
|
148
|
+
try:
|
|
149
|
+
last_notified_time = datetime.strptime(last_notified_str, "%Y-%m-%d %H:%M:%S")
|
|
150
|
+
except ValueError:
|
|
151
|
+
last_notified_time = datetime.min
|
|
152
|
+
else:
|
|
153
|
+
last_notified_time = datetime.min
|
|
154
|
+
|
|
155
|
+
# Determine the timing constraint: must be > interval since last drink AND last notification
|
|
156
|
+
interval_mins = profile['reminder_interval']
|
|
157
|
+
next_due_after_drink = last_drink_time + timedelta(minutes=interval_mins)
|
|
158
|
+
next_due_after_notif = last_notified_time + timedelta(minutes=interval_mins)
|
|
159
|
+
|
|
160
|
+
due_time = max(next_due_after_drink, next_due_after_notif)
|
|
161
|
+
|
|
162
|
+
if now >= due_time:
|
|
163
|
+
# Trigger notification!
|
|
164
|
+
title = "Drunken Chunk [~]"
|
|
165
|
+
message = random.choice(REMINDER_MESSAGES)
|
|
166
|
+
send_notification(title, message)
|
|
167
|
+
|
|
168
|
+
# Save state
|
|
169
|
+
set_daemon_state("last_notified", now.strftime("%Y-%m-%d %H:%M:%S"))
|
|
170
|
+
|
|
171
|
+
def is_pid_running(pid):
|
|
172
|
+
"""Check to see if a process ID is still running (Windows & Unix native)."""
|
|
173
|
+
if not pid:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
pid_val = int(pid)
|
|
177
|
+
if os.name != 'nt':
|
|
178
|
+
try:
|
|
179
|
+
os.kill(pid_val, 0)
|
|
180
|
+
return True
|
|
181
|
+
except ProcessLookupError:
|
|
182
|
+
return False
|
|
183
|
+
except PermissionError:
|
|
184
|
+
return True
|
|
185
|
+
except OSError as e:
|
|
186
|
+
import errno
|
|
187
|
+
return e.errno == errno.EPERM
|
|
188
|
+
|
|
189
|
+
import ctypes
|
|
190
|
+
PROCESS_QUERY_INFORMATION = 0x0400
|
|
191
|
+
PROCESS_SYNCHRONIZE = 0x00100000
|
|
192
|
+
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SYNCHRONIZE, False, pid_val)
|
|
193
|
+
if handle:
|
|
194
|
+
exit_code = ctypes.c_ulong()
|
|
195
|
+
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
|
|
196
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
197
|
+
return exit_code.value == 259 # STILL_ACTIVE
|
|
198
|
+
return False
|
|
199
|
+
except Exception:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def run_daemon_loop():
|
|
203
|
+
"""Runs the main daemon polling loop."""
|
|
204
|
+
init_db()
|
|
205
|
+
set_daemon_state("is_active", "1")
|
|
206
|
+
set_daemon_state("daemon_pid", str(os.getpid()))
|
|
207
|
+
|
|
208
|
+
# Send a launch notification
|
|
209
|
+
send_notification("Drunken Chunk [~]", "Water reminder service started. Happy hydrating!")
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
while True:
|
|
213
|
+
# Check if we were told to stop
|
|
214
|
+
is_active = get_daemon_state("is_active", "0")
|
|
215
|
+
if is_active != "1":
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
check_and_notify()
|
|
219
|
+
time.sleep(20) # Poll every 20 seconds
|
|
220
|
+
finally:
|
|
221
|
+
set_daemon_state("is_active", "0")
|
|
222
|
+
set_daemon_state("daemon_pid", "")
|
|
223
|
+
|
|
224
|
+
def start_background_daemon():
|
|
225
|
+
"""Spawns the daemon process detached from the current console."""
|
|
226
|
+
pid = get_daemon_state("daemon_pid", "")
|
|
227
|
+
if pid and is_pid_running(pid):
|
|
228
|
+
return False, f"Daemon is already running with PID {pid}."
|
|
229
|
+
|
|
230
|
+
set_daemon_state("is_active", "1")
|
|
231
|
+
|
|
232
|
+
# Spawn background daemon
|
|
233
|
+
cmd = [sys.executable, "-c", f"import sys; sys.path.insert(0, r'{PACKAGE_PARENT}'); import drunken_chunk.reminder as r; r.run_daemon_loop()"]
|
|
234
|
+
|
|
235
|
+
popen_kwargs = {
|
|
236
|
+
"close_fds": True,
|
|
237
|
+
"stdout": subprocess.DEVNULL,
|
|
238
|
+
"stderr": subprocess.DEVNULL,
|
|
239
|
+
"stdin": subprocess.DEVNULL,
|
|
240
|
+
}
|
|
241
|
+
if os.name == 'nt':
|
|
242
|
+
# DETACHED_PROCESS | CREATE_NO_WINDOW
|
|
243
|
+
popen_kwargs["creationflags"] = 0x00000008 | 0x08000000
|
|
244
|
+
else:
|
|
245
|
+
popen_kwargs["start_new_session"] = True
|
|
246
|
+
|
|
247
|
+
subprocess.Popen(cmd, **popen_kwargs)
|
|
248
|
+
|
|
249
|
+
# Give it a second to boot up and write PID
|
|
250
|
+
time.sleep(1.5)
|
|
251
|
+
new_pid = get_daemon_state("daemon_pid", "")
|
|
252
|
+
|
|
253
|
+
return True, f"Daemon successfully started in background (PID: {new_pid})."
|
|
254
|
+
|
|
255
|
+
def stop_background_daemon():
|
|
256
|
+
"""Stops the daemon gracefully by modifying database state."""
|
|
257
|
+
pid = get_daemon_state("daemon_pid", "")
|
|
258
|
+
set_daemon_state("is_active", "0")
|
|
259
|
+
|
|
260
|
+
if not pid:
|
|
261
|
+
return False, "Daemon is not running."
|
|
262
|
+
|
|
263
|
+
# Wait for graceful exit
|
|
264
|
+
for _ in range(5):
|
|
265
|
+
if not is_pid_running(pid):
|
|
266
|
+
set_daemon_state("daemon_pid", "")
|
|
267
|
+
return True, "Daemon stopped gracefully."
|
|
268
|
+
|
|
269
|
+
time.sleep(0.5)
|
|
270
|
+
|
|
271
|
+
# Force kill if it didn't exit
|
|
272
|
+
try:
|
|
273
|
+
if os.name == 'nt':
|
|
274
|
+
subprocess.run(["taskkill", "/F", "/PID", pid], capture_output=True, creationflags=0x08000000)
|
|
275
|
+
else:
|
|
276
|
+
os.kill(int(pid), 9)
|
|
277
|
+
set_daemon_state("daemon_pid", "")
|
|
278
|
+
return True, f"Daemon process {pid} was forced to stop."
|
|
279
|
+
except Exception as e:
|
|
280
|
+
return False, f"Failed to stop daemon {pid}: {str(e)}"
|
|
281
|
+
|
|
282
|
+
def manage_autostart(action):
|
|
283
|
+
"""
|
|
284
|
+
Manages startup autostart toggle/enable/disable/status for Windows, macOS, and Linux.
|
|
285
|
+
action: 'status', 'enable', 'disable', or 'toggle'
|
|
286
|
+
Returns: (success_bool, message_str)
|
|
287
|
+
"""
|
|
288
|
+
platform = sys.platform
|
|
289
|
+
|
|
290
|
+
# Identify target file and startup path
|
|
291
|
+
if platform == 'win32':
|
|
292
|
+
appdata = os.environ.get("APPDATA")
|
|
293
|
+
if not appdata:
|
|
294
|
+
return False, "Could not locate APPDATA directory."
|
|
295
|
+
startup_dir = os.path.join(appdata, r"Microsoft\Windows\Start Menu\Programs\Startup")
|
|
296
|
+
startup_file = os.path.join(startup_dir, "drunken-chunk-autostart.bat")
|
|
297
|
+
elif platform == 'darwin':
|
|
298
|
+
startup_dir = os.path.expanduser("~/Library/LaunchAgents")
|
|
299
|
+
startup_file = os.path.join(startup_dir, "com.drunkenchunk.reminder.plist")
|
|
300
|
+
else: # Assume Linux / generic Unix
|
|
301
|
+
startup_dir = os.path.expanduser("~/.config/autostart")
|
|
302
|
+
startup_file = os.path.join(startup_dir, "drunken-chunk.desktop")
|
|
303
|
+
|
|
304
|
+
exists = os.path.exists(startup_file)
|
|
305
|
+
|
|
306
|
+
if action == 'status':
|
|
307
|
+
status_str = "ENABLED" if exists else "DISABLED"
|
|
308
|
+
return True, f"Startup Autostart is currently: {status_str}"
|
|
309
|
+
|
|
310
|
+
if action == 'toggle':
|
|
311
|
+
action = 'disable' if exists else 'enable'
|
|
312
|
+
|
|
313
|
+
if action == 'enable':
|
|
314
|
+
try:
|
|
315
|
+
os.makedirs(startup_dir, exist_ok=True)
|
|
316
|
+
|
|
317
|
+
if platform == 'win32':
|
|
318
|
+
python_exe = sys.executable
|
|
319
|
+
cmd_str = f'@"{python_exe}" -c "import sys; sys.path.insert(0, r\'{PACKAGE_PARENT}\'); import drunken_chunk.reminder as r; r.start_background_daemon()"\n'
|
|
320
|
+
with open(startup_file, "w", encoding="utf-8") as f:
|
|
321
|
+
f.write(cmd_str)
|
|
322
|
+
elif platform == 'darwin':
|
|
323
|
+
plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
324
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
325
|
+
<plist version="1.0">
|
|
326
|
+
<dict>
|
|
327
|
+
<key>Label</key>
|
|
328
|
+
<string>com.drunkenchunk.reminder</string>
|
|
329
|
+
<key>ProgramArguments</key>
|
|
330
|
+
<array>
|
|
331
|
+
<string>{sys.executable}</string>
|
|
332
|
+
<string>-c</string>
|
|
333
|
+
<string>import sys; sys.path.insert(0, '{PACKAGE_PARENT}'); import drunken_chunk.reminder as r; r.start_background_daemon()</string>
|
|
334
|
+
</array>
|
|
335
|
+
<key>RunAtLoad</key>
|
|
336
|
+
<true/>
|
|
337
|
+
<key>KeepAlive</key>
|
|
338
|
+
<false/>
|
|
339
|
+
</dict>
|
|
340
|
+
</plist>
|
|
341
|
+
"""
|
|
342
|
+
with open(startup_file, "w", encoding="utf-8") as f:
|
|
343
|
+
f.write(plist_content)
|
|
344
|
+
# Load the launch agent immediately
|
|
345
|
+
subprocess.run(["launchctl", "load", startup_file], capture_output=True)
|
|
346
|
+
else: # Linux
|
|
347
|
+
desktop_content = f"""[Desktop Entry]
|
|
348
|
+
Type=Application
|
|
349
|
+
Name=Drunken Chunk Reminder
|
|
350
|
+
Comment=Starts the Drunken Chunk background reminder daemon
|
|
351
|
+
Exec={sys.executable} -c "import sys; sys.path.insert(0, '{PACKAGE_PARENT}'); import drunken_chunk.reminder as r; r.start_background_daemon()"
|
|
352
|
+
Terminal=false
|
|
353
|
+
X-GNOME-Autostart-enabled=true
|
|
354
|
+
"""
|
|
355
|
+
with open(startup_file, "w", encoding="utf-8") as f:
|
|
356
|
+
f.write(desktop_content)
|
|
357
|
+
|
|
358
|
+
# Automatically start the background reminder service immediately
|
|
359
|
+
daemon_started, daemon_msg = start_background_daemon()
|
|
360
|
+
msg = "Autostart ENABLED. Drunken Chunk will now launch on system startup."
|
|
361
|
+
if daemon_started:
|
|
362
|
+
msg += f"\nReminder service started automatically in background (PID: {get_daemon_state('daemon_pid', '')})."
|
|
363
|
+
else:
|
|
364
|
+
msg += "\nReminder service is already active."
|
|
365
|
+
return True, msg
|
|
366
|
+
except Exception as e:
|
|
367
|
+
return False, f"Failed to enable autostart: {str(e)}"
|
|
368
|
+
|
|
369
|
+
elif action == 'disable':
|
|
370
|
+
if exists:
|
|
371
|
+
try:
|
|
372
|
+
if platform == 'darwin':
|
|
373
|
+
# Unload the launch agent
|
|
374
|
+
subprocess.run(["launchctl", "unload", startup_file], capture_output=True)
|
|
375
|
+
os.remove(startup_file)
|
|
376
|
+
return True, "Autostart DISABLED. Drunken Chunk will no longer launch on system startup."
|
|
377
|
+
except Exception as e:
|
|
378
|
+
return False, f"Failed to disable autostart: {str(e)}"
|
|
379
|
+
else:
|
|
380
|
+
return True, "Autostart was already disabled."
|
|
381
|
+
|
|
382
|
+
return False, f"Unknown action: {action}"
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__":
|
|
385
|
+
# If run directly as a script
|
|
386
|
+
run_daemon_loop()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from .db import save_shortcut, delete_shortcut, get_shortcuts
|
|
3
|
+
|
|
4
|
+
def validate_shortcut_name(name):
|
|
5
|
+
"""
|
|
6
|
+
Validates that a shortcut name is alphanumeric and not a pure integer.
|
|
7
|
+
Pure integers are reserved for raw logging of milliliters (e.g., 'log 250').
|
|
8
|
+
"""
|
|
9
|
+
name = name.strip().lower()
|
|
10
|
+
if not name:
|
|
11
|
+
return False, "Shortcut name cannot be empty."
|
|
12
|
+
if not re.match(r"^[a-zA-Z0-9_\-]+$", name):
|
|
13
|
+
return False, "Shortcut name can only contain letters, numbers, underscores, and hyphens."
|
|
14
|
+
if name.isdigit():
|
|
15
|
+
return False, "Shortcut name cannot be a number. Numbers are reserved for logging raw milliliters."
|
|
16
|
+
return True, ""
|
|
17
|
+
|
|
18
|
+
def add_custom_shortcut(name, amount):
|
|
19
|
+
"""Registers or updates a shortcut if validation passes."""
|
|
20
|
+
name = name.strip().lower()
|
|
21
|
+
is_valid, err_msg = validate_shortcut_name(name)
|
|
22
|
+
if not is_valid:
|
|
23
|
+
return False, err_msg
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
amount_val = int(amount)
|
|
27
|
+
if amount_val <= 0:
|
|
28
|
+
return False, "Amount must be a positive integer."
|
|
29
|
+
except ValueError:
|
|
30
|
+
return False, "Amount must be a valid integer number (in ml)."
|
|
31
|
+
|
|
32
|
+
save_shortcut(name, amount_val)
|
|
33
|
+
return True, f"Vessel size locked and loaded. Shortcut '{name}' registered with {amount_val} ml. Logging is now 10x faster."
|
|
34
|
+
|
|
35
|
+
def remove_custom_shortcut(name):
|
|
36
|
+
"""Removes a shortcut if it exists."""
|
|
37
|
+
name = name.strip().lower()
|
|
38
|
+
shortcuts = get_shortcuts()
|
|
39
|
+
if name not in shortcuts:
|
|
40
|
+
return False, f"Shortcut '{name}' does not exist."
|
|
41
|
+
|
|
42
|
+
delete_shortcut(name)
|
|
43
|
+
return True, f"Shortcut '{name}' has been destroyed. May it rest in plastic."
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
# Enable Virtual Terminal Processing for Windows to support ANSI color sequences natively
|
|
5
|
+
def enable_ansi_support():
|
|
6
|
+
if os.name == 'nt':
|
|
7
|
+
import ctypes
|
|
8
|
+
kernel32 = ctypes.windll.kernel32
|
|
9
|
+
# -11 is STD_OUTPUT_HANDLE
|
|
10
|
+
h_out = kernel32.GetStdHandle(-11)
|
|
11
|
+
if h_out != -1:
|
|
12
|
+
mode = ctypes.c_ulong()
|
|
13
|
+
if kernel32.GetConsoleMode(h_out, ctypes.byref(mode)):
|
|
14
|
+
# 0x0004 is ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
15
|
+
kernel32.SetConsoleMode(h_out, mode.value | 0x0004)
|
|
16
|
+
|
|
17
|
+
# Call on import to ensure colors are enabled
|
|
18
|
+
enable_ansi_support()
|
|
19
|
+
|
|
20
|
+
# ANSI Color Codes
|
|
21
|
+
class Colors:
|
|
22
|
+
BLUE = "\033[38;5;39m"
|
|
23
|
+
CYAN = "\033[38;5;81m"
|
|
24
|
+
GREEN = "\033[38;5;76m"
|
|
25
|
+
RED = "\033[38;5;196m"
|
|
26
|
+
YELLOW = "\033[38;5;220m"
|
|
27
|
+
MAGENTA = "\033[38;5;170m"
|
|
28
|
+
GRAY = "\033[38;5;244m"
|
|
29
|
+
WHITE = "\033[97m"
|
|
30
|
+
|
|
31
|
+
# Backgrounds
|
|
32
|
+
BG_BLUE = "\033[48;5;27m"
|
|
33
|
+
BG_CYAN = "\033[48;5;39m"
|
|
34
|
+
|
|
35
|
+
# Styles
|
|
36
|
+
BOLD = "\033[1m"
|
|
37
|
+
UNDERLINE = "\033[4m"
|
|
38
|
+
RESET = "\033[0m"
|
|
39
|
+
|
|
40
|
+
# Styling Helper Functions
|
|
41
|
+
def colorize(text, color):
|
|
42
|
+
return f"{color}{text}{Colors.RESET}"
|
|
43
|
+
|
|
44
|
+
def bold(text):
|
|
45
|
+
return f"{Colors.BOLD}{text}{Colors.RESET}"
|
|
46
|
+
|
|
47
|
+
def italic(text):
|
|
48
|
+
return f"\033[3m{text}{Colors.RESET}"
|
|
49
|
+
|
|
50
|
+
def print_banner():
|
|
51
|
+
banner = f"""
|
|
52
|
+
{Colors.BLUE}┌────────────────────────────────────────────────────────┐{Colors.RESET}
|
|
53
|
+
{Colors.BLUE}│{Colors.RESET} {Colors.CYAN}{Colors.BOLD}[ DRUNKEN CHUNK ]{Colors.RESET} {Colors.BLUE}│{Colors.RESET}
|
|
54
|
+
{Colors.BLUE}│{Colors.RESET} {Colors.GRAY}The ultimate CLI water tracker, reminders & analytics{Colors.RESET} {Colors.BLUE}│{Colors.RESET}
|
|
55
|
+
{Colors.BLUE}└────────────────────────────────────────────────────────┘{Colors.RESET}
|
|
56
|
+
"""
|
|
57
|
+
print(banner)
|
|
58
|
+
|
|
59
|
+
def get_progress_bar(percentage, width=30):
|
|
60
|
+
"""Generates a colored ASCII progress bar."""
|
|
61
|
+
percentage = min(max(percentage, 0.0), 100.0)
|
|
62
|
+
filled_length = int(width * percentage // 100)
|
|
63
|
+
empty_length = width - filled_length
|
|
64
|
+
|
|
65
|
+
# Determine color based on completion
|
|
66
|
+
if percentage < 25:
|
|
67
|
+
bar_color = Colors.RED
|
|
68
|
+
elif percentage < 75:
|
|
69
|
+
bar_color = Colors.YELLOW
|
|
70
|
+
elif percentage < 100:
|
|
71
|
+
bar_color = Colors.CYAN
|
|
72
|
+
else:
|
|
73
|
+
bar_color = Colors.GREEN
|
|
74
|
+
|
|
75
|
+
bar = colorize("█" * filled_length, bar_color) + colorize("░" * empty_length, Colors.GRAY)
|
|
76
|
+
return f"[{bar}] {colorize(f'{percentage:.1f}%', Colors.BOLD)}"
|
|
77
|
+
|
|
78
|
+
def print_table(headers, rows, title=None):
|
|
79
|
+
"""Draws a beautiful custom ASCII table."""
|
|
80
|
+
if not rows:
|
|
81
|
+
print(colorize("No data to display in table.", Colors.GRAY))
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Calculate column widths
|
|
85
|
+
col_widths = [len(str(h)) for h in headers]
|
|
86
|
+
for row in rows:
|
|
87
|
+
for idx, cell in enumerate(row):
|
|
88
|
+
col_widths[idx] = max(col_widths[idx], len(str(cell)))
|
|
89
|
+
|
|
90
|
+
# Generate border formats
|
|
91
|
+
top_border = "┌─" + "─┬─".join("─" * w for w in col_widths) + "─┐"
|
|
92
|
+
header_sep = "├─" + "─┼─".join("─" * w for w in col_widths) + "─┤"
|
|
93
|
+
bottom_border = "└─" + "─┴─".join("─" * w for w in col_widths) + "─┘"
|
|
94
|
+
|
|
95
|
+
# Display Title
|
|
96
|
+
if title:
|
|
97
|
+
print(colorize(f"\n─── {title} ───", Colors.BOLD + Colors.CYAN))
|
|
98
|
+
else:
|
|
99
|
+
print()
|
|
100
|
+
|
|
101
|
+
# Print Top Border
|
|
102
|
+
print(colorize(top_border, Colors.BLUE))
|
|
103
|
+
|
|
104
|
+
# Print Headers
|
|
105
|
+
header_cells = [str(headers[i]).ljust(col_widths[i]) for i in range(len(headers))]
|
|
106
|
+
print(colorize("│ ", Colors.BLUE) + colorize(" │ ", Colors.BLUE).join(colorize(c, Colors.BOLD + Colors.WHITE) for c in header_cells) + colorize(" │", Colors.BLUE))
|
|
107
|
+
|
|
108
|
+
# Print Header Separator
|
|
109
|
+
print(colorize(header_sep, Colors.BLUE))
|
|
110
|
+
|
|
111
|
+
# Print Rows
|
|
112
|
+
for row in rows:
|
|
113
|
+
row_cells = []
|
|
114
|
+
for i, cell in enumerate(row):
|
|
115
|
+
cell_str = str(cell)
|
|
116
|
+
# Try to colorize numbers/statuses if needed or just display
|
|
117
|
+
row_cells.append(cell_str.ljust(col_widths[i]))
|
|
118
|
+
print(colorize("│ ", Colors.BLUE) + colorize(" │ ", Colors.BLUE).join(row_cells) + colorize(" │", Colors.BLUE))
|
|
119
|
+
|
|
120
|
+
# Print Bottom Border
|
|
121
|
+
print(colorize(bottom_border, Colors.BLUE))
|
|
122
|
+
print()
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "drunken-chunk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A beautiful, premium CLI water tracker, reminders & behavior analytics tool with cross-platform native desktop notifications.",
|
|
5
|
+
"main": "bin/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"drunken-chunk": "bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=14.0.0"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"water",
|
|
14
|
+
"tracker",
|
|
15
|
+
"cli",
|
|
16
|
+
"hydrator",
|
|
17
|
+
"hydration",
|
|
18
|
+
"health",
|
|
19
|
+
"wellness",
|
|
20
|
+
"reminders",
|
|
21
|
+
"analytics"
|
|
22
|
+
],
|
|
23
|
+
"author": "Antigravity Team",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/vikashpatel04/drunken-chunk.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/vikashpatel04/drunken-chunk/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/vikashpatel04/drunken-chunk#readme"
|
|
33
|
+
}
|